Skip to content

Commit e56dd62

Browse files
committed
fix(hub): throw when restarting a session with a closed stream
restart() silently returned once a startChildProcess()/startPtySession() session's streamClosed flag was set — after a natural process exit or terminate(). That flag guards a single-use ReadableStream controller which cannot be reopened, so restarting in place genuinely cannot work; the defect was that a caller had no way to distinguish success from a no-op (hub:terminals:restart resolves either way). Throw a new DF8206 diagnostic instead, pointing callers at remove(session) + a fresh start*() with a new id. This is a behaviour change on a case #148 (four days ago) deliberately pinned as a silent no-op — the two host-terminals tests that pinned it are updated to assert the rejection while keeping their original assertions (stream stays closed / status stays 'stopped'): only the silence changes. Not reusing DF8205 (its fix text describes restartable: false, which would misdescribe a spent stream) and not flipping `restartable` in closeStream() (a different concept — "lifecycle owned elsewhere" vs. "stream spent"). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 5669caf commit e56dd62

5 files changed

Lines changed: 33 additions & 6 deletions

File tree

docs/errors/DF8206.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8206: Terminal Session Restart on Closed Stream
6+
7+
## Message
8+
9+
> Terminal session "`{id}`" cannot be restarted — its output stream is already closed
10+
11+
## Cause
12+
13+
`restart()` was called on a `startChildProcess()` or `startPtySession()` session whose output stream is already closed. The stream closes irreversibly on a natural process exit or after `terminate()` — it backs a single-use `ReadableStream` controller that cannot be reopened, so restarting in place is not possible once it has closed.
14+
15+
## Fix
16+
17+
`ctx.terminals.remove(session)` the spent session, then spawn a fresh one via `startChildProcess()` / `startPtySession()` with a new id.
18+
19+
## Source
20+
21+
- [`packages/hub/src/node/host-terminals.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-terminals.ts) — the `restart()` handle returned by `startChildProcess()` and `startPtySession()` throws this once the session's stream has closed.

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ describe('devframeTerminalHost stream lifecycle', () => {
163163
expect(session.buffer!.includes('line-0')).toBe(false)
164164
})
165165

166-
it('does not restart a terminated child-process session', async () => {
166+
it('rejects restarting a terminated child-process session', async () => {
167167
const { host, sinks } = createTerminalHost()
168168
const session = await host.startChildProcess(
169169
{ command: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'] },
@@ -173,7 +173,7 @@ describe('devframeTerminalHost stream lifecycle', () => {
173173
await waitUntil(() => {
174174
expect(sinks.get('child')?.closed).toBe(true)
175175
})
176-
await session.restart()
176+
await expect(session.restart()).rejects.toThrow(expect.objectContaining({ code: 'DF8206' }))
177177
// Stream stays closed; no orphan output stream.
178178
expect(sinks.get('child')?.closed).toBe(true)
179179
})
@@ -509,9 +509,9 @@ describe('devframeTerminalHost PTY status lifecycle', () => {
509509
await waitUntil(() => {
510510
expect(session.status).toBe('stopped')
511511
})
512-
// The stream is closed for good, so `restart()` is a no-op — the session
512+
// The stream is closed for good, so `restart()` rejects — the session
513513
// stays reported as stopped rather than flipping back to running.
514-
await session.restart()
514+
await expect(session.restart()).rejects.toThrow(expect.objectContaining({ code: 'DF8206' }))
515515
expect(session.status).toBe('stopped')
516516
})
517517
})

packages/hub/src/node/diagnostics.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ export const diagnostics = defineDiagnostics({
6767
why: (p: { id: string }) => `Terminal session "${p.id}" is not restartable`,
6868
fix: 'It was registered with `restartable: false`; restart it through its owner\'s controls, or spawn it with `restartable: true` (the default) to allow in-place restarts.',
6969
},
70+
DF8206: {
71+
why: (p: { id: string }) => `Terminal session "${p.id}" cannot be restarted — its output stream is already closed`,
72+
fix: 'The session already exited (or was terminated) and its stream is spent. `ctx.terminals.remove(session)` then re-`startChildProcess()`/`startPtySession()` with a fresh id instead.',
73+
},
7074
DF8400: {
7175
why: (p: { id: string }) => `Command "${p.id}" is already registered`,
7276
},

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
322322

323323
const restart = async () => {
324324
if (streamClosed)
325-
return
325+
throw diagnostics.DF8206({ id: terminal.id })
326326
cp?.kill()
327327
cp = createChildProcess()
328328
markStatus('running')
@@ -497,7 +497,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
497497
},
498498
restart: async () => {
499499
if (streamClosed)
500-
return
500+
throw diagnostics.DF8206({ id: terminal.id })
501501
pty?.kill()
502502
pty = spawnPty()
503503
markStatus('running')

packages/hub/src/types/terminals.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export interface DevframeChildProcessTerminalSession extends DevframeTerminalSes
114114
*/
115115
getResult: () => DevframeChildProcessResult
116116
terminate: () => Promise<void>
117+
/** Throws `DF8206` once the session's output stream has closed (after a natural exit or `terminate()`) — `remove()` it and start a fresh session instead. */
117118
restart: () => Promise<void>
118119
}
119120

@@ -139,5 +140,6 @@ export interface DevframePtyTerminalSession extends DevframeTerminalSession {
139140
/** Current foreground process name, when the backend can resolve it. */
140141
getProcessName: () => string | undefined
141142
terminate: () => Promise<void>
143+
/** Throws `DF8206` once the session's output stream has closed (after a natural exit or `terminate()`) — `remove()` it and start a fresh session instead. */
142144
restart: () => Promise<void>
143145
}

0 commit comments

Comments
 (0)