Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/plugins/terminals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
78 changes: 78 additions & 0 deletions packages/hub/src/node/__tests__/host-terminals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
84 changes: 69 additions & 15 deletions packages/hub/src/node/host-terminals.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -169,6 +172,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {

let controller: ReadableStreamDefaultController<string> | undefined
let cp: TinyExecResult | undefined
let currentResult: DevframeChildProcessResult | undefined
let runId = 0
let streamClosed = false

Expand Down Expand Up @@ -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<DevframeChildProcessOutput>((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
}
Expand All @@ -265,6 +318,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
type: 'child-process',
executeOptions,
getChildProcess: () => cp?.process,
getResult: () => currentResult!,
terminate,
restart,
}
Expand Down
34 changes: 34 additions & 0 deletions packages/hub/src/types/terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,44 @@ export interface DevframeChildProcessExecuteOptions {
env?: Record<string, string>
}

/**
* 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<DevframeChildProcessOutput> {
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<void>
restart: () => Promise<void>
}
Expand Down
2 changes: 2 additions & 0 deletions tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export { ConnectionMeta }
export { CreateHubContextOptions }
export { DevframeCapabilities }
export { DevframeChildProcessExecuteOptions }
export { DevframeChildProcessOutput }
export { DevframeChildProcessResult }
export { DevframeChildProcessTerminalSession }
export { DevframeClientCommand }
export { DevframeCommandBase }
Expand Down
2 changes: 2 additions & 0 deletions tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export { ConnectionMeta }
export { CreateHubContextOptions }
export { DevframeCapabilities }
export { DevframeChildProcessExecuteOptions }
export { DevframeChildProcessOutput }
export { DevframeChildProcessResult }
export { DevframeChildProcessTerminalSession }
export { DevframeClientCommand }
export { DevframeCommandBase }
Expand Down
Loading