Skip to content
Merged
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
21 changes: 21 additions & 0 deletions docs/errors/DF8206.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
outline: deep
---

# DF8206: Terminal Session Restart on Closed Stream

## Message

> Terminal session "`{id}`" cannot be restarted — its output stream is already closed

## Cause

`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.

## Fix

Drop the spent session with `ctx.terminals.remove(session)`, then spawn a replacement via `ctx.terminals.startChildProcess()` or `ctx.terminals.startPtySession()` with a fresh id.

## Source

- [`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.
8 changes: 4 additions & 4 deletions packages/hub/src/node/__tests__/host-terminals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ describe('devframeTerminalHost stream lifecycle', () => {
expect(session.buffer!.includes('line-0')).toBe(false)
})

it('does not restart a terminated child-process session', async () => {
it('rejects restarting a terminated child-process session', async () => {
const { host, sinks } = createTerminalHost()
const session = await host.startChildProcess(
{ command: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'] },
Expand All @@ -173,7 +173,7 @@ describe('devframeTerminalHost stream lifecycle', () => {
await waitUntil(() => {
expect(sinks.get('child')?.closed).toBe(true)
})
await session.restart()
await expect(session.restart()).rejects.toThrow(expect.objectContaining({ code: 'DF8206' }))
// Stream stays closed; no orphan output stream.
expect(sinks.get('child')?.closed).toBe(true)
})
Expand Down Expand Up @@ -509,9 +509,9 @@ describe('devframeTerminalHost PTY status lifecycle', () => {
await waitUntil(() => {
expect(session.status).toBe('stopped')
})
// The stream is closed for good, so `restart()` is a no-op — the session
// The stream is closed for good, so `restart()` rejects — the session
// stays reported as stopped rather than flipping back to running.
await session.restart()
await expect(session.restart()).rejects.toThrow(expect.objectContaining({ code: 'DF8206' }))
expect(session.status).toBe('stopped')
})
})
4 changes: 4 additions & 0 deletions packages/hub/src/node/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ export const diagnostics = defineDiagnostics({
why: (p: { id: string }) => `Terminal session "${p.id}" is not restartable`,
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.',
},
DF8206: {
why: (p: { id: string }) => `Terminal session "${p.id}" cannot be restarted — its output stream is already closed`,
fix: 'The session already exited (or was terminated) and its stream is spent. Drop it with `ctx.terminals.remove(session)`, then spawn a replacement via `ctx.terminals.startChildProcess()` or `ctx.terminals.startPtySession()` with a fresh id.',
},
DF8400: {
why: (p: { id: string }) => `Command "${p.id}" is already registered`,
},
Expand Down
4 changes: 2 additions & 2 deletions packages/hub/src/node/host-terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {

const restart = async () => {
if (streamClosed)
return
throw diagnostics.DF8206({ id: terminal.id })
cp?.kill()
cp = createChildProcess()
markStatus('running')
Expand Down Expand Up @@ -497,7 +497,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
},
restart: async () => {
if (streamClosed)
return
throw diagnostics.DF8206({ id: terminal.id })
pty?.kill()
pty = spawnPty()
markStatus('running')
Expand Down
2 changes: 2 additions & 0 deletions packages/hub/src/types/terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export interface DevframeChildProcessTerminalSession extends DevframeTerminalSes
*/
getResult: () => DevframeChildProcessResult
terminate: () => Promise<void>
/** Throws `DF8206` once the session's output stream has closed (after a natural exit or `terminate()`) — drop it with `ctx.terminals.remove(session)` and start a fresh session instead. */
restart: () => Promise<void>
}

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