From 9a3132c0c28c32cbff7861ae2034f344cfdd6a02 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Mon, 31 Aug 2026 11:51:34 +0200 Subject: [PATCH 1/4] feat(hub): expose PTY session results --- .../5.add-ons/1.devframes/6.terminals.md | 2 +- .../src/node/__tests__/host-terminals.test.ts | 112 ++++++++++++++++++ packages/hub/src/node/host-terminals.ts | 59 ++++++++- packages/hub/src/types/terminals.ts | 24 ++++ .../tsnapi/@devframes/hub/index.snapshot.d.ts | 11 ++ .../tsnapi/@devframes/hub/types.snapshot.d.ts | 2 + 6 files changed, 203 insertions(+), 7 deletions(-) diff --git a/docs/content/5.add-ons/1.devframes/6.terminals.md b/docs/content/5.add-ons/1.devframes/6.terminals.md index 64360cb25..56734911c 100644 --- a/docs/content/5.add-ons/1.devframes/6.terminals.md +++ b/docs/content/5.add-ons/1.devframes/6.terminals.md @@ -61,7 +61,7 @@ Mounted into a hub, the devframe spawns on its own channel (`devframes:plugin:te `ctx.terminals` is the source of truth; the devframe, the sole PTY provider, duck-types a minimal `register` / `update` / `events` shape to run without `@devframes/hub`. -`startChildProcess()` sessions carry a `getResult()` accessor (`tinyexec`'s `Result`: `await`able `{ stdout, stderr, exitCode }`, plus live getters and `kill()`). +Both spawned session types carry a `getResult()` accessor. A `startChildProcess()` result is an `await`able `{ stdout, stderr, exitCode }` with live process getters and `kill()`. A `startPtySession()` result captures its merged terminal stream as an `await`able `{ output, exitCode, signal }` with live `pid`, `exitCode`, and `killed` getters. ## Focusing a session diff --git a/packages/hub/src/node/__tests__/host-terminals.test.ts b/packages/hub/src/node/__tests__/host-terminals.test.ts index b3b2e056c..dd7de36c9 100644 --- a/packages/hub/src/node/__tests__/host-terminals.test.ts +++ b/packages/hub/src/node/__tests__/host-terminals.test.ts @@ -418,6 +418,118 @@ describe('devframeTerminalHost interactive PTY sessions', () => { }) }) + itPty('getResult() resolves merged PTY output after natural exit', async () => { + const { host } = createTerminalHost() + + const session = await host.startPtySession({ + command: NODE, + args: ['-e', 'process.stdout.write("out"); process.stderr.write("err")'], + }, { id: 'pty-result', title: 'PTY result' }) + const result = session.getResult() + + expect(result.pid).toBeTypeOf('number') + expect(result.exitCode).toBeUndefined() + expect(result.killed).toBe(false) + + const output = await result + expect(output.output).toContain('out') + expect(output.output).toContain('err') + expect(output.exitCode).toBe(0) + expect(output.signal).toBeUndefined() + expect(result.exitCode).toBe(0) + expect(result.killed).toBe(false) + }) + + itPty('getResult() preserves a non-zero PTY exit code', async () => { + const { host } = createTerminalHost() + + const session = await host.startPtySession({ + command: NODE, + args: ['-e', 'process.stdout.write("failed"); process.exit(3)'], + }, { id: 'pty-result-error', title: 'PTY result error' }) + const result = session.getResult() + + await expect(result).resolves.toMatchObject({ + output: expect.stringContaining('failed'), + exitCode: 3, + signal: undefined, + }) + expect(result.exitCode).toBe(3) + expect(result.killed).toBe(false) + }) + + itPty('getResult() marks a terminated PTY run as killed', async () => { + const { host } = createTerminalHost() + + const session = await host.startPtySession({ + command: NODE, + args: ['-e', 'process.stdout.write("started"); setInterval(() => {}, 4000)'], + }, { id: 'pty-result-terminate', title: 'PTY result terminate' }) + const result = session.getResult() + await waitUntil(() => { + expect(session.buffer?.join('')).toContain('started') + }) + + await session.terminate() + + expect(result.killed).toBe(true) + expect(result.exitCode).toBeUndefined() + await expect(result).resolves.toMatchObject({ + output: expect.stringContaining('started'), + exitCode: undefined, + signal: expect.any(Number), + }) + }) + + itPty('isolates getResult() output between independently spawned PTY sessions', async () => { + const { host } = createTerminalHost() + + const firstSession = await host.startPtySession({ + command: NODE, + args: ['-e', 'process.stdout.write("first-run")'], + }, { id: 'pty-result-first', title: 'First PTY result' }) + const secondSession = await host.startPtySession({ + command: NODE, + args: ['-e', 'process.stdout.write("second-run")'], + }, { id: 'pty-result-second', title: 'Second PTY result' }) + + const [firstOutput, secondOutput] = await Promise.all([firstSession.getResult(), secondSession.getResult()]) + expect(firstOutput.output).toContain('first-run') + expect(firstOutput.output).not.toContain('second-run') + expect(secondOutput.output).toContain('second-run') + expect(secondOutput.output).not.toContain('first-run') + }) + + itPty('getResult() isolates the previous PTY run after restart()', async () => { + const { host } = createTerminalHost() + + const session = await host.startPtySession({ + command: NODE, + args: ['-e', 'process.stdout.write("run:" + process.pid); setInterval(() => {}, 4000)'], + }, { id: 'pty-result-restart', title: 'PTY result restart' }) + const firstResult = session.getResult() + await waitUntil(() => { + expect(session.buffer?.join('')).toContain(`run:${firstResult.pid}`) + }) + + await session.restart() + const secondResult = session.getResult() + expect(secondResult).not.toBe(firstResult) + expect(secondResult.pid).not.toBe(firstResult.pid) + await waitUntil(() => { + expect(session.buffer?.join('')).toContain(`run:${secondResult.pid}`) + }) + + await session.terminate() + const [firstOutput, secondOutput] = await Promise.all([firstResult, secondResult]) + expect(firstResult.killed).toBe(true) + expect(secondResult.killed).toBe(true) + expect(firstOutput.output).toContain(`run:${firstResult.pid}`) + expect(firstOutput.output).not.toContain(`run:${secondResult.pid}`) + expect(secondOutput.output).toContain(`run:${secondResult.pid}`) + expect(secondOutput.output).not.toContain(`run:${firstResult.pid}`) + }) + itPty('does not accept resize after termination without throwing', async () => { const { host } = createTerminalHost() diff --git a/packages/hub/src/node/host-terminals.ts b/packages/hub/src/node/host-terminals.ts index d1b3b542b..a114ac025 100644 --- a/packages/hub/src/node/host-terminals.ts +++ b/packages/hub/src/node/host-terminals.ts @@ -8,6 +8,8 @@ import type { DevframeChildProcessResult, DevframeChildProcessTerminalSession, DevframePtyExecuteOptions, + DevframePtyOutput, + DevframePtyResult, DevframePtyTerminalSession, DevframeTerminalSession, DevframeTerminalSessionBase, @@ -365,6 +367,8 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { let controller: ReadableStreamDefaultController | undefined let pty: IPty | undefined + let currentResult: DevframePtyResult | undefined + let killCurrentRun: (() => void) | undefined let runId = 0 let streamClosed = false let session: DevframePtyTerminalSession @@ -409,7 +413,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { controller = _controller }, cancel() { - pty?.kill() + killCurrentRun?.() pty = undefined closeStream() }, @@ -430,12 +434,36 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { ...(executeOptions.env ?? {}), }, }) - proc.onData((data) => { - if (streamClosed || currentRun !== runId) + const outputChunks: string[] = [] + let killed = false + let settled = false + let settledExitCode: number | undefined + let resolveOutput!: (output: DevframePtyOutput) => void + const outputPromise = new Promise((resolve) => { + resolveOutput = resolve + }) + + const settle = (exitCode: number, signal: number): void => { + if (settled) return - controller?.enqueue(typeof data === 'string' ? data : data.toString('utf8')) + settled = true + killed ||= signal !== 0 + settledExitCode = killed ? undefined : exitCode + resolveOutput({ + output: outputChunks.join(''), + exitCode: settledExitCode, + signal: signal === 0 ? undefined : signal, + }) + } + + proc.onData((data) => { + const text = typeof data === 'string' ? data : data.toString('utf8') + outputChunks.push(text) + if (!streamClosed && currentRun === runId) + controller?.enqueue(text) }) proc.onExit(({ exitCode, signal }) => { + settle(exitCode, signal) if (currentRun !== runId) return closeStream() @@ -444,6 +472,24 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { // code is a crash, matching the child-process comment above. markStatus(signal === 0 && exitCode !== 0 ? 'error' : 'stopped') }) + currentResult = { + get pid() { + return proc.pid + }, + get exitCode() { + return killed ? undefined : (proc.exitCode ?? settledExitCode) + }, + get killed() { + return killed + }, + then: (onfulfilled, onrejected) => outputPromise.then(onfulfilled, onrejected), + } + killCurrentRun = () => { + if (proc.exitCode !== null) + return + killed = true + proc.kill() + } return proc } @@ -490,8 +536,9 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { return undefined } }, + getResult: () => currentResult!, terminate: async () => { - pty?.kill() + killCurrentRun?.() pty = undefined closeStream() markStatus('stopped') @@ -499,7 +546,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { restart: async () => { if (streamClosed) throw diagnostics.DF8206({ id: terminal.id }) - pty?.kill() + killCurrentRun?.() pty = spawnPty() markStatus('running') }, diff --git a/packages/hub/src/types/terminals.ts b/packages/hub/src/types/terminals.ts index 939634a96..32253498b 100644 --- a/packages/hub/src/types/terminals.ts +++ b/packages/hub/src/types/terminals.ts @@ -129,6 +129,25 @@ export interface DevframePtyExecuteOptions { rows?: number } +/** + * The settled outcome of a {@link DevframePtyTerminalSession} run. PTYs merge + * stdout and stderr into one terminal output stream, so the captured text is + * exposed as a single `output` value. + */ +export interface DevframePtyOutput { + output: string + exitCode: number | undefined + signal: number | undefined +} + +/** A live handle on the current PTY run's merged output and process state. */ +export interface DevframePtyResult extends PromiseLike { + readonly pid: number | undefined + /** `undefined` while the process is running or after a signal kill. */ + readonly exitCode: number | undefined + readonly killed: boolean +} + export interface DevframePtyTerminalSession extends DevframeTerminalSession { type: 'pty' interactive: true @@ -139,6 +158,11 @@ export interface DevframePtyTerminalSession extends DevframeTerminalSession { resize: (cols: number, rows: number) => void /** Current foreground process name, when the backend can resolve it. */ getProcessName: () => string | undefined + /** + * Get a live handle on the current run's outcome. Call it again after + * `restart()` to track the new run. + */ + getResult: () => DevframePtyResult 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 diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 96010d69f..461f0e132 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -229,6 +229,16 @@ export interface DevframePtyExecuteOptions { cols?: number; rows?: number; } +export interface DevframePtyOutput { + output: string; + exitCode: number | undefined; + signal: number | undefined; +} +export interface DevframePtyResult extends PromiseLike { + readonly pid: number | undefined; + readonly exitCode: number | undefined; + readonly killed: boolean; +} export interface DevframePtyTerminalSession extends DevframeTerminalSession { type: 'pty'; interactive: true; @@ -236,6 +246,7 @@ export interface DevframePtyTerminalSession extends DevframeTerminalSession { write: (_: string) => void; resize: (_: number, _: number) => void; getProcessName: () => string | undefined; + getResult: () => DevframePtyResult; terminate: () => Promise; restart: () => Promise; } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index 2eb679650..7ca82ca14 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -52,6 +52,8 @@ export { DevframeMessagesLevelShortcuts } export { DevframeMessagesListDelta } export { DevframeNodeRpcSession } export { DevframePtyExecuteOptions } +export { DevframePtyOutput } +export { DevframePtyResult } export { DevframePtyTerminalSession } export { DevframeRpcClientFunctions } export { DevframeRpcServerFunctions } From 6a26f09260a2cd9a4187dbef44485f4617f08cf2 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Mon, 31 Aug 2026 15:37:15 +0200 Subject: [PATCH 2/4] fix(hub): handle PTY restart failures --- .../5.add-ons/1.devframes/6.terminals.md | 2 +- docs/content/6.errors/DF8203.md | 2 +- docs/content/8.references/6.hub-api.md | 2 +- .../src/node/__tests__/host-terminals.test.ts | 81 +++++++++++++------ packages/hub/src/node/host-terminals.ts | 36 ++++++--- 5 files changed, 85 insertions(+), 38 deletions(-) diff --git a/docs/content/5.add-ons/1.devframes/6.terminals.md b/docs/content/5.add-ons/1.devframes/6.terminals.md index 56734911c..3abe8e5b9 100644 --- a/docs/content/5.add-ons/1.devframes/6.terminals.md +++ b/docs/content/5.add-ons/1.devframes/6.terminals.md @@ -61,7 +61,7 @@ Mounted into a hub, the devframe spawns on its own channel (`devframes:plugin:te `ctx.terminals` is the source of truth; the devframe, the sole PTY provider, duck-types a minimal `register` / `update` / `events` shape to run without `@devframes/hub`. -Both spawned session types carry a `getResult()` accessor. A `startChildProcess()` result is an `await`able `{ stdout, stderr, exitCode }` with live process getters and `kill()`. A `startPtySession()` result captures its merged terminal stream as an `await`able `{ output, exitCode, signal }` with live `pid`, `exitCode`, and `killed` getters. +Both spawned terminal session types carry a `getResult()` accessor. A `startChildProcess()` result is an `await`able `{ stdout, stderr, exitCode }` with live process getters and `kill()`. A `startPtySession()` result captures its merged terminal stream as an `await`able `{ output, exitCode, signal }` with live `pid`, `exitCode`, and `killed` getters. `killed` is the portable termination indicator; `signal` is present when the PTY backend reports one. ## Focusing a session diff --git a/docs/content/6.errors/DF8203.md b/docs/content/6.errors/DF8203.md index 5644c9e2d..19eda723c 100644 --- a/docs/content/6.errors/DF8203.md +++ b/docs/content/6.errors/DF8203.md @@ -21,4 +21,4 @@ directory does not exist, or spawning was denied by the OS. ## Source -- [`packages/hub/src/node/host-terminals.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-terminals.ts) — `DevframeTerminalsHost.startPtySession()` throws this when the initial `zigpty` spawn fails. +- [`packages/hub/src/node/host-terminals.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-terminals.ts) — `DevframeTerminalsHost.startPtySession()` throws this when an initial or restart `zigpty` spawn fails. diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md index abb6972a8..a08b0e2b2 100644 --- a/docs/content/8.references/6.hub-api.md +++ b/docs/content/8.references/6.hub-api.md @@ -14,7 +14,7 @@ What `DevframeHubContext` adds to `DevframeNodeContext` — [Hub](/guide/hub). | Subsystem | API | Purpose | |---|---|---| | `ctx.docks` | `register / update / values / activate` | Dock entries (iframes, launchers, custom-render) and groups; `activate(dockId, params?)` sets the active dock ([Cross-iframe dock activation](/guide/hub#cross-iframe-dock-activation)). | -| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, streaming output ([Terminals](/add-ons/devframes/terminals#hub-aggregation)). | +| `ctx.terminals` | `register / startChildProcess / startPtySession` | Aggregate terminal sessions, streaming output ([Terminals](/add-ons/devframes/terminals#hub-aggregation)). | | `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped at 1000). | | `ctx.commands` | `register / execute / list` | Hierarchical command palette with keybindings and `when` clauses. | diff --git a/packages/hub/src/node/__tests__/host-terminals.test.ts b/packages/hub/src/node/__tests__/host-terminals.test.ts index dd7de36c9..3f29add75 100644 --- a/packages/hub/src/node/__tests__/host-terminals.test.ts +++ b/packages/hub/src/node/__tests__/host-terminals.test.ts @@ -5,6 +5,19 @@ import { describe, expect, it, vi } from 'vitest' import { hasNative } from 'zigpty' import { DevframeTerminalsHost } from '../host-terminals' +const zigptyModuleMock = vi.hoisted(() => ({ + spawn: vi.fn(), +})) + +vi.mock('zigpty', async (importOriginal) => { + const originalModule = await importOriginal() + zigptyModuleMock.spawn.mockImplementation(originalModule.spawn) + return { + ...originalModule, + spawn: zigptyModuleMock.spawn, + } +}) + const NODE = process.execPath // A real PTY works wherever zigpty's native bindings load (incl. Windows // ConPTY); skip when they're unavailable. @@ -419,6 +432,8 @@ describe('devframeTerminalHost interactive PTY sessions', () => { }) itPty('getResult() resolves merged PTY output after natural exit', async () => { + expect.assertions(9) + const { host } = createTerminalHost() const session = await host.startPtySession({ @@ -441,6 +456,8 @@ describe('devframeTerminalHost interactive PTY sessions', () => { }) itPty('getResult() preserves a non-zero PTY exit code', async () => { + expect.assertions(3) + const { host } = createTerminalHost() const session = await host.startPtySession({ @@ -459,6 +476,8 @@ describe('devframeTerminalHost interactive PTY sessions', () => { }) itPty('getResult() marks a terminated PTY run as killed', async () => { + expect.assertions(4) + const { host } = createTerminalHost() const session = await host.startPtySession({ @@ -467,7 +486,8 @@ describe('devframeTerminalHost interactive PTY sessions', () => { }, { id: 'pty-result-terminate', title: 'PTY result terminate' }) const result = session.getResult() await waitUntil(() => { - expect(session.buffer?.join('')).toContain('started') + if (!session.buffer?.join('').includes('started')) + throw new Error('PTY output has not started') }) await session.terminate() @@ -477,30 +497,16 @@ describe('devframeTerminalHost interactive PTY sessions', () => { await expect(result).resolves.toMatchObject({ output: expect.stringContaining('started'), exitCode: undefined, - signal: expect.any(Number), }) - }) - - itPty('isolates getResult() output between independently spawned PTY sessions', async () => { - const { host } = createTerminalHost() - - const firstSession = await host.startPtySession({ - command: NODE, - args: ['-e', 'process.stdout.write("first-run")'], - }, { id: 'pty-result-first', title: 'First PTY result' }) - const secondSession = await host.startPtySession({ - command: NODE, - args: ['-e', 'process.stdout.write("second-run")'], - }, { id: 'pty-result-second', title: 'Second PTY result' }) - - const [firstOutput, secondOutput] = await Promise.all([firstSession.getResult(), secondSession.getResult()]) - expect(firstOutput.output).toContain('first-run') - expect(firstOutput.output).not.toContain('second-run') - expect(secondOutput.output).toContain('second-run') - expect(secondOutput.output).not.toContain('first-run') + if (process.platform === 'win32') + await expect(result).resolves.toHaveProperty('signal', undefined) + else + await expect(result).resolves.toHaveProperty('signal', expect.any(Number)) }) itPty('getResult() isolates the previous PTY run after restart()', async () => { + expect.assertions(8) + const { host } = createTerminalHost() const session = await host.startPtySession({ @@ -509,7 +515,8 @@ describe('devframeTerminalHost interactive PTY sessions', () => { }, { id: 'pty-result-restart', title: 'PTY result restart' }) const firstResult = session.getResult() await waitUntil(() => { - expect(session.buffer?.join('')).toContain(`run:${firstResult.pid}`) + if (!session.buffer?.join('').includes(`run:${firstResult.pid}`)) + throw new Error('First PTY run has not started') }) await session.restart() @@ -517,7 +524,8 @@ describe('devframeTerminalHost interactive PTY sessions', () => { expect(secondResult).not.toBe(firstResult) expect(secondResult.pid).not.toBe(firstResult.pid) await waitUntil(() => { - expect(session.buffer?.join('')).toContain(`run:${secondResult.pid}`) + if (!session.buffer?.join('').includes(`run:${secondResult.pid}`)) + throw new Error('Second PTY run has not started') }) await session.terminate() @@ -530,6 +538,33 @@ describe('devframeTerminalHost interactive PTY sessions', () => { expect(secondOutput.output).not.toContain(`run:${firstResult.pid}`) }) + itPty('reports a structured error when restart fails to spawn a PTY', async () => { + expect.assertions(5) + + const { host } = createTerminalHost() + const session = await host.startPtySession({ + command: NODE, + args: ['-e', 'process.stdout.write("started"); setInterval(() => {}, 4000)'], + }, { id: 'pty-result-restart-error', title: 'PTY result restart error' }) + const result = session.getResult() + await waitUntil(() => { + if (!session.buffer?.join('').includes('started')) + throw new Error('PTY output has not started') + }) + zigptyModuleMock.spawn.mockImplementationOnce(() => { + throw new Error('restart spawn failed') + }) + + await expect(session.restart()).rejects.toThrow(expect.objectContaining({ code: 'DF8203' })) + expect(session.status).toBe('error') + expect(session.getProcessName()).toBeUndefined() + expect(session.getResult()).toBe(result) + await expect(result).resolves.toMatchObject({ + output: expect.stringContaining('started'), + exitCode: undefined, + }) + }) + itPty('does not accept resize after termination without throwing', async () => { const { host } = createTerminalHost() diff --git a/packages/hub/src/node/host-terminals.ts b/packages/hub/src/node/host-terminals.ts index a114ac025..99293c12c 100644 --- a/packages/hub/src/node/host-terminals.ts +++ b/packages/hub/src/node/host-terminals.ts @@ -421,7 +421,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { const spawnPty = (): IPty => { const currentRun = ++runId - const proc = spawn(executeOptions.command, executeOptions.args ?? [], { + const ptyProcess = spawn(executeOptions.command, executeOptions.args ?? [], { name: PTY_TERM_NAME, cols, rows, @@ -456,28 +456,29 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { }) } - proc.onData((data) => { + ptyProcess.onData((data) => { const text = typeof data === 'string' ? data : data.toString('utf8') outputChunks.push(text) if (!streamClosed && currentRun === runId) controller?.enqueue(text) }) - proc.onExit(({ exitCode, signal }) => { + ptyProcess.onExit(({ exitCode, signal }) => { settle(exitCode, signal) if (currentRun !== runId) return closeStream() - // A signal kill (terminate()/restart()) is a deliberate stop; a clean - // exit is a deliberate stop too. Only an unsignalled non-zero exit - // code is a crash, matching the child-process comment above. + /** + * A signal kill (terminate()/restart()) and a clean exit are deliberate stops. + * Only an unsignalled non-zero exit code is a crash, matching the child-process path. + */ markStatus(signal === 0 && exitCode !== 0 ? 'error' : 'stopped') }) currentResult = { get pid() { - return proc.pid + return ptyProcess.pid }, get exitCode() { - return killed ? undefined : (proc.exitCode ?? settledExitCode) + return killed ? undefined : (ptyProcess.exitCode ?? settledExitCode) }, get killed() { return killed @@ -485,12 +486,12 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { then: (onfulfilled, onrejected) => outputPromise.then(onfulfilled, onrejected), } killCurrentRun = () => { - if (proc.exitCode !== null) + if (ptyProcess.exitCode !== null) return killed = true - proc.kill() + ptyProcess.kill() } - return proc + return ptyProcess } try { @@ -547,7 +548,18 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { if (streamClosed) throw diagnostics.DF8206({ id: terminal.id }) killCurrentRun?.() - pty = spawnPty() + pty = undefined + try { + pty = spawnPty() + } + catch (error) { + errorStream(error) + markStatus('error') + throw diagnostics.DF8203({ + command: executeOptions.command, + reason: error instanceof Error ? error.message : String(error), + }) + } markStatus('running') }, } From 90b89f4c43eff7ae013b41a833613761c9c9c5ea Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Mon, 31 Aug 2026 16:55:12 +0200 Subject: [PATCH 3/4] fix(hub): keep PTY restart retryable --- .../src/node/__tests__/host-terminals.test.ts | 22 ++++++++++++++----- packages/hub/src/node/host-terminals.ts | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/hub/src/node/__tests__/host-terminals.test.ts b/packages/hub/src/node/__tests__/host-terminals.test.ts index 3f29add75..7a19f0362 100644 --- a/packages/hub/src/node/__tests__/host-terminals.test.ts +++ b/packages/hub/src/node/__tests__/host-terminals.test.ts @@ -538,17 +538,17 @@ describe('devframeTerminalHost interactive PTY sessions', () => { expect(secondOutput.output).not.toContain(`run:${firstResult.pid}`) }) - itPty('reports a structured error when restart fails to spawn a PTY', async () => { - expect.assertions(5) + itPty('allows retry after a structured PTY restart spawn error', async () => { + expect.assertions(9) const { host } = createTerminalHost() const session = await host.startPtySession({ command: NODE, - args: ['-e', 'process.stdout.write("started"); setInterval(() => {}, 4000)'], + args: ['-e', 'process.stdout.write("started:" + process.pid); setInterval(() => {}, 4000)'], }, { id: 'pty-result-restart-error', title: 'PTY result restart error' }) const result = session.getResult() await waitUntil(() => { - if (!session.buffer?.join('').includes('started')) + if (!session.buffer?.join('').includes(`started:${result.pid}`)) throw new Error('PTY output has not started') }) zigptyModuleMock.spawn.mockImplementationOnce(() => { @@ -559,10 +559,22 @@ describe('devframeTerminalHost interactive PTY sessions', () => { expect(session.status).toBe('error') expect(session.getProcessName()).toBeUndefined() expect(session.getResult()).toBe(result) + + await expect(session.restart()).resolves.toBeUndefined() + expect(session.status).toBe('running') + const retryResult = session.getResult() + expect(retryResult).not.toBe(result) + await waitUntil(() => { + if (!session.buffer?.join('').includes(`started:${retryResult.pid}`)) + throw new Error('Retried PTY output has not started') + }) + expect(session.buffer?.join('')).toContain(`started:${retryResult.pid}`) + await session.terminate() await expect(result).resolves.toMatchObject({ - output: expect.stringContaining('started'), + output: expect.stringContaining(`started:${result.pid}`), exitCode: undefined, }) + await retryResult }) itPty('does not accept resize after termination without throwing', async () => { diff --git a/packages/hub/src/node/host-terminals.ts b/packages/hub/src/node/host-terminals.ts index 99293c12c..030c73efa 100644 --- a/packages/hub/src/node/host-terminals.ts +++ b/packages/hub/src/node/host-terminals.ts @@ -548,12 +548,12 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { if (streamClosed) throw diagnostics.DF8206({ id: terminal.id }) killCurrentRun?.() + killCurrentRun = undefined pty = undefined try { pty = spawnPty() } catch (error) { - errorStream(error) markStatus('error') throw diagnostics.DF8203({ command: executeOptions.command, From 5577db26aa3102c34c42f6eef642cc7a0823fcaf Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Mon, 31 Aug 2026 17:20:01 +0200 Subject: [PATCH 4/4] fix(hub): preserve killed PTY status --- packages/hub/src/node/__tests__/host-terminals.test.ts | 6 +++++- packages/hub/src/node/host-terminals.ts | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/hub/src/node/__tests__/host-terminals.test.ts b/packages/hub/src/node/__tests__/host-terminals.test.ts index 7a19f0362..fad1ee253 100644 --- a/packages/hub/src/node/__tests__/host-terminals.test.ts +++ b/packages/hub/src/node/__tests__/host-terminals.test.ts @@ -476,9 +476,11 @@ describe('devframeTerminalHost interactive PTY sessions', () => { }) itPty('getResult() marks a terminated PTY run as killed', async () => { - expect.assertions(4) + expect.assertions(6) const { host } = createTerminalHost() + const updates: string[] = [] + host.events.on('terminals:session:updated', session => updates.push(session.status)) const session = await host.startPtySession({ command: NODE, @@ -502,6 +504,8 @@ describe('devframeTerminalHost interactive PTY sessions', () => { await expect(result).resolves.toHaveProperty('signal', undefined) else await expect(result).resolves.toHaveProperty('signal', expect.any(Number)) + expect(session.status).toBe('stopped') + expect(updates).not.toContain('error') }) itPty('getResult() isolates the previous PTY run after restart()', async () => { diff --git a/packages/hub/src/node/host-terminals.ts b/packages/hub/src/node/host-terminals.ts index 030c73efa..6dcd47faa 100644 --- a/packages/hub/src/node/host-terminals.ts +++ b/packages/hub/src/node/host-terminals.ts @@ -468,10 +468,10 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { return closeStream() /** - * A signal kill (terminate()/restart()) and a clean exit are deliberate stops. - * Only an unsignalled non-zero exit code is a crash, matching the child-process path. + * Killed runs and clean exits are stopped. Only a non-killed non-zero exit + * code is a crash, matching the child-process path. */ - markStatus(signal === 0 && exitCode !== 0 ? 'error' : 'stopped') + markStatus(!killed && exitCode !== 0 ? 'error' : 'stopped') }) currentResult = { get pid() {