Skip to content

Commit 3c2e74a

Browse files
authored
feat(hub): expose PTY session results (#321)
1 parent a79bcff commit 3c2e74a

8 files changed

Lines changed: 276 additions & 17 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 terminal 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. `killed` is the portable termination indicator; `signal` is present when the PTY backend reports one.
6565

6666
## Focusing a session
6767

‎docs/content/6.errors/DF8203.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@ directory does not exist, or spawning was denied by the OS.
2121

2222
## Source
2323

24-
- [`packages/hub/src/node/host-terminals.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-terminals.ts) — `DevframeTerminalsHost.startPtySession()` throws this when the initial `zigpty` spawn fails.
24+
- [`packages/hub/src/node/host-terminals.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-terminals.ts) — `DevframeTerminalsHost.startPtySession()` throws this when an initial or restart `zigpty` spawn fails.

‎docs/content/8.references/6.hub-api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ What `DevframeHubContext` adds to `DevframeNodeContext` — [Hub](/guide/hub).
1414
| Subsystem | API | Purpose |
1515
|---|---|---|
1616
| `ctx.docks` | `register / update / values / activate` | Dock entries (iframes, launchers, custom-render) and groups; `activate(dockId, params?)` sets the active dock ([Cross-iframe dock activation](/guide/hub#cross-iframe-dock-activation)). |
17-
| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, streaming output ([Terminals](/add-ons/devframes/terminals#hub-aggregation)). |
17+
| `ctx.terminals` | `register / startChildProcess / startPtySession` | Aggregate terminal sessions, streaming output ([Terminals](/add-ons/devframes/terminals#hub-aggregation)). |
1818
| `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped at 1000). |
1919
| `ctx.commands` | `register / execute / list` | Hierarchical command palette with keybindings and `when` clauses. |
2020

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

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ import { describe, expect, it, vi } from 'vitest'
55
import { hasNative } from 'zigpty'
66
import { DevframeTerminalsHost } from '../host-terminals'
77

8+
const zigptyModuleMock = vi.hoisted(() => ({
9+
spawn: vi.fn(),
10+
}))
11+
12+
vi.mock('zigpty', async (importOriginal) => {
13+
const originalModule = await importOriginal<typeof import('zigpty')>()
14+
zigptyModuleMock.spawn.mockImplementation(originalModule.spawn)
15+
return {
16+
...originalModule,
17+
spawn: zigptyModuleMock.spawn,
18+
}
19+
})
20+
821
const NODE = process.execPath
922
// A real PTY works wherever zigpty's native bindings load (incl. Windows
1023
// ConPTY); skip when they're unavailable.
@@ -418,6 +431,156 @@ describe('devframeTerminalHost interactive PTY sessions', () => {
418431
})
419432
})
420433

434+
itPty('getResult() resolves merged PTY output after natural exit', async () => {
435+
expect.assertions(9)
436+
437+
const { host } = createTerminalHost()
438+
439+
const session = await host.startPtySession({
440+
command: NODE,
441+
args: ['-e', 'process.stdout.write("out"); process.stderr.write("err")'],
442+
}, { id: 'pty-result', title: 'PTY result' })
443+
const result = session.getResult()
444+
445+
expect(result.pid).toBeTypeOf('number')
446+
expect(result.exitCode).toBeUndefined()
447+
expect(result.killed).toBe(false)
448+
449+
const output = await result
450+
expect(output.output).toContain('out')
451+
expect(output.output).toContain('err')
452+
expect(output.exitCode).toBe(0)
453+
expect(output.signal).toBeUndefined()
454+
expect(result.exitCode).toBe(0)
455+
expect(result.killed).toBe(false)
456+
})
457+
458+
itPty('getResult() preserves a non-zero PTY exit code', async () => {
459+
expect.assertions(3)
460+
461+
const { host } = createTerminalHost()
462+
463+
const session = await host.startPtySession({
464+
command: NODE,
465+
args: ['-e', 'process.stdout.write("failed"); process.exit(3)'],
466+
}, { id: 'pty-result-error', title: 'PTY result error' })
467+
const result = session.getResult()
468+
469+
await expect(result).resolves.toMatchObject({
470+
output: expect.stringContaining('failed'),
471+
exitCode: 3,
472+
signal: undefined,
473+
})
474+
expect(result.exitCode).toBe(3)
475+
expect(result.killed).toBe(false)
476+
})
477+
478+
itPty('getResult() marks a terminated PTY run as killed', async () => {
479+
expect.assertions(6)
480+
481+
const { host } = createTerminalHost()
482+
const updates: string[] = []
483+
host.events.on('terminals:session:updated', session => updates.push(session.status))
484+
485+
const session = await host.startPtySession({
486+
command: NODE,
487+
args: ['-e', 'process.stdout.write("started"); setInterval(() => {}, 4000)'],
488+
}, { id: 'pty-result-terminate', title: 'PTY result terminate' })
489+
const result = session.getResult()
490+
await waitUntil(() => {
491+
if (!session.buffer?.join('').includes('started'))
492+
throw new Error('PTY output has not started')
493+
})
494+
495+
await session.terminate()
496+
497+
expect(result.killed).toBe(true)
498+
expect(result.exitCode).toBeUndefined()
499+
await expect(result).resolves.toMatchObject({
500+
output: expect.stringContaining('started'),
501+
exitCode: undefined,
502+
})
503+
if (process.platform === 'win32')
504+
await expect(result).resolves.toHaveProperty('signal', undefined)
505+
else
506+
await expect(result).resolves.toHaveProperty('signal', expect.any(Number))
507+
expect(session.status).toBe('stopped')
508+
expect(updates).not.toContain('error')
509+
})
510+
511+
itPty('getResult() isolates the previous PTY run after restart()', async () => {
512+
expect.assertions(8)
513+
514+
const { host } = createTerminalHost()
515+
516+
const session = await host.startPtySession({
517+
command: NODE,
518+
args: ['-e', 'process.stdout.write("run:" + process.pid); setInterval(() => {}, 4000)'],
519+
}, { id: 'pty-result-restart', title: 'PTY result restart' })
520+
const firstResult = session.getResult()
521+
await waitUntil(() => {
522+
if (!session.buffer?.join('').includes(`run:${firstResult.pid}`))
523+
throw new Error('First PTY run has not started')
524+
})
525+
526+
await session.restart()
527+
const secondResult = session.getResult()
528+
expect(secondResult).not.toBe(firstResult)
529+
expect(secondResult.pid).not.toBe(firstResult.pid)
530+
await waitUntil(() => {
531+
if (!session.buffer?.join('').includes(`run:${secondResult.pid}`))
532+
throw new Error('Second PTY run has not started')
533+
})
534+
535+
await session.terminate()
536+
const [firstOutput, secondOutput] = await Promise.all([firstResult, secondResult])
537+
expect(firstResult.killed).toBe(true)
538+
expect(secondResult.killed).toBe(true)
539+
expect(firstOutput.output).toContain(`run:${firstResult.pid}`)
540+
expect(firstOutput.output).not.toContain(`run:${secondResult.pid}`)
541+
expect(secondOutput.output).toContain(`run:${secondResult.pid}`)
542+
expect(secondOutput.output).not.toContain(`run:${firstResult.pid}`)
543+
})
544+
545+
itPty('allows retry after a structured PTY restart spawn error', async () => {
546+
expect.assertions(9)
547+
548+
const { host } = createTerminalHost()
549+
const session = await host.startPtySession({
550+
command: NODE,
551+
args: ['-e', 'process.stdout.write("started:" + process.pid); setInterval(() => {}, 4000)'],
552+
}, { id: 'pty-result-restart-error', title: 'PTY result restart error' })
553+
const result = session.getResult()
554+
await waitUntil(() => {
555+
if (!session.buffer?.join('').includes(`started:${result.pid}`))
556+
throw new Error('PTY output has not started')
557+
})
558+
zigptyModuleMock.spawn.mockImplementationOnce(() => {
559+
throw new Error('restart spawn failed')
560+
})
561+
562+
await expect(session.restart()).rejects.toThrow(expect.objectContaining({ code: 'DF8203' }))
563+
expect(session.status).toBe('error')
564+
expect(session.getProcessName()).toBeUndefined()
565+
expect(session.getResult()).toBe(result)
566+
567+
await expect(session.restart()).resolves.toBeUndefined()
568+
expect(session.status).toBe('running')
569+
const retryResult = session.getResult()
570+
expect(retryResult).not.toBe(result)
571+
await waitUntil(() => {
572+
if (!session.buffer?.join('').includes(`started:${retryResult.pid}`))
573+
throw new Error('Retried PTY output has not started')
574+
})
575+
expect(session.buffer?.join('')).toContain(`started:${retryResult.pid}`)
576+
await session.terminate()
577+
await expect(result).resolves.toMatchObject({
578+
output: expect.stringContaining(`started:${result.pid}`),
579+
exitCode: undefined,
580+
})
581+
await retryResult
582+
})
583+
421584
itPty('does not accept resize after termination without throwing', async () => {
422585
const { host } = createTerminalHost()
423586

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

Lines changed: 73 additions & 14 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,15 +413,15 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
409413
controller = _controller
410414
},
411415
cancel() {
412-
pty?.kill()
416+
killCurrentRun?.()
413417
pty = undefined
414418
closeStream()
415419
},
416420
})
417421

418422
const spawnPty = (): IPty => {
419423
const currentRun = ++runId
420-
const proc = spawn(executeOptions.command, executeOptions.args ?? [], {
424+
const ptyProcess = spawn(executeOptions.command, executeOptions.args ?? [], {
421425
name: PTY_TERM_NAME,
422426
cols,
423427
rows,
@@ -430,21 +434,64 @@ 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+
ptyProcess.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
})
438-
proc.onExit(({ exitCode, signal }) => {
465+
ptyProcess.onExit(({ exitCode, signal }) => {
466+
settle(exitCode, signal)
439467
if (currentRun !== runId)
440468
return
441469
closeStream()
442-
// A signal kill (terminate()/restart()) is a deliberate stop; a clean
443-
// exit is a deliberate stop too. Only an unsignalled non-zero exit
444-
// code is a crash, matching the child-process comment above.
445-
markStatus(signal === 0 && exitCode !== 0 ? 'error' : 'stopped')
470+
/**
471+
* Killed runs and clean exits are stopped. Only a non-killed non-zero exit
472+
* code is a crash, matching the child-process path.
473+
*/
474+
markStatus(!killed && exitCode !== 0 ? 'error' : 'stopped')
446475
})
447-
return proc
476+
currentResult = {
477+
get pid() {
478+
return ptyProcess.pid
479+
},
480+
get exitCode() {
481+
return killed ? undefined : (ptyProcess.exitCode ?? settledExitCode)
482+
},
483+
get killed() {
484+
return killed
485+
},
486+
then: (onfulfilled, onrejected) => outputPromise.then(onfulfilled, onrejected),
487+
}
488+
killCurrentRun = () => {
489+
if (ptyProcess.exitCode !== null)
490+
return
491+
killed = true
492+
ptyProcess.kill()
493+
}
494+
return ptyProcess
448495
}
449496

450497
try {
@@ -490,17 +537,29 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
490537
return undefined
491538
}
492539
},
540+
getResult: () => currentResult!,
493541
terminate: async () => {
494-
pty?.kill()
542+
killCurrentRun?.()
495543
pty = undefined
496544
closeStream()
497545
markStatus('stopped')
498546
},
499547
restart: async () => {
500548
if (streamClosed)
501549
throw diagnostics.DF8206({ id: terminal.id })
502-
pty?.kill()
503-
pty = spawnPty()
550+
killCurrentRun?.()
551+
killCurrentRun = undefined
552+
pty = undefined
553+
try {
554+
pty = spawnPty()
555+
}
556+
catch (error) {
557+
markStatus('error')
558+
throw diagnostics.DF8203({
559+
command: executeOptions.command,
560+
reason: error instanceof Error ? error.message : String(error),
561+
})
562+
}
504563
markStatus('running')
505564
},
506565
}

‎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>

0 commit comments

Comments
 (0)