Skip to content

Commit 5ea7f98

Browse files
antfubotopencode
andcommitted
feat(hub): expose getResult() on startChildProcess() terminal sessions
Add a getResult() accessor to DevframeChildProcessTerminalSession that mirrors tinyexec's Result contract — a promise-like handle with live pid/exitCode/killed getters that resolves to { stdout, stderr, exitCode } once the process exits. stdout and stderr are now captured separately (alongside the existing merged display stream) by listening on the raw child process directly, rather than through tinyexec's own async iterator/promise, which would otherwise starve each other reading the same underlying streams. This gives tinyexec/execa-based subprocess-runner APIs (e.g. Nuxt DevTools' startSubprocess().getResult()) a compatible seam to adopt ctx.terminals.startChildProcess() directly. Co-authored-by: opencode <noreply@opencode.ai>
1 parent bdd3aa9 commit 5ea7f98

7 files changed

Lines changed: 188 additions & 16 deletions

File tree

docs/guide/hub.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ A hub-aware node context (`DevframeHubContext`) extends `DevframeNodeContext` wi
1313
| Subsystem | Surface | Purpose |
1414
|---|---|---|
1515
| `ctx.docks` | `register / update / values` | Multi-tool dock entries (iframes, launchers, json-render, custom-render) and groups that collapse them under one button. |
16-
| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. The single source of truth for "what sessions exist" — see [Terminals](/plugins/terminals#hub-aggregation) for how the terminals plugin renders and mirrors into it. |
16+
| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. The single source of truth for "what sessions exist" — see [Terminals](/plugins/terminals#hub-aggregation) for how the terminals plugin renders and mirrors into it. A `startChildProcess()` session's `getResult()` mirrors `tinyexec`'s `Result` (promise-like, plus live `pid` / `exitCode` / `killed`), so subprocess-runner APIs built on `tinyexec`/`execa` can adopt it with little change. |
1717
| `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped at 1000). |
1818
| `ctx.commands` | `register / execute / list` | Hierarchical command palette with keybindings and `when` clauses. |
1919

docs/plugins/terminals.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ Mounted into a hub, the plugin owns PTY/child-process spawning and its own strea
5656

5757
`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.
5858

59+
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.
60+
5961
## Source
6062

6163
[`plugins/terminals`](https://github.com/devframes/devframe/tree/main/plugins/terminals)

packages/hub/src/node/__tests__/host-terminals.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,84 @@ describe('devframeTerminalHost stream lifecycle', () => {
177177
// Stream stays closed; no orphan output stream.
178178
expect(sinks.get('child')?.closed).toBe(true)
179179
})
180+
181+
it('getResult() resolves with separately captured stdout/stderr and the exit code', async () => {
182+
const { host } = createTerminalHost()
183+
184+
const session = await host.startChildProcess({
185+
command: process.execPath,
186+
args: ['-e', 'process.stdout.write("out"); process.stderr.write("err"); process.exit(3)'],
187+
}, {
188+
id: 'child',
189+
title: 'Child',
190+
})
191+
192+
const output = await session.getResult()
193+
expect(output).toEqual({ stdout: 'out', stderr: 'err', exitCode: 3 })
194+
// The merged display stream still carries both, for terminal rendering.
195+
expect(session.buffer?.join('')).toContain('out')
196+
expect(session.buffer?.join('')).toContain('err')
197+
})
198+
199+
it('getResult() exposes live pid/exitCode/killed before and after exit', async () => {
200+
const { host } = createTerminalHost()
201+
202+
const session = await host.startChildProcess({
203+
command: process.execPath,
204+
args: ['-e', 'setTimeout(() => process.exit(0), 50)'],
205+
}, {
206+
id: 'child',
207+
title: 'Child',
208+
})
209+
210+
const result = session.getResult()
211+
expect(result.pid).toBeTypeOf('number')
212+
expect(result.exitCode).toBeUndefined()
213+
expect(result.killed).toBe(false)
214+
215+
await result
216+
expect(result.exitCode).toBe(0)
217+
})
218+
219+
it('getResult() tracks the new run after restart()', async () => {
220+
const { host } = createTerminalHost()
221+
222+
// Long-running, so the stream is still open (and `restart()` allowed)
223+
// by the time we restart it.
224+
const session = await host.startChildProcess({
225+
command: process.execPath,
226+
args: ['-e', 'setInterval(() => {}, 1000)'],
227+
}, {
228+
id: 'child',
229+
title: 'Child',
230+
})
231+
232+
const firstHandle = session.getResult()
233+
expect(firstHandle.exitCode).toBeUndefined()
234+
235+
await session.restart()
236+
const secondHandle = session.getResult()
237+
// A fresh result handle for the fresh run, not the stale first one.
238+
expect(secondHandle).not.toBe(firstHandle)
239+
240+
await session.terminate()
241+
})
242+
243+
it('getResult() still settles with the real exit code after terminate()', async () => {
244+
const { host, sinks } = createTerminalHost()
245+
const session = await host.startChildProcess(
246+
{ command: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'] },
247+
{ id: 'child', title: 'Child' },
248+
)
249+
const result = session.getResult()
250+
await session.terminate()
251+
await waitUntil(() => {
252+
expect(sinks.get('child')?.closed).toBe(true)
253+
})
254+
255+
const output = await result
256+
expect(output.exitCode).toBeUndefined()
257+
})
180258
})
181259

182260
describe('devframeTerminalHost interactive PTY sessions', () => {

packages/hub/src/node/host-terminals.ts

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import type { RpcStreamingChannel } from 'devframe/types'
2+
import type { Buffer } from 'node:buffer'
23
import type { Result as TinyExecResult } from 'tinyexec'
34
import type { IPty } from 'zigpty'
45
import type {
56
DevframeChildProcessExecuteOptions,
7+
DevframeChildProcessOutput,
8+
DevframeChildProcessResult,
69
DevframeChildProcessTerminalSession,
710
DevframePtyExecuteOptions,
811
DevframePtyTerminalSession,
@@ -169,6 +172,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
169172

170173
let controller: ReadableStreamDefaultController<string> | undefined
171174
let cp: TinyExecResult | undefined
175+
let currentResult: DevframeChildProcessResult | undefined
172176
let runId = 0
173177
let streamClosed = false
174178

@@ -225,21 +229,70 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
225229
},
226230
)
227231

228-
;(async () => {
229-
try {
230-
for await (const chunk of cp) {
231-
if (streamClosed || currentRun !== runId)
232-
return
233-
controller?.enqueue(chunk)
234-
}
235-
if (currentRun === runId)
236-
closeStream()
237-
}
238-
catch (error) {
239-
if (currentRun === runId)
240-
errorStream(error)
241-
}
242-
})()
232+
// Capture stdout/stderr separately (for `getResult()`) by listening on
233+
// the raw child process directly, rather than consuming `cp`'s own
234+
// async iterator/promise — those merge stdout+stderr line-by-line and
235+
// would starve one another if both were read from.
236+
const stdoutChunks: string[] = []
237+
const stderrChunks: string[] = []
238+
let settled = false
239+
let resolveOutput!: (output: DevframeChildProcessOutput) => void
240+
const outputPromise = new Promise<DevframeChildProcessOutput>((resolve) => {
241+
resolveOutput = resolve
242+
})
243+
244+
const settle = (exitCode: number | undefined) => {
245+
if (settled || currentRun !== runId)
246+
return
247+
settled = true
248+
resolveOutput({
249+
stdout: stdoutChunks.join(''),
250+
stderr: stderrChunks.join(''),
251+
exitCode,
252+
})
253+
}
254+
255+
cp.process?.stdout?.on('data', (chunk: Buffer | string) => {
256+
if (currentRun !== runId)
257+
return
258+
const text = chunk.toString()
259+
stdoutChunks.push(text)
260+
if (!streamClosed)
261+
controller?.enqueue(text)
262+
})
263+
cp.process?.stderr?.on('data', (chunk: Buffer | string) => {
264+
if (currentRun !== runId)
265+
return
266+
const text = chunk.toString()
267+
stderrChunks.push(text)
268+
if (!streamClosed)
269+
controller?.enqueue(text)
270+
})
271+
cp.process?.once('error', (error) => {
272+
if (currentRun !== runId)
273+
return
274+
settle(cp.process?.exitCode ?? undefined)
275+
errorStream(error)
276+
})
277+
cp.process?.once('close', (code) => {
278+
settle(code ?? undefined)
279+
if (currentRun === runId)
280+
closeStream()
281+
})
282+
283+
currentResult = {
284+
get pid() {
285+
return cp.process?.pid
286+
},
287+
get exitCode() {
288+
return cp.process?.exitCode ?? undefined
289+
},
290+
get killed() {
291+
return cp.process?.killed === true
292+
},
293+
kill: signal => cp.kill(signal),
294+
then: (onfulfilled, onrejected) => outputPromise.then(onfulfilled, onrejected),
295+
}
243296

244297
return cp
245298
}
@@ -265,6 +318,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
265318
type: 'child-process',
266319
executeOptions,
267320
getChildProcess: () => cp?.process,
321+
getResult: () => currentResult!,
268322
terminate,
269323
restart,
270324
}

packages/hub/src/types/terminals.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,44 @@ export interface DevframeChildProcessExecuteOptions {
6464
env?: Record<string, string>
6565
}
6666

67+
/**
68+
* The settled outcome of a {@link DevframeChildProcessTerminalSession} run —
69+
* stdout/stderr captured separately (unlike the session's merged display
70+
* `stream`), plus the process's exit code (`undefined` if it was killed by a
71+
* signal before exiting).
72+
*/
73+
export interface DevframeChildProcessOutput {
74+
stdout: string
75+
stderr: string
76+
exitCode: number | undefined
77+
}
78+
79+
/**
80+
* A live handle on a child process's outcome — mirrors the ergonomics of
81+
* `tinyexec`'s `Result` (a promise-like paired with synchronous accessors) so
82+
* callers migrating from a `tinyexec`/`execa`-based subprocess API (e.g.
83+
* Nuxt DevTools' `startSubprocess().getResult()`) can adopt
84+
* {@link DevframeTerminalsHost.startChildProcess} with minimal changes.
85+
* `await`ing it (or calling `.then()`) resolves once the process exits, with
86+
* the full captured {@link DevframeChildProcessOutput}.
87+
*/
88+
export interface DevframeChildProcessResult extends PromiseLike<DevframeChildProcessOutput> {
89+
readonly pid: number | undefined
90+
/** `undefined` while the process is still running. */
91+
readonly exitCode: number | undefined
92+
readonly killed: boolean
93+
kill: (signal?: NodeJS.Signals | number) => boolean
94+
}
95+
6796
export interface DevframeChildProcessTerminalSession extends DevframeTerminalSession {
6897
type: 'child-process'
6998
executeOptions: DevframeChildProcessExecuteOptions
7099
getChildProcess: () => ChildProcess | undefined
100+
/**
101+
* Get a live handle on the current run's outcome. Reflects the most recent
102+
* `restart()` — call it again after restarting to track the new run.
103+
*/
104+
getResult: () => DevframeChildProcessResult
71105
terminate: () => Promise<void>
72106
restart: () => Promise<void>
73107
}

tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export { ConnectionMeta }
2222
export { CreateHubContextOptions }
2323
export { DevframeCapabilities }
2424
export { DevframeChildProcessExecuteOptions }
25+
export { DevframeChildProcessOutput }
26+
export { DevframeChildProcessResult }
2527
export { DevframeChildProcessTerminalSession }
2628
export { DevframeClientCommand }
2729
export { DevframeCommandBase }

tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export { ConnectionMeta }
88
export { CreateHubContextOptions }
99
export { DevframeCapabilities }
1010
export { DevframeChildProcessExecuteOptions }
11+
export { DevframeChildProcessOutput }
12+
export { DevframeChildProcessResult }
1113
export { DevframeChildProcessTerminalSession }
1214
export { DevframeClientCommand }
1315
export { DevframeCommandBase }

0 commit comments

Comments
 (0)