From 1d49e47b92a030acacd9126e7364696fdfffb4b8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 29 Aug 2026 06:36:31 +0000 Subject: [PATCH 1/8] fix(workbench): stop in-place writes to watched files in e2e suites An in-place writeFile to a file the dev compiler is watching is truncate-then-append: the watcher can compile off the truncation event, read incomplete content, and drop the append event inside the same mtime tick, so the final content never activates. PR #31 root-caused this for the runtime-playground HMR suite; this applies the same staged-rename pattern (temp file in the unwatched project parent, then rename) to the remaining e2e suites through a shared tests/support/watched-files.ts helper: overview.e2e (7 watched-write sites) and packed-release.e2e (4). mcp-app-real.e2e had no watched writes; its observed Verify flake (PR #29 CI attempt 2, teardown-ack poll timing out after 60s) was a different unrecoverable race: the runtime App bridge waited only a fixed 1s for the ui/resource-teardown acknowledgement before revoking the binding and destroying the frame, so an ack that missed the window on a contended two-core runner could never be delivered. The budget is now 10s (a healthy app acks in milliseconds; the budget only bounds a hung app), with a test seam so the bounded-teardown unit proof stays fast. --- .../workbench/src/mcp/runtime-app-bridge.ts | 18 +++++++++++++++--- packages/workbench/tests/overview.e2e.test.ts | 19 ++++++++++--------- .../tests/packed-release.e2e.test.ts | 9 +++++---- .../tests/runtime-app-bridge.test.ts | 7 ++++++- .../workbench/tests/support/watched-files.ts | 18 ++++++++++++++++++ 5 files changed, 54 insertions(+), 17 deletions(-) create mode 100644 packages/workbench/tests/support/watched-files.ts diff --git a/packages/workbench/src/mcp/runtime-app-bridge.ts b/packages/workbench/src/mcp/runtime-app-bridge.ts index 609192b49..2e04584e6 100644 --- a/packages/workbench/src/mcp/runtime-app-bridge.ts +++ b/packages/workbench/src/mcp/runtime-app-bridge.ts @@ -56,6 +56,8 @@ export interface RuntimeAppBridgeOptions { readonly preview: McpAppPreviewSnapshot; readonly requestConsent: (challenge: McpAppConsentChallenge, signal?: AbortSignal) => Promise<'allow-once' | 'deny'>; readonly simulationFeatures: McpAppSimulationFeatures; + /** Test seam for the graceful `ui/resource-teardown` ack budget. Production always uses the module default. */ + readonly teardownAckTimeoutMs?: number; } interface BrowserMessageEvent { @@ -117,7 +119,16 @@ const maximumInboundMessageBytes = runtimeAppMessageLimits.appToHostBytes; const maximumOutboundMessageBytes = runtimeAppMessageLimits.hostToAppBytes; const maximumQueuedMessages = 32; const maximumFailures = 3; -const gracefulTeardownTimeoutMs = 1_000; +/** + * The ack budget for `ui/resource-teardown` before the host proceeds to + * revoke the binding and unmount the frame. Missing this window is + * unrecoverable: the frame is destroyed, so a late acknowledgement can never + * be delivered. The round trip crosses two postMessage hops and the + * sandboxed app's event loop; a healthy app answers in milliseconds, but a + * contended two-core CI runner was observed missing a 1s window (PR #29 + * Verify), so the budget only bounds a genuinely hung app. + */ +const gracefulTeardownTimeoutMs = 10_000; const aggregateFailure = (message: string, reasons: readonly unknown[]): Error => new AggregateError(reasons, message); @@ -548,11 +559,12 @@ export const createRuntimeAppBridgeFactory = (options: RuntimeAppBridgeOptions): }) as unknown as RuntimeAppBridge; rawBridgeClose = bridge.close.bind(bridge); const rawTeardownResource = bridge.teardownResource.bind(bridge); + const teardownAckTimeoutMs = options.teardownAckTimeoutMs ?? gracefulTeardownTimeoutMs; Object.defineProperty(bridge, 'teardownResource', { configurable: false, value: (params: Readonly>): Promise>> => rawTeardownResource(params, { - maxTotalTimeout: gracefulTeardownTimeoutMs, - timeout: gracefulTeardownTimeoutMs, + maxTotalTimeout: teardownAckTimeoutMs, + timeout: teardownAckTimeoutMs, }), writable: false, }); diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index 0217c4454..d726744ca 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -19,6 +19,7 @@ import { startDevServer } from '../../agent-bundle/src/dev/workbench-server.ts'; import { createProjectFixture, removeProjectFixture } from '../../agent-bundle/tests/helpers/project-fixture.ts'; import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; import { startRuntimePlaygroundFixture } from './helpers/runtime-playground-fixture.ts'; +import { replaceWatchedSource } from './support/watched-files.ts'; import { buildWorkbench } from './support/workbench-e2e.ts'; const workspaceRoot = process.cwd(); @@ -356,8 +357,8 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime const editedStyles = `${styles}\n.timeline__header [data-testid="runtime-hmr-marker"] { color: rgb(1, 2, 3); }\n`; expect(editedSource).not.toBe(source); await Promise.all([ - writeFile(fixture.widgetAppSource, editedSource), - writeFile(fixture.appStyles, editedStyles), + replaceWatchedSource(fixture.root, fixture.widgetAppSource, editedSource), + replaceWatchedSource(fixture.root, fixture.appStyles, editedStyles), ]); await expect.poll(() => runtimePreviewHmrMessages.some((message) => message === JSON.stringify({ type: 'full-reload' })), { timeout: 15_000 }) .toBe(true); @@ -420,7 +421,7 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime const finiteConfigMarker = `config-reconcile-finite-${Math.random().toString(36).slice(2)}`; const finiteConfig = sourceConfig.replace(' codex: {},', ` codex: { configReconcileMarker: '${finiteConfigMarker}' },`); expect(finiteConfig).not.toBe(sourceConfig); - await writeFile(fixture.configSource, finiteConfig); + await replaceWatchedSource(fixture.root, fixture.configSource, finiteConfig); await expect.poll(async () => { const source = await readProjectSource(); return source.state === 'ready' && source.revision !== sourceRevision ? source.revision : undefined; @@ -435,7 +436,7 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime 'configReconcileMarker: Number.NaN', ); expect(invalidConfig).not.toBe(finiteConfig); - await writeFile(fixture.configSource, invalidConfig); + await replaceWatchedSource(fixture.root, fixture.configSource, invalidConfig); await expect.poll(async () => { const source = await readProjectSource(); const diagnostic = source.diagnostics.find((candidate) => candidate.code === 'AB4500'); @@ -515,7 +516,7 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime 'configReconcileMarker: Number.NaN', `configReconcileMarker: '${repairedConfigMarker}'`, ); - await writeFile(fixture.configSource, repairedConfig); + await replaceWatchedSource(fixture.root, fixture.configSource, repairedConfig); await expect.poll(async () => { const source = await readProjectSource(); return source.state === 'ready' && source.revision !== finiteSourceRevision ? source.revision : undefined; @@ -870,7 +871,7 @@ e2e('restarts the real Runtime MCP App session when definition or transport auth ); expect(changedDefinition).not.toBe(definitionSource); const definitionEventStart = runtimeEvents.length; - await writeFile(fixture.definitionSource, changedDefinition); + await replaceWatchedSource(fixture.root, fixture.definitionSource, changedDefinition); await expect.poll(() => eventsSince(definitionEventStart, 'runtime.mcp.restarting', initial), { timeout: 15_000 }).not.toEqual([]); await expect.poll(() => eventsSince(definitionEventStart, 'runtime.mcp.ready', initial), { timeout: 15_000 }).not.toEqual([]); expect(runtimeEvents.findIndex((event, index) => index >= definitionEventStart && event.payload.type === 'runtime.mcp.restarting')).toBeLessThan( @@ -930,7 +931,7 @@ e2e('restarts the real Runtime MCP App session when definition or transport auth fixture.disconnectProjectEventStream(); await expect.poll(() => fixture.eventHubState.subscriptionCount, { timeout: 15_000 }) .toBe(serverOwnedProjectSubscriptions); - await writeFile(fixture.configSource, changedTransport); + await replaceWatchedSource(fixture.root, fixture.configSource, changedTransport); const definitionOperationPath = `/api/runtime/apps/${encodeURIComponent(definitionRun.id)}/operations`; await expect.poll(async () => { const response = await fetch(new URL(definitionOperationPath, fixture.url), { @@ -1224,7 +1225,7 @@ e2e('opens one real epoch MCP session and keeps its playground operations respon const sourceConfig = await readFile(configPath, 'utf8'); const changedConfig = sourceConfig.replace(initialConfigValue, changedConfigValue); expect(changedConfig).not.toBe(sourceConfig); - await writeFile(configPath, changedConfig); + await replaceWatchedSource(project.root, configPath, changedConfig); await expect.poll(() => { const next = server!.status().artifact; return next.state === 'active' && next.activeEpoch.id !== epochId && next.activeEpoch.modelDigest !== modelDigest @@ -1492,7 +1493,7 @@ e2e('retains the Overview and marks the foreground connection unavailable after }); }); - await writeFile(join(project.skillDir, 'SKILL.md'), `${project.skillMarkdown}\n\nThe source refresh failure fixture changed.\n`); + await replaceWatchedSource(project.root, join(project.skillDir, 'SKILL.md'), `${project.skillMarkdown}\n\nThe source refresh failure fixture changed.\n`); await expect.poll(() => failedStatusRequests, { timeout: browserTimeout }).toBe(1); await expect(page.getByRole('status')).toContainText('Foreground server unavailable', { timeout: browserTimeout }); diff --git a/packages/workbench/tests/packed-release.e2e.test.ts b/packages/workbench/tests/packed-release.e2e.test.ts index 0b0a07eed..ccc8e8550 100644 --- a/packages/workbench/tests/packed-release.e2e.test.ts +++ b/packages/workbench/tests/packed-release.e2e.test.ts @@ -31,6 +31,7 @@ import { workspaceRoot, writeFakeClaude, } from './support/packed-release-harness.ts'; +import { replaceWatchedSource } from './support/watched-files.ts'; import { workbenchUrl } from './support/workbench-e2e.ts'; const fixtureRoot = join(workspaceRoot, 'fixtures', 'integration', 'packed-release'); @@ -558,7 +559,7 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * phase = 'good edit rebuild B'; const epochBMarker = 'Epoch B changed the packed review guidance.'; - await writeFile(skillSource, `${originalSkill}\n\n${epochBMarker}\n`); + await replaceWatchedSource(project, skillSource, `${originalSkill}\n\n${epochBMarker}\n`); await page.getByRole('link', { name: 'Overview', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Bundle dashboard' })).toBeVisible({ timeout: browserTimeout }); await rebuildFromOverview('epoch B'); @@ -611,7 +612,7 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * await expectGeneratedSkill('last-good epoch B', lastGoodEpochB, epochBMarker); const invalidConfig = originalConfig.replace('ui://packed-release/dashboard.html', 'https://packed-release.example/dashboard.html'); if (invalidConfig === originalConfig) throw new Error('The packed fixture did not contain the resource URI used for the invalid rebuild.'); - await writeFile(configSource, invalidConfig); + await replaceWatchedSource(project, configSource, invalidConfig); await page.getByRole('link', { name: 'Overview', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Bundle dashboard' })).toBeVisible({ timeout: browserTimeout }); await rebuildFromOverview('invalid epoch B'); @@ -627,8 +628,8 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * phase = 'repaired edit rebuild C'; const epochCMarker = 'Epoch C repaired the packed review guidance.'; await Promise.all([ - writeFile(configSource, originalConfig), - writeFile(skillSource, `${originalSkill}\n\n${epochCMarker}\n`), + replaceWatchedSource(project, configSource, originalConfig), + replaceWatchedSource(project, skillSource, `${originalSkill}\n\n${epochCMarker}\n`), ]); await rebuildFromOverview('epoch C'); const epochCStatus = activeEpochFrom(await call('project_status'), 'epoch C'); diff --git a/packages/workbench/tests/runtime-app-bridge.test.ts b/packages/workbench/tests/runtime-app-bridge.test.ts index 00ec3aba6..201c623b3 100644 --- a/packages/workbench/tests/runtime-app-bridge.test.ts +++ b/packages/workbench/tests/runtime-app-bridge.test.ts @@ -100,6 +100,7 @@ const runtimeFactory = ( readonly installedHandlers?: Parameters[0]['installedHandlers']; readonly policy?: Readonly<{ readonly bindingId: string; readonly snapshot: Readonly<{ readonly allow: string; readonly approvedPermissions: Readonly>; readonly revision: number; readonly warnings: readonly unknown[] }> }>; readonly requestConsent?: Parameters[0]['requestConsent']; + readonly teardownAckTimeoutMs?: number; }> = {}, ): RuntimeAppBridgeFactory => { const policy = options.policy ?? Object.freeze({ @@ -134,6 +135,7 @@ const runtimeFactory = ( }) as never, requestConsent: options.requestConsent ?? (async () => 'deny'), simulationFeatures: Object.freeze({ chatGptWidgetState: 'disabled' as const }), + ...(options.teardownAckTimeoutMs === undefined ? {} : { teardownAckTimeoutMs: options.teardownAckTimeoutMs }), }); }; @@ -533,7 +535,10 @@ it('keeps inbound App messages at 256 KiB while admitting a 1 MiB host result en it('bounds an unresponsive resource-teardown before the factory releases transport and access', async () => { await withBrowser(async (browser) => { let accessCloses = 0; - const factory = runtimeFactory(async () => appAccess(async () => { accessCloses += 1; })); + // The production ack budget is deliberately generous (a contended runner + // must not permanently lose the ack); the seam keeps this bounded-timeout + // proof fast without weakening the production window. + const factory = runtimeFactory(async () => appAccess(async () => { accessCloses += 1; }), { teardownAckTimeoutMs: 500 }); const bridge = await invokeBridgeFactory(factory, browser); const started = Date.now(); const teardown = bridge.teardownResource({}); diff --git a/packages/workbench/tests/support/watched-files.ts b/packages/workbench/tests/support/watched-files.ts new file mode 100644 index 000000000..c8d88e55e --- /dev/null +++ b/packages/workbench/tests/support/watched-files.ts @@ -0,0 +1,18 @@ +import { rename, writeFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; + +/** + * Replaces a watched source atomically through a rename staged OUTSIDE the + * watched project. An in-place write is truncate-then-append: the dev + * compiler can start a compile off the truncation event, read incomplete + * content, and then drop the append event because both operations land + * within the same mtime tick, so the final content never compiles and the + * expected revision never activates. The temp file lives in the project's + * parent (same filesystem, never watched) so the rename into place is the + * only event the watcher observes. + */ +export const replaceWatchedSource = async (projectRoot: string, path: string, content: string): Promise => { + const temporary = join(projectRoot, '..', `.${basename(path)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`); + await writeFile(temporary, content); + await rename(temporary, path); +}; From c8de0a9e08a19c53d3eb5f3e1b33f4aa217ff343 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 29 Aug 2026 07:45:14 +0000 Subject: [PATCH 2/8] fix(workbench): deflake contended-runner races the staged-rename stress exposed Stressing the suites under taskset -c 0,1 (mirroring two-core CI) surfaced three more root causes beyond the in-place writes: - overview handoff test: the config-reconcile polls used raw 15s budgets while each reconcile recompiles the config plus three bundles (~4s apiece pinned), failing 4/4 under taskset; they now scale with timeScale like the rest of the file. - overview restart test: the registry-replay-gap fallback announcement was asserted with the default 5s budget although it renders only after the invalidation cleanup settles; now scaled, and toHaveText reports the actual reason when the wrong invalidation wins. - mcp-app-real third teardown: the test navigated away as soon as the third App frame's heading rendered, but the App SDK can acknowledge ui/resource-teardown only once its transport is connected. Teardown then raced initialization: relayed into a frame that cannot answer, the host's bounded grace elapsed, the frame was destroyed, and the ack became unobservable forever (2/3 pinned failures; same signature as the PR #29 Verify flake). The navigation now waits for the third frame's ui/initialize evidence, matching the existing destination gate. --- .../workbench/tests/mcp-app-real.e2e.test.ts | 12 ++++++++++++ packages/workbench/tests/overview.e2e.test.ts | 16 ++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/workbench/tests/mcp-app-real.e2e.test.ts b/packages/workbench/tests/mcp-app-real.e2e.test.ts index 4ba1a8ec6..9aff49aee 100644 --- a/packages/workbench/tests/mcp-app-real.e2e.test.ts +++ b/packages/workbench/tests/mcp-app-real.e2e.test.ts @@ -1297,6 +1297,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await expect(page.getByRole('heading', { name: 'Bundle dashboard' })).toBeVisible({ timeout: 15_000 * timeScale }); await expect(page.locator('.mcp-page-app-preview iframe')).toHaveCount(0); expect(runtimeCreates()).toHaveLength(2); + const appMessagesBeforeThirdCreate = appMessages.length; await page.evaluate(() => { window.location.hash = '#runtime'; }); await expect.poll(runtimeCreates, { timeout: 15_000 * timeScale }).toHaveLength(3); const thirdCreate = runtimeCreates()[2]; @@ -1336,6 +1337,17 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', if (thirdController === null) throw new Error('Third Runtime App trusted controller frame was unavailable.'); const thirdFrameHref = thirdController.url(); const thirdDeletePath = `/api/runtime/apps/${encodeURIComponent(thirdBinding.id)}`; + // The App can acknowledge ui/resource-teardown only once its SDK + // transport is connected, evidenced by its ui/initialize request reaching + // the host. The rendered heading alone does not prove that: navigating + // away earlier relays teardown into a frame that cannot answer yet, the + // host's bounded grace elapses, the frame is destroyed, and the + // acknowledgement becomes unobservable forever (the third-teardown ack + // poll timed out this way on contended two-core runners). + await expect.poll(() => appMessages.slice(appMessagesBeforeThirdCreate).filter((entry) => + new URL(entry.href).origin === fixture.url && entry.senderOrigin === thirdOrigin && + entry.message !== null && typeof entry.message === 'object' && + (entry.message as Readonly>).method === 'ui/initialize').length, { timeout: 15_000 * timeScale }).toBeGreaterThan(0); await page.evaluate(() => { window.location.hash = '#mcp'; }); const teardownRequestForThird = (): RuntimeAppMessage | undefined => appMessages.find((entry) => entry.href === thirdFrameHref && entry.senderOrigin === fixture.url && entry.message !== null && typeof entry.message === 'object' && diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index d726744ca..0c8d3b3b2 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -422,10 +422,14 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime const finiteConfig = sourceConfig.replace(' codex: {},', ` codex: { configReconcileMarker: '${finiteConfigMarker}' },`); expect(finiteConfig).not.toBe(sourceConfig); await replaceWatchedSource(fixture.root, fixture.configSource, finiteConfig); + // Config reconcile polls scale with contention: each reconcile recompiles + // the config plus the rsc/widget/app bundles (~4s apiece on a pinned + // two-core runner), so a raw 15s budget fails deterministically under + // taskset -c 0,1 while the change itself is delivered fine. await expect.poll(async () => { const source = await readProjectSource(); return source.state === 'ready' && source.revision !== sourceRevision ? source.revision : undefined; - }, { timeout: 15_000 }).toEqual(expect.any(String)); + }, { timeout: browserTimeout }).toEqual(expect.any(String)); const finiteSource = await readProjectSource(); const finiteSourceRevision = finiteSource.revision; if (finiteSourceRevision === undefined) throw new Error('Finite configuration update did not expose a source revision.'); @@ -443,7 +447,7 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime return source.state === 'invalid' && diagnostic?.message === 'A registered config extension must contain strict finite JSON data.' ? source : undefined; - }, { timeout: 15_000 }).toMatchObject({ + }, { timeout: browserTimeout }).toMatchObject({ diagnostics: [expect.objectContaining({ code: 'AB4500', message: 'A registered config extension must contain strict finite JSON data.', @@ -520,7 +524,7 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime await expect.poll(async () => { const source = await readProjectSource(); return source.state === 'ready' && source.revision !== finiteSourceRevision ? source.revision : undefined; - }, { timeout: 15_000 }).toEqual(expect.any(String)); + }, { timeout: browserTimeout }).toEqual(expect.any(String)); const repairedSource = await readProjectSource(); const repairedSourceRevision = repairedSource.revision; if (repairedSourceRevision === undefined) throw new Error('Repaired configuration update did not expose a source revision.'); @@ -986,7 +990,11 @@ e2e('restarts the real Runtime MCP App session when definition or transport auth await page.locator('.runtime-stage .mcp-app-preview iframe').waitFor({ state: 'detached', timeout: 15_000 }); expect(await page.locator('.runtime-stage .mcp-app-preview iframe').count()).toBe(0); await expect(page.locator('.connection-content')).toHaveAttribute('data-recovery-probe', 'same-instance'); - await expect(page.getByText('Interactive App rendering is unavailable (registry-replay-gap). Showing the ordinary tool result instead.')).toBeVisible(); + // Scaled budget: the fallback section renders after the invalidation + // cleanup settles, which lags the iframe detach on a contended runner. + // toHaveText also reports the actual reason if the wrong invalidation won. + await expect(page.locator('.runtime-stage .mcp-app-preview__fallback > p[role="status"]')) + .toHaveText('Interactive App rendering is unavailable (registry-replay-gap). Showing the ordinary tool result instead.', { timeout: browserTimeout }); expect(runtimeAppRequests.filter((request) => request.startsWith('DELETE /api/runtime/apps/'))).toEqual([]); await expect.poll(() => runtimeAppCreates.length, { timeout: 1_000 }).toBe(2); From cf8350cee17c0f606c9fa4a194d655ba80721ad0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 29 Aug 2026 11:16:53 +0000 Subject: [PATCH 3/8] fix(dev): stop dropping host messages relayed during the Runtime App handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime client-surface relay forwarded host-to-app traffic only in the 'initialized' lifecycle (plus the initialize response while 'initializing') and silently dropped everything else. A host request relayed into that handshake window — observed as ui/resource-teardown racing the App's ui/notifications/initialized on contended two-core runners — vanished: the host burned its bounded teardown grace waiting for an answer that could never arrive, destroyed the frame, and the acknowledgement evidence was lost forever (the PR #29 Verify flake signature; budget-independent, still failing 2/8 under taskset -c 0,1 even with a 30s grace). The relay now queues up to 32 validated host messages during the handshake and flushes them once the App reports initialized; the queue survives an HMR entry reload so a request sent to a retiring App instance is answered by its replacement. The teardown ack poll also carries enriched failure evidence now. Single-test stress under taskset -c 0,1: 8/8 after the fix. --- .../src/dev/runtime-client-surface-proxy.ts | 21 +++++++++++++++---- .../workbench/src/mcp/runtime-app-bridge.ts | 8 ++++--- .../workbench/tests/mcp-app-real.e2e.test.ts | 16 +++++++++++++- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts b/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts index 7f88df52b..ba5dea7a2 100644 --- a/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts +++ b/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts @@ -191,6 +191,8 @@ const runtimeProxyShell = (entryPath: string, entryDocument: string, hostOrigin: const allowedKeys = new Set(['error', 'id', 'jsonrpc', 'method', 'params', 'result']); let initializeId; let lifecycle = 'created'; + const maxPendingHostMessages = 32; + let pendingHostMessages = []; let hmr; let hmrReconnectAttempts = 0; let hmrReconnectTimer; @@ -296,14 +298,23 @@ const runtimeProxyShell = (entryPath: string, entryDocument: string, hostOrigin: addEventListener('message', (event) => { if (lifecycle === 'closed') return; if (event.source === parent) { - if (!isRpc(event.data, maxHostToAppMessageBytes)) return; - if (lifecycle === 'initializing') { - if (event.origin !== hostOrigin || !isInitializeResponse(event.data)) return; + if (event.origin !== hostOrigin || !isRpc(event.data, maxHostToAppMessageBytes)) return; + if (lifecycle === 'initializing' && isInitializeResponse(event.data)) { lifecycle = 'initialize-responded'; post(app.contentWindow, '*', event.data, event.ports, maxHostToAppMessageBytes); return; } - if (lifecycle !== 'initialized' || event.origin !== hostOrigin) return; + if (lifecycle !== 'initialized') { + // Queue instead of dropping: a host request relayed into the + // handshake window (ui/resource-teardown racing the App's + // ui/notifications/initialized) would otherwise vanish, its sender + // would burn its bounded grace waiting for an answer that can never + // arrive, and the acknowledgement evidence would be lost forever. + // The queue also survives an HMR entry reload, so a request sent to + // the retiring App instance is answered by its replacement. + if (pendingHostMessages.length < maxPendingHostMessages) pendingHostMessages.push({ data: event.data, ports: event.ports }); + return; + } post(app.contentWindow, '*', event.data, event.ports, maxHostToAppMessageBytes); return; } @@ -317,12 +328,14 @@ const runtimeProxyShell = (entryPath: string, entryDocument: string, hostOrigin: if (lifecycle === 'initialize-responded' && isNotification(event.data, 'ui/notifications/initialized', maxAppToHostMessageBytes)) { lifecycle = 'initialized'; post(parent, hostOrigin, event.data, event.ports, maxAppToHostMessageBytes); + for (const pending of pendingHostMessages.splice(0)) post(app.contentWindow, '*', pending.data, pending.ports, maxHostToAppMessageBytes); return; } if (lifecycle === 'initialized') post(parent, hostOrigin, event.data, event.ports, maxAppToHostMessageBytes); }); addEventListener('pagehide', () => { lifecycle = 'closed'; + pendingHostMessages = []; if (hmrReconnectTimer !== undefined) clearTimeout(hmrReconnectTimer); hmrReconnectTimer = undefined; const activeHmr = hmr; diff --git a/packages/workbench/src/mcp/runtime-app-bridge.ts b/packages/workbench/src/mcp/runtime-app-bridge.ts index 2e04584e6..83f47bf0a 100644 --- a/packages/workbench/src/mcp/runtime-app-bridge.ts +++ b/packages/workbench/src/mcp/runtime-app-bridge.ts @@ -123,10 +123,12 @@ const maximumFailures = 3; * The ack budget for `ui/resource-teardown` before the host proceeds to * revoke the binding and unmount the frame. Missing this window is * unrecoverable: the frame is destroyed, so a late acknowledgement can never - * be delivered. The round trip crosses two postMessage hops and the + * be delivered. The round trip crosses two postMessage hops (the client + * surface queues it until the App's initialize handshake settles) plus the * sandboxed app's event loop; a healthy app answers in milliseconds, but a - * contended two-core CI runner was observed missing a 1s window (PR #29 - * Verify), so the budget only bounds a genuinely hung app. + * contended two-core runner was observed missing a 1s window during a + * dev-compile storm (PR #29 Verify), so the budget only bounds an app that + * cannot answer at all — it never delays a responsive one. */ const gracefulTeardownTimeoutMs = 10_000; diff --git a/packages/workbench/tests/mcp-app-real.e2e.test.ts b/packages/workbench/tests/mcp-app-real.e2e.test.ts index 9aff49aee..f5abf0b79 100644 --- a/packages/workbench/tests/mcp-app-real.e2e.test.ts +++ b/packages/workbench/tests/mcp-app-real.e2e.test.ts @@ -1360,7 +1360,21 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', if (typeof thirdTeardownId !== 'string' && typeof thirdTeardownId !== 'number') throw new Error('Third Runtime App teardown request omitted its JSON-RPC id.'); const thirdAcknowledgement = () => messageFor(fixture.url, thirdOrigin, (message) => message.id === thirdTeardownId && (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error'))); - await expect.poll(thirdAcknowledgement, { timeout: 15_000 * timeScale }).toBeDefined(); + try { + await expect.poll(thirdAcknowledgement, { timeout: 15_000 * timeScale }).toBeDefined(); + } catch { + throw new Error(`Third Runtime App teardown was never acknowledged: ${JSON.stringify({ + deletes: runtimeAppRequests.filter((entry) => entry.method === 'DELETE').map((entry) => entry.path), + frames: page.frames().map((frame) => frame.url()), + hmrSockets: runtimePreviewSockets, + messagesSinceThirdCreate: appMessages.slice(appMessagesBeforeThirdCreate).map((entry) => Object.freeze({ + method: (entry.message as Readonly<{ readonly method?: unknown }>).method, + id: (entry.message as Readonly<{ readonly id?: unknown }>).id, + receiver: new URL(entry.href).origin, + sender: entry.senderOrigin, + })), + })}`); + } await expect.poll(() => runtimeAppRequests.filter((entry) => entry.method === 'DELETE' && entry.path === thirdDeletePath), { timeout: 15_000 * timeScale }).toHaveLength(1); const thirdDelete = runtimeAppRequests.find((entry) => entry.method === 'DELETE' && entry.path === thirdDeletePath); expect(lifecycleIndex('message', thirdAcknowledgement()!)).toBeGreaterThan(lifecycleIndex('message', thirdTeardown!)); From 35dc7e14b58e7152b15f64eb70f15a994d67e7de Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 29 Aug 2026 11:21:13 +0000 Subject: [PATCH 4/8] test: pin the origin-checked host relay admission line in the proxy shell --- .../agent-bundle/tests/runtime-client-surface-proxy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts b/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts index 0da51d526..16c14767e 100644 --- a/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts +++ b/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts @@ -518,7 +518,7 @@ it('uses a one-use bootstrap capability before proxying only the declared app an expect(shell).toContain('