mcp-accessibility-scanner 3.1.0 → 3.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.
- package/README.md +42 -13
- package/index.d.ts +1 -1
- package/lib/browserContextFactory.js +548 -81
- package/lib/browserContextFactory.js.map +1 -1
- package/lib/browserServerBackend.js +192 -21
- package/lib/browserServerBackend.js.map +1 -1
- package/lib/browserSessions.js +174 -0
- package/lib/browserSessions.js.map +1 -0
- package/lib/config.js +52 -9
- package/lib/config.js.map +1 -1
- package/lib/context.js +143 -10
- package/lib/context.js.map +1 -1
- package/lib/extension/browserModel.js +166 -13
- package/lib/extension/browserModel.js.map +1 -1
- package/lib/extension/cdpRelay.js +53 -2
- package/lib/extension/cdpRelay.js.map +1 -1
- package/lib/extension/extensionContextFactory.js +9 -1
- package/lib/extension/extensionContextFactory.js.map +1 -1
- package/lib/index.js +5 -1
- package/lib/index.js.map +1 -1
- package/lib/mcp/http.js +195 -45
- package/lib/mcp/http.js.map +1 -1
- package/lib/mcp/inProcessTransport.js.map +1 -1
- package/lib/mcp/manualPromise.js +1 -0
- package/lib/mcp/manualPromise.js.map +1 -1
- package/lib/mcp/proxyBackend.js +75 -18
- package/lib/mcp/proxyBackend.js.map +1 -1
- package/lib/mcp/server.js +70 -41
- package/lib/mcp/server.js.map +1 -1
- package/lib/mcp/sharedClientSlot.js +134 -0
- package/lib/mcp/sharedClientSlot.js.map +1 -0
- package/lib/mcp/tool.js +4 -0
- package/lib/mcp/tool.js.map +1 -1
- package/lib/mcp/toolListChanged.js +1 -0
- package/lib/mcp/toolListChanged.js.map +1 -1
- package/lib/program.js +70 -9
- package/lib/program.js.map +1 -1
- package/lib/response.js +8 -0
- package/lib/response.js.map +1 -1
- package/lib/sessionLog.js +36 -6
- package/lib/sessionLog.js.map +1 -1
- package/lib/tab.js +37 -8
- package/lib/tab.js.map +1 -1
- package/lib/tools/auditKeyboard.js +4 -6
- package/lib/tools/auditKeyboard.js.map +1 -1
- package/lib/tools/auditScreenReader.js +4 -4
- package/lib/tools/auditScreenReader.js.map +1 -1
- package/lib/tools/auditSite.js +1 -4
- package/lib/tools/auditSite.js.map +1 -1
- package/lib/tools/axe.js +1 -0
- package/lib/tools/axe.js.map +1 -1
- package/lib/tools/dialogs.js +4 -6
- package/lib/tools/dialogs.js.map +1 -1
- package/lib/tools/evaluate.js +2 -0
- package/lib/tools/evaluate.js.map +1 -1
- package/lib/tools/network.js +13 -102
- package/lib/tools/network.js.map +1 -1
- package/lib/tools/pdf.js +5 -2
- package/lib/tools/pdf.js.map +1 -1
- package/lib/tools/scanPageMatrix.js +1 -4
- package/lib/tools/scanPageMatrix.js.map +1 -1
- package/lib/tools/screenshot.js +6 -2
- package/lib/tools/screenshot.js.map +1 -1
- package/lib/tools/session.js +53 -0
- package/lib/tools/session.js.map +1 -0
- package/lib/tools/snapshot.js +1 -4
- package/lib/tools/snapshot.js.map +1 -1
- package/lib/tools.js +7 -0
- package/lib/tools.js.map +1 -1
- package/lib/utils/dataUrl.js +1 -0
- package/lib/utils/dataUrl.js.map +1 -1
- package/lib/utils/fileUtils.js +35 -14
- package/lib/utils/fileUtils.js.map +1 -1
- package/lib/utils/guid.js +8 -0
- package/lib/utils/guid.js.map +1 -1
- package/lib/utils/jsSource.js +1 -1
- package/lib/utils/jsSource.js.map +1 -1
- package/lib/vscode/browserContextFactory.js +4 -0
- package/lib/vscode/browserContextFactory.js.map +1 -1
- package/lib/vscode/host.js +174 -32
- package/lib/vscode/host.js.map +1 -1
- package/lib/vscode/main.js +2 -2
- package/lib/vscode/main.js.map +1 -1
- package/package.json +21 -20
- package/lib/mcp/mdb.js +0 -200
- package/lib/mcp/mdb.js.map +0 -1
|
@@ -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
|
|
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
|
-
|
|
455
|
-
|
|
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
|
|
487
|
+
if (this._browserPromise === promise)
|
|
488
|
+
this._browserPromise = undefined;
|
|
458
489
|
});
|
|
459
490
|
}).catch(() => {
|
|
460
|
-
this._browserPromise
|
|
491
|
+
if (this._browserPromise === promise)
|
|
492
|
+
this._browserPromise = undefined;
|
|
461
493
|
});
|
|
462
|
-
return
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
592
|
+
const { cdpPortOptions, releaseCdpPort } = await allocateCdpPort(this.config.browser);
|
|
488
593
|
const browserType = playwright[this.config.browser.browserName];
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
738
|
-
|
|
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
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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,
|
|
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
|
|
832
|
-
// keeping — the state file
|
|
833
|
-
// than left to pile up next to
|
|
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
|
|
840
|
-
// persistent profile (and, carrying a per-context
|
|
841
|
-
// removing one can never destroy an interactive
|
|
842
|
-
|
|
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
|
-
|
|
846
|
-
const
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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';
|