mcp-accessibility-scanner 3.1.0 → 3.2.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.
Files changed (70) hide show
  1. package/README.md +40 -12
  2. package/index.d.ts +1 -1
  3. package/lib/browserContextFactory.js +548 -81
  4. package/lib/browserContextFactory.js.map +1 -1
  5. package/lib/browserServerBackend.js +192 -21
  6. package/lib/browserServerBackend.js.map +1 -1
  7. package/lib/browserSessions.js +172 -0
  8. package/lib/browserSessions.js.map +1 -0
  9. package/lib/config.js +48 -7
  10. package/lib/config.js.map +1 -1
  11. package/lib/context.js +143 -10
  12. package/lib/context.js.map +1 -1
  13. package/lib/extension/cdpRelay.js +5 -0
  14. package/lib/extension/cdpRelay.js.map +1 -1
  15. package/lib/extension/extensionContextFactory.js +9 -1
  16. package/lib/extension/extensionContextFactory.js.map +1 -1
  17. package/lib/index.js +5 -1
  18. package/lib/index.js.map +1 -1
  19. package/lib/mcp/http.js +194 -44
  20. package/lib/mcp/http.js.map +1 -1
  21. package/lib/mcp/inProcessTransport.js.map +1 -1
  22. package/lib/mcp/mdb.js +7 -9
  23. package/lib/mcp/mdb.js.map +1 -1
  24. package/lib/mcp/proxyBackend.js +75 -18
  25. package/lib/mcp/proxyBackend.js.map +1 -1
  26. package/lib/mcp/server.js +70 -41
  27. package/lib/mcp/server.js.map +1 -1
  28. package/lib/mcp/sharedClientSlot.js +134 -0
  29. package/lib/mcp/sharedClientSlot.js.map +1 -0
  30. package/lib/mcp/tool.js +3 -0
  31. package/lib/mcp/tool.js.map +1 -1
  32. package/lib/program.js +70 -9
  33. package/lib/program.js.map +1 -1
  34. package/lib/response.js +8 -0
  35. package/lib/response.js.map +1 -1
  36. package/lib/sessionLog.js +36 -6
  37. package/lib/sessionLog.js.map +1 -1
  38. package/lib/tab.js +32 -2
  39. package/lib/tab.js.map +1 -1
  40. package/lib/tools/auditKeyboard.js +1 -4
  41. package/lib/tools/auditKeyboard.js.map +1 -1
  42. package/lib/tools/auditScreenReader.js +1 -4
  43. package/lib/tools/auditScreenReader.js.map +1 -1
  44. package/lib/tools/auditSite.js +1 -4
  45. package/lib/tools/auditSite.js.map +1 -1
  46. package/lib/tools/network.js +13 -102
  47. package/lib/tools/network.js.map +1 -1
  48. package/lib/tools/pdf.js +5 -2
  49. package/lib/tools/pdf.js.map +1 -1
  50. package/lib/tools/scanPageMatrix.js +1 -4
  51. package/lib/tools/scanPageMatrix.js.map +1 -1
  52. package/lib/tools/screenshot.js +6 -2
  53. package/lib/tools/screenshot.js.map +1 -1
  54. package/lib/tools/session.js +53 -0
  55. package/lib/tools/session.js.map +1 -0
  56. package/lib/tools/snapshot.js +1 -4
  57. package/lib/tools/snapshot.js.map +1 -1
  58. package/lib/tools.js +6 -0
  59. package/lib/tools.js.map +1 -1
  60. package/lib/utils/fileUtils.js +35 -0
  61. package/lib/utils/fileUtils.js.map +1 -1
  62. package/lib/utils/guid.js +8 -0
  63. package/lib/utils/guid.js.map +1 -1
  64. package/lib/vscode/browserContextFactory.js +4 -0
  65. package/lib/vscode/browserContextFactory.js.map +1 -1
  66. package/lib/vscode/host.js +173 -32
  67. package/lib/vscode/host.js.map +1 -1
  68. package/lib/vscode/main.js +2 -2
  69. package/lib/vscode/main.js.map +1 -1
  70. package/package.json +8 -6
@@ -22,7 +22,7 @@ import coreBundle from 'playwright-core/lib/coreBundle';
22
22
  const { registryDirectory } = coreBundle.registry;
23
23
  const { startTraceViewerServer } = coreBundle.server;
24
24
  import { logUnhandledError, testDebug } from './utils/log.js';
25
- import { createGuid, createHash } from './utils/guid.js';
25
+ import { createGuid, createHash, createShortGuid } from './utils/guid.js';
26
26
  import { outputFile } from './config.js';
27
27
  import { ensureNetworkPolicyRoutes } from './networkPolicy.js';
28
28
  /**
@@ -443,39 +443,144 @@ class BaseContextFactory {
443
443
  config;
444
444
  _logName;
445
445
  _browserPromise;
446
+ // Counts live handouts per browser object, claimed BEFORE awaiting context
447
+ // creation — the same pattern as CdpContextFactory. A `browser.contexts()`
448
+ // census cannot see a sibling still inside _doCreateContext(): if session
449
+ // A's close ran while session B's first newContext() was in flight, A saw
450
+ // itself as the last context and closed the shared browser out from under
451
+ // B. Keyed per browser object (not per factory) because an external
452
+ // disconnect makes _obtainBrowser hand out a fresh browser while stale
453
+ // handouts still reference the old one.
454
+ _handoutCounts = new WeakMap();
455
+ // Acquisitions that have entered createContext() but not yet claimed their
456
+ // per-browser handout count: `await` yields to the microtask queue even on
457
+ // an already-resolved browser promise, so a sibling's close running inside
458
+ // that window used to see zero remaining handouts and shut the shared
459
+ // browser down under the resuming caller. Registered synchronously before
460
+ // the first await (see _acquireBrowser) and consulted by every "am I the
461
+ // last one?" check. Keyed per obtain promise, not per factory: a pending
462
+ // acquisition on a NEW promise (after an external disconnect evicted the
463
+ // old one) must not keep the old browser from closing.
464
+ _pendingAcquisitions = new Map();
446
465
  constructor(name, config) {
447
466
  this._logName = name;
448
467
  this.config = config;
449
468
  }
450
- async _obtainBrowser(clientInfo) {
469
+ // Deliberately not async: the returned promise must be the cached
470
+ // `_browserPromise` itself so callers can register pending acquisitions
471
+ // against it and use it for the identity guards, and the body must run
472
+ // synchronously so obtaining and registering happen in one continuation.
473
+ _obtainBrowser(clientInfo) {
451
474
  if (this._browserPromise)
452
475
  return this._browserPromise;
453
476
  testDebug(`obtain browser (${this._logName})`);
454
- this._browserPromise = this._doObtainBrowser(clientInfo);
455
- void this._browserPromise.then(browser => {
477
+ const promise = this._doObtainBrowser(clientInfo);
478
+ this._browserPromise = promise;
479
+ // The eviction is bound to the promise this browser came from — the same
480
+ // identity guard the close paths use: a close evicts the cache eagerly,
481
+ // so by the time the closing browser's asynchronous 'disconnected' fires
482
+ // (or a failed obtain rejects), a successor connection may already be
483
+ // cached, and clearing it would churn yet another browser for the next
484
+ // session while the successor is alive.
485
+ void promise.then(browser => {
456
486
  browser.on('disconnected', () => {
457
- this._browserPromise = undefined;
487
+ if (this._browserPromise === promise)
488
+ this._browserPromise = undefined;
458
489
  });
459
490
  }).catch(() => {
460
- this._browserPromise = undefined;
491
+ if (this._browserPromise === promise)
492
+ this._browserPromise = undefined;
461
493
  });
462
- return this._browserPromise;
494
+ return promise;
495
+ }
496
+ /**
497
+ * Obtains the shared browser and claims the caller's per-browser count via
498
+ * `claim`, atomically with the browser's delivery: the acquisition is
499
+ * registered in a synchronous counter before the first await, and `claim`
500
+ * runs in the same continuation that resolves the browser, so at every
501
+ * point the caller is visible either as pending or as a live handout. The
502
+ * close paths consult _hasPendingAcquisition() and defer the browser
503
+ * shutdown to a pending acquisition instead of treating themselves as last.
504
+ */
505
+ async _acquireBrowser(clientInfo, claim) {
506
+ const obtainedPromise = this._obtainBrowser(clientInfo);
507
+ this._pendingAcquisitions.set(obtainedPromise, (this._pendingAcquisitions.get(obtainedPromise) ?? 0) + 1);
508
+ try {
509
+ const browser = await obtainedPromise;
510
+ claim(browser);
511
+ return { browser, obtainedPromise };
512
+ }
513
+ finally {
514
+ const pending = (this._pendingAcquisitions.get(obtainedPromise) ?? 1) - 1;
515
+ if (pending > 0)
516
+ this._pendingAcquisitions.set(obtainedPromise, pending);
517
+ else
518
+ this._pendingAcquisitions.delete(obtainedPromise);
519
+ }
520
+ }
521
+ /**
522
+ * True while a createContext() has started against `obtainedPromise` but
523
+ * not yet claimed its per-browser count. A release that would otherwise be
524
+ * the last defers the browser shutdown to that acquisition — which either
525
+ * claims the count in the same continuation the promise resolves in (its
526
+ * own release then closes the browser), or fails to obtain the browser
527
+ * altogether, in which case there is no browser left to close (a rejected
528
+ * obtain never launched one).
529
+ */
530
+ _hasPendingAcquisition(obtainedPromise) {
531
+ return !!this._pendingAcquisitions.get(obtainedPromise);
532
+ }
533
+ _releaseHandout(browser) {
534
+ const remaining = Math.max(0, (this._handoutCounts.get(browser) ?? 1) - 1);
535
+ this._handoutCounts.set(browser, remaining);
536
+ return remaining === 0;
463
537
  }
464
538
  async createContext(clientInfo) {
465
539
  testDebug(`create browser context (${this._logName})`);
466
- const browser = await this._obtainBrowser(clientInfo);
467
- const browserContext = await this._doCreateContext(browser);
468
- return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
469
- }
470
- async _closeBrowserContext(browserContext, browser) {
471
- testDebug(`close browser context (${this._logName})`);
472
- if (browser.contexts().length === 1)
473
- this._browserPromise = undefined;
474
- await browserContext.close().catch(logUnhandledError);
475
- if (browser.contexts().length === 0) {
476
- testDebug(`close browser (${this._logName})`);
477
- await browser.close().catch(logUnhandledError);
540
+ // `obtainedPromise` is the promise this browser came from — it guards the
541
+ // eager `_browserPromise` resets below: after an external disconnect a
542
+ // NEW promise may be in place, and clearing it would orphan the fresh
543
+ // connection other sessions are about to use.
544
+ const { browser, obtainedPromise } = await this._acquireBrowser(clientInfo, acquired => {
545
+ this._handoutCounts.set(acquired, (this._handoutCounts.get(acquired) ?? 0) + 1);
546
+ });
547
+ let browserContext;
548
+ try {
549
+ browserContext = await this._doCreateContext(browser);
478
550
  }
551
+ catch (error) {
552
+ // The handout never materialized. When it was the last one, the browser
553
+ // must not stay behind ownerless — a sibling's close may have deferred
554
+ // the browser shutdown to this in-flight creation.
555
+ if (this._releaseHandout(browser) && !this._hasPendingAcquisition(obtainedPromise)) {
556
+ if (this._browserPromise === obtainedPromise)
557
+ this._browserPromise = undefined;
558
+ testDebug(`close browser (${this._logName})`);
559
+ await browser.close().catch(logUnhandledError);
560
+ }
561
+ throw error;
562
+ }
563
+ let released = false;
564
+ return {
565
+ browserContext,
566
+ close: async () => {
567
+ if (released)
568
+ return;
569
+ released = true;
570
+ testDebug(`close browser context (${this._logName})`);
571
+ const last = this._releaseHandout(browser) && !this._hasPendingAcquisition(obtainedPromise);
572
+ // Cleared before the awaits so a createContext() arriving while this
573
+ // close is still in flight obtains a fresh browser instead of the
574
+ // closing one.
575
+ if (last && this._browserPromise === obtainedPromise)
576
+ this._browserPromise = undefined;
577
+ await browserContext.close().catch(logUnhandledError);
578
+ if (last) {
579
+ testDebug(`close browser (${this._logName})`);
580
+ await browser.close().catch(logUnhandledError);
581
+ }
582
+ },
583
+ };
479
584
  }
480
585
  }
481
586
  class IsolatedContextFactory extends BaseContextFactory {
@@ -484,18 +589,26 @@ class IsolatedContextFactory extends BaseContextFactory {
484
589
  super('isolated', config);
485
590
  }
486
591
  async _doObtainBrowser(clientInfo) {
487
- await injectCdpPort(this.config.browser);
592
+ const { cdpPortOptions, releaseCdpPort } = await allocateCdpPort(this.config.browser);
488
593
  const browserType = playwright[this.config.browser.browserName];
489
- return browserType.launch({
490
- tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
491
- ...this.config.browser.launchOptions,
492
- handleSIGINT: false,
493
- handleSIGTERM: false,
494
- }).catch(error => {
495
- if (error.message.includes('Executable doesn\'t exist'))
496
- throw browserNotInstalledError(error);
497
- throw error;
498
- });
594
+ try {
595
+ return await browserType.launch({
596
+ tracesDir: await startTraceServer(this.config),
597
+ ...this.config.browser.launchOptions,
598
+ ...cdpPortOptions,
599
+ handleSIGINT: false,
600
+ handleSIGTERM: false,
601
+ }).catch(error => {
602
+ if (error.message.includes('Executable doesn\'t exist'))
603
+ throw browserNotInstalledError(error);
604
+ throw error;
605
+ });
606
+ }
607
+ finally {
608
+ // Bound by the launched browser on success, free for reuse on failure —
609
+ // either way the reservation has served its purpose.
610
+ releaseCdpPort();
611
+ }
499
612
  }
500
613
  async _doCreateContext(browser) {
501
614
  return browser.newContext(this.config.browser.contextOptions);
@@ -519,6 +632,14 @@ class CdpContextFactory extends BaseContextFactory {
519
632
  constructor(config) {
520
633
  super('cdp', config);
521
634
  }
635
+ // Without --isolated every session gets the attached browser's one existing
636
+ // context, so a "separate" browser session would share its tabs, cookies
637
+ // and storage with everything else.
638
+ get sessionsUnsupportedReason() {
639
+ if (this.config.browser.isolated)
640
+ return undefined;
641
+ return 'this connection attaches to the browser\'s existing context, which every session would share (same tabs, cookies and storage). Add --isolated to give each session its own browser context.';
642
+ }
522
643
  // The CDP connection (and with it every route and page proxy) is shared by
523
644
  // all live sessions of this factory, so nothing may close it while a
524
645
  // sibling session still audits through it — neither a session's own
@@ -539,8 +660,16 @@ class CdpContextFactory extends BaseContextFactory {
539
660
  }
540
661
  async createContext(clientInfo) {
541
662
  testDebug('create browser context (cdp)');
542
- const browser = await this._obtainBrowser(clientInfo);
543
- this._sessionCounts.set(browser, (this._sessionCounts.get(browser) ?? 0) + 1);
663
+ // `obtainedPromise` guards the eager `_browserPromise` evictions below —
664
+ // same pattern as the base class: after an external disconnect a NEW
665
+ // promise may be in place, and clearing it would orphan the fresh
666
+ // connection other sessions are about to use. The session count is
667
+ // claimed atomically with the browser's delivery (see _acquireBrowser),
668
+ // so a sibling's close inside createContext's own await window defers to
669
+ // this acquisition instead of disconnecting under it.
670
+ const { browser, obtainedPromise } = await this._acquireBrowser(clientInfo, acquired => {
671
+ this._sessionCounts.set(acquired, (this._sessionCounts.get(acquired) ?? 0) + 1);
672
+ });
544
673
  let browserContext;
545
674
  try {
546
675
  browserContext = await this._doCreateContext(browser);
@@ -549,8 +678,11 @@ class CdpContextFactory extends BaseContextFactory {
549
678
  // Without this the CDP connection stays open after e.g. an unreadable
550
679
  // storage-state file, even though no context was ever handed out — but
551
680
  // only when no sibling session is still using the shared connection.
552
- if (this._releaseBrowser(browser))
681
+ if (this._releaseBrowser(browser) && !this._hasPendingAcquisition(obtainedPromise)) {
682
+ if (this._browserPromise === obtainedPromise)
683
+ this._browserPromise = undefined;
553
684
  await browser.close().catch(logUnhandledError);
685
+ }
554
686
  throw error;
555
687
  }
556
688
  let released = false;
@@ -567,7 +699,13 @@ class CdpContextFactory extends BaseContextFactory {
567
699
  // it stays.
568
700
  if (this.config.browser.isolated)
569
701
  await browserContext.close().catch(logUnhandledError);
570
- if (this._releaseBrowser(browser)) {
702
+ if (this._releaseBrowser(browser) && !this._hasPendingAcquisition(obtainedPromise)) {
703
+ // Evicted before the await so a createContext() arriving while
704
+ // this disconnect is still in flight obtains a fresh connection
705
+ // instead of the closing one — the 'disconnected' event that also
706
+ // clears the cache fires too late to catch that window.
707
+ if (this._browserPromise === obtainedPromise)
708
+ this._browserPromise = undefined;
571
709
  testDebug('disconnect browser (cdp)');
572
710
  await browser.close().catch(logUnhandledError);
573
711
  }
@@ -640,26 +778,97 @@ class CdpLaunchContextFactory {
640
778
  config;
641
779
  // See CdpContextFactory: fresh context when isolated, setStorageState otherwise.
642
780
  appliesStorageState = true;
781
+ // The live child launched on a pinned --cdp-launch-port, tracked from spawn
782
+ // until the process exits. The sessionsUnsupportedReason veto below keeps
783
+ // registry sessions off this path, but DEFAULT contexts reach it too —
784
+ // parallel handshake-free HTTP requests each build a per-request backend
785
+ // whose default context launches here — and a second launch against the
786
+ // pinned port cannot work while the first child lives: its own child can
787
+ // never bind the busy port, so the connect loop attaches to the FIRST
788
+ // child's endpoint, the "separate" context lands in a sibling's
789
+ // application, and cleanup kills a child that owns nothing. Serializing
790
+ // the launches would not fix that — the port stays bound for the first
791
+ // context's whole lifetime, so a queued second launch could only time out
792
+ // or cross-attach after all — hence the second concurrent context is
793
+ // honestly rejected instead. `closing` marks a teardown in progress (kill
794
+ // sent), which a new arrival may briefly wait out rather than failing a
795
+ // plain sequential close-then-relaunch on the OS shutdown tail.
796
+ _pinnedPortLaunch;
643
797
  constructor(config) {
644
798
  this.config = config;
645
799
  }
800
+ // Without --isolated a session would reuse a launched application's single
801
+ // existing context. With --isolated each context is created fresh, at the
802
+ // documented cost of launching another application instance per context —
803
+ // unless the port is pinned: then every session's child is launched against
804
+ // the SAME endpoint, the second session's connect loop reaches the first
805
+ // session's instance (its own child never bound the busy port), its
806
+ // "separate" context lands in a sibling's application, and its cleanup
807
+ // kills a child that owns nothing while leaking that context.
808
+ get sessionsUnsupportedReason() {
809
+ if (!this.config.browser.isolated)
810
+ return 'without --isolated each session would attach to a launched application\'s single shared context (same tabs, cookies and storage). Add --isolated to give each session its own browser context.';
811
+ if (this.config.browser.cdpLaunch?.port !== undefined)
812
+ return 'the pinned --cdp-launch-port can serve only one launched application at a time, so a second session would attach to the first session\'s instance. Drop --cdp-launch-port (each session then launches on its own free port) or run one session at a time.';
813
+ return undefined;
814
+ }
646
815
  async createContext(clientInfo) {
647
816
  const cdpLaunch = this.config.browser.cdpLaunch;
648
- const port = cdpLaunch.port ?? await findFreePort();
817
+ if (cdpLaunch.port !== undefined) {
818
+ await this._waitForClosingPinnedPortHolder(cdpLaunch);
819
+ // Checked synchronously with the spawn-and-track below (nothing awaits
820
+ // in between on the pinned path): a concurrent createContext() resuming
821
+ // from the same wait could otherwise interleave here and both would
822
+ // launch against the one port.
823
+ this._assertPinnedPortFree(cdpLaunch);
824
+ }
825
+ // Reserved until the child is known to have bound the port (or the launch
826
+ // failed): findFreePort()'s probe socket is closed before the child
827
+ // spawns, so a concurrent session's probe could otherwise be handed the
828
+ // same port — both connect loops would then attach to whichever child
829
+ // bound first, sharing its context and killing the wrong child on
830
+ // cleanup (exactly the confusion the pinned-port session veto exists to
831
+ // prevent). In-process reservation suffices: the realistic collision
832
+ // source is concurrent createContext() calls in this process racing one
833
+ // OS port pool.
834
+ const allocatedPort = cdpLaunch.port === undefined ? await findFreePort({ reserve: true }) : undefined;
835
+ const port = cdpLaunch.port ?? allocatedPort;
649
836
  const endpoint = `http://127.0.0.1:${port}`;
650
837
  const args = (cdpLaunch.args ?? []).map(arg => arg.replaceAll('{port}', String(port)));
651
- const childProcess = spawn(cdpLaunch.command, args, {
652
- cwd: cdpLaunch.cwd,
653
- env: {
654
- ...process.env,
655
- ...cdpLaunch.env,
656
- },
657
- stdio: ['ignore', 'ignore', 'pipe'],
658
- });
659
- childProcess.stderr.on('data', data => {
660
- testDebug(`cdp-launch stderr: ${String(data).trimEnd()}`);
661
- });
662
- const browser = await this._waitForBrowser(endpoint, clientInfo, childProcess, cdpLaunch.startupTimeoutMs ?? 30000);
838
+ let browser;
839
+ let childProcess;
840
+ let pinnedLaunch;
841
+ try {
842
+ childProcess = spawn(cdpLaunch.command, args, {
843
+ cwd: cdpLaunch.cwd,
844
+ env: {
845
+ ...process.env,
846
+ ...cdpLaunch.env,
847
+ },
848
+ stdio: ['ignore', 'ignore', 'pipe'],
849
+ });
850
+ if (cdpLaunch.port !== undefined)
851
+ pinnedLaunch = this._trackPinnedPortChild(childProcess);
852
+ childProcess.stderr?.on('data', data => {
853
+ testDebug(`cdp-launch stderr: ${String(data).trimEnd()}`);
854
+ });
855
+ // A successful connect proves the child owns the port, so the OS can no
856
+ // longer hand it to a sibling's probe; on failure the child is already
857
+ // killed and the port free again.
858
+ browser = await this._waitForBrowser(endpoint, clientInfo, childProcess, cdpLaunch.startupTimeoutMs ?? 30000);
859
+ }
860
+ catch (error) {
861
+ // _waitForBrowser has already killed the child on failure; marking the
862
+ // tracked launch closing lets the next pinned-port context wait out the
863
+ // exit instead of rejecting against a corpse.
864
+ if (pinnedLaunch)
865
+ pinnedLaunch.closing = true;
866
+ throw error;
867
+ }
868
+ finally {
869
+ if (allocatedPort !== undefined)
870
+ reservedPorts.delete(allocatedPort);
871
+ }
663
872
  let browserContext;
664
873
  try {
665
874
  if (this.config.browser.isolated) {
@@ -682,6 +891,8 @@ class CdpLaunchContextFactory {
682
891
  // The desktop process is already running by now; failing to obtain a
683
892
  // context (say, an unreadable storage-state file) must not leave it and
684
893
  // the CDP connection behind with nobody holding a close() for them.
894
+ if (pinnedLaunch)
895
+ pinnedLaunch.closing = true;
685
896
  await browser.close().catch(logUnhandledError);
686
897
  childProcess.kill('SIGTERM');
687
898
  throw error;
@@ -689,11 +900,62 @@ class CdpLaunchContextFactory {
689
900
  return {
690
901
  browserContext,
691
902
  close: async () => {
903
+ if (pinnedLaunch)
904
+ pinnedLaunch.closing = true;
692
905
  await browser.close().catch(logUnhandledError);
693
906
  childProcess.kill('SIGTERM');
694
907
  }
695
908
  };
696
909
  }
910
+ /**
911
+ * Waits out a pinned-port holder whose teardown has already begun (kill
912
+ * sent, exit pending), bounded by the configured startup timeout — the
913
+ * same budget a launch gets — so a plain sequential close-then-relaunch
914
+ * does not flake on the child's asynchronous exit. A holder that is NOT
915
+ * closing is genuine concurrency; _assertPinnedPortFree rejects it.
916
+ */
917
+ async _waitForClosingPinnedPortHolder(cdpLaunch) {
918
+ const previous = this._pinnedPortLaunch;
919
+ if (!previous?.closing)
920
+ return;
921
+ let timer;
922
+ try {
923
+ await Promise.race([
924
+ previous.exited,
925
+ new Promise(resolve => {
926
+ timer = setTimeout(resolve, cdpLaunch.startupTimeoutMs ?? 30000);
927
+ timer.unref?.();
928
+ }),
929
+ ]);
930
+ }
931
+ finally {
932
+ clearTimeout(timer);
933
+ }
934
+ }
935
+ /** Rejects a pinned-port launch while another live context's child still
936
+ * holds the port (see _pinnedPortLaunch). Synchronous, so callers can bind
937
+ * the check to the spawn without an interleaving window. */
938
+ _assertPinnedPortFree(cdpLaunch) {
939
+ if (this._pinnedPortLaunch)
940
+ throw new Error(`The pinned --cdp-launch-port ${cdpLaunch.port} already serves a launched application from another live browser context, and a second launch on the same port would silently attach to that application instead of its own. Close the other context first, or drop --cdp-launch-port so each context launches on its own free port.`);
941
+ }
942
+ /** Tracks the pinned-port child until its process is gone; identity-guarded
943
+ * so a stale exit can never untrack a successor's launch. The 'error'
944
+ * listener covers a spawn that never produces an 'exit' (e.g. ENOENT). */
945
+ _trackPinnedPortChild(childProcess) {
946
+ const launch = { closing: false, exited: undefined };
947
+ launch.exited = new Promise(resolve => {
948
+ const done = () => {
949
+ if (this._pinnedPortLaunch === launch)
950
+ this._pinnedPortLaunch = undefined;
951
+ resolve();
952
+ };
953
+ childProcess.once('exit', done);
954
+ childProcess.once('error', done);
955
+ });
956
+ this._pinnedPortLaunch = launch;
957
+ return launch;
958
+ }
697
959
  async _waitForBrowser(endpoint, clientInfo, childProcess, startupTimeoutMs) {
698
960
  const deadline = Date.now() + startupTimeoutMs;
699
961
  const connectOptions = {
@@ -731,11 +993,60 @@ export class PersistentContextFactory {
731
993
  name = 'persistent';
732
994
  description = 'Create a new persistent browser context';
733
995
  _userDataDirs = new Set();
996
+ // Set while a live context (or one still launching) holds the stable
997
+ // `mcp-<browser>-<workspace>` profile. The profile can back only one running browser at
998
+ // a time (Chromium's ProcessSingleton lock), and every stateful backend's
999
+ // default context resolves to it — one such context under stdio, but each
1000
+ // concurrent Mcp-Session-Id HTTP client brings its own backend, and the
1001
+ // second used to spin on the lock and fail with "Browser is already in
1002
+ // use". The stable profile goes to the FIRST claimant; genuinely
1003
+ // concurrent claimants fall back to a disposable profile (their audit
1004
+ // runs, without the stable profile's sign-in state), and the claim is
1005
+ // released when the holder's context closes so the next default context —
1006
+ // and the profile's persisted state — line up again. Checked-and-set
1007
+ // synchronously, so concurrent createContext() calls cannot both claim.
1008
+ //
1009
+ // A holder that has BEGUN closing (`closing`, set via the handle's
1010
+ // closeStarting notice) is a release in progress, not genuine concurrency:
1011
+ // its async shutdown — dominated by the Context's bounded pending-download
1012
+ // drain — can outlast a --connect-tool/--vscode provider switch-away, and a
1013
+ // claimant arriving in that window (the user switching back) used to be
1014
+ // silently demoted to a disposable profile, losing the stable profile's
1015
+ // sign-in state. Such a claimant now waits on `released` (bounded: the
1016
+ // drain is capped at 30s and the browser shutdown bounds the rest) and
1017
+ // then claims the stable profile itself.
1018
+ _stableProfileClaim;
1019
+ // Claims the stable profile for one context. Synchronous, so a concurrent
1020
+ // createContext() cannot interleave between check and set.
1021
+ _claimStableProfile() {
1022
+ let resolveReleased;
1023
+ const released = new Promise(resolve => { resolveReleased = resolve; });
1024
+ const claim = {
1025
+ closing: false,
1026
+ released,
1027
+ release: () => {
1028
+ if (this._stableProfileClaim === claim)
1029
+ this._stableProfileClaim = undefined;
1030
+ resolveReleased();
1031
+ },
1032
+ };
1033
+ this._stableProfileClaim = claim;
1034
+ return claim;
1035
+ }
734
1036
  constructor(config) {
735
1037
  this.config = config;
736
1038
  }
737
- async createContext(clientInfo) {
738
- await injectCdpPort(this.config.browser);
1039
+ // A user-supplied profile directory can back only one running browser at a
1040
+ // time (Chromium's ProcessSingleton lock), and minting disposable profiles
1041
+ // behind the user's back would silently drop the sign-in state they asked
1042
+ // for — so explicit sessions are refused in that configuration. Without
1043
+ // --user-data-dir, sessions run in their own disposable profiles below.
1044
+ get sessionsUnsupportedReason() {
1045
+ if (this.config.browser.userDataDir)
1046
+ return 'the configured --user-data-dir profile can back only one running browser at a time. Drop --user-data-dir (extra sessions run in their own disposable profiles) or use --isolated.';
1047
+ return undefined;
1048
+ }
1049
+ async createContext(clientInfo, _abortSignal, _toolName, options) {
739
1050
  testDebug('create browser context (persistent)');
740
1051
  // launchPersistentContext() accepts a storageState option without applying
741
1052
  // it (verified against 1.61.1) — the profile is normally the state — so it
@@ -757,22 +1068,103 @@ export class PersistentContextFactory {
757
1068
  // unwritable output directory), and nothing after the directory is created
758
1069
  // may throw outside the cleanup scope below, or failed starts would leave
759
1070
  // stray profiles behind.
760
- const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
761
- const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath, storageState ? `-storage-state-${createGuid()}` : '');
762
- this._userDataDirs.add(userDataDir);
763
- testDebug('lock user data dir', userDataDir);
1071
+ const tracesDir = await startTraceServer(this.config);
1072
+ // Per-launch and reserved until this launch binds it: two explicit
1073
+ // sessions launching concurrently interleave their awaits here, and a
1074
+ // port written into the shared config would be overwritten by the
1075
+ // sibling before launchPersistentContext() reads it — both browsers
1076
+ // then race for one port and one fails to bind.
1077
+ const { cdpPortOptions, releaseCdpPort } = await allocateCdpPort(this.config.browser);
1078
+ // An explicitly opened browser session gets its own disposable profile for
1079
+ // the same reason a storage-state context does: the stable profile can back
1080
+ // only one running browser, so a second session sharing it would spin on
1081
+ // the ProcessSingleton lock and fail with "Browser is already in use". The
1082
+ // DEFAULT (no-handle) context keeps the stable `mcp-<browser>-<workspace>` profile, so
1083
+ // its sign-in state still survives restarts.
1084
+ let profileSuffix = storageState
1085
+ ? `-storage-state-${createGuid()}`
1086
+ : options?.browserSession
1087
+ ? `-session-${createGuid()}`
1088
+ : '';
1089
+ let claim;
1090
+ let userDataDir;
1091
+ let disposableProfile = false;
764
1092
  const browserType = playwright[this.config.browser.browserName];
1093
+ // The cleanup scope opens right after the port reservation: the
1094
+ // profile-directory mkdir below can reject too (e.g. a transient volume
1095
+ // failure), and a failure between the claim and the launch loop used to
1096
+ // escape the cleanup — the claim was never reset (every later default
1097
+ // context misclassified as concurrent and demoted to a disposable
1098
+ // profile) and the reserved CDP port was never released.
765
1099
  try {
1100
+ // A default (no-suffix) context claims the stable profile — unless a
1101
+ // sibling already holds it (see _stableProfileClaim): then it runs in
1102
+ // a disposable profile instead of failing the launch. A user-supplied
1103
+ // --user-data-dir is exempt: silently substituting a disposable profile
1104
+ // would drop the sign-in state the user explicitly asked for, so that
1105
+ // configuration keeps the launch-time contention error.
1106
+ if (!profileSuffix && !this.config.browser.userDataDir) {
1107
+ // A holder that has begun closing is a release in progress, not
1108
+ // genuine concurrency: wait for the release (bounded by the holder's
1109
+ // capped download drain and browser shutdown) instead of silently
1110
+ // demoting this context to a disposable profile. Re-checked after
1111
+ // the wait — another claimant may have won the freed claim.
1112
+ const holder = this._stableProfileClaim;
1113
+ if (holder?.closing) {
1114
+ testDebug('stable persistent profile holder is closing; waiting for its release');
1115
+ await holder.released;
1116
+ }
1117
+ if (this._stableProfileClaim) {
1118
+ profileSuffix = `-concurrent-${createGuid()}`;
1119
+ testDebug('stable persistent profile is in use by a concurrent context; falling back to a disposable profile');
1120
+ }
1121
+ else {
1122
+ claim = this._claimStableProfile();
1123
+ }
1124
+ }
1125
+ userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(profileSuffix);
1126
+ // Guarded on the config profile too: sessionsUnsupportedReason keeps
1127
+ // registry sessions out of a user-supplied --user-data-dir, so a suffix
1128
+ // here always means the guid-fresh managed directory above — but a direct
1129
+ // caller combining both must still never see the user's profile deleted.
1130
+ disposableProfile = !!profileSuffix && !this.config.browser.userDataDir;
1131
+ this._userDataDirs.add(userDataDir);
1132
+ testDebug('lock user data dir', userDataDir);
766
1133
  for (let i = 0; i < 5; i++) {
767
1134
  try {
768
1135
  const browserContext = await browserType.launchPersistentContext(userDataDir, {
769
1136
  tracesDir,
770
1137
  ...this.config.browser.launchOptions,
1138
+ ...cdpPortOptions,
771
1139
  ...contextOptions,
772
1140
  handleSIGINT: false,
773
1141
  handleSIGTERM: false,
774
1142
  });
775
- return await this._applyStorageState(browserContext, storageState, userDataDir);
1143
+ const result = await this._applyStorageState(browserContext, storageState, userDataDir, disposableProfile);
1144
+ if (!claim)
1145
+ return result;
1146
+ const heldClaim = claim;
1147
+ return {
1148
+ browserContext: result.browserContext,
1149
+ // The owning Context's advance notice that close() will follow
1150
+ // once its async cleanup (the bounded download drain) finishes:
1151
+ // from here on a new default claimant waits for the release
1152
+ // instead of treating this holder as genuine concurrency.
1153
+ closeStarting: () => { heldClaim.closing = true; },
1154
+ close: async () => {
1155
+ try {
1156
+ await result.close();
1157
+ }
1158
+ finally {
1159
+ // Released only after the browser has shut down, so the next
1160
+ // claimant's launch meets a freed ProcessSingleton lock (the
1161
+ // launch retry loop covers the OS-level shutdown tail).
1162
+ // release() is idempotent and identity-guarded, so a repeated
1163
+ // close() can never free a claim a successor context holds.
1164
+ heldClaim.release();
1165
+ }
1166
+ },
1167
+ };
776
1168
  }
777
1169
  catch (error) {
778
1170
  if (error instanceof StorageStateError)
@@ -790,20 +1182,29 @@ export class PersistentContextFactory {
790
1182
  throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
791
1183
  }
792
1184
  catch (error) {
1185
+ // A claim that never produced a context must not pin the stable profile
1186
+ // forever — the next default context would needlessly fall back to a
1187
+ // disposable profile with the stable one sitting free.
1188
+ claim?.release();
793
1189
  // The disposable profile belongs to this context alone, so a launch that
794
1190
  // never produced a context must not leave it behind — repeated failed
795
1191
  // starts would otherwise pile one stray directory into the registry each.
796
1192
  // (Already removed on the StorageStateError path; rm is idempotent.)
797
- if (storageState) {
1193
+ if (disposableProfile && userDataDir !== undefined) {
798
1194
  await fs.promises.rm(userDataDir, { recursive: true, force: true }).catch(() => { });
799
1195
  this._userDataDirs.delete(userDataDir);
800
1196
  }
801
1197
  throw error;
802
1198
  }
1199
+ finally {
1200
+ // Bound by the launched browser on success, free for reuse on failure —
1201
+ // either way the reservation has served its purpose.
1202
+ releaseCdpPort();
1203
+ }
803
1204
  }
804
1205
  // Separate from the launch retry loop: its `catch` retries on messages a
805
1206
  // malformed storage-state file could coincidentally match (`Invalid URL`).
806
- async _applyStorageState(browserContext, storageState, userDataDir) {
1207
+ async _applyStorageState(browserContext, storageState, userDataDir, disposableProfile) {
807
1208
  if (storageState) {
808
1209
  try {
809
1210
  // Startup pages can keep persisting their anonymous identity while the
@@ -817,41 +1218,61 @@ export class PersistentContextFactory {
817
1218
  catch (error) {
818
1219
  // Nobody holds a close() for this context yet, so a bad storage-state
819
1220
  // file must not leave the launched browser running.
820
- await this._closeBrowserContext(browserContext, userDataDir, true);
1221
+ await this._closeBrowserContext(browserContext, userDataDir, disposableProfile);
821
1222
  throw new StorageStateError(error instanceof Error ? error.message : String(error));
822
1223
  }
823
1224
  }
824
- const close = () => this._closeBrowserContext(browserContext, userDataDir, !!storageState);
1225
+ const close = () => this._closeBrowserContext(browserContext, userDataDir, disposableProfile);
825
1226
  return { browserContext, close };
826
1227
  }
827
1228
  async _closeBrowserContext(browserContext, userDataDir, disposeUserDataDir = false) {
828
1229
  testDebug('close browser context (persistent)');
829
1230
  testDebug('release user data dir', userDataDir);
830
1231
  await browserContext.close().catch(() => { });
831
- // A storage-state profile is unique to this context and holds nothing worth
832
- // keeping — the state file is the durable copy — so it is removed rather
833
- // than left to pile up next to the regular persistent profile.
1232
+ // A storage-state or browser-session profile is unique to this context and
1233
+ // holds nothing worth keeping — the state file (or the default profile) is
1234
+ // the durable copy — so it is removed rather than left to pile up next to
1235
+ // the regular persistent profile.
834
1236
  if (disposeUserDataDir)
835
1237
  await fs.promises.rm(userDataDir, { recursive: true, force: true }).catch(() => { });
836
1238
  this._userDataDirs.delete(userDataDir);
837
1239
  testDebug('close browser context complete (persistent)');
838
1240
  }
839
- // The suffix keeps disposable storage-state profiles apart from the regular
840
- // persistent profile (and, carrying a per-context guid, from each other), so
841
- // removing one can never destroy an interactive session or a sibling's.
842
- async _createUserDataDir(rootPath, suffix) {
1241
+ // The suffix keeps disposable storage-state and browser-session profiles
1242
+ // apart from the regular persistent profile (and, carrying a per-context
1243
+ // guid, from each other), so removing one can never destroy an interactive
1244
+ // session or a sibling's.
1245
+ //
1246
+ // The workspace token keeps different servers' stable profiles apart. MCP
1247
+ // clients typically launch one stdio server per workspace, cwd'd into it, so
1248
+ // hashing process.cwd() gives each workspace its own deterministic profile:
1249
+ // sign-in state survives restarts of the same server (same cwd, same hash),
1250
+ // while servers for other workspaces neither contend for this profile's
1251
+ // ProcessSingleton lock nor inherit its cookies and storage. (This restores
1252
+ // the separation the deprecated MCP Roots hash used to provide — keyed on
1253
+ // the server's own launch directory instead of a client-reported root, so it
1254
+ // covers every client rather than only those that exposed roots.)
1255
+ async _createUserDataDir(suffix) {
843
1256
  const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
844
1257
  const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
845
- // Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
846
- const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
847
- const result = path.join(dir, `mcp-${browserToken}${rootPathToken}${suffix}`);
1258
+ const workspaceToken = `-${createHash(process.cwd())}`;
1259
+ const result = path.join(dir, `mcp-${browserToken}${workspaceToken}${suffix}`);
848
1260
  await fs.promises.mkdir(result, { recursive: true });
849
1261
  return result;
850
1262
  }
851
1263
  }
852
- async function injectCdpPort(browserConfig) {
853
- if (browserConfig.browserName === 'chromium')
854
- browserConfig.launchOptions.cdpPort = await findFreePort();
1264
+ /**
1265
+ * Allocates the CDP port for a Chromium launch, reserved until that launch has
1266
+ * bound it (or failed). The port travels in per-launch options instead of
1267
+ * being written into the shared config: concurrent launches from one factory
1268
+ * (e.g. two explicit persistent sessions) would otherwise overwrite each
1269
+ * other's `cdpPort` between allocation and launch and race for a single port.
1270
+ */
1271
+ async function allocateCdpPort(browserConfig) {
1272
+ if (browserConfig.browserName !== 'chromium')
1273
+ return { cdpPortOptions: {}, releaseCdpPort: () => { } };
1274
+ const cdpPort = await findFreePort({ reserve: true });
1275
+ return { cdpPortOptions: { cdpPort }, releaseCdpPort: () => reservedPorts.delete(cdpPort) };
855
1276
  }
856
1277
  /**
857
1278
  * Builds the HTTP headers sent with a `connectOverCDP` request: the client
@@ -866,15 +1287,33 @@ function cdpConnectHeaders(clientInfo, browserConfig) {
866
1287
  Object.assign(headers, browserConfig.cdpHeaders);
867
1288
  return Object.keys(headers).length ? headers : undefined;
868
1289
  }
869
- async function findFreePort() {
870
- return new Promise((resolve, reject) => {
871
- const server = net.createServer();
872
- server.listen(0, () => {
873
- const { port } = server.address();
874
- server.close(() => resolve(port));
1290
+ /**
1291
+ * Ports handed out by `findFreePort({ reserve: true })` whose intended owner
1292
+ * has not bound them yet. The probe socket below is closed before the caller
1293
+ * uses the port, so without this set two concurrent allocations in this
1294
+ * process could be handed the same port. Module-level because every factory
1295
+ * in the process draws from the one OS port pool.
1296
+ */
1297
+ const reservedPorts = new Set();
1298
+ async function findFreePort(options) {
1299
+ for (;;) {
1300
+ const port = await new Promise((resolve, reject) => {
1301
+ const server = net.createServer();
1302
+ server.listen(0, () => {
1303
+ const { port } = server.address();
1304
+ server.close(() => resolve(port));
1305
+ });
1306
+ server.on('error', reject);
875
1307
  });
876
- server.on('error', reject);
877
- });
1308
+ // Reserved ports are skipped for every caller — nothing may be pointed at
1309
+ // a port a launched child is still starting up on. The check-and-reserve
1310
+ // is synchronous, so concurrent allocations cannot interleave inside it.
1311
+ if (reservedPorts.has(port))
1312
+ continue;
1313
+ if (options?.reserve)
1314
+ reservedPorts.add(port);
1315
+ return port;
1316
+ }
878
1317
  }
879
1318
  /**
880
1319
  * Builds the user-facing "browser not installed" error from Playwright's raw
@@ -889,10 +1328,38 @@ function browserNotInstalledError(error) {
889
1328
  const location = match ? `; expected executable at ${match[1].trim()}` : '';
890
1329
  return new Error(`Browser specified in your config is not installed${location}. Either install it (likely) or change the config.`);
891
1330
  }
892
- async function startTraceServer(config, rootPath) {
1331
+ // One trace-viewer server and traces directory per config i.e. per server
1332
+ // run, the same WeakMap pattern as resolveOutputDir. startTraceViewerServer()
1333
+ // binds a listening HTTP socket that nothing ever closes, so starting one per
1334
+ // browser launch (the persistent factory launches per context, so every
1335
+ // explicit-session open/close cycle) leaked a listener for the life of the
1336
+ // process. Sharing one tracesDir across launches is safe for the trace files:
1337
+ // each Context records under its own `trace-<guid>` name (see acquireTrace in
1338
+ // context.ts), exactly as --isolated mode has always shared its per-browser
1339
+ // tracesDir.
1340
+ const traceServers = new WeakMap();
1341
+ async function startTraceServer(config) {
893
1342
  if (!config.saveTrace)
894
1343
  return undefined;
895
- const tracesDir = await outputFile(config, rootPath, `traces-${Date.now()}`);
1344
+ let started = traceServers.get(config);
1345
+ if (!started) {
1346
+ started = doStartTraceServer(config);
1347
+ traceServers.set(config, started);
1348
+ // A failed start (e.g. an unwritable output directory) is not memoized:
1349
+ // the next launch retries instead of replaying the rejection for the
1350
+ // process lifetime. Guarded by identity — a retry may already have
1351
+ // stored a fresh in-flight promise by the time this handler runs.
1352
+ started.catch(() => {
1353
+ if (traceServers.get(config) === started)
1354
+ traceServers.delete(config);
1355
+ });
1356
+ }
1357
+ return started;
1358
+ }
1359
+ async function doStartTraceServer(config) {
1360
+ // The random suffix keeps two configs resolving in the same millisecond
1361
+ // from sharing a trace folder. Nothing parses the folder name back.
1362
+ const tracesDir = await outputFile(config, `traces-${Date.now()}-${createShortGuid()}`);
896
1363
  const server = await startTraceViewerServer();
897
1364
  const urlPrefix = server.urlPrefix('human-readable');
898
1365
  const url = urlPrefix + '/trace/index.html?trace=' + tracesDir + '/trace.json';