Skip to content

Commit a8f8c50

Browse files
antfubotopencode
andauthored
feat(hub): expose getResult() on startChildProcess() terminal sessions (#90)
Co-authored-by: opencode <noreply@opencode.ai>
1 parent bdd3aa9 commit a8f8c50

6 files changed

Lines changed: 187 additions & 15 deletions

File tree

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)