From e56dd628f6cb42e477bf2aa1075d247fb1224a11 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Wed, 5 Aug 2026 08:50:32 +0200 Subject: [PATCH 1/2] fix(hub): throw when restarting a session with a closed stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/errors/DF8206.md | 21 +++++++++++++++++++ .../src/node/__tests__/host-terminals.test.ts | 8 +++---- packages/hub/src/node/diagnostics.ts | 4 ++++ packages/hub/src/node/host-terminals.ts | 4 ++-- packages/hub/src/types/terminals.ts | 2 ++ 5 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 docs/errors/DF8206.md diff --git a/docs/errors/DF8206.md b/docs/errors/DF8206.md new file mode 100644 index 00000000..4af1d122 --- /dev/null +++ b/docs/errors/DF8206.md @@ -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 + +`ctx.terminals.remove(session)` the spent session, then spawn a fresh one via `startChildProcess()` / `startPtySession()` with a new 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. diff --git a/packages/hub/src/node/__tests__/host-terminals.test.ts b/packages/hub/src/node/__tests__/host-terminals.test.ts index 8c013bba..26049d58 100644 --- a/packages/hub/src/node/__tests__/host-terminals.test.ts +++ b/packages/hub/src/node/__tests__/host-terminals.test.ts @@ -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)'] }, @@ -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) }) @@ -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') }) }) diff --git a/packages/hub/src/node/diagnostics.ts b/packages/hub/src/node/diagnostics.ts index 839e856c..3d9b363f 100644 --- a/packages/hub/src/node/diagnostics.ts +++ b/packages/hub/src/node/diagnostics.ts @@ -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. `ctx.terminals.remove(session)` then re-`startChildProcess()`/`startPtySession()` with a fresh id instead.', + }, DF8400: { why: (p: { id: string }) => `Command "${p.id}" is already registered`, }, diff --git a/packages/hub/src/node/host-terminals.ts b/packages/hub/src/node/host-terminals.ts index 9f682fc8..abd698d4 100644 --- a/packages/hub/src/node/host-terminals.ts +++ b/packages/hub/src/node/host-terminals.ts @@ -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') @@ -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') diff --git a/packages/hub/src/types/terminals.ts b/packages/hub/src/types/terminals.ts index 55e9d575..ffbe1907 100644 --- a/packages/hub/src/types/terminals.ts +++ b/packages/hub/src/types/terminals.ts @@ -114,6 +114,7 @@ export interface DevframeChildProcessTerminalSession extends DevframeTerminalSes */ getResult: () => DevframeChildProcessResult terminate: () => Promise + /** Throws `DF8206` once the session's output stream has closed (after a natural exit or `terminate()`) — `remove()` it and start a fresh session instead. */ restart: () => Promise } @@ -139,5 +140,6 @@ export interface DevframePtyTerminalSession extends DevframeTerminalSession { /** Current foreground process name, when the backend can resolve it. */ getProcessName: () => string | undefined terminate: () => Promise + /** Throws `DF8206` once the session's output stream has closed (after a natural exit or `terminate()`) — `remove()` it and start a fresh session instead. */ restart: () => Promise } From dedffdb4b566b556081cd5b84367d931fc8ef973 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Wed, 5 Aug 2026 11:45:53 +0200 Subject: [PATCH 2/2] docs(hub): clarify DF8206 recovery steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the wording only, no behaviour change: remove() is a method on ctx.terminals, not on the session, so both restart() JSDoc lines said it wrongly; DF8206's fix text now prefixes the calls with ctx.terminals. the way DF8202/DF8204 do and spells out the two recovery steps instead of contracting them; the docs page's Fix section is reworded as a sentence. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/errors/DF8206.md | 2 +- packages/hub/src/node/diagnostics.ts | 2 +- packages/hub/src/types/terminals.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/errors/DF8206.md b/docs/errors/DF8206.md index 4af1d122..fe2963f5 100644 --- a/docs/errors/DF8206.md +++ b/docs/errors/DF8206.md @@ -14,7 +14,7 @@ outline: deep ## Fix -`ctx.terminals.remove(session)` the spent session, then spawn a fresh one via `startChildProcess()` / `startPtySession()` with a new id. +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 diff --git a/packages/hub/src/node/diagnostics.ts b/packages/hub/src/node/diagnostics.ts index 3d9b363f..75211a2f 100644 --- a/packages/hub/src/node/diagnostics.ts +++ b/packages/hub/src/node/diagnostics.ts @@ -69,7 +69,7 @@ export const diagnostics = defineDiagnostics({ }, 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. `ctx.terminals.remove(session)` then re-`startChildProcess()`/`startPtySession()` with a fresh id instead.', + 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`, diff --git a/packages/hub/src/types/terminals.ts b/packages/hub/src/types/terminals.ts index ffbe1907..3243485b 100644 --- a/packages/hub/src/types/terminals.ts +++ b/packages/hub/src/types/terminals.ts @@ -114,7 +114,7 @@ export interface DevframeChildProcessTerminalSession extends DevframeTerminalSes */ getResult: () => DevframeChildProcessResult terminate: () => Promise - /** Throws `DF8206` once the session's output stream has closed (after a natural exit or `terminate()`) — `remove()` it and start a fresh session instead. */ + /** 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 } @@ -140,6 +140,6 @@ export interface DevframePtyTerminalSession extends DevframeTerminalSession { /** Current foreground process name, when the backend can resolve it. */ getProcessName: () => string | undefined terminate: () => Promise - /** Throws `DF8206` once the session's output stream has closed (after a natural exit or `terminate()`) — `remove()` it and start a fresh session instead. */ + /** 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 }