Skip to content

Commit 00f5ddd

Browse files
committed
test(server): Add native-crash pinpointing probes for reinitialize.js
The reinitialize.js test exits with 0xC0000005 (access violation) on Windows, a native crash in the forked AVA worker rather than a JS test failure. Add standalone probes that drive each native subsystem the test exercises in isolation — node:sqlite (single and overlapping WAL+mmap handles), the @parcel/watcher subscribe/unsubscribe cycle, and the full serve/reinitialize/ close lifecycle without AVA — so the crash origin can be attributed on Windows before changing the teardown logic further. See test/probes/README.md.
1 parent 0efe503 commit 00f5ddd

5 files changed

Lines changed: 210 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Windows `0xC0000005` pinpointing kit for `reinitialize.js`
2+
3+
`test/lib/server/reinitialize.js` exits with `3221225477` (`0xC0000005`, an **access
4+
violation**) on Windows. That is a native crash in the AVA worker **child process**
5+
(AVA runs each test file in a forked process — `workerThreads: false`), not a JS-level
6+
test failure. The two native subsystems this test drives are:
7+
8+
- **`@parcel/watcher`** — the source watcher (`WatchHandler`) and the definition watcher
9+
(`ProjectDefinitionWatcher`). On Windows it runs a background `ReadDirectoryChangesW`
10+
thread; a watch thread outliving `unsubscribe()` or racing process exit is a classic
11+
`0xC0000005` source.
12+
- **`node:sqlite`** (`BuildCacheStorage`) — opened with `PRAGMA journal_mode=WAL` and
13+
`PRAGMA mmap_size=268435456`. Closing a memory-mapped WAL database, or letting the
14+
process exit while pages are still mapped, can access-violate on Windows.
15+
16+
The branch already serialized `destroy()` against the in-flight swap and cancelled the
17+
settle timer, yet the crash persists — which points at native teardown / process exit
18+
rather than a JS race.
19+
20+
Run these probes on the Windows machine (from `packages/server`) and report the exit
21+
code of each. `echo %ERRORLEVEL%` after each in cmd, or `$LASTEXITCODE` in PowerShell.
22+
`3221225477` = the crash; `0` = clean.
23+
24+
```
25+
node test/probes/probe-sqlite.mjs # node:sqlite open/write/close, single handle
26+
node test/probes/probe-sqlite-reopen.mjs # two overlapping handles on one WAL+mmap db (the swap pattern)
27+
node test/probes/probe-parcel.mjs # @parcel/watcher subscribe/event/unsubscribe
28+
node test/probes/probe-serve.mjs # full serve() -> reinitialize() -> close(), no AVA
29+
```
30+
31+
`probe-serve.mjs` prints staged markers (`serving` / `reinitialized` / `closed` /
32+
`settled`). Note the last line printed before a crash:
33+
34+
- crash **before** `closed` → teardown while JS still running.
35+
- `closed` + `settled` printed, crash **after** → exit-time native handle not released.
36+
- exit 0 → the isolated lifecycle is clean; the trigger needs the AVA worker environment.
37+
38+
## Which probe crashed → where it originates
39+
40+
| Crashes | Clean | Origin |
41+
|---|---|---|
42+
| `probe-sqlite` || `node:sqlite` close (WAL checkpoint / mmap unmap) — single handle is enough |
43+
| `probe-sqlite-reopen` | `probe-sqlite` | overlapping handles on one WAL+mmap db (the reinitialize swap) |
44+
| `probe-parcel` | sqlite probes | `@parcel/watcher` native teardown / watch thread vs. exit |
45+
| `probe-serve` | all component probes | interaction only visible in the full lifecycle |
46+
| none | all | needs the AVA worker env (supertest sockets, `--loader`, concurrent files) |
47+
48+
## Bisecting the AVA subtests
49+
50+
`reinitialize.js` has three serial subtests. Run each alone to see which crashes:
51+
52+
```
53+
npx ava test/lib/server/reinitialize.js -m "reinitialize() keeps the port bound*"
54+
npx ava test/lib/server/reinitialize.js -m "editing ui5.yaml triggers*"
55+
npx ava test/lib/server/reinitialize.js -m "reinitialize() without a graphFactory*"
56+
```
57+
58+
Delete this directory once the origin is confirmed.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Probe C — @parcel/watcher subscribe/unsubscribe native teardown in isolation.
2+
//
3+
// Subscribes to a directory, triggers an event, unsubscribes, then lets the process exit.
4+
// If this crashes with 0xC0000005 on Windows, the origin is the @parcel/watcher native
5+
// binding (a background ReadDirectoryChangesW thread outliving unsubscribe / racing exit),
6+
// independent of node:sqlite and the swap logic.
7+
//
8+
// Run from packages/project: node ../server/test/tmp/probe-parcel.mjs
9+
import path from "node:path";
10+
import fs from "node:fs/promises";
11+
import {fileURLToPath} from "node:url";
12+
13+
const here = path.dirname(fileURLToPath(import.meta.url));
14+
const {subscribe} = await import(path.resolve(here, "../../../project/lib/build/helpers/fileWatcher.js"));
15+
16+
const dir = path.resolve(here, `probe-parcel-${process.pid}`);
17+
await fs.mkdir(dir, {recursive: true});
18+
console.log(`[probe-parcel] subscribing to ${dir}`);
19+
20+
let eventCount = 0;
21+
const sub = await subscribe(dir, (err, events) => {
22+
if (err) {
23+
console.error(`[probe-parcel] watcher error: ${err.message}`);
24+
return;
25+
}
26+
eventCount += events.length;
27+
});
28+
29+
// Produce a change so the native watch thread is actively delivering.
30+
await fs.writeFile(path.join(dir, "file.txt"), "hello");
31+
await new Promise((r) => setTimeout(r, 300));
32+
await fs.writeFile(path.join(dir, "file.txt"), "world");
33+
await new Promise((r) => setTimeout(r, 300));
34+
console.log(`[probe-parcel] observed ${eventCount} event(s)`);
35+
36+
console.log(`[probe-parcel] unsubscribing`);
37+
await sub.unsubscribe();
38+
console.log(`[probe-parcel] unsubscribed OK — process exiting`);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Probe D — Full serve() -> reinitialize() -> close() lifecycle, standalone (no AVA).
2+
//
3+
// Mirrors the first subtest of reinitialize.js against the real graph + BuildServer +
4+
// Supervisor, but as a plain Node process so the exit code is attributable to this flow
5+
// alone (AVA is not in the picture). After close() resolves it prints a marker, waits
6+
// briefly, then exits.
7+
//
8+
// - If it crashes 0xC0000005 BEFORE "[probe-serve] closed" -> the crash is during
9+
// destroy()/teardown while JS is still running.
10+
// - If it prints "[probe-serve] closed" and "[probe-serve] settled" and THEN the
11+
// process crashes on exit -> the crash is at process exit with a native handle
12+
// (parcel watch thread / sqlite mmap) not fully released.
13+
// - Exit 0 clean -> the isolated lifecycle does not reproduce it; the trigger needs
14+
// the AVA worker environment (e.g. supertest sockets, the loader, concurrent files).
15+
//
16+
// Run from packages/server: node test/probes/probe-serve.mjs
17+
import path from "node:path";
18+
import {fileURLToPath} from "node:url";
19+
20+
process.env.NODE_ENV = "test";
21+
const here = path.dirname(fileURLToPath(import.meta.url));
22+
const serverRoot = path.resolve(here, "..", "..");
23+
process.chdir(serverRoot); // so ./test/fixtures/application.a resolves like the test
24+
25+
const {serve} = await import(path.resolve(serverRoot, "lib/server.js"));
26+
const {graphFromPackageDependencies} = await import("@ui5/project/graph");
27+
const projectWatcher = await import("@ui5/project/internal/graph/ProjectDefinitionWatcher");
28+
29+
const buildGraph = () => graphFromPackageDependencies({cwd: "./test/fixtures/application.a"});
30+
31+
const ui5DataDir = path.resolve("test", "tmp", "buildcache", `probe-serve-${process.pid}`);
32+
console.log(`[probe-serve] serving (ui5DataDir=${ui5DataDir})`);
33+
const graph = await buildGraph();
34+
const server = await serve(graph, {
35+
port: 3399, // fixed port; changePortIfInUse bumps it if busy
36+
changePortIfInUse: true,
37+
liveReload: false,
38+
ui5DataDir,
39+
}, undefined, buildGraph, projectWatcher);
40+
console.log(`[probe-serve] listening on ${server.port}`);
41+
42+
console.log(`[probe-serve] reinitialize`);
43+
await server.reinitialize();
44+
console.log(`[probe-serve] reinitialized`);
45+
46+
await new Promise((resolve) => server.close(resolve));
47+
console.log(`[probe-serve] closed`);
48+
49+
// Hold the process open briefly so an exit-time native crash is clearly separated from
50+
// the in-flight teardown above.
51+
await new Promise((r) => setTimeout(r, 1000));
52+
console.log(`[probe-serve] settled — process exiting`);
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Probe B — node:sqlite reopen/refcount pattern of a reinitialize() swap.
2+
//
3+
// A reinitialize() opens a SECOND handle on the same cache dir before the first is
4+
// closed (refcount 1 -> 2), then closes the first (2 -> 1), later the second (1 -> 0).
5+
// Two DatabaseSync handles onto the same WAL+mmap file, overlapping, then both closed.
6+
// If this crashes but probe-sqlite does not, the origin is concurrent handles onto the
7+
// same mmapped WAL DB on Windows.
8+
//
9+
// Run from packages/project: node ../server/test/tmp/probe-sqlite-reopen.mjs
10+
import path from "node:path";
11+
import {fileURLToPath} from "node:url";
12+
13+
const here = path.dirname(fileURLToPath(import.meta.url));
14+
const {default: BuildCacheStorage} =
15+
await import(path.resolve(here, "../../../project/lib/build/cache/BuildCacheStorage.js"));
16+
17+
const dbDir = path.resolve(here, `probe-sqlite-reopen-${process.pid}`);
18+
console.log(`[probe-reopen] open #1 ${dbDir}`);
19+
const a = new BuildCacheStorage(dbDir);
20+
a.transaction(() => a.putContent("sha512-a", Buffer.alloc(4096, 1)));
21+
22+
console.log(`[probe-reopen] open #2 (overlapping) same dir`);
23+
const b = new BuildCacheStorage(dbDir);
24+
console.log(`[probe-reopen] #2 reads #1's row: ${b.hasContent("sha512-a")}`);
25+
b.transaction(() => b.putContent("sha512-b", Buffer.alloc(4096, 2)));
26+
27+
console.log(`[probe-reopen] close #1 (swap: old stack torn down)`);
28+
a.close();
29+
console.log(`[probe-reopen] #2 still serving: ${b.hasContent("sha512-a")} / ${b.hasContent("sha512-b")}`);
30+
31+
console.log(`[probe-reopen] close #2 (server.close)`);
32+
b.close();
33+
console.log(`[probe-reopen] both closed OK — process exiting`);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Probe A — node:sqlite (BuildCacheStorage) native teardown in isolation.
2+
//
3+
// Opens the build-cache DB the exact way the server does (WAL + mmap + busy_timeout),
4+
// writes a row, closes it (WAL checkpoint TRUNCATE + db.close()), then lets the process
5+
// exit. If this crashes with 0xC0000005 on Windows, the origin is node:sqlite teardown
6+
// (mmap unmap / WAL checkpoint on close), independent of parcel and the swap logic.
7+
//
8+
// Run from packages/project: node ../server/test/tmp/probe-sqlite.mjs
9+
import path from "node:path";
10+
import {fileURLToPath} from "node:url";
11+
12+
const here = path.dirname(fileURLToPath(import.meta.url));
13+
const {default: BuildCacheStorage} =
14+
await import(path.resolve(here, "../../../project/lib/build/cache/BuildCacheStorage.js"));
15+
16+
const dbDir = path.resolve(here, `probe-sqlite-${process.pid}`);
17+
console.log(`[probe-sqlite] opening ${dbDir}`);
18+
const storage = new BuildCacheStorage(dbDir);
19+
20+
// Exercise a write + read so mmap pages are actually mapped in.
21+
storage.transaction(() => {
22+
storage.putContent("sha512-probe", Buffer.alloc(4096, 7));
23+
});
24+
console.log(`[probe-sqlite] hasContent=${storage.hasContent("sha512-probe")}`);
25+
console.log(`[probe-sqlite] size=${storage.getDatabaseSize()}`);
26+
27+
console.log(`[probe-sqlite] closing`);
28+
storage.close();
29+
console.log(`[probe-sqlite] closed OK — process exiting`);

0 commit comments

Comments
 (0)