diff --git a/.changeset/runtime-relay-handshake-queue.md b/.changeset/runtime-relay-handshake-queue.md new file mode 100644 index 000000000..72e9fae51 --- /dev/null +++ b/.changeset/runtime-relay-handshake-queue.md @@ -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. 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/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('