Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/hub/src/node/__tests__/host-terminals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
27 changes: 23 additions & 4 deletions packages/hub/src/node/host-terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -440,7 +457,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
})
}

const session: DevframePtyTerminalSession = {
session = {
...terminal,
status: 'running',
interactive: true,
Expand Down Expand Up @@ -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)
Expand Down
Loading