diff --git a/docs/plugins/terminals.md b/docs/plugins/terminals.md index c39db75..f91e013 100644 --- a/docs/plugins/terminals.md +++ b/docs/plugins/terminals.md @@ -56,6 +56,8 @@ Mounted into a hub, the plugin owns PTY/child-process spawning and its own strea `ctx.terminals` is the source of truth for "what sessions exist"; the plugin is the panel that renders them and the one PTY-capable provider among possibly several session sources. The plugin never imports `@devframes/hub`'s types to stay mountable without a hub — it duck-types the minimal `register` / `update` / `events` shape it needs. +A session from `ctx.terminals.startChildProcess()` carries a `getResult()` accessor shaped like `tinyexec`'s `Result` — `await`able to `{ stdout, stderr, exitCode }` (captured separately from the merged display stream), with live `pid` / `exitCode` / `killed` getters and `kill()` in the meantime. That's the seam for migrating an existing `tinyexec`/`execa`-based "run a subprocess and get its result" API onto the hub's terminals: keep the same calling code, swap the runner for `startChildProcess()`, and the session's output shows up in every hub-aware terminal panel for free. + ## Source [`plugins/terminals`](https://github.com/devframes/devframe/tree/main/plugins/terminals) diff --git a/packages/hub/src/node/__tests__/host-terminals.test.ts b/packages/hub/src/node/__tests__/host-terminals.test.ts index a6b4e17..7ccd870 100644 --- a/packages/hub/src/node/__tests__/host-terminals.test.ts +++ b/packages/hub/src/node/__tests__/host-terminals.test.ts @@ -177,6 +177,84 @@ describe('devframeTerminalHost stream lifecycle', () => { // Stream stays closed; no orphan output stream. expect(sinks.get('child')?.closed).toBe(true) }) + + it('getResult() resolves with separately captured stdout/stderr and the exit code', async () => { + const { host } = createTerminalHost() + + const session = await host.startChildProcess({ + command: process.execPath, + args: ['-e', 'process.stdout.write("out"); process.stderr.write("err"); process.exit(3)'], + }, { + id: 'child', + title: 'Child', + }) + + const output = await session.getResult() + expect(output).toEqual({ stdout: 'out', stderr: 'err', exitCode: 3 }) + // The merged display stream still carries both, for terminal rendering. + expect(session.buffer?.join('')).toContain('out') + expect(session.buffer?.join('')).toContain('err') + }) + + it('getResult() exposes live pid/exitCode/killed before and after exit', async () => { + const { host } = createTerminalHost() + + const session = await host.startChildProcess({ + command: process.execPath, + args: ['-e', 'setTimeout(() => process.exit(0), 50)'], + }, { + id: 'child', + title: 'Child', + }) + + const result = session.getResult() + expect(result.pid).toBeTypeOf('number') + expect(result.exitCode).toBeUndefined() + expect(result.killed).toBe(false) + + await result + expect(result.exitCode).toBe(0) + }) + + it('getResult() tracks the new run after restart()', async () => { + const { host } = createTerminalHost() + + // Long-running, so the stream is still open (and `restart()` allowed) + // by the time we restart it. + const session = await host.startChildProcess({ + command: process.execPath, + args: ['-e', 'setInterval(() => {}, 1000)'], + }, { + id: 'child', + title: 'Child', + }) + + const firstHandle = session.getResult() + expect(firstHandle.exitCode).toBeUndefined() + + await session.restart() + const secondHandle = session.getResult() + // A fresh result handle for the fresh run, not the stale first one. + expect(secondHandle).not.toBe(firstHandle) + + await session.terminate() + }) + + it('getResult() still settles with the real exit code after terminate()', async () => { + const { host, sinks } = createTerminalHost() + const session = await host.startChildProcess( + { command: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'] }, + { id: 'child', title: 'Child' }, + ) + const result = session.getResult() + await session.terminate() + await waitUntil(() => { + expect(sinks.get('child')?.closed).toBe(true) + }) + + const output = await result + expect(output.exitCode).toBeUndefined() + }) }) describe('devframeTerminalHost interactive PTY sessions', () => { diff --git a/packages/hub/src/node/host-terminals.ts b/packages/hub/src/node/host-terminals.ts index 417e697..ed24ff3 100644 --- a/packages/hub/src/node/host-terminals.ts +++ b/packages/hub/src/node/host-terminals.ts @@ -1,8 +1,11 @@ import type { RpcStreamingChannel } from 'devframe/types' +import type { Buffer } from 'node:buffer' import type { Result as TinyExecResult } from 'tinyexec' import type { IPty } from 'zigpty' import type { DevframeChildProcessExecuteOptions, + DevframeChildProcessOutput, + DevframeChildProcessResult, DevframeChildProcessTerminalSession, DevframePtyExecuteOptions, DevframePtyTerminalSession, @@ -169,6 +172,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { let controller: ReadableStreamDefaultController | undefined let cp: TinyExecResult | undefined + let currentResult: DevframeChildProcessResult | undefined let runId = 0 let streamClosed = false @@ -225,21 +229,70 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { }, ) - ;(async () => { - try { - for await (const chunk of cp) { - if (streamClosed || currentRun !== runId) - return - controller?.enqueue(chunk) - } - if (currentRun === runId) - closeStream() - } - catch (error) { - if (currentRun === runId) - errorStream(error) - } - })() + // Capture stdout/stderr separately (for `getResult()`) by listening on + // the raw child process directly, rather than consuming `cp`'s own + // async iterator/promise — those merge stdout+stderr line-by-line and + // would starve one another if both were read from. + const stdoutChunks: string[] = [] + const stderrChunks: string[] = [] + let settled = false + let resolveOutput!: (output: DevframeChildProcessOutput) => void + const outputPromise = new Promise((resolve) => { + resolveOutput = resolve + }) + + const settle = (exitCode: number | undefined) => { + if (settled || currentRun !== runId) + return + settled = true + resolveOutput({ + stdout: stdoutChunks.join(''), + stderr: stderrChunks.join(''), + exitCode, + }) + } + + cp.process?.stdout?.on('data', (chunk: Buffer | string) => { + if (currentRun !== runId) + return + const text = chunk.toString() + stdoutChunks.push(text) + if (!streamClosed) + controller?.enqueue(text) + }) + cp.process?.stderr?.on('data', (chunk: Buffer | string) => { + if (currentRun !== runId) + return + const text = chunk.toString() + stderrChunks.push(text) + if (!streamClosed) + controller?.enqueue(text) + }) + cp.process?.once('error', (error) => { + if (currentRun !== runId) + return + settle(cp.process?.exitCode ?? undefined) + errorStream(error) + }) + cp.process?.once('close', (code) => { + settle(code ?? undefined) + if (currentRun === runId) + closeStream() + }) + + currentResult = { + get pid() { + return cp.process?.pid + }, + get exitCode() { + return cp.process?.exitCode ?? undefined + }, + get killed() { + return cp.process?.killed === true + }, + kill: signal => cp.kill(signal), + then: (onfulfilled, onrejected) => outputPromise.then(onfulfilled, onrejected), + } return cp } @@ -265,6 +318,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { type: 'child-process', executeOptions, getChildProcess: () => cp?.process, + getResult: () => currentResult!, terminate, restart, } diff --git a/packages/hub/src/types/terminals.ts b/packages/hub/src/types/terminals.ts index 1f5c6a7..2e21735 100644 --- a/packages/hub/src/types/terminals.ts +++ b/packages/hub/src/types/terminals.ts @@ -64,10 +64,44 @@ export interface DevframeChildProcessExecuteOptions { env?: Record } +/** + * The settled outcome of a {@link DevframeChildProcessTerminalSession} run — + * stdout/stderr captured separately (unlike the session's merged display + * `stream`), plus the process's exit code (`undefined` if it was killed by a + * signal before exiting). + */ +export interface DevframeChildProcessOutput { + stdout: string + stderr: string + exitCode: number | undefined +} + +/** + * A live handle on a child process's outcome — mirrors the ergonomics of + * `tinyexec`'s `Result` (a promise-like paired with synchronous accessors) so + * callers migrating from a `tinyexec`/`execa`-based subprocess API (e.g. + * Nuxt DevTools' `startSubprocess().getResult()`) can adopt + * {@link DevframeTerminalsHost.startChildProcess} with minimal changes. + * `await`ing it (or calling `.then()`) resolves once the process exits, with + * the full captured {@link DevframeChildProcessOutput}. + */ +export interface DevframeChildProcessResult extends PromiseLike { + readonly pid: number | undefined + /** `undefined` while the process is still running. */ + readonly exitCode: number | undefined + readonly killed: boolean + kill: (signal?: NodeJS.Signals | number) => boolean +} + export interface DevframeChildProcessTerminalSession extends DevframeTerminalSession { type: 'child-process' executeOptions: DevframeChildProcessExecuteOptions getChildProcess: () => ChildProcess | undefined + /** + * Get a live handle on the current run's outcome. Reflects the most recent + * `restart()` — call it again after restarting to track the new run. + */ + getResult: () => DevframeChildProcessResult terminate: () => Promise 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 6230f5d..93cac2b 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -22,6 +22,8 @@ export { ConnectionMeta } export { CreateHubContextOptions } export { DevframeCapabilities } export { DevframeChildProcessExecuteOptions } +export { DevframeChildProcessOutput } +export { DevframeChildProcessResult } export { DevframeChildProcessTerminalSession } export { DevframeClientCommand } export { DevframeCommandBase } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index 752de5c..d832b93 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -8,6 +8,8 @@ export { ConnectionMeta } export { CreateHubContextOptions } export { DevframeCapabilities } export { DevframeChildProcessExecuteOptions } +export { DevframeChildProcessOutput } +export { DevframeChildProcessResult } export { DevframeChildProcessTerminalSession } export { DevframeClientCommand } export { DevframeCommandBase }