@geometra/mcp 1.37.0 → 1.39.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/dist/__tests__/session-isolation.test.js +34 -0
- package/dist/server.js +15 -2
- package/dist/session.js +39 -2
- package/package.json +1 -1
|
@@ -156,4 +156,38 @@ describe('connectThroughProxy({ isolated: true })', () => {
|
|
|
156
156
|
expect(markerB).toBe('marker-from-/page-a');
|
|
157
157
|
disconnect({ sessionId: sessionB.id, closeProxy: true });
|
|
158
158
|
}, 30_000);
|
|
159
|
+
it('serializes concurrent default connects to a pooled proxy onto a single session', async () => {
|
|
160
|
+
// Regression for the per-proxy attach race. Before the attachLock fix,
|
|
161
|
+
// two concurrent connectThroughProxy calls that both picked the same
|
|
162
|
+
// pooled proxy entry could both pass attachToReusableProxy's
|
|
163
|
+
// "reusedExistingSession" check (because neither's session was in
|
|
164
|
+
// activeSessions yet — connect() runs first) and then both call
|
|
165
|
+
// connect(proxy.wsUrl), creating two distinct WebSocket sessions
|
|
166
|
+
// bound to the same Chromium. Two agents would silently mutate the
|
|
167
|
+
// same DOM. With the lock, the second connect waits for the first,
|
|
168
|
+
// re-picks via findReusableProxy, and takes the reusedExistingSession
|
|
169
|
+
// branch — both calls return the same Session object.
|
|
170
|
+
//
|
|
171
|
+
// Step 1: warm the pool with a single connect+disconnect so the next
|
|
172
|
+
// connects find an existing entry. Cold-start parallel connects
|
|
173
|
+
// legitimately create separate browsers (no shared state to leak), so
|
|
174
|
+
// the race only matters when the pool is already populated.
|
|
175
|
+
const warmup = await connectThroughProxy({
|
|
176
|
+
pageUrl: `${baseUrl}/page-a`,
|
|
177
|
+
headless: true,
|
|
178
|
+
});
|
|
179
|
+
disconnect({ sessionId: warmup.id, closeProxy: false });
|
|
180
|
+
// Step 2: fire two concurrent connects. Without the lock, both would
|
|
181
|
+
// call connect(proxy.wsUrl) and create distinct sessions bound to the
|
|
182
|
+
// same browser. With the lock, the second waits for the first to bind,
|
|
183
|
+
// then sees the bound session and reuses it.
|
|
184
|
+
const [sessionA, sessionB] = await Promise.all([
|
|
185
|
+
connectThroughProxy({ pageUrl: `${baseUrl}/page-a`, headless: true }),
|
|
186
|
+
connectThroughProxy({ pageUrl: `${baseUrl}/page-a`, headless: true }),
|
|
187
|
+
]);
|
|
188
|
+
// Both connects must converge on the same underlying session. If they
|
|
189
|
+
// don't, the race re-emerged: two agents would race in the same browser.
|
|
190
|
+
expect(sessionA.id).toBe(sessionB.id);
|
|
191
|
+
disconnect({ sessionId: sessionA.id, closeProxy: true });
|
|
192
|
+
}, 30_000);
|
|
159
193
|
});
|
package/dist/server.js
CHANGED
|
@@ -3403,9 +3403,22 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3403
3403
|
for (let index = 0; index < resolvedFields.fields.length; index++) {
|
|
3404
3404
|
const field = resolvedFields.fields[index];
|
|
3405
3405
|
const result = await executeFillField(session, field, detail);
|
|
3406
|
+
// A step is only honestly "ok" when the proxy's underlying action
|
|
3407
|
+
// both (a) did not throw and (b) reported a tree-updating ack —
|
|
3408
|
+
// `wait: 'timed_out'` means the action was sent but the proxy never
|
|
3409
|
+
// confirmed a DOM change that matched the request, which is the
|
|
3410
|
+
// silent-failure mode that used to leak through as a false positive
|
|
3411
|
+
// (e.g. react-select listbox picks that revert on the next render).
|
|
3412
|
+
// Choice fields that land in this branch are almost always failed
|
|
3413
|
+
// commits, not slow commits, so we surface the failure to the caller
|
|
3414
|
+
// instead of pretending the field was set.
|
|
3415
|
+
const waitStatus = result.compact && typeof result.compact === 'object' && 'wait' in result.compact
|
|
3416
|
+
? result.compact.wait
|
|
3417
|
+
: undefined;
|
|
3418
|
+
const ok = waitStatus !== 'timed_out';
|
|
3406
3419
|
steps.push(detail === 'verbose'
|
|
3407
|
-
? { index, kind: field.kind, ok
|
|
3408
|
-
: { index, kind: field.kind, ok
|
|
3420
|
+
? { index, kind: field.kind, ok, summary: result.summary }
|
|
3421
|
+
: { index, kind: field.kind, ok, ...result.compact });
|
|
3409
3422
|
}
|
|
3410
3423
|
return {
|
|
3411
3424
|
summary: steps.map(step => String(step.summary ?? '')).filter(Boolean).join('\n'),
|
package/dist/session.js
CHANGED
|
@@ -460,6 +460,7 @@ async function startFreshProxySession(options) {
|
|
|
460
460
|
: options.awaitInitialFrame !== false
|
|
461
461
|
? undefined
|
|
462
462
|
: false;
|
|
463
|
+
let pendingEmbeddedRuntime;
|
|
463
464
|
try {
|
|
464
465
|
const proxyStartStartedAt = performance.now();
|
|
465
466
|
const { runtime, wsUrl } = await startEmbeddedGeometraProxy({
|
|
@@ -471,12 +472,16 @@ async function startFreshProxySession(options) {
|
|
|
471
472
|
slowMo: options.slowMo,
|
|
472
473
|
eagerInitialExtract,
|
|
473
474
|
});
|
|
475
|
+
pendingEmbeddedRuntime = runtime;
|
|
474
476
|
const proxyStartMs = performance.now() - proxyStartStartedAt;
|
|
475
477
|
const session = await connect(wsUrl, {
|
|
476
478
|
skipInitialResize: true,
|
|
477
479
|
closePreviousProxy: false,
|
|
478
480
|
awaitInitialFrame: options.awaitInitialFrame,
|
|
479
481
|
});
|
|
482
|
+
// Connect succeeded — the session now owns the runtime, so the
|
|
483
|
+
// catch-block cleanup below must not also close it.
|
|
484
|
+
pendingEmbeddedRuntime = undefined;
|
|
480
485
|
session.proxyRuntime = runtime;
|
|
481
486
|
session.proxyReusable = !options.isolated;
|
|
482
487
|
if (options.isolated) {
|
|
@@ -508,6 +513,16 @@ async function startFreshProxySession(options) {
|
|
|
508
513
|
return session;
|
|
509
514
|
}
|
|
510
515
|
catch (e) {
|
|
516
|
+
// If startEmbeddedGeometraProxy() returned but the subsequent connect()
|
|
517
|
+
// threw (e.g. WebSocket failure, first-frame timeout), the runtime
|
|
518
|
+
// still owns a launched Chromium that nobody is going to clean up.
|
|
519
|
+
// Without this, that Chromium leaks for the lifetime of the MCP server
|
|
520
|
+
// every time we fall through to the child-process fallback path.
|
|
521
|
+
if (pendingEmbeddedRuntime) {
|
|
522
|
+
const leaked = pendingEmbeddedRuntime;
|
|
523
|
+
pendingEmbeddedRuntime = undefined;
|
|
524
|
+
void leaked.close().catch(() => { });
|
|
525
|
+
}
|
|
511
526
|
const proxyStartStartedAt = performance.now();
|
|
512
527
|
const { child, wsUrl } = await spawnGeometraProxy({
|
|
513
528
|
pageUrl: options.pageUrl,
|
|
@@ -721,14 +736,36 @@ export async function connectThroughProxy(options) {
|
|
|
721
736
|
return await startFreshProxySession({ ...options, isolated: true });
|
|
722
737
|
}
|
|
723
738
|
let reuseFailure;
|
|
724
|
-
|
|
725
|
-
|
|
739
|
+
// Loop because if a candidate is currently being attached by another
|
|
740
|
+
// concurrent connectThroughProxy call we wait for it, then re-pick. The
|
|
741
|
+
// second pick may now find the same entry already bound to an active
|
|
742
|
+
// session — attachToReusableProxy's reusedExistingSession branch then
|
|
743
|
+
// returns that session instead of opening a second WebSocket. Bounded
|
|
744
|
+
// by the pool size to defend against pathological churn.
|
|
745
|
+
for (let attempt = 0; attempt < REUSABLE_PROXY_POOL_LIMIT + 1; attempt++) {
|
|
746
|
+
const reusableProxy = findReusableProxy(options);
|
|
747
|
+
if (!reusableProxy)
|
|
748
|
+
break;
|
|
749
|
+
if (reusableProxy.attachLock) {
|
|
750
|
+
try {
|
|
751
|
+
await reusableProxy.attachLock;
|
|
752
|
+
}
|
|
753
|
+
catch { /* lock holder failed; we'll re-pick */ }
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
let releaseLock = () => { };
|
|
757
|
+
reusableProxy.attachLock = new Promise(resolve => { releaseLock = resolve; });
|
|
726
758
|
try {
|
|
727
759
|
return await attachToReusableProxy(reusableProxy, options);
|
|
728
760
|
}
|
|
729
761
|
catch (err) {
|
|
730
762
|
reuseFailure = err;
|
|
731
763
|
closeReusableProxy(reusableProxy);
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
finally {
|
|
767
|
+
reusableProxy.attachLock = null;
|
|
768
|
+
releaseLock();
|
|
732
769
|
}
|
|
733
770
|
}
|
|
734
771
|
try {
|
package/package.json
CHANGED