Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" }
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 68 additions & 26 deletions npm/zerograph/bin/zerograph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)) {
Expand All @@ -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 };
108 changes: 108 additions & 0 deletions npm/zerograph/test/launcher.test.js
Original file line number Diff line number Diff line change
@@ -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"]);
});
Loading