@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,212 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { createCompileWorkerStandby, } from './compile-worker-standby.js';
6
+ /**
7
+ * Hardening contracts for the warm-standby manager (see
8
+ * `compile-worker-standby.test.ts` for the base ensure/adopt/dispose
9
+ * contract and the ping/prewarm protocol fixtures speak).
10
+ *
11
+ * Group A: adopt() must only ever hand over a spare whose prewarm has
12
+ * actually completed — a spare still warming is not safe to use, and a
13
+ * spare that is handed over must not be silently orphaned by whatever
14
+ * outcome its still-in-flight prewarm eventually reports.
15
+ *
16
+ * Group B: dispose() must not leave a spare alive forever just because it
17
+ * traps SIGTERM — it escalates to SIGKILL after `disposeGraceMs`.
18
+ *
19
+ * These are real-fork tests: fixtures are scripted child entry scripts, and
20
+ * every "the spare died" / "the spare is still alive" check is a real OS
21
+ * PID check (`process.kill(pid, 0)`), not a spy on intent.
22
+ */
23
+ function sleep(ms) {
24
+ return new Promise(resolve => setTimeout(resolve, ms));
25
+ }
26
+ function pidAlive(pid) {
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ async function waitForPidDeath(pid, timeoutMs = 8000) {
36
+ const deadline = Date.now() + timeoutMs;
37
+ while (Date.now() < deadline) {
38
+ if (!pidAlive(pid))
39
+ return true;
40
+ await sleep(100);
41
+ }
42
+ return !pidAlive(pid);
43
+ }
44
+ async function pollUntil(fn, timeoutMs, intervalMs = 50) {
45
+ const deadline = Date.now() + timeoutMs;
46
+ let last = fn();
47
+ while (!last && Date.now() < deadline) {
48
+ await sleep(intervalMs);
49
+ last = fn();
50
+ }
51
+ return last;
52
+ }
53
+ async function waitForState(standby, target, timeoutMs = 5000) {
54
+ await pollUntil(() => standby.state === target, timeoutMs);
55
+ return standby.state;
56
+ }
57
+ /** Answers ping normally; prewarm succeeds only after a delay, to give tests a window while state is still 'warming'. */
58
+ const FIXTURE_DELAYED_PREWARM_OK = `
59
+ process.on('message', (msg) => {
60
+ if (!msg) return
61
+ if (msg.cmd === 'ping') process.send({ type: 'pong', id: msg.id })
62
+ else if (msg.cmd === 'prewarm') {
63
+ setTimeout(() => process.send({ type: 'prewarm-result', id: msg.id, ok: true }), 1000)
64
+ }
65
+ })
66
+ `;
67
+ /** Answers ping normally; prewarm reports failure only after a delay, to exercise the death path once already adopt()-attempted while warming. */
68
+ const FIXTURE_DELAYED_PREWARM_FAIL = `
69
+ process.on('message', (msg) => {
70
+ if (!msg) return
71
+ if (msg.cmd === 'ping') process.send({ type: 'pong', id: msg.id })
72
+ else if (msg.cmd === 'prewarm') {
73
+ setTimeout(() => process.send({ type: 'prewarm-result', id: msg.id, ok: false, error: 'delayed toolchain failure (scripted)' }), 800)
74
+ }
75
+ })
76
+ `;
77
+ /** Answers both ping and prewarm immediately — the happy-path spare. */
78
+ const FIXTURE_HAPPY = `
79
+ process.on('message', (msg) => {
80
+ if (!msg) return
81
+ if (msg.cmd === 'ping') process.send({ type: 'pong', id: msg.id })
82
+ else if (msg.cmd === 'prewarm') process.send({ type: 'prewarm-result', id: msg.id, ok: true })
83
+ })
84
+ `;
85
+ /** Traps SIGTERM and swallows it forever while still answering ping/prewarm normally — a spare that will not exit gracefully. */
86
+ const FIXTURE_TRAP_SIGTERM = `
87
+ process.on('SIGTERM', () => {})
88
+ process.on('message', (msg) => {
89
+ if (!msg) return
90
+ if (msg.cmd === 'ping') process.send({ type: 'pong', id: msg.id })
91
+ else if (msg.cmd === 'prewarm') process.send({ type: 'prewarm-result', id: msg.id, ok: true })
92
+ })
93
+ `;
94
+ const cleanupDirs = [];
95
+ const doomedPids = [];
96
+ const openStandbys = [];
97
+ function mkTmp() {
98
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-standby-hardening-'));
99
+ cleanupDirs.push(dir);
100
+ return dir;
101
+ }
102
+ function writeFixture(dir, body) {
103
+ const file = path.join(dir, 'entry.mjs');
104
+ fs.writeFileSync(file, body);
105
+ return file;
106
+ }
107
+ afterEach(async () => {
108
+ for (const standby of openStandbys.splice(0)) {
109
+ try {
110
+ await standby.dispose();
111
+ }
112
+ catch {
113
+ // best-effort teardown
114
+ }
115
+ }
116
+ for (const pid of doomedPids.splice(0)) {
117
+ if (pidAlive(pid)) {
118
+ try {
119
+ process.kill(pid, 'SIGKILL');
120
+ }
121
+ catch {
122
+ // already gone
123
+ }
124
+ }
125
+ }
126
+ for (const dir of cleanupDirs.splice(0)) {
127
+ fs.rmSync(dir, { recursive: true, force: true });
128
+ }
129
+ });
130
+ describe('createCompileWorkerStandby — adopt() only hands over a spare whose prewarm has completed', () => {
131
+ it('adopt() during warming returns null and leaves the spare running untouched; the same spare is handed over once ready', async () => {
132
+ const dir = mkTmp();
133
+ const entry = writeFixture(dir, FIXTURE_DELAYED_PREWARM_OK);
134
+ const events = [];
135
+ const standby = createCompileWorkerStandby({ entry, onEvent: (ev) => events.push(ev) });
136
+ openStandbys.push(standby);
137
+ standby.ensure();
138
+ await sleep(200);
139
+ expect(standby.state, 'prewarm is scripted to take ~1s, so it must still be in flight').toBe('warming');
140
+ const duringWarming = await standby.adopt();
141
+ expect(duringWarming, 'adopt() must not hand over a spare whose prewarm has not completed').toBeNull();
142
+ const pid = events.find(ev => ev.type === 'spawned').pid;
143
+ expect(pidAlive(pid), 'the spare must keep running untouched while adopt() defers to a null result').toBe(true);
144
+ expect(events.some(ev => ev.type === 'adopted')).toBe(false);
145
+ expect(events.some(ev => ev.type === 'health-check-failed')).toBe(false);
146
+ expect(standby.state).toBe('warming');
147
+ const readyState = await waitForState(standby, 'ready', 3000);
148
+ expect(readyState).toBe('ready');
149
+ const adopted = await standby.adopt();
150
+ expect(adopted, 'once ready, the same spare must be handed over').not.toBeNull();
151
+ expect(adopted.pid).toBe(pid);
152
+ process.kill(pid, 'SIGKILL');
153
+ await waitForPidDeath(pid);
154
+ }, 10_000);
155
+ it('a null adopt() during warming does not orphan the spare — a later prewarm failure still kills it via the died path', async () => {
156
+ const dir = mkTmp();
157
+ const entry = writeFixture(dir, FIXTURE_DELAYED_PREWARM_FAIL);
158
+ const events = [];
159
+ const standby = createCompileWorkerStandby({ entry, onEvent: (ev) => events.push(ev) });
160
+ openStandbys.push(standby);
161
+ standby.ensure();
162
+ await sleep(200);
163
+ const duringWarming = await standby.adopt();
164
+ expect(duringWarming).toBeNull();
165
+ const pid = events.find(ev => ev.type === 'spawned').pid;
166
+ doomedPids.push(pid);
167
+ const diedFired = await pollUntil(() => events.some(ev => ev.type === 'died'), 3000);
168
+ expect(diedFired, 'a spare whose prewarm later fails must still be torn down by the manager, not left unmonitored by the earlier null adopt()').toBe(true);
169
+ expect(await waitForPidDeath(pid, 3000), 'a spare orphaned by an earlier null adopt() and a failed prewarm must actually die, not leak as a live process').toBe(true);
170
+ }, 10_000);
171
+ it('adopt() called right after an unexpected death, before the refill fork completes, still fails its health check', async () => {
172
+ const dir = mkTmp();
173
+ const entry = writeFixture(dir, FIXTURE_HAPPY);
174
+ const events = [];
175
+ const standby = createCompileWorkerStandby({ entry, onEvent: (ev) => events.push(ev) });
176
+ openStandbys.push(standby);
177
+ standby.ensure();
178
+ await waitForState(standby, 'ready', 8000);
179
+ const pid = events.find(ev => ev.type === 'spawned').pid;
180
+ process.kill(pid, 'SIGKILL');
181
+ const diedFired = await pollUntil(() => events.some(ev => ev.type === 'died'), 5000);
182
+ expect(diedFired).toBe(true);
183
+ const adopted = await standby.adopt();
184
+ expect(adopted).toBeNull();
185
+ const failure = events.find(ev => ev.type === 'health-check-failed');
186
+ expect(failure, 'adopt() in the dead-but-not-yet-refilled window must fail its health check').toBeTruthy();
187
+ expect(String(failure?.reason)).toContain('died before adoption');
188
+ }, 10_000);
189
+ });
190
+ describe('createCompileWorkerStandby — dispose() escalates past a spare that ignores SIGTERM', () => {
191
+ it('a spare trapping SIGTERM is still killed within disposeGraceMs, and dispose() resolves promptly rather than hanging', async () => {
192
+ const dir = mkTmp();
193
+ const entry = writeFixture(dir, FIXTURE_TRAP_SIGTERM);
194
+ const events = [];
195
+ // `disposeGraceMs` is passed through a variable (not an inline object
196
+ // literal) so this test compiles against today's narrower option type
197
+ // while still exercising the new option once implemented.
198
+ const opts = { entry, onEvent: (ev) => events.push(ev), disposeGraceMs: 300 };
199
+ const standby = createCompileWorkerStandby(opts);
200
+ openStandbys.push(standby);
201
+ standby.ensure();
202
+ await waitForState(standby, 'ready', 8000);
203
+ const pid = events.find(ev => ev.type === 'spawned').pid;
204
+ doomedPids.push(pid);
205
+ const disposeOutcome = await Promise.race([
206
+ standby.dispose().then(() => 'resolved'),
207
+ sleep(3000).then(() => 'timed-out'),
208
+ ]);
209
+ expect(disposeOutcome, 'dispose() must resolve within a bounded time even when the spare ignores SIGTERM, not hang forever waiting for a graceful exit').toBe('resolved');
210
+ expect(await waitForPidDeath(pid, 2000), 'a spare that traps SIGTERM must still end up dead — dispose() must escalate to SIGKILL after disposeGraceMs').toBe(true);
211
+ }, 10_000);
212
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=compile-worker-standby-integration.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compile-worker-standby-integration.test.d.ts","sourceRoot":"","sources":["../src/compile-worker-standby-integration.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,243 @@
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 * as devkit from './index.js';
7
+ function getEnable() {
8
+ const fn = devkit.enableCompileWorkerStandby;
9
+ expect(typeof fn, 'devkit must export enableCompileWorkerStandby(opts?) — the one switch that turns the warm-standby accelerator on').toBe('function');
10
+ return fn;
11
+ }
12
+ function sleep(ms) {
13
+ return new Promise(resolve => setTimeout(resolve, ms));
14
+ }
15
+ function pidAlive(pid) {
16
+ try {
17
+ process.kill(pid, 0);
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ async function waitForPidDeath(pid, timeoutMs = 8000) {
25
+ const deadline = Date.now() + timeoutMs;
26
+ while (Date.now() < deadline) {
27
+ if (!pidAlive(pid))
28
+ return true;
29
+ await sleep(100);
30
+ }
31
+ return !pidAlive(pid);
32
+ }
33
+ async function pollUntil(fn, timeoutMs, intervalMs = 100) {
34
+ const deadline = Date.now() + timeoutMs;
35
+ let last = fn();
36
+ while (!last && Date.now() < deadline) {
37
+ await sleep(intervalMs);
38
+ last = fn();
39
+ }
40
+ return last;
41
+ }
42
+ /** Direct compile-worker-entry children of THIS test process (real PID scan). */
43
+ function findCompileWorkerPids() {
44
+ const out = execFileSync('ps', ['-axo', 'pid=,ppid=,command='], { encoding: 'utf8' });
45
+ const pids = [];
46
+ for (const line of out.split('\n')) {
47
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
48
+ if (!match)
49
+ continue;
50
+ const [, pid, ppid, command] = match;
51
+ if (Number(ppid) !== process.pid)
52
+ continue;
53
+ if (!command.includes('compile-worker-entry'))
54
+ continue;
55
+ pids.push(Number(pid));
56
+ }
57
+ return pids;
58
+ }
59
+ const openSessions = [];
60
+ const activeManagers = [];
61
+ const cleanupRoots = [];
62
+ afterEach(async () => {
63
+ for (const session of openSessions.splice(0)) {
64
+ try {
65
+ await session.close();
66
+ }
67
+ catch {
68
+ // best-effort teardown
69
+ }
70
+ }
71
+ for (const manager of activeManagers.splice(0)) {
72
+ try {
73
+ await manager.dispose();
74
+ }
75
+ catch {
76
+ // best-effort teardown
77
+ }
78
+ }
79
+ // Hard sweep: nothing this suite forks may outlive its test.
80
+ for (const pid of findCompileWorkerPids()) {
81
+ try {
82
+ process.kill(pid, 'SIGKILL');
83
+ }
84
+ catch {
85
+ // already gone
86
+ }
87
+ }
88
+ for (const root of cleanupRoots.splice(0)) {
89
+ fs.rmSync(root, { recursive: true, force: true });
90
+ }
91
+ });
92
+ function makeFixture() {
93
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-standby-integ-'));
94
+ cleanupRoots.push(root);
95
+ const write = (rel, content) => {
96
+ const target = path.join(root, rel);
97
+ fs.mkdirSync(path.dirname(target), { recursive: true });
98
+ fs.writeFileSync(target, content);
99
+ };
100
+ write('project.config.json', JSON.stringify({ appid: 'fixture_app_001', projectname: 'fixture-app' }));
101
+ write('app.json', JSON.stringify({ pages: ['pages/index/index'] }));
102
+ write('app.js', 'App({})\n');
103
+ write('app.wxss', 'page { font-size: 14px; }\n');
104
+ write('pages/index/index.json', '{}\n');
105
+ write('pages/index/index.js', 'Page({ data: { msg: "hi" } })\n');
106
+ write('pages/index/index.wxml', '<view>{{msg}}</view>\n');
107
+ write('pages/index/index.wxss', '.x { color: red; }\n');
108
+ return root;
109
+ }
110
+ /**
111
+ * A projectPath that does not exist on disk: the worker-side
112
+ * chdir(projectPath) throws ENOENT, the build settles as a failure, and
113
+ * openProject rejects — AFTER the compile worker (the adopted spare) was
114
+ * already consumed. A merely-miscompiling project is NOT enough here:
115
+ * dmcc swallows compile errors (resolves undefined) and openProject's appId
116
+ * fallback tolerates even an unparsable project.config.json, so a broken-
117
+ * content fixture opens "successfully" in degraded form.
118
+ */
119
+ function makeBrokenFixture() {
120
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-standby-integ-broken-'));
121
+ fs.rmSync(root, { recursive: true, force: true });
122
+ return root;
123
+ }
124
+ function spawnedPids(events) {
125
+ return events.filter(ev => ev.type === 'spawned').map(ev => ev.pid);
126
+ }
127
+ async function enableAndAwaitReady(events) {
128
+ const enable = getEnable();
129
+ const manager = enable({ onEvent: (ev) => events.push(ev) });
130
+ activeManagers.push(manager);
131
+ const ready = await pollUntil(() => manager.state === 'ready', 30_000);
132
+ expect(ready, `standby never reached ready (stuck at ${manager.state}) — the REAL entry must prewarm end to end`).toBe(true);
133
+ return manager;
134
+ }
135
+ describe('enableCompileWorkerStandby — enable-time warm-up and idempotence', () => {
136
+ it('enabling immediately warms ONE real spare to ready; repeated calls return the SAME instance without a second fork; after dispose() a new enable builds a fresh manager that warms again', async () => {
137
+ const events = [];
138
+ const manager = await enableAndAwaitReady(events);
139
+ const again = getEnable()();
140
+ expect(again, 'enableCompileWorkerStandby while the manager lives must return the SAME instance — two live managers would each own a spare').toBe(manager);
141
+ await sleep(300);
142
+ expect(spawnedPids(events), 'the repeated enable must not fork a second spare').toHaveLength(1);
143
+ const firstPid = spawnedPids(events)[0];
144
+ expect(findCompileWorkerPids()).toEqual([firstPid]);
145
+ await manager.dispose();
146
+ expect(manager.state).toBe('disposed');
147
+ expect(await waitForPidDeath(firstPid), 'dispose() must leave the spare actually dead').toBe(true);
148
+ const rebornEvents = [];
149
+ const reborn = await enableAndAwaitReady(rebornEvents);
150
+ expect(reborn, 'enable after dispose() must build a FRESH manager — the disposed one is terminal').not.toBe(manager);
151
+ const secondPid = spawnedPids(rebornEvents)[0];
152
+ expect(secondPid).not.toBe(firstPid);
153
+ expect(pidAlive(secondPid)).toBe(true);
154
+ }, 90_000);
155
+ });
156
+ describe('enableCompileWorkerStandby — openProject adopts the spare, close() refills it', () => {
157
+ it('openProject with a ready spare reuses THAT process for its first compile (no fresh fork), and after session.close() the manager refills a new, different-pid spare back to ready', async () => {
158
+ const events = [];
159
+ const manager = await enableAndAwaitReady(events);
160
+ const standbyPid = spawnedPids(events)[0];
161
+ expect(findCompileWorkerPids(), 'precondition: exactly the spare is alive before open').toEqual([standbyPid]);
162
+ const root = makeFixture();
163
+ const session = await devkit.openProject({
164
+ projectPath: root,
165
+ watch: false,
166
+ outputDir: path.join(root, '.out'),
167
+ });
168
+ openSessions.push(session);
169
+ expect(session.appInfo.appId, 'the adopted spare must produce a real, correct compile').toBe('fixture_app_001');
170
+ expect(findCompileWorkerPids(), 'after open, the ONLY live compile-worker-entry child must be the pre-warmed spare — adoption, not a fresh fork next to an idle spare').toEqual([standbyPid]);
171
+ await session.close();
172
+ expect(await waitForPidDeath(standbyPid), 'the adopted worker must die with its session — adoption transfers ownership, not immortality').toBe(true);
173
+ const refilled = await pollUntil(() => manager.state === 'ready', 30_000);
174
+ expect(refilled, `after close() the manager must refill a new spare on its own (stuck at ${manager.state})`).toBe(true);
175
+ const pids = spawnedPids(events);
176
+ expect(pids.length, 'the refill must be a NEW spawn').toBeGreaterThanOrEqual(2);
177
+ const refillPid = pids.at(-1);
178
+ expect(refillPid).not.toBe(standbyPid);
179
+ expect(pidAlive(refillPid)).toBe(true);
180
+ }, 120_000);
181
+ it('a FAILED openProject also consumes the spare cleanly: the adopted worker is dead afterwards and the manager refills back to ready on a new pid', async () => {
182
+ const events = [];
183
+ const manager = await enableAndAwaitReady(events);
184
+ const standbyPid = spawnedPids(events)[0];
185
+ expect(findCompileWorkerPids(), 'precondition: exactly the spare is alive before open').toEqual([standbyPid]);
186
+ const root = makeBrokenFixture();
187
+ // The outputDir must live OUTSIDE the nonexistent root: openProject
188
+ // creates the outputDir recursively, and an outputDir nested under the
189
+ // root would re-create the root as a side effect — turning the intended
190
+ // hard failure into a degraded-but-successful open of an empty project.
191
+ const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-standby-integ-out-'));
192
+ cleanupRoots.push(outputDir);
193
+ let rejected = false;
194
+ try {
195
+ const session = await devkit.openProject({
196
+ projectPath: root,
197
+ watch: false,
198
+ outputDir,
199
+ });
200
+ openSessions.push(session);
201
+ }
202
+ catch {
203
+ rejected = true;
204
+ }
205
+ expect(rejected, 'precondition: a nonexistent projectPath must make openProject reject (worker-side chdir ENOENT)').toBe(true);
206
+ // The failure path must not leak the consumed spare: it was adopted
207
+ // into the failed open, and the failed open's cleanup must kill it.
208
+ expect(await waitForPidDeath(standbyPid, 10_000), 'the spare consumed by a FAILED open must be dead afterwards — failure cleanup owns the adopted worker exactly like session.close() does').toBe(true);
209
+ const refilled = await pollUntil(() => manager.state === 'ready', 30_000);
210
+ expect(refilled, `a failed open must refill the standby just like a successful open+close does (stuck at ${manager.state})`).toBe(true);
211
+ const pids = spawnedPids(events);
212
+ expect(pids.length).toBeGreaterThanOrEqual(2);
213
+ const refillPid = pids.at(-1);
214
+ expect(refillPid).not.toBe(standbyPid);
215
+ expect(pidAlive(refillPid)).toBe(true);
216
+ }, 120_000);
217
+ });
218
+ describe('enableCompileWorkerStandby — dispose() restores the pure cold path', () => {
219
+ it('after manager.dispose(), openProject still succeeds via a fresh cold fork, and after close() NO spare ever appears again (quiet window)', async () => {
220
+ const events = [];
221
+ const manager = await enableAndAwaitReady(events);
222
+ const oldStandbyPid = spawnedPids(events)[0];
223
+ await manager.dispose();
224
+ expect(await waitForPidDeath(oldStandbyPid)).toBe(true);
225
+ const root = makeFixture();
226
+ const session = await devkit.openProject({
227
+ projectPath: root,
228
+ watch: false,
229
+ outputDir: path.join(root, '.out'),
230
+ });
231
+ openSessions.push(session);
232
+ expect(session.appInfo.appId, 'a disposed standby must not break opening — cold path is the structural fallback').toBe('fixture_app_001');
233
+ const coldPids = findCompileWorkerPids();
234
+ expect(coldPids, 'the cold path forks exactly one fresh worker').toHaveLength(1);
235
+ expect(coldPids[0]).not.toBe(oldStandbyPid);
236
+ await session.close();
237
+ expect(await waitForPidDeath(coldPids[0])).toBe(true);
238
+ // Quiet window: a disposed manager must never refill — no new
239
+ // compile-worker-entry child may appear after the session closed.
240
+ await sleep(2000);
241
+ expect(findCompileWorkerPids(), 'a disposed standby manager must NEVER fork again — a post-dispose refill is an untracked orphan factory').toHaveLength(0);
242
+ }, 120_000);
243
+ });
@@ -0,0 +1,60 @@
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 { type ChildProcess } from 'node:child_process';
25
+ export type StandbyState = 'empty' | 'warming' | 'ready' | 'degraded' | 'disposed';
26
+ export interface StandbyEvent {
27
+ type: 'spawned' | 'prewarmed' | 'adopted' | 'health-check-failed' | 'died' | 'degraded';
28
+ pid?: number;
29
+ reason?: string;
30
+ }
31
+ export interface CompileWorkerStandby {
32
+ /** Fork + prewarm a spare if none exists. No-op while warming/ready/degraded/disposed. */
33
+ ensure: () => void;
34
+ /**
35
+ * Health-check the spare and hand it over (the caller owns it afterwards;
36
+ * pass it to `createCompileWorker({ adopt })`). Only a fully-prewarmed
37
+ * ('ready') spare is ever handed over. Returns null — never throws, never
38
+ * hangs — when there is no healthy prewarmed spare to give.
39
+ */
40
+ adopt: () => Promise<ChildProcess | null>;
41
+ /** Kill the spare (waiting for real death) and shut the manager down for good. */
42
+ dispose: () => Promise<void>;
43
+ readonly state: StandbyState;
44
+ }
45
+ export interface CompileWorkerStandbyOptions {
46
+ /** Lifecycle telemetry (diagnostics bus, logs). Errors thrown here are swallowed. */
47
+ onEvent?: (ev: StandbyEvent) => void;
48
+ /** Sliding window for the crash-loop circuit breaker. Default 30000. */
49
+ breakerWindowMs?: number;
50
+ /** Spare deaths within the window that trip 'degraded'. Default 3. */
51
+ breakerMaxDeaths?: number;
52
+ /** ping→pong deadline for adopt()'s health check. Default 1000. */
53
+ healthCheckTimeoutMs?: number;
54
+ /** dispose()'s grace period between the polite kill and a SIGKILL escalation. Default 5000. */
55
+ disposeGraceMs?: number;
56
+ /** Fork target override (tests script the spare's behavior). Default: the real compile-worker-entry. */
57
+ entry?: string;
58
+ }
59
+ export declare function createCompileWorkerStandby(opts?: CompileWorkerStandbyOptions): CompileWorkerStandby;
60
+ //# sourceMappingURL=compile-worker-standby.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compile-worker-standby.d.ts","sourceRoot":"","sources":["../src/compile-worker-standby.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,EAAQ,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAG5D,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,SAAS,GAAG,OAAO,GAAG,UAAU,GAAG,UAAU,CAAA;AAElF,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,qBAAqB,GAAG,MAAM,GAAG,UAAU,CAAA;IACvF,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,oBAAoB;IACpC,0FAA0F;IAC1F,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB;;;;;OAKG;IACH,KAAK,EAAE,MAAM,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAA;IACzC,kFAAkF;IAClF,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5B,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;CAC5B;AAED,MAAM,WAAW,2BAA2B;IAC3C,qFAAqF;IACrF,OAAO,CAAC,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,IAAI,CAAA;IACpC,wEAAwE;IACxE,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,sEAAsE;IACtE,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,mEAAmE;IACnE,oBAAoB,CAAC,EAAE,MAAM,CAAA;IAC7B,+FAA+F;IAC/F,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,wGAAwG;IACxG,KAAK,CAAC,EAAE,MAAM,CAAA;CACd;AAQD,wBAAgB,0BAA0B,CACzC,IAAI,GAAE,2BAAgC,GACpC,oBAAoB,CAoQtB"}