@push.rocks/smartpuppeteer 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,17 @@
1
1
  import { getEnvAwareBrowserInstance } from './smartpuppeteer.classes.smartpuppeteer.js';
2
+ import {
3
+ liveBrowserDefaultMaxOutstandingFrames,
4
+ liveBrowserMaxOutstandingFrames,
5
+ } from './smartpuppeteer.interfaces.livebrowser.js';
6
+ import {
7
+ delay,
8
+ type IOwnedProcessIdentity,
9
+ killFrozenOwnedProcessGroup,
10
+ listOwnedProcessGroupMembers,
11
+ readOwnedProcessIdentity,
12
+ signalOwnedProcessIdentity,
13
+ signalOwnedProcessGroup,
14
+ } from './smartpuppeteer.helpers.process.js';
2
15
  import type {
3
16
  ILiveBrowserClickOptions,
4
17
  ILiveBrowserCreateTabOptions,
@@ -18,11 +31,14 @@ import type {
18
31
  ILiveBrowserObserveOptions,
19
32
  ILiveBrowserOperationOptions,
20
33
  ILiveBrowserPressOptions,
34
+ ILiveBrowserProcessState,
21
35
  ILiveBrowserSessionOptions,
22
36
  ILiveBrowserSnapshot,
23
37
  ILiveBrowserSnapshotOptions,
24
38
  ILiveBrowserState,
25
39
  ILiveBrowserTabState,
40
+ ILiveBrowserTerminationOptions,
41
+ ILiveBrowserTerminationResult,
26
42
  ILiveBrowserViewport,
27
43
  ILiveBrowserWheelInput,
28
44
  TLiveBrowserEvent,
@@ -47,7 +63,7 @@ const maxSelectorLength = 4096;
47
63
  const maxTextLength = 32768;
48
64
  const maxUrlLength = 16384;
49
65
  const maxTimeoutMs = 60000;
50
- const maxOutstandingFrames = 3;
66
+ const frameAcknowledgementTimeoutMs = 5000;
51
67
  const maxQueuedPublicOperations = 64;
52
68
  const maxQueuedInternalOperations = 128;
53
69
  const maxEvaluationScriptBytes = 262144;
@@ -60,10 +76,52 @@ const maxEvaluationObjectKeys = 10000;
60
76
  const evaluationBootstrapKey = '__smartpuppeteerEvaluate';
61
77
  const evaluationCancelKey = '__smartpuppeteerCancel';
62
78
  const evaluationCleanupKey = '__smartpuppeteerCleanup';
79
+ const maxProxySecuritySessions = 256;
80
+ const maxTrackedProxyRequests = 1024;
81
+ const maxTotalTrackedProxyRequests = 4096;
82
+ const maxProxySecurityOperations = 2048;
83
+ const proxySeedTargetTypes = new Set([
84
+ 'background_page',
85
+ 'page',
86
+ 'service_worker',
87
+ 'shared_worker',
88
+ 'webview',
89
+ ]);
90
+ const proxyFetchUnsupportedTargetTypes = new Set(['tab', 'worker']);
63
91
 
64
92
  type TScreencastFrameEvent = plugins.puppeteer.Protocol.Page.ScreencastFrameEvent;
65
93
  type TScreencastFrameListener = (event: TScreencastFrameEvent) => void;
66
94
  type TCdpSessionDetachedListener = (session: plugins.puppeteer.CDPSession) => void;
95
+ type TOwnedChildProcess = NonNullable<ReturnType<plugins.puppeteer.Browser['process']>>;
96
+ type TProxyAuthRequiredEvent = plugins.puppeteer.Protocol.Fetch.AuthRequiredEvent;
97
+ type TProxyRequestPausedEvent = plugins.puppeteer.Protocol.Fetch.RequestPausedEvent;
98
+ type TNetworkLoadingFinishedEvent = plugins.puppeteer.Protocol.Network.LoadingFinishedEvent;
99
+ type TNetworkLoadingFailedEvent = plugins.puppeteer.Protocol.Network.LoadingFailedEvent;
100
+
101
+ interface IProxySecuritySession {
102
+ session: plugins.puppeteer.CDPSession;
103
+ generation: number;
104
+ targetId?: string;
105
+ targetType?: string;
106
+ fetchEnabled: boolean;
107
+ allowLiveTargetDetach: boolean;
108
+ ownedByProxySecurity: boolean;
109
+ attemptedAuthentications: Set<string>;
110
+ requestIdsByNetworkId: Map<string, string>;
111
+ authRequiredListener: (event: TProxyAuthRequiredEvent) => void;
112
+ requestPausedListener: (event: TProxyRequestPausedEvent) => void;
113
+ loadingFinishedListener: (event: TNetworkLoadingFinishedEvent) => void;
114
+ loadingFailedListener: (event: TNetworkLoadingFailedEvent) => void;
115
+ setupPromise: Promise<void>;
116
+ }
117
+
118
+ interface IOwnedBrowserProcess {
119
+ generation: number;
120
+ childProcess: TOwnedChildProcess;
121
+ identity?: IOwnedProcessIdentity;
122
+ exitPromise: Promise<void>;
123
+ forceSignalled: boolean;
124
+ }
67
125
 
68
126
  interface IPrivateLiveBrowserTab {
69
127
  id: string;
@@ -89,12 +147,17 @@ interface IPrivateLiveBrowserTab {
89
147
  removeListeners: Array<() => void>;
90
148
  }
91
149
 
92
- interface IOutstandingFrame {
150
+ interface ICdpScreencastFrame {
93
151
  tabId: string;
94
152
  generation: number;
95
153
  viewportRevision: number;
96
154
  cdpSessionId: number;
97
155
  cdpSession: plugins.puppeteer.CDPSession;
156
+ acknowledgementPromise?: Promise<boolean>;
157
+ }
158
+
159
+ interface IOutstandingFrame extends ICdpScreencastFrame {
160
+ sequence: number;
98
161
  }
99
162
 
100
163
  interface IImageDimensions {
@@ -313,17 +376,34 @@ export class LiveBrowserSession {
313
376
  private readonly tabs = new Map<string, IPrivateLiveBrowserTab>();
314
377
  private readonly tabIdsByPage = new WeakMap<plugins.puppeteer.Page, string>();
315
378
  private readonly outstandingFrames = new Map<number, IOutstandingFrame>();
379
+ private readonly cdpFramesBeingAcknowledged = new Set<ICdpScreencastFrame>();
380
+ private readonly maxOutstandingFrames: number;
316
381
 
317
382
  private browser?: plugins.puppeteer.Browser;
318
383
  private browserContext?: plugins.puppeteer.BrowserContext;
319
384
  private browserLifetimeController?: AbortController;
320
385
  private browserDisconnectedListener?: () => void;
386
+ private browserTargetCreatedListener?: (target: plugins.puppeteer.Target) => void;
387
+ private browserSecurityCdpSession?: plugins.puppeteer.CDPSession;
388
+ private browserSecurityConnection?: plugins.puppeteer.Connection;
389
+ private browserSecuritySessionAttachedListener?: (session: plugins.puppeteer.CDPSession) => void;
390
+ private browserSecuritySessionDetachedListener?: TCdpSessionDetachedListener;
391
+ private readonly proxySecuritySessions = new Map<string, IProxySecuritySession>();
392
+ private readonly proxySecurityOperations = new Set<Promise<void>>();
393
+ private proxySecurityGeneration = 0;
394
+ private proxySecurityStopping = false;
395
+ private ownedBrowserProcess?: IOwnedBrowserProcess;
396
+ private processGeneration = 0;
397
+ private startRequestCount = 0;
398
+ private terminationSettled = true;
399
+ private terminationPromise?: Promise<ILiveBrowserTerminationResult>;
321
400
  private readonly operationQueue: IQueuedOperation[] = [];
322
401
  private activeOperation?: IQueuedOperation;
323
402
  private operationRunning = false;
324
403
  private admittedPublicOperations = 0;
325
404
  private admittedInternalOperations = 0;
326
405
  private shutdownPromise?: Promise<void>;
406
+ private shutdownFrameDrainPromise?: Promise<void>;
327
407
  private status: 'stopped' | 'starting' | 'running' | 'stopping' = 'stopped';
328
408
  private activeTabId: string | null = null;
329
409
  private viewport: ILiveBrowserViewport = { ...defaultViewport };
@@ -350,9 +430,42 @@ export class LiveBrowserSession {
350
430
  if (optionsArg.launchOptions && 'signal' in optionsArg.launchOptions) {
351
431
  throw new Error('LiveBrowserSession owns launch cancellation; launchOptions.signal is unsupported');
352
432
  }
433
+ if (
434
+ optionsArg.screencast !== undefined
435
+ && (
436
+ !optionsArg.screencast
437
+ || typeof optionsArg.screencast !== 'object'
438
+ || Array.isArray(optionsArg.screencast)
439
+ )
440
+ ) {
441
+ throw new Error('screencast must be an object');
442
+ }
353
443
  validateOptionalBoolean(optionsArg.allowEvaluation, 'allowEvaluation');
354
- for (const [name, value] of Object.entries(optionsArg.security ?? {})) {
355
- validateOptionalBoolean(value, `security.${name}`);
444
+ for (const name of [
445
+ 'denyDownloads',
446
+ 'denyFileChoosers',
447
+ 'denyPermissions',
448
+ 'httpNavigationOnly',
449
+ ] as const) {
450
+ validateOptionalBoolean(optionsArg.security?.[name], `security.${name}`);
451
+ }
452
+ const proxyCredentials = optionsArg.security?.proxyCredentials;
453
+ if (proxyCredentials !== undefined) {
454
+ if (!proxyCredentials || typeof proxyCredentials !== 'object') {
455
+ throw new Error('security.proxyCredentials must be an object');
456
+ }
457
+ validateBoundedString(
458
+ proxyCredentials.username,
459
+ 'security.proxyCredentials.username',
460
+ 1,
461
+ 256,
462
+ );
463
+ validateBoundedString(
464
+ proxyCredentials.password,
465
+ 'security.proxyCredentials.password',
466
+ 1,
467
+ 1024,
468
+ );
356
469
  }
357
470
  const launchViewport = optionsArg.launchOptions?.defaultViewport;
358
471
  const viewport = normalizeViewport(
@@ -377,10 +490,17 @@ export class LiveBrowserSession {
377
490
  },
378
491
  viewport,
379
492
  screencast: optionsArg.screencast ? { ...optionsArg.screencast } : undefined,
380
- security: optionsArg.security ? { ...optionsArg.security } : undefined,
493
+ security: optionsArg.security
494
+ ? {
495
+ ...optionsArg.security,
496
+ proxyCredentials: proxyCredentials ? { ...proxyCredentials } : undefined,
497
+ }
498
+ : undefined,
381
499
  };
382
500
  this.viewport = { ...viewport };
383
501
  this.validateScreencastOptions();
502
+ this.maxOutstandingFrames = this.options.screencast?.maxOutstandingFrames
503
+ ?? liveBrowserDefaultMaxOutstandingFrames;
384
504
  }
385
505
 
386
506
  public onEvent(listener: TLiveBrowserEventListener): () => void {
@@ -404,12 +524,86 @@ export class LiveBrowserSession {
404
524
  };
405
525
  }
406
526
 
407
- public async start(operationOptions: ILiveBrowserOperationOptions = {}): Promise<void> {
408
- return this.enqueuePublicOperation(async (signal) => {
527
+ public getProcessState(): ILiveBrowserProcessState {
528
+ const ownedProcess = this.ownedBrowserProcess;
529
+ if (!ownedProcess) {
530
+ return {
531
+ generation: this.processGeneration,
532
+ pid: null,
533
+ processGroupId: null,
534
+ running: false,
535
+ exitCode: null,
536
+ signalCode: null,
537
+ };
538
+ }
539
+ const childProcess = ownedProcess.childProcess;
540
+ return {
541
+ generation: ownedProcess.generation,
542
+ pid: childProcess.pid ?? null,
543
+ processGroupId: ownedProcess.identity?.processGroupId ?? null,
544
+ running: childProcess.exitCode === null && childProcess.signalCode === null,
545
+ exitCode: childProcess.exitCode,
546
+ signalCode: childProcess.signalCode,
547
+ };
548
+ }
549
+
550
+ public terminate(
551
+ optionsArg: ILiveBrowserTerminationOptions = {},
552
+ ): Promise<ILiveBrowserTerminationResult> {
553
+ if (this.terminationPromise) {
554
+ return this.terminationPromise;
555
+ }
556
+ const gracefulTimeoutMs = optionsArg.gracefulTimeoutMs === undefined
557
+ ? 5000
558
+ : validateInteger(optionsArg.gracefulTimeoutMs, 'gracefulTimeoutMs', 1, 60000);
559
+ const forceTimeoutMs = optionsArg.forceTimeoutMs === undefined
560
+ ? 5000
561
+ : validateInteger(optionsArg.forceTimeoutMs, 'forceTimeoutMs', 1, 60000);
562
+ let resolveTermination!: (result: ILiveBrowserTerminationResult) => void;
563
+ let rejectTermination!: (error: unknown) => void;
564
+ const terminationPromise = new Promise<ILiveBrowserTerminationResult>((resolve, reject) => {
565
+ resolveTermination = resolve;
566
+ rejectTermination = reject;
567
+ });
568
+ this.terminationPromise = terminationPromise;
569
+ this.terminationSettled = false;
570
+ void this.terminateInternal(gracefulTimeoutMs, forceTimeoutMs).then(
571
+ (result) => {
572
+ this.terminationSettled = true;
573
+ resolveTermination(result);
574
+ },
575
+ (error) => {
576
+ if (this.terminationPromise === terminationPromise) {
577
+ this.terminationPromise = undefined;
578
+ }
579
+ this.terminationSettled = true;
580
+ rejectTermination(error);
581
+ },
582
+ );
583
+ return terminationPromise;
584
+ }
585
+
586
+ public start(operationOptions: ILiveBrowserOperationOptions = {}): Promise<void> {
587
+ if (!this.terminationSettled) {
588
+ return Promise.reject(new Error('A browser termination is still in progress'));
589
+ }
590
+ if (!this.ownedBrowserProcess && this.status === 'stopped') {
591
+ this.terminationPromise = undefined;
592
+ }
593
+ this.startRequestCount += 1;
594
+ const startPromise = this.enqueuePublicOperation(async (signal) => {
409
595
  if (this.status === 'running' || this.status === 'starting') {
410
596
  return;
411
597
  }
412
598
 
599
+ await this.releaseExitedOwnedProcess();
600
+ if (signal.aborted) {
601
+ throw signal.reason;
602
+ }
603
+ if (this.ownedBrowserProcess) {
604
+ throw new Error('A previous owned Chromium process has not been confirmed dead');
605
+ }
606
+
413
607
  this.normalStopRequested = false;
414
608
  this.lastError = undefined;
415
609
  this.viewportRevision = 1;
@@ -445,10 +639,56 @@ export class LiveBrowserSession {
445
639
  } finally {
446
640
  signal.removeEventListener('abort', abortBrowserLaunch);
447
641
  }
642
+ const childProcess = this.browser.process();
643
+ if (!childProcess?.pid) {
644
+ throw new Error('LiveBrowserSession did not receive an owned Chromium process');
645
+ }
646
+ const exitPromise = new Promise<void>((resolve) => {
647
+ if (childProcess.exitCode !== null || childProcess.signalCode !== null) {
648
+ resolve();
649
+ return;
650
+ }
651
+ childProcess.once('exit', () => resolve());
652
+ });
653
+ this.processGeneration += 1;
654
+ this.ownedBrowserProcess = {
655
+ generation: this.processGeneration,
656
+ childProcess,
657
+ exitPromise,
658
+ forceSignalled: false,
659
+ };
660
+ const identity = process.platform === 'linux'
661
+ ? await readOwnedProcessIdentity(childProcess.pid)
662
+ : undefined;
663
+ if (process.platform === 'linux' && !identity) {
664
+ throw new Error('The owned Chromium process exited before its identity was captured');
665
+ }
666
+ if (
667
+ identity
668
+ && (identity.processGroupId !== childProcess.pid || identity.sessionId !== childProcess.pid)
669
+ ) {
670
+ throw new Error('Chromium was not launched as a dedicated process-group leader');
671
+ }
672
+ this.ownedBrowserProcess.identity = identity;
448
673
  if (signal.aborted) {
449
674
  throw signal.reason;
450
675
  }
451
676
  this.browserContext = this.browser.defaultBrowserContext();
677
+ this.browserTargetCreatedListener = (target) => {
678
+ if (target.type() !== 'page' || target.browserContext() !== this.browserContext) {
679
+ return;
680
+ }
681
+ void target.page().then((page) => {
682
+ if (page) {
683
+ this.scheduleDiscoveredPageRegistration(page);
684
+ }
685
+ }).catch((error) => {
686
+ if (!this.normalStopRequested && this.status !== 'stopped' && this.status !== 'stopping') {
687
+ this.handleDiscoveredPageFailure(error);
688
+ }
689
+ });
690
+ };
691
+ this.browser.on('targetcreated', this.browserTargetCreatedListener);
452
692
  await this.configureBrowserSecurity(this.browser);
453
693
  this.browserDisconnectedListener = () => {
454
694
  if (this.normalStopRequested || this.status === 'stopped' || this.status === 'stopping') {
@@ -497,7 +737,14 @@ export class LiveBrowserSession {
497
737
  }
498
738
  } catch (error) {
499
739
  if (signal.aborted || this.normalStopRequested) {
500
- await this.stopInternal();
740
+ try {
741
+ await this.stopInternal();
742
+ } catch (cleanupError) {
743
+ throw new AggregateError(
744
+ [error, cleanupError],
745
+ 'Cancelled browser startup cleanup was incomplete',
746
+ );
747
+ }
501
748
  throw error;
502
749
  }
503
750
  const startError: ILiveBrowserError = {
@@ -506,10 +753,20 @@ export class LiveBrowserSession {
506
753
  fatal: true,
507
754
  };
508
755
  this.emitError(startError);
509
- await this.stopInternal(startError);
756
+ try {
757
+ await this.stopInternal(startError);
758
+ } catch (cleanupError) {
759
+ throw new AggregateError(
760
+ [error, cleanupError],
761
+ 'Browser startup failed and cleanup was incomplete',
762
+ );
763
+ }
510
764
  throw error;
511
765
  }
512
766
  }, operationOptions);
767
+ return startPromise.finally(() => {
768
+ this.startRequestCount -= 1;
769
+ });
513
770
  }
514
771
 
515
772
  public async stop(): Promise<void> {
@@ -1070,6 +1327,7 @@ export class LiveBrowserSession {
1070
1327
  return this.enqueuePublicOperation(async (signal) => {
1071
1328
  const tab = this.resolveActionTab(optionsArg.tabId);
1072
1329
  const cdpSession = await tab.page.createCDPSession();
1330
+ this.allowOperationalCdpSessionDetach(cdpSession);
1073
1331
  let executionContextId: number | undefined;
1074
1332
  let cancellationPromise: Promise<void> | undefined;
1075
1333
  let terminationPromise: Promise<void> | undefined;
@@ -1346,7 +1604,25 @@ export class LiveBrowserSession {
1346
1604
  return Promise.reject(new Error('LiveBrowserSession operation queue is full'));
1347
1605
  }
1348
1606
  this.admittedPublicOperations += 1;
1349
- return this.enqueueQueuedOperation('public', operation, false, callerSignal);
1607
+ return this.enqueueQueuedOperation('public', async (signal) => {
1608
+ this.assertProxySecurityReady();
1609
+ return operation(signal);
1610
+ }, false, callerSignal);
1611
+ }
1612
+
1613
+ private assertProxySecurityReady(): void {
1614
+ if (!this.options.security?.proxyCredentials || this.status !== 'running') {
1615
+ return;
1616
+ }
1617
+ if (
1618
+ !this.browserSecurityConnection
1619
+ || !this.browserSecurityCdpSession
1620
+ || this.browserSecurityCdpSession.detached
1621
+ ) {
1622
+ const error = new Error('Authenticated proxy security is not attached');
1623
+ this.handleProxySecurityFailure('proxy_security_unavailable', error);
1624
+ throw error;
1625
+ }
1350
1626
  }
1351
1627
 
1352
1628
  private enqueueInternalOperation(
@@ -1495,21 +1771,34 @@ export class LiveBrowserSession {
1495
1771
  this.emitState();
1496
1772
  }
1497
1773
  const shutdownError = new Error('LiveBrowserSession is stopping');
1498
- if (
1774
+ const activeOperation = this.activeOperation;
1775
+ const shouldAbortActiveOperation = Boolean(
1499
1776
  abortActiveOperation
1500
- && this.activeOperation
1501
- && this.activeOperation.kind !== 'shutdown'
1502
- ) {
1503
- this.activeOperation.controller.abort(shutdownError);
1504
- }
1505
- if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
1506
- this.browserLifetimeController.abort(shutdownError);
1777
+ && activeOperation
1778
+ && activeOperation.kind !== 'shutdown'
1779
+ );
1780
+ if (shouldAbortActiveOperation && activeOperation) {
1781
+ const browserLifetimeController = this.browserLifetimeController;
1782
+ const frameDrainPromise = (async () => {
1783
+ try {
1784
+ await this.retireOutstandingFrames(() => true);
1785
+ await this.waitForCdpFrameAcknowledgements();
1786
+ } finally {
1787
+ if (browserLifetimeController && !browserLifetimeController.signal.aborted) {
1788
+ browserLifetimeController.abort(shutdownError);
1789
+ }
1790
+ }
1791
+ })();
1792
+ this.shutdownFrameDrainPromise = frameDrainPromise;
1793
+ void frameDrainPromise.catch(() => {});
1794
+ activeOperation.controller.abort(shutdownError);
1507
1795
  }
1508
1796
  this.cancelQueuedOperations(shutdownError);
1509
1797
  }
1510
1798
 
1511
1799
  private requestShutdown(error?: ILiveBrowserError): Promise<void> {
1512
- if (this.status === 'stopped') {
1800
+ if (this.status === 'stopped' && !this.operationRunning && this.startRequestCount === 0) {
1801
+ this.normalStopRequested = false;
1513
1802
  return Promise.resolve();
1514
1803
  }
1515
1804
  if (this.shutdownPromise) {
@@ -1622,6 +1911,248 @@ export class LiveBrowserSession {
1622
1911
  };
1623
1912
  }
1624
1913
 
1914
+ private async releaseExitedOwnedProcess(): Promise<void> {
1915
+ const ownedProcess = this.ownedBrowserProcess;
1916
+ if (!ownedProcess) {
1917
+ return;
1918
+ }
1919
+ if (
1920
+ ownedProcess.childProcess.exitCode === null
1921
+ && ownedProcess.childProcess.signalCode === null
1922
+ ) {
1923
+ await Promise.race([ownedProcess.exitPromise, delay(100)]);
1924
+ if (
1925
+ ownedProcess.childProcess.exitCode === null
1926
+ && ownedProcess.childProcess.signalCode === null
1927
+ ) {
1928
+ return;
1929
+ }
1930
+ }
1931
+ if (process.platform === 'linux' && !ownedProcess.identity) {
1932
+ return;
1933
+ }
1934
+ if (ownedProcess.identity) {
1935
+ const members = await listOwnedProcessGroupMembers(ownedProcess.identity);
1936
+ if (members.length > 0) {
1937
+ return;
1938
+ }
1939
+ }
1940
+ this.ownedBrowserProcess = undefined;
1941
+ }
1942
+
1943
+ private async terminateInternal(
1944
+ gracefulTimeoutMs: number,
1945
+ forceTimeoutMs: number,
1946
+ ): Promise<ILiveBrowserTerminationResult> {
1947
+ const errors: string[] = [];
1948
+ let shutdownSettled = this.status === 'stopped';
1949
+ const shutdownObserved = this.stop().then(
1950
+ () => {
1951
+ shutdownSettled = true;
1952
+ },
1953
+ (error) => {
1954
+ errors.push(normalizeErrorMessage(error));
1955
+ shutdownSettled = true;
1956
+ },
1957
+ );
1958
+ const createConfirmedResult = (
1959
+ ownedProcess: IOwnedBrowserProcess | undefined,
1960
+ forced: boolean,
1961
+ ): ILiveBrowserTerminationResult => {
1962
+ const childProcess = ownedProcess?.childProcess;
1963
+ const result: ILiveBrowserTerminationResult = {
1964
+ generation: ownedProcess?.generation ?? this.processGeneration,
1965
+ pid: childProcess?.pid ?? null,
1966
+ processGroupId: ownedProcess?.identity?.processGroupId ?? null,
1967
+ running: false,
1968
+ exitCode: childProcess?.exitCode ?? null,
1969
+ signalCode: childProcess?.signalCode ?? null,
1970
+ forced: forced || Boolean(ownedProcess?.forceSignalled),
1971
+ shutdownComplete: true,
1972
+ confirmedDead: true,
1973
+ errors,
1974
+ };
1975
+ if (this.ownedBrowserProcess === ownedProcess) {
1976
+ this.ownedBrowserProcess = undefined;
1977
+ }
1978
+ return result;
1979
+ };
1980
+ const isConfirmedDead = async (
1981
+ ownedProcess: IOwnedBrowserProcess | undefined,
1982
+ ): Promise<boolean> => {
1983
+ if (!ownedProcess) {
1984
+ return true;
1985
+ }
1986
+ if (process.platform !== 'linux' || !ownedProcess.identity) {
1987
+ if (process.platform === 'linux' && this.startRequestCount > 0) {
1988
+ return false;
1989
+ }
1990
+ throw new Error('Confirmed browser process-group termination is supported on Linux only');
1991
+ }
1992
+ if (
1993
+ ownedProcess.childProcess.exitCode === null
1994
+ && ownedProcess.childProcess.signalCode === null
1995
+ ) {
1996
+ return false;
1997
+ }
1998
+ const members = await listOwnedProcessGroupMembers(ownedProcess.identity);
1999
+ if (members.length > 0) {
2000
+ return false;
2001
+ }
2002
+ return true;
2003
+ };
2004
+
2005
+ const gracefulDeadline = Date.now() + gracefulTimeoutMs;
2006
+ while (Date.now() < gracefulDeadline) {
2007
+ const ownedProcess = this.ownedBrowserProcess;
2008
+ if (
2009
+ await isConfirmedDead(ownedProcess)
2010
+ && shutdownSettled
2011
+ && this.status === 'stopped'
2012
+ ) {
2013
+ return createConfirmedResult(ownedProcess, false);
2014
+ }
2015
+ if (shutdownSettled) {
2016
+ await delay(100);
2017
+ } else {
2018
+ await Promise.race([shutdownObserved, delay(100)]);
2019
+ }
2020
+ }
2021
+
2022
+ const ownedProcess = this.ownedBrowserProcess;
2023
+ let forced = false;
2024
+ if (ownedProcess) {
2025
+ if (process.platform !== 'linux') {
2026
+ throw new Error('Confirmed browser process-group termination is supported on Linux only');
2027
+ }
2028
+ if (ownedProcess.identity) {
2029
+ const membersBeforeFreeze = await listOwnedProcessGroupMembers(ownedProcess.identity);
2030
+ if (membersBeforeFreeze.length > 0) {
2031
+ forced = true;
2032
+ const groupStopped = await signalOwnedProcessGroup(ownedProcess.identity, 'SIGSTOP');
2033
+ if (groupStopped) {
2034
+ killFrozenOwnedProcessGroup(ownedProcess.identity);
2035
+ } else {
2036
+ await this.killOwnedProcessGroupSurvivors(ownedProcess.identity, membersBeforeFreeze);
2037
+ }
2038
+ }
2039
+ } else if (this.startRequestCount === 0) {
2040
+ throw new Error('Owned Chromium process identity was never confirmed');
2041
+ }
2042
+ }
2043
+
2044
+ const forceDeadline = Date.now() + forceTimeoutMs;
2045
+ while (Date.now() < forceDeadline) {
2046
+ const currentOwnedProcess = this.ownedBrowserProcess;
2047
+ if (
2048
+ await isConfirmedDead(currentOwnedProcess)
2049
+ && shutdownSettled
2050
+ && this.status === 'stopped'
2051
+ ) {
2052
+ return createConfirmedResult(currentOwnedProcess, forced);
2053
+ }
2054
+ if (shutdownSettled) {
2055
+ await delay(100);
2056
+ } else {
2057
+ await Promise.race([shutdownObserved, delay(100)]);
2058
+ }
2059
+ }
2060
+
2061
+ const remainingOwnedProcess = this.ownedBrowserProcess;
2062
+ if (remainingOwnedProcess?.identity) {
2063
+ const survivors = await listOwnedProcessGroupMembers(remainingOwnedProcess.identity);
2064
+ if (survivors.length > 0) {
2065
+ throw new Error(
2066
+ `Owned Chromium process group did not terminate: ${survivors.map((item) => item.pid).join(', ')}`,
2067
+ );
2068
+ }
2069
+ }
2070
+ if (!shutdownSettled || this.status !== 'stopped') {
2071
+ throw new Error(
2072
+ 'Owned Chromium process group exited but LiveBrowserSession shutdown did not settle',
2073
+ );
2074
+ }
2075
+ throw new Error('Owned Chromium process exit was not confirmed by Node.js');
2076
+ }
2077
+
2078
+ private async killOwnedProcessGroupSurvivors(
2079
+ rootIdentity: IOwnedProcessIdentity,
2080
+ initialMembers: IOwnedProcessIdentity[],
2081
+ ): Promise<void> {
2082
+ let previousKeys = new Set(initialMembers.map((member) => `${member.pid}:${member.startTime}`));
2083
+ for (let attempt = 0; attempt < 20; attempt += 1) {
2084
+ const members = await listOwnedProcessGroupMembers(rootIdentity);
2085
+ if (members.length === 0) {
2086
+ return;
2087
+ }
2088
+ for (const member of members) {
2089
+ await signalOwnedProcessIdentity(member, 'SIGSTOP');
2090
+ }
2091
+ await delay(10);
2092
+ const frozenMembers = await listOwnedProcessGroupMembers(rootIdentity);
2093
+ const currentKeys = new Set(
2094
+ frozenMembers.map((member) => `${member.pid}:${member.startTime}`),
2095
+ );
2096
+ const sameMembers = currentKeys.size === previousKeys.size
2097
+ && [...currentKeys].every((key) => previousKeys.has(key));
2098
+ const allFrozen = frozenMembers.every((member) => (
2099
+ member.state === 'T' || member.state === 't' || member.state === 'Z'
2100
+ ));
2101
+ if (sameMembers && allFrozen) {
2102
+ for (const member of frozenMembers) {
2103
+ await signalOwnedProcessIdentity(member, 'SIGKILL');
2104
+ }
2105
+ return;
2106
+ }
2107
+ previousKeys = currentKeys;
2108
+ }
2109
+ throw new Error('Unable to stabilize the surviving Chromium process group');
2110
+ }
2111
+
2112
+ private async forceOwnedBrowserProcessGroup(
2113
+ ownedProcess: IOwnedBrowserProcess,
2114
+ ): Promise<void> {
2115
+ ownedProcess.forceSignalled = true;
2116
+ if (process.platform !== 'linux') {
2117
+ ownedProcess.childProcess.kill('SIGKILL');
2118
+ await Promise.race([ownedProcess.exitPromise, delay(5000)]);
2119
+ if (
2120
+ ownedProcess.childProcess.exitCode === null
2121
+ && ownedProcess.childProcess.signalCode === null
2122
+ ) {
2123
+ throw new Error('Owned Chromium process did not exit after SIGKILL');
2124
+ }
2125
+ return;
2126
+ }
2127
+ if (!ownedProcess.identity) {
2128
+ throw new Error('Owned Chromium process identity was never confirmed');
2129
+ }
2130
+ const members = await listOwnedProcessGroupMembers(ownedProcess.identity);
2131
+ if (members.length > 0) {
2132
+ const groupStopped = await signalOwnedProcessGroup(ownedProcess.identity, 'SIGSTOP');
2133
+ if (groupStopped) {
2134
+ killFrozenOwnedProcessGroup(ownedProcess.identity);
2135
+ } else {
2136
+ await this.killOwnedProcessGroupSurvivors(ownedProcess.identity, members);
2137
+ }
2138
+ }
2139
+ const deadline = Date.now() + 5000;
2140
+ while (Date.now() < deadline) {
2141
+ const survivors = await listOwnedProcessGroupMembers(ownedProcess.identity);
2142
+ if (survivors.length === 0) {
2143
+ await Promise.race([ownedProcess.exitPromise, delay(100)]);
2144
+ if (
2145
+ ownedProcess.childProcess.exitCode !== null
2146
+ || ownedProcess.childProcess.signalCode !== null
2147
+ ) {
2148
+ return;
2149
+ }
2150
+ }
2151
+ await delay(100);
2152
+ }
2153
+ throw new Error('Owned Chromium process group survived forced startup cleanup');
2154
+ }
2155
+
1625
2156
  private async stopInternal(error?: ILiveBrowserError): Promise<void> {
1626
2157
  if (this.status === 'stopped') {
1627
2158
  return;
@@ -1631,17 +2162,31 @@ export class LiveBrowserSession {
1631
2162
  this.lastError = { ...error };
1632
2163
  }
1633
2164
  this.emitState();
1634
- if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
1635
- this.browserLifetimeController.abort(new Error('LiveBrowserSession is stopping'));
1636
- }
1637
-
1638
2165
  const browser = this.browser;
2166
+ if (browser && this.browserTargetCreatedListener) {
2167
+ browser.off('targetcreated', this.browserTargetCreatedListener);
2168
+ }
2169
+ this.browserTargetCreatedListener = undefined;
1639
2170
  if (browser && this.browserDisconnectedListener) {
1640
2171
  browser.off('disconnected', this.browserDisconnectedListener);
1641
2172
  }
1642
2173
  this.browserDisconnectedListener = undefined;
1643
2174
 
1644
2175
  const shutdownErrors: unknown[] = [];
2176
+ const shutdownFrameDrainPromise = this.shutdownFrameDrainPromise;
2177
+ this.shutdownFrameDrainPromise = undefined;
2178
+ if (shutdownFrameDrainPromise) {
2179
+ try {
2180
+ await shutdownFrameDrainPromise;
2181
+ } catch (cleanupError) {
2182
+ shutdownErrors.push(cleanupError);
2183
+ }
2184
+ }
2185
+ try {
2186
+ await this.teardownProxySecurity();
2187
+ } catch (cleanupError) {
2188
+ shutdownErrors.push(cleanupError);
2189
+ }
1645
2190
  for (const tab of [...this.tabs.values()]) {
1646
2191
  tab.closing = true;
1647
2192
  const securityCdpSession = tab.securityCdpSession;
@@ -1669,12 +2214,24 @@ export class LiveBrowserSession {
1669
2214
  } catch (cleanupError) {
1670
2215
  shutdownErrors.push(cleanupError);
1671
2216
  }
2217
+ await this.waitForCdpFrameAcknowledgements();
2218
+ if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
2219
+ this.browserLifetimeController.abort(new Error('LiveBrowserSession is stopping'));
2220
+ }
1672
2221
 
1673
2222
  if (browser) {
1674
2223
  try {
1675
2224
  await browser.close();
1676
2225
  } catch (closeError) {
1677
2226
  shutdownErrors.push(closeError);
2227
+ const ownedProcess = this.ownedBrowserProcess;
2228
+ if (ownedProcess) {
2229
+ try {
2230
+ await this.forceOwnedBrowserProcessGroup(ownedProcess);
2231
+ } catch (forceError) {
2232
+ shutdownErrors.push(forceError);
2233
+ }
2234
+ }
1678
2235
  }
1679
2236
  }
1680
2237
 
@@ -1689,7 +2246,7 @@ export class LiveBrowserSession {
1689
2246
  this.activeTabId = null;
1690
2247
  this.status = 'stopped';
1691
2248
  this.emitState();
1692
- if (shutdownErrors.length > 0 && !error) {
2249
+ if (shutdownErrors.length > 0) {
1693
2250
  throw new AggregateError(shutdownErrors, 'LiveBrowserSession shutdown was incomplete');
1694
2251
  }
1695
2252
  }
@@ -1697,19 +2254,444 @@ export class LiveBrowserSession {
1697
2254
  private async configureBrowserSecurity(
1698
2255
  browser: plugins.puppeteer.Browser,
1699
2256
  ): Promise<void> {
1700
- if (!this.options.security?.denyPermissions) {
2257
+ const denyPermissions = this.options.security?.denyPermissions;
2258
+ const proxyCredentials = this.options.security?.proxyCredentials;
2259
+ if (!denyPermissions && !proxyCredentials) {
1701
2260
  return;
1702
2261
  }
1703
2262
  const cdpSession = await browser.target().createCDPSession();
2263
+ if (!proxyCredentials) {
2264
+ try {
2265
+ if (denyPermissions) {
2266
+ await cdpSession.send('Browser.grantPermissions', { permissions: [] });
2267
+ }
2268
+ } finally {
2269
+ if (!cdpSession.detached) {
2270
+ await cdpSession.detach();
2271
+ }
2272
+ }
2273
+ return;
2274
+ }
2275
+
2276
+ const generation = this.ownedBrowserProcess?.generation;
2277
+ if (generation === undefined) {
2278
+ throw new Error('Proxy security requires an owned browser generation');
2279
+ }
2280
+ this.proxySecurityStopping = false;
2281
+ this.proxySecurityGeneration = generation;
2282
+ this.browserSecurityCdpSession = cdpSession;
1704
2283
  try {
1705
- await cdpSession.send('Browser.grantPermissions', { permissions: [] });
1706
- } finally {
1707
- if (!cdpSession.detached) {
1708
- await cdpSession.detach();
2284
+ if (denyPermissions) {
2285
+ await cdpSession.send('Browser.grantPermissions', { permissions: [] });
2286
+ }
2287
+ const browserSecurityConnection = cdpSession.connection();
2288
+ if (!browserSecurityConnection) {
2289
+ throw new Error('Browser security CDP session has no connection');
2290
+ }
2291
+ this.browserSecurityConnection = browserSecurityConnection;
2292
+ this.browserSecuritySessionAttachedListener = (attachedSession) => {
2293
+ this.trackProxySecurityOperation(
2294
+ this.configureProxySecuritySession(attachedSession, generation),
2295
+ generation,
2296
+ 'proxy_security_session_setup_failed',
2297
+ );
2298
+ };
2299
+ this.browserSecuritySessionDetachedListener = (detachedSession) => {
2300
+ if (detachedSession === cdpSession) {
2301
+ this.handleProxySecurityFailure(
2302
+ 'proxy_security_browser_session_detached',
2303
+ new Error('Browser security CDP session detached unexpectedly'),
2304
+ generation,
2305
+ );
2306
+ return;
2307
+ }
2308
+ this.trackProxySecurityOperation(
2309
+ this.verifyProxySecuritySessionDetached(detachedSession, generation),
2310
+ generation,
2311
+ 'proxy_security_detach_verification_failed',
2312
+ );
2313
+ };
2314
+ browserSecurityConnection.on(
2315
+ 'sessionattached',
2316
+ this.browserSecuritySessionAttachedListener,
2317
+ );
2318
+ browserSecurityConnection.on(
2319
+ 'sessiondetached',
2320
+ this.browserSecuritySessionDetachedListener,
2321
+ );
2322
+
2323
+ for (const target of browser.targets()) {
2324
+ if (!proxySeedTargetTypes.has(target.type())) {
2325
+ continue;
2326
+ }
2327
+ const securitySession = await target.createCDPSession();
2328
+ const securityRecord = this.proxySecuritySessions.get(securitySession.id());
2329
+ if (!securityRecord) {
2330
+ throw new Error(`Proxy security did not observe session ${securitySession.id()}`);
2331
+ }
2332
+ securityRecord.ownedByProxySecurity = true;
2333
+ await securityRecord.setupPromise;
2334
+ }
2335
+ } catch (error) {
2336
+ await this.teardownProxySecurity();
2337
+ throw error;
2338
+ }
2339
+ }
2340
+
2341
+ private configureProxySecuritySession(
2342
+ session: plugins.puppeteer.CDPSession,
2343
+ generation: number,
2344
+ ): Promise<void> {
2345
+ const existing = this.proxySecuritySessions.get(session.id());
2346
+ if (existing) {
2347
+ if (existing.generation !== generation) {
2348
+ return Promise.reject(new Error(`CDP session ${session.id()} crossed browser generations`));
2349
+ }
2350
+ return existing.setupPromise;
2351
+ }
2352
+ if (this.proxySecuritySessions.size >= maxProxySecuritySessions) {
2353
+ return Promise.reject(
2354
+ new Error(`Proxy security exceeded ${maxProxySecuritySessions} CDP sessions`),
2355
+ );
2356
+ }
2357
+
2358
+ const attemptedAuthentications = new Set<string>();
2359
+ const requestIdsByNetworkId = new Map<string, string>();
2360
+ const handleProtocolFailure = (operation: string, error: unknown): void => {
2361
+ this.handleProxySecurityFailure(
2362
+ 'proxy_security_protocol_failed',
2363
+ new Error(`${operation}: ${normalizeErrorMessage(error)}`),
2364
+ generation,
2365
+ );
2366
+ };
2367
+ const authRequiredListener = (event: TProxyAuthRequiredEvent): void => {
2368
+ const credentials = this.options.security?.proxyCredentials;
2369
+ const totalTrackedRequests = [...this.proxySecuritySessions.values()].reduce(
2370
+ (total, record) => total
2371
+ + record.attemptedAuthentications.size
2372
+ + record.requestIdsByNetworkId.size,
2373
+ 0,
2374
+ );
2375
+ const hasTrackingCapacity = attemptedAuthentications.size < maxTrackedProxyRequests
2376
+ && totalTrackedRequests < maxTotalTrackedProxyRequests;
2377
+ const isFirstProxyAttempt = event.authChallenge.source === 'Proxy'
2378
+ && !attemptedAuthentications.has(event.requestId)
2379
+ && hasTrackingCapacity;
2380
+ if (isFirstProxyAttempt) {
2381
+ attemptedAuthentications.add(event.requestId);
2382
+ } else if (
2383
+ event.authChallenge.source === 'Proxy'
2384
+ && !attemptedAuthentications.has(event.requestId)
2385
+ && !hasTrackingCapacity
2386
+ ) {
2387
+ handleProtocolFailure(
2388
+ 'Proxy authentication tracking failed',
2389
+ new Error(`A security session exceeded ${maxTrackedProxyRequests} tracked requests`),
2390
+ );
2391
+ }
2392
+ const authChallengeResponse = isFirstProxyAttempt && credentials
2393
+ ? {
2394
+ response: 'ProvideCredentials' as const,
2395
+ username: credentials.username,
2396
+ password: credentials.password,
2397
+ }
2398
+ : { response: 'CancelAuth' as const };
2399
+ this.trackProxySecurityOperation(
2400
+ session.send('Fetch.continueWithAuth', {
2401
+ requestId: event.requestId,
2402
+ authChallengeResponse,
2403
+ }).then(() => undefined),
2404
+ generation,
2405
+ 'proxy_security_protocol_failed',
2406
+ );
2407
+ };
2408
+ const requestPausedListener = (event: TProxyRequestPausedEvent): void => {
2409
+ if (event.networkId) {
2410
+ const totalTrackedRequests = [...this.proxySecuritySessions.values()].reduce(
2411
+ (total, record) => total
2412
+ + record.attemptedAuthentications.size
2413
+ + record.requestIdsByNetworkId.size,
2414
+ 0,
2415
+ );
2416
+ if (
2417
+ !requestIdsByNetworkId.has(event.networkId)
2418
+ && (
2419
+ requestIdsByNetworkId.size >= maxTrackedProxyRequests
2420
+ || totalTrackedRequests >= maxTotalTrackedProxyRequests
2421
+ )
2422
+ ) {
2423
+ handleProtocolFailure(
2424
+ 'Proxy request tracking failed',
2425
+ new Error(`A security session exceeded ${maxTrackedProxyRequests} tracked requests`),
2426
+ );
2427
+ } else {
2428
+ const previousRequestId = requestIdsByNetworkId.get(event.networkId);
2429
+ if (previousRequestId && previousRequestId !== event.requestId) {
2430
+ attemptedAuthentications.delete(previousRequestId);
2431
+ }
2432
+ requestIdsByNetworkId.set(event.networkId, event.requestId);
2433
+ }
1709
2434
  }
2435
+ this.trackProxySecurityOperation(
2436
+ session.send('Fetch.continueRequest', { requestId: event.requestId }).then(() => undefined),
2437
+ generation,
2438
+ 'proxy_security_protocol_failed',
2439
+ );
2440
+ };
2441
+ const forgetAuthentication = (networkId: string): void => {
2442
+ const requestId = requestIdsByNetworkId.get(networkId);
2443
+ if (!requestId) {
2444
+ return;
2445
+ }
2446
+ requestIdsByNetworkId.delete(networkId);
2447
+ attemptedAuthentications.delete(requestId);
2448
+ };
2449
+ const loadingFinishedListener = (event: TNetworkLoadingFinishedEvent): void => {
2450
+ forgetAuthentication(event.requestId);
2451
+ };
2452
+ const loadingFailedListener = (event: TNetworkLoadingFailedEvent): void => {
2453
+ forgetAuthentication(event.requestId);
2454
+ };
2455
+ session.on('Fetch.authRequired', authRequiredListener);
2456
+ session.on('Fetch.requestPaused', requestPausedListener);
2457
+ session.on('Network.loadingFinished', loadingFinishedListener);
2458
+ session.on('Network.loadingFailed', loadingFailedListener);
2459
+
2460
+ const proxySecuritySession: IProxySecuritySession = {
2461
+ session,
2462
+ generation,
2463
+ fetchEnabled: false,
2464
+ allowLiveTargetDetach: false,
2465
+ ownedByProxySecurity: false,
2466
+ attemptedAuthentications,
2467
+ requestIdsByNetworkId,
2468
+ authRequiredListener,
2469
+ requestPausedListener,
2470
+ loadingFinishedListener,
2471
+ loadingFailedListener,
2472
+ setupPromise: Promise.resolve(),
2473
+ };
2474
+ const targetInfoPromise = session.send('Target.getTargetInfo');
2475
+ const networkEnablePromise = session.send('Network.enable');
2476
+ const fetchEnablePromise = session.send('Fetch.enable', {
2477
+ handleAuthRequests: true,
2478
+ patterns: [{ urlPattern: '*' }],
2479
+ });
2480
+ const commandResultsPromise = Promise.allSettled([
2481
+ targetInfoPromise,
2482
+ networkEnablePromise,
2483
+ fetchEnablePromise,
2484
+ ]);
2485
+ const setupPromise = (async (): Promise<void> => {
2486
+ try {
2487
+ const [targetInfoResult, networkResult, fetchResult] = await commandResultsPromise;
2488
+ if (targetInfoResult.status === 'rejected') {
2489
+ throw targetInfoResult.reason;
2490
+ }
2491
+ const { targetInfo } = targetInfoResult.value;
2492
+ proxySecuritySession.targetId = targetInfo.targetId;
2493
+ proxySecuritySession.targetType = targetInfo.type;
2494
+ if (fetchResult.status === 'rejected') {
2495
+ const unsupportedTarget = proxyFetchUnsupportedTargetTypes.has(targetInfo.type)
2496
+ && fetchResult.reason instanceof plugins.puppeteer.ProtocolError
2497
+ && fetchResult.reason.originalMessage === "'Fetch.enable' wasn't found";
2498
+ if (unsupportedTarget) {
2499
+ this.removeProxySecuritySession(proxySecuritySession);
2500
+ return;
2501
+ }
2502
+ throw fetchResult.reason;
2503
+ }
2504
+ if (networkResult.status === 'rejected') {
2505
+ throw networkResult.reason;
2506
+ }
2507
+ proxySecuritySession.fetchEnabled = true;
2508
+ } catch (error) {
2509
+ this.removeProxySecuritySession(proxySecuritySession);
2510
+ if (session.detached || this.proxySecurityStopping || this.normalStopRequested) {
2511
+ return;
2512
+ }
2513
+ throw error;
2514
+ }
2515
+ })();
2516
+ proxySecuritySession.setupPromise = setupPromise;
2517
+ this.proxySecuritySessions.set(session.id(), proxySecuritySession);
2518
+ return setupPromise;
2519
+ }
2520
+
2521
+ private async verifyProxySecuritySessionDetached(
2522
+ session: plugins.puppeteer.CDPSession,
2523
+ generation: number,
2524
+ ): Promise<void> {
2525
+ const proxySecuritySession = this.proxySecuritySessions.get(session.id());
2526
+ if (!proxySecuritySession || proxySecuritySession.generation !== generation) {
2527
+ return;
2528
+ }
2529
+ this.removeProxySecuritySession(proxySecuritySession);
2530
+ if (this.proxySecurityStopping || this.normalStopRequested || this.status !== 'running') {
2531
+ return;
2532
+ }
2533
+ if (proxySecuritySession.allowLiveTargetDetach) {
2534
+ return;
2535
+ }
2536
+ if (!proxySecuritySession.targetId) {
2537
+ throw new Error(`Proxy security session detached before target identification: ${session.id()}`);
2538
+ }
2539
+ for (let attempt = 0; attempt < 3; attempt += 1) {
2540
+ const generationSessions = [...this.proxySecuritySessions.values()].filter((candidate) => (
2541
+ candidate.generation === generation
2542
+ ));
2543
+ await Promise.allSettled(generationSessions.map((candidate) => candidate.setupPromise));
2544
+ const hasReplacement = [...this.proxySecuritySessions.values()].some((candidate) => (
2545
+ candidate.generation === generation
2546
+ && candidate.targetId === proxySecuritySession.targetId
2547
+ && candidate.fetchEnabled
2548
+ && !candidate.session.detached
2549
+ ));
2550
+ if (hasReplacement) {
2551
+ return;
2552
+ }
2553
+ if (attempt < 2) {
2554
+ await delay(25);
2555
+ }
2556
+ }
2557
+ const browserSecurityCdpSession = this.browserSecurityCdpSession;
2558
+ if (!browserSecurityCdpSession || browserSecurityCdpSession.detached) {
2559
+ throw new Error('Browser security CDP session detached unexpectedly');
2560
+ }
2561
+ const { targetInfos } = await browserSecurityCdpSession.send('Target.getTargets');
2562
+ if (targetInfos.some((targetInfo) => targetInfo.targetId === proxySecuritySession.targetId)) {
2563
+ throw new Error(
2564
+ `Proxy security detached from live target ${proxySecuritySession.targetId}`,
2565
+ );
1710
2566
  }
1711
2567
  }
1712
2568
 
2569
+ private removeProxySecuritySession(proxySecuritySession: IProxySecuritySession): void {
2570
+ if (this.proxySecuritySessions.get(proxySecuritySession.session.id()) !== proxySecuritySession) {
2571
+ return;
2572
+ }
2573
+ proxySecuritySession.session.off('Fetch.authRequired', proxySecuritySession.authRequiredListener);
2574
+ proxySecuritySession.session.off('Fetch.requestPaused', proxySecuritySession.requestPausedListener);
2575
+ proxySecuritySession.session.off(
2576
+ 'Network.loadingFinished',
2577
+ proxySecuritySession.loadingFinishedListener,
2578
+ );
2579
+ proxySecuritySession.session.off(
2580
+ 'Network.loadingFailed',
2581
+ proxySecuritySession.loadingFailedListener,
2582
+ );
2583
+ proxySecuritySession.attemptedAuthentications.clear();
2584
+ proxySecuritySession.requestIdsByNetworkId.clear();
2585
+ this.proxySecuritySessions.delete(proxySecuritySession.session.id());
2586
+ }
2587
+
2588
+ private allowOperationalCdpSessionDetach(session: plugins.puppeteer.CDPSession): void {
2589
+ const proxySecuritySession = this.proxySecuritySessions.get(session.id());
2590
+ if (proxySecuritySession) {
2591
+ proxySecuritySession.allowLiveTargetDetach = true;
2592
+ }
2593
+ }
2594
+
2595
+ private trackProxySecurityOperation(
2596
+ operation: Promise<void>,
2597
+ generation: number,
2598
+ errorCode: string,
2599
+ ): void {
2600
+ if (this.proxySecurityOperations.size >= maxProxySecurityOperations) {
2601
+ void operation.catch(() => undefined);
2602
+ this.handleProxySecurityFailure(
2603
+ 'proxy_security_operation_capacity_exceeded',
2604
+ new Error(`Proxy security exceeded ${maxProxySecurityOperations} protocol operations`),
2605
+ generation,
2606
+ );
2607
+ return;
2608
+ }
2609
+ let trackedOperation: Promise<void>;
2610
+ trackedOperation = operation.catch((error) => {
2611
+ this.handleProxySecurityFailure(errorCode, error, generation);
2612
+ }).finally(() => {
2613
+ this.proxySecurityOperations.delete(trackedOperation);
2614
+ });
2615
+ this.proxySecurityOperations.add(trackedOperation);
2616
+ }
2617
+
2618
+ private handleProxySecurityFailure(
2619
+ code: string,
2620
+ error: unknown,
2621
+ generation = this.proxySecurityGeneration,
2622
+ ): void {
2623
+ if (
2624
+ generation !== this.proxySecurityGeneration
2625
+ || this.proxySecurityStopping
2626
+ || this.normalStopRequested
2627
+ || this.status === 'stopped'
2628
+ || this.status === 'stopping'
2629
+ ) {
2630
+ return;
2631
+ }
2632
+ const liveBrowserError: ILiveBrowserError = {
2633
+ code,
2634
+ message: normalizeErrorMessage(error),
2635
+ fatal: true,
2636
+ };
2637
+ this.emitError(liveBrowserError);
2638
+ this.beginShutdown(true);
2639
+ void this.terminate().catch((terminationError) => {
2640
+ this.emitError({
2641
+ code: 'proxy_security_termination_failed',
2642
+ message: normalizeErrorMessage(terminationError),
2643
+ fatal: true,
2644
+ });
2645
+ });
2646
+ }
2647
+
2648
+ private async teardownProxySecurity(): Promise<void> {
2649
+ this.proxySecurityStopping = true;
2650
+ if (this.browserSecurityConnection && this.browserSecuritySessionAttachedListener) {
2651
+ this.browserSecurityConnection.off(
2652
+ 'sessionattached',
2653
+ this.browserSecuritySessionAttachedListener,
2654
+ );
2655
+ }
2656
+ if (this.browserSecurityConnection && this.browserSecuritySessionDetachedListener) {
2657
+ this.browserSecurityConnection.off(
2658
+ 'sessiondetached',
2659
+ this.browserSecuritySessionDetachedListener,
2660
+ );
2661
+ }
2662
+ this.browserSecuritySessionAttachedListener = undefined;
2663
+ this.browserSecuritySessionDetachedListener = undefined;
2664
+ this.browserSecurityConnection = undefined;
2665
+ const ownedSessions = [...this.proxySecuritySessions.values()]
2666
+ .filter((record) => record.ownedByProxySecurity)
2667
+ .map((record) => record.session);
2668
+ for (const proxySecuritySession of [...this.proxySecuritySessions.values()]) {
2669
+ this.removeProxySecuritySession(proxySecuritySession);
2670
+ }
2671
+ for (const ownedSession of ownedSessions) {
2672
+ if (!ownedSession.detached) {
2673
+ try {
2674
+ await ownedSession.detach();
2675
+ } catch {
2676
+ // Browser shutdown can close a target before explicit detach settles.
2677
+ }
2678
+ }
2679
+ }
2680
+ const browserSecurityCdpSession = this.browserSecurityCdpSession;
2681
+ this.browserSecurityCdpSession = undefined;
2682
+ if (browserSecurityCdpSession && !browserSecurityCdpSession.detached) {
2683
+ try {
2684
+ await browserSecurityCdpSession.detach();
2685
+ } catch {
2686
+ // Browser shutdown can detach the browser target first.
2687
+ }
2688
+ }
2689
+ while (this.proxySecurityOperations.size > 0) {
2690
+ await Promise.allSettled([...this.proxySecurityOperations]);
2691
+ }
2692
+ this.proxySecurityGeneration = 0;
2693
+ }
2694
+
1713
2695
  private requireBrowserContext(): plugins.puppeteer.BrowserContext {
1714
2696
  if (this.status !== 'running' || !this.browserContext) {
1715
2697
  throw new Error('LiveBrowserSession is not running');
@@ -1776,6 +2758,68 @@ export class LiveBrowserSession {
1776
2758
  }
1777
2759
  }
1778
2760
 
2761
+ private scheduleDiscoveredPageRegistration(page: plugins.puppeteer.Page): void {
2762
+ if (
2763
+ page.isClosed()
2764
+ || this.tabIdsByPage.has(page)
2765
+ || this.normalStopRequested
2766
+ || this.status === 'stopped'
2767
+ || this.status === 'stopping'
2768
+ ) {
2769
+ return;
2770
+ }
2771
+ const wasScheduled = this.scheduleOperation(async () => {
2772
+ if (page.isClosed() || this.tabIdsByPage.has(page)) {
2773
+ return;
2774
+ }
2775
+ const previousActiveTabId = this.activeTabId;
2776
+ let discoveredTab: IPrivateLiveBrowserTab | undefined;
2777
+ try {
2778
+ discoveredTab = await this.registerPage(page);
2779
+ await this.activateTabInternal(discoveredTab.id);
2780
+ } catch (error) {
2781
+ const rollbackError = await this.rollbackCreatedPage(
2782
+ page,
2783
+ discoveredTab,
2784
+ previousActiveTabId,
2785
+ );
2786
+ if (rollbackError) {
2787
+ const aggregateError = new AggregateError(
2788
+ [error, rollbackError],
2789
+ 'Discovered page registration rollback failed',
2790
+ );
2791
+ this.handleDiscoveredPageFailure(aggregateError);
2792
+ throw aggregateError;
2793
+ }
2794
+ throw error;
2795
+ }
2796
+ }, 'discovered_page_registration_failed', undefined, true);
2797
+ if (!wasScheduled) {
2798
+ this.handleDiscoveredPageFailure(
2799
+ new Error('A discovered page could not be admitted to the internal operation queue'),
2800
+ );
2801
+ }
2802
+ }
2803
+
2804
+ private handleDiscoveredPageFailure(error: unknown): void {
2805
+ if (this.normalStopRequested || this.status === 'stopped' || this.status === 'stopping') {
2806
+ return;
2807
+ }
2808
+ const liveBrowserError: ILiveBrowserError = {
2809
+ code: 'untracked_page_detected',
2810
+ message: normalizeErrorMessage(error),
2811
+ fatal: true,
2812
+ };
2813
+ this.emitError(liveBrowserError);
2814
+ void this.requestShutdown(liveBrowserError).catch((shutdownError) => {
2815
+ this.emitError({
2816
+ code: 'untracked_page_shutdown_failed',
2817
+ message: normalizeErrorMessage(shutdownError),
2818
+ fatal: true,
2819
+ });
2820
+ });
2821
+ }
2822
+
1779
2823
  private async registerPage(page: plugins.puppeteer.Page): Promise<IPrivateLiveBrowserTab> {
1780
2824
  const existingTabId = this.tabIdsByPage.get(page);
1781
2825
  if (existingTabId) {
@@ -1807,6 +2851,7 @@ export class LiveBrowserSession {
1807
2851
  try {
1808
2852
  if (this.options.security?.denyFileChoosers) {
1809
2853
  const securityCdpSession = await page.createCDPSession();
2854
+ this.allowOperationalCdpSessionDetach(securityCdpSession);
1810
2855
  tab.securityCdpSession = securityCdpSession;
1811
2856
  await securityCdpSession.send('Page.enable', {
1812
2857
  enableFileChooserOpenedEvent: true,
@@ -2270,6 +3315,7 @@ export class LiveBrowserSession {
2270
3315
  await this.ensureTabViewport(tab);
2271
3316
 
2272
3317
  const cdpSession = await tab.page.createCDPSession();
3318
+ this.allowOperationalCdpSessionDetach(cdpSession);
2273
3319
  const generation = tab.generation + 1;
2274
3320
  const frameListener: TScreencastFrameListener = (event) => {
2275
3321
  this.handleScreencastFrame(tab, cdpSession, generation, event);
@@ -2320,6 +3366,8 @@ export class LiveBrowserSession {
2320
3366
  plugins.puppeteer.CDPSessionEvent.SessionDetached,
2321
3367
  cdpSessionDetachedListener,
2322
3368
  );
3369
+ await this.retireOutstandingFrames((frame) => frame.cdpSession === cdpSession);
3370
+ await this.waitForCdpFrameAcknowledgements(cdpSession);
2323
3371
  if (!cdpSession.detached) {
2324
3372
  try {
2325
3373
  await cdpSession.detach();
@@ -2364,6 +3412,7 @@ export class LiveBrowserSession {
2364
3412
  cdpSessionDetachedListener,
2365
3413
  );
2366
3414
  }
3415
+ await this.waitForCdpFrameAcknowledgements(cdpSession);
2367
3416
  if (!cdpSession.detached) {
2368
3417
  try {
2369
3418
  await cdpSession.detach();
@@ -2408,12 +3457,13 @@ export class LiveBrowserSession {
2408
3457
  const sequence = ++this.frameSequence;
2409
3458
  const outstandingFrame: IOutstandingFrame = {
2410
3459
  tabId: tab.id,
3460
+ sequence,
2411
3461
  generation,
2412
3462
  viewportRevision: this.viewportRevision,
2413
3463
  cdpSessionId: event.sessionId,
2414
3464
  cdpSession,
2415
3465
  };
2416
- while (this.outstandingFrames.size >= maxOutstandingFrames) {
3466
+ while (this.outstandingFrames.size >= this.maxOutstandingFrames) {
2417
3467
  const oldestFrameEntry = this.outstandingFrames.entries().next().value as
2418
3468
  | [number, IOutstandingFrame]
2419
3469
  | undefined;
@@ -2449,31 +3499,40 @@ export class LiveBrowserSession {
2449
3499
  this.emitEvent({ type: 'frame', frame });
2450
3500
  }
2451
3501
 
2452
- private async acknowledgeCdpFrame(frame: IOutstandingFrame): Promise<boolean> {
2453
- if (frame.cdpSession.detached) {
2454
- this.handleFrameAcknowledgementFailure(
2455
- frame,
2456
- new Error('CDP session detached before frame acknowledgement'),
2457
- );
2458
- return false;
3502
+ private acknowledgeCdpFrame(frame: ICdpScreencastFrame): Promise<boolean> {
3503
+ if (frame.acknowledgementPromise) {
3504
+ return frame.acknowledgementPromise;
2459
3505
  }
2460
- try {
3506
+ let resolveAcknowledgement!: (accepted: boolean) => void;
3507
+ const acknowledgementPromise = new Promise<boolean>((resolve) => {
3508
+ resolveAcknowledgement = resolve;
3509
+ });
3510
+ frame.acknowledgementPromise = acknowledgementPromise;
3511
+ this.cdpFramesBeingAcknowledged.add(frame);
3512
+ void (async (): Promise<boolean> => {
3513
+ if (frame.cdpSession.detached) {
3514
+ throw new Error('CDP session detached before frame acknowledgement');
3515
+ }
2461
3516
  await frame.cdpSession.send('Page.screencastFrameAck', {
2462
3517
  sessionId: frame.cdpSessionId,
2463
- });
3518
+ }, { timeout: frameAcknowledgementTimeoutMs });
2464
3519
  return true;
2465
- } catch (error) {
3520
+ })().then(resolveAcknowledgement, (error) => {
2466
3521
  this.handleFrameAcknowledgementFailure(frame, error);
2467
- return false;
2468
- }
3522
+ resolveAcknowledgement(false);
3523
+ });
3524
+ void acknowledgementPromise.then(() => {
3525
+ this.cdpFramesBeingAcknowledged.delete(frame);
3526
+ });
3527
+ return acknowledgementPromise;
2469
3528
  }
2470
3529
 
2471
- private acknowledgeCdpFrameInBackground(frame: IOutstandingFrame): void {
3530
+ private acknowledgeCdpFrameInBackground(frame: ICdpScreencastFrame): void {
2472
3531
  void this.acknowledgeCdpFrame(frame);
2473
3532
  }
2474
3533
 
2475
3534
  private handleFrameAcknowledgementFailure(
2476
- frame: IOutstandingFrame,
3535
+ frame: ICdpScreencastFrame,
2477
3536
  error: unknown,
2478
3537
  ): void {
2479
3538
  const tab = this.tabs.get(frame.tabId);
@@ -2513,6 +3572,15 @@ export class LiveBrowserSession {
2513
3572
  await Promise.all(acknowledgements);
2514
3573
  }
2515
3574
 
3575
+ private async waitForCdpFrameAcknowledgements(
3576
+ cdpSession?: plugins.puppeteer.CDPSession,
3577
+ ): Promise<void> {
3578
+ const acknowledgements = [...this.cdpFramesBeingAcknowledged]
3579
+ .filter((frame) => !cdpSession || frame.cdpSession === cdpSession)
3580
+ .map((frame) => frame.acknowledgementPromise!);
3581
+ await Promise.all(acknowledgements);
3582
+ }
3583
+
2516
3584
  private handlePossibleCdpDisconnection(
2517
3585
  tab: IPrivateLiveBrowserTab,
2518
3586
  cdpSession: plugins.puppeteer.CDPSession,
@@ -2971,5 +4039,13 @@ export class LiveBrowserSession {
2971
4039
  if (options.everyNthFrame !== undefined) {
2972
4040
  validateInteger(options.everyNthFrame, 'screencast.everyNthFrame', 1, 100);
2973
4041
  }
4042
+ if (options.maxOutstandingFrames !== undefined) {
4043
+ validateInteger(
4044
+ options.maxOutstandingFrames,
4045
+ 'screencast.maxOutstandingFrames',
4046
+ 1,
4047
+ liveBrowserMaxOutstandingFrames,
4048
+ );
4049
+ }
2974
4050
  }
2975
4051
  }