diff --git a/docs/errors/DF8206.md b/docs/errors/DF8206.md new file mode 100644 index 00000000..fe2963f5 --- /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 + +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. 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..75211a2f 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. 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/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..3243485b 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()`) — drop it with `ctx.terminals.remove(session)` 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()`) — drop it with `ctx.terminals.remove(session)` and start a fresh session instead. */ restart: () => Promise }