fix(terminal): publish daemon sessions before output

This commit is contained in:
Brennan Benson
2026-07-13 12:00:21 -07:00
parent 2384370d25
commit 4dacb08ec5
4 changed files with 62 additions and 1 deletions
+11 -1
View File
@@ -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": {
+14
View File
@@ -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<void>
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))
}
+35
View File
@@ -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',
+2
View File
@@ -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