mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
fix(terminal): publish daemon sessions before output
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user