Skip to content

Commit 32ef7f2

Browse files
antfubotantfu
andauthored
feat(terminals): kill process without discarding the session, and clear stopped sessions (#119)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 97b65e9 commit 32ef7f2

24 files changed

Lines changed: 648 additions & 19 deletions

File tree

design/design.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ export function card(extra?: string): string {
207207
return cx('flex flex-col rounded-xl border border-base bg-base shadow-sm', extra)
208208
}
209209

210+
export function modalBackdrop(extra?: string): string {
211+
return cx('fixed inset-0 z-modal-backdrop grid place-items-center p-4 bg-black/40 backdrop-blur-sm', extra)
212+
}
213+
214+
export function modalCard(extra?: string): string {
215+
return cx('z-modal-content w-full max-w-sm flex flex-col gap-3 p-4 rounded-xl border border-base bg-base shadow-lg', extra)
216+
}
217+
210218
export function panel(extra?: string): string {
211219
return cx('rounded-lg border border-base bg-base', extra)
212220
}

docs/errors/DF8204.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8204: Terminal Session Cannot Be Controlled
6+
7+
## Message
8+
9+
> Terminal session "`{id}`" cannot be controlled (no lifecycle handle)
10+
11+
## Cause
12+
13+
`hub:terminals:terminate` or `hub:terminals:restart` was called for a session
14+
that carries no lifecycle handle. Sessions added with a bare
15+
`ctx.terminals.register()` are just a descriptor plus an output stream — they
16+
expose no `terminate()` / `restart()`. Only sessions spawned via
17+
`ctx.terminals.startChildProcess()` or `ctx.terminals.startPtySession()` own
18+
their process and can be terminated or restarted.
19+
20+
## Fix
21+
22+
- Spawn the session via `ctx.terminals.startChildProcess(executeOptions, terminal)`
23+
or `ctx.terminals.startPtySession(executeOptions, terminal)` so it owns a
24+
process the hub can control.
25+
- For a bare `register()` session, drive its lifecycle from whatever owns the
26+
underlying process; `hub:terminals:remove` still drops it from the registry.
27+
28+
## Source
29+
30+
- [`packages/hub/src/node/rpc-builtins.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/rpc-builtins.ts) — the `hub:terminals:terminate` / `hub:terminals:restart` handlers throw this when the resolved session has no `terminate` handle.

docs/errors/DF8205.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8205: Terminal Session Is Not Restartable
6+
7+
## Message
8+
9+
> Terminal session "`{id}`" is not restartable
10+
11+
## Cause
12+
13+
`hub:terminals:restart` was called for a session registered with
14+
`restartable: false`. That flag marks a session whose lifecycle is owned
15+
elsewhere — for example a one-shot build, or a server (like code-server) that
16+
should be restarted through its own controls rather than by re-spawning the raw
17+
process. A hub-aware terminal UI hides its restart affordance for these
18+
sessions, and the RPC rejects a direct call.
19+
20+
## Fix
21+
22+
- Restart the session through its owner's controls (the plugin or dock that
23+
started it), not the generic terminal restart.
24+
- If in-place restarts are safe, spawn it with `restartable: true` (the default)
25+
in the `terminal` descriptor passed to `startChildProcess()` /
26+
`startPtySession()`.
27+
28+
## Source
29+
30+
- [`packages/hub/src/node/rpc-builtins.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/rpc-builtins.ts) — the `hub:terminals:restart` handler throws this when the resolved session has `restartable: false`.

examples/minimal-next-devframe-hub/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"description": "Protocol-witness example — a tiny Next.js Devframe Hub built on @devframes/hub that exercises every hub subsystem end-to-end.",
77
"homepage": "https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub",
88
"scripts": {
9-
"dev": "next dev src/client -H 0.0.0.0",
9+
"dev": "pnpm -C ../.. run build && next dev src/client",
1010
"build": "next build src/client",
1111
"test": "vitest run --config vitest.config.ts"
1212
},

examples/minimal-vite-devframe-hub/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"description": "Protocol-witness example — a tiny Vite Devframe Hub built on @devframes/hub that exercises every hub subsystem end-to-end.",
77
"homepage": "https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub",
88
"scripts": {
9-
"dev": "vite",
9+
"dev": "pnpm -C ../.. run build && vite",
1010
"build": "vite build",
1111
"typecheck": "tsc --noEmit"
1212
},

packages/hub/src/node/__tests__/rpc-builtins.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import type { DevframeHubContext } from '../context'
22
import { describe, expect, it, vi } from 'vitest'
3-
import { hubDocksActivate, hubTerminalsResize, hubTerminalsWrite } from '../rpc-builtins'
3+
import {
4+
hubDocksActivate,
5+
hubTerminalsRemove,
6+
hubTerminalsResize,
7+
hubTerminalsRestart,
8+
hubTerminalsTerminate,
9+
hubTerminalsWrite,
10+
} from '../rpc-builtins'
411

512
function contextWithSessions(sessions: Map<string, any>): DevframeHubContext {
613
return { terminals: { sessions } } as unknown as DevframeHubContext
@@ -38,6 +45,80 @@ describe('hub terminal write/resize RPC', () => {
3845
})
3946
})
4047

48+
describe('hub terminal terminate/restart/remove RPC', () => {
49+
it('terminates a controllable (child-process or pty) session', async () => {
50+
const terminate = vi.fn()
51+
const ctx = contextWithSessions(new Map([
52+
['log', { id: 'log', type: 'child-process', terminate }],
53+
]))
54+
const fn = await hubTerminalsTerminate.setup!(ctx)
55+
await fn.handler!('log')
56+
expect(terminate).toHaveBeenCalledOnce()
57+
})
58+
59+
it('restarts a restartable session', async () => {
60+
const restart = vi.fn()
61+
const ctx = contextWithSessions(new Map([
62+
['log', { id: 'log', type: 'child-process', terminate: vi.fn(), restart }],
63+
]))
64+
const fn = await hubTerminalsRestart.setup!(ctx)
65+
await fn.handler!('log')
66+
expect(restart).toHaveBeenCalledOnce()
67+
})
68+
69+
it('rejects restarting a session marked restartable: false', async () => {
70+
const restart = vi.fn()
71+
const ctx = contextWithSessions(new Map([
72+
['svc', { id: 'svc', type: 'child-process', terminate: vi.fn(), restart, restartable: false }],
73+
]))
74+
const fn = await hubTerminalsRestart.setup!(ctx)
75+
await expect(fn.handler!('svc')).rejects.toThrow(/not restartable/i)
76+
expect(restart).not.toHaveBeenCalled()
77+
})
78+
79+
it('rejects controlling a session with no lifecycle handle', async () => {
80+
const ctx = contextWithSessions(new Map([
81+
['bare', { id: 'bare' }],
82+
]))
83+
const fn = await hubTerminalsTerminate.setup!(ctx)
84+
await expect(fn.handler!('bare')).rejects.toThrow(/cannot be controlled/i)
85+
})
86+
87+
it('kills then drops a session on remove', async () => {
88+
const terminate = vi.fn()
89+
const remove = vi.fn()
90+
const session = { id: 'log', type: 'child-process', terminate }
91+
const ctx = {
92+
terminals: { sessions: new Map([['log', session]]), remove },
93+
} as unknown as DevframeHubContext
94+
const fn = await hubTerminalsRemove.setup!(ctx)
95+
await fn.handler!('log')
96+
expect(terminate).toHaveBeenCalledOnce()
97+
expect(remove).toHaveBeenCalledWith(session)
98+
})
99+
100+
it('removes a bare registered session without a terminate handle', async () => {
101+
const remove = vi.fn()
102+
const session = { id: 'mirror' }
103+
const ctx = {
104+
terminals: { sessions: new Map([['mirror', session]]), remove },
105+
} as unknown as DevframeHubContext
106+
const fn = await hubTerminalsRemove.setup!(ctx)
107+
await fn.handler!('mirror')
108+
expect(remove).toHaveBeenCalledWith(session)
109+
})
110+
111+
it('rejects removing an unknown session', async () => {
112+
const remove = vi.fn()
113+
const ctx = {
114+
terminals: { sessions: new Map(), remove },
115+
} as unknown as DevframeHubContext
116+
const fn = await hubTerminalsRemove.setup!(ctx)
117+
await expect(fn.handler!('nope')).rejects.toThrow(/not registered/i)
118+
expect(remove).not.toHaveBeenCalled()
119+
})
120+
})
121+
41122
describe('hub docks activate RPC', () => {
42123
it('forwards dockId and params to docks.activate', async () => {
43124
const activate = vi.fn()

packages/hub/src/node/diagnostics.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ export const diagnostics = defineDiagnostics({
5959
DF8203: {
6060
why: (p: { command: string, reason: string }) => `Failed to spawn PTY session for "${p.command}": ${p.reason}`,
6161
},
62+
DF8204: {
63+
why: (p: { id: string }) => `Terminal session "${p.id}" cannot be controlled (no lifecycle handle)`,
64+
fix: 'Spawn it via ctx.terminals.startChildProcess() or startPtySession() — sessions added with a bare register() expose no terminate/restart handle.',
65+
},
66+
DF8205: {
67+
why: (p: { id: string }) => `Terminal session "${p.id}" is not restartable`,
68+
fix: 'It was registered with `restartable: false`; restart it through its owner\'s controls, or spawn it with `restartable: true` (the default) to allow in-place restarts.',
69+
},
6270
DF8400: {
6371
why: (p: { id: string }) => `Command "${p.id}" is already registered`,
6472
},

packages/hub/src/node/rpc-builtins.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { RpcFunctionDefinitionAny } from 'devframe/rpc'
22
import type { DevframeMessageEntry, DevframeMessageEntryInput } from '../types/messages'
3-
import type { DevframePtyTerminalSession } from '../types/terminals'
3+
import type {
4+
DevframeChildProcessTerminalSession,
5+
DevframePtyTerminalSession,
6+
} from '../types/terminals'
47
import { defineHubRpcFunction } from '../define'
58
import { diagnostics } from './diagnostics'
69

@@ -20,6 +23,26 @@ function resolveInteractiveSession(
2023
return session
2124
}
2225

26+
/** A session exposing lifecycle handles — spawned via startChildProcess/startPtySession. */
27+
type ControllableTerminalSession = DevframeChildProcessTerminalSession | DevframePtyTerminalSession
28+
29+
/**
30+
* Resolve a session that can be terminated/restarted (spawned via
31+
* `startChildProcess` or `startPtySession`), or throw. Sessions added with a
32+
* bare `register()` carry no lifecycle handle and are rejected.
33+
*/
34+
function resolveControllableSession(
35+
sessions: Map<string, { id: string }>,
36+
id: string,
37+
): ControllableTerminalSession {
38+
const session = sessions.get(id) as ControllableTerminalSession | undefined
39+
if (!session)
40+
throw diagnostics.DF8201({ id })
41+
if (typeof session.terminate !== 'function')
42+
throw diagnostics.DF8204({ id })
43+
return session
44+
}
45+
2346
/**
2447
* `hub:commands:execute` — Invoke a registered server command by id. The
2548
* arguments after `id` are forwarded to the command's `handler(...)`.
@@ -119,6 +142,61 @@ export const hubTerminalsResize = defineHubRpcFunction({
119142
}),
120143
})
121144

145+
/**
146+
* `hub:terminals:terminate` — Kill a session's process while keeping the
147+
* session registered (its output/scrollback stays). Works for both read-only
148+
* child-process and interactive PTY sessions, letting a hub-aware terminal UI
149+
* force-kill a session owned by another plugin.
150+
*/
151+
export const hubTerminalsTerminate = defineHubRpcFunction({
152+
name: 'hub:terminals:terminate',
153+
type: 'action',
154+
setup: context => ({
155+
async handler(id: string): Promise<void> {
156+
await resolveControllableSession(context.terminals.sessions, id).terminate()
157+
},
158+
}),
159+
})
160+
161+
/**
162+
* `hub:terminals:restart` — Re-run a session's command in place. Rejected for
163+
* sessions registered with `restartable: false`, whose lifecycle is owned
164+
* elsewhere.
165+
*/
166+
export const hubTerminalsRestart = defineHubRpcFunction({
167+
name: 'hub:terminals:restart',
168+
type: 'action',
169+
setup: context => ({
170+
async handler(id: string): Promise<void> {
171+
const session = resolveControllableSession(context.terminals.sessions, id)
172+
if (session.restartable === false)
173+
throw diagnostics.DF8205({ id })
174+
await session.restart()
175+
},
176+
}),
177+
})
178+
179+
/**
180+
* `hub:terminals:remove` — Kill a session's process (when it still owns one)
181+
* and drop it from the registry, disposing its output stream. Lets a hub-aware
182+
* terminal UI discard a stopped aggregated session.
183+
*/
184+
export const hubTerminalsRemove = defineHubRpcFunction({
185+
name: 'hub:terminals:remove',
186+
type: 'action',
187+
setup: context => ({
188+
async handler(id: string): Promise<void> {
189+
const session = context.terminals.sessions.get(id)
190+
if (!session)
191+
throw diagnostics.DF8201({ id })
192+
const controllable = session as Partial<ControllableTerminalSession>
193+
if (typeof controllable.terminate === 'function')
194+
await controllable.terminate()
195+
context.terminals.remove(session)
196+
},
197+
}),
198+
})
199+
122200
/**
123201
* `hub:docks:activate` — Ask the active viewer to switch its focused dock to
124202
* `dockId`, optionally carrying `params` for the target dock to interpret
@@ -156,4 +234,7 @@ export const builtinHubRpcDeclarations: readonly RpcFunctionDefinitionAny[] = [
156234
hubMessagesClear,
157235
hubTerminalsWrite,
158236
hubTerminalsResize,
237+
hubTerminalsTerminate,
238+
hubTerminalsRestart,
239+
hubTerminalsRemove,
159240
]

packages/hub/src/types/terminals.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export interface DevframeTerminalsHost {
1010

1111
register: (session: DevframeTerminalSession) => DevframeTerminalSession
1212
update: (session: DevframeTerminalSession) => void
13+
/** Drop a session from the registry, disposing its bound output stream. */
14+
remove: (session: DevframeTerminalSession) => void
1315

1416
/**
1517
* Spawn a read-only child process (pipe-backed, output only). Use this for
@@ -50,6 +52,15 @@ export interface DevframeTerminalSessionBase {
5052
* decide whether to enable stdin and wire resize.
5153
*/
5254
interactive?: boolean
55+
/**
56+
* Whether the session may be restarted in place (re-running its command).
57+
* Defaults to `true`. Set `false` for sessions whose lifecycle is owned
58+
* elsewhere — e.g. a one-shot build, or a server (like code-server) that
59+
* should be restarted through its own controls rather than by re-spawning
60+
* the raw process. A hub-aware terminal UI hides its restart affordance for
61+
* these, and `hub:terminals:restart` rejects them.
62+
*/
63+
restartable?: boolean
5364
}
5465

5566
export interface DevframeTerminalSession extends DevframeTerminalSessionBase {

plugins/code-server/src/node/supervisor.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ interface HubTerminalsBridge {
6565
remove?: (session: { id: string }) => void
6666
startChildProcess: (
6767
executeOptions: { command: string, args: string[], cwd?: string, env?: Record<string, string> },
68-
terminal: { id: string, title: string, description?: string, icon?: string },
68+
terminal: { id: string, title: string, description?: string, icon?: string, restartable?: boolean },
6969
) => Promise<HubChildProcessSession>
7070
}
7171

@@ -557,6 +557,10 @@ export class CodeServerSupervisor {
557557
title: TERMINAL_SESSION_TITLE,
558558
description: folder,
559559
icon: TERMINAL_SESSION_ICON,
560+
// Restarting the editor means re-running the supervisor's start
561+
// flow (fresh port + secret), not re-spawning this raw process, so
562+
// hide the terminal panel's generic restart for this session.
563+
restartable: false,
560564
},
561565
)
562566
const child = session.getChildProcess()

0 commit comments

Comments
 (0)