@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.
- package/README.md +16 -0
- package/dist/compile-worker-adopt.test.d.ts +2 -0
- package/dist/compile-worker-adopt.test.d.ts.map +1 -0
- package/dist/compile-worker-adopt.test.js +187 -0
- package/dist/compile-worker-entry-warmpool.test.d.ts +2 -0
- package/dist/compile-worker-entry-warmpool.test.d.ts.map +1 -0
- package/dist/compile-worker-entry-warmpool.test.js +110 -0
- package/dist/compile-worker-entry.d.ts +35 -5
- package/dist/compile-worker-entry.d.ts.map +1 -1
- package/dist/compile-worker-entry.js +45 -1
- package/dist/compile-worker-standby-hardening.test.d.ts +2 -0
- package/dist/compile-worker-standby-hardening.test.d.ts.map +1 -0
- package/dist/compile-worker-standby-hardening.test.js +212 -0
- package/dist/compile-worker-standby-integration.test.d.ts +2 -0
- package/dist/compile-worker-standby-integration.test.d.ts.map +1 -0
- package/dist/compile-worker-standby-integration.test.js +243 -0
- package/dist/compile-worker-standby.d.ts +60 -0
- package/dist/compile-worker-standby.d.ts.map +1 -0
- package/dist/compile-worker-standby.js +283 -0
- package/dist/compile-worker-standby.test.d.ts +2 -0
- package/dist/compile-worker-standby.test.d.ts.map +1 -0
- package/dist/compile-worker-standby.test.js +356 -0
- package/dist/compile-worker.d.ts +53 -0
- package/dist/compile-worker.d.ts.map +1 -1
- package/dist/compile-worker.js +30 -11
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +53 -4
- package/fe/dimina-fe-container/assets/index.js +8 -8
- package/fe/dimina-fe-container/assets/pageFrame.js +3 -3
- package/fe/dimina-fe-container/assets/service.js +2 -2
- package/fe/dimina-fe-container/dimina-version.json +2 -2
- 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 @@
|
|
|
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
|
+
});
|