diff --git a/packages/hub/src/node/__tests__/host-terminals.test.ts b/packages/hub/src/node/__tests__/host-terminals.test.ts index d8f1b8c..8c013bb 100644 --- a/packages/hub/src/node/__tests__/host-terminals.test.ts +++ b/packages/hub/src/node/__tests__/host-terminals.test.ts @@ -436,3 +436,82 @@ describe('devframeTerminalHost interactive PTY sessions', () => { expect(() => session.resize(100, 30)).not.toThrow() }) }) + +describe('devframeTerminalHost PTY status lifecycle', () => { + itPty('marks status stopped and emits an update on a clean exit', async () => { + const { host } = createTerminalHost() + const updates: string[] = [] + host.events.on('terminal:session:updated', s => updates.push(s.status)) + + const session = await host.startPtySession({ + command: NODE, + args: ['-e', 'process.exit(0)'], + }, { id: 'pty-status', title: 'PTY status' }) + + await waitUntil(() => { + expect(session.status).toBe('stopped') + }) + expect(updates).toContain('stopped') + }) + + itPty('marks status error on a non-zero exit', async () => { + const { host } = createTerminalHost() + + const session = await host.startPtySession({ + command: NODE, + args: ['-e', 'process.exit(3)'], + }, { id: 'pty-status-error', title: 'PTY status error' }) + + await waitUntil(() => { + expect(session.status).toBe('error') + }) + }) + + itPty('marks status stopped on terminate() rather than error', async () => { + const { host, sinks } = createTerminalHost() + + const session = await host.startPtySession({ + command: NODE, + args: ['-e', 'setInterval(() => {}, 4000)'], + }, { id: 'pty-status-terminate', title: 'PTY status terminate' }) + + expect(session.status).toBe('running') + await session.terminate() + + await waitUntil(() => { + expect(sinks.get('pty-status-terminate')?.closed).toBe(true) + }) + expect(session.status).toBe('stopped') + }) + + itPty('returns to running after restart() without an error flash', async () => { + const { host } = createTerminalHost() + + const session = await host.startPtySession({ + command: NODE, + args: ['-e', 'setInterval(() => {}, 4000)'], + }, { id: 'pty-status-restart', title: 'PTY status restart' }) + + await session.restart() + expect(session.status).toBe('running') + + await session.terminate() + }) + + itPty('keeps status stopped when restart() is called after the process exited', async () => { + const { host } = createTerminalHost() + + const session = await host.startPtySession({ + command: NODE, + args: ['-e', 'process.exit(0)'], + }, { id: 'pty-status-restart-exited', title: 'PTY status restart exited' }) + + await waitUntil(() => { + expect(session.status).toBe('stopped') + }) + // The stream is closed for good, so `restart()` is a no-op — the session + // stays reported as stopped rather than flipping back to running. + await session.restart() + expect(session.status).toBe('stopped') + }) +}) diff --git a/packages/hub/src/node/host-terminals.ts b/packages/hub/src/node/host-terminals.ts index ef92d8f..9f682fc 100644 --- a/packages/hub/src/node/host-terminals.ts +++ b/packages/hub/src/node/host-terminals.ts @@ -366,6 +366,18 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { let pty: IPty | undefined let runId = 0 let streamClosed = false + let session: DevframePtyTerminalSession + + // Keep the registered session's `status` in step with the process + // lifecycle so a hub-aware client (and any launcher tracking this session) + // sees `running` → `stopped`/`error` transitions instead of a value frozen + // at spawn time. + const markStatus = (next: DevframeTerminalSession['status']): void => { + if (session.status === next) + return + session.status = next + this.events.emit('terminal:session:updated', session) + } const closeStream = () => { if (streamClosed) @@ -422,9 +434,14 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { return controller?.enqueue(typeof data === 'string' ? data : data.toString('utf8')) }) - proc.onExit(() => { - if (currentRun === runId) - closeStream() + proc.onExit(({ exitCode, signal }) => { + if (currentRun !== runId) + return + closeStream() + // A signal kill (terminate()/restart()) is a deliberate stop; a clean + // exit is a deliberate stop too. Only an unsignalled non-zero exit + // code is a crash, matching the child-process comment above. + markStatus(signal === 0 && exitCode !== 0 ? 'error' : 'stopped') }) return proc } @@ -440,7 +457,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { }) } - const session: DevframePtyTerminalSession = { + session = { ...terminal, status: 'running', interactive: true, @@ -476,12 +493,14 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { pty?.kill() pty = undefined closeStream() + markStatus('stopped') }, restart: async () => { if (streamClosed) return pty?.kill() pty = spawnPty() + markStatus('running') }, } this.register(session)