Skip to content
Merged
14 changes: 14 additions & 0 deletions .changeset/runtime-relay-handshake-queue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"agent-bundle": patch
---

Stop dropping host messages relayed to a Runtime App during its initialize
handshake. The dev-server client-surface relay forwarded host-to-app
traffic only once the App had reported `ui/notifications/initialized`
(plus the initialize response itself) and silently discarded anything
earlier, so a host request that raced the handshake — observed as
`ui/resource-teardown` on contended runners — could never be answered.
The relay now queues up to 32 validated host messages during the handshake
and flushes them once the App initializes; the queue survives an HMR entry
reload so a request sent to a retiring App instance is answered by its
replacement.
21 changes: 17 additions & 4 deletions packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ it('uses a one-use bootstrap capability before proxying only the declared app an
expect(shell).toContain('<iframe id="app" sandbox="allow-scripts"');
expect(shell).toContain(`const maxAppToHostMessageBytes = ${runtimeAppMessageLimits.appToHostBytes};`);
expect(shell).toContain(`const maxHostToAppMessageBytes = ${runtimeAppMessageLimits.hostToAppBytes};`);
expect(shell).toContain("if (!isRpc(event.data, maxHostToAppMessageBytes)) return;");
expect(shell).toContain("if (event.origin !== hostOrigin || !isRpc(event.data, maxHostToAppMessageBytes)) return;");
expect(shell).toContain("!isRpc(event.data, maxAppToHostMessageBytes)");

const second = await fetch(binding.bootstrapUrl, { redirect: 'manual' });
Expand Down
20 changes: 17 additions & 3 deletions packages/workbench/src/mcp/runtime-app-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -117,7 +119,18 @@ 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 (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 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;

const aggregateFailure = (message: string, reasons: readonly unknown[]): Error =>
new AggregateError(reasons, message);
Expand Down Expand Up @@ -548,11 +561,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<Record<string, never>>): Promise<Readonly<Record<string, never>>> => rawTeardownResource(params, {
maxTotalTimeout: gracefulTeardownTimeoutMs,
timeout: gracefulTeardownTimeoutMs,
maxTotalTimeout: teardownAckTimeoutMs,
timeout: teardownAckTimeoutMs,
}),
writable: false,
});
Expand Down
28 changes: 27 additions & 1 deletion packages/workbench/tests/mcp-app-real.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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<Record<string, unknown>>).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' &&
Expand All @@ -1348,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!));
Expand Down
Loading
Loading