From 51e60d7c431918b4c8b6c5aa99f319ca118d0583 Mon Sep 17 00:00:00 2001 From: Gunwoo Kim Date: Sat, 7 Nov 2026 16:55:59 +0900 Subject: [PATCH 1/2] test(npm): replace executable fixtures atomically Signed-off-by: Gunwoo Kim --- docs/agents/release-and-notices.md | 1 + packages/cli/test/native.test.mjs | 36 +++++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/agents/release-and-notices.md b/docs/agents/release-and-notices.md index eb3df2878..e5bd01026 100644 --- a/docs/agents/release-and-notices.md +++ b/docs/agents/release-and-notices.md @@ -4,6 +4,7 @@ Licenses, distribution notices, and package rehearsal. | Rule | Enforced by | |---|---| +| Reinstall fixtures replace a previously executed optional binary by publishing a fresh executable inode rather than truncating it | `replaceBinary` stages complete executable contents and renames them into the fixture path. `binary replacement retains a running executable's inode` holds a real Linux executable open, proves in-place writing gets ETXTBSY, and requires replacement plus execution while the original process stays alive. Filesystem executable lifetime cannot be encoded by a path type | | A launcher lifecycle fixture publishes a complete positive child PID before the test probes or signals it | The stand-in writes a sibling file and atomically renames it into place; `stopping the launcher stops the binary` validates the record and requires ESRCH after launcher exit. Atomic publication removes the empty-file-to-PID-0 process-group race; cleanup signals only a validated PID. A numeric conversion alone cannot distinguish a complete record from an empty file | | The installer and Node launcher reject the same missing, wrong-version, non-executable or failed-check platform binary, including a Unix version process that ignores termination | `nativeBinary` owns validation and uses SIGKILL on Unix at its timeout. `native.test.mjs` executes recovery without an environment override and runs a real SIGTERM-ignoring child with the production signal option. External package contents and process signals cannot be enforced by a type | | Dependencies keep to the license policy | `cargo deny check` and the fuzz projects' license check (`deny.yml`); `scripts/svt_av1_licenses.py` checks the native SVT dependency against its recipe identity and independently pinned notice bytes (`svt-av1-native.yml`). Its rewritten-manifest regressions reject changing both the text and its recorded hash | diff --git a/packages/cli/test/native.test.mjs b/packages/cli/test/native.test.mjs index b2a259e76..1a475a66c 100644 --- a/packages/cli/test/native.test.mjs +++ b/packages/cli/test/native.test.mjs @@ -5,7 +5,8 @@ // of this package's version); the fixture cases also COREAV_FIXTURE_DIRS. import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; +import { once } from "node:events"; import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; @@ -79,6 +80,35 @@ function installed(scratch, binary) { return copy; } +function replaceBinary(binary, contents) { + const replacement = `${binary}.replacement`; + writeFileSync(replacement, contents); + chmodSync(replacement, 0o755); + renameSync(replacement, binary); +} + +test("binary replacement retains a running executable's inode", { skip: process.platform !== "linux" && "Linux executable write protection", timeout: 10000 }, async (t) => { + const scratch = mkdtempSync(join(tmpdir(), "coreav-running-inode-")); + const binary = join(scratch, "binary"); + cpSync(process.execPath, binary); + const child = spawn(binary, ["-e", 'process.stdout.write("ready\\n"); setInterval(() => {}, 1000);'], { stdio: ["ignore", "pipe", "pipe"] }); + const closed = once(child, "close"); + try { + const [ready] = await once(child.stdout, "data", { signal: t.signal }); + assert.equal(ready.toString(), "ready\n"); + assert.throws(() => writeFileSync(binary, "replacement"), { code: "ETXTBSY" }); + replaceBinary(binary, "#!/bin/sh\necho replacement\n"); + assert.equal(child.exitCode, null, "the original executable stopped during replacement"); + const done = run(binary, ["--version"]); + assert.equal(done.status, 0, done.stderr.toString()); + assert.equal(done.stdout.toString(), "replacement\n"); + } finally { + child.kill("SIGKILL"); + await closed; + rmSync(scratch, { recursive: true, force: true }); + } +}); + test("the install script makes the command the native binary", { skip: needsBin || (process.platform === "win32" && "the launcher stays on Windows") }, () => { const scratch = mkdtempSync(join(tmpdir(), "coreav-install-")); try { @@ -145,9 +175,9 @@ test("reinstall restores the WASI launcher after the optional binary is removed assert.equal(run(process.execPath, [join(copy, "install.js")]).status, 0); assert.equal(realpathSync(command), realpathSync(binary)); if (failure === "removed") rmSync(dirname(dirname(binary)), { recursive: true, force: true }); - else if (failure === "wrong version") writeFileSync(binary, "#!/bin/sh\necho coreav 9.9.9\n"); + else if (failure === "wrong version") replaceBinary(binary, "#!/bin/sh\necho coreav 9.9.9\n"); else if (failure === "cannot execute") chmodSync(binary, 0o644); - else writeFileSync(binary, `#!/bin/sh\necho coreav ${pkg.version}\necho rejected-native-binary >&2\nexit 1\n`); + else replaceBinary(binary, `#!/bin/sh\necho coreav ${pkg.version}\necho rejected-native-binary >&2\nexit 1\n`); const done = run(process.execPath, [join(copy, "install.js")]); assert.equal(done.status, 0, done.stderr.toString()); assert.equal(realpathSync(command), realpathSync(join(copy, "lib", "main.js"))); From 1aacfd3c7b1870af531b12ea80bc126995a7fee9 Mon Sep 17 00:00:00 2001 From: Gunwoo Kim Date: Sat, 7 Nov 2026 17:22:47 +0900 Subject: [PATCH 2/2] test(npm): confirm child survives binary replacement Signed-off-by: Gunwoo Kim --- docs/agents/release-and-notices.md | 2 +- packages/cli/test/native.test.mjs | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/agents/release-and-notices.md b/docs/agents/release-and-notices.md index e5bd01026..8003c365c 100644 --- a/docs/agents/release-and-notices.md +++ b/docs/agents/release-and-notices.md @@ -4,7 +4,7 @@ Licenses, distribution notices, and package rehearsal. | Rule | Enforced by | |---|---| -| Reinstall fixtures replace a previously executed optional binary by publishing a fresh executable inode rather than truncating it | `replaceBinary` stages complete executable contents and renames them into the fixture path. `binary replacement retains a running executable's inode` holds a real Linux executable open, proves in-place writing gets ETXTBSY, and requires replacement plus execution while the original process stays alive. Filesystem executable lifetime cannot be encoded by a path type | +| Reinstall fixtures replace a previously executed optional binary by publishing a fresh executable inode rather than truncating it | `replaceBinary` stages complete executable contents and renames them into the fixture path. `binary replacement retains a running executable's inode` holds a real Linux executable open, proves in-place writing gets ETXTBSY, and requires an IPC acknowledgment from the original process after executing the replacement, failing if it exits first. Filesystem executable lifetime cannot be encoded by a path type | | A launcher lifecycle fixture publishes a complete positive child PID before the test probes or signals it | The stand-in writes a sibling file and atomically renames it into place; `stopping the launcher stops the binary` validates the record and requires ESRCH after launcher exit. Atomic publication removes the empty-file-to-PID-0 process-group race; cleanup signals only a validated PID. A numeric conversion alone cannot distinguish a complete record from an empty file | | The installer and Node launcher reject the same missing, wrong-version, non-executable or failed-check platform binary, including a Unix version process that ignores termination | `nativeBinary` owns validation and uses SIGKILL on Unix at its timeout. `native.test.mjs` executes recovery without an environment override and runs a real SIGTERM-ignoring child with the production signal option. External package contents and process signals cannot be enforced by a type | | Dependencies keep to the license policy | `cargo deny check` and the fuzz projects' license check (`deny.yml`); `scripts/svt_av1_licenses.py` checks the native SVT dependency against its recipe identity and independently pinned notice bytes (`svt-av1-native.yml`). Its rewritten-manifest regressions reject changing both the text and its recorded hash | diff --git a/packages/cli/test/native.test.mjs b/packages/cli/test/native.test.mjs index 1a475a66c..de6eff791 100644 --- a/packages/cli/test/native.test.mjs +++ b/packages/cli/test/native.test.mjs @@ -91,17 +91,24 @@ test("binary replacement retains a running executable's inode", { skip: process. const scratch = mkdtempSync(join(tmpdir(), "coreav-running-inode-")); const binary = join(scratch, "binary"); cpSync(process.execPath, binary); - const child = spawn(binary, ["-e", 'process.stdout.write("ready\\n"); setInterval(() => {}, 1000);'], { stdio: ["ignore", "pipe", "pipe"] }); + const child = spawn(binary, ["-e", 'process.on("message", () => process.send("alive")); process.send("ready");'], { stdio: ["ignore", "ignore", "pipe", "ipc"] }); const closed = once(child, "close"); + const nextMessage = () => Promise.race([ + once(child, "message", { signal: t.signal }), + closed.then(() => assert.fail("the original executable exited before responding")), + ]); try { - const [ready] = await once(child.stdout, "data", { signal: t.signal }); - assert.equal(ready.toString(), "ready\n"); + const [ready] = await nextMessage(); + assert.equal(ready, "ready"); assert.throws(() => writeFileSync(binary, "replacement"), { code: "ETXTBSY" }); replaceBinary(binary, "#!/bin/sh\necho replacement\n"); - assert.equal(child.exitCode, null, "the original executable stopped during replacement"); const done = run(binary, ["--version"]); assert.equal(done.status, 0, done.stderr.toString()); assert.equal(done.stdout.toString(), "replacement\n"); + const response = nextMessage(); + child.send("ping"); + const [alive] = await response; + assert.equal(alive, "alive"); } finally { child.kill("SIGKILL"); await closed;