diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index b5e37aec9c0..4dabf8d7141 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3687,7 +3687,8 @@ "assertions": [ "a reused live daemon session rejects attach and kill from a replacement pane identity", "native immediate force-kill failure preserves the live daemon session for retry", - "same-id create waits for physical exit proof without disposing the terminating session owner" + "same-id create waits for physical exit proof without disposing the terminating session owner", + "the host publishes Session ownership and attaches its client before buffered data and exit flush synchronously on subscription" ] }, { @@ -4193,6 +4194,15 @@ "result": "passed", "durationSeconds": 10.49, "summary": "1800 tests passed with exact same-id startup replay admission, retained pre-handler/liveness bounds, and cross-provider retry identity preserved." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 9.06, + "summary": "1443 tests passed: two-phase Session startup delivered synchronous buffered BOOT output and exit to the published owner/client, reaped the exact map entry, and allowed a clean same-ID replacement." } ], "runtimeBudget": { diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 37e335e3031..8d13c0feef3 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -93,6 +93,9 @@ export type SessionOptions = { shellReadyTimeoutMs?: number historySeed?: string scrollback?: number + // Why: TerminalHost must publish the Session and attach its first client + // before node-pty flushes buffered data/exit synchronously on subscription. + deferSubprocessSubscription?: boolean // Why: fired once the session reaches a terminal state (natural exit or // kill-timeout force-dispose) so the owner (TerminalHost) can reap it — // dispose the headless emulator and drop it from its session map. Without a @@ -141,6 +144,7 @@ export class Session { private readonly _historySeeded: boolean | undefined private readonly exitProofPromise: Promise private resolveExitProof!: () => void + private subprocessSubscriptionStarted = false constructor(opts: SessionOptions) { this.sessionId = opts.sessionId @@ -179,6 +183,16 @@ export class Session { } this.postReadyFlushGate = new PostReadyFlushGate(() => this.flushPreReadyQueue()) + if (!opts.deferSubprocessSubscription) { + this.startSubprocessSubscription() + } + } + + startSubprocessSubscription(): void { + if (this.subprocessSubscriptionStarted) { + return + } + this.subprocessSubscriptionStarted = true this.subprocess.onData((data) => this.handleSubprocessData(data)) this.subprocess.onExit((code) => this.handleSubprocessExit(code)) } diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index d1987881684..7569dfc70d7 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -156,6 +156,41 @@ describe('TerminalHost', () => { expect(spawnFn).toHaveBeenCalledTimes(2) }) + it('publishes ownership before buffered data and exit flush on subscription', async () => { + const buffered = createMockSubprocess() + buffered.onData = vi.fn((callback) => callback('BOOT')) + buffered.onExit = vi.fn((callback) => callback(0)) + const replacement = createMockSubprocess() + spawnFn = vi.fn().mockReturnValueOnce(buffered).mockReturnValue(replacement) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + const onData = vi.fn() + const onExit = vi.fn() + + const first = await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData, onExit } + }) + + expect(first).toMatchObject({ isNew: true, snapshot: null }) + expect(onData).toHaveBeenCalledWith('BOOT') + expect(onExit).toHaveBeenCalledWith(0, first.sessionGeneration) + expect(host.listSessions()).toHaveLength(0) + + await expect( + host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + ).resolves.toMatchObject({ isNew: true }) + expect(host.listSessions()).toHaveLength(1) + expect(spawnFn).toHaveBeenCalledTimes(2) + }) + it('returns snapshot when attaching to existing session', async () => { await host.createOrAttach({ sessionId: 'session-1', diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index e56b92e8134..42417f86483 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -136,6 +136,7 @@ export class TerminalHost { subprocess, shellReadySupported, historySeed: opts.historySeed, + deferSubprocessSubscription: true, // Why: reap the dead session (dispose emulator + drop from the map) the // moment its subprocess exits, instead of retaining it for the daemon's // lifetime. Nothing reads a dead session's emulator (getSnapshot/ @@ -149,6 +150,7 @@ export class TerminalHost { this.sessions.set(opts.sessionId, session) const token = session.attachClient(opts.streamClient) + session.startSubprocessSubscription() if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) { // Why: startup commands must run inside the long-lived interactive shell