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 64360cb2..3abe8e5b 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 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 5644c9e2..19eda723 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 abb6972a..a08b0e2b 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 b3b2e056..fad1ee25 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. @@ -418,6 +431,156 @@ 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({ + 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 () => { + expect.assertions(3) + + 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 () => { + 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, + args: ['-e', 'process.stdout.write("started"); setInterval(() => {}, 4000)'], + }, { id: 'pty-result-terminate', title: 'PTY result terminate' }) + const result = session.getResult() + await waitUntil(() => { + if (!session.buffer?.join('').includes('started')) + throw new Error('PTY output has not started') + }) + + await session.terminate() + + expect(result.killed).toBe(true) + expect(result.exitCode).toBeUndefined() + await expect(result).resolves.toMatchObject({ + output: expect.stringContaining('started'), + exitCode: undefined, + }) + if (process.platform === 'win32') + 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 () => { + expect.assertions(8) + + 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(() => { + if (!session.buffer?.join('').includes(`run:${firstResult.pid}`)) + throw new Error('First PTY run has not started') + }) + + await session.restart() + const secondResult = session.getResult() + expect(secondResult).not.toBe(firstResult) + expect(secondResult.pid).not.toBe(firstResult.pid) + await waitUntil(() => { + if (!session.buffer?.join('').includes(`run:${secondResult.pid}`)) + throw new Error('Second PTY run has not started') + }) + + 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('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:" + 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:${result.pid}`)) + 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(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:${result.pid}`), + exitCode: undefined, + }) + await retryResult + }) + 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 d1b3b542..6dcd47fa 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() }, @@ -417,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, @@ -430,21 +434,64 @@ 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, + }) + } + + 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. - markStatus(signal === 0 && exitCode !== 0 ? 'error' : 'stopped') + /** + * Killed runs and clean exits are stopped. Only a non-killed non-zero exit + * code is a crash, matching the child-process path. + */ + markStatus(!killed && exitCode !== 0 ? 'error' : 'stopped') }) - return proc + currentResult = { + get pid() { + return ptyProcess.pid + }, + get exitCode() { + return killed ? undefined : (ptyProcess.exitCode ?? settledExitCode) + }, + get killed() { + return killed + }, + then: (onfulfilled, onrejected) => outputPromise.then(onfulfilled, onrejected), + } + killCurrentRun = () => { + if (ptyProcess.exitCode !== null) + return + killed = true + ptyProcess.kill() + } + return ptyProcess } try { @@ -490,8 +537,9 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { return undefined } }, + getResult: () => currentResult!, terminate: async () => { - pty?.kill() + killCurrentRun?.() pty = undefined closeStream() markStatus('stopped') @@ -499,8 +547,19 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { restart: async () => { if (streamClosed) throw diagnostics.DF8206({ id: terminal.id }) - pty?.kill() - pty = spawnPty() + killCurrentRun?.() + killCurrentRun = undefined + pty = undefined + try { + pty = spawnPty() + } + catch (error) { + markStatus('error') + throw diagnostics.DF8203({ + command: executeOptions.command, + reason: error instanceof Error ? error.message : String(error), + }) + } markStatus('running') }, } diff --git a/packages/hub/src/types/terminals.ts b/packages/hub/src/types/terminals.ts index 939634a9..32253498 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 96010d69..461f0e13 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 2eb67965..7ca82ca1 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 }