Skip to content

Commit 9a3132c

Browse files
committed
feat(hub): expose PTY session results
1 parent a55f3d5 commit 9a3132c

6 files changed

Lines changed: 203 additions & 7 deletions

File tree

‎docs/content/5.add-ons/1.devframes/6.terminals.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ Mounted into a hub, the devframe spawns on its own channel (`devframes:plugin:te
6161

6262
`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`.
6363

64-
`startChildProcess()` sessions carry a `getResult()` accessor (`tinyexec`'s `Result`: `await`able `{ stdout, stderr, exitCode }`, plus live getters and `kill()`).
64+
Both spawned 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.
6565

6666
## Focusing a session
6767

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

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,118 @@ describe('devframeTerminalHost interactive PTY sessions', () => {
418418
})
419419
})
420420

421+
itPty('getResult() resolves merged PTY output after natural exit', async () => {
422+
const { host } = createTerminalHost()
423+
424+
const session = await host.startPtySession({
425+
command: NODE,
426+
args: ['-e', 'process.stdout.write("out"); process.stderr.write("err")'],
427+
}, { id: 'pty-result', title: 'PTY result' })
428+
const result = session.getResult()
429+
430+
expect(result.pid).toBeTypeOf('number')
431+
expect(result.exitCode).toBeUndefined()
432+
expect(result.killed).toBe(false)
433+
434+
const output = await result
435+
expect(output.output).toContain('out')
436+
expect(output.output).toContain('err')
437+
expect(output.exitCode).toBe(0)
438+
expect(output.signal).toBeUndefined()
439+
expect(result.exitCode).toBe(0)
440+
expect(result.killed).toBe(false)
441+
})
442+
443+
itPty('getResult() preserves a non-zero PTY exit code', async () => {
444+
const { host } = createTerminalHost()
445+
446+
const session = await host.startPtySession({
447+
command: NODE,
448+
args: ['-e', 'process.stdout.write("failed"); process.exit(3)'],
449+
}, { id: 'pty-result-error', title: 'PTY result error' })
450+
const result = session.getResult()
451+
452+
await expect(result).resolves.toMatchObject({
453+
output: expect.stringContaining('failed'),
454+
exitCode: 3,
455+
signal: undefined,
456+
})
457+
expect(result.exitCode).toBe(3)
458+
expect(result.killed).toBe(false)
459+
})
460+
461+
itPty('getResult() marks a terminated PTY run as killed', async () => {
462+
const { host } = createTerminalHost()
463+
464+
const session = await host.startPtySession({
465+
command: NODE,
466+
args: ['-e', 'process.stdout.write("started"); setInterval(() => {}, 4000)'],
467+
}, { id: 'pty-result-terminate', title: 'PTY result terminate' })
468+
const result = session.getResult()
469+
await waitUntil(() => {
470+
expect(session.buffer?.join('')).toContain('started')
471+
})
472+
473+
await session.terminate()
474+
475+
expect(result.killed).toBe(true)
476+
expect(result.exitCode).toBeUndefined()
477+
await expect(result).resolves.toMatchObject({
478+
output: expect.stringContaining('started'),
479+
exitCode: undefined,
480+
signal: expect.any(Number),
481+
})
482+
})
483+
484+
itPty('isolates getResult() output between independently spawned PTY sessions', async () => {
485+
const { host } = createTerminalHost()
486+
487+
const firstSession = await host.startPtySession({
488+
command: NODE,
489+
args: ['-e', 'process.stdout.write("first-run")'],
490+
}, { id: 'pty-result-first', title: 'First PTY result' })
491+
const secondSession = await host.startPtySession({
492+
command: NODE,
493+
args: ['-e', 'process.stdout.write("second-run")'],
494+
}, { id: 'pty-result-second', title: 'Second PTY result' })
495+
496+
const [firstOutput, secondOutput] = await Promise.all([firstSession.getResult(), secondSession.getResult()])
497+
expect(firstOutput.output).toContain('first-run')
498+
expect(firstOutput.output).not.toContain('second-run')
499+
expect(secondOutput.output).toContain('second-run')
500+
expect(secondOutput.output).not.toContain('first-run')
501+
})
502+
503+
itPty('getResult() isolates the previous PTY run after restart()', async () => {
504+
const { host } = createTerminalHost()
505+
506+
const session = await host.startPtySession({
507+
command: NODE,
508+
args: ['-e', 'process.stdout.write("run:" + process.pid); setInterval(() => {}, 4000)'],
509+
}, { id: 'pty-result-restart', title: 'PTY result restart' })
510+
const firstResult = session.getResult()
511+
await waitUntil(() => {
512+
expect(session.buffer?.join('')).toContain(`run:${firstResult.pid}`)
513+
})
514+
515+
await session.restart()
516+
const secondResult = session.getResult()
517+
expect(secondResult).not.toBe(firstResult)
518+
expect(secondResult.pid).not.toBe(firstResult.pid)
519+
await waitUntil(() => {
520+
expect(session.buffer?.join('')).toContain(`run:${secondResult.pid}`)
521+
})
522+
523+
await session.terminate()
524+
const [firstOutput, secondOutput] = await Promise.all([firstResult, secondResult])
525+
expect(firstResult.killed).toBe(true)
526+
expect(secondResult.killed).toBe(true)
527+
expect(firstOutput.output).toContain(`run:${firstResult.pid}`)
528+
expect(firstOutput.output).not.toContain(`run:${secondResult.pid}`)
529+
expect(secondOutput.output).toContain(`run:${secondResult.pid}`)
530+
expect(secondOutput.output).not.toContain(`run:${firstResult.pid}`)
531+
})
532+
421533
itPty('does not accept resize after termination without throwing', async () => {
422534
const { host } = createTerminalHost()
423535

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

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type {
88
DevframeChildProcessResult,
99
DevframeChildProcessTerminalSession,
1010
DevframePtyExecuteOptions,
11+
DevframePtyOutput,
12+
DevframePtyResult,
1113
DevframePtyTerminalSession,
1214
DevframeTerminalSession,
1315
DevframeTerminalSessionBase,
@@ -365,6 +367,8 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
365367

366368
let controller: ReadableStreamDefaultController<string> | undefined
367369
let pty: IPty | undefined
370+
let currentResult: DevframePtyResult | undefined
371+
let killCurrentRun: (() => void) | undefined
368372
let runId = 0
369373
let streamClosed = false
370374
let session: DevframePtyTerminalSession
@@ -409,7 +413,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
409413
controller = _controller
410414
},
411415
cancel() {
412-
pty?.kill()
416+
killCurrentRun?.()
413417
pty = undefined
414418
closeStream()
415419
},
@@ -430,12 +434,36 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
430434
...(executeOptions.env ?? {}),
431435
},
432436
})
433-
proc.onData((data) => {
434-
if (streamClosed || currentRun !== runId)
437+
const outputChunks: string[] = []
438+
let killed = false
439+
let settled = false
440+
let settledExitCode: number | undefined
441+
let resolveOutput!: (output: DevframePtyOutput) => void
442+
const outputPromise = new Promise<DevframePtyOutput>((resolve) => {
443+
resolveOutput = resolve
444+
})
445+
446+
const settle = (exitCode: number, signal: number): void => {
447+
if (settled)
435448
return
436-
controller?.enqueue(typeof data === 'string' ? data : data.toString('utf8'))
449+
settled = true
450+
killed ||= signal !== 0
451+
settledExitCode = killed ? undefined : exitCode
452+
resolveOutput({
453+
output: outputChunks.join(''),
454+
exitCode: settledExitCode,
455+
signal: signal === 0 ? undefined : signal,
456+
})
457+
}
458+
459+
proc.onData((data) => {
460+
const text = typeof data === 'string' ? data : data.toString('utf8')
461+
outputChunks.push(text)
462+
if (!streamClosed && currentRun === runId)
463+
controller?.enqueue(text)
437464
})
438465
proc.onExit(({ exitCode, signal }) => {
466+
settle(exitCode, signal)
439467
if (currentRun !== runId)
440468
return
441469
closeStream()
@@ -444,6 +472,24 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
444472
// code is a crash, matching the child-process comment above.
445473
markStatus(signal === 0 && exitCode !== 0 ? 'error' : 'stopped')
446474
})
475+
currentResult = {
476+
get pid() {
477+
return proc.pid
478+
},
479+
get exitCode() {
480+
return killed ? undefined : (proc.exitCode ?? settledExitCode)
481+
},
482+
get killed() {
483+
return killed
484+
},
485+
then: (onfulfilled, onrejected) => outputPromise.then(onfulfilled, onrejected),
486+
}
487+
killCurrentRun = () => {
488+
if (proc.exitCode !== null)
489+
return
490+
killed = true
491+
proc.kill()
492+
}
447493
return proc
448494
}
449495

@@ -490,16 +536,17 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
490536
return undefined
491537
}
492538
},
539+
getResult: () => currentResult!,
493540
terminate: async () => {
494-
pty?.kill()
541+
killCurrentRun?.()
495542
pty = undefined
496543
closeStream()
497544
markStatus('stopped')
498545
},
499546
restart: async () => {
500547
if (streamClosed)
501548
throw diagnostics.DF8206({ id: terminal.id })
502-
pty?.kill()
549+
killCurrentRun?.()
503550
pty = spawnPty()
504551
markStatus('running')
505552
},

‎packages/hub/src/types/terminals.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,25 @@ export interface DevframePtyExecuteOptions {
129129
rows?: number
130130
}
131131

132+
/**
133+
* The settled outcome of a {@link DevframePtyTerminalSession} run. PTYs merge
134+
* stdout and stderr into one terminal output stream, so the captured text is
135+
* exposed as a single `output` value.
136+
*/
137+
export interface DevframePtyOutput {
138+
output: string
139+
exitCode: number | undefined
140+
signal: number | undefined
141+
}
142+
143+
/** A live handle on the current PTY run's merged output and process state. */
144+
export interface DevframePtyResult extends PromiseLike<DevframePtyOutput> {
145+
readonly pid: number | undefined
146+
/** `undefined` while the process is running or after a signal kill. */
147+
readonly exitCode: number | undefined
148+
readonly killed: boolean
149+
}
150+
132151
export interface DevframePtyTerminalSession extends DevframeTerminalSession {
133152
type: 'pty'
134153
interactive: true
@@ -139,6 +158,11 @@ export interface DevframePtyTerminalSession extends DevframeTerminalSession {
139158
resize: (cols: number, rows: number) => void
140159
/** Current foreground process name, when the backend can resolve it. */
141160
getProcessName: () => string | undefined
161+
/**
162+
* Get a live handle on the current run's outcome. Call it again after
163+
* `restart()` to track the new run.
164+
*/
165+
getResult: () => DevframePtyResult
142166
terminate: () => Promise<void>
143167
/** 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. */
144168
restart: () => Promise<void>

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,13 +229,24 @@ export interface DevframePtyExecuteOptions {
229229
cols?: number;
230230
rows?: number;
231231
}
232+
export interface DevframePtyOutput {
233+
output: string;
234+
exitCode: number | undefined;
235+
signal: number | undefined;
236+
}
237+
export interface DevframePtyResult extends PromiseLike<DevframePtyOutput> {
238+
readonly pid: number | undefined;
239+
readonly exitCode: number | undefined;
240+
readonly killed: boolean;
241+
}
232242
export interface DevframePtyTerminalSession extends DevframeTerminalSession {
233243
type: 'pty';
234244
interactive: true;
235245
executeOptions: DevframePtyExecuteOptions;
236246
write: (_: string) => void;
237247
resize: (_: number, _: number) => void;
238248
getProcessName: () => string | undefined;
249+
getResult: () => DevframePtyResult;
239250
terminate: () => Promise<void>;
240251
restart: () => Promise<void>;
241252
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export { DevframeMessagesLevelShortcuts }
5252
export { DevframeMessagesListDelta }
5353
export { DevframeNodeRpcSession }
5454
export { DevframePtyExecuteOptions }
55+
export { DevframePtyOutput }
56+
export { DevframePtyResult }
5557
export { DevframePtyTerminalSession }
5658
export { DevframeRpcClientFunctions }
5759
export { DevframeRpcServerFunctions }

0 commit comments

Comments
 (0)