Skip to content

Commit bb8e962

Browse files
authored
fix(hub): mark PTY session status on exit, terminate and restart (#148)
1 parent 6f0ebcf commit bb8e962

2 files changed

Lines changed: 102 additions & 4 deletions

File tree

packages/hub/src/node/__tests__/host-terminals.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,3 +436,82 @@ describe('devframeTerminalHost interactive PTY sessions', () => {
436436
expect(() => session.resize(100, 30)).not.toThrow()
437437
})
438438
})
439+
440+
describe('devframeTerminalHost PTY status lifecycle', () => {
441+
itPty('marks status stopped and emits an update on a clean exit', async () => {
442+
const { host } = createTerminalHost()
443+
const updates: string[] = []
444+
host.events.on('terminal:session:updated', s => updates.push(s.status))
445+
446+
const session = await host.startPtySession({
447+
command: NODE,
448+
args: ['-e', 'process.exit(0)'],
449+
}, { id: 'pty-status', title: 'PTY status' })
450+
451+
await waitUntil(() => {
452+
expect(session.status).toBe('stopped')
453+
})
454+
expect(updates).toContain('stopped')
455+
})
456+
457+
itPty('marks status error on a non-zero exit', async () => {
458+
const { host } = createTerminalHost()
459+
460+
const session = await host.startPtySession({
461+
command: NODE,
462+
args: ['-e', 'process.exit(3)'],
463+
}, { id: 'pty-status-error', title: 'PTY status error' })
464+
465+
await waitUntil(() => {
466+
expect(session.status).toBe('error')
467+
})
468+
})
469+
470+
itPty('marks status stopped on terminate() rather than error', async () => {
471+
const { host, sinks } = createTerminalHost()
472+
473+
const session = await host.startPtySession({
474+
command: NODE,
475+
args: ['-e', 'setInterval(() => {}, 4000)'],
476+
}, { id: 'pty-status-terminate', title: 'PTY status terminate' })
477+
478+
expect(session.status).toBe('running')
479+
await session.terminate()
480+
481+
await waitUntil(() => {
482+
expect(sinks.get('pty-status-terminate')?.closed).toBe(true)
483+
})
484+
expect(session.status).toBe('stopped')
485+
})
486+
487+
itPty('returns to running after restart() without an error flash', async () => {
488+
const { host } = createTerminalHost()
489+
490+
const session = await host.startPtySession({
491+
command: NODE,
492+
args: ['-e', 'setInterval(() => {}, 4000)'],
493+
}, { id: 'pty-status-restart', title: 'PTY status restart' })
494+
495+
await session.restart()
496+
expect(session.status).toBe('running')
497+
498+
await session.terminate()
499+
})
500+
501+
itPty('keeps status stopped when restart() is called after the process exited', async () => {
502+
const { host } = createTerminalHost()
503+
504+
const session = await host.startPtySession({
505+
command: NODE,
506+
args: ['-e', 'process.exit(0)'],
507+
}, { id: 'pty-status-restart-exited', title: 'PTY status restart exited' })
508+
509+
await waitUntil(() => {
510+
expect(session.status).toBe('stopped')
511+
})
512+
// The stream is closed for good, so `restart()` is a no-op — the session
513+
// stays reported as stopped rather than flipping back to running.
514+
await session.restart()
515+
expect(session.status).toBe('stopped')
516+
})
517+
})

packages/hub/src/node/host-terminals.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,18 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
366366
let pty: IPty | undefined
367367
let runId = 0
368368
let streamClosed = false
369+
let session: DevframePtyTerminalSession
370+
371+
// Keep the registered session's `status` in step with the process
372+
// lifecycle so a hub-aware client (and any launcher tracking this session)
373+
// sees `running` → `stopped`/`error` transitions instead of a value frozen
374+
// at spawn time.
375+
const markStatus = (next: DevframeTerminalSession['status']): void => {
376+
if (session.status === next)
377+
return
378+
session.status = next
379+
this.events.emit('terminal:session:updated', session)
380+
}
369381

370382
const closeStream = () => {
371383
if (streamClosed)
@@ -422,9 +434,14 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
422434
return
423435
controller?.enqueue(typeof data === 'string' ? data : data.toString('utf8'))
424436
})
425-
proc.onExit(() => {
426-
if (currentRun === runId)
427-
closeStream()
437+
proc.onExit(({ exitCode, signal }) => {
438+
if (currentRun !== runId)
439+
return
440+
closeStream()
441+
// A signal kill (terminate()/restart()) is a deliberate stop; a clean
442+
// exit is a deliberate stop too. Only an unsignalled non-zero exit
443+
// code is a crash, matching the child-process comment above.
444+
markStatus(signal === 0 && exitCode !== 0 ? 'error' : 'stopped')
428445
})
429446
return proc
430447
}
@@ -440,7 +457,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
440457
})
441458
}
442459

443-
const session: DevframePtyTerminalSession = {
460+
session = {
444461
...terminal,
445462
status: 'running',
446463
interactive: true,
@@ -476,12 +493,14 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
476493
pty?.kill()
477494
pty = undefined
478495
closeStream()
496+
markStatus('stopped')
479497
},
480498
restart: async () => {
481499
if (streamClosed)
482500
return
483501
pty?.kill()
484502
pty = spawnPty()
503+
markStatus('running')
485504
},
486505
}
487506
this.register(session)

0 commit comments

Comments
 (0)