@dimina-kit/devkit 0.1.2-dev.20260612025610 → 0.1.2-dev.20260615070430

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 (41) hide show
  1. package/README.md +5 -3
  2. package/dist/compile-log.d.ts +16 -0
  3. package/dist/compile-log.d.ts.map +1 -0
  4. package/dist/compile-log.js +42 -0
  5. package/dist/compile-log.test.d.ts +2 -0
  6. package/dist/compile-log.test.d.ts.map +1 -0
  7. package/dist/compile-log.test.js +134 -0
  8. package/dist/compile-worker-entry.d.ts +47 -0
  9. package/dist/compile-worker-entry.d.ts.map +1 -0
  10. package/dist/compile-worker-entry.js +117 -0
  11. package/dist/compile-worker-entry.test.d.ts +2 -0
  12. package/dist/compile-worker-entry.test.d.ts.map +1 -0
  13. package/dist/compile-worker-entry.test.js +247 -0
  14. package/dist/compile-worker-leak.test.d.ts +2 -0
  15. package/dist/compile-worker-leak.test.d.ts.map +1 -0
  16. package/dist/compile-worker-leak.test.js +284 -0
  17. package/dist/compile-worker.d.ts +33 -0
  18. package/dist/compile-worker.d.ts.map +1 -0
  19. package/dist/compile-worker.js +213 -0
  20. package/dist/compile-worker.test.d.ts +2 -0
  21. package/dist/compile-worker.test.d.ts.map +1 -0
  22. package/dist/compile-worker.test.js +791 -0
  23. package/dist/index.d.ts +15 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +100 -31
  26. package/dist/open-project-cleanup.test.d.ts +2 -0
  27. package/dist/open-project-cleanup.test.d.ts.map +1 -0
  28. package/dist/open-project-cleanup.test.js +176 -0
  29. package/dist/open-project-compile-log.test.d.ts +2 -0
  30. package/dist/open-project-compile-log.test.d.ts.map +1 -0
  31. package/dist/open-project-compile-log.test.js +174 -0
  32. package/dist/rebuild-scheduler.d.ts +24 -0
  33. package/dist/rebuild-scheduler.d.ts.map +1 -0
  34. package/dist/rebuild-scheduler.js +58 -0
  35. package/dist/rebuild-scheduler.test.d.ts +2 -0
  36. package/dist/rebuild-scheduler.test.d.ts.map +1 -0
  37. package/dist/rebuild-scheduler.test.js +201 -0
  38. package/dist/watch-rebuild.testutil.d.ts +21 -0
  39. package/dist/watch-rebuild.testutil.d.ts.map +1 -0
  40. package/dist/watch-rebuild.testutil.js +81 -0
  41. package/package.json +3 -3
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=compile-worker.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compile-worker.test.d.ts","sourceRoot":"","sources":["../src/compile-worker.test.ts"],"names":[],"mappings":""}