@push.rocks/smartpuppeteer 2.2.0 → 2.3.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,13 @@
1
1
  import { getEnvAwareBrowserInstance } from './smartpuppeteer.classes.smartpuppeteer.js';
2
+ import {
3
+ delay,
4
+ type IOwnedProcessIdentity,
5
+ killFrozenOwnedProcessGroup,
6
+ listOwnedProcessGroupMembers,
7
+ readOwnedProcessIdentity,
8
+ signalOwnedProcessIdentity,
9
+ signalOwnedProcessGroup,
10
+ } from './smartpuppeteer.helpers.process.js';
2
11
  import type {
3
12
  ILiveBrowserClickOptions,
4
13
  ILiveBrowserCreateTabOptions,
@@ -18,11 +27,14 @@ import type {
18
27
  ILiveBrowserObserveOptions,
19
28
  ILiveBrowserOperationOptions,
20
29
  ILiveBrowserPressOptions,
30
+ ILiveBrowserProcessState,
21
31
  ILiveBrowserSessionOptions,
22
32
  ILiveBrowserSnapshot,
23
33
  ILiveBrowserSnapshotOptions,
24
34
  ILiveBrowserState,
25
35
  ILiveBrowserTabState,
36
+ ILiveBrowserTerminationOptions,
37
+ ILiveBrowserTerminationResult,
26
38
  ILiveBrowserViewport,
27
39
  ILiveBrowserWheelInput,
28
40
  TLiveBrowserEvent,
@@ -60,10 +72,52 @@ const maxEvaluationObjectKeys = 10000;
60
72
  const evaluationBootstrapKey = '__smartpuppeteerEvaluate';
61
73
  const evaluationCancelKey = '__smartpuppeteerCancel';
62
74
  const evaluationCleanupKey = '__smartpuppeteerCleanup';
75
+ const maxProxySecuritySessions = 256;
76
+ const maxTrackedProxyRequests = 1024;
77
+ const maxTotalTrackedProxyRequests = 4096;
78
+ const maxProxySecurityOperations = 2048;
79
+ const proxySeedTargetTypes = new Set([
80
+ 'background_page',
81
+ 'page',
82
+ 'service_worker',
83
+ 'shared_worker',
84
+ 'webview',
85
+ ]);
86
+ const proxyFetchUnsupportedTargetTypes = new Set(['tab', 'worker']);
63
87
 
64
88
  type TScreencastFrameEvent = plugins.puppeteer.Protocol.Page.ScreencastFrameEvent;
65
89
  type TScreencastFrameListener = (event: TScreencastFrameEvent) => void;
66
90
  type TCdpSessionDetachedListener = (session: plugins.puppeteer.CDPSession) => void;
91
+ type TOwnedChildProcess = NonNullable<ReturnType<plugins.puppeteer.Browser['process']>>;
92
+ type TProxyAuthRequiredEvent = plugins.puppeteer.Protocol.Fetch.AuthRequiredEvent;
93
+ type TProxyRequestPausedEvent = plugins.puppeteer.Protocol.Fetch.RequestPausedEvent;
94
+ type TNetworkLoadingFinishedEvent = plugins.puppeteer.Protocol.Network.LoadingFinishedEvent;
95
+ type TNetworkLoadingFailedEvent = plugins.puppeteer.Protocol.Network.LoadingFailedEvent;
96
+
97
+ interface IProxySecuritySession {
98
+ session: plugins.puppeteer.CDPSession;
99
+ generation: number;
100
+ targetId?: string;
101
+ targetType?: string;
102
+ fetchEnabled: boolean;
103
+ allowLiveTargetDetach: boolean;
104
+ ownedByProxySecurity: boolean;
105
+ attemptedAuthentications: Set<string>;
106
+ requestIdsByNetworkId: Map<string, string>;
107
+ authRequiredListener: (event: TProxyAuthRequiredEvent) => void;
108
+ requestPausedListener: (event: TProxyRequestPausedEvent) => void;
109
+ loadingFinishedListener: (event: TNetworkLoadingFinishedEvent) => void;
110
+ loadingFailedListener: (event: TNetworkLoadingFailedEvent) => void;
111
+ setupPromise: Promise<void>;
112
+ }
113
+
114
+ interface IOwnedBrowserProcess {
115
+ generation: number;
116
+ childProcess: TOwnedChildProcess;
117
+ identity?: IOwnedProcessIdentity;
118
+ exitPromise: Promise<void>;
119
+ forceSignalled: boolean;
120
+ }
67
121
 
68
122
  interface IPrivateLiveBrowserTab {
69
123
  id: string;
@@ -318,6 +372,20 @@ export class LiveBrowserSession {
318
372
  private browserContext?: plugins.puppeteer.BrowserContext;
319
373
  private browserLifetimeController?: AbortController;
320
374
  private browserDisconnectedListener?: () => void;
375
+ private browserTargetCreatedListener?: (target: plugins.puppeteer.Target) => void;
376
+ private browserSecurityCdpSession?: plugins.puppeteer.CDPSession;
377
+ private browserSecurityConnection?: plugins.puppeteer.Connection;
378
+ private browserSecuritySessionAttachedListener?: (session: plugins.puppeteer.CDPSession) => void;
379
+ private browserSecuritySessionDetachedListener?: TCdpSessionDetachedListener;
380
+ private readonly proxySecuritySessions = new Map<string, IProxySecuritySession>();
381
+ private readonly proxySecurityOperations = new Set<Promise<void>>();
382
+ private proxySecurityGeneration = 0;
383
+ private proxySecurityStopping = false;
384
+ private ownedBrowserProcess?: IOwnedBrowserProcess;
385
+ private processGeneration = 0;
386
+ private startRequestCount = 0;
387
+ private terminationSettled = true;
388
+ private terminationPromise?: Promise<ILiveBrowserTerminationResult>;
321
389
  private readonly operationQueue: IQueuedOperation[] = [];
322
390
  private activeOperation?: IQueuedOperation;
323
391
  private operationRunning = false;
@@ -351,8 +419,31 @@ export class LiveBrowserSession {
351
419
  throw new Error('LiveBrowserSession owns launch cancellation; launchOptions.signal is unsupported');
352
420
  }
353
421
  validateOptionalBoolean(optionsArg.allowEvaluation, 'allowEvaluation');
354
- for (const [name, value] of Object.entries(optionsArg.security ?? {})) {
355
- validateOptionalBoolean(value, `security.${name}`);
422
+ for (const name of [
423
+ 'denyDownloads',
424
+ 'denyFileChoosers',
425
+ 'denyPermissions',
426
+ 'httpNavigationOnly',
427
+ ] as const) {
428
+ validateOptionalBoolean(optionsArg.security?.[name], `security.${name}`);
429
+ }
430
+ const proxyCredentials = optionsArg.security?.proxyCredentials;
431
+ if (proxyCredentials !== undefined) {
432
+ if (!proxyCredentials || typeof proxyCredentials !== 'object') {
433
+ throw new Error('security.proxyCredentials must be an object');
434
+ }
435
+ validateBoundedString(
436
+ proxyCredentials.username,
437
+ 'security.proxyCredentials.username',
438
+ 1,
439
+ 256,
440
+ );
441
+ validateBoundedString(
442
+ proxyCredentials.password,
443
+ 'security.proxyCredentials.password',
444
+ 1,
445
+ 1024,
446
+ );
356
447
  }
357
448
  const launchViewport = optionsArg.launchOptions?.defaultViewport;
358
449
  const viewport = normalizeViewport(
@@ -377,7 +468,12 @@ export class LiveBrowserSession {
377
468
  },
378
469
  viewport,
379
470
  screencast: optionsArg.screencast ? { ...optionsArg.screencast } : undefined,
380
- security: optionsArg.security ? { ...optionsArg.security } : undefined,
471
+ security: optionsArg.security
472
+ ? {
473
+ ...optionsArg.security,
474
+ proxyCredentials: proxyCredentials ? { ...proxyCredentials } : undefined,
475
+ }
476
+ : undefined,
381
477
  };
382
478
  this.viewport = { ...viewport };
383
479
  this.validateScreencastOptions();
@@ -404,12 +500,86 @@ export class LiveBrowserSession {
404
500
  };
405
501
  }
406
502
 
407
- public async start(operationOptions: ILiveBrowserOperationOptions = {}): Promise<void> {
408
- return this.enqueuePublicOperation(async (signal) => {
503
+ public getProcessState(): ILiveBrowserProcessState {
504
+ const ownedProcess = this.ownedBrowserProcess;
505
+ if (!ownedProcess) {
506
+ return {
507
+ generation: this.processGeneration,
508
+ pid: null,
509
+ processGroupId: null,
510
+ running: false,
511
+ exitCode: null,
512
+ signalCode: null,
513
+ };
514
+ }
515
+ const childProcess = ownedProcess.childProcess;
516
+ return {
517
+ generation: ownedProcess.generation,
518
+ pid: childProcess.pid ?? null,
519
+ processGroupId: ownedProcess.identity?.processGroupId ?? null,
520
+ running: childProcess.exitCode === null && childProcess.signalCode === null,
521
+ exitCode: childProcess.exitCode,
522
+ signalCode: childProcess.signalCode,
523
+ };
524
+ }
525
+
526
+ public terminate(
527
+ optionsArg: ILiveBrowserTerminationOptions = {},
528
+ ): Promise<ILiveBrowserTerminationResult> {
529
+ if (this.terminationPromise) {
530
+ return this.terminationPromise;
531
+ }
532
+ const gracefulTimeoutMs = optionsArg.gracefulTimeoutMs === undefined
533
+ ? 5000
534
+ : validateInteger(optionsArg.gracefulTimeoutMs, 'gracefulTimeoutMs', 1, 60000);
535
+ const forceTimeoutMs = optionsArg.forceTimeoutMs === undefined
536
+ ? 5000
537
+ : validateInteger(optionsArg.forceTimeoutMs, 'forceTimeoutMs', 1, 60000);
538
+ let resolveTermination!: (result: ILiveBrowserTerminationResult) => void;
539
+ let rejectTermination!: (error: unknown) => void;
540
+ const terminationPromise = new Promise<ILiveBrowserTerminationResult>((resolve, reject) => {
541
+ resolveTermination = resolve;
542
+ rejectTermination = reject;
543
+ });
544
+ this.terminationPromise = terminationPromise;
545
+ this.terminationSettled = false;
546
+ void this.terminateInternal(gracefulTimeoutMs, forceTimeoutMs).then(
547
+ (result) => {
548
+ this.terminationSettled = true;
549
+ resolveTermination(result);
550
+ },
551
+ (error) => {
552
+ if (this.terminationPromise === terminationPromise) {
553
+ this.terminationPromise = undefined;
554
+ }
555
+ this.terminationSettled = true;
556
+ rejectTermination(error);
557
+ },
558
+ );
559
+ return terminationPromise;
560
+ }
561
+
562
+ public start(operationOptions: ILiveBrowserOperationOptions = {}): Promise<void> {
563
+ if (!this.terminationSettled) {
564
+ return Promise.reject(new Error('A browser termination is still in progress'));
565
+ }
566
+ if (!this.ownedBrowserProcess && this.status === 'stopped') {
567
+ this.terminationPromise = undefined;
568
+ }
569
+ this.startRequestCount += 1;
570
+ const startPromise = this.enqueuePublicOperation(async (signal) => {
409
571
  if (this.status === 'running' || this.status === 'starting') {
410
572
  return;
411
573
  }
412
574
 
575
+ await this.releaseExitedOwnedProcess();
576
+ if (signal.aborted) {
577
+ throw signal.reason;
578
+ }
579
+ if (this.ownedBrowserProcess) {
580
+ throw new Error('A previous owned Chromium process has not been confirmed dead');
581
+ }
582
+
413
583
  this.normalStopRequested = false;
414
584
  this.lastError = undefined;
415
585
  this.viewportRevision = 1;
@@ -445,10 +615,56 @@ export class LiveBrowserSession {
445
615
  } finally {
446
616
  signal.removeEventListener('abort', abortBrowserLaunch);
447
617
  }
618
+ const childProcess = this.browser.process();
619
+ if (!childProcess?.pid) {
620
+ throw new Error('LiveBrowserSession did not receive an owned Chromium process');
621
+ }
622
+ const exitPromise = new Promise<void>((resolve) => {
623
+ if (childProcess.exitCode !== null || childProcess.signalCode !== null) {
624
+ resolve();
625
+ return;
626
+ }
627
+ childProcess.once('exit', () => resolve());
628
+ });
629
+ this.processGeneration += 1;
630
+ this.ownedBrowserProcess = {
631
+ generation: this.processGeneration,
632
+ childProcess,
633
+ exitPromise,
634
+ forceSignalled: false,
635
+ };
636
+ const identity = process.platform === 'linux'
637
+ ? await readOwnedProcessIdentity(childProcess.pid)
638
+ : undefined;
639
+ if (process.platform === 'linux' && !identity) {
640
+ throw new Error('The owned Chromium process exited before its identity was captured');
641
+ }
642
+ if (
643
+ identity
644
+ && (identity.processGroupId !== childProcess.pid || identity.sessionId !== childProcess.pid)
645
+ ) {
646
+ throw new Error('Chromium was not launched as a dedicated process-group leader');
647
+ }
648
+ this.ownedBrowserProcess.identity = identity;
448
649
  if (signal.aborted) {
449
650
  throw signal.reason;
450
651
  }
451
652
  this.browserContext = this.browser.defaultBrowserContext();
653
+ this.browserTargetCreatedListener = (target) => {
654
+ if (target.type() !== 'page' || target.browserContext() !== this.browserContext) {
655
+ return;
656
+ }
657
+ void target.page().then((page) => {
658
+ if (page) {
659
+ this.scheduleDiscoveredPageRegistration(page);
660
+ }
661
+ }).catch((error) => {
662
+ if (!this.normalStopRequested && this.status !== 'stopped' && this.status !== 'stopping') {
663
+ this.handleDiscoveredPageFailure(error);
664
+ }
665
+ });
666
+ };
667
+ this.browser.on('targetcreated', this.browserTargetCreatedListener);
452
668
  await this.configureBrowserSecurity(this.browser);
453
669
  this.browserDisconnectedListener = () => {
454
670
  if (this.normalStopRequested || this.status === 'stopped' || this.status === 'stopping') {
@@ -497,7 +713,14 @@ export class LiveBrowserSession {
497
713
  }
498
714
  } catch (error) {
499
715
  if (signal.aborted || this.normalStopRequested) {
500
- await this.stopInternal();
716
+ try {
717
+ await this.stopInternal();
718
+ } catch (cleanupError) {
719
+ throw new AggregateError(
720
+ [error, cleanupError],
721
+ 'Cancelled browser startup cleanup was incomplete',
722
+ );
723
+ }
501
724
  throw error;
502
725
  }
503
726
  const startError: ILiveBrowserError = {
@@ -506,10 +729,20 @@ export class LiveBrowserSession {
506
729
  fatal: true,
507
730
  };
508
731
  this.emitError(startError);
509
- await this.stopInternal(startError);
732
+ try {
733
+ await this.stopInternal(startError);
734
+ } catch (cleanupError) {
735
+ throw new AggregateError(
736
+ [error, cleanupError],
737
+ 'Browser startup failed and cleanup was incomplete',
738
+ );
739
+ }
510
740
  throw error;
511
741
  }
512
742
  }, operationOptions);
743
+ return startPromise.finally(() => {
744
+ this.startRequestCount -= 1;
745
+ });
513
746
  }
514
747
 
515
748
  public async stop(): Promise<void> {
@@ -1070,6 +1303,7 @@ export class LiveBrowserSession {
1070
1303
  return this.enqueuePublicOperation(async (signal) => {
1071
1304
  const tab = this.resolveActionTab(optionsArg.tabId);
1072
1305
  const cdpSession = await tab.page.createCDPSession();
1306
+ this.allowOperationalCdpSessionDetach(cdpSession);
1073
1307
  let executionContextId: number | undefined;
1074
1308
  let cancellationPromise: Promise<void> | undefined;
1075
1309
  let terminationPromise: Promise<void> | undefined;
@@ -1346,7 +1580,25 @@ export class LiveBrowserSession {
1346
1580
  return Promise.reject(new Error('LiveBrowserSession operation queue is full'));
1347
1581
  }
1348
1582
  this.admittedPublicOperations += 1;
1349
- return this.enqueueQueuedOperation('public', operation, false, callerSignal);
1583
+ return this.enqueueQueuedOperation('public', async (signal) => {
1584
+ this.assertProxySecurityReady();
1585
+ return operation(signal);
1586
+ }, false, callerSignal);
1587
+ }
1588
+
1589
+ private assertProxySecurityReady(): void {
1590
+ if (!this.options.security?.proxyCredentials || this.status !== 'running') {
1591
+ return;
1592
+ }
1593
+ if (
1594
+ !this.browserSecurityConnection
1595
+ || !this.browserSecurityCdpSession
1596
+ || this.browserSecurityCdpSession.detached
1597
+ ) {
1598
+ const error = new Error('Authenticated proxy security is not attached');
1599
+ this.handleProxySecurityFailure('proxy_security_unavailable', error);
1600
+ throw error;
1601
+ }
1350
1602
  }
1351
1603
 
1352
1604
  private enqueueInternalOperation(
@@ -1509,7 +1761,8 @@ export class LiveBrowserSession {
1509
1761
  }
1510
1762
 
1511
1763
  private requestShutdown(error?: ILiveBrowserError): Promise<void> {
1512
- if (this.status === 'stopped') {
1764
+ if (this.status === 'stopped' && !this.operationRunning && this.startRequestCount === 0) {
1765
+ this.normalStopRequested = false;
1513
1766
  return Promise.resolve();
1514
1767
  }
1515
1768
  if (this.shutdownPromise) {
@@ -1622,6 +1875,248 @@ export class LiveBrowserSession {
1622
1875
  };
1623
1876
  }
1624
1877
 
1878
+ private async releaseExitedOwnedProcess(): Promise<void> {
1879
+ const ownedProcess = this.ownedBrowserProcess;
1880
+ if (!ownedProcess) {
1881
+ return;
1882
+ }
1883
+ if (
1884
+ ownedProcess.childProcess.exitCode === null
1885
+ && ownedProcess.childProcess.signalCode === null
1886
+ ) {
1887
+ await Promise.race([ownedProcess.exitPromise, delay(100)]);
1888
+ if (
1889
+ ownedProcess.childProcess.exitCode === null
1890
+ && ownedProcess.childProcess.signalCode === null
1891
+ ) {
1892
+ return;
1893
+ }
1894
+ }
1895
+ if (process.platform === 'linux' && !ownedProcess.identity) {
1896
+ return;
1897
+ }
1898
+ if (ownedProcess.identity) {
1899
+ const members = await listOwnedProcessGroupMembers(ownedProcess.identity);
1900
+ if (members.length > 0) {
1901
+ return;
1902
+ }
1903
+ }
1904
+ this.ownedBrowserProcess = undefined;
1905
+ }
1906
+
1907
+ private async terminateInternal(
1908
+ gracefulTimeoutMs: number,
1909
+ forceTimeoutMs: number,
1910
+ ): Promise<ILiveBrowserTerminationResult> {
1911
+ const errors: string[] = [];
1912
+ let shutdownSettled = this.status === 'stopped';
1913
+ const shutdownObserved = this.stop().then(
1914
+ () => {
1915
+ shutdownSettled = true;
1916
+ },
1917
+ (error) => {
1918
+ errors.push(normalizeErrorMessage(error));
1919
+ shutdownSettled = true;
1920
+ },
1921
+ );
1922
+ const createConfirmedResult = (
1923
+ ownedProcess: IOwnedBrowserProcess | undefined,
1924
+ forced: boolean,
1925
+ ): ILiveBrowserTerminationResult => {
1926
+ const childProcess = ownedProcess?.childProcess;
1927
+ const result: ILiveBrowserTerminationResult = {
1928
+ generation: ownedProcess?.generation ?? this.processGeneration,
1929
+ pid: childProcess?.pid ?? null,
1930
+ processGroupId: ownedProcess?.identity?.processGroupId ?? null,
1931
+ running: false,
1932
+ exitCode: childProcess?.exitCode ?? null,
1933
+ signalCode: childProcess?.signalCode ?? null,
1934
+ forced: forced || Boolean(ownedProcess?.forceSignalled),
1935
+ shutdownComplete: true,
1936
+ confirmedDead: true,
1937
+ errors,
1938
+ };
1939
+ if (this.ownedBrowserProcess === ownedProcess) {
1940
+ this.ownedBrowserProcess = undefined;
1941
+ }
1942
+ return result;
1943
+ };
1944
+ const isConfirmedDead = async (
1945
+ ownedProcess: IOwnedBrowserProcess | undefined,
1946
+ ): Promise<boolean> => {
1947
+ if (!ownedProcess) {
1948
+ return true;
1949
+ }
1950
+ if (process.platform !== 'linux' || !ownedProcess.identity) {
1951
+ if (process.platform === 'linux' && this.startRequestCount > 0) {
1952
+ return false;
1953
+ }
1954
+ throw new Error('Confirmed browser process-group termination is supported on Linux only');
1955
+ }
1956
+ if (
1957
+ ownedProcess.childProcess.exitCode === null
1958
+ && ownedProcess.childProcess.signalCode === null
1959
+ ) {
1960
+ return false;
1961
+ }
1962
+ const members = await listOwnedProcessGroupMembers(ownedProcess.identity);
1963
+ if (members.length > 0) {
1964
+ return false;
1965
+ }
1966
+ return true;
1967
+ };
1968
+
1969
+ const gracefulDeadline = Date.now() + gracefulTimeoutMs;
1970
+ while (Date.now() < gracefulDeadline) {
1971
+ const ownedProcess = this.ownedBrowserProcess;
1972
+ if (
1973
+ await isConfirmedDead(ownedProcess)
1974
+ && shutdownSettled
1975
+ && this.status === 'stopped'
1976
+ ) {
1977
+ return createConfirmedResult(ownedProcess, false);
1978
+ }
1979
+ if (shutdownSettled) {
1980
+ await delay(100);
1981
+ } else {
1982
+ await Promise.race([shutdownObserved, delay(100)]);
1983
+ }
1984
+ }
1985
+
1986
+ const ownedProcess = this.ownedBrowserProcess;
1987
+ let forced = false;
1988
+ if (ownedProcess) {
1989
+ if (process.platform !== 'linux') {
1990
+ throw new Error('Confirmed browser process-group termination is supported on Linux only');
1991
+ }
1992
+ if (ownedProcess.identity) {
1993
+ const membersBeforeFreeze = await listOwnedProcessGroupMembers(ownedProcess.identity);
1994
+ if (membersBeforeFreeze.length > 0) {
1995
+ forced = true;
1996
+ const groupStopped = await signalOwnedProcessGroup(ownedProcess.identity, 'SIGSTOP');
1997
+ if (groupStopped) {
1998
+ killFrozenOwnedProcessGroup(ownedProcess.identity);
1999
+ } else {
2000
+ await this.killOwnedProcessGroupSurvivors(ownedProcess.identity, membersBeforeFreeze);
2001
+ }
2002
+ }
2003
+ } else if (this.startRequestCount === 0) {
2004
+ throw new Error('Owned Chromium process identity was never confirmed');
2005
+ }
2006
+ }
2007
+
2008
+ const forceDeadline = Date.now() + forceTimeoutMs;
2009
+ while (Date.now() < forceDeadline) {
2010
+ const currentOwnedProcess = this.ownedBrowserProcess;
2011
+ if (
2012
+ await isConfirmedDead(currentOwnedProcess)
2013
+ && shutdownSettled
2014
+ && this.status === 'stopped'
2015
+ ) {
2016
+ return createConfirmedResult(currentOwnedProcess, forced);
2017
+ }
2018
+ if (shutdownSettled) {
2019
+ await delay(100);
2020
+ } else {
2021
+ await Promise.race([shutdownObserved, delay(100)]);
2022
+ }
2023
+ }
2024
+
2025
+ const remainingOwnedProcess = this.ownedBrowserProcess;
2026
+ if (remainingOwnedProcess?.identity) {
2027
+ const survivors = await listOwnedProcessGroupMembers(remainingOwnedProcess.identity);
2028
+ if (survivors.length > 0) {
2029
+ throw new Error(
2030
+ `Owned Chromium process group did not terminate: ${survivors.map((item) => item.pid).join(', ')}`,
2031
+ );
2032
+ }
2033
+ }
2034
+ if (!shutdownSettled || this.status !== 'stopped') {
2035
+ throw new Error(
2036
+ 'Owned Chromium process group exited but LiveBrowserSession shutdown did not settle',
2037
+ );
2038
+ }
2039
+ throw new Error('Owned Chromium process exit was not confirmed by Node.js');
2040
+ }
2041
+
2042
+ private async killOwnedProcessGroupSurvivors(
2043
+ rootIdentity: IOwnedProcessIdentity,
2044
+ initialMembers: IOwnedProcessIdentity[],
2045
+ ): Promise<void> {
2046
+ let previousKeys = new Set(initialMembers.map((member) => `${member.pid}:${member.startTime}`));
2047
+ for (let attempt = 0; attempt < 20; attempt += 1) {
2048
+ const members = await listOwnedProcessGroupMembers(rootIdentity);
2049
+ if (members.length === 0) {
2050
+ return;
2051
+ }
2052
+ for (const member of members) {
2053
+ await signalOwnedProcessIdentity(member, 'SIGSTOP');
2054
+ }
2055
+ await delay(10);
2056
+ const frozenMembers = await listOwnedProcessGroupMembers(rootIdentity);
2057
+ const currentKeys = new Set(
2058
+ frozenMembers.map((member) => `${member.pid}:${member.startTime}`),
2059
+ );
2060
+ const sameMembers = currentKeys.size === previousKeys.size
2061
+ && [...currentKeys].every((key) => previousKeys.has(key));
2062
+ const allFrozen = frozenMembers.every((member) => (
2063
+ member.state === 'T' || member.state === 't' || member.state === 'Z'
2064
+ ));
2065
+ if (sameMembers && allFrozen) {
2066
+ for (const member of frozenMembers) {
2067
+ await signalOwnedProcessIdentity(member, 'SIGKILL');
2068
+ }
2069
+ return;
2070
+ }
2071
+ previousKeys = currentKeys;
2072
+ }
2073
+ throw new Error('Unable to stabilize the surviving Chromium process group');
2074
+ }
2075
+
2076
+ private async forceOwnedBrowserProcessGroup(
2077
+ ownedProcess: IOwnedBrowserProcess,
2078
+ ): Promise<void> {
2079
+ ownedProcess.forceSignalled = true;
2080
+ if (process.platform !== 'linux') {
2081
+ ownedProcess.childProcess.kill('SIGKILL');
2082
+ await Promise.race([ownedProcess.exitPromise, delay(5000)]);
2083
+ if (
2084
+ ownedProcess.childProcess.exitCode === null
2085
+ && ownedProcess.childProcess.signalCode === null
2086
+ ) {
2087
+ throw new Error('Owned Chromium process did not exit after SIGKILL');
2088
+ }
2089
+ return;
2090
+ }
2091
+ if (!ownedProcess.identity) {
2092
+ throw new Error('Owned Chromium process identity was never confirmed');
2093
+ }
2094
+ const members = await listOwnedProcessGroupMembers(ownedProcess.identity);
2095
+ if (members.length > 0) {
2096
+ const groupStopped = await signalOwnedProcessGroup(ownedProcess.identity, 'SIGSTOP');
2097
+ if (groupStopped) {
2098
+ killFrozenOwnedProcessGroup(ownedProcess.identity);
2099
+ } else {
2100
+ await this.killOwnedProcessGroupSurvivors(ownedProcess.identity, members);
2101
+ }
2102
+ }
2103
+ const deadline = Date.now() + 5000;
2104
+ while (Date.now() < deadline) {
2105
+ const survivors = await listOwnedProcessGroupMembers(ownedProcess.identity);
2106
+ if (survivors.length === 0) {
2107
+ await Promise.race([ownedProcess.exitPromise, delay(100)]);
2108
+ if (
2109
+ ownedProcess.childProcess.exitCode !== null
2110
+ || ownedProcess.childProcess.signalCode !== null
2111
+ ) {
2112
+ return;
2113
+ }
2114
+ }
2115
+ await delay(100);
2116
+ }
2117
+ throw new Error('Owned Chromium process group survived forced startup cleanup');
2118
+ }
2119
+
1625
2120
  private async stopInternal(error?: ILiveBrowserError): Promise<void> {
1626
2121
  if (this.status === 'stopped') {
1627
2122
  return;
@@ -1636,12 +2131,21 @@ export class LiveBrowserSession {
1636
2131
  }
1637
2132
 
1638
2133
  const browser = this.browser;
2134
+ if (browser && this.browserTargetCreatedListener) {
2135
+ browser.off('targetcreated', this.browserTargetCreatedListener);
2136
+ }
2137
+ this.browserTargetCreatedListener = undefined;
1639
2138
  if (browser && this.browserDisconnectedListener) {
1640
2139
  browser.off('disconnected', this.browserDisconnectedListener);
1641
2140
  }
1642
2141
  this.browserDisconnectedListener = undefined;
1643
2142
 
1644
2143
  const shutdownErrors: unknown[] = [];
2144
+ try {
2145
+ await this.teardownProxySecurity();
2146
+ } catch (cleanupError) {
2147
+ shutdownErrors.push(cleanupError);
2148
+ }
1645
2149
  for (const tab of [...this.tabs.values()]) {
1646
2150
  tab.closing = true;
1647
2151
  const securityCdpSession = tab.securityCdpSession;
@@ -1675,6 +2179,14 @@ export class LiveBrowserSession {
1675
2179
  await browser.close();
1676
2180
  } catch (closeError) {
1677
2181
  shutdownErrors.push(closeError);
2182
+ const ownedProcess = this.ownedBrowserProcess;
2183
+ if (ownedProcess) {
2184
+ try {
2185
+ await this.forceOwnedBrowserProcessGroup(ownedProcess);
2186
+ } catch (forceError) {
2187
+ shutdownErrors.push(forceError);
2188
+ }
2189
+ }
1678
2190
  }
1679
2191
  }
1680
2192
 
@@ -1689,7 +2201,7 @@ export class LiveBrowserSession {
1689
2201
  this.activeTabId = null;
1690
2202
  this.status = 'stopped';
1691
2203
  this.emitState();
1692
- if (shutdownErrors.length > 0 && !error) {
2204
+ if (shutdownErrors.length > 0) {
1693
2205
  throw new AggregateError(shutdownErrors, 'LiveBrowserSession shutdown was incomplete');
1694
2206
  }
1695
2207
  }
@@ -1697,17 +2209,442 @@ export class LiveBrowserSession {
1697
2209
  private async configureBrowserSecurity(
1698
2210
  browser: plugins.puppeteer.Browser,
1699
2211
  ): Promise<void> {
1700
- if (!this.options.security?.denyPermissions) {
2212
+ const denyPermissions = this.options.security?.denyPermissions;
2213
+ const proxyCredentials = this.options.security?.proxyCredentials;
2214
+ if (!denyPermissions && !proxyCredentials) {
1701
2215
  return;
1702
2216
  }
1703
2217
  const cdpSession = await browser.target().createCDPSession();
2218
+ if (!proxyCredentials) {
2219
+ try {
2220
+ if (denyPermissions) {
2221
+ await cdpSession.send('Browser.grantPermissions', { permissions: [] });
2222
+ }
2223
+ } finally {
2224
+ if (!cdpSession.detached) {
2225
+ await cdpSession.detach();
2226
+ }
2227
+ }
2228
+ return;
2229
+ }
2230
+
2231
+ const generation = this.ownedBrowserProcess?.generation;
2232
+ if (generation === undefined) {
2233
+ throw new Error('Proxy security requires an owned browser generation');
2234
+ }
2235
+ this.proxySecurityStopping = false;
2236
+ this.proxySecurityGeneration = generation;
2237
+ this.browserSecurityCdpSession = cdpSession;
1704
2238
  try {
1705
- await cdpSession.send('Browser.grantPermissions', { permissions: [] });
1706
- } finally {
1707
- if (!cdpSession.detached) {
1708
- await cdpSession.detach();
2239
+ if (denyPermissions) {
2240
+ await cdpSession.send('Browser.grantPermissions', { permissions: [] });
2241
+ }
2242
+ const browserSecurityConnection = cdpSession.connection();
2243
+ if (!browserSecurityConnection) {
2244
+ throw new Error('Browser security CDP session has no connection');
2245
+ }
2246
+ this.browserSecurityConnection = browserSecurityConnection;
2247
+ this.browserSecuritySessionAttachedListener = (attachedSession) => {
2248
+ this.trackProxySecurityOperation(
2249
+ this.configureProxySecuritySession(attachedSession, generation),
2250
+ generation,
2251
+ 'proxy_security_session_setup_failed',
2252
+ );
2253
+ };
2254
+ this.browserSecuritySessionDetachedListener = (detachedSession) => {
2255
+ if (detachedSession === cdpSession) {
2256
+ this.handleProxySecurityFailure(
2257
+ 'proxy_security_browser_session_detached',
2258
+ new Error('Browser security CDP session detached unexpectedly'),
2259
+ generation,
2260
+ );
2261
+ return;
2262
+ }
2263
+ this.trackProxySecurityOperation(
2264
+ this.verifyProxySecuritySessionDetached(detachedSession, generation),
2265
+ generation,
2266
+ 'proxy_security_detach_verification_failed',
2267
+ );
2268
+ };
2269
+ browserSecurityConnection.on(
2270
+ 'sessionattached',
2271
+ this.browserSecuritySessionAttachedListener,
2272
+ );
2273
+ browserSecurityConnection.on(
2274
+ 'sessiondetached',
2275
+ this.browserSecuritySessionDetachedListener,
2276
+ );
2277
+
2278
+ for (const target of browser.targets()) {
2279
+ if (!proxySeedTargetTypes.has(target.type())) {
2280
+ continue;
2281
+ }
2282
+ const securitySession = await target.createCDPSession();
2283
+ const securityRecord = this.proxySecuritySessions.get(securitySession.id());
2284
+ if (!securityRecord) {
2285
+ throw new Error(`Proxy security did not observe session ${securitySession.id()}`);
2286
+ }
2287
+ securityRecord.ownedByProxySecurity = true;
2288
+ await securityRecord.setupPromise;
2289
+ }
2290
+ } catch (error) {
2291
+ await this.teardownProxySecurity();
2292
+ throw error;
2293
+ }
2294
+ }
2295
+
2296
+ private configureProxySecuritySession(
2297
+ session: plugins.puppeteer.CDPSession,
2298
+ generation: number,
2299
+ ): Promise<void> {
2300
+ const existing = this.proxySecuritySessions.get(session.id());
2301
+ if (existing) {
2302
+ if (existing.generation !== generation) {
2303
+ return Promise.reject(new Error(`CDP session ${session.id()} crossed browser generations`));
2304
+ }
2305
+ return existing.setupPromise;
2306
+ }
2307
+ if (this.proxySecuritySessions.size >= maxProxySecuritySessions) {
2308
+ return Promise.reject(
2309
+ new Error(`Proxy security exceeded ${maxProxySecuritySessions} CDP sessions`),
2310
+ );
2311
+ }
2312
+
2313
+ const attemptedAuthentications = new Set<string>();
2314
+ const requestIdsByNetworkId = new Map<string, string>();
2315
+ const handleProtocolFailure = (operation: string, error: unknown): void => {
2316
+ this.handleProxySecurityFailure(
2317
+ 'proxy_security_protocol_failed',
2318
+ new Error(`${operation}: ${normalizeErrorMessage(error)}`),
2319
+ generation,
2320
+ );
2321
+ };
2322
+ const authRequiredListener = (event: TProxyAuthRequiredEvent): void => {
2323
+ const credentials = this.options.security?.proxyCredentials;
2324
+ const totalTrackedRequests = [...this.proxySecuritySessions.values()].reduce(
2325
+ (total, record) => total
2326
+ + record.attemptedAuthentications.size
2327
+ + record.requestIdsByNetworkId.size,
2328
+ 0,
2329
+ );
2330
+ const hasTrackingCapacity = attemptedAuthentications.size < maxTrackedProxyRequests
2331
+ && totalTrackedRequests < maxTotalTrackedProxyRequests;
2332
+ const isFirstProxyAttempt = event.authChallenge.source === 'Proxy'
2333
+ && !attemptedAuthentications.has(event.requestId)
2334
+ && hasTrackingCapacity;
2335
+ if (isFirstProxyAttempt) {
2336
+ attemptedAuthentications.add(event.requestId);
2337
+ } else if (
2338
+ event.authChallenge.source === 'Proxy'
2339
+ && !attemptedAuthentications.has(event.requestId)
2340
+ && !hasTrackingCapacity
2341
+ ) {
2342
+ handleProtocolFailure(
2343
+ 'Proxy authentication tracking failed',
2344
+ new Error(`A security session exceeded ${maxTrackedProxyRequests} tracked requests`),
2345
+ );
2346
+ }
2347
+ const authChallengeResponse = isFirstProxyAttempt && credentials
2348
+ ? {
2349
+ response: 'ProvideCredentials' as const,
2350
+ username: credentials.username,
2351
+ password: credentials.password,
2352
+ }
2353
+ : { response: 'CancelAuth' as const };
2354
+ this.trackProxySecurityOperation(
2355
+ session.send('Fetch.continueWithAuth', {
2356
+ requestId: event.requestId,
2357
+ authChallengeResponse,
2358
+ }).then(() => undefined),
2359
+ generation,
2360
+ 'proxy_security_protocol_failed',
2361
+ );
2362
+ };
2363
+ const requestPausedListener = (event: TProxyRequestPausedEvent): void => {
2364
+ if (event.networkId) {
2365
+ const totalTrackedRequests = [...this.proxySecuritySessions.values()].reduce(
2366
+ (total, record) => total
2367
+ + record.attemptedAuthentications.size
2368
+ + record.requestIdsByNetworkId.size,
2369
+ 0,
2370
+ );
2371
+ if (
2372
+ !requestIdsByNetworkId.has(event.networkId)
2373
+ && (
2374
+ requestIdsByNetworkId.size >= maxTrackedProxyRequests
2375
+ || totalTrackedRequests >= maxTotalTrackedProxyRequests
2376
+ )
2377
+ ) {
2378
+ handleProtocolFailure(
2379
+ 'Proxy request tracking failed',
2380
+ new Error(`A security session exceeded ${maxTrackedProxyRequests} tracked requests`),
2381
+ );
2382
+ } else {
2383
+ const previousRequestId = requestIdsByNetworkId.get(event.networkId);
2384
+ if (previousRequestId && previousRequestId !== event.requestId) {
2385
+ attemptedAuthentications.delete(previousRequestId);
2386
+ }
2387
+ requestIdsByNetworkId.set(event.networkId, event.requestId);
2388
+ }
2389
+ }
2390
+ this.trackProxySecurityOperation(
2391
+ session.send('Fetch.continueRequest', { requestId: event.requestId }).then(() => undefined),
2392
+ generation,
2393
+ 'proxy_security_protocol_failed',
2394
+ );
2395
+ };
2396
+ const forgetAuthentication = (networkId: string): void => {
2397
+ const requestId = requestIdsByNetworkId.get(networkId);
2398
+ if (!requestId) {
2399
+ return;
2400
+ }
2401
+ requestIdsByNetworkId.delete(networkId);
2402
+ attemptedAuthentications.delete(requestId);
2403
+ };
2404
+ const loadingFinishedListener = (event: TNetworkLoadingFinishedEvent): void => {
2405
+ forgetAuthentication(event.requestId);
2406
+ };
2407
+ const loadingFailedListener = (event: TNetworkLoadingFailedEvent): void => {
2408
+ forgetAuthentication(event.requestId);
2409
+ };
2410
+ session.on('Fetch.authRequired', authRequiredListener);
2411
+ session.on('Fetch.requestPaused', requestPausedListener);
2412
+ session.on('Network.loadingFinished', loadingFinishedListener);
2413
+ session.on('Network.loadingFailed', loadingFailedListener);
2414
+
2415
+ const proxySecuritySession: IProxySecuritySession = {
2416
+ session,
2417
+ generation,
2418
+ fetchEnabled: false,
2419
+ allowLiveTargetDetach: false,
2420
+ ownedByProxySecurity: false,
2421
+ attemptedAuthentications,
2422
+ requestIdsByNetworkId,
2423
+ authRequiredListener,
2424
+ requestPausedListener,
2425
+ loadingFinishedListener,
2426
+ loadingFailedListener,
2427
+ setupPromise: Promise.resolve(),
2428
+ };
2429
+ const targetInfoPromise = session.send('Target.getTargetInfo');
2430
+ const networkEnablePromise = session.send('Network.enable');
2431
+ const fetchEnablePromise = session.send('Fetch.enable', {
2432
+ handleAuthRequests: true,
2433
+ patterns: [{ urlPattern: '*' }],
2434
+ });
2435
+ const commandResultsPromise = Promise.allSettled([
2436
+ targetInfoPromise,
2437
+ networkEnablePromise,
2438
+ fetchEnablePromise,
2439
+ ]);
2440
+ const setupPromise = (async (): Promise<void> => {
2441
+ try {
2442
+ const [targetInfoResult, networkResult, fetchResult] = await commandResultsPromise;
2443
+ if (targetInfoResult.status === 'rejected') {
2444
+ throw targetInfoResult.reason;
2445
+ }
2446
+ const { targetInfo } = targetInfoResult.value;
2447
+ proxySecuritySession.targetId = targetInfo.targetId;
2448
+ proxySecuritySession.targetType = targetInfo.type;
2449
+ if (fetchResult.status === 'rejected') {
2450
+ const unsupportedTarget = proxyFetchUnsupportedTargetTypes.has(targetInfo.type)
2451
+ && fetchResult.reason instanceof plugins.puppeteer.ProtocolError
2452
+ && fetchResult.reason.originalMessage === "'Fetch.enable' wasn't found";
2453
+ if (unsupportedTarget) {
2454
+ this.removeProxySecuritySession(proxySecuritySession);
2455
+ return;
2456
+ }
2457
+ throw fetchResult.reason;
2458
+ }
2459
+ if (networkResult.status === 'rejected') {
2460
+ throw networkResult.reason;
2461
+ }
2462
+ proxySecuritySession.fetchEnabled = true;
2463
+ } catch (error) {
2464
+ this.removeProxySecuritySession(proxySecuritySession);
2465
+ if (session.detached || this.proxySecurityStopping || this.normalStopRequested) {
2466
+ return;
2467
+ }
2468
+ throw error;
2469
+ }
2470
+ })();
2471
+ proxySecuritySession.setupPromise = setupPromise;
2472
+ this.proxySecuritySessions.set(session.id(), proxySecuritySession);
2473
+ return setupPromise;
2474
+ }
2475
+
2476
+ private async verifyProxySecuritySessionDetached(
2477
+ session: plugins.puppeteer.CDPSession,
2478
+ generation: number,
2479
+ ): Promise<void> {
2480
+ const proxySecuritySession = this.proxySecuritySessions.get(session.id());
2481
+ if (!proxySecuritySession || proxySecuritySession.generation !== generation) {
2482
+ return;
2483
+ }
2484
+ this.removeProxySecuritySession(proxySecuritySession);
2485
+ if (this.proxySecurityStopping || this.normalStopRequested || this.status !== 'running') {
2486
+ return;
2487
+ }
2488
+ if (proxySecuritySession.allowLiveTargetDetach) {
2489
+ return;
2490
+ }
2491
+ if (!proxySecuritySession.targetId) {
2492
+ throw new Error(`Proxy security session detached before target identification: ${session.id()}`);
2493
+ }
2494
+ for (let attempt = 0; attempt < 3; attempt += 1) {
2495
+ const generationSessions = [...this.proxySecuritySessions.values()].filter((candidate) => (
2496
+ candidate.generation === generation
2497
+ ));
2498
+ await Promise.allSettled(generationSessions.map((candidate) => candidate.setupPromise));
2499
+ const hasReplacement = [...this.proxySecuritySessions.values()].some((candidate) => (
2500
+ candidate.generation === generation
2501
+ && candidate.targetId === proxySecuritySession.targetId
2502
+ && candidate.fetchEnabled
2503
+ && !candidate.session.detached
2504
+ ));
2505
+ if (hasReplacement) {
2506
+ return;
2507
+ }
2508
+ if (attempt < 2) {
2509
+ await delay(25);
2510
+ }
2511
+ }
2512
+ const browserSecurityCdpSession = this.browserSecurityCdpSession;
2513
+ if (!browserSecurityCdpSession || browserSecurityCdpSession.detached) {
2514
+ throw new Error('Browser security CDP session detached unexpectedly');
2515
+ }
2516
+ const { targetInfos } = await browserSecurityCdpSession.send('Target.getTargets');
2517
+ if (targetInfos.some((targetInfo) => targetInfo.targetId === proxySecuritySession.targetId)) {
2518
+ throw new Error(
2519
+ `Proxy security detached from live target ${proxySecuritySession.targetId}`,
2520
+ );
2521
+ }
2522
+ }
2523
+
2524
+ private removeProxySecuritySession(proxySecuritySession: IProxySecuritySession): void {
2525
+ if (this.proxySecuritySessions.get(proxySecuritySession.session.id()) !== proxySecuritySession) {
2526
+ return;
2527
+ }
2528
+ proxySecuritySession.session.off('Fetch.authRequired', proxySecuritySession.authRequiredListener);
2529
+ proxySecuritySession.session.off('Fetch.requestPaused', proxySecuritySession.requestPausedListener);
2530
+ proxySecuritySession.session.off(
2531
+ 'Network.loadingFinished',
2532
+ proxySecuritySession.loadingFinishedListener,
2533
+ );
2534
+ proxySecuritySession.session.off(
2535
+ 'Network.loadingFailed',
2536
+ proxySecuritySession.loadingFailedListener,
2537
+ );
2538
+ proxySecuritySession.attemptedAuthentications.clear();
2539
+ proxySecuritySession.requestIdsByNetworkId.clear();
2540
+ this.proxySecuritySessions.delete(proxySecuritySession.session.id());
2541
+ }
2542
+
2543
+ private allowOperationalCdpSessionDetach(session: plugins.puppeteer.CDPSession): void {
2544
+ const proxySecuritySession = this.proxySecuritySessions.get(session.id());
2545
+ if (proxySecuritySession) {
2546
+ proxySecuritySession.allowLiveTargetDetach = true;
2547
+ }
2548
+ }
2549
+
2550
+ private trackProxySecurityOperation(
2551
+ operation: Promise<void>,
2552
+ generation: number,
2553
+ errorCode: string,
2554
+ ): void {
2555
+ if (this.proxySecurityOperations.size >= maxProxySecurityOperations) {
2556
+ void operation.catch(() => undefined);
2557
+ this.handleProxySecurityFailure(
2558
+ 'proxy_security_operation_capacity_exceeded',
2559
+ new Error(`Proxy security exceeded ${maxProxySecurityOperations} protocol operations`),
2560
+ generation,
2561
+ );
2562
+ return;
2563
+ }
2564
+ let trackedOperation: Promise<void>;
2565
+ trackedOperation = operation.catch((error) => {
2566
+ this.handleProxySecurityFailure(errorCode, error, generation);
2567
+ }).finally(() => {
2568
+ this.proxySecurityOperations.delete(trackedOperation);
2569
+ });
2570
+ this.proxySecurityOperations.add(trackedOperation);
2571
+ }
2572
+
2573
+ private handleProxySecurityFailure(
2574
+ code: string,
2575
+ error: unknown,
2576
+ generation = this.proxySecurityGeneration,
2577
+ ): void {
2578
+ if (
2579
+ generation !== this.proxySecurityGeneration
2580
+ || this.proxySecurityStopping
2581
+ || this.normalStopRequested
2582
+ || this.status === 'stopped'
2583
+ || this.status === 'stopping'
2584
+ ) {
2585
+ return;
2586
+ }
2587
+ const liveBrowserError: ILiveBrowserError = {
2588
+ code,
2589
+ message: normalizeErrorMessage(error),
2590
+ fatal: true,
2591
+ };
2592
+ this.emitError(liveBrowserError);
2593
+ this.beginShutdown(true);
2594
+ void this.terminate().catch((terminationError) => {
2595
+ this.emitError({
2596
+ code: 'proxy_security_termination_failed',
2597
+ message: normalizeErrorMessage(terminationError),
2598
+ fatal: true,
2599
+ });
2600
+ });
2601
+ }
2602
+
2603
+ private async teardownProxySecurity(): Promise<void> {
2604
+ this.proxySecurityStopping = true;
2605
+ if (this.browserSecurityConnection && this.browserSecuritySessionAttachedListener) {
2606
+ this.browserSecurityConnection.off(
2607
+ 'sessionattached',
2608
+ this.browserSecuritySessionAttachedListener,
2609
+ );
2610
+ }
2611
+ if (this.browserSecurityConnection && this.browserSecuritySessionDetachedListener) {
2612
+ this.browserSecurityConnection.off(
2613
+ 'sessiondetached',
2614
+ this.browserSecuritySessionDetachedListener,
2615
+ );
2616
+ }
2617
+ this.browserSecuritySessionAttachedListener = undefined;
2618
+ this.browserSecuritySessionDetachedListener = undefined;
2619
+ this.browserSecurityConnection = undefined;
2620
+ const ownedSessions = [...this.proxySecuritySessions.values()]
2621
+ .filter((record) => record.ownedByProxySecurity)
2622
+ .map((record) => record.session);
2623
+ for (const proxySecuritySession of [...this.proxySecuritySessions.values()]) {
2624
+ this.removeProxySecuritySession(proxySecuritySession);
2625
+ }
2626
+ for (const ownedSession of ownedSessions) {
2627
+ if (!ownedSession.detached) {
2628
+ try {
2629
+ await ownedSession.detach();
2630
+ } catch {
2631
+ // Browser shutdown can close a target before explicit detach settles.
2632
+ }
2633
+ }
2634
+ }
2635
+ const browserSecurityCdpSession = this.browserSecurityCdpSession;
2636
+ this.browserSecurityCdpSession = undefined;
2637
+ if (browserSecurityCdpSession && !browserSecurityCdpSession.detached) {
2638
+ try {
2639
+ await browserSecurityCdpSession.detach();
2640
+ } catch {
2641
+ // Browser shutdown can detach the browser target first.
1709
2642
  }
1710
2643
  }
2644
+ while (this.proxySecurityOperations.size > 0) {
2645
+ await Promise.allSettled([...this.proxySecurityOperations]);
2646
+ }
2647
+ this.proxySecurityGeneration = 0;
1711
2648
  }
1712
2649
 
1713
2650
  private requireBrowserContext(): plugins.puppeteer.BrowserContext {
@@ -1776,6 +2713,68 @@ export class LiveBrowserSession {
1776
2713
  }
1777
2714
  }
1778
2715
 
2716
+ private scheduleDiscoveredPageRegistration(page: plugins.puppeteer.Page): void {
2717
+ if (
2718
+ page.isClosed()
2719
+ || this.tabIdsByPage.has(page)
2720
+ || this.normalStopRequested
2721
+ || this.status === 'stopped'
2722
+ || this.status === 'stopping'
2723
+ ) {
2724
+ return;
2725
+ }
2726
+ const wasScheduled = this.scheduleOperation(async () => {
2727
+ if (page.isClosed() || this.tabIdsByPage.has(page)) {
2728
+ return;
2729
+ }
2730
+ const previousActiveTabId = this.activeTabId;
2731
+ let discoveredTab: IPrivateLiveBrowserTab | undefined;
2732
+ try {
2733
+ discoveredTab = await this.registerPage(page);
2734
+ await this.activateTabInternal(discoveredTab.id);
2735
+ } catch (error) {
2736
+ const rollbackError = await this.rollbackCreatedPage(
2737
+ page,
2738
+ discoveredTab,
2739
+ previousActiveTabId,
2740
+ );
2741
+ if (rollbackError) {
2742
+ const aggregateError = new AggregateError(
2743
+ [error, rollbackError],
2744
+ 'Discovered page registration rollback failed',
2745
+ );
2746
+ this.handleDiscoveredPageFailure(aggregateError);
2747
+ throw aggregateError;
2748
+ }
2749
+ throw error;
2750
+ }
2751
+ }, 'discovered_page_registration_failed', undefined, true);
2752
+ if (!wasScheduled) {
2753
+ this.handleDiscoveredPageFailure(
2754
+ new Error('A discovered page could not be admitted to the internal operation queue'),
2755
+ );
2756
+ }
2757
+ }
2758
+
2759
+ private handleDiscoveredPageFailure(error: unknown): void {
2760
+ if (this.normalStopRequested || this.status === 'stopped' || this.status === 'stopping') {
2761
+ return;
2762
+ }
2763
+ const liveBrowserError: ILiveBrowserError = {
2764
+ code: 'untracked_page_detected',
2765
+ message: normalizeErrorMessage(error),
2766
+ fatal: true,
2767
+ };
2768
+ this.emitError(liveBrowserError);
2769
+ void this.requestShutdown(liveBrowserError).catch((shutdownError) => {
2770
+ this.emitError({
2771
+ code: 'untracked_page_shutdown_failed',
2772
+ message: normalizeErrorMessage(shutdownError),
2773
+ fatal: true,
2774
+ });
2775
+ });
2776
+ }
2777
+
1779
2778
  private async registerPage(page: plugins.puppeteer.Page): Promise<IPrivateLiveBrowserTab> {
1780
2779
  const existingTabId = this.tabIdsByPage.get(page);
1781
2780
  if (existingTabId) {
@@ -1807,6 +2806,7 @@ export class LiveBrowserSession {
1807
2806
  try {
1808
2807
  if (this.options.security?.denyFileChoosers) {
1809
2808
  const securityCdpSession = await page.createCDPSession();
2809
+ this.allowOperationalCdpSessionDetach(securityCdpSession);
1810
2810
  tab.securityCdpSession = securityCdpSession;
1811
2811
  await securityCdpSession.send('Page.enable', {
1812
2812
  enableFileChooserOpenedEvent: true,
@@ -2270,6 +3270,7 @@ export class LiveBrowserSession {
2270
3270
  await this.ensureTabViewport(tab);
2271
3271
 
2272
3272
  const cdpSession = await tab.page.createCDPSession();
3273
+ this.allowOperationalCdpSessionDetach(cdpSession);
2273
3274
  const generation = tab.generation + 1;
2274
3275
  const frameListener: TScreencastFrameListener = (event) => {
2275
3276
  this.handleScreencastFrame(tab, cdpSession, generation, event);