diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbc59fc7..02c51124 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: uses: actions/setup-node@v4 with: { node-version: '20' } - if: ${{ !cancelled() }} - run: node --test agent/hook.test.mjs + run: node --test agent/hook.test.mjs npm/zerograph/test/launcher.test.js # A default Git for Windows install puts `git` on PATH and not `sh` — the Unix tools are an # opt-in — so `ask --rerank` looks for one. Route 1: Git's shell directories @@ -281,8 +281,8 @@ jobs: npx zerograph --repo $R --no-dense trace write refund if ($LASTEXITCODE -ne 3) { throw "a verdict through the shim should exit 3, not $LASTEXITCODE" } - # An upgrade under a live server: the launcher runs a per-version copy, so the package's - # own binary can be overwritten while a detached `serve` answers, and `stop` ends it. + # An upgrade under a live server: the launcher runs a copy kept under LOCALAPPDATA, so the + # package's own binary can be overwritten while a detached `serve` answers, and `stop` ends it. if ($IsWindows) { npx zerograph --repo $R --no-dense serve --detach --idle 600 if ($LASTEXITCODE -ne 0) { throw "serve --detach through the launcher failed: $LASTEXITCODE" } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75b27c88..f785b365 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,7 +97,9 @@ jobs: # can only be configured on a package that exists, so its first version was published by # hand (docs/bench/runbook.md). scripts/npm-pack.sh packs the same tarball. npm: - needs: build + # After `verify`, so a version whose attestation failed is never published: npm will not take + # the same version twice, and the docs promise every binary it ships verifies. + needs: [build, verify] # Only a tag has a release to download from and a version to publish under. if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest diff --git a/npm/zerograph/bin/zerograph.js b/npm/zerograph/bin/zerograph.js index 04ae2c74..22cdfd55 100644 --- a/npm/zerograph/bin/zerograph.js +++ b/npm/zerograph/bin/zerograph.js @@ -33,32 +33,60 @@ function binaryPath() { // failed with EBUSY. The binary runs from a copy under LOCALAPPDATA instead, and the package's own // file is never held open. Any failure here runs the package's file as before. // -// One slot per install, not per version: a project's devDependency and a global install, or two -// packs of one version, each keep their own copy rather than evicting each other's on every run. -// npm writes every file with one fixed mtime, so an install is told apart by its size and the time -// it created the file. +// One slot per version and size, so a project's devDependency and a global install at different +// versions keep their own copies instead of evicting each other on every run, and a reinstall of +// the same version reuses its copy rather than adding a 95 MB one. The size is not proof of the +// bytes (the linker pads a PE file to 512 bytes, so a rebuild often keeps it), so an install the +// slot has not seen is hashed once against the copy; `seen` remembers the installs that matched. +// npm writes every file with one fixed mtime, so an install is told apart by when it created it. const SLOT_TTL_MS = 30 * 24 * 3600 * 1000; const TOUCH_MS = 24 * 3600 * 1000; -function runnable(path) { - if (process.platform !== "win32" || !process.env.LOCALAPPDATA) return path; +function sha256(file) { + const hash = require("node:crypto").createHash("sha256"); + const fd = fs.openSync(file, "r"); + try { + const buf = Buffer.alloc(1 << 20); + let n; + while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) hash.update(buf.subarray(0, n)); + } finally { + fs.closeSync(fd); + } + return hash.digest("hex"); +} + +function runnable(path, { platform = process.platform, localAppData = process.env.LOCALAPPDATA, now = Date.now() } = {}) { + if (platform !== "win32" || !localAppData) return path; try { const { version } = require("../package.json"); - const root = join(process.env.LOCALAPPDATA, "zerograph", "bin"); + const root = join(localAppData, "zerograph", "bin"); const src = fs.statSync(path); - const slot = `${version}-${src.size}-${Math.trunc(src.ctimeMs)}`; + const slot = `${version}-${src.size}`; + const install = `${src.size}:${Math.trunc(src.ctimeMs)}`; const dir = join(root, slot); const copy = join(dir, "zerograph.exe"); const used = join(dir, "used"); - const now = Date.now(); - if (fs.existsSync(copy)) { - // Marked at most once a day, so a slot in use is never the one the sweep below removes. + const seen = join(dir, "seen"); + const digest = join(dir, "sha256"); + const read = (f) => { try { return fs.readFileSync(f, "utf8"); } catch { return ""; } }; + const mark = () => { + // At most once a day, so a slot in use is never the one the sweep below removes. try { - if (now - fs.statSync(used).mtimeMs > TOUCH_MS) fs.utimesSync(used, new Date(), new Date()); + if (now - fs.statSync(used).mtimeMs <= TOUCH_MS) return; } catch { fs.writeFileSync(used, ""); } - return copy; + fs.utimesSync(used, new Date(now), new Date(now)); + }; + let hash; + if (fs.existsSync(copy)) { + if (read(seen).split("\n").includes(install)) { mark(); return copy; } + hash = sha256(path); + if (hash === read(digest).trim()) { + fs.appendFileSync(seen, `${install}\n`); + mark(); + return copy; + } } fs.mkdirSync(dir, { recursive: true }); // Left by a launcher killed mid-copy; one another launcher is still writing stays locked. @@ -70,11 +98,14 @@ function runnable(path) { fs.copyFileSync(path, tmp); fs.renameSync(tmp, copy); } catch (e) { - // A full disk, or another launcher that renamed the same bytes into place first. + // A full disk, an older build of these bytes still running, or another launcher that renamed + // the same bytes into place first: only the last leaves a copy that is ours to run. fs.rmSync(tmp, { force: true }); - if (!fs.existsSync(copy)) throw e; + if (!fs.existsSync(copy) || (hash ?? sha256(path)) !== sha256(copy)) return path; } - fs.writeFileSync(used, ""); + fs.writeFileSync(digest, hash ?? sha256(path)); + fs.writeFileSync(seen, `${install}\n`); + mark(); // Slots nothing has launched for a month; one whose binary still runs fails the delete and // stays. for (const other of fs.readdirSync(root)) { @@ -92,16 +123,27 @@ function runnable(path) { } } -const found = binaryPath(); -if (found.error) { - console.error(found.error); - process.exit(2); +// A sweep in another launcher can remove an old slot between `runnable` and the spawn. +function spawnBinary(exe, pkg, args, spawn = spawnSync) { + const run = spawn(exe, args, { stdio: "inherit" }); + if (run.error && run.error.code === "ENOENT" && exe !== pkg) return spawn(pkg, args, { stdio: "inherit" }); + return run; } -const run = spawnSync(runnable(found.path), process.argv.slice(2), { stdio: "inherit" }); -if (run.error) { - console.error(`zerograph: ${run.error.message}`); - process.exit(2); +function main() { + const found = binaryPath(); + if (found.error) { + console.error(found.error); + process.exit(2); + } + const run = spawnBinary(runnable(found.path), found.path, process.argv.slice(2)); + if (run.error) { + console.error(`zerograph: ${run.error.message}`); + process.exit(2); + } + // A signal death has no status; report it the way a shell would. + process.exit(run.status === null ? 128 : run.status); } -// A signal death has no status; report it the way a shell would. -process.exit(run.status === null ? 128 : run.status); + +if (require.main === module) main(); +module.exports = { runnable, spawnBinary }; diff --git a/npm/zerograph/test/launcher.test.js b/npm/zerograph/test/launcher.test.js new file mode 100644 index 00000000..5750d685 --- /dev/null +++ b/npm/zerograph/test/launcher.test.js @@ -0,0 +1,108 @@ +"use strict"; + +// The Windows copy logic runs on every platform here: `runnable` takes the platform and +// LOCALAPPDATA as arguments, so the slot rules are pinned without a Windows box. +const test = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const { join } = require("node:path"); +const { runnable, spawnBinary } = require("../bin/zerograph.js"); +const { version } = require("../package.json"); + +const DAY = 24 * 3600 * 1000; + +function sandbox() { + const base = fs.mkdtempSync(join(os.tmpdir(), "zerograph-launcher-")); + const pkg = join(base, "pkg.exe"); + fs.writeFileSync(pkg, "x".repeat(1000)); + const app = join(base, "app"); + const root = join(app, "zerograph", "bin"); + return { pkg, app, root, win: (now = Date.now()) => ({ platform: "win32", localAppData: app, now }) }; +} + +function slot(root, name, lastUsed) { + const dir = join(root, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(join(dir, "zerograph.exe"), "old"); + fs.writeFileSync(join(dir, "used"), ""); + fs.utimesSync(join(dir, "used"), new Date(lastUsed), new Date(lastUsed)); + return dir; +} + +test("off Windows the package's own binary runs", () => { + const s = sandbox(); + assert.strictEqual(runnable(s.pkg, { platform: "linux", localAppData: s.app }), s.pkg); + assert.strictEqual(fs.existsSync(s.root), false); +}); + +test("the binary runs from a copy, and a second launch reuses it", () => { + const s = sandbox(); + const first = runnable(s.pkg, s.win()); + assert.notStrictEqual(first, s.pkg); + assert.strictEqual(fs.readFileSync(first, "utf8"), fs.readFileSync(s.pkg, "utf8")); + assert.strictEqual(runnable(s.pkg, s.win()), first); +}); + +test("a reinstall of the same version reuses its copy instead of adding one", () => { + const s = sandbox(); + const first = runnable(s.pkg, s.win()); + fs.rmSync(s.pkg); + fs.writeFileSync(s.pkg, "x".repeat(1000)); + assert.strictEqual(runnable(s.pkg, s.win()), first); + assert.deepStrictEqual(fs.readdirSync(s.root), [`${version}-1000`]); +}); + +test("another version's copy used within the month stays", () => { + const s = sandbox(); + const now = Date.now(); + const other = slot(s.root, "0.6.0-999", now - 5 * DAY); + runnable(s.pkg, s.win(now)); + assert.ok(fs.existsSync(join(other, "zerograph.exe"))); +}); + +test("a copy nothing launched for a month is swept", () => { + const s = sandbox(); + const now = Date.now(); + const stale = slot(s.root, "0.6.0-999", now - 31 * DAY); + runnable(s.pkg, s.win(now)); + assert.strictEqual(fs.existsSync(stale), false); +}); + +test("launching an old copy marks it, so the next sweep keeps it", () => { + const s = sandbox(); + const now = Date.now(); + const mine = runnable(s.pkg, s.win(now - 29 * DAY)); + assert.strictEqual(runnable(s.pkg, s.win(now)), mine); + const used = fs.statSync(join(mine, "..", "used")).mtimeMs; + assert.ok(now - used < DAY, "the launch did not mark the slot"); +}); + +test("a rebuild of the same version and size runs the new bytes, not the old copy", () => { + const s = sandbox(); + runnable(s.pkg, s.win()); + fs.rmSync(s.pkg); + fs.writeFileSync(s.pkg, "y".repeat(1000)); + const copy = runnable(s.pkg, s.win()); + assert.strictEqual(fs.readFileSync(copy, "utf8"), "y".repeat(1000)); +}); + +test("a copy swept before the spawn falls back to the package's binary", () => { + const calls = []; + const spawn = (exe) => { + calls.push(exe); + return exe === "copy.exe" ? { error: Object.assign(new Error("gone"), { code: "ENOENT" }) } : { status: 0 }; + }; + assert.strictEqual(spawnBinary("copy.exe", "pkg.exe", [], spawn).status, 0); + assert.deepStrictEqual(calls, ["copy.exe", "pkg.exe"]); +}); + +test("a binary that fails for another reason is not run twice", () => { + const calls = []; + const spawn = (exe) => { + calls.push(exe); + return { error: Object.assign(new Error("denied"), { code: "EACCES" }) }; + }; + assert.strictEqual(spawnBinary("copy.exe", "pkg.exe", [], spawn).error.code, "EACCES"); + assert.deepStrictEqual(calls, ["copy.exe"]); +});