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

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: 1 addition & 1 deletion docs/content/5.add-ons/1.devframes/6.terminals.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Mounted into a hub, the devframe spawns on its own channel (`devframes:plugin:te

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

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

## Focusing a session

Expand Down
2 changes: 1 addition & 1 deletion docs/content/6.errors/DF8203.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ directory does not exist, or spawning was denied by the OS.

## Source

- [`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.
- [`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.
2 changes: 1 addition & 1 deletion docs/content/8.references/6.hub-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ What `DevframeHubContext` adds to `DevframeNodeContext` — [Hub](/guide/hub).
| Subsystem | API | Purpose |
|---|---|---|
| `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)). |
| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, streaming output ([Terminals](/add-ons/devframes/terminals#hub-aggregation)). |
| `ctx.terminals` | `register / startChildProcess / startPtySession` | Aggregate terminal sessions, streaming output ([Terminals](/add-ons/devframes/terminals#hub-aggregation)). |
| `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped at 1000). |
| `ctx.commands` | `register / execute / list` | Hierarchical command palette with keybindings and `when` clauses. |

Expand Down
163 changes: 163 additions & 0 deletions packages/hub/src/node/__tests__/host-terminals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ import { describe, expect, it, vi } from 'vitest'
import { hasNative } from 'zigpty'
import { DevframeTerminalsHost } from '../host-terminals'

const zigptyModuleMock = vi.hoisted(() => ({
spawn: vi.fn(),
}))

vi.mock('zigpty', async (importOriginal) => {
const originalModule = await importOriginal<typeof import('zigpty')>()
zigptyModuleMock.spawn.mockImplementation(originalModule.spawn)
return {
...originalModule,
spawn: zigptyModuleMock.spawn,
}
})

const NODE = process.execPath
// A real PTY works wherever zigpty's native bindings load (incl. Windows
// ConPTY); skip when they're unavailable.
Expand Down Expand Up @@ -418,6 +431,156 @@ describe('devframeTerminalHost interactive PTY sessions', () => {
})
})

itPty('getResult() resolves merged PTY output after natural exit', async () => {
expect.assertions(9)

const { host } = createTerminalHost()

const session = await host.startPtySession({
command: NODE,
args: ['-e', 'process.stdout.write("out"); process.stderr.write("err")'],
}, { id: 'pty-result', title: 'PTY result' })
const result = session.getResult()

expect(result.pid).toBeTypeOf('number')
expect(result.exitCode).toBeUndefined()
expect(result.killed).toBe(false)

const output = await result
expect(output.output).toContain('out')
expect(output.output).toContain('err')
expect(output.exitCode).toBe(0)
expect(output.signal).toBeUndefined()
expect(result.exitCode).toBe(0)
expect(result.killed).toBe(false)
})

itPty('getResult() preserves a non-zero PTY exit code', async () => {
expect.assertions(3)

const { host } = createTerminalHost()

const session = await host.startPtySession({
command: NODE,
args: ['-e', 'process.stdout.write("failed"); process.exit(3)'],
}, { id: 'pty-result-error', title: 'PTY result error' })
const result = session.getResult()

await expect(result).resolves.toMatchObject({
output: expect.stringContaining('failed'),
exitCode: 3,
signal: undefined,
})
expect(result.exitCode).toBe(3)
expect(result.killed).toBe(false)
})

itPty('getResult() marks a terminated PTY run as killed', async () => {
expect.assertions(6)

const { host } = createTerminalHost()
const updates: string[] = []
host.events.on('terminals:session:updated', session => updates.push(session.status))

const session = await host.startPtySession({
command: NODE,
args: ['-e', 'process.stdout.write("started"); setInterval(() => {}, 4000)'],
}, { id: 'pty-result-terminate', title: 'PTY result terminate' })
const result = session.getResult()
await waitUntil(() => {
if (!session.buffer?.join('').includes('started'))
throw new Error('PTY output has not started')
})

await session.terminate()

expect(result.killed).toBe(true)
expect(result.exitCode).toBeUndefined()
await expect(result).resolves.toMatchObject({
output: expect.stringContaining('started'),
exitCode: undefined,
})
if (process.platform === 'win32')
await expect(result).resolves.toHaveProperty('signal', undefined)
else
await expect(result).resolves.toHaveProperty('signal', expect.any(Number))
expect(session.status).toBe('stopped')
expect(updates).not.toContain('error')
})

itPty('getResult() isolates the previous PTY run after restart()', async () => {
expect.assertions(8)

const { host } = createTerminalHost()

const session = await host.startPtySession({
command: NODE,
args: ['-e', 'process.stdout.write("run:" + process.pid); setInterval(() => {}, 4000)'],
}, { id: 'pty-result-restart', title: 'PTY result restart' })
const firstResult = session.getResult()
await waitUntil(() => {
if (!session.buffer?.join('').includes(`run:${firstResult.pid}`))
throw new Error('First PTY run has not started')
})

await session.restart()
const secondResult = session.getResult()
expect(secondResult).not.toBe(firstResult)
expect(secondResult.pid).not.toBe(firstResult.pid)
await waitUntil(() => {
if (!session.buffer?.join('').includes(`run:${secondResult.pid}`))
throw new Error('Second PTY run has not started')
})

await session.terminate()
const [firstOutput, secondOutput] = await Promise.all([firstResult, secondResult])
expect(firstResult.killed).toBe(true)
expect(secondResult.killed).toBe(true)
expect(firstOutput.output).toContain(`run:${firstResult.pid}`)
expect(firstOutput.output).not.toContain(`run:${secondResult.pid}`)
expect(secondOutput.output).toContain(`run:${secondResult.pid}`)
expect(secondOutput.output).not.toContain(`run:${firstResult.pid}`)
})

itPty('allows retry after a structured PTY restart spawn error', async () => {
expect.assertions(9)

const { host } = createTerminalHost()
const session = await host.startPtySession({
command: NODE,
args: ['-e', 'process.stdout.write("started:" + process.pid); setInterval(() => {}, 4000)'],
}, { id: 'pty-result-restart-error', title: 'PTY result restart error' })
const result = session.getResult()
await waitUntil(() => {
if (!session.buffer?.join('').includes(`started:${result.pid}`))
throw new Error('PTY output has not started')
})
zigptyModuleMock.spawn.mockImplementationOnce(() => {
throw new Error('restart spawn failed')
})

await expect(session.restart()).rejects.toThrow(expect.objectContaining({ code: 'DF8203' }))
expect(session.status).toBe('error')
expect(session.getProcessName()).toBeUndefined()
expect(session.getResult()).toBe(result)

await expect(session.restart()).resolves.toBeUndefined()
expect(session.status).toBe('running')
const retryResult = session.getResult()
expect(retryResult).not.toBe(result)
await waitUntil(() => {
if (!session.buffer?.join('').includes(`started:${retryResult.pid}`))
throw new Error('Retried PTY output has not started')
})
expect(session.buffer?.join('')).toContain(`started:${retryResult.pid}`)
await session.terminate()
await expect(result).resolves.toMatchObject({
output: expect.stringContaining(`started:${result.pid}`),
exitCode: undefined,
})
await retryResult
})

itPty('does not accept resize after termination without throwing', async () => {
const { host } = createTerminalHost()

Expand Down
87 changes: 73 additions & 14 deletions packages/hub/src/node/host-terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
DevframeChildProcessResult,
DevframeChildProcessTerminalSession,
DevframePtyExecuteOptions,
DevframePtyOutput,
DevframePtyResult,
DevframePtyTerminalSession,
DevframeTerminalSession,
DevframeTerminalSessionBase,
Expand Down Expand Up @@ -365,6 +367,8 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {

let controller: ReadableStreamDefaultController<string> | undefined
let pty: IPty | undefined
let currentResult: DevframePtyResult | undefined
let killCurrentRun: (() => void) | undefined
let runId = 0
let streamClosed = false
let session: DevframePtyTerminalSession
Expand Down Expand Up @@ -409,15 +413,15 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
controller = _controller
},
cancel() {
pty?.kill()
killCurrentRun?.()
pty = undefined
closeStream()
},
})

const spawnPty = (): IPty => {
const currentRun = ++runId
const proc = spawn(executeOptions.command, executeOptions.args ?? [], {
const ptyProcess = spawn(executeOptions.command, executeOptions.args ?? [], {
name: PTY_TERM_NAME,
cols,
rows,
Expand All @@ -430,21 +434,64 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
...(executeOptions.env ?? {}),
},
})
proc.onData((data) => {
if (streamClosed || currentRun !== runId)
const outputChunks: string[] = []
let killed = false
let settled = false
let settledExitCode: number | undefined
let resolveOutput!: (output: DevframePtyOutput) => void
const outputPromise = new Promise<DevframePtyOutput>((resolve) => {
resolveOutput = resolve
})

const settle = (exitCode: number, signal: number): void => {
if (settled)
return
controller?.enqueue(typeof data === 'string' ? data : data.toString('utf8'))
settled = true
killed ||= signal !== 0
settledExitCode = killed ? undefined : exitCode
resolveOutput({
output: outputChunks.join(''),
exitCode: settledExitCode,
signal: signal === 0 ? undefined : signal,
})
}

ptyProcess.onData((data) => {
const text = typeof data === 'string' ? data : data.toString('utf8')
outputChunks.push(text)
if (!streamClosed && currentRun === runId)
controller?.enqueue(text)
})
proc.onExit(({ exitCode, signal }) => {
ptyProcess.onExit(({ exitCode, signal }) => {
settle(exitCode, signal)
if (currentRun !== runId)
return
closeStream()
// A signal kill (terminate()/restart()) is a deliberate stop; a clean
// exit is a deliberate stop too. Only an unsignalled non-zero exit
// code is a crash, matching the child-process comment above.
markStatus(signal === 0 && exitCode !== 0 ? 'error' : 'stopped')
/**
* Killed runs and clean exits are stopped. Only a non-killed non-zero exit
* code is a crash, matching the child-process path.
*/
markStatus(!killed && exitCode !== 0 ? 'error' : 'stopped')
})
return proc
currentResult = {
get pid() {
return ptyProcess.pid
},
get exitCode() {
return killed ? undefined : (ptyProcess.exitCode ?? settledExitCode)
},
get killed() {
return killed
},
then: (onfulfilled, onrejected) => outputPromise.then(onfulfilled, onrejected),
}
killCurrentRun = () => {
if (ptyProcess.exitCode !== null)
return
killed = true
ptyProcess.kill()
}
return ptyProcess
}

try {
Expand Down Expand Up @@ -490,17 +537,29 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
return undefined
}
},
getResult: () => currentResult!,
terminate: async () => {
pty?.kill()
killCurrentRun?.()
pty = undefined
closeStream()
markStatus('stopped')
},
restart: async () => {
if (streamClosed)
throw diagnostics.DF8206({ id: terminal.id })
pty?.kill()
pty = spawnPty()
killCurrentRun?.()
killCurrentRun = undefined
pty = undefined
try {
pty = spawnPty()
}
catch (error) {
markStatus('error')
throw diagnostics.DF8203({
command: executeOptions.command,
reason: error instanceof Error ? error.message : String(error),
})
}
markStatus('running')
},
}
Expand Down
24 changes: 24 additions & 0 deletions packages/hub/src/types/terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,25 @@ export interface DevframePtyExecuteOptions {
rows?: number
}

/**
* The settled outcome of a {@link DevframePtyTerminalSession} run. PTYs merge
* stdout and stderr into one terminal output stream, so the captured text is
* exposed as a single `output` value.
*/
export interface DevframePtyOutput {
output: string
exitCode: number | undefined
signal: number | undefined
}

/** A live handle on the current PTY run's merged output and process state. */
export interface DevframePtyResult extends PromiseLike<DevframePtyOutput> {
readonly pid: number | undefined
/** `undefined` while the process is running or after a signal kill. */
readonly exitCode: number | undefined
readonly killed: boolean
}

export interface DevframePtyTerminalSession extends DevframeTerminalSession {
type: 'pty'
interactive: true
Expand All @@ -139,6 +158,11 @@ export interface DevframePtyTerminalSession extends DevframeTerminalSession {
resize: (cols: number, rows: number) => void
/** Current foreground process name, when the backend can resolve it. */
getProcessName: () => string | undefined
/**
* Get a live handle on the current run's outcome. Call it again after
* `restart()` to track the new run.
*/
getResult: () => DevframePtyResult
terminate: () => Promise<void>
/** 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. */
restart: () => Promise<void>
Expand Down
Loading
Loading