@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.
- 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 +3 -3
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { PassThrough } from 'node:stream';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import * as devkit from './index.js';
|
|
8
|
+
import { writeUntilPredicate, writeUntilSettled } from './watch-rebuild.testutil.js';
|
|
9
|
+
/**
|
|
10
|
+
* FLAKE HARDENING (no assertions changed): the watcher-driven rebuild tests
|
|
11
|
+
* below depend on a REAL chokidar inotify watch (fork is mocked, chokidar is
|
|
12
|
+
* not). Under CI load (concurrent real dmcc compile in
|
|
13
|
+
* open-project-compile-log.test.ts pegging CPU) a single fs.writeFileSync's
|
|
14
|
+
* inotify event can be dropped, hanging `await rebuilt`/`vi.waitFor` forever
|
|
15
|
+
* (PR #44 30s timeout at the "fork exactly once across … 2 rebuilds" test).
|
|
16
|
+
* Count-agnostic / `>=` waiters are wrapped in writeUntilSettled /
|
|
17
|
+
* writeUntilPredicate, which RE-WRITE the source file (micro-varied content →
|
|
18
|
+
* fresh inotify event) until the rebuild lands; the rebuild scheduler
|
|
19
|
+
* coalesces the extra writes into one trailing build, so the pinned outcomes
|
|
20
|
+
* are unchanged. The exact-count serial-IPC test deliberately does NOT use
|
|
21
|
+
* them (see its inline note).
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* FORK-ARCHITECTURE WAVE (dmcc 编译子进程化) — TDD contract for the PARENT
|
|
25
|
+
* side: `openProject` orchestrating a long-lived forked compile worker
|
|
26
|
+
* (NOT yet implemented).
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ ARCHITECTURE-DECISION CHANGE (user-approved, 2026-06-12):
|
|
29
|
+
* Compilation moves from the in-process `require('@dimina/compiler')` call
|
|
30
|
+
* (src/index.ts:104-105 with its host-global `process.chdir`) into a forked
|
|
31
|
+
* long-lived child process. The tee-style `withCapturedStdio` contract from
|
|
32
|
+
* ROUND 2 was deleted (see compile-log.test.ts header); THIS file pins its
|
|
33
|
+
* replacement. Explicit architecture correction, not goalpost-moving.
|
|
34
|
+
*
|
|
35
|
+
* Parent-side contract pinned here (child_process.fork is mocked; the fake
|
|
36
|
+
* child is an EventEmitter with PassThrough stdout/stderr + send/kill spies):
|
|
37
|
+
* 1. `openProject` forks the compile worker EXACTLY ONCE and keeps it for
|
|
38
|
+
* the whole session — first compile and every watcher rebuild are
|
|
39
|
+
* `{ cmd: 'build', projectPath, outputDir, options }` IPC messages to
|
|
40
|
+
* the SAME child. Fork options must pipe stdout/stderr.
|
|
41
|
+
* 2. The parent process NEVER calls `process.chdir` — the core motive of
|
|
42
|
+
* this architecture (the worker chdirs in its own process instead).
|
|
43
|
+
* 3. child stdout/stderr are split into lines (with cross-chunk half-line
|
|
44
|
+
* buffering), passed through `filterDmccLogLine`, and only surviving
|
|
45
|
+
* lines reach `opts.onLog({ stream, text })`.
|
|
46
|
+
* 4. The worker's `{ type: 'result', appInfo }` reply becomes the resolved
|
|
47
|
+
* `session.appInfo` — same shape as today (downstream consumers key
|
|
48
|
+
* storage prefixes etc. off `appId`).
|
|
49
|
+
* 5. `session.close()` kills the worker.
|
|
50
|
+
* 6. A worker that exits unexpectedly mid-build settles the in-flight build
|
|
51
|
+
* via `opts.onBuildError(Error mentioning the worker)` instead of
|
|
52
|
+
* hanging; the NEXT rebuild re-forks a fresh worker (crash recovery).
|
|
53
|
+
* 7. Serial IPC: while a build command is unanswered, watcher events never
|
|
54
|
+
* produce a concurrent build command — they coalesce into exactly one
|
|
55
|
+
* trailing build (rebuild-scheduler semantics across the IPC boundary).
|
|
56
|
+
* 8. Without `onLog` the worker is STILL forked (fork is the uniform
|
|
57
|
+
* compile path, not a logging feature) and stray child output is simply
|
|
58
|
+
* not delivered anywhere.
|
|
59
|
+
*
|
|
60
|
+
* NOTE for the implementer: this file mocks BOTH 'node:child_process' and
|
|
61
|
+
* 'child_process' — import fork from either. The fake child auto-replies to
|
|
62
|
+
* build commands unless a test flips `autoRespond` off to control timing.
|
|
63
|
+
*/
|
|
64
|
+
const mocks = vi.hoisted(() => ({ fork: vi.fn() }));
|
|
65
|
+
vi.mock('node:child_process', async (importOriginal) => {
|
|
66
|
+
const actual = await importOriginal();
|
|
67
|
+
return { ...actual, fork: mocks.fork, default: { ...actual, fork: mocks.fork } };
|
|
68
|
+
});
|
|
69
|
+
vi.mock('child_process', async (importOriginal) => {
|
|
70
|
+
const actual = await importOriginal();
|
|
71
|
+
return { ...actual, fork: mocks.fork, default: { ...actual, fork: mocks.fork } };
|
|
72
|
+
});
|
|
73
|
+
/** The appInfo the fake worker replies with (path is echoed per message). */
|
|
74
|
+
const WORKER_APP = { appId: 'worker_app_777', name: 'from-worker' };
|
|
75
|
+
class FakeChild extends EventEmitter {
|
|
76
|
+
stdout = new PassThrough();
|
|
77
|
+
stderr = new PassThrough();
|
|
78
|
+
connected = true;
|
|
79
|
+
killed = false;
|
|
80
|
+
pid = 4242;
|
|
81
|
+
/** When true (default), every {cmd:'build'} is answered on a microtask. */
|
|
82
|
+
autoRespond = true;
|
|
83
|
+
/**
|
|
84
|
+
* When true, kill() does NOT auto-emit 'exit' — the test simulates the
|
|
85
|
+
* child death manually (or never). Default false keeps every pre-existing
|
|
86
|
+
* test's behaviour byte-identical.
|
|
87
|
+
*/
|
|
88
|
+
manualExit = false;
|
|
89
|
+
sent = [];
|
|
90
|
+
send = vi.fn((msg) => {
|
|
91
|
+
const m = msg;
|
|
92
|
+
this.sent.push(m);
|
|
93
|
+
if (this.autoRespond && m && m.cmd === 'build') {
|
|
94
|
+
queueMicrotask(() => {
|
|
95
|
+
if (!this.connected)
|
|
96
|
+
return;
|
|
97
|
+
this.emit('message', {
|
|
98
|
+
type: 'result',
|
|
99
|
+
appInfo: { ...WORKER_APP, path: String(m.projectPath ?? '') },
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
});
|
|
105
|
+
kill = vi.fn((..._args) => {
|
|
106
|
+
this.killed = true;
|
|
107
|
+
this.connected = false;
|
|
108
|
+
if (!this.manualExit)
|
|
109
|
+
queueMicrotask(() => this.emit('exit', null, 'SIGTERM'));
|
|
110
|
+
return true;
|
|
111
|
+
});
|
|
112
|
+
buildSends() {
|
|
113
|
+
return this.sent.filter(m => m && m.cmd === 'build');
|
|
114
|
+
}
|
|
115
|
+
/** Manually answer the in-flight build (for autoRespond=false tests). */
|
|
116
|
+
respondToBuild(projectPath) {
|
|
117
|
+
this.emit('message', {
|
|
118
|
+
type: 'result',
|
|
119
|
+
appInfo: { ...WORKER_APP, path: projectPath },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Simulate an unexpected worker death. */
|
|
123
|
+
crash(code = 1) {
|
|
124
|
+
this.connected = false;
|
|
125
|
+
this.emit('exit', code, null);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const children = [];
|
|
129
|
+
const openSessions = [];
|
|
130
|
+
const cleanupRoots = [];
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
children.length = 0;
|
|
133
|
+
mocks.fork.mockReset();
|
|
134
|
+
mocks.fork.mockImplementation(() => {
|
|
135
|
+
const child = new FakeChild();
|
|
136
|
+
children.push(child);
|
|
137
|
+
return child;
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
afterEach(async () => {
|
|
141
|
+
for (const session of openSessions.splice(0)) {
|
|
142
|
+
try {
|
|
143
|
+
await session.close();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// best-effort teardown
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const root of cleanupRoots.splice(0)) {
|
|
150
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
function makeFixture() {
|
|
154
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-compile-worker-'));
|
|
155
|
+
cleanupRoots.push(root);
|
|
156
|
+
const write = (rel, content) => {
|
|
157
|
+
const target = path.join(root, rel);
|
|
158
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
159
|
+
fs.writeFileSync(target, content);
|
|
160
|
+
};
|
|
161
|
+
write('project.config.json', JSON.stringify({ appid: 'fixture_app_001', projectname: 'fixture-app' }));
|
|
162
|
+
write('app.json', JSON.stringify({ pages: ['pages/index/index'] }));
|
|
163
|
+
write('app.js', 'App({})\n');
|
|
164
|
+
write('app.wxss', 'page { font-size: 14px; }\n');
|
|
165
|
+
write('pages/index/index.json', '{}\n');
|
|
166
|
+
write('pages/index/index.js', 'Page({ data: { msg: "hi" } })\n');
|
|
167
|
+
write('pages/index/index.wxml', '<view>{{msg}}</view>\n');
|
|
168
|
+
write('pages/index/index.wxss', '.x { color: red; }\n');
|
|
169
|
+
return root;
|
|
170
|
+
}
|
|
171
|
+
function theChild() {
|
|
172
|
+
const child = children.at(-1);
|
|
173
|
+
expect(child, 'openProject must fork the compile worker — child_process.fork was never called').toBeDefined();
|
|
174
|
+
return child;
|
|
175
|
+
}
|
|
176
|
+
function rebuildWaiter() {
|
|
177
|
+
const waiters = [];
|
|
178
|
+
return {
|
|
179
|
+
onRebuild: () => {
|
|
180
|
+
for (const wake of waiters.splice(0))
|
|
181
|
+
wake();
|
|
182
|
+
},
|
|
183
|
+
next: () => new Promise(resolve => waiters.push(resolve)),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function sleep(ms) {
|
|
187
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
188
|
+
}
|
|
189
|
+
describe('openProject — fork-based compile worker orchestration', () => {
|
|
190
|
+
it('forks the compile worker exactly once, targeting the compile-worker-entry module with piped stdio', async () => {
|
|
191
|
+
const root = makeFixture();
|
|
192
|
+
const session = await devkit.openProject({
|
|
193
|
+
projectPath: root,
|
|
194
|
+
watch: false,
|
|
195
|
+
outputDir: path.join(root, '.out'),
|
|
196
|
+
});
|
|
197
|
+
openSessions.push(session);
|
|
198
|
+
expect(mocks.fork, 'openProject must fork the long-lived compile worker via child_process.fork — compilation no longer runs in the host process').toHaveBeenCalledTimes(1);
|
|
199
|
+
const call = mocks.fork.mock.calls[0];
|
|
200
|
+
expect(String(call[0]), 'the fork target must be the compile-worker-entry module').toContain('compile-worker-entry');
|
|
201
|
+
// Without piped stdio the parent can never read dmcc output: either
|
|
202
|
+
// silent:true or an explicit stdio array containing 'pipe' is required.
|
|
203
|
+
const optionsArg = call.find(arg => typeof arg === 'object' && arg !== null && !Array.isArray(arg));
|
|
204
|
+
let piped = false;
|
|
205
|
+
if (optionsArg) {
|
|
206
|
+
piped = optionsArg.silent === true
|
|
207
|
+
|| (Array.isArray(optionsArg.stdio) && optionsArg.stdio.includes('pipe'));
|
|
208
|
+
}
|
|
209
|
+
expect(piped, 'fork options must pipe the child stdout/stderr (silent:true or a stdio array containing "pipe") — otherwise onLog can never see dmcc output').toBe(true);
|
|
210
|
+
}, 45_000);
|
|
211
|
+
it('first build flows over IPC and openProject resolves with the worker-returned AppInfo (downstream shape preserved)', async () => {
|
|
212
|
+
const root = makeFixture();
|
|
213
|
+
const outputDir = path.join(root, '.out');
|
|
214
|
+
const session = await devkit.openProject({
|
|
215
|
+
projectPath: root,
|
|
216
|
+
watch: false,
|
|
217
|
+
outputDir,
|
|
218
|
+
sourcemap: true,
|
|
219
|
+
});
|
|
220
|
+
openSessions.push(session);
|
|
221
|
+
const child = theChild();
|
|
222
|
+
const builds = child.buildSends();
|
|
223
|
+
expect(builds.length, 'the first compile must be a {cmd:"build"} IPC message to the worker').toBe(1);
|
|
224
|
+
expect(builds[0]).toEqual(expect.objectContaining({
|
|
225
|
+
cmd: 'build',
|
|
226
|
+
projectPath: root,
|
|
227
|
+
outputDir,
|
|
228
|
+
options: expect.objectContaining({ sourcemap: true }),
|
|
229
|
+
}));
|
|
230
|
+
// The fake worker replied {type:'result', appInfo} — openProject must
|
|
231
|
+
// resolve with exactly that appInfo. Consumers key storage prefixes
|
|
232
|
+
// etc. off appId; the shape must survive the IPC hop unchanged.
|
|
233
|
+
expect(session.appInfo).toEqual({ ...WORKER_APP, path: root });
|
|
234
|
+
expect(typeof session.port).toBe('number');
|
|
235
|
+
expect(typeof session.close).toBe('function');
|
|
236
|
+
}, 45_000);
|
|
237
|
+
it('the parent process NEVER calls process.chdir — first build and rebuild alike', async () => {
|
|
238
|
+
const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => { });
|
|
239
|
+
try {
|
|
240
|
+
const root = makeFixture();
|
|
241
|
+
const waiter = rebuildWaiter();
|
|
242
|
+
const session = await devkit.openProject({
|
|
243
|
+
projectPath: root,
|
|
244
|
+
watch: true,
|
|
245
|
+
outputDir: path.join(root, '.out'),
|
|
246
|
+
onRebuild: waiter.onRebuild,
|
|
247
|
+
// Either way the rebuild attempt finished — the pin below is
|
|
248
|
+
// chdir-zero, not the rebuild outcome.
|
|
249
|
+
onBuildError: waiter.onRebuild,
|
|
250
|
+
});
|
|
251
|
+
openSessions.push(session);
|
|
252
|
+
const rebuilt = waiter.next();
|
|
253
|
+
// Count-agnostic (chdir-zero): re-write until the rebuild actually lands.
|
|
254
|
+
await writeUntilSettled(rebuilt, path.join(root, 'pages', 'index', 'index.js'), attempt => `Page({ data: { msg: "updated-${attempt}" } })\n`);
|
|
255
|
+
expect(chdirSpy, 'process.chdir in the host (Electron main) process is the root cause this architecture kills — the parent must never chdir; the worker chdirs in its OWN process').not.toHaveBeenCalled();
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
chdirSpy.mockRestore();
|
|
259
|
+
}
|
|
260
|
+
}, 45_000);
|
|
261
|
+
it('rebuilds reuse the same long-lived worker: fork exactly once across first build + 2 rebuilds', async () => {
|
|
262
|
+
const root = makeFixture();
|
|
263
|
+
const waiter = rebuildWaiter();
|
|
264
|
+
const session = await devkit.openProject({
|
|
265
|
+
projectPath: root,
|
|
266
|
+
watch: true,
|
|
267
|
+
outputDir: path.join(root, '.out'),
|
|
268
|
+
onRebuild: waiter.onRebuild,
|
|
269
|
+
});
|
|
270
|
+
openSessions.push(session);
|
|
271
|
+
const child = theChild();
|
|
272
|
+
for (const round of [1, 2]) {
|
|
273
|
+
const rebuilt = waiter.next();
|
|
274
|
+
// Count-agnostic (fork-once / buildSends >= 3): re-write until each
|
|
275
|
+
// rebuild lands. Extra coalesced rebuilds cannot break a `>=` or a
|
|
276
|
+
// single-fork pin — fork is mocked and stays one child.
|
|
277
|
+
await writeUntilSettled(rebuilt, path.join(root, 'pages', 'index', 'index.js'), attempt => `Page({ data: { msg: "round-${round}-${attempt}" } })\n`);
|
|
278
|
+
}
|
|
279
|
+
expect(mocks.fork, 'the worker is LONG-LIVED: re-forking per build is the old CLI-subprocess model this contract rejects (compiler/module caches would be cold every time)').toHaveBeenCalledTimes(1);
|
|
280
|
+
expect(child.buildSends().length, 'first compile + 2 rebuilds must all be build commands on the SAME child').toBeGreaterThanOrEqual(3);
|
|
281
|
+
expect(children).toHaveLength(1);
|
|
282
|
+
}, 45_000);
|
|
283
|
+
it('child stdout/stderr lines are filtered through filterDmccLogLine and delivered to onLog with stream tags', async () => {
|
|
284
|
+
const root = makeFixture();
|
|
285
|
+
const entries = [];
|
|
286
|
+
const logOpts = { onLog: (entry) => entries.push(entry) };
|
|
287
|
+
const session = await devkit.openProject({
|
|
288
|
+
projectPath: root,
|
|
289
|
+
watch: false,
|
|
290
|
+
outputDir: path.join(root, '.out'),
|
|
291
|
+
...logOpts,
|
|
292
|
+
});
|
|
293
|
+
openSessions.push(session);
|
|
294
|
+
const child = theChild();
|
|
295
|
+
entries.length = 0;
|
|
296
|
+
// keep / drop / drop on stdout; keep on stderr — verbatim spike lines.
|
|
297
|
+
child.stdout.write('✔ 收集配置信息\n❯ 编译页面逻辑\n› [████░░░░░░░░░░░░░░░░░░░░░░░░░░] 12.50%\n');
|
|
298
|
+
child.stderr.write('[compat] Unsupported wx API: wx.createInnerAudioContext (/pages/audio-test/audio-test.js:33)\n');
|
|
299
|
+
await vi.waitFor(() => {
|
|
300
|
+
expect(entries.length).toBeGreaterThanOrEqual(2);
|
|
301
|
+
}, { timeout: 5000 });
|
|
302
|
+
await sleep(50); // noise lines must not straggle in late
|
|
303
|
+
expect(entries).toHaveLength(2);
|
|
304
|
+
expect(entries).toEqual(expect.arrayContaining([
|
|
305
|
+
{ stream: 'stdout', text: '✔ 收集配置信息' },
|
|
306
|
+
{ stream: 'stderr', text: '[compat] Unsupported wx API: wx.createInnerAudioContext (/pages/audio-test/audio-test.js:33)' },
|
|
307
|
+
]));
|
|
308
|
+
}, 45_000);
|
|
309
|
+
it('half lines split across chunks are buffered until the newline arrives (no partial-line delivery)', async () => {
|
|
310
|
+
const root = makeFixture();
|
|
311
|
+
const entries = [];
|
|
312
|
+
const logOpts = { onLog: (entry) => entries.push(entry) };
|
|
313
|
+
const session = await devkit.openProject({
|
|
314
|
+
projectPath: root,
|
|
315
|
+
watch: false,
|
|
316
|
+
outputDir: path.join(root, '.out'),
|
|
317
|
+
...logOpts,
|
|
318
|
+
});
|
|
319
|
+
openSessions.push(session);
|
|
320
|
+
const child = theChild();
|
|
321
|
+
entries.length = 0;
|
|
322
|
+
child.stdout.write('✔ 收集');
|
|
323
|
+
await sleep(30);
|
|
324
|
+
child.stdout.write('配置信息\n');
|
|
325
|
+
child.stderr.write('[logic] esbuild 转换失败 /p/x.js: Transform fail');
|
|
326
|
+
await sleep(30);
|
|
327
|
+
child.stderr.write('ed with 1 error:\n');
|
|
328
|
+
await vi.waitFor(() => {
|
|
329
|
+
expect(entries.length).toBeGreaterThanOrEqual(2);
|
|
330
|
+
}, { timeout: 5000 });
|
|
331
|
+
await sleep(50);
|
|
332
|
+
expect(entries.some(entry => entry.text === '✔ 收集'), 'a half line must never be delivered — buffer it until its newline arrives').toBe(false);
|
|
333
|
+
expect(entries).toHaveLength(2);
|
|
334
|
+
expect(entries).toEqual(expect.arrayContaining([
|
|
335
|
+
{ stream: 'stdout', text: '✔ 收集配置信息' },
|
|
336
|
+
{ stream: 'stderr', text: '[logic] esbuild 转换失败 /p/x.js: Transform failed with 1 error:' },
|
|
337
|
+
]));
|
|
338
|
+
}, 45_000);
|
|
339
|
+
it('without onLog the worker is STILL forked (uniform architecture) and stray output is simply not delivered', async () => {
|
|
340
|
+
const root = makeFixture();
|
|
341
|
+
const session = await devkit.openProject({
|
|
342
|
+
projectPath: root,
|
|
343
|
+
watch: false,
|
|
344
|
+
outputDir: path.join(root, '.out'),
|
|
345
|
+
});
|
|
346
|
+
openSessions.push(session);
|
|
347
|
+
const child = theChild();
|
|
348
|
+
expect(mocks.fork, 'forking is the uniform compile path — it must not be conditional on the onLog option').toHaveBeenCalledTimes(1);
|
|
349
|
+
// Child output with no onLog consumer must be inert (no crash, no
|
|
350
|
+
// unhandled error) — zero-callback contract.
|
|
351
|
+
child.stdout.write('✔ 收集配置信息\n');
|
|
352
|
+
child.stderr.write('✖ 编译页面逻辑 [FAILED: x]\n');
|
|
353
|
+
await sleep(80);
|
|
354
|
+
expect(session.appInfo.appId).toBe(WORKER_APP.appId);
|
|
355
|
+
}, 45_000);
|
|
356
|
+
it('session.close() kills the long-lived worker', async () => {
|
|
357
|
+
const root = makeFixture();
|
|
358
|
+
const session = await devkit.openProject({
|
|
359
|
+
projectPath: root,
|
|
360
|
+
watch: false,
|
|
361
|
+
outputDir: path.join(root, '.out'),
|
|
362
|
+
});
|
|
363
|
+
openSessions.push(session);
|
|
364
|
+
const child = theChild();
|
|
365
|
+
await session.close();
|
|
366
|
+
expect(child.kill, 'close() must kill the compile worker — a leaked child per closed project is the new architecture\'s one new failure mode').toHaveBeenCalled();
|
|
367
|
+
}, 45_000);
|
|
368
|
+
it('a worker crash mid-build settles the in-flight rebuild via onBuildError, and the next rebuild re-forks (crash recovery)', async () => {
|
|
369
|
+
const root = makeFixture();
|
|
370
|
+
const waiter = rebuildWaiter();
|
|
371
|
+
const buildErrors = [];
|
|
372
|
+
const session = await devkit.openProject({
|
|
373
|
+
projectPath: root,
|
|
374
|
+
watch: true,
|
|
375
|
+
outputDir: path.join(root, '.out'),
|
|
376
|
+
onRebuild: waiter.onRebuild,
|
|
377
|
+
onBuildError: (err) => buildErrors.push(err),
|
|
378
|
+
});
|
|
379
|
+
openSessions.push(session);
|
|
380
|
+
const first = theChild();
|
|
381
|
+
first.autoRespond = false;
|
|
382
|
+
// Trigger a rebuild whose build command will never be answered…
|
|
383
|
+
// buildSends===2 is a STABLE equality here: the child never auto-responds,
|
|
384
|
+
// so the first build stays in flight and every extra (re-written) watcher
|
|
385
|
+
// event coalesces to dirty — buildSends cannot exceed 2. Re-write until
|
|
386
|
+
// the (single) in-flight build command is observed.
|
|
387
|
+
await writeUntilPredicate(() => first.buildSends().length === 2, path.join(root, 'pages', 'index', 'index.js'), attempt => `Page({ data: { msg: "doomed-${attempt}" } })\n`);
|
|
388
|
+
expect(first.buildSends().length).toBe(2);
|
|
389
|
+
// …then kill the worker out from under it.
|
|
390
|
+
first.crash(1);
|
|
391
|
+
await vi.waitFor(() => {
|
|
392
|
+
expect(buildErrors.length, 'an in-flight build whose worker died must settle through onBuildError — NOT hang the rebuild scheduler forever').toBeGreaterThanOrEqual(1);
|
|
393
|
+
}, { timeout: 5000 });
|
|
394
|
+
const err = buildErrors[0];
|
|
395
|
+
expect(err).toBeInstanceOf(Error);
|
|
396
|
+
expect(String(err.message), 'the error must identify the compile worker death (so devtools can render it, not a generic failure)').toMatch(/worker/i);
|
|
397
|
+
// Recovery: the NEXT rebuild re-forks a fresh worker and succeeds.
|
|
398
|
+
// Count-agnostic (fork-times-2 after a crash): the first worker is dead, so
|
|
399
|
+
// re-writing only schedules trailing rebuilds on the single fresh worker.
|
|
400
|
+
const rebuilt = waiter.next();
|
|
401
|
+
await writeUntilSettled(rebuilt, path.join(root, 'pages', 'index', 'index.js'), attempt => `Page({ data: { msg: "recovered-${attempt}" } })\n`);
|
|
402
|
+
await vi.waitFor(() => {
|
|
403
|
+
expect(children.length, 'after a crash the next rebuild must fork a FRESH worker').toBe(2);
|
|
404
|
+
}, { timeout: 5000 });
|
|
405
|
+
expect(mocks.fork).toHaveBeenCalledTimes(2);
|
|
406
|
+
const second = children.at(-1);
|
|
407
|
+
expect(second.buildSends().length).toBeGreaterThanOrEqual(1);
|
|
408
|
+
}, 45_000);
|
|
409
|
+
it('a worker crash while IDLE is recovered too: the next rebuild forks a fresh worker and succeeds', async () => {
|
|
410
|
+
const root = makeFixture();
|
|
411
|
+
const waiter = rebuildWaiter();
|
|
412
|
+
const session = await devkit.openProject({
|
|
413
|
+
projectPath: root,
|
|
414
|
+
watch: true,
|
|
415
|
+
outputDir: path.join(root, '.out'),
|
|
416
|
+
onRebuild: waiter.onRebuild,
|
|
417
|
+
});
|
|
418
|
+
openSessions.push(session);
|
|
419
|
+
const first = theChild();
|
|
420
|
+
first.crash(1);
|
|
421
|
+
// Count-agnostic (fork-times-2): the crashed worker is gone, so re-writes
|
|
422
|
+
// only schedule trailing rebuilds on the one fresh worker — no extra fork.
|
|
423
|
+
const rebuilt = waiter.next();
|
|
424
|
+
await writeUntilSettled(rebuilt, path.join(root, 'pages', 'index', 'index.js'), attempt => `Page({ data: { msg: "after-idle-crash-${attempt}" } })\n`);
|
|
425
|
+
expect(mocks.fork, 'an idle crash must not wedge the session — the next rebuild re-forks exactly one fresh worker').toHaveBeenCalledTimes(2);
|
|
426
|
+
const second = children.at(-1);
|
|
427
|
+
expect(second).not.toBe(first);
|
|
428
|
+
expect(second.buildSends().length).toBeGreaterThanOrEqual(1);
|
|
429
|
+
}, 45_000);
|
|
430
|
+
it('serial IPC: a save during an in-flight build never sends a concurrent build command — it coalesces into one trailing build', async () => {
|
|
431
|
+
const root = makeFixture();
|
|
432
|
+
const waiter = rebuildWaiter();
|
|
433
|
+
const session = await devkit.openProject({
|
|
434
|
+
projectPath: root,
|
|
435
|
+
watch: true,
|
|
436
|
+
outputDir: path.join(root, '.out'),
|
|
437
|
+
onRebuild: waiter.onRebuild,
|
|
438
|
+
});
|
|
439
|
+
openSessions.push(session);
|
|
440
|
+
const child = theChild();
|
|
441
|
+
child.autoRespond = false;
|
|
442
|
+
const pageFile = path.join(root, 'pages', 'index', 'index.js');
|
|
443
|
+
// EXACT-COUNT test — the writeUntilSettled re-write helper is NOT applied
|
|
444
|
+
// to the coalescing assertions (===2 in-flight, ===3 after the response).
|
|
445
|
+
// The first waiter is a STABLE EQUALITY (===2): while no build is answered
|
|
446
|
+
// every extra watcher event coalesces to dirty, so re-writing to defeat a
|
|
447
|
+
// dropped inotify event cannot push the count past 2.
|
|
448
|
+
await writeUntilPredicate(() => child.buildSends().length === 2, pageFile, attempt => `Page({ data: { msg: "a-${attempt}" } })\n`);
|
|
449
|
+
expect(child.buildSends().length).toBe(2);
|
|
450
|
+
// A second save lands while the first rebuild's IPC is unanswered. Pump it
|
|
451
|
+
// across the in-flight window: EVERY save while a build is unanswered folds
|
|
452
|
+
// into ONE dirty flag (rebuild-scheduler coalescing), so the count provably
|
|
453
|
+
// stays 2 — no concurrent build — no matter how many we issue. Re-issuing
|
|
454
|
+
// thus CANNOT over-count past the single trailing build; it only defeats a
|
|
455
|
+
// dropped inotify event under CI CPU contention (a single mid-build write
|
|
456
|
+
// whose event is lost would otherwise leave dirty unset → no trailing build
|
|
457
|
+
// → the ===3 assertion below hangs to timeout). The ===2 invariant is the
|
|
458
|
+
// real serialization proof and is asserted on every pump.
|
|
459
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
460
|
+
fs.writeFileSync(pageFile, `Page({ data: { msg: "b-${attempt}" } })\n`);
|
|
461
|
+
await sleep(250);
|
|
462
|
+
expect(child.buildSends().length, 'NO concurrent build command while one is in flight — rebuild-scheduler serialization must hold across the IPC boundary').toBe(2);
|
|
463
|
+
}
|
|
464
|
+
// Answer the in-flight build: the coalesced dirty flag (set by the pumped
|
|
465
|
+
// saves above, all while the build was unanswered) yields exactly ONE
|
|
466
|
+
// trailing build.
|
|
467
|
+
child.respondToBuild(root);
|
|
468
|
+
await vi.waitFor(() => {
|
|
469
|
+
expect(child.buildSends().length, 'the save that landed mid-build must coalesce into exactly one trailing build once the in-flight one settles').toBe(3);
|
|
470
|
+
}, { timeout: 5000 });
|
|
471
|
+
// Settle everything for teardown.
|
|
472
|
+
child.autoRespond = true;
|
|
473
|
+
child.respondToBuild(root);
|
|
474
|
+
await sleep(50);
|
|
475
|
+
expect(mocks.fork).toHaveBeenCalledTimes(1);
|
|
476
|
+
}, 45_000);
|
|
477
|
+
});
|
|
478
|
+
/**
|
|
479
|
+
* LEAK-PROOFING WAVE (项目关闭时保证编译子进程同步关闭) — close-time guards on
|
|
480
|
+
* the exported `createCompileWorker` directly (same mocked fork as above).
|
|
481
|
+
* Real process death is pinned by `compile-worker-leak.test.ts`; these pin
|
|
482
|
+
* the parent-side STATE MACHINE around close:
|
|
483
|
+
* - close with a build in flight kills immediately and SETTLES the pending
|
|
484
|
+
* promise (a hung promise wedges the rebuild scheduler — and a scheduler
|
|
485
|
+
* wedged at close time is itself a teardown leak),
|
|
486
|
+
* - a closed worker can never be resurrected (build-after-close must not
|
|
487
|
+
* re-fork — a zombie re-fork after project close IS the leak).
|
|
488
|
+
*/
|
|
489
|
+
describe('createCompileWorker — close-time leak guards (direct use)', () => {
|
|
490
|
+
it('close() with a build in flight kills the worker immediately and the pending build settles (no hang)', async () => {
|
|
491
|
+
// Children for this test never auto-respond: the build stays in flight.
|
|
492
|
+
mocks.fork.mockImplementation(() => {
|
|
493
|
+
const child = new FakeChild();
|
|
494
|
+
child.autoRespond = false;
|
|
495
|
+
children.push(child);
|
|
496
|
+
return child;
|
|
497
|
+
});
|
|
498
|
+
const worker = devkit.createCompileWorker({});
|
|
499
|
+
const pending = worker.build({
|
|
500
|
+
projectPath: '/tmp/p',
|
|
501
|
+
outputDir: '/tmp/out',
|
|
502
|
+
options: {},
|
|
503
|
+
});
|
|
504
|
+
// Pre-arm the rejection expectation so the settle is observed even if
|
|
505
|
+
// it happens while we are still asserting on the kill spy.
|
|
506
|
+
const settled = expect(pending, 'a build in flight at close() must SETTLE (reject) — a forever-pending build wedges the rebuild scheduler').rejects.toThrow(/worker|closed/i);
|
|
507
|
+
await vi.waitFor(() => {
|
|
508
|
+
expect(children.length).toBe(1);
|
|
509
|
+
}, { timeout: 5000 });
|
|
510
|
+
const child = children[0];
|
|
511
|
+
worker.close();
|
|
512
|
+
expect(child.kill, 'close() must kill the worker even while a build is in flight — waiting for the build to finish first '
|
|
513
|
+
+ 'leaves a busy compiler running after the project closed').toHaveBeenCalled();
|
|
514
|
+
await settled;
|
|
515
|
+
}, 45_000);
|
|
516
|
+
it('build() after close() rejects and NEVER re-forks — a closed session must not resurrect a worker process', async () => {
|
|
517
|
+
const worker = devkit.createCompileWorker({});
|
|
518
|
+
const request = {
|
|
519
|
+
projectPath: '/tmp/p',
|
|
520
|
+
outputDir: '/tmp/out',
|
|
521
|
+
options: {},
|
|
522
|
+
};
|
|
523
|
+
// One normal build so the worker exists, then close it.
|
|
524
|
+
await worker.build(request);
|
|
525
|
+
expect(mocks.fork).toHaveBeenCalledTimes(1);
|
|
526
|
+
worker.close();
|
|
527
|
+
const child = children[0];
|
|
528
|
+
expect(child.kill).toHaveBeenCalled();
|
|
529
|
+
await expect(worker.build(request), 'build on a closed worker must reject loudly — silently re-forking would leak a process the session '
|
|
530
|
+
+ 'owner already believes is gone').rejects.toThrow(/clos/i);
|
|
531
|
+
expect(mocks.fork, 'NO re-fork after close(): close is terminal for the instance (refill-on-close is the documented '
|
|
532
|
+
+ 'teardown-wedge anti-pattern, and a post-close fork is an untracked orphan)').toHaveBeenCalledTimes(1);
|
|
533
|
+
expect(children).toHaveLength(1);
|
|
534
|
+
}, 45_000);
|
|
535
|
+
it('close() is idempotent — the second close neither throws nor double-kills', async () => {
|
|
536
|
+
const worker = devkit.createCompileWorker({});
|
|
537
|
+
await worker.build({
|
|
538
|
+
projectPath: '/tmp/p',
|
|
539
|
+
outputDir: '/tmp/out',
|
|
540
|
+
options: {},
|
|
541
|
+
});
|
|
542
|
+
const child = children[0];
|
|
543
|
+
worker.close();
|
|
544
|
+
expect(() => worker.close()).not.toThrow();
|
|
545
|
+
expect(child.kill, 'double-close must not double-kill: the second kill could land on a recycled OS PID').toHaveBeenCalledTimes(1);
|
|
546
|
+
}, 45_000);
|
|
547
|
+
});
|
|
548
|
+
/**
|
|
549
|
+
* CODEX-REVIEW REGRESSION WAVE (fix/editor-hot-reload-and-simulator-leftovers)
|
|
550
|
+
* — failing regression tests for review findings M1 / M2 / M3 / m7. Same
|
|
551
|
+
* mocked-fork harness as above; each test names the finding it pins.
|
|
552
|
+
*
|
|
553
|
+
* M1 a `{ type:'result', error }` reply is currently resolved as a SUCCESS
|
|
554
|
+
* with appInfo:null — it must reject the in-flight build (message passed
|
|
555
|
+
* through) and surface via onBuildError on the rebuild path.
|
|
556
|
+
* M2 a child 'error' event (spawn/IPC failure) is swallowed; Node does NOT
|
|
557
|
+
* guarantee an accompanying 'exit', so the in-flight build hangs forever
|
|
558
|
+
* and the dead child is never replaced.
|
|
559
|
+
* M3 close() kills and returns immediately — it must return a promise that
|
|
560
|
+
* resolves only after the child actually exited, and must settle the
|
|
561
|
+
* in-flight build itself (not rely on the child cooperating with exit).
|
|
562
|
+
* m7 a final line without a trailing newline is buffered forever — it must
|
|
563
|
+
* be flushed to onLog exactly once when the stream ends.
|
|
564
|
+
*/
|
|
565
|
+
describe('codex review regressions — worker error replies, fork errors, close semantics, trailing line (M1/M2/M3/m7)', () => {
|
|
566
|
+
const REQUEST = {
|
|
567
|
+
projectPath: '/tmp/p',
|
|
568
|
+
outputDir: '/tmp/out',
|
|
569
|
+
options: {},
|
|
570
|
+
};
|
|
571
|
+
function raceSettle(promise, ms) {
|
|
572
|
+
return Promise.race([
|
|
573
|
+
promise.then(() => 'resolved', () => 'rejected'),
|
|
574
|
+
sleep(ms).then(() => 'hung'),
|
|
575
|
+
]);
|
|
576
|
+
}
|
|
577
|
+
/** Children that never auto-respond — the build stays in flight. */
|
|
578
|
+
function useSilentChildren() {
|
|
579
|
+
mocks.fork.mockImplementation(() => {
|
|
580
|
+
const child = new FakeChild();
|
|
581
|
+
child.autoRespond = false;
|
|
582
|
+
children.push(child);
|
|
583
|
+
return child;
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
it('M1: build() rejects (error message passed through) when the worker reply carries an error — not a silent appInfo:null success', async () => {
|
|
587
|
+
useSilentChildren();
|
|
588
|
+
const worker = devkit.createCompileWorker({});
|
|
589
|
+
const pending = worker.build(REQUEST);
|
|
590
|
+
const settled = expect(pending, 'a worker reply carrying { error } must REJECT the in-flight build with the worker-reported message — '
|
|
591
|
+
+ 'resolving it as appInfo:null reports a failed compile as a success').rejects.toThrow(/compiler init failed/);
|
|
592
|
+
await vi.waitFor(() => {
|
|
593
|
+
expect(children.length).toBe(1);
|
|
594
|
+
}, { timeout: 5000 });
|
|
595
|
+
const child = children[0];
|
|
596
|
+
child.emit('message', {
|
|
597
|
+
type: 'result',
|
|
598
|
+
appInfo: null,
|
|
599
|
+
error: { message: 'boom — compiler init failed' },
|
|
600
|
+
});
|
|
601
|
+
await settled;
|
|
602
|
+
}, 45_000);
|
|
603
|
+
it('M1: a rebuild whose worker reply carries an error settles through onBuildError — NOT through onRebuild as a fake hot-reload success', async () => {
|
|
604
|
+
const root = makeFixture();
|
|
605
|
+
const buildErrors = [];
|
|
606
|
+
const rebuilds = [];
|
|
607
|
+
const session = await devkit.openProject({
|
|
608
|
+
projectPath: root,
|
|
609
|
+
watch: true,
|
|
610
|
+
outputDir: path.join(root, '.out'),
|
|
611
|
+
onRebuild: () => rebuilds.push(true),
|
|
612
|
+
onBuildError: (err) => buildErrors.push(err),
|
|
613
|
+
});
|
|
614
|
+
openSessions.push(session);
|
|
615
|
+
const child = theChild();
|
|
616
|
+
child.autoRespond = false;
|
|
617
|
+
// buildSends===2 is a stable equality (child never auto-responds, so the
|
|
618
|
+
// first build stays in flight and re-written watcher events coalesce to
|
|
619
|
+
// dirty). Re-write until the single in-flight build command is observed.
|
|
620
|
+
await writeUntilPredicate(() => child.buildSends().length === 2, path.join(root, 'pages', 'index', 'index.js'), attempt => `Page({ data: { msg: "will fail-${attempt}" } })\n`);
|
|
621
|
+
expect(child.buildSends().length).toBe(2);
|
|
622
|
+
child.emit('message', {
|
|
623
|
+
type: 'result',
|
|
624
|
+
appInfo: null,
|
|
625
|
+
error: { message: 'boom — esbuild exploded' },
|
|
626
|
+
});
|
|
627
|
+
await vi.waitFor(() => {
|
|
628
|
+
expect(buildErrors.length, 'an error reply must surface through onBuildError — treating it as appInfo:null makes the rebuild path '
|
|
629
|
+
+ 'report 编译完成/hot-reload SUCCESS for a build the worker itself flagged as failed').toBeGreaterThanOrEqual(1);
|
|
630
|
+
}, { timeout: 5000 });
|
|
631
|
+
expect(buildErrors[0]).toBeInstanceOf(Error);
|
|
632
|
+
expect(String(buildErrors[0].message), 'the worker-reported error message must pass through to onBuildError (the devtools panel renders it)').toMatch(/esbuild exploded/);
|
|
633
|
+
expect(rebuilds, 'the failed rebuild must NOT fire onRebuild — onRebuild triggers the hot-reload toast').toHaveLength(0);
|
|
634
|
+
}, 45_000);
|
|
635
|
+
it("M2: a fork 'error' event with NO accompanying 'exit' settles the in-flight build (bounded) and the next build re-forks a fresh worker", async () => {
|
|
636
|
+
useSilentChildren();
|
|
637
|
+
const worker = devkit.createCompileWorker({});
|
|
638
|
+
const pending = worker.build(REQUEST);
|
|
639
|
+
await vi.waitFor(() => {
|
|
640
|
+
expect(children.length).toBe(1);
|
|
641
|
+
}, { timeout: 5000 });
|
|
642
|
+
const first = children[0];
|
|
643
|
+
// Node does NOT guarantee 'exit' after 'error' (spawn/IPC failures can
|
|
644
|
+
// surface as a lone 'error' event). Swallowing it leaves the in-flight
|
|
645
|
+
// build pending forever.
|
|
646
|
+
first.emit('error', new Error('spawn EAGAIN'));
|
|
647
|
+
expect(await raceSettle(pending, 2000), "a child 'error' event without 'exit' must settle (reject) the in-flight build — today it hangs the rebuild scheduler forever").toBe('rejected');
|
|
648
|
+
// The errored child must be discarded so the NEXT build re-forks.
|
|
649
|
+
const second = worker.build(REQUEST);
|
|
650
|
+
await vi.waitFor(() => {
|
|
651
|
+
expect(children.length, "after a fork 'error' the child must be cleared — the next build must fork a FRESH worker, not reuse the broken one").toBe(2);
|
|
652
|
+
}, { timeout: 5000 });
|
|
653
|
+
const fresh = children[1];
|
|
654
|
+
await vi.waitFor(() => {
|
|
655
|
+
expect(fresh.buildSends().length).toBe(1);
|
|
656
|
+
}, { timeout: 5000 });
|
|
657
|
+
fresh.respondToBuild('/tmp/p');
|
|
658
|
+
await expect(second).resolves.toEqual(expect.objectContaining({ appId: WORKER_APP.appId }));
|
|
659
|
+
}, 45_000);
|
|
660
|
+
it("M2: 'error' followed by a LATE 'exit' is idempotent — the stale exit must not settle the NEXT build on the fresh worker", async () => {
|
|
661
|
+
useSilentChildren();
|
|
662
|
+
const worker = devkit.createCompileWorker({});
|
|
663
|
+
const pending = worker.build(REQUEST);
|
|
664
|
+
await vi.waitFor(() => {
|
|
665
|
+
expect(children.length).toBe(1);
|
|
666
|
+
}, { timeout: 5000 });
|
|
667
|
+
const first = children[0];
|
|
668
|
+
first.emit('error', new Error('spawn failure'));
|
|
669
|
+
expect(await raceSettle(pending, 2000), "the 'error' event alone must settle the in-flight build (see the companion M2 test)").toBe('rejected');
|
|
670
|
+
// Start the recovery build, THEN let the dead child's 'exit' fire late
|
|
671
|
+
// (the real-world double-fire). The stale exit must not reject the new
|
|
672
|
+
// in-flight build that belongs to the fresh worker.
|
|
673
|
+
const second = worker.build(REQUEST);
|
|
674
|
+
await vi.waitFor(() => {
|
|
675
|
+
expect(children.length).toBe(2);
|
|
676
|
+
}, { timeout: 5000 });
|
|
677
|
+
first.crash(1);
|
|
678
|
+
await sleep(20);
|
|
679
|
+
const fresh = children[1];
|
|
680
|
+
await vi.waitFor(() => {
|
|
681
|
+
expect(fresh.buildSends().length).toBe(1);
|
|
682
|
+
}, { timeout: 5000 });
|
|
683
|
+
fresh.respondToBuild('/tmp/p');
|
|
684
|
+
await expect(second, "the dead child's late 'exit' must be a no-op for the new generation — error+exit double-fire settles ONE build, once").resolves.toEqual(expect.objectContaining({ appId: WORKER_APP.appId }));
|
|
685
|
+
}, 45_000);
|
|
686
|
+
it("M3: close() returns a promise that resolves only AFTER the child actually exited — kill-and-return is not a close", async () => {
|
|
687
|
+
const worker = devkit.createCompileWorker({});
|
|
688
|
+
await worker.build(REQUEST);
|
|
689
|
+
const child = children[0];
|
|
690
|
+
// From here the child only dies when the test says so.
|
|
691
|
+
child.manualExit = true;
|
|
692
|
+
const closeResult = worker.close();
|
|
693
|
+
expect(closeResult, 'close() must return a promise tied to the child exit — a void kill-and-return lets callers proceed while the compiler is still dying').toBeInstanceOf(Promise);
|
|
694
|
+
let settledEarly = false;
|
|
695
|
+
void closeResult.then(() => {
|
|
696
|
+
settledEarly = true;
|
|
697
|
+
}, () => {
|
|
698
|
+
settledEarly = true;
|
|
699
|
+
});
|
|
700
|
+
await sleep(50);
|
|
701
|
+
expect(settledEarly, "close()'s promise must NOT settle before the child emitted 'exit' — resolving early defeats the whole guarantee").toBe(false);
|
|
702
|
+
child.emit('exit', null, 'SIGTERM');
|
|
703
|
+
await closeResult;
|
|
704
|
+
}, 45_000);
|
|
705
|
+
it('M3: close() itself rejects the in-flight build — even when the child never emits exit', async () => {
|
|
706
|
+
useSilentChildren();
|
|
707
|
+
const worker = devkit.createCompileWorker({});
|
|
708
|
+
const pending = worker.build(REQUEST);
|
|
709
|
+
await vi.waitFor(() => {
|
|
710
|
+
expect(children.length).toBe(1);
|
|
711
|
+
}, { timeout: 5000 });
|
|
712
|
+
const child = children[0];
|
|
713
|
+
// A wedged child that ignores SIGTERM: kill() never produces 'exit'.
|
|
714
|
+
child.manualExit = true;
|
|
715
|
+
worker.close();
|
|
716
|
+
expect(await raceSettle(pending, 1500), 'close() must settle the in-flight build ITSELF — delegating the rejection to a child exit that may never '
|
|
717
|
+
+ 'come leaves the rebuild scheduler hanging at teardown').toBe('rejected');
|
|
718
|
+
}, 45_000);
|
|
719
|
+
/**
|
|
720
|
+
* CODEX RE-REVIEW — M3 NOT-RESOLVED follow-up. `settleDeath`'s
|
|
721
|
+
* `removeAllListeners()` also strips the `once('exit', resolve)` that
|
|
722
|
+
* close() registered on the SAME child: when a child dies through the
|
|
723
|
+
* 'error' path while a close() is in flight (kill sent, exit not yet
|
|
724
|
+
* emitted), the closePromise loses its only resolver and hangs forever.
|
|
725
|
+
* Contract: a child 'error' during an in-flight close is a death signal
|
|
726
|
+
* (Node does NOT guarantee an 'exit' after 'error' — see M2) — the close
|
|
727
|
+
* promise must still resolve, whether a late 'exit' follows or never comes.
|
|
728
|
+
* The normal-exit-resolves case is already pinned above ("M3: close()
|
|
729
|
+
* returns a promise that resolves only AFTER the child actually exited").
|
|
730
|
+
*/
|
|
731
|
+
it("M3 follow-up: a child 'error' during an in-flight close() must not strip the close resolver — closePromise resolves even though only a LATE 'exit' follows", async () => {
|
|
732
|
+
const worker = devkit.createCompileWorker({});
|
|
733
|
+
await worker.build(REQUEST);
|
|
734
|
+
const child = children[0];
|
|
735
|
+
// kill() no longer auto-exits: close() stays in flight until the test
|
|
736
|
+
// drives the death events by hand.
|
|
737
|
+
child.manualExit = true;
|
|
738
|
+
const closeResult = worker.close();
|
|
739
|
+
expect(child.kill).toHaveBeenCalled();
|
|
740
|
+
// The child dies via 'error' first (settleDeath runs and — today —
|
|
741
|
+
// removeAllListeners() takes close()'s once('exit') resolver with it)…
|
|
742
|
+
child.emit('error', new Error('EPIPE'));
|
|
743
|
+
// …then the real-world late 'exit' fires. With the resolver stripped,
|
|
744
|
+
// nobody is listening and closePromise hangs forever.
|
|
745
|
+
child.emit('exit', null, 'SIGTERM');
|
|
746
|
+
expect(await raceSettle(closeResult, 1500), "settleDeath's removeAllListeners must not strip close()'s exit resolver — a child that dies via "
|
|
747
|
+
+ "'error' mid-close leaves closePromise hanging forever, wedging every awaiter of session.close()").toBe('resolved');
|
|
748
|
+
}, 45_000);
|
|
749
|
+
it("M3 follow-up: a child 'error' during an in-flight close() with NO 'exit' ever resolves the close too — 'error' is a death signal, not a wait-longer signal", async () => {
|
|
750
|
+
const worker = devkit.createCompileWorker({});
|
|
751
|
+
await worker.build(REQUEST);
|
|
752
|
+
const child = children[0];
|
|
753
|
+
child.manualExit = true;
|
|
754
|
+
const closeResult = worker.close();
|
|
755
|
+
expect(child.kill).toHaveBeenCalled();
|
|
756
|
+
// Node does NOT guarantee an 'exit' after 'error' (the exact premise M2
|
|
757
|
+
// pinned for builds): the lone 'error' must settle the close as well.
|
|
758
|
+
child.emit('error', new Error('spawn EAGAIN'));
|
|
759
|
+
expect(await raceSettle(closeResult, 1500), "a lone child 'error' during close() must resolve the close promise — settleDeath already treats "
|
|
760
|
+
+ "'error' as death everywhere else (clears the child, rejects builds); close() waiting for an 'exit' "
|
|
761
|
+
+ 'that Node never guarantees hangs teardown forever').toBe('resolved');
|
|
762
|
+
}, 45_000);
|
|
763
|
+
it('m7: a final line WITHOUT a trailing newline is flushed to onLog exactly once when the stream ends', async () => {
|
|
764
|
+
const root = makeFixture();
|
|
765
|
+
const entries = [];
|
|
766
|
+
const logOpts = { onLog: (entry) => entries.push(entry) };
|
|
767
|
+
const session = await devkit.openProject({
|
|
768
|
+
projectPath: root,
|
|
769
|
+
watch: false,
|
|
770
|
+
outputDir: path.join(root, '.out'),
|
|
771
|
+
...logOpts,
|
|
772
|
+
});
|
|
773
|
+
openSessions.push(session);
|
|
774
|
+
const child = theChild();
|
|
775
|
+
entries.length = 0;
|
|
776
|
+
// dmcc's last line (often the error summary) can end without a '\n'
|
|
777
|
+
// when the process dies — the buffered remainder must still surface.
|
|
778
|
+
child.stderr.write('pages/index/index.js 编译出错: Transform failed');
|
|
779
|
+
child.stderr.end();
|
|
780
|
+
await vi.waitFor(() => {
|
|
781
|
+
expect(entries.filter(entry => entry.text.includes('编译出错')).length, 'the un-terminated final line must be flushed to onLog when the stream ends — today it is buffered forever and silently dropped').toBe(1);
|
|
782
|
+
}, { timeout: 3000 });
|
|
783
|
+
await sleep(50);
|
|
784
|
+
// PassThrough emits BOTH 'end' and 'close' — the flush must not double-fire.
|
|
785
|
+
expect(entries).toHaveLength(1);
|
|
786
|
+
expect(entries[0]).toEqual({
|
|
787
|
+
stream: 'stderr',
|
|
788
|
+
text: 'pages/index/index.js 编译出错: Transform failed',
|
|
789
|
+
});
|
|
790
|
+
}, 45_000);
|
|
791
|
+
});
|