@dimina-kit/devkit 0.1.2-dev.20260612025610 → 0.1.2-dev.20260612152115
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 +5 -3
- package/dist/compile-log.d.ts +16 -0
- package/dist/compile-log.d.ts.map +1 -0
- package/dist/compile-log.js +42 -0
- package/dist/compile-log.test.d.ts +2 -0
- package/dist/compile-log.test.d.ts.map +1 -0
- package/dist/compile-log.test.js +134 -0
- package/dist/compile-worker-entry.d.ts +47 -0
- package/dist/compile-worker-entry.d.ts.map +1 -0
- package/dist/compile-worker-entry.js +117 -0
- package/dist/compile-worker-entry.test.d.ts +2 -0
- package/dist/compile-worker-entry.test.d.ts.map +1 -0
- package/dist/compile-worker-entry.test.js +247 -0
- package/dist/compile-worker-leak.test.d.ts +2 -0
- package/dist/compile-worker-leak.test.d.ts.map +1 -0
- package/dist/compile-worker-leak.test.js +284 -0
- package/dist/compile-worker.d.ts +33 -0
- package/dist/compile-worker.d.ts.map +1 -0
- package/dist/compile-worker.js +213 -0
- package/dist/compile-worker.test.d.ts +2 -0
- package/dist/compile-worker.test.d.ts.map +1 -0
- package/dist/compile-worker.test.js +791 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +100 -31
- package/dist/open-project-cleanup.test.d.ts +2 -0
- package/dist/open-project-cleanup.test.d.ts.map +1 -0
- package/dist/open-project-cleanup.test.js +176 -0
- package/dist/open-project-compile-log.test.d.ts +2 -0
- package/dist/open-project-compile-log.test.d.ts.map +1 -0
- package/dist/open-project-compile-log.test.js +174 -0
- package/dist/rebuild-scheduler.d.ts +24 -0
- package/dist/rebuild-scheduler.d.ts.map +1 -0
- package/dist/rebuild-scheduler.js +58 -0
- package/dist/rebuild-scheduler.test.d.ts +2 -0
- package/dist/rebuild-scheduler.test.d.ts.map +1 -0
- package/dist/rebuild-scheduler.test.js +201 -0
- package/dist/watch-rebuild.testutil.d.ts +21 -0
- package/dist/watch-rebuild.testutil.d.ts.map +1 -0
- package/dist/watch-rebuild.testutil.js +81 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compile-worker-leak.test.d.ts","sourceRoot":"","sources":["../src/compile-worker-leak.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { execFileSync, fork, spawn } from 'node:child_process';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
8
|
+
import * as devkit from './index.js';
|
|
9
|
+
import { writeUntilPredicate } from './watch-rebuild.testutil.js';
|
|
10
|
+
/*
|
|
11
|
+
* FLAKE HARDENING (no assertions changed): the "close during in-flight
|
|
12
|
+
* rebuild" test below relies on a REAL chokidar inotify watch to start a
|
|
13
|
+
* rebuild. Under CI load a single fs.writeFileSync's event can be dropped,
|
|
14
|
+
* so the `sawRebuildOutput` precondition (count-agnostic: logEntries.length
|
|
15
|
+
* > 0) re-writes the source file until the rebuild's first log line appears.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* LEAK-PROOFING WAVE (项目关闭时保证编译子进程同步关闭) — REAL-PROCESS
|
|
19
|
+
* integration contract. Nothing in this file mocks `child_process`: every
|
|
20
|
+
* test forks the actual `compile-worker-entry` (and, for the `openProject`
|
|
21
|
+
* tests, runs the actual `@dimina/compiler`) and asserts on REAL process
|
|
22
|
+
* death via `process.kill(pid, 0)` (ESRCH ⇒ the PID is gone).
|
|
23
|
+
*
|
|
24
|
+
* Why these tests exist on top of the existing mocked-fork suite
|
|
25
|
+
* (`compile-worker.test.ts` pins `child.kill` was CALLED): a kill() spy
|
|
26
|
+
* proves intent, not death. The two leak classes this file closes:
|
|
27
|
+
*
|
|
28
|
+
* ① ORPHAN SAFETY NET — the worker must kill ITSELF when the fork IPC
|
|
29
|
+
* channel goes away. This is the only mechanism that covers every parent
|
|
30
|
+
* death that never reaches `session.close()`: host crash, SIGKILL,
|
|
31
|
+
* `process.exit` in a teardown race. Pinned two ways:
|
|
32
|
+
* - parent-side `child.disconnect()` (the channel-closed signal in
|
|
33
|
+
* isolation) → the entry must exit(0) on its own;
|
|
34
|
+
* - a REAL orphaning: an intermediate parent forks the entry and then
|
|
35
|
+
* SIGKILLs itself — the orphan must still die unaided.
|
|
36
|
+
*
|
|
37
|
+
* ② TRUE DEATH ON close() — `session.close()` must leave the worker PID
|
|
38
|
+
* actually dead (not merely "kill was invoked"), including when a build
|
|
39
|
+
* is in flight at close time.
|
|
40
|
+
*
|
|
41
|
+
* PID discovery (decision recorded for the implementer): NO new public API
|
|
42
|
+
* is pinned for exposing the worker PID. The worker is discovered externally
|
|
43
|
+
* via a `ps -axo pid=,ppid=,command=` scan for children of THIS test process
|
|
44
|
+
* whose command line contains `compile-worker-entry`. That keeps the
|
|
45
|
+
* contract purely behavioral — if the implementation later wants to expose
|
|
46
|
+
* `worker.pid`, nothing here constrains it either way.
|
|
47
|
+
*
|
|
48
|
+
* CI stability: all death checks are bounded polls (8s deadline, 100ms
|
|
49
|
+
* interval), and every discovered/forked PID is SIGKILLed in cleanup so a
|
|
50
|
+
* genuinely failing implementation cannot leak processes out of the suite.
|
|
51
|
+
*/
|
|
52
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
53
|
+
/** Fork target — same resolution rule as compile-worker.ts (src ⇒ .ts entry). */
|
|
54
|
+
function workerEntryPath() {
|
|
55
|
+
const js = path.join(__dirname, 'compile-worker-entry.js');
|
|
56
|
+
if (fs.existsSync(js))
|
|
57
|
+
return js;
|
|
58
|
+
return path.join(__dirname, 'compile-worker-entry.ts');
|
|
59
|
+
}
|
|
60
|
+
function pidAlive(pid) {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Bounded poll for real process death. Resolves true iff the PID vanished. */
|
|
70
|
+
async function waitForPidDeath(pid, timeoutMs = 8000) {
|
|
71
|
+
const deadline = Date.now() + timeoutMs;
|
|
72
|
+
while (Date.now() < deadline) {
|
|
73
|
+
if (!pidAlive(pid))
|
|
74
|
+
return true;
|
|
75
|
+
await sleep(100);
|
|
76
|
+
}
|
|
77
|
+
return !pidAlive(pid);
|
|
78
|
+
}
|
|
79
|
+
function sleep(ms) {
|
|
80
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Find compile-worker PIDs that are DIRECT children of this test process.
|
|
84
|
+
* esbuild service processes are children of the worker (grandchildren of the
|
|
85
|
+
* test), and vitest's own pool children belong to a different parent — the
|
|
86
|
+
* ppid filter excludes both.
|
|
87
|
+
*/
|
|
88
|
+
function findCompileWorkerPids() {
|
|
89
|
+
const out = execFileSync('ps', ['-axo', 'pid=,ppid=,command='], { encoding: 'utf8' });
|
|
90
|
+
const pids = [];
|
|
91
|
+
for (const line of out.split('\n')) {
|
|
92
|
+
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
93
|
+
if (!match)
|
|
94
|
+
continue;
|
|
95
|
+
const [, pid, ppid, command] = match;
|
|
96
|
+
if (Number(ppid) !== process.pid)
|
|
97
|
+
continue;
|
|
98
|
+
if (!command.includes('compile-worker-entry'))
|
|
99
|
+
continue;
|
|
100
|
+
pids.push(Number(pid));
|
|
101
|
+
}
|
|
102
|
+
return pids;
|
|
103
|
+
}
|
|
104
|
+
// ── cleanup ledger: nothing this suite spawns may outlive it ───────────────
|
|
105
|
+
const doomedPids = [];
|
|
106
|
+
const openSessions = [];
|
|
107
|
+
const cleanupRoots = [];
|
|
108
|
+
afterEach(async () => {
|
|
109
|
+
for (const session of openSessions.splice(0)) {
|
|
110
|
+
try {
|
|
111
|
+
await session.close();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// best-effort teardown
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const pid of doomedPids.splice(0)) {
|
|
118
|
+
if (pidAlive(pid)) {
|
|
119
|
+
try {
|
|
120
|
+
process.kill(pid, 'SIGKILL');
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// already gone
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const root of cleanupRoots.splice(0)) {
|
|
128
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
function makeFixture() {
|
|
132
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-worker-leak-'));
|
|
133
|
+
cleanupRoots.push(root);
|
|
134
|
+
const write = (rel, content) => {
|
|
135
|
+
const target = path.join(root, rel);
|
|
136
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
137
|
+
fs.writeFileSync(target, content);
|
|
138
|
+
};
|
|
139
|
+
write('project.config.json', JSON.stringify({ appid: 'fixture_app_001', projectname: 'fixture-app' }));
|
|
140
|
+
write('app.json', JSON.stringify({ pages: ['pages/index/index'] }));
|
|
141
|
+
write('app.js', 'App({})\n');
|
|
142
|
+
write('app.wxss', 'page { font-size: 14px; }\n');
|
|
143
|
+
write('pages/index/index.json', '{}\n');
|
|
144
|
+
write('pages/index/index.js', 'Page({ data: { msg: "hi" } })\n');
|
|
145
|
+
write('pages/index/index.wxml', '<view>{{msg}}</view>\n');
|
|
146
|
+
write('pages/index/index.wxss', '.x { color: red; }\n');
|
|
147
|
+
return root;
|
|
148
|
+
}
|
|
149
|
+
describe('① orphan safety net — the worker kills ITSELF when the IPC channel dies', () => {
|
|
150
|
+
it('a forked entry whose parent disconnects the IPC channel exits ON ITS OWN with code 0 (no kill from anyone)', async () => {
|
|
151
|
+
const child = fork(workerEntryPath(), [], { execArgv: [], silent: true });
|
|
152
|
+
doomedPids.push(child.pid);
|
|
153
|
+
await once(child, 'spawn');
|
|
154
|
+
const exited = once(child, 'exit');
|
|
155
|
+
// The channel-closed signal in isolation: the parent stays alive and
|
|
156
|
+
// NEVER calls kill — the only way the child dies is its own
|
|
157
|
+
// disconnect handler. This is exactly what the worker observes when
|
|
158
|
+
// the host process dies without running session.close().
|
|
159
|
+
child.disconnect();
|
|
160
|
+
const result = await Promise.race([
|
|
161
|
+
exited,
|
|
162
|
+
sleep(8000).then(() => 'TIMEOUT'),
|
|
163
|
+
]);
|
|
164
|
+
expect(result, 'the compile worker must exit on its own when the fork IPC channel disconnects — '
|
|
165
|
+
+ 'a worker that lingers after channel loss is an orphan leak on every parent death that skips close()').not.toBe('TIMEOUT');
|
|
166
|
+
const [code] = result;
|
|
167
|
+
expect(code, 'the disconnect self-exit must be graceful (exit code 0) — a crash-style death would pollute crash telemetry/logs').toBe(0);
|
|
168
|
+
expect(await waitForPidDeath(child.pid)).toBe(true);
|
|
169
|
+
}, 30_000);
|
|
170
|
+
it('REAL orphaning: parent process SIGKILLed (never calls close) — the orphaned worker still dies unaided', async () => {
|
|
171
|
+
// Intermediate parent: forks the entry, reports the child PID on
|
|
172
|
+
// stdout, then SIGKILLs ITSELF. SIGKILL is the harshest parent death —
|
|
173
|
+
// no exit handlers, no kill(child), nothing but the OS closing the
|
|
174
|
+
// IPC pipe. The orphan must notice and exit.
|
|
175
|
+
const helperSource = `
|
|
176
|
+
const { fork } = require('node:child_process')
|
|
177
|
+
const fs = require('node:fs')
|
|
178
|
+
const child = fork(process.argv[1], [], { execArgv: [], silent: true })
|
|
179
|
+
child.on('spawn', () => {
|
|
180
|
+
fs.writeSync(1, 'CHILD_PID=' + child.pid + '\\n')
|
|
181
|
+
process.kill(process.pid, 'SIGKILL')
|
|
182
|
+
})
|
|
183
|
+
`;
|
|
184
|
+
const parent = spawn(process.execPath, ['-e', helperSource, workerEntryPath()], {
|
|
185
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
186
|
+
});
|
|
187
|
+
doomedPids.push(parent.pid);
|
|
188
|
+
let stdout = '';
|
|
189
|
+
parent.stdout.on('data', (chunk) => {
|
|
190
|
+
stdout += chunk.toString('utf8');
|
|
191
|
+
});
|
|
192
|
+
const [, signal] = (await once(parent, 'exit'));
|
|
193
|
+
expect(signal, 'precondition: the intermediate parent must die by SIGKILL').toBe('SIGKILL');
|
|
194
|
+
const match = stdout.match(/CHILD_PID=(\d+)/);
|
|
195
|
+
expect(match, 'precondition: the intermediate parent must report the worker PID before dying').not.toBeNull();
|
|
196
|
+
const orphanPid = Number(match[1]);
|
|
197
|
+
doomedPids.push(orphanPid);
|
|
198
|
+
expect(await waitForPidDeath(orphanPid), `orphaned compile worker (pid ${orphanPid}) must exit on its own after its parent was SIGKILLed — `
|
|
199
|
+
+ 'this is the safety net for every host death that never reaches session.close()').toBe(true);
|
|
200
|
+
}, 30_000);
|
|
201
|
+
});
|
|
202
|
+
describe('② session.close() — the worker PID is ACTUALLY dead afterwards (real fork, real compiler)', () => {
|
|
203
|
+
it('openProject forks a real worker; session.close() leaves that PID dead within the deadline', async () => {
|
|
204
|
+
const root = makeFixture();
|
|
205
|
+
const session = await devkit.openProject({
|
|
206
|
+
projectPath: root,
|
|
207
|
+
watch: false,
|
|
208
|
+
outputDir: path.join(root, '.out'),
|
|
209
|
+
});
|
|
210
|
+
openSessions.push(session);
|
|
211
|
+
const pids = findCompileWorkerPids();
|
|
212
|
+
doomedPids.push(...pids);
|
|
213
|
+
expect(pids.length, 'precondition: after openProject resolves, exactly one live compile-worker child of this process is expected').toBe(1);
|
|
214
|
+
const workerPid = pids[0];
|
|
215
|
+
expect(pidAlive(workerPid)).toBe(true);
|
|
216
|
+
await session.close();
|
|
217
|
+
expect(await waitForPidDeath(workerPid), `session.close() must leave the compile worker (pid ${workerPid}) actually dead — `
|
|
218
|
+
+ 'the existing suite only pins that kill() was INVOKED; this pins that the process is GONE').toBe(true);
|
|
219
|
+
}, 90_000);
|
|
220
|
+
it('close() during an in-flight rebuild still leaves the worker PID dead within the deadline (no mid-build survivor)', async () => {
|
|
221
|
+
const root = makeFixture();
|
|
222
|
+
const logEntries = [];
|
|
223
|
+
const session = await devkit.openProject({
|
|
224
|
+
projectPath: root,
|
|
225
|
+
watch: true,
|
|
226
|
+
outputDir: path.join(root, '.out'),
|
|
227
|
+
// The in-flight build's death-by-close settles through the error
|
|
228
|
+
// path — it must be swallowed here, not crash the suite.
|
|
229
|
+
onBuildError: () => { },
|
|
230
|
+
onLog: entry => logEntries.push(entry),
|
|
231
|
+
});
|
|
232
|
+
openSessions.push(session);
|
|
233
|
+
const pids = findCompileWorkerPids();
|
|
234
|
+
doomedPids.push(...pids);
|
|
235
|
+
expect(pids.length, 'precondition: one live compile worker after open').toBe(1);
|
|
236
|
+
const workerPid = pids[0];
|
|
237
|
+
// Trigger a watcher rebuild and close as soon as the worker shows
|
|
238
|
+
// life (first rebuild log line ⇒ the build is running RIGHT NOW).
|
|
239
|
+
// Best-effort in-flight timing: even if the rebuild squeaks through
|
|
240
|
+
// before close lands, the pinned outcome (PID dead) must still hold.
|
|
241
|
+
logEntries.length = 0;
|
|
242
|
+
// Count-agnostic precondition (logEntries.length > 0): re-write the
|
|
243
|
+
// source until the rebuild's first log line appears, so a dropped
|
|
244
|
+
// inotify event under CI load can't fail the precondition. The rebuild
|
|
245
|
+
// scheduler coalesces the extra writes into one trailing build.
|
|
246
|
+
let sawRebuildOutput = false;
|
|
247
|
+
await Promise.race([
|
|
248
|
+
writeUntilPredicate(() => logEntries.length > 0, path.join(root, 'pages', 'index', 'index.js'), attempt => `Page({ data: { msg: "mid-build close-${attempt}" } })\n`).then(() => { sawRebuildOutput = true; }),
|
|
249
|
+
sleep(20_000),
|
|
250
|
+
]);
|
|
251
|
+
if (logEntries.length > 0)
|
|
252
|
+
sawRebuildOutput = true;
|
|
253
|
+
expect(sawRebuildOutput, 'precondition: the watcher rebuild must have started (worker emitted output)').toBe(true);
|
|
254
|
+
await session.close();
|
|
255
|
+
expect(await waitForPidDeath(workerPid), `close() issued while a build was in flight must still leave the worker (pid ${workerPid}) dead — `
|
|
256
|
+
+ 'a busy worker that survives close is the classic "compile still running after project closed" leak').toBe(true);
|
|
257
|
+
}, 90_000);
|
|
258
|
+
/**
|
|
259
|
+
* CODEX-REVIEW REGRESSION (M3): the tests above tolerate an 8s post-close
|
|
260
|
+
* death poll. The actual contract is stronger — `await session.close()`
|
|
261
|
+
* must RETURN only after the worker already exited (close() awaits the
|
|
262
|
+
* child 'exit'), so the await itself is the death guarantee and no caller
|
|
263
|
+
* ever needs a grace poll.
|
|
264
|
+
*/
|
|
265
|
+
it("M3: await session.close() returns only AFTER the worker PID is already dead — the await IS the guarantee, no grace poll", async () => {
|
|
266
|
+
const root = makeFixture();
|
|
267
|
+
const session = await devkit.openProject({
|
|
268
|
+
projectPath: root,
|
|
269
|
+
watch: false,
|
|
270
|
+
outputDir: path.join(root, '.out'),
|
|
271
|
+
});
|
|
272
|
+
openSessions.push(session);
|
|
273
|
+
const pids = findCompileWorkerPids();
|
|
274
|
+
doomedPids.push(...pids);
|
|
275
|
+
expect(pids.length, 'precondition: one live compile worker after open').toBe(1);
|
|
276
|
+
const workerPid = pids[0];
|
|
277
|
+
expect(pidAlive(workerPid)).toBe(true);
|
|
278
|
+
await session.close();
|
|
279
|
+
// Checked synchronously right after the await — NO waitForPidDeath poll.
|
|
280
|
+
expect(pidAlive(workerPid), `session.close() resolved while the compile worker (pid ${workerPid}) was still alive — `
|
|
281
|
+
+ "close() must await the child 'exit' before resolving; kill-and-return makes every caller race a dying "
|
|
282
|
+
+ 'compiler for ports, output files and cwd').toBe(false);
|
|
283
|
+
}, 90_000);
|
|
284
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { WorkerAppInfo, WorkerBuildOptions } from './compile-worker-entry.js';
|
|
2
|
+
export interface CompileLogEntry {
|
|
3
|
+
stream: 'stdout' | 'stderr';
|
|
4
|
+
text: string;
|
|
5
|
+
}
|
|
6
|
+
export interface CompileWorkerOptions {
|
|
7
|
+
/** Filtered dmcc log lines (already through `filterDmccLogLine`). */
|
|
8
|
+
onLog?: (entry: CompileLogEntry) => void;
|
|
9
|
+
}
|
|
10
|
+
export interface BuildRequest {
|
|
11
|
+
projectPath: string;
|
|
12
|
+
outputDir: string;
|
|
13
|
+
options: WorkerBuildOptions;
|
|
14
|
+
}
|
|
15
|
+
export interface CompileWorker {
|
|
16
|
+
/**
|
|
17
|
+
* Run one build in the worker. Serialized: a call made while another build
|
|
18
|
+
* is in flight waits for it to settle first (the rebuild scheduler already
|
|
19
|
+
* coalesces watcher events; this guard keeps the IPC protocol single-flight
|
|
20
|
+
* even under direct use).
|
|
21
|
+
*/
|
|
22
|
+
build: (req: BuildRequest) => Promise<WorkerAppInfo | null>;
|
|
23
|
+
/**
|
|
24
|
+
* Kill the worker and resolve once the child actually died — first of
|
|
25
|
+
* 'exit'/'close'/'error' (a lone 'error' counts: Node does not guarantee
|
|
26
|
+
* an 'exit' after it). An in-flight build is rejected by close() itself
|
|
27
|
+
* (not delegated to a child 'exit' that a wedged child may never emit).
|
|
28
|
+
* Idempotent; the instance is dead afterwards — no re-fork.
|
|
29
|
+
*/
|
|
30
|
+
close: () => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export declare function createCompileWorker(opts?: CompileWorkerOptions): CompileWorker;
|
|
33
|
+
//# sourceMappingURL=compile-worker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compile-worker.d.ts","sourceRoot":"","sources":["../src/compile-worker.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAuB,MAAM,2BAA2B,CAAA;AAIvG,MAAM,WAAW,eAAe;IAC/B,MAAM,EAAE,QAAQ,GAAG,QAAQ,CAAA;IAC3B,IAAI,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,oBAAoB;IACpC,qEAAqE;IACrE,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;CACxC;AAED,MAAM,WAAW,YAAY;IAC5B,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,kBAAkB,CAAA;CAC3B;AAED,MAAM,WAAW,aAAa;IAC7B;;;;;OAKG;IACH,KAAK,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAA;IAC3D;;;;;;OAMG;IACH,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1B;AAeD,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,oBAAyB,GAAG,aAAa,CA8KlF"}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parent-side orchestration of the forked compile worker.
|
|
3
|
+
*
|
|
4
|
+
* One long-lived worker per `openProject` session: the first compile and
|
|
5
|
+
* every watcher rebuild are `{ cmd: 'build' }` IPC messages to the SAME
|
|
6
|
+
* child, so the compiler's module/worker caches stay warm. The parent never
|
|
7
|
+
* calls `process.chdir` — the worker chdirs in its own process (the root
|
|
8
|
+
* motive of this architecture).
|
|
9
|
+
*
|
|
10
|
+
* Crash handling: an unexpected worker death ('exit', 'close' or a lone
|
|
11
|
+
* 'error' event — Node does NOT guarantee an 'exit' after 'error') rejects
|
|
12
|
+
* the in-flight build (so the rebuild scheduler settles instead of hanging)
|
|
13
|
+
* and the NEXT build lazily re-forks a fresh worker. The death handler is
|
|
14
|
+
* idempotent and generation-guarded: a dead child's late 'exit' can never
|
|
15
|
+
* settle a build that belongs to a fresh worker. `close()` kills, rejects
|
|
16
|
+
* the in-flight build itself, and resolves on the child's first death event
|
|
17
|
+
* ('exit'/'close', or a lone 'error') — it never re-forks
|
|
18
|
+
* (refill-on-graceful-close would wedge process teardown).
|
|
19
|
+
*/
|
|
20
|
+
import { fork } from 'node:child_process';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { filterDmccLogLine } from './compile-log.js';
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the fork target. In the published build this is the compiled
|
|
28
|
+
* `dist/compile-worker-entry.js` sibling; under vitest/dev the module runs
|
|
29
|
+
* from `src/`, where only the `.ts` source exists — Node ≥22.18 strips types
|
|
30
|
+
* natively, so forking the `.ts` entry works (the entry is a zero-dependency,
|
|
31
|
+
* erasable-syntax-only shell by design).
|
|
32
|
+
*/
|
|
33
|
+
function resolveWorkerEntry() {
|
|
34
|
+
const js = path.join(__dirname, 'compile-worker-entry.js');
|
|
35
|
+
if (fs.existsSync(js))
|
|
36
|
+
return js;
|
|
37
|
+
return path.join(__dirname, 'compile-worker-entry.ts');
|
|
38
|
+
}
|
|
39
|
+
export function createCompileWorker(opts = {}) {
|
|
40
|
+
let child = null;
|
|
41
|
+
let closed = false;
|
|
42
|
+
let closePromise = null;
|
|
43
|
+
// Pending close() resolver, driven by settleDeath — NOT by a listener on
|
|
44
|
+
// the child. settleDeath's removeAllListeners() would strip a
|
|
45
|
+
// once('exit') resolver when the child dies through the 'error' path
|
|
46
|
+
// first (Node does not guarantee an 'exit' after 'error'), leaving the
|
|
47
|
+
// closePromise hanging forever. Routing the resolve through settleDeath
|
|
48
|
+
// makes ANY first death event ('exit', 'close' or a lone 'error') settle
|
|
49
|
+
// the close, consistent with how it already treats 'error' as death for
|
|
50
|
+
// builds and child cleanup.
|
|
51
|
+
let resolveClose = null;
|
|
52
|
+
let inFlight = null;
|
|
53
|
+
// Serialization chain: at most one unanswered build command on the wire.
|
|
54
|
+
let chain = Promise.resolve();
|
|
55
|
+
function attachLineReader(stream, tag) {
|
|
56
|
+
if (!stream)
|
|
57
|
+
return;
|
|
58
|
+
// Cross-chunk half-line buffering: deliver only complete lines.
|
|
59
|
+
let buffered = '';
|
|
60
|
+
let flushed = false;
|
|
61
|
+
stream.setEncoding('utf8');
|
|
62
|
+
const deliver = (line) => {
|
|
63
|
+
const kept = filterDmccLogLine(line.replace(/\r$/, ''));
|
|
64
|
+
if (kept !== null)
|
|
65
|
+
opts.onLog?.({ stream: tag, text: kept });
|
|
66
|
+
};
|
|
67
|
+
stream.on('data', (chunk) => {
|
|
68
|
+
buffered += chunk;
|
|
69
|
+
let newlineAt = buffered.indexOf('\n');
|
|
70
|
+
while (newlineAt !== -1) {
|
|
71
|
+
const line = buffered.slice(0, newlineAt);
|
|
72
|
+
buffered = buffered.slice(newlineAt + 1);
|
|
73
|
+
deliver(line);
|
|
74
|
+
newlineAt = buffered.indexOf('\n');
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
// A dying process's last line (often the error summary) can end without
|
|
78
|
+
// a trailing newline — flush the remainder when the stream finishes.
|
|
79
|
+
// Streams emit BOTH 'end' and 'close'; the flag keeps the flush single
|
|
80
|
+
// and clearing the buffer prevents a double delivery either way.
|
|
81
|
+
const flush = () => {
|
|
82
|
+
if (flushed)
|
|
83
|
+
return;
|
|
84
|
+
flushed = true;
|
|
85
|
+
if (buffered.length === 0)
|
|
86
|
+
return;
|
|
87
|
+
const line = buffered;
|
|
88
|
+
buffered = '';
|
|
89
|
+
deliver(line);
|
|
90
|
+
};
|
|
91
|
+
stream.on('end', flush);
|
|
92
|
+
stream.on('close', flush);
|
|
93
|
+
}
|
|
94
|
+
function spawnWorker() {
|
|
95
|
+
const entry = resolveWorkerEntry();
|
|
96
|
+
const worker = fork(entry, [], {
|
|
97
|
+
// Pipe stdout/stderr back to the parent — the dmcc log transport.
|
|
98
|
+
silent: true,
|
|
99
|
+
// Do NOT inherit the parent's execArgv (vitest/electron loaders would
|
|
100
|
+
// leak into a plain Node child). Node ≥22.18 type-strips a .ts entry
|
|
101
|
+
// by default, so no flags are needed either way.
|
|
102
|
+
execArgv: [],
|
|
103
|
+
});
|
|
104
|
+
attachLineReader(worker.stdout, 'stdout');
|
|
105
|
+
attachLineReader(worker.stderr, 'stderr');
|
|
106
|
+
// Shared, idempotent death handler for 'exit' / 'close' / 'error': the
|
|
107
|
+
// three can fire in any combination ('error' alone on spawn failures,
|
|
108
|
+
// exit+close on a normal death, error followed by a late exit). Only
|
|
109
|
+
// the FIRST one acts; all state mutations are guarded by worker
|
|
110
|
+
// identity so a previous generation's death never touches a fresh one.
|
|
111
|
+
let settled = false;
|
|
112
|
+
const settleDeath = (reason) => {
|
|
113
|
+
if (settled)
|
|
114
|
+
return;
|
|
115
|
+
settled = true;
|
|
116
|
+
// Clear the dead child so the next build re-forks a fresh worker.
|
|
117
|
+
if (child === worker)
|
|
118
|
+
child = null;
|
|
119
|
+
// Settle an in-flight build instead of hanging the rebuild
|
|
120
|
+
// scheduler — but only a build that was sent to THIS worker.
|
|
121
|
+
if (inFlight && inFlight.worker === worker) {
|
|
122
|
+
const pending = inFlight;
|
|
123
|
+
inFlight = null;
|
|
124
|
+
pending.reject(new Error(reason));
|
|
125
|
+
}
|
|
126
|
+
// Resolve a pending close(): this death IS the exit close() was
|
|
127
|
+
// waiting for. (At most one unsettled worker exists at a time —
|
|
128
|
+
// `child` only changes hands through settleDeath or close(), and
|
|
129
|
+
// after close() no re-fork happens — so an old generation can never
|
|
130
|
+
// race a newer worker's close here.)
|
|
131
|
+
if (resolveClose) {
|
|
132
|
+
const settleClose = resolveClose;
|
|
133
|
+
resolveClose = null;
|
|
134
|
+
settleClose();
|
|
135
|
+
}
|
|
136
|
+
// Drop stale listeners on the dead child; the next build re-forks.
|
|
137
|
+
worker.removeAllListeners();
|
|
138
|
+
};
|
|
139
|
+
worker.on('message', (msg) => {
|
|
140
|
+
if (child !== worker)
|
|
141
|
+
return;
|
|
142
|
+
const result = msg;
|
|
143
|
+
if (!result || result.type !== 'result' || !inFlight)
|
|
144
|
+
return;
|
|
145
|
+
const pending = inFlight;
|
|
146
|
+
inFlight = null;
|
|
147
|
+
if (result.error) {
|
|
148
|
+
// A reply carrying an error is a FAILED build — pass the
|
|
149
|
+
// worker-reported message through (devtools renders it).
|
|
150
|
+
pending.reject(new Error(result.error.message));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
pending.resolve(result.appInfo ?? null);
|
|
154
|
+
});
|
|
155
|
+
worker.on('exit', () => settleDeath('compile worker exited unexpectedly mid-build'));
|
|
156
|
+
worker.on('close', () => settleDeath('compile worker exited unexpectedly mid-build'));
|
|
157
|
+
worker.on('error', (err) => settleDeath(`compile worker errored: ${err.message}`));
|
|
158
|
+
return worker;
|
|
159
|
+
}
|
|
160
|
+
function runBuild(req) {
|
|
161
|
+
if (closed) {
|
|
162
|
+
return Promise.reject(new Error('compile worker is closed'));
|
|
163
|
+
}
|
|
164
|
+
if (!child)
|
|
165
|
+
child = spawnWorker();
|
|
166
|
+
const worker = child;
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
inFlight = { worker, resolve, reject };
|
|
169
|
+
worker.send({ cmd: 'build', ...req });
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
build(req) {
|
|
174
|
+
const result = chain.then(() => runBuild(req));
|
|
175
|
+
chain = result.catch(() => {
|
|
176
|
+
// Failures are reported to the caller via `result`; the chain only
|
|
177
|
+
// guarantees single-flight ordering and must not reject.
|
|
178
|
+
});
|
|
179
|
+
return result;
|
|
180
|
+
},
|
|
181
|
+
close() {
|
|
182
|
+
if (closed)
|
|
183
|
+
return closePromise ?? Promise.resolve();
|
|
184
|
+
closed = true;
|
|
185
|
+
const worker = child;
|
|
186
|
+
child = null;
|
|
187
|
+
// Reject the in-flight build HERE: a wedged child that ignores
|
|
188
|
+
// SIGTERM never emits 'exit', and the rebuild scheduler must not
|
|
189
|
+
// hang at teardown waiting on it.
|
|
190
|
+
if (inFlight) {
|
|
191
|
+
const pending = inFlight;
|
|
192
|
+
inFlight = null;
|
|
193
|
+
pending.reject(new Error('compile worker closed'));
|
|
194
|
+
}
|
|
195
|
+
if (!worker) {
|
|
196
|
+
// Never forked, or already dead (death handler cleared `child`).
|
|
197
|
+
closePromise = Promise.resolve();
|
|
198
|
+
return closePromise;
|
|
199
|
+
}
|
|
200
|
+
closePromise = new Promise((resolve) => {
|
|
201
|
+
// Handed to settleDeath BEFORE kill so a synchronous exit can't
|
|
202
|
+
// be missed. No listener is attached to the child here: the
|
|
203
|
+
// worker's existing 'exit'/'close'/'error' handlers drive
|
|
204
|
+
// settleDeath, which resolves this on the FIRST death event —
|
|
205
|
+
// surviving the removeAllListeners() that would strip a
|
|
206
|
+
// once('exit') resolver when 'error' fires before 'exit'.
|
|
207
|
+
resolveClose = resolve;
|
|
208
|
+
worker.kill();
|
|
209
|
+
});
|
|
210
|
+
return closePromise;
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compile-worker.test.d.ts","sourceRoot":"","sources":["../src/compile-worker.test.ts"],"names":[],"mappings":""}
|