diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index 4f9cdef921..07b0dfc95d 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -141,6 +141,43 @@ test('quiesces reconnect and waits for the Host process before update install', await owner.close(); }); +test('waits through a reconnect gap before quiescing Host retirement', async () => { + const first = candidateHarness(); + const replacement = candidateHarness({ disconnectOnPrepare: true }); + let starts = 0; + let reportReplacementStart!: () => void; + let releaseReplacement!: () => void; + const replacementStarted = new Promise((resolve) => { + reportReplacementStart = resolve; + }); + const replacementReleased = new Promise((resolve) => { + releaseReplacement = resolve; + }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => { + starts += 1; + if (starts === 1) return ready(first.candidate); + reportReplacementStart(); + await replacementReleased; + return ready(replacement.candidate); + }, + waitForHostExit: async () => {}, + }); + + first.disconnect(); + await replacementStarted; + const retirement = owner.retireOwnedLocalHost('interrupt_active_work'); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(replacement.prepareRetirementCalls, 0); + + releaseReplacement(); + assert.equal((await retirement).kind, 'retired'); + assert.equal(replacement.prepareRetirementCalls, 1); + assert.deepEqual(replacement.retirementModes, ['interrupt_active_work']); + assert.equal(starts, 2); + await owner.close(); +}); + test('retires the owned ephemeral Host before Desktop quit', async () => { const events: string[] = []; const current = candidateHarness({ diff --git a/apps/desktop/src/main/__tests__/runtime-host-reconnecting-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-reconnecting-ipc-main.test.ts index 2223d93af6..0ca503bbca 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-reconnecting-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-reconnecting-ipc-main.test.ts @@ -128,7 +128,7 @@ test("reconciles a dispatched control on a replacement without replaying it", as test("bounds reconciliation when no replacement candidate becomes available", async () => { const ipc = ipcHarness(); const router = new RuntimeHostReconnectingIpcMain(ipc, { - reconciliationWaitTimeoutMs: 5, + replacementWaitTimeoutMs: 5, }); const firstTarget = router.createTarget("target-a") as ReconciledControlTarget; let dispatches = 0; @@ -339,10 +339,10 @@ test("holds an invocation across a Runtime Host candidate replacement", async () assert.equal(ipc.size, 0); }); -test("bounds invocation while an active Runtime Host has no handler", async () => { +test("bounds an invocation while an active Runtime Host has no handler", async () => { const ipc = ipcHarness(); const router = new RuntimeHostReconnectingIpcMain(ipc, { - handlerWaitTimeoutMs: 5, + replacementWaitTimeoutMs: 5, }); const target = router.createTarget("target-a"); target.handle("sessions:send", async () => "sent"); @@ -354,17 +354,14 @@ test("bounds invocation while an active Runtime Host has no handler", async () = RuntimeHostHandlerUnavailableError, ); target.handle("sessions:send", async () => "retried"); - assert.equal( - await ipc.invoke("sessions:send", scope("target-a")), - "retried", - ); + assert.equal(await ipc.invoke("sessions:send", scope("target-a")), "retried"); router.close(); }); test("bounds reconnectable reads when no replacement handler becomes available", async () => { const ipc = ipcHarness(); const router = new RuntimeHostReconnectingIpcMain(ipc, { - handlerWaitTimeoutMs: 5, + replacementWaitTimeoutMs: 5, }); const target = router.createTarget("target-a"); const failRead = deferred(); @@ -389,6 +386,79 @@ test("bounds reconnectable reads when no replacement handler becomes available", router.close(); }); +test("settles concurrent invocations after one bounded replacement window", async () => { + const ipc = ipcHarness(); + const router = new RuntimeHostReconnectingIpcMain(ipc, { + replacementWaitTimeoutMs: 5, + }); + const target = router.createTarget("target-a"); + target.handleReconnectableRead?.("projects:getSnapshot", async () => ({ projects: [] })); + router.activate("target-a"); + target.removeHandler("projects:getSnapshot"); + + const reads = Array.from({ length: 200 }, (_, index) => + ipc.invoke("projects:getSnapshot", scope("target-a"), { index }).then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }), + ), + ); + const settled = Promise.all(reads); + try { + const result = await Promise.race([ + settled, + new Promise<{ readonly timedOut: true }>((resolve) => + setTimeout(() => resolve({ timedOut: true }), 100), + ), + ]); + assert.ok(Array.isArray(result), "Runtime Host invocations did not settle"); + assert.equal(result.length, 200); + for (const read of result) { + assert.equal(read.ok, false); + if (!read.ok) assert.ok(read.error instanceof RuntimeHostHandlerUnavailableError); + } + } finally { + router.close(); + await settled; + } +}); + +test("does not reset one reconnectable read deadline across failed replacements", async (t) => { + const ipc = ipcHarness(); + const router = new RuntimeHostReconnectingIpcMain(ipc, { + replacementWaitTimeoutMs: 15, + }); + let monotonicNow = 0; + t.mock.method(performance, "now", () => monotonicNow); + router.activate("target-a"); + let attempts = 0; + const maximumAttempts = 20; + const installFailingTarget = (): void => { + const target = router.createTarget("target-a"); + target.handleReconnectableRead?.("projects:getSnapshot", async () => { + attempts += 1; + monotonicNow += 5; + target.removeHandler("projects:getSnapshot"); + if (attempts < maximumAttempts) installFailingTarget(); + throw new RuntimeHostOperationError( + "project.catalog.query", + "host_draining", + "Runtime Host is draining", + ); + }); + }; + installFailingTarget(); + + try { + await assert.rejects( + () => ipc.invoke("projects:getSnapshot", scope("target-a")), + RuntimeHostHandlerUnavailableError, + ); + assert.equal(attempts, 4); + } finally { + router.close(); + } +}); + test("does not return a late read from a replaced Runtime Host candidate", async () => { const ipc = ipcHarness(); const router = new RuntimeHostReconnectingIpcMain(ipc); diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 61ab1868a2..cc347473c6 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -557,7 +557,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { const lifecycle = this.#requireLifecycle( this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id), ); - const quiescence = lifecycle.quiesce(); + const quiescence = await lifecycle.quiesce(); let hostPid = quiescence.current.hostPid; let launchBarrierPaused = false; const resume = () => { diff --git a/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts b/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts index 0e62489137..22a5390b32 100644 --- a/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts @@ -33,18 +33,16 @@ type ReconcileIpcHandler = ( type ReconciliationUnavailableIpcHandler = ReconcileIpcHandler; -const DEFAULT_RECONCILIATION_WAIT_TIMEOUT_MS = 15_000; -const DEFAULT_HANDLER_WAIT_TIMEOUT_MS = 5_000; +const DEFAULT_REPLACEMENT_WAIT_TIMEOUT_MS = 15_000; export interface RuntimeHostReconnectingIpcMainOptions { - readonly reconciliationWaitTimeoutMs?: number; - readonly handlerWaitTimeoutMs?: number; + readonly replacementWaitTimeoutMs?: number; } -class ReconciliationWaitExpiredError extends Error { +class HandlerWaitExpiredError extends Error { constructor() { - super("Runtime Host reconciliation replacement wait expired"); - this.name = "ReconciliationWaitExpiredError"; + super("Runtime Host replacement wait expired"); + this.name = "HandlerWaitExpiredError"; } } @@ -99,8 +97,7 @@ export class RuntimeHostReconnectingIpcMain { readonly #ipcMain: Pick; readonly #slots = new Map(); readonly #activeEpochs = new Set(); - readonly #reconciliationWaitTimeoutMs: number; - readonly #handlerWaitTimeoutMs: number; + readonly #replacementWaitTimeoutMs: number; #closed = false; constructor( @@ -108,18 +105,12 @@ export class RuntimeHostReconnectingIpcMain { options: RuntimeHostReconnectingIpcMainOptions = {}, ) { this.#ipcMain = ipcMain; - const reconciliationWaitTimeoutMs = - options.reconciliationWaitTimeoutMs ?? DEFAULT_RECONCILIATION_WAIT_TIMEOUT_MS; - if (!Number.isSafeInteger(reconciliationWaitTimeoutMs) || reconciliationWaitTimeoutMs <= 0) { - throw new TypeError("Runtime Host reconciliation wait timeout must be positive"); + const replacementWaitTimeoutMs = + options.replacementWaitTimeoutMs ?? DEFAULT_REPLACEMENT_WAIT_TIMEOUT_MS; + if (!Number.isSafeInteger(replacementWaitTimeoutMs) || replacementWaitTimeoutMs <= 0) { + throw new TypeError("Runtime Host replacement wait timeout must be positive"); } - this.#reconciliationWaitTimeoutMs = reconciliationWaitTimeoutMs; - const handlerWaitTimeoutMs = - options.handlerWaitTimeoutMs ?? DEFAULT_HANDLER_WAIT_TIMEOUT_MS; - if (!Number.isSafeInteger(handlerWaitTimeoutMs) || handlerWaitTimeoutMs <= 0) { - throw new TypeError("Runtime Host handler wait timeout must be positive"); - } - this.#handlerWaitTimeoutMs = handlerWaitTimeoutMs; + this.#replacementWaitTimeoutMs = replacementWaitTimeoutMs; } createTarget(epoch: string): RuntimeHostTargetIpcMain { @@ -248,41 +239,28 @@ export class RuntimeHostReconnectingIpcMain { args: readonly unknown[], ): Promise { const epoch = this.#requireTargetEpoch(args[0]); - let handler: BoundHandler = - slot.handlers.get(epoch) ?? - await this.#waitForHandler( - slot, - epoch, - undefined, - this.#handlerWaitTimeoutMs, - () => new RuntimeHostHandlerUnavailableError(), - ); - let reconciliationContext: unknown; let reconciling = false; - let reconciliationDeadline: number | undefined; + let replacementDeadline: number | undefined; const waitForReplacement = async ( - previous: BoundHandler, + previous?: BoundHandler, ): Promise => { - if (!reconciling) { - return this.#waitForHandler( - slot, - epoch, - previous, - this.#handlerWaitTimeoutMs, - () => new RuntimeHostHandlerUnavailableError(), - ); - } - const remainingMs = Math.max( - 0, - (reconciliationDeadline ?? Date.now()) - Date.now(), - ); + // One invocation gets one monotonic replacement window across every + // candidate it visits; flapping must not restart its lifetime. + replacementDeadline ??= performance.now() + this.#replacementWaitTimeoutMs; + const remainingMs = Math.max(0, replacementDeadline - performance.now()); try { + if (remainingMs <= 0) throw new HandlerWaitExpiredError(); return await this.#waitForHandler(slot, epoch, previous, remainingMs); } catch (error) { - if (error instanceof ReconciliationWaitExpiredError) return undefined; - throw error; + if (!(error instanceof HandlerWaitExpiredError)) throw error; + if (reconciling) return undefined; + throw new RuntimeHostHandlerUnavailableError(); } }; + const initialHandler = slot.handlers.get(epoch) ?? await waitForReplacement(); + if (!initialHandler) throw new RuntimeHostHandlerUnavailableError(); + let handler: BoundHandler = initialHandler; + let reconciliationContext: unknown; const unavailable = (): Promise => requireReconciliationUnavailableHandler(handler)( reconciliationContext, @@ -309,7 +287,6 @@ export class RuntimeHostReconnectingIpcMain { if (step.kind === "completed") return step.value; reconciliationContext = step.context; reconciling = true; - reconciliationDeadline = Date.now() + this.#reconciliationWaitTimeoutMs; const replacement = await waitForReplacement(handler); if (!replacement) return unavailable(); handler = replacement; @@ -345,7 +322,6 @@ export class RuntimeHostReconnectingIpcMain { epoch: string, previous?: BoundHandler, timeoutMs?: number, - timeoutError: () => Error = () => new ReconciliationWaitExpiredError(), ): Promise { try { this.#assertActive(epoch); @@ -357,7 +333,7 @@ export class RuntimeHostReconnectingIpcMain { return Promise.resolve(current); } if (timeoutMs !== undefined && timeoutMs <= 0) { - return Promise.reject(timeoutError()); + return Promise.reject(new HandlerWaitExpiredError()); } return new Promise((resolve, reject) => { let timeout: ReturnType | undefined; @@ -376,7 +352,7 @@ export class RuntimeHostReconnectingIpcMain { if (timeoutMs !== undefined) { timeout = setTimeout(() => { if (!slot.waiters.delete(waiter)) return; - waiter.reject(timeoutError()); + waiter.reject(new HandlerWaitExpiredError()); }, timeoutMs); } }); diff --git a/packages/runtime-host/src/__tests__/connection-session.test.ts b/packages/runtime-host/src/__tests__/connection-session.test.ts index 5760ad57e2..4f2c4c399c 100644 --- a/packages/runtime-host/src/__tests__/connection-session.test.ts +++ b/packages/runtime-host/src/__tests__/connection-session.test.ts @@ -41,7 +41,9 @@ import { RUNTIME_HOST_COMPATIBILITY_EPOCH, RUNTIME_HOST_MAX_IN_FLIGHT_DOMAIN_REQUESTS, RUNTIME_HOST_PROTOCOL_VERSION, + SESSION_CONTINUITY_SCHEMA_VERSION, type ClientFrame, + type EncodedProtocolMessage, type HostFrame, type ResponseFrame, type TurnSnapshot, @@ -53,6 +55,7 @@ import type { ClientCapabilityService, } from '../server/client-capability-service.js'; import { RuntimeHostConnectionSession } from '../server/connection-session.js'; +import type { SessionContinuityService } from '../server/session-continuity-service.js'; import { createUnavailableAccessAuthorityOperationHandlers, createUnavailableDomainOperationHandlers, @@ -68,6 +71,7 @@ import { RuntimeHostOutboundQueueError, } from '../server/serial-outbound-writer.js'; import { FramedTransport } from '../transport/framed-transport.js'; +import type { RuntimeHostMessageTransport } from '../transport/message-transport.js'; const CURRENT_PROTOCOL = { min: RUNTIME_HOST_PROTOCOL_VERSION, @@ -361,6 +365,191 @@ test('serial outbound writer reports its 2 MiB byte bound before its frame bound } }); +test('flushes concurrent subscription opens before activating their live frame streams', async () => { + const releaseWrites = deferred(); + const requestsEntered = deferred(); + const allWrites = deferred(); + const inbound = Array.from({ length: 16 }, (_, index) => ({ + requestId: `open-${index}`, + operation: 'subscription.open', + input: { sessionId: `session-${index}`, transcript: { kind: 'none' } }, + })); + const written: EncodedProtocolMessage[] = []; + let aborted = false; + let resolveClosed!: () => void; + let rejectRead: ((error: Error) => void) | undefined; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + const transport: RuntimeHostMessageTransport = { + closed, + read: async () => { + const frame = inbound.shift(); + if (frame) return frame; + return new Promise((_resolve, reject) => { + rejectRead = reject; + }); + }, + write: async (message) => { + await releaseWrites.promise; + written.push(message); + if (written.length === 32) allWrites.resolve(); + }, + closeAfterFlush: () => { + resolveClosed(); + }, + abort: (error) => { + if (aborted) return; + aborted = true; + rejectRead?.(error ?? new Error('in-memory transport aborted')); + resolveClosed(); + }, + }; + let openCalls = 0; + let sink: Parameters[1] | undefined; + const largeSnapshot = (sessionId: string) => { + const snapshot = canonicalProjection(sessionId); + return { + ...snapshot, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + projectionRevision: 1, + queue: { + ...snapshot.queue, + followup: Array.from({ length: 2 }, (_, index) => ({ + entryId: `entry-${sessionId}-${index}`, + messageId: `message-${sessionId}-${index}`, + content: { text: 'q'.repeat(25 * 1024), quotes: [] }, + placement: 'next_turn' as const, + state: 'queued' as const, + })), + }, + }; + }; + const continuity: SessionContinuityService = { + handlers: { + 'subscription.open': async (input) => { + openCalls += 1; + if (openCalls === 16) requestsEntered.resolve(); + return { + ok: true, + result: { + hostEpoch: 'host-epoch', + subscriptionId: `subscription-${input.sessionId}`, + nextSequence: 1, + snapshot: largeSnapshot(input.sessionId), + activeAssistantStreams: Array.from({ length: 180 }, (_, index) => ({ + kind: 'text' as const, + turnId: `turn-${input.sessionId}`, + messageId: `stream-${input.sessionId}-${index}`, + })), + transcript: transcriptBootstrapFor(input.sessionId), + }, + }; + }, + 'subscription.close': async (input) => ({ + ok: true, + result: { subscriptionId: input.subscriptionId }, + }), + 'session.transcript.overlay.release': async () => ({ + ok: false, + error: { code: 'operation_unavailable', message: 'not used' }, + }), + 'session.transcript.page': async () => ({ + ok: false, + error: { code: 'operation_unavailable', message: 'not used' }, + }), + }, + attachConnection: (_connectionId, attachedSink) => { + sink = attachedSink; + return { + activate: (subscriptionId) => { + const sessionId = subscriptionId.slice('subscription-'.length); + void sink + ?.send({ + kind: 'subscription.session_projection', + hostEpoch: 'host-epoch', + subscriptionId, + sequence: 1, + snapshot: largeSnapshot(sessionId), + }) + .catch(() => undefined); + }, + abort() {}, + close() {}, + }; + }, + }; + const handlers: OperationHandlerMap = { + 'host.status': async () => ({ + ok: true, + result: { + hostEpoch: 'host-epoch', + compositionId: 'maka.interactive', + compositionRevision: '1', + state: 'ready', + connections: 1, + activeOperations: 0, + activeResidencies: 0, + }, + }), + ...UNUSED_HOST_DIAGNOSTICS_HANDLER, + ...createUnavailableAccessAuthorityOperationHandlers(), + ...createHandlers(async (input) => ({ + ok: true, + result: runningSnapshot(input.sessionId, input.turnId), + })), + ...continuity.handlers, + }; + const session = new RuntimeHostConnectionSession({ + transport, + connection: acceptedConnection('concurrent-subscription-opens'), + resolveHandlers: () => handlers, + resolveContinuity: () => continuity, + beginOperation: async () => ({ + acquireResidency: () => ({ release() {} }), + seal() {}, + finish() {}, + }), + onTeardown() {}, + }); + const run = session.run(); + try { + await withTimeout(requestsEntered.promise, 1_000, 'subscription opens were not dispatched'); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(aborted, false); + + releaseWrites.resolve(); + await withTimeout(allWrites.promise, 2_000, 'subscription frames were not flushed'); + const frames = written.map((message) => + decodeHostFrame(JSON.parse(message.toString('utf8')) as unknown), + ); + const openResponseBytes = written.reduce( + (total, message, index) => + !('kind' in frames[index]!) && frames[index]!.operation === 'subscription.open' + ? total + message.byteLength + : total, + 0, + ); + assert.ok(openResponseBytes < 2 * 1024 * 1024); + assert.ok(written.reduce((total, message) => total + message.byteLength, 0) > 2 * 1024 * 1024); + assert.equal( + frames.filter((frame) => !('kind' in frame) && frame.operation === 'subscription.open') + .length, + 16, + ); + assert.equal( + frames.filter((frame) => 'kind' in frame && frame.kind === 'subscription.session_projection') + .length, + 16, + ); + assert.equal(aborted, false); + } finally { + releaseWrites.resolve(); + transport.abort(); + await run; + } +}); + test('clean read EOF drains an already dispatched response before closing', async () => { const fixture = await openHalfClosedDispatchedSession('half-close'); try { @@ -1439,6 +1628,43 @@ function canonicalProjection(sessionId: string): CanonicalSessionProjection { }; } +function transcriptBootstrapFor(sessionId: string) { + const contents = Buffer.from('t'.repeat(16 * 1024)); + return { + throughSequence: 0, + overlayMessageCount: 0, + durable: { + kind: 'page' as const, + sessionId, + source: 'durable' as const, + direction: 'older' as const, + throughSequence: 0, + rawBytes: contents.byteLength, + fragments: [ + { + kind: 'durable' as const, + sequence: 0, + byteOffset: 0, + totalBytes: contents.byteLength, + payloadDigest: null, + data: contents.toString('base64'), + }, + ], + nextCursor: null, + }, + overlay: { + kind: 'page' as const, + sessionId, + source: 'overlay' as const, + direction: 'older' as const, + throughSequence: 0, + rawBytes: 0, + fragments: [], + nextCursor: null, + }, + }; +} + function connectionTextEvent(sessionId: string, index: number, messageId?: string) { return { type: 'text_delta' as const, diff --git a/packages/runtime-host/src/__tests__/reconnecting-connection.test.ts b/packages/runtime-host/src/__tests__/reconnecting-connection.test.ts index a13b13a39a..59ac6eca8e 100644 --- a/packages/runtime-host/src/__tests__/reconnecting-connection.test.ts +++ b/packages/runtime-host/src/__tests__/reconnecting-connection.test.ts @@ -399,7 +399,7 @@ test('reconnect lifecycle quiescence suppresses replacement until it is resumed' }, }); - const quiescence = lifecycle.quiesce(); + const quiescence = await lifecycle.quiesce(); assert.equal(quiescence.current, first.connection); first.disconnect(); await new Promise((resolve) => setImmediate(resolve)); @@ -411,6 +411,35 @@ test('reconnect lifecycle quiescence suppresses replacement until it is resumed' await lifecycle.close(); }); +test('reconnect lifecycle quiescence waits through a connection gap before freezing', async () => { + const first = connectionHarness('first', () => undefined); + const replacement = connectionHarness('replacement', () => undefined); + const reconnecting = deferredValue(); + const connectStarted = deferred(); + let connectCalls = 0; + const lifecycle = await startRuntimeHostReconnectLifecycle({ + initial: first.connection, + connect: async () => { + connectCalls += 1; + connectStarted.resolve(); + return reconnecting.promise; + }, + }); + + first.disconnect(); + await connectStarted.promise; + const quiescenceTask = lifecycle.quiesce(); + reconnecting.resolve(replacement.connection); + const quiescence = await quiescenceTask; + assert.equal(quiescence.current, replacement.connection); + + replacement.disconnect(); + await yieldToEventLoop(); + assert.equal(connectCalls, 1); + quiescence.resume(); + await lifecycle.close(); +}); + test('reconnect delay escalates past maxMs while the Host never stabilizes', async () => { const first = connectionHarness('first', () => undefined); const delays: number[] = []; diff --git a/packages/runtime-host/src/client/reconnect-lifecycle.ts b/packages/runtime-host/src/client/reconnect-lifecycle.ts index 5a1cec6b8e..ef252fac87 100644 --- a/packages/runtime-host/src/client/reconnect-lifecycle.ts +++ b/packages/runtime-host/src/client/reconnect-lifecycle.ts @@ -49,7 +49,7 @@ export interface RuntimeHostReconnectLifecycle; subscribe(listener: (current: T | undefined) => void): () => void; - quiesce(): RuntimeHostReconnectQuiescence; + quiesce(): Promise>; close(): Promise; } @@ -190,13 +190,21 @@ class RuntimeHostReconnectLifecycleImpl return () => this.#listeners.delete(listener); } - quiesce(): RuntimeHostReconnectQuiescence { + async quiesce(): Promise> { + while (!this.#current) { + if (this.#closed || this.#terminalError) { + throw new Error('Runtime Host reconnect lifecycle is closed'); + } + if (this.#quiesced) { + throw new Error('Runtime Host reconnect lifecycle is already quiesced'); + } + await this.waitForCurrent(); + } if (this.#closed || this.#terminalError) { throw new Error('Runtime Host reconnect lifecycle is closed'); } if (this.#quiesced) throw new Error('Runtime Host reconnect lifecycle is already quiesced'); const current = this.#current; - if (!current) throw new Error('Runtime Host has no current connection to quiesce'); this.#quiesced = true; let active = true; return { diff --git a/packages/runtime-host/src/server/connection-session.ts b/packages/runtime-host/src/server/connection-session.ts index 717699b3ed..4f54f122d3 100644 --- a/packages/runtime-host/src/server/connection-session.ts +++ b/packages/runtime-host/src/server/connection-session.ts @@ -233,9 +233,12 @@ export class RuntimeHostConnectionSession { response.ok && response.operation === 'subscription.open' ? response.result.subscriptionId : undefined; - if (openedSubscriptionId) continuity?.activate(openedSubscriptionId); try { await receipt.flushed; + // Subscriber-local queues retain pre-activation events. Expose them + // only after the open result leaves the connection-wide writer, or a + // restore fan-out can make legal responses and first frames overflow it. + if (openedSubscriptionId) continuity?.activate(openedSubscriptionId); } catch (error) { if (openedSubscriptionId) continuity?.abort(openedSubscriptionId); throw error;