@dimina-kit/devkit 0.1.2-dev.20260703101348 → 0.1.2

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 (33) hide show
  1. package/README.md +16 -0
  2. package/dist/compile-worker-adopt.test.d.ts +2 -0
  3. package/dist/compile-worker-adopt.test.d.ts.map +1 -0
  4. package/dist/compile-worker-adopt.test.js +187 -0
  5. package/dist/compile-worker-entry-warmpool.test.d.ts +2 -0
  6. package/dist/compile-worker-entry-warmpool.test.d.ts.map +1 -0
  7. package/dist/compile-worker-entry-warmpool.test.js +110 -0
  8. package/dist/compile-worker-entry.d.ts +35 -5
  9. package/dist/compile-worker-entry.d.ts.map +1 -1
  10. package/dist/compile-worker-entry.js +45 -1
  11. package/dist/compile-worker-standby-hardening.test.d.ts +2 -0
  12. package/dist/compile-worker-standby-hardening.test.d.ts.map +1 -0
  13. package/dist/compile-worker-standby-hardening.test.js +212 -0
  14. package/dist/compile-worker-standby-integration.test.d.ts +2 -0
  15. package/dist/compile-worker-standby-integration.test.d.ts.map +1 -0
  16. package/dist/compile-worker-standby-integration.test.js +243 -0
  17. package/dist/compile-worker-standby.d.ts +60 -0
  18. package/dist/compile-worker-standby.d.ts.map +1 -0
  19. package/dist/compile-worker-standby.js +283 -0
  20. package/dist/compile-worker-standby.test.d.ts +2 -0
  21. package/dist/compile-worker-standby.test.d.ts.map +1 -0
  22. package/dist/compile-worker-standby.test.js +356 -0
  23. package/dist/compile-worker.d.ts +53 -0
  24. package/dist/compile-worker.d.ts.map +1 -1
  25. package/dist/compile-worker.js +30 -11
  26. package/dist/index.d.ts +12 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +53 -4
  29. package/fe/dimina-fe-container/assets/index.js +8 -8
  30. package/fe/dimina-fe-container/assets/pageFrame.js +3 -3
  31. package/fe/dimina-fe-container/assets/service.js +2 -2
  32. package/fe/dimina-fe-container/dimina-version.json +2 -2
  33. package/package.json +3 -3
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Warm-standby manager for the forked compile worker.
3
+ *
4
+ * A host (devtools) keeps ONE project-agnostic spare compile worker forked and
5
+ * prewarmed while no project is open; `openProject` adopts it so the first
6
+ * compile skips fork + compiler import + toolchain load. The spare never
7
+ * chdirs and never builds (see compile-worker-entry's prewarm contract), so it
8
+ * can be handed to ANY next-opened project.
9
+ *
10
+ * The manager is a PURE ACCELERATOR — every failure mode degrades to "no
11
+ * spare" and the caller's normal cold-fork path:
12
+ * - adopt() health-checks the spare (ping/pong with a timeout) and kills a
13
+ * wedged one rather than handing it over.
14
+ * - an unexpected spare death auto-refills, but through a circuit breaker:
15
+ * `breakerMaxDeaths` deaths inside `breakerWindowMs` trip the manager into
16
+ * 'degraded' — permanently cold, no fork storm on a broken install.
17
+ * - dispose() (host quit) kills the spare and is terminal; ensure()/adopt()
18
+ * afterwards are no-ops, so a teardown racing a refill cannot leak a fork.
19
+ *
20
+ * State machine: empty → (ensure) warming → ready → (adopt) empty; an
21
+ * unexpected death keeps the refill obligation ('warming' with the respawn
22
+ * scheduled after a short delay); 'degraded' and 'disposed' are terminal.
23
+ */
24
+ import { fork } from 'node:child_process';
25
+ import { resolveWorkerEntry, workerExecArgv } from './compile-worker.js';
26
+ // Delay between an unexpected spare death and the refill fork. Long enough
27
+ // that an adopt() racing the death observes "obligation but no spare" (and
28
+ // reports a failed hand-off) instead of adopting a just-born replacement the
29
+ // caller never warmed; short enough that the refill is imperceptible.
30
+ const REFILL_DELAY_MS = 500;
31
+ export function createCompileWorkerStandby(opts = {}) {
32
+ const { onEvent, breakerWindowMs = 30_000, breakerMaxDeaths = 3, healthCheckTimeoutMs = 1000, disposeGraceMs = 5000, } = opts;
33
+ let state = 'empty';
34
+ let spare = null;
35
+ // The manager's own listeners on the current spare — removed on hand-off /
36
+ // deliberate kill so only UNEXPECTED deaths flow into the breaker.
37
+ let detachSpareListeners = null;
38
+ let refillTimer = null;
39
+ let deathTimestamps = [];
40
+ let msgSeq = 0;
41
+ function emit(ev) {
42
+ try {
43
+ onEvent?.(ev);
44
+ }
45
+ catch {
46
+ // telemetry must never break the manager
47
+ }
48
+ }
49
+ function cancelRefill() {
50
+ if (refillTimer) {
51
+ clearTimeout(refillTimer);
52
+ refillTimer = null;
53
+ }
54
+ }
55
+ /** Take the spare out of the manager without killing it (hand-off / deliberate kill). */
56
+ function releaseSpare(child) {
57
+ if (spare !== child)
58
+ return;
59
+ detachSpareListeners?.();
60
+ detachSpareListeners = null;
61
+ spare = null;
62
+ }
63
+ /**
64
+ * Single death-accounting authority: emits `died`, feeds the breaker, and
65
+ * either trips 'degraded' or keeps the refill obligation ('warming' with the
66
+ * respawn scheduled after REFILL_DELAY_MS). Both an unexpected spare death
67
+ * and a synchronous fork failure flow through here — one breaker, one refill
68
+ * policy.
69
+ */
70
+ function recordDeathAndScheduleRefill(pid, reason) {
71
+ emit({ type: 'died', pid, reason });
72
+ const now = Date.now();
73
+ deathTimestamps = deathTimestamps.filter(t => now - t < breakerWindowMs);
74
+ deathTimestamps.push(now);
75
+ if (deathTimestamps.length >= breakerMaxDeaths) {
76
+ state = 'degraded';
77
+ cancelRefill();
78
+ emit({
79
+ type: 'degraded',
80
+ reason: `${deathTimestamps.length} spare deaths within ${breakerWindowMs}ms — standby disabled for this session`,
81
+ });
82
+ return;
83
+ }
84
+ state = 'warming';
85
+ cancelRefill();
86
+ refillTimer = setTimeout(() => {
87
+ refillTimer = null;
88
+ if (state !== 'warming' || spare !== null)
89
+ return;
90
+ spawnSpare();
91
+ }, REFILL_DELAY_MS);
92
+ refillTimer.unref?.();
93
+ }
94
+ function onSpareDeath(child, reason) {
95
+ if (spare !== child)
96
+ return;
97
+ releaseSpare(child);
98
+ if (state === 'disposed' || state === 'degraded')
99
+ return;
100
+ recordDeathAndScheduleRefill(child.pid, reason);
101
+ }
102
+ function spawnSpare() {
103
+ const entry = opts.entry ?? resolveWorkerEntry();
104
+ let child;
105
+ try {
106
+ // Same fork shape as compile-worker's own spawnWorker — piped stdio,
107
+ // explicit execArgv — so the adopter can't tell the difference.
108
+ child = fork(entry, [], { silent: true, execArgv: workerExecArgv(entry) });
109
+ }
110
+ catch (err) {
111
+ // A synchronous fork failure is a death for breaker purposes: a broken
112
+ // install must trip 'degraded', not retry forever.
113
+ recordDeathAndScheduleRefill(undefined, `spare fork failed: ${err instanceof Error ? err.message : String(err)}`);
114
+ return;
115
+ }
116
+ spare = child;
117
+ state = 'warming';
118
+ emit({ type: 'spawned', pid: child.pid });
119
+ const onExit = () => onSpareDeath(child, 'spare exited');
120
+ const onError = (err) => onSpareDeath(child, `spare errored: ${err.message}`);
121
+ const onMessage = (msg) => {
122
+ if (spare !== child)
123
+ return;
124
+ const reply = msg;
125
+ if (reply && reply.type === 'prewarm-result' && reply.id === prewarmId) {
126
+ if (reply.ok) {
127
+ if (state === 'warming') {
128
+ state = 'ready';
129
+ emit({ type: 'prewarmed', pid: child.pid });
130
+ }
131
+ }
132
+ else {
133
+ // A spare that cannot prewarm is useless — kill it deliberately
134
+ // and let the death path decide between refill and breaker.
135
+ const failed = child;
136
+ try {
137
+ failed.kill('SIGKILL');
138
+ }
139
+ catch {
140
+ // already dying
141
+ }
142
+ }
143
+ }
144
+ };
145
+ child.on('exit', onExit);
146
+ child.on('error', onError);
147
+ child.on('message', onMessage);
148
+ detachSpareListeners = () => {
149
+ child.off('exit', onExit);
150
+ child.off('error', onError);
151
+ child.off('message', onMessage);
152
+ };
153
+ const prewarmId = `standby-prewarm-${++msgSeq}`;
154
+ try {
155
+ child.send({ cmd: 'prewarm', id: prewarmId });
156
+ }
157
+ catch {
158
+ // channel already gone — the exit listener owns this death
159
+ }
160
+ }
161
+ /** ping→pong round-trip with a deadline; false on timeout, death or a closed channel. */
162
+ function pingCheck(child) {
163
+ return new Promise((resolve) => {
164
+ if (child.exitCode !== null || child.signalCode !== null || !child.connected) {
165
+ resolve(false);
166
+ return;
167
+ }
168
+ const id = `standby-ping-${++msgSeq}`;
169
+ const onMsg = (msg) => {
170
+ const reply = msg;
171
+ if (reply && reply.type === 'pong' && reply.id === id)
172
+ settle(true);
173
+ };
174
+ const onGone = () => settle(false);
175
+ const timer = setTimeout(() => settle(false), healthCheckTimeoutMs);
176
+ timer.unref?.();
177
+ const settle = (ok) => {
178
+ clearTimeout(timer);
179
+ child.off('message', onMsg);
180
+ child.off('exit', onGone);
181
+ child.off('error', onGone);
182
+ resolve(ok);
183
+ };
184
+ child.on('message', onMsg);
185
+ child.on('exit', onGone);
186
+ child.on('error', onGone);
187
+ try {
188
+ child.send({ cmd: 'ping', id });
189
+ }
190
+ catch {
191
+ settle(false);
192
+ }
193
+ });
194
+ }
195
+ return {
196
+ get state() {
197
+ return state;
198
+ },
199
+ ensure() {
200
+ if (state !== 'empty')
201
+ return;
202
+ spawnSpare();
203
+ },
204
+ async adopt() {
205
+ if (state !== 'ready' && state !== 'warming')
206
+ return null;
207
+ const child = spare;
208
+ if (!child) {
209
+ // The manager owes a spare (a refill is pending after a death) but
210
+ // has nothing healthy to hand over right now.
211
+ emit({ type: 'health-check-failed', reason: 'spare died before adoption' });
212
+ return null;
213
+ }
214
+ // Only a spare whose prewarm has COMPLETED is proven usable. A still-warming
215
+ // spare stays with the manager — its prewarm-result (including a failure,
216
+ // which the manager must kill and account for) needs the manager's own
217
+ // listener. The caller simply cold-forks this once.
218
+ if (state !== 'ready')
219
+ return null;
220
+ const healthy = await pingCheck(child);
221
+ if (spare !== child) {
222
+ // Died during the check — the death path already took over.
223
+ emit({ type: 'health-check-failed', pid: child.pid, reason: 'spare died during health check' });
224
+ return null;
225
+ }
226
+ if (!healthy) {
227
+ emit({ type: 'health-check-failed', pid: child.pid, reason: 'no pong within the health-check window' });
228
+ // Deliberate kill: detach FIRST so this never counts as an
229
+ // unexpected death (no refill, no breaker hit). The caller is about
230
+ // to cold-fork; it re-arms the standby explicitly via ensure().
231
+ releaseSpare(child);
232
+ state = 'empty';
233
+ try {
234
+ child.kill('SIGKILL');
235
+ }
236
+ catch {
237
+ // already gone
238
+ }
239
+ return null;
240
+ }
241
+ releaseSpare(child);
242
+ cancelRefill();
243
+ state = 'empty';
244
+ emit({ type: 'adopted', pid: child.pid });
245
+ return child;
246
+ },
247
+ async dispose() {
248
+ if (state === 'disposed')
249
+ return;
250
+ state = 'disposed';
251
+ cancelRefill();
252
+ const child = spare;
253
+ if (child)
254
+ releaseSpare(child);
255
+ if (!child || child.exitCode !== null || child.signalCode !== null)
256
+ return;
257
+ await new Promise((resolve) => {
258
+ const done = () => resolve();
259
+ child.once('exit', done);
260
+ child.once('error', done);
261
+ // Bounded AND final: a spare that ignores the polite kill for the whole
262
+ // grace period is force-killed so it cannot outlive the host, and the
263
+ // quit path is never wedged waiting for a graceful exit.
264
+ const guard = setTimeout(() => {
265
+ try {
266
+ child.kill('SIGKILL');
267
+ }
268
+ catch {
269
+ // already gone
270
+ }
271
+ done();
272
+ }, disposeGraceMs);
273
+ guard.unref?.();
274
+ try {
275
+ child.kill();
276
+ }
277
+ catch {
278
+ resolve();
279
+ }
280
+ });
281
+ },
282
+ };
283
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=compile-worker-standby.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compile-worker-standby.test.d.ts","sourceRoot":"","sources":["../src/compile-worker-standby.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,356 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+ import { createCompileWorkerStandby, } from './compile-worker-standby.js';
7
+ /**
8
+ * Contract for the warm-standby MANAGER: devtools forks a project-agnostic
9
+ * "spare" compile worker while no project is open and pre-loads its compiler
10
+ * (see `compile-worker-entry-warmpool.test.ts` for the ping/prewarm protocol
11
+ * the spare speaks). `createCompileWorkerStandby` owns exactly one spare at a
12
+ * time and must be a pure accelerator: any failure of the spare degrades to
13
+ * "no spare" — it must never make opening a project fail or hang.
14
+ *
15
+ * These are REAL-fork tests (no `child_process` mock): a fixture entry script
16
+ * is forked via the `entry` test hook and its behavior (answer ping/prewarm,
17
+ * stay silent, or crash immediately) is scripted per test. Every check on
18
+ * "the spare died" or "the spare was killed" is a real OS PID check —
19
+ * `process.kill(pid, 0)` — not a spy on an intent to kill.
20
+ */
21
+ function sleep(ms) {
22
+ return new Promise(resolve => setTimeout(resolve, ms));
23
+ }
24
+ function pidAlive(pid) {
25
+ try {
26
+ process.kill(pid, 0);
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ async function waitForPidDeath(pid, timeoutMs = 8000) {
34
+ const deadline = Date.now() + timeoutMs;
35
+ while (Date.now() < deadline) {
36
+ if (!pidAlive(pid))
37
+ return true;
38
+ await sleep(100);
39
+ }
40
+ return !pidAlive(pid);
41
+ }
42
+ /** Bounded poll for an arbitrary condition — never throws/hangs past the deadline. */
43
+ async function pollUntil(fn, timeoutMs, intervalMs = 50) {
44
+ const deadline = Date.now() + timeoutMs;
45
+ let last = fn();
46
+ while (!last && Date.now() < deadline) {
47
+ await sleep(intervalMs);
48
+ last = fn();
49
+ }
50
+ return last;
51
+ }
52
+ async function waitForState(standby, target, timeoutMs = 5000) {
53
+ await pollUntil(() => standby.state === target, timeoutMs);
54
+ return standby.state;
55
+ }
56
+ /**
57
+ * Direct children of THIS test process whose command line contains `tag`
58
+ * (the fixture's own unique tmp-dir path) — mirrors the ps-scan technique
59
+ * `compile-worker-leak.test.ts` uses for real PID discovery, scoped by a
60
+ * per-test unique tag instead of a shared module name.
61
+ */
62
+ function findChildPidsByCommand(tag) {
63
+ const out = execFileSync('ps', ['-axo', 'pid=,ppid=,command='], { encoding: 'utf8' });
64
+ const pids = [];
65
+ for (const line of out.split('\n')) {
66
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
67
+ if (!match)
68
+ continue;
69
+ const [, pid, ppid, command] = match;
70
+ if (Number(ppid) !== process.pid)
71
+ continue;
72
+ if (!command.includes(tag))
73
+ continue;
74
+ pids.push(Number(pid));
75
+ }
76
+ return pids;
77
+ }
78
+ // ── fixture entries: scripted IPC children, no compiler involved ───────────
79
+ /** Answers both ping and prewarm normally — the happy-path spare. */
80
+ const FIXTURE_HAPPY = `
81
+ process.on('message', (msg) => {
82
+ if (!msg) return
83
+ if (msg.cmd === 'ping') process.send({ type: 'pong', id: msg.id })
84
+ else if (msg.cmd === 'prewarm') process.send({ type: 'prewarm-result', id: msg.id, ok: true })
85
+ })
86
+ `;
87
+ /** Prewarms fine but never answers ping — simulates a wedged-but-alive spare. */
88
+ const FIXTURE_SILENT_PING = `
89
+ process.on('message', (msg) => {
90
+ if (!msg) return
91
+ if (msg.cmd === 'prewarm') process.send({ type: 'prewarm-result', id: msg.id, ok: true })
92
+ })
93
+ `;
94
+ /** Dies immediately on load — drives the circuit-breaker crash-loop scenario. */
95
+ const FIXTURE_CRASH_IMMEDIATELY = `
96
+ process.exit(1)
97
+ `;
98
+ /**
99
+ * First spawn answers prewarm with ok:false (the process itself stays alive
100
+ * and still answers ping); every later spawn prewarms normally. The spawn
101
+ * counter lives in a file next to the fixture, so the SECOND process the
102
+ * manager forks (the refill) sees n=2 and behaves healthy — modelling a
103
+ * transient toolchain-load failure that a fresh process recovers from.
104
+ */
105
+ const FIXTURE_PREWARM_FAIL_ONCE = `
106
+ import fs from 'node:fs'
107
+ import { fileURLToPath } from 'node:url'
108
+ const counter = fileURLToPath(new URL('./spawn-count.txt', import.meta.url))
109
+ let n = 0
110
+ try { n = Number(fs.readFileSync(counter, 'utf8')) || 0 } catch {}
111
+ n += 1
112
+ fs.writeFileSync(counter, String(n))
113
+ const failPrewarm = n === 1
114
+ process.on('message', (msg) => {
115
+ if (!msg) return
116
+ if (msg.cmd === 'ping') process.send({ type: 'pong', id: msg.id })
117
+ else if (msg.cmd === 'prewarm') {
118
+ if (failPrewarm) process.send({ type: 'prewarm-result', id: msg.id, ok: false, error: 'toolchain load failed (scripted)' })
119
+ else process.send({ type: 'prewarm-result', id: msg.id, ok: true })
120
+ }
121
+ })
122
+ `;
123
+ const cleanupDirs = [];
124
+ const doomedPids = [];
125
+ const openStandbys = [];
126
+ function mkTmp() {
127
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-standby-'));
128
+ cleanupDirs.push(dir);
129
+ return dir;
130
+ }
131
+ function writeFixture(dir, body) {
132
+ const file = path.join(dir, 'entry.mjs');
133
+ fs.writeFileSync(file, body);
134
+ return file;
135
+ }
136
+ afterEach(async () => {
137
+ for (const standby of openStandbys.splice(0)) {
138
+ try {
139
+ await standby.dispose();
140
+ }
141
+ catch {
142
+ // best-effort teardown
143
+ }
144
+ }
145
+ for (const pid of doomedPids.splice(0)) {
146
+ if (pidAlive(pid)) {
147
+ try {
148
+ process.kill(pid, 'SIGKILL');
149
+ }
150
+ catch {
151
+ // already gone
152
+ }
153
+ }
154
+ }
155
+ for (const dir of cleanupDirs.splice(0)) {
156
+ fs.rmSync(dir, { recursive: true, force: true });
157
+ }
158
+ });
159
+ describe('createCompileWorkerStandby — ensure() forks + prewarms exactly one spare (idempotent)', () => {
160
+ it('ensure() transitions empty → warming → ready; spawned (with pid) fires before prewarmed; repeated ensure() forks nothing new', async () => {
161
+ const dir = mkTmp();
162
+ const entry = writeFixture(dir, FIXTURE_HAPPY);
163
+ const events = [];
164
+ const standby = createCompileWorkerStandby({ entry, onEvent: (ev) => events.push(ev) });
165
+ openStandbys.push(standby);
166
+ expect(standby.state).toBe('empty');
167
+ standby.ensure();
168
+ const readyState = await waitForState(standby, 'ready', 8000);
169
+ expect(readyState, `standby never reached ready (stuck at ${standby.state})`).toBe('ready');
170
+ const spawned = events.filter(ev => ev.type === 'spawned');
171
+ expect(spawned).toHaveLength(1);
172
+ expect(typeof spawned[0].pid).toBe('number');
173
+ doomedPids.push(spawned[0].pid);
174
+ const spawnedIdx = events.findIndex(ev => ev.type === 'spawned');
175
+ const prewarmedIdx = events.findIndex(ev => ev.type === 'prewarmed');
176
+ expect(prewarmedIdx, 'a prewarmed event must fire').toBeGreaterThanOrEqual(0);
177
+ expect(spawnedIdx).toBeLessThan(prewarmedIdx);
178
+ standby.ensure();
179
+ standby.ensure();
180
+ await sleep(200);
181
+ expect(events.filter(ev => ev.type === 'spawned'), 'repeated ensure() while warming/ready must be a no-op — exactly one spare, ever').toHaveLength(1);
182
+ expect(findChildPidsByCommand(dir)).toHaveLength(1);
183
+ }, 20_000);
184
+ });
185
+ describe('createCompileWorkerStandby — adopt() hands the spare over', () => {
186
+ it('adopt() after ready returns the live ChildProcess (matching pid, connected), fires adopted, resets to empty; a second adopt() is null; dispose() afterwards leaves the handed-over process ALIVE', async () => {
187
+ const dir = mkTmp();
188
+ const entry = writeFixture(dir, FIXTURE_HAPPY);
189
+ const events = [];
190
+ const standby = createCompileWorkerStandby({ entry, onEvent: (ev) => events.push(ev) });
191
+ openStandbys.push(standby);
192
+ standby.ensure();
193
+ await waitForState(standby, 'ready', 8000);
194
+ const spawnedPid = events.find(ev => ev.type === 'spawned').pid;
195
+ const adopted = await standby.adopt();
196
+ expect(adopted, 'adopt() on a ready spare must hand over a ChildProcess').not.toBeNull();
197
+ expect(adopted.pid).toBe(spawnedPid);
198
+ expect(adopted.connected).toBe(true);
199
+ expect(standby.state).toBe('empty');
200
+ expect(events.some(ev => ev.type === 'adopted' && ev.pid === spawnedPid)).toBe(true);
201
+ const second = await standby.adopt();
202
+ expect(second, 'adopt() with no spare left must return null, not resurrect one').toBeNull();
203
+ await standby.dispose();
204
+ expect(pidAlive(spawnedPid), 'dispose() must never kill a process the manager already handed off — the CALLER owns it now').toBe(true);
205
+ // test-owned cleanup of the handed-over process
206
+ process.kill(spawnedPid, 'SIGKILL');
207
+ await waitForPidDeath(spawnedPid);
208
+ }, 20_000);
209
+ });
210
+ describe('createCompileWorkerStandby — adopt() health check', () => {
211
+ it('a spare externally SIGKILLed before adopt() fails the health check: adopt() → null, health-check-failed fires', async () => {
212
+ const dir = mkTmp();
213
+ const entry = writeFixture(dir, FIXTURE_HAPPY);
214
+ const events = [];
215
+ const standby = createCompileWorkerStandby({ entry, onEvent: (ev) => events.push(ev) });
216
+ openStandbys.push(standby);
217
+ standby.ensure();
218
+ await waitForState(standby, 'ready', 8000);
219
+ const pid = events.find(ev => ev.type === 'spawned').pid;
220
+ process.kill(pid, 'SIGKILL');
221
+ await waitForPidDeath(pid);
222
+ const adopted = await standby.adopt();
223
+ expect(adopted).toBeNull();
224
+ expect(events.some(ev => ev.type === 'health-check-failed')).toBe(true);
225
+ }, 20_000);
226
+ it('a spare that never answers ping times out the health check: adopt() → null, health-check-failed fires, and the wedged spare is ACTUALLY killed', async () => {
227
+ const dir = mkTmp();
228
+ const entry = writeFixture(dir, FIXTURE_SILENT_PING);
229
+ const events = [];
230
+ const standby = createCompileWorkerStandby({
231
+ entry,
232
+ onEvent: (ev) => events.push(ev),
233
+ healthCheckTimeoutMs: 300,
234
+ });
235
+ openStandbys.push(standby);
236
+ standby.ensure();
237
+ await waitForState(standby, 'ready', 8000);
238
+ const pid = events.find(ev => ev.type === 'spawned').pid;
239
+ expect(pidAlive(pid)).toBe(true);
240
+ doomedPids.push(pid);
241
+ const adopted = await standby.adopt();
242
+ expect(adopted).toBeNull();
243
+ expect(events.some(ev => ev.type === 'health-check-failed')).toBe(true);
244
+ expect(await waitForPidDeath(pid, 5000), 'a spare that fails its own health check must be killed, not left running unmonitored').toBe(true);
245
+ }, 20_000);
246
+ });
247
+ describe('createCompileWorkerStandby — unexpected death auto-refills', () => {
248
+ it('an unexpected death of the READY spare fires died and auto-refills a NEW spare (different pid), returning to ready', async () => {
249
+ const dir = mkTmp();
250
+ const entry = writeFixture(dir, FIXTURE_HAPPY);
251
+ const events = [];
252
+ const standby = createCompileWorkerStandby({ entry, onEvent: (ev) => events.push(ev) });
253
+ openStandbys.push(standby);
254
+ standby.ensure();
255
+ await waitForState(standby, 'ready', 8000);
256
+ const firstPid = events.find(ev => ev.type === 'spawned').pid;
257
+ process.kill(firstPid, 'SIGKILL');
258
+ const diedFired = await pollUntil(() => events.some(ev => ev.type === 'died'), 5000);
259
+ expect(diedFired, 'an unexpected spare death must fire a died event').toBe(true);
260
+ const readyAgain = await waitForState(standby, 'ready', 8000);
261
+ expect(readyAgain, 'the manager must auto-refill after an unexpected death').toBe('ready');
262
+ const spawnedPids = events.filter(ev => ev.type === 'spawned').map(ev => ev.pid);
263
+ expect(spawnedPids).toHaveLength(2);
264
+ expect(spawnedPids[1]).not.toBe(firstPid);
265
+ doomedPids.push(spawnedPids[1]);
266
+ }, 20_000);
267
+ });
268
+ describe('createCompileWorkerStandby — a spare whose prewarm FAILED is unusable', () => {
269
+ it('a prewarm-result ok:false spare is never handed out as ready: the manager kills it (real PID death), fires died with its pid, and refills to ready on a fresh pid', async () => {
270
+ const dir = mkTmp();
271
+ const entry = writeFixture(dir, FIXTURE_PREWARM_FAIL_ONCE);
272
+ const events = [];
273
+ const standby = createCompileWorkerStandby({ entry, onEvent: (ev) => events.push(ev) });
274
+ openStandbys.push(standby);
275
+ standby.ensure();
276
+ // The first spawn's prewarm fails; the manager must recover through the
277
+ // normal died→refill path and end ready on the SECOND (healthy) spawn.
278
+ const readyState = await waitForState(standby, 'ready', 10_000);
279
+ expect(readyState, `the manager must reach ready via a refill after the failed prewarm (stuck at ${standby.state})`).toBe('ready');
280
+ const spawnedPids = events.filter(ev => ev.type === 'spawned').map(ev => ev.pid);
281
+ expect(spawnedPids.length, 'a failed prewarm must trigger a SECOND spawn — the broken spare is not retried in place').toBeGreaterThanOrEqual(2);
282
+ const firstPid = spawnedPids[0];
283
+ const currentPid = spawnedPids.at(-1);
284
+ expect(currentPid).not.toBe(firstPid);
285
+ doomedPids.push(...spawnedPids);
286
+ // The un-prewarmed process was alive and answering ping — only the
287
+ // manager itself can have killed it. It must be REALLY dead, not idling
288
+ // unowned next to the refill.
289
+ expect(await waitForPidDeath(firstPid, 5000), 'the spare whose prewarm failed must be killed by the manager — a live-but-cold process handed out later would silently lose the whole warm-up').toBe(true);
290
+ expect(events.some(ev => ev.type === 'died' && ev.pid === firstPid), 'the failed-prewarm teardown must surface as a died event carrying the broken spare\'s pid').toBe(true);
291
+ // No prewarmed event may reference the broken first pid: ready was
292
+ // reached by the refill, never by the ok:false spare.
293
+ expect(events.some(ev => ev.type === 'prewarmed' && ev.pid === firstPid), 'a spare that answered prewarm-result ok:false must never be reported prewarmed').toBe(false);
294
+ expect(pidAlive(currentPid)).toBe(true);
295
+ }, 30_000);
296
+ });
297
+ describe('createCompileWorkerStandby — circuit breaker', () => {
298
+ it('3 deaths within the window trip state=degraded, fire degraded exactly once, and permanently stop forking — ensure()/adopt() become no-ops', async () => {
299
+ const dir = mkTmp();
300
+ const entry = writeFixture(dir, FIXTURE_CRASH_IMMEDIATELY);
301
+ const events = [];
302
+ const standby = createCompileWorkerStandby({
303
+ entry,
304
+ onEvent: (ev) => events.push(ev),
305
+ breakerWindowMs: 60_000,
306
+ breakerMaxDeaths: 3,
307
+ healthCheckTimeoutMs: 300,
308
+ });
309
+ openStandbys.push(standby);
310
+ standby.ensure();
311
+ const degraded = await pollUntil(() => standby.state === 'degraded', 10_000);
312
+ expect(degraded, `standby never degraded (stuck at ${standby.state})`).toBe(true);
313
+ expect(events.filter(ev => ev.type === 'degraded')).toHaveLength(1);
314
+ expect(events.filter(ev => ev.type === 'spawned')).toHaveLength(3);
315
+ // quiet period: no further fork attempts once tripped
316
+ await sleep(1000);
317
+ expect(events.filter(ev => ev.type === 'spawned')).toHaveLength(3);
318
+ expect(findChildPidsByCommand(dir).filter(pidAlive), 'a tripped breaker must leave no live spare process behind').toHaveLength(0);
319
+ standby.ensure();
320
+ await sleep(200);
321
+ expect(events.filter(ev => ev.type === 'spawned'), 'ensure() while degraded is a no-op').toHaveLength(3);
322
+ const adopted = await standby.adopt();
323
+ expect(adopted, 'adopt() while degraded must return null, never resurrect the crash-loop').toBeNull();
324
+ }, 30_000);
325
+ });
326
+ describe('createCompileWorkerStandby — dispose()', () => {
327
+ it('dispose() while ready kills the spare (real PID death), moves state to disposed, and is idempotent; ensure()/adopt() afterwards are no-ops', async () => {
328
+ const dir = mkTmp();
329
+ const entry = writeFixture(dir, FIXTURE_HAPPY);
330
+ const events = [];
331
+ const standby = createCompileWorkerStandby({ entry, onEvent: (ev) => events.push(ev) });
332
+ openStandbys.push(standby);
333
+ standby.ensure();
334
+ await waitForState(standby, 'ready', 8000);
335
+ const pid = events.find(ev => ev.type === 'spawned').pid;
336
+ await standby.dispose();
337
+ expect(standby.state).toBe('disposed');
338
+ expect(await waitForPidDeath(pid, 5000), 'dispose() must leave the spare actually dead, not merely requested to die').toBe(true);
339
+ await expect(standby.dispose()).resolves.toBeUndefined();
340
+ standby.ensure();
341
+ await sleep(200);
342
+ expect(standby.state, 'a disposed standby is terminal — ensure() must not resurrect it').toBe('disposed');
343
+ const adopted = await standby.adopt();
344
+ expect(adopted).toBeNull();
345
+ }, 20_000);
346
+ });
347
+ describe('createCompileWorkerStandby — empty state', () => {
348
+ it('adopt() with no ensure() ever called returns null and forks nothing', async () => {
349
+ const standby = createCompileWorkerStandby({});
350
+ openStandbys.push(standby);
351
+ expect(standby.state).toBe('empty');
352
+ const adopted = await standby.adopt();
353
+ expect(adopted).toBeNull();
354
+ expect(standby.state).toBe('empty');
355
+ });
356
+ });