@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.
- package/README.md +66 -0
- package/bin/davstack.mjs +45 -0
- package/package.json +33 -0
- package/src/App.test.tsx +92 -0
- package/src/App.tsx +103 -0
- package/src/cli.ts +62 -0
- package/src/commands/check.test.ts +160 -0
- package/src/commands/check.ts +112 -0
- package/src/components/BottomBar.tsx +28 -0
- package/src/components/DaemonSupervisor.tsx +50 -0
- package/src/components/DescriptorSync.tsx +19 -0
- package/src/components/GlobalHotkeys.tsx +30 -0
- package/src/components/MainView.tsx +75 -0
- package/src/components/QuitConfirm.tsx +20 -0
- package/src/components/QuitController.tsx +69 -0
- package/src/components/StatusBar.test.tsx +47 -0
- package/src/components/StatusBar.tsx +67 -0
- package/src/hooks/useConfigDiscovery.ts +56 -0
- package/src/hooks/useDaemonProcess.test.ts +415 -0
- package/src/hooks/useDaemonProcess.ts +275 -0
- package/src/hooks/useHotkeys.test.tsx +508 -0
- package/src/hooks/useHotkeys.ts +164 -0
- package/src/hooks/useNoColor.test.ts +50 -0
- package/src/hooks/useNoColor.ts +35 -0
- package/src/hooks/useRingBuffer.test.tsx +86 -0
- package/src/hooks/useRingBuffer.ts +46 -0
- package/src/lib/config-discovery.test.ts +77 -0
- package/src/lib/config-discovery.ts +57 -0
- package/src/lib/daemon-registry.ts +143 -0
- package/src/lib/global-teardown.test.ts +75 -0
- package/src/lib/global-teardown.ts +78 -0
- package/src/lib/kill-tree.test.ts +69 -0
- package/src/lib/kill-tree.ts +31 -0
- package/src/lib/package-info.ts +35 -0
- package/src/lib/port-owner.test.ts +105 -0
- package/src/lib/port-owner.ts +90 -0
- package/src/lib/port-probe.test.ts +41 -0
- package/src/lib/port-probe.ts +29 -0
- package/src/lib/repo-root.test.ts +36 -0
- package/src/lib/repo-root.ts +30 -0
- package/src/lib/ring-buffer.test.ts +63 -0
- package/src/lib/ring-buffer.ts +47 -0
- package/src/state/daemons-context.tsx +149 -0
- package/src/state/quit-context.tsx +27 -0
- package/src/state/view-context.tsx +32 -0
- package/src/views/ServerList.test.tsx +167 -0
- package/src/views/ServerList.tsx +109 -0
- 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
|
+
}
|