@davstack/tui 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +66 -0
  2. package/bin/davstack.mjs +45 -0
  3. package/package.json +33 -0
  4. package/src/App.test.tsx +92 -0
  5. package/src/App.tsx +103 -0
  6. package/src/cli.ts +62 -0
  7. package/src/commands/check.test.ts +160 -0
  8. package/src/commands/check.ts +112 -0
  9. package/src/components/BottomBar.tsx +28 -0
  10. package/src/components/DaemonSupervisor.tsx +50 -0
  11. package/src/components/DescriptorSync.tsx +19 -0
  12. package/src/components/GlobalHotkeys.tsx +30 -0
  13. package/src/components/MainView.tsx +75 -0
  14. package/src/components/QuitConfirm.tsx +20 -0
  15. package/src/components/QuitController.tsx +69 -0
  16. package/src/components/StatusBar.test.tsx +47 -0
  17. package/src/components/StatusBar.tsx +67 -0
  18. package/src/hooks/useConfigDiscovery.ts +56 -0
  19. package/src/hooks/useDaemonProcess.test.ts +415 -0
  20. package/src/hooks/useDaemonProcess.ts +275 -0
  21. package/src/hooks/useHotkeys.test.tsx +508 -0
  22. package/src/hooks/useHotkeys.ts +164 -0
  23. package/src/hooks/useNoColor.test.ts +50 -0
  24. package/src/hooks/useNoColor.ts +35 -0
  25. package/src/hooks/useRingBuffer.test.tsx +86 -0
  26. package/src/hooks/useRingBuffer.ts +46 -0
  27. package/src/lib/config-discovery.test.ts +77 -0
  28. package/src/lib/config-discovery.ts +57 -0
  29. package/src/lib/daemon-registry.ts +143 -0
  30. package/src/lib/global-teardown.test.ts +75 -0
  31. package/src/lib/global-teardown.ts +78 -0
  32. package/src/lib/kill-tree.test.ts +69 -0
  33. package/src/lib/kill-tree.ts +31 -0
  34. package/src/lib/package-info.ts +35 -0
  35. package/src/lib/port-owner.test.ts +105 -0
  36. package/src/lib/port-owner.ts +90 -0
  37. package/src/lib/port-probe.test.ts +41 -0
  38. package/src/lib/port-probe.ts +29 -0
  39. package/src/lib/repo-root.test.ts +36 -0
  40. package/src/lib/repo-root.ts +30 -0
  41. package/src/lib/ring-buffer.test.ts +63 -0
  42. package/src/lib/ring-buffer.ts +47 -0
  43. package/src/state/daemons-context.tsx +149 -0
  44. package/src/state/quit-context.tsx +27 -0
  45. package/src/state/view-context.tsx +32 -0
  46. package/src/views/ServerList.test.tsx +167 -0
  47. package/src/views/ServerList.tsx +109 -0
  48. package/src/views/ServerLogView.tsx +79 -0
@@ -0,0 +1,415 @@
1
+ // Unit tests for useDaemonProcess via ink-testing-library: a small probe
2
+ // component exposes the hook's return value to the test through a ref-like
3
+ // callback so we can assert state transitions without a DOM dependency.
4
+
5
+ import React from "react"
6
+ import { EventEmitter } from "node:events"
7
+ import { PassThrough } from "node:stream"
8
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
9
+ import { render } from "ink-testing-library"
10
+ import { Text } from "ink"
11
+
12
+ import {
13
+ useDaemonProcess,
14
+ type UseDaemonProcessDeps,
15
+ type UseDaemonProcessResult,
16
+ } from "./useDaemonProcess.ts"
17
+ import type { DaemonDescriptor } from "../lib/daemon-registry.ts"
18
+
19
+ type FakeChild = EventEmitter & {
20
+ stdout: PassThrough
21
+ stderr: PassThrough
22
+ pid: number
23
+ kill: (sig?: string) => boolean
24
+ killCalls: string[]
25
+ }
26
+
27
+ let nextPid = 10000
28
+ function makeFakeChild(): FakeChild {
29
+ const ee = new EventEmitter() as FakeChild
30
+ ee.stdout = new PassThrough()
31
+ ee.stderr = new PassThrough()
32
+ ee.pid = nextPid++
33
+ ee.killCalls = []
34
+ ee.kill = (sig?: string) => {
35
+ ee.killCalls.push(sig ?? "SIGTERM")
36
+ return true
37
+ }
38
+ return ee
39
+ }
40
+
41
+ function makeDescriptor(child: FakeChild): DaemonDescriptor {
42
+ return {
43
+ key: "logs",
44
+ label: "logs",
45
+ port: 0,
46
+ readyRegex: /listening on http:\/\//i,
47
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
+ spawn: () => child as any,
49
+ }
50
+ }
51
+
52
+ function makeDeps(overrides: Partial<UseDaemonProcessDeps> = {}): {
53
+ deps: UseDaemonProcessDeps
54
+ killTreeCalls: Array<{ pid: number; signal: string }>
55
+ probeReturns: { value: boolean }
56
+ } {
57
+ const killTreeCalls: Array<{ pid: number; signal: string }> = []
58
+ const probeReturns = { value: false }
59
+ const deps: UseDaemonProcessDeps = {
60
+ killTree: async (pid, signal) => {
61
+ killTreeCalls.push({ pid, signal })
62
+ },
63
+ probePort: async () => probeReturns.value,
64
+ registerChild: () => {},
65
+ unregisterChild: () => {},
66
+ ...overrides,
67
+ }
68
+ return { deps, killTreeCalls, probeReturns }
69
+ }
70
+
71
+ // Probe component: passes the hook result back through `onRender` each render.
72
+ function Probe({
73
+ descriptor,
74
+ deps,
75
+ onRender,
76
+ }: {
77
+ descriptor: DaemonDescriptor
78
+ deps: UseDaemonProcessDeps
79
+ onRender: (r: UseDaemonProcessResult) => void
80
+ }): React.ReactElement {
81
+ const r = useDaemonProcess(descriptor, deps)
82
+ onRender(r)
83
+ return React.createElement(Text, null, r.status)
84
+ }
85
+
86
+ // Flush microtasks so React commits the state update.
87
+ async function tick(): Promise<void> {
88
+ await new Promise<void>((resolve) => setImmediate(resolve))
89
+ }
90
+
91
+ // Two ticks — probePort resolves in a microtask, then doSpawn fires and
92
+ // state settles in a second commit.
93
+ async function tick2(): Promise<void> {
94
+ await tick()
95
+ await tick()
96
+ }
97
+
98
+ describe("useDaemonProcess", () => {
99
+ let captured: UseDaemonProcessResult | null = null
100
+ let active: ReturnType<typeof render> | null = null
101
+
102
+ beforeEach(() => {
103
+ captured = null
104
+ vi.useFakeTimers({ shouldAdvanceTime: true })
105
+ })
106
+
107
+ afterEach(() => {
108
+ active?.unmount()
109
+ active = null
110
+ vi.useRealTimers()
111
+ })
112
+
113
+ function mount(desc: DaemonDescriptor, deps: UseDaemonProcessDeps) {
114
+ active = render(
115
+ React.createElement(Probe, {
116
+ descriptor: desc,
117
+ deps,
118
+ onRender: (r) => {
119
+ captured = r
120
+ },
121
+ }),
122
+ )
123
+ }
124
+
125
+ test("idle -> starting -> running on ready line", async () => {
126
+ const child = makeFakeChild()
127
+ const desc = makeDescriptor(child)
128
+ const { deps } = makeDeps()
129
+ mount(desc, deps)
130
+
131
+ expect(captured!.status).toBe("idle")
132
+
133
+ captured!.start()
134
+ await tick()
135
+ expect(captured!.status).toBe("starting")
136
+ await tick2()
137
+
138
+ child.stdout.write("booting...\n")
139
+ await tick()
140
+ expect(captured!.status).toBe("starting")
141
+
142
+ child.stdout.write("log-server listening on http://127.0.0.1:7077 db=x\n")
143
+ await tick()
144
+ expect(captured!.status).toBe("running")
145
+ expect(captured!.lines.some((l) => l.text.includes("listening on http"))).toBe(true)
146
+ })
147
+
148
+ test("crashes on non-zero exit, captures exit code in state", async () => {
149
+ const child = makeFakeChild()
150
+ const desc = makeDescriptor(child)
151
+ const { deps } = makeDeps()
152
+ mount(desc, deps)
153
+
154
+ captured!.start()
155
+ await tick2()
156
+
157
+ child.emit("exit", 1, null)
158
+ await tick()
159
+
160
+ expect(captured!.status).toBe("crashed")
161
+ expect(captured!.exitCode).toBe(1)
162
+ })
163
+
164
+ test("stop() sends SIGTERM via killTree, escalates to SIGKILL after 2s", async () => {
165
+ const child = makeFakeChild()
166
+ const desc = makeDescriptor(child)
167
+ const { deps, killTreeCalls } = makeDeps()
168
+ mount(desc, deps)
169
+
170
+ captured!.start()
171
+ await tick2()
172
+ child.stdout.write("listening on http://x\n")
173
+ await tick()
174
+ expect(captured!.status).toBe("running")
175
+
176
+ captured!.stop()
177
+ await tick()
178
+ expect(captured!.status).toBe("exiting")
179
+ expect(killTreeCalls.map((c) => c.signal)).toEqual(["SIGTERM"])
180
+ expect(killTreeCalls[0].pid).toBe(child.pid)
181
+
182
+ vi.advanceTimersByTime(2_001)
183
+ expect(killTreeCalls.map((c) => c.signal)).toEqual(["SIGTERM", "SIGKILL"])
184
+
185
+ child.emit("exit", null, "SIGKILL")
186
+ await tick()
187
+ expect(captured!.status).toBe("exited")
188
+ })
189
+
190
+ test("stop() is idempotent", async () => {
191
+ const child = makeFakeChild()
192
+ const desc = makeDescriptor(child)
193
+ const { deps, killTreeCalls } = makeDeps()
194
+ mount(desc, deps)
195
+
196
+ captured!.start()
197
+ await tick2()
198
+ captured!.stop()
199
+ captured!.stop()
200
+ await tick()
201
+ expect(killTreeCalls.length).toBe(1)
202
+ })
203
+
204
+ test("captures stdout and stderr into lines", async () => {
205
+ const child = makeFakeChild()
206
+ const desc = makeDescriptor(child)
207
+ const { deps } = makeDeps()
208
+ mount(desc, deps)
209
+
210
+ captured!.start()
211
+ await tick2()
212
+ child.stdout.write("hello\nworld\n")
213
+ child.stderr.write("warning!\n")
214
+ await tick()
215
+
216
+ const texts = captured!.lines.map((l) => `${l.stream}:${l.text}`)
217
+ expect(texts).toContain("out:hello")
218
+ expect(texts).toContain("out:world")
219
+ expect(texts).toContain("err:warning!")
220
+ })
221
+
222
+ test("port-probe returning true sets status=blocked and skips spawn", async () => {
223
+ const child = makeFakeChild()
224
+ const spawnSpy = vi.fn(() => child as never)
225
+ const desc: DaemonDescriptor = {
226
+ key: "logs",
227
+ label: "logs",
228
+ port: 7077,
229
+ readyRegex: /listening on http:\/\//i,
230
+ spawn: spawnSpy,
231
+ }
232
+ const { deps, probeReturns } = makeDeps()
233
+ probeReturns.value = true
234
+ mount(desc, deps)
235
+
236
+ captured!.start()
237
+ await tick2()
238
+ expect(captured!.status).toBe("blocked")
239
+ expect(spawnSpy).not.toHaveBeenCalled()
240
+ expect(
241
+ captured!.lines.some((l) => /port 7077 already in use/.test(l.text)),
242
+ ).toBe(true)
243
+ })
244
+
245
+ test("takeover() kills external port owner, polls port free, then re-spawns", async () => {
246
+ const child = makeFakeChild()
247
+ const spawnSpy = vi.fn(() => child as never)
248
+ const desc: DaemonDescriptor = {
249
+ key: "logs",
250
+ label: "logs",
251
+ port: 7077,
252
+ readyRegex: /listening on http:\/\//i,
253
+ spawn: spawnSpy,
254
+ }
255
+ let probeCall = 0
256
+ const { deps, killTreeCalls } = makeDeps({
257
+ probePort: async () => {
258
+ probeCall++
259
+ // start preflight → blocked; takeover poll → still busy, then free; start preflight → ok
260
+ return probeCall === 1 || probeCall === 2
261
+ },
262
+ findPortOwner: async () => 9999,
263
+ isSupervisedChild: () => false,
264
+ })
265
+ mount(desc, deps)
266
+
267
+ captured!.start()
268
+ await tick2()
269
+ expect(captured!.status).toBe("blocked")
270
+ expect(spawnSpy).not.toHaveBeenCalled()
271
+
272
+ captured!.takeover()
273
+ await tick()
274
+ await tick()
275
+ vi.advanceTimersByTime(250)
276
+ await tick()
277
+ await tick2()
278
+
279
+ expect(killTreeCalls).toEqual([{ pid: 9999, signal: "SIGKILL" }])
280
+ expect(spawnSpy).toHaveBeenCalledTimes(1)
281
+ expect(captured!.lines.some((l) => /taking over :7077 from PID 9999/.test(l.text))).toBe(true)
282
+
283
+ child.stdout.write("log-server listening on http://127.0.0.1:7077\n")
284
+ await tick()
285
+ expect(captured!.status).toBe("running")
286
+ })
287
+
288
+ test("Windows path routes kill through killTree (no child.kill)", async () => {
289
+ const child = makeFakeChild()
290
+ const desc = makeDescriptor(child)
291
+ const { deps, killTreeCalls } = makeDeps()
292
+ mount(desc, deps)
293
+
294
+ captured!.start()
295
+ await tick2()
296
+ child.stdout.write("listening on http://x\n")
297
+ await tick()
298
+ captured!.stop()
299
+ await tick()
300
+ // We never call child.kill directly anymore — killTree owns it.
301
+ expect(child.killCalls).toEqual([])
302
+ expect(killTreeCalls[0]).toEqual({ pid: child.pid, signal: "SIGTERM" })
303
+ })
304
+
305
+ test("graceful /shutdown: POST succeeds + child exits within timeout, no killTree", async () => {
306
+ const child = makeFakeChild()
307
+ const desc: DaemonDescriptor = {
308
+ key: "vitest",
309
+ label: "vitest",
310
+ port: 0,
311
+ readyRegex: /listening on http:\/\//i,
312
+ shutdownUrl: "http://127.0.0.1:5179/shutdown",
313
+ shutdownTimeoutMs: 500,
314
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
315
+ spawn: () => child as any,
316
+ }
317
+ const fetchCalls: Array<{ url: string; init?: RequestInit }> = []
318
+ const fakeFetch = (url: string | URL | Request, init?: RequestInit) => {
319
+ fetchCalls.push({ url: String(url), init })
320
+ return Promise.resolve(new Response("ok", { status: 200 }))
321
+ }
322
+ const { deps, killTreeCalls } = makeDeps({ fetch: fakeFetch as typeof fetch })
323
+ mount(desc, deps)
324
+
325
+ captured!.start()
326
+ await tick2()
327
+ child.stdout.write("listening on http://x\n")
328
+ await tick()
329
+ expect(captured!.status).toBe("running")
330
+
331
+ captured!.stop()
332
+ await tick()
333
+ expect(captured!.status).toBe("exiting")
334
+
335
+ // Let the fetch microtask settle (it's a resolved Promise).
336
+ await tick()
337
+ await tick()
338
+ expect(fetchCalls).toHaveLength(1)
339
+ expect(fetchCalls[0].url).toBe("http://127.0.0.1:5179/shutdown")
340
+ expect(fetchCalls[0].init?.method).toBe("POST")
341
+
342
+ // Child exits cleanly before the escalation timer fires.
343
+ child.emit("exit", 0, null)
344
+ await tick()
345
+ expect(captured!.status).toBe("exited")
346
+ expect(killTreeCalls).toHaveLength(0)
347
+ })
348
+
349
+ test("graceful /shutdown: fetch rejects → falls back to killTree", async () => {
350
+ const child = makeFakeChild()
351
+ const desc: DaemonDescriptor = {
352
+ key: "vitest",
353
+ label: "vitest",
354
+ port: 0,
355
+ readyRegex: /listening on http:\/\//i,
356
+ shutdownUrl: "http://127.0.0.1:5179/shutdown",
357
+ shutdownTimeoutMs: 500,
358
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
359
+ spawn: () => child as any,
360
+ }
361
+ const fakeFetch = () => Promise.reject(new Error("ECONNREFUSED"))
362
+ const { deps, killTreeCalls } = makeDeps({ fetch: fakeFetch as typeof fetch })
363
+ mount(desc, deps)
364
+
365
+ captured!.start()
366
+ await tick2()
367
+ child.stdout.write("listening on http://x\n")
368
+ await tick()
369
+
370
+ captured!.stop()
371
+ await tick()
372
+ // Fetch rejection microtask flush.
373
+ await tick()
374
+ await tick()
375
+
376
+ // Now advance past the shutdownTimeoutMs to trigger escalate().
377
+ vi.advanceTimersByTime(501)
378
+ await tick()
379
+
380
+ expect(killTreeCalls.map((c) => c.signal)).toEqual(["SIGTERM"])
381
+ expect(killTreeCalls[0].pid).toBe(child.pid)
382
+
383
+ // Confirm the error line landed in the ring buffer.
384
+ expect(
385
+ captured!.lines.some((l) => /\/shutdown.*ECONNREFUSED/i.test(l.text)),
386
+ ).toBe(true)
387
+ })
388
+
389
+ test("POSIX path: real killTree invokes process.kill", async () => {
390
+ const child = makeFakeChild()
391
+ const desc = makeDescriptor(child)
392
+ // Don't override killTree — use the real one via the default import path.
393
+ const { deps } = makeDeps()
394
+ delete (deps as Partial<UseDaemonProcessDeps>).killTree
395
+ // Force the POSIX branch.
396
+ const originalPlatform = process.platform
397
+ Object.defineProperty(process, "platform", { value: "linux", configurable: true })
398
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true)
399
+ try {
400
+ mount(desc, deps)
401
+ captured!.start()
402
+ await tick2()
403
+ child.stdout.write("listening on http://x\n")
404
+ await tick()
405
+ captured!.stop()
406
+ await tick()
407
+ // killTree runs via microtask — wait for it.
408
+ await tick()
409
+ expect(killSpy).toHaveBeenCalledWith(child.pid, "SIGTERM")
410
+ } finally {
411
+ Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true })
412
+ killSpy.mockRestore()
413
+ }
414
+ })
415
+ })
@@ -0,0 +1,275 @@
1
+ // Owns one supervised ChildProcess for one DaemonDescriptor.
2
+ //
3
+ // Lifecycle:
4
+ // idle -> starting -> running (after stdout matches readyRegex)
5
+ // running -> exiting -> exited (after stop() resolves cleanly)
6
+ // idle -> blocked (port-probe found external listener)
7
+ // * -> crashed (exit code non-zero / signal != SIGTERM)
8
+ //
9
+ // Cleanup: on unmount, stop() is invoked. The killTree module handles
10
+ // Windows process-tree teardown so we don't orphan bun grandchildren.
11
+
12
+ import { useCallback, useEffect, useRef, useState } from "react"
13
+ import type { ChildProcess } from "node:child_process"
14
+
15
+ import { useRingBuffer, type LogLine } from "./useRingBuffer.ts"
16
+ import type { DaemonDescriptor } from "../lib/daemon-registry.ts"
17
+ import { killTree as defaultKillTree } from "../lib/kill-tree.ts"
18
+ import { findPortOwner as defaultFindPortOwner } from "../lib/port-owner.ts"
19
+ import { probePort as defaultProbePort } from "../lib/port-probe.ts"
20
+ import {
21
+ isSupervisedChild as defaultIsSupervisedChild,
22
+ registerChild as defaultRegisterChild,
23
+ unregisterChild as defaultUnregisterChild,
24
+ } from "../lib/global-teardown.ts"
25
+
26
+ export type DaemonStatus =
27
+ | "idle"
28
+ | "starting"
29
+ | "running"
30
+ | "exiting"
31
+ | "exited"
32
+ | "crashed"
33
+ | "blocked"
34
+
35
+ export type UseDaemonProcessResult = {
36
+ status: DaemonStatus
37
+ lines: LogLine[]
38
+ start: () => void
39
+ stop: () => void
40
+ takeover: () => void
41
+ clear: () => void
42
+ exitCode: number | null
43
+ }
44
+
45
+ // Injection seam — tests override these so they don't actually shell out
46
+ // or open sockets.
47
+ export type UseDaemonProcessDeps = {
48
+ killTree?: (pid: number, signal: "SIGTERM" | "SIGKILL") => Promise<void>
49
+ findPortOwner?: (host: string, port: number) => Promise<number | null>
50
+ isSupervisedChild?: (pid: number) => boolean
51
+ probePort?: (host: string, port: number, timeoutMs?: number) => Promise<boolean>
52
+ registerChild?: (pid: number) => void
53
+ unregisterChild?: (pid: number) => void
54
+ // Pluggable fetch so tests don't open real sockets. Defaults to global.
55
+ fetch?: typeof globalThis.fetch
56
+ }
57
+
58
+ const KILL_GRACE_MS = 2_000
59
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 1_500
60
+
61
+ // Split incoming chunk into complete lines, buffering the trailing partial
62
+ // line for the next chunk. Returns [completeLines, newBuffer].
63
+ function splitLines(prev: string, chunk: string): [string[], string] {
64
+ const combined = prev + chunk
65
+ const parts = combined.split(/\r?\n/)
66
+ const trailing = parts.pop() ?? ""
67
+ return [parts, trailing]
68
+ }
69
+
70
+ export function useDaemonProcess(
71
+ descriptor: DaemonDescriptor,
72
+ deps: UseDaemonProcessDeps = {},
73
+ ): UseDaemonProcessResult {
74
+ const killTree = deps.killTree ?? defaultKillTree
75
+ const findPortOwner = deps.findPortOwner ?? defaultFindPortOwner
76
+ const isSupervisedChild = deps.isSupervisedChild ?? defaultIsSupervisedChild
77
+ const probePort = deps.probePort ?? defaultProbePort
78
+ const registerChild = deps.registerChild ?? defaultRegisterChild
79
+ const unregisterChild = deps.unregisterChild ?? defaultUnregisterChild
80
+ const doFetch = deps.fetch ?? globalThis.fetch
81
+
82
+ const { lines, push, clear } = useRingBuffer(10_000)
83
+ const [status, setStatus] = useState<DaemonStatus>("idle")
84
+ const [exitCode, setExitCode] = useState<number | null>(null)
85
+ const childRef = useRef<ChildProcess | null>(null)
86
+ const killTimerRef = useRef<NodeJS.Timeout | null>(null)
87
+ const readyRef = useRef(false)
88
+ const stoppingRef = useRef(false)
89
+ const statusRef = useRef(status)
90
+ statusRef.current = status
91
+
92
+ const clearKillTimer = useCallback(() => {
93
+ if (killTimerRef.current) {
94
+ clearTimeout(killTimerRef.current)
95
+ killTimerRef.current = null
96
+ }
97
+ }, [])
98
+
99
+ const start = useCallback(() => {
100
+ if (childRef.current) return
101
+ readyRef.current = false
102
+ stoppingRef.current = false
103
+ setExitCode(null)
104
+ setStatus("starting")
105
+
106
+ const host = descriptor.host ?? "127.0.0.1"
107
+
108
+ const doSpawn = (): void => {
109
+ const child = descriptor.spawn()
110
+ childRef.current = child
111
+ if (typeof child.pid === "number") registerChild(child.pid)
112
+
113
+ let outBuf = ""
114
+ let errBuf = ""
115
+
116
+ const handleLine = (stream: "out" | "err", text: string): void => {
117
+ push({ ts: Date.now(), stream, text })
118
+ if (!readyRef.current && descriptor.readyRegex.test(text)) {
119
+ readyRef.current = true
120
+ setStatus("running")
121
+ }
122
+ }
123
+
124
+ child.stdout?.on("data", (chunk: Buffer) => {
125
+ const [done, rest] = splitLines(outBuf, chunk.toString("utf8"))
126
+ outBuf = rest
127
+ for (const ln of done) handleLine("out", ln)
128
+ })
129
+ child.stderr?.on("data", (chunk: Buffer) => {
130
+ const [done, rest] = splitLines(errBuf, chunk.toString("utf8"))
131
+ errBuf = rest
132
+ for (const ln of done) handleLine("err", ln)
133
+ })
134
+
135
+ child.on("error", (err) => {
136
+ push({ ts: Date.now(), stream: "err", text: `[spawn error] ${err.message}` })
137
+ setStatus("crashed")
138
+ if (typeof child.pid === "number") unregisterChild(child.pid)
139
+ childRef.current = null
140
+ clearKillTimer()
141
+ })
142
+
143
+ child.on("exit", (code, signal) => {
144
+ // Flush any trailing buffered output.
145
+ if (outBuf.length > 0) handleLine("out", outBuf)
146
+ if (errBuf.length > 0) handleLine("err", errBuf)
147
+ outBuf = ""
148
+ errBuf = ""
149
+
150
+ setExitCode(code)
151
+ if (typeof child.pid === "number") unregisterChild(child.pid)
152
+ childRef.current = null
153
+ clearKillTimer()
154
+
155
+ const stopping = stoppingRef.current
156
+ // On Windows, taskkill /F sets code != 0 and signal === null even
157
+ // though we asked for the kill — treat any exit during stop() as
158
+ // intentional ("exited"), only mark "crashed" for unprompted death.
159
+ const clean = code === 0 || stopping
160
+ setStatus(clean ? "exited" : "crashed")
161
+ })
162
+ }
163
+
164
+ // Port preflight — refuse to spawn if something else is listening.
165
+ void (async (): Promise<void> => {
166
+ const inUse = await probePort(host, descriptor.port)
167
+ if (inUse) {
168
+ push({
169
+ ts: Date.now(),
170
+ stream: "err",
171
+ text: `port ${descriptor.port} already in use — refusing to spawn`,
172
+ })
173
+ setStatus("blocked")
174
+ return
175
+ }
176
+ doSpawn()
177
+ })()
178
+ }, [descriptor, push, clearKillTimer, probePort, registerChild, unregisterChild])
179
+
180
+ const stop = useCallback(() => {
181
+ const child = childRef.current
182
+ if (!child) return
183
+ if (stoppingRef.current) return
184
+ stoppingRef.current = true
185
+ setStatus("exiting")
186
+
187
+ const shutdownUrl = descriptor.shutdownUrl
188
+ const shutdownTimeoutMs = descriptor.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS
189
+
190
+ const escalate = (): void => {
191
+ const c = childRef.current
192
+ if (!c) return // child already exited cleanly via /shutdown
193
+ const pid = c.pid
194
+ if (typeof pid === "number") {
195
+ void killTree(pid, "SIGTERM")
196
+ }
197
+ killTimerRef.current = setTimeout(() => {
198
+ const cc = childRef.current
199
+ if (cc && typeof cc.pid === "number") {
200
+ void killTree(cc.pid, "SIGKILL")
201
+ }
202
+ }, KILL_GRACE_MS)
203
+ }
204
+
205
+ if (shutdownUrl) {
206
+ void (async (): Promise<void> => {
207
+ try {
208
+ await doFetch(shutdownUrl, {
209
+ method: "POST",
210
+ signal: AbortSignal.timeout(shutdownTimeoutMs),
211
+ })
212
+ } catch (err) {
213
+ // 404, connection refused, timeout — all fine. We log and fall
214
+ // through to SIGTERM. Don't crash the supervisor on a flaky
215
+ // shutdown endpoint.
216
+ const msg = err instanceof Error ? err.message : String(err)
217
+ push({
218
+ ts: Date.now(),
219
+ stream: "err",
220
+ text: `[shutdown ${descriptor.label}] HTTP /shutdown failed: ${msg}`,
221
+ })
222
+ }
223
+ // Give the child a tick to actually exit gracefully before SIGTERM.
224
+ killTimerRef.current = setTimeout(escalate, shutdownTimeoutMs)
225
+ })()
226
+ return
227
+ }
228
+
229
+ escalate()
230
+ }, [killTree, descriptor.shutdownUrl, descriptor.shutdownTimeoutMs, descriptor.label, doFetch, push])
231
+
232
+ const takeover = useCallback(() => {
233
+ void (async (): Promise<void> => {
234
+ if (statusRef.current !== "blocked") return
235
+ const host = descriptor.host ?? "127.0.0.1"
236
+ const owner = await findPortOwner(host, descriptor.port)
237
+ if (owner == null) {
238
+ push({
239
+ ts: Date.now(),
240
+ stream: "err",
241
+ text: `could not identify owner of :${descriptor.port} — kill it manually`,
242
+ })
243
+ return
244
+ }
245
+ if (isSupervisedChild(owner)) {
246
+ push({
247
+ ts: Date.now(),
248
+ stream: "out",
249
+ text: `port owner PID ${owner} is a supervised child — skipping kill`,
250
+ })
251
+ return
252
+ }
253
+ push({
254
+ ts: Date.now(),
255
+ stream: "out",
256
+ text: `taking over :${descriptor.port} from PID ${owner}`,
257
+ })
258
+ await killTree(owner, "SIGKILL")
259
+ for (let i = 0; i < 8; i++) {
260
+ if (!(await probePort(host, descriptor.port))) break
261
+ await new Promise((r) => setTimeout(r, 250))
262
+ }
263
+ setStatus("idle")
264
+ start()
265
+ })()
266
+ }, [descriptor, findPortOwner, isSupervisedChild, killTree, probePort, push, start])
267
+
268
+ useEffect(() => {
269
+ return () => {
270
+ stop()
271
+ }
272
+ }, [stop])
273
+
274
+ return { status, lines, start, stop, takeover, clear, exitCode }
275
+ }