@geometra/mcp 1.37.0 → 1.38.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.
@@ -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: true, summary: result.summary }
3408
- : { index, kind: field.kind, ok: true, ...result.compact });
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
- const reusableProxy = findReusableProxy(options);
725
- if (reusableProxy) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.37.0",
3
+ "version": "1.38.0",
4
4
  "description": "MCP server for Geometra — interact with running Geometra apps via the geometry protocol, no browser needed",
5
5
  "license": "MIT",
6
6
  "type": "module",