Files
stack/packages/seat/tests/checkout.test.mjs
T
jason.woltjeandClaude Opus 5.5 0069949ffc seat: give role sessions a git identity and report the checkout commit (row 104, #1598)
sandboxArgv sets GIT_AUTHOR_NAME/EMAIL and GIT_COMMITTER_NAME/EMAIL from
the instance and business ids (name = instance id, email
<instance>@<business>.mosaic.invalid); spawnSession and the launcher pass
the ids through. checkout results carry the clone's commit (a kept clone
gets none), and the bundle prompt names it. allowed() is unchanged.
Packet agents/darkwing/work/queue-104/r1/ (manifest f99f2ae7). Dewey
approved round 1 in 28293 (posted under jarvis by mistake; 28294 is
Dewey's confirmation). Sage's gate on f00613106 plus the candidate:
every package suite and scripts/test-*.sh green (test-release failed 3
activation checks once, not reproduced; rerun 14/0).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:40:33 -05:00

462 lines
26 KiB
JavaScript

// The project checkouts in a role workspace (src/checkout.mjs), against
// real git repositories in a temp directory. Every case also checks that
// the root's files and refs are byte for byte what they were.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CONFIG_MAX_BYTES, CheckoutError, ensureCheckouts, gitEnv } from "../src/checkout.mjs";
function tmp(t) {
const dir = mkdtempSync(join(tmpdir(), "mosaic-checkout-"));
t.after(() => {
spawnSync("chmod", ["-R", "u+rwx", dir]);
rmSync(dir, { recursive: true, force: true });
});
return dir;
}
// git for the test's own setup, with no user config.
function git(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=t", "-c", "[email protected]", ...args], { cwd, encoding: "utf8", env: gitEnv() });
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
return r.stdout.trim();
}
// A repository on `main` with one commit.
function repo(dir, name = "root") {
const root = join(dir, name);
mkdirSync(root);
git(root, "init", "-q", "-b", "main");
writeFileSync(join(root, "README"), "one\n");
git(root, "add", "README");
git(root, "commit", "-q", "-m", "one");
return root;
}
function commit(root, text) {
writeFileSync(join(root, "README"), text);
git(root, "commit", "-q", "-am", text.trim());
return git(root, "rev-parse", "HEAD");
}
// Every path under `dir` with its type, mode, link count and contents or
// link target.
function fingerprint(dir) {
const h = createHash("sha256");
const walk = (d) => {
for (const name of readdirSync(d).sort()) {
const p = join(d, name);
const st = lstatSync(p);
h.update(`${p}\0${st.mode}\0${st.nlink}\0`);
if (st.isDirectory()) walk(p);
else if (st.isSymbolicLink()) h.update(readlinkSync(p));
else if (st.isFile()) h.update(readFileSync(p));
}
};
walk(dir);
return h.digest("hex");
}
// A root, an empty workspace, and `ensure()` for the one project `stack`.
function world(t) {
const dir = tmp(t);
const root = repo(dir);
const workspace = join(dir, "ws");
mkdirSync(workspace, { mode: 0o700 });
const projects = { stack: { root } };
return { dir, root, workspace, clone: join(workspace, "stack"), ensure: () => ensureCheckouts({ workspace, projects }) };
}
// ensure() returning its one checkout, with the root unchanged by it.
async function once(w) {
const before = fingerprint(w.root);
const list = await w.ensure();
assert.equal(list.length, 1);
assert.equal(fingerprint(w.root), before, "the root is unchanged");
return list[0];
}
test("a first launch clones the root at its branch, with the root's path as origin", async (t) => {
const w = world(t);
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "cloned", branch: "main", commit: git(w.root, "rev-parse", "HEAD") });
assert.equal(git(w.clone, "symbolic-ref", "--short", "HEAD"), "main");
assert.equal(git(w.clone, "rev-parse", "HEAD"), git(w.root, "rev-parse", "HEAD"));
assert.equal(git(w.clone, "remote", "get-url", "origin"), w.root);
assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "one\n");
assert.deepEqual(readdirSync(w.workspace), ["stack"], "no temp directory is left");
// --no-hardlinks: no file under .git is shared with the root, and no hook came with it.
const files = readdirSync(join(w.clone, ".git"), { recursive: true }).map((p) => join(w.clone, ".git", p)).filter((p) => lstatSync(p).isFile());
assert.ok(files.some((p) => p.includes("/objects/")));
for (const p of files) assert.equal(lstatSync(p).nlink, 1, p);
assert.equal(existsSync(join(w.clone, ".git", "hooks")), false);
});
// A host stopped mid-clone must not leave a half clone where the next
// launch takes it for the session's. A git on PATH runs the real one, then
// notes whether <workspace>/stack exists yet.
test("the clone is not at its place until git clone has finished", async (t) => {
const w = world(t);
const bin = join(w.dir, "bin");
mkdirSync(bin);
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
const seen = join(w.dir, "seen");
writeFileSync(join(bin, "git"), `#!/bin/sh\n'${real}' "$@"; s=$?\ncase " $* " in *" clone "*) if [ -e '${w.clone}' ]; then echo there; else echo absent; fi >> '${seen}';; esac\nexit $s\n`, { mode: 0o755 });
const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: { PATH: `${bin}:${process.env.PATH}` } });
assert.equal(list[0].outcome, "cloned");
assert.equal(readFileSync(seen, "utf8"), "absent\n");
assert.deepEqual(readdirSync(w.workspace), ["stack"]);
});
test("a later launch fast-forwards a clean clone after a commit to the root, then finds it current", async (t) => {
const w = world(t);
await once(w);
const head = commit(w.root, "two\n");
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "updated", branch: "main", commit: head });
assert.equal(git(w.clone, "rev-parse", "HEAD"), head);
assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "two\n");
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "current", branch: "main", commit: head });
assert.equal(git(w.clone, "status", "--porcelain"), "");
});
test("a clone ahead of the root keeps its commits and counts as current", async (t) => {
const w = world(t);
await once(w);
const mine = commit(w.clone, "mine\n");
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "current", branch: "main", commit: mine });
assert.equal(git(w.clone, "rev-parse", "HEAD"), mine);
});
test("a dirty clone, one on another branch and one diverged are kept as they are", async (t) => {
for (const [name, edit, reason] of [
["a modified file", (c) => writeFileSync(join(c, "README"), "mine\n"), "dirty"],
["an untracked file", (c) => writeFileSync(join(c, "NEW"), "mine\n"), "dirty"],
["a staged file", (c) => (writeFileSync(join(c, "NEW"), "x"), git(c, "add", "NEW")), "dirty"],
["another branch", (c) => git(c, "checkout", "-q", "-b", "work"), "other-branch"],
["a detached HEAD", (c) => git(c, "checkout", "-q", "--detach"), "other-branch"],
["a diverged branch", (c) => commit(c, "mine\n"), "not-fast-forward"],
]) {
await t.test(name, async (t) => {
const w = world(t);
await once(w);
edit(w.clone);
commit(w.root, "two\n");
const head = git(w.clone, "rev-parse", "HEAD");
const before = reason === "not-fast-forward" ? null : fingerprint(w.clone);
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "kept", reason });
assert.equal(git(w.clone, "rev-parse", "HEAD"), head, "HEAD didn't move");
// A diverged clone gets the fetch into refs/remotes, and nothing else.
if (before) assert.equal(fingerprint(w.clone), before, "the clone is exactly as it was");
else assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "mine\n");
});
}
});
// Row 104 (#1598): a session that sets its own git identity in the clone's
// config gets the clone kept at every later launch. The host only reads the
// clone's config file and runs no git in it, so the kept result has no commit.
test("a clone with user.name or user.email in its config is kept, and git runs nothing in it", async (t) => {
for (const key of ["user.name", "user.email"]) {
await t.test(key, async (t) => {
const w = world(t);
await once(w);
git(w.clone, "config", key, "someone");
commit(w.root, "two\n");
const bin = join(w.dir, "bin");
mkdirSync(bin);
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
const calls = join(w.dir, "calls");
writeFileSync(join(bin, "git"), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${calls}'\nexec '${real}' "$@"\n`, { mode: 0o755 });
const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: { PATH: `${bin}:${process.env.PATH}` } });
assert.deepEqual(list, [{ project: "stack", path: w.clone, outcome: "kept", reason: "config-changed" }]);
// The one call naming the clone reads its config file, as unsafe() does for every clone.
const named = readFileSync(calls, "utf8").split("\n").filter((l) => l.includes(w.clone));
assert.deepEqual(named.map((l) => l.slice(l.indexOf(" config "))), [` config --file ${join(w.clone, ".git", "config")} --list --null`], "no git ran in the kept clone");
});
}
});
// When git can't read the clone's HEAD after the fast-forward, the commit is
// null, not an empty string. A git on PATH fails the second rev-parse HEAD.
test("a commit git can't read after the fast-forward is null", async (t) => {
const w = world(t);
await once(w);
commit(w.root, "two\n");
const bin = join(w.dir, "bin");
mkdirSync(bin);
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
const count = join(w.dir, "count");
writeFileSync(join(bin, "git"), `#!/bin/sh\ncase " $* " in *" rev-parse HEAD ") echo x >> '${count}'; [ "$(wc -l < '${count}')" -ge 2 ] && exit 1;; esac\nexec '${real}' "$@"\n`, { mode: 0o755 });
const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: { PATH: `${bin}:${process.env.PATH}` } });
assert.deepEqual(list, [{ project: "stack", path: w.clone, outcome: "updated", branch: "main", commit: null }]);
});
// A new clone whose HEAD git can't read refuses like a failed clone, and its
// temp directory goes. A git on PATH fails rev-parse --verify HEAD.
test("a new clone whose commit can't be read refuses the launch and leaves nothing behind", async (t) => {
const w = world(t);
const bin = join(w.dir, "bin");
mkdirSync(bin);
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
writeFileSync(join(bin, "git"), `#!/bin/sh\ncase " $* " in *" rev-parse --verify HEAD ") echo 'fatal: no HEAD' >&2; exit 128;; esac\nexec '${real}' "$@"\n`, { mode: 0o755 });
await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: { PATH: `${bin}:${process.env.PATH}` } }), (e) => e instanceof CheckoutError && e.project === "stack" && /^the new clone of .* has no readable HEAD: /.test(e.message));
assert.deepEqual(readdirSync(w.workspace), [], "no clone and no temp directory");
});
// A repository outside the workspace whose config and hooks run a command
// that touches `marker`, for the cases that point git there.
function bait(dir, marker) {
const evil = repo(dir, "evil");
const script = join(dir, "run.sh");
writeFileSync(script, `#!/bin/sh\ntouch '${marker}'\n`, { mode: 0o755 });
git(evil, "config", "core.fsmonitor", script);
git(evil, "config", "core.hooksPath", join(evil, ".git", "hooks"));
mkdirSync(join(evil, ".git", "hooks"), { recursive: true });
for (const h of ["post-merge", "post-checkout", "reference-transaction", "pre-auto-gc", "post-index-change"]) writeFileSync(join(evil, ".git", "hooks", h), `#!/bin/sh\ntouch '${marker}'\n`, { mode: 0o755 });
return { evil, script };
}
// A repository at <dir>/sub whose own config names a clean filter that
// runs the bait script, applied to a tracked file left modified at the same
// size, so any git status inside it must hash the file and run the filter.
// Returns its commit.
function nested(dir, b) {
const sub = join(dir, "sub");
mkdirSync(sub);
git(sub, "init", "-q", "-b", "main");
writeFileSync(join(sub, "data"), "one\n");
writeFileSync(join(sub, ".gitattributes"), "data filter=x\n");
git(sub, "add", "data", ".gitattributes");
git(sub, "commit", "-q", "-m", "sub");
git(sub, "config", "filter.x.clean", b.script);
writeFileSync(join(sub, "data"), "ONE\n");
return git(sub, "rev-parse", "HEAD");
}
const gitlink = (c, b) => git(c, "update-index", "--add", "--cacheinfo", `160000,${nested(c, b)},sub`);
// <clone>/sub carries its own gitlink to <clone>/sub/sub, which alone names the filter.
const gitlinkNested = (c, b) => {
const outer = join(c, "sub");
mkdirSync(outer);
git(outer, "init", "-q", "-b", "main");
writeFileSync(join(outer, "f"), "x\n");
git(outer, "add", "f");
gitlink(outer, b);
git(outer, "commit", "-q", "-m", "outer");
git(c, "update-index", "--add", "--cacheinfo", `160000,${git(outer, "rev-parse", "HEAD")},sub`);
};
const gitmodules = (c) => (writeFileSync(join(c, ".gitmodules"), '[submodule "sub"]\n\tpath = sub\n\turl = ./sub\n'), git(c, "add", ".gitmodules"));
// Each way a session could make the host's git run a command or write
// outside the clone. Every one keeps the clone, nothing runs, and neither
// the root nor the bait repository changes.
test("a clone the session rigged is kept, and git runs nothing and writes nothing outside it", async (t) => {
const egit = (b) => join(b.evil, ".git");
for (const [name, edit, reason] of [
["core.fsmonitor", (c, b) => git(c, "config", "core.fsmonitor", b.script), "config-changed"],
["core.hooksPath", (c, b) => git(c, "config", "core.hooksPath", join(egit(b), "hooks")), "config-changed"],
["a hook file", (c, b) => {
mkdirSync(join(c, ".git", "hooks"));
writeFileSync(join(c, ".git", "hooks", "post-merge"), readFileSync(join(egit(b), "hooks", "post-merge")), { mode: 0o755 });
}, "hooks-present"],
["include.path", (c, b) => git(c, "config", "include.path", join(egit(b), "config")), "config-changed"],
["includeIf", (c, b) => git(c, "config", "includeIf.gitdir:/.path", join(egit(b), "config")), "config-changed"],
["a filter driver", (c, b) => {
git(c, "config", "filter.x.smudge", `${b.script}; cat`);
mkdirSync(join(c, ".git", "info"), { recursive: true });
writeFileSync(join(c, ".git", "info", "attributes"), "* filter=x\n");
}, "config-changed"],
["origin moved", (c, b) => git(c, "remote", "set-url", "origin", b.evil), "config-changed"],
["a second origin url", (c, b) => git(c, "config", "--add", "remote.origin.url", b.evil), "config-changed"],
["the origin url twice, both the root", (c) => git(c, "config", "--add", "remote.origin.url", git(c, "config", "--get", "remote.origin.url")), "config-changed"],
["an ext:: origin", (c, b) => git(c, "remote", "set-url", "origin", `ext::${b.script}`), "config-changed"],
["a branch tracking another remote", (c) => git(c, "config", "branch.main.remote", "elsewhere"), "config-changed"],
["a branch merging a tag", (c) => git(c, "config", "branch.main.merge", "refs/tags/x"), "config-changed"],
["a fetch line onto local branches", (c) => git(c, "config", "remote.origin.fetch", "+refs/heads/*:refs/heads/*"), "config-changed"],
["no origin", (c) => git(c, "remote", "remove", "origin"), "config-changed"],
["a config file over the size cap", (c) => writeFileSync(join(c, ".git", "config"), `${readFileSync(join(c, ".git", "config"), "utf8")}#${"x".repeat(CONFIG_MAX_BYTES)}\n`), "config-changed"],
["config a symlink to /dev/zero", (c) => (rmSync(join(c, ".git", "config")), symlinkSync("/dev/zero", join(c, ".git", "config"))), "not-a-clone"],
["the clone a symlink to the bait", (c, b) => (rmSync(c, { recursive: true }), symlinkSync(b.evil, c)), "not-a-clone"],
["the clone a file", (c) => (rmSync(c, { recursive: true }), writeFileSync(c, "x")), "not-a-clone"],
["the clone a plain directory", (c) => rmSync(join(c, ".git"), { recursive: true }), "not-a-clone"],
[".git a gitdir: file", (c, b) => (rmSync(join(c, ".git"), { recursive: true }), writeFileSync(join(c, ".git"), `gitdir: ${egit(b)}\n`)), "not-a-clone"],
[".git a symlink", (c, b) => (rmSync(join(c, ".git"), { recursive: true }), symlinkSync(egit(b), join(c, ".git"))), "not-a-clone"],
[".git/logs a symlink", (c, b) => (rmSync(join(c, ".git", "logs"), { recursive: true }), symlinkSync(join(egit(b), "logs"), join(c, ".git", "logs"))), "not-a-clone"],
[".git/refs/heads a symlink", (c, b) => (rmSync(join(c, ".git", "refs", "heads"), { recursive: true }), symlinkSync(join(egit(b), "refs", "heads"), join(c, ".git", "refs", "heads"))), "not-a-clone"],
[".git/objects a symlink", (c, b) => (rmSync(join(c, ".git", "objects"), { recursive: true }), symlinkSync(join(egit(b), "objects"), join(c, ".git", "objects"))), "not-a-clone"],
["a reflog hard-linked to a file outside", (c, b) => (rmSync(join(c, ".git", "logs", "HEAD")), linkSync(join(egit(b), "logs", "HEAD"), join(c, ".git", "logs", "HEAD"))), "not-a-clone"],
["HEAD a fifo", (c) => (rmSync(join(c, ".git", "HEAD")), spawnSync("mkfifo", [join(c, ".git", "HEAD")])), "not-a-clone"],
["a commondir file", (c, b) => writeFileSync(join(c, ".git", "commondir"), `${egit(b)}\n`), "not-a-clone"],
["an alternates file", (c, b) => (mkdirSync(join(c, ".git", "objects", "info"), { recursive: true }), writeFileSync(join(c, ".git", "objects", "info", "alternates"), `${join(egit(b), "objects")}\n`)), "not-a-clone"],
// A submodule's own config is outside every check above, and git status
// runs a second git inside a populated submodule.
["a gitlink staged, no .gitmodules", gitlink, "submodule"],
["a gitlink and .gitmodules staged", (c, b) => (gitlink(c, b), gitmodules(c)), "submodule"],
["a gitlink to a repository with its own gitlink", gitlinkNested, "submodule"],
["a gitlink and .gitmodules committed", (c, b) => (gitlink(c, b), gitmodules(c), git(c, "commit", "-q", "-m", "sub")), "submodule"],
["a gitlink and submodule.active in config", (c, b) => (gitlink(c, b), gitmodules(c), git(c, "config", "submodule.active", ".")), "config-changed"],
["a .git/modules with no gitlink", (c, b) => {
const m = join(c, ".git", "modules", "sub");
git(c, "init", "-q", "--bare", "--template=", m);
git(m, "config", "filter.x.clean", b.script);
}, "submodule"],
]) {
await t.test(name, async (t) => {
const w = world(t);
const marker = join(w.dir, "RAN");
const b = bait(w.dir, marker);
await once(w);
edit(w.clone, b);
commit(w.root, "two\n");
const evilBefore = fingerprint(b.evil);
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "kept", reason });
assert.equal(existsSync(marker), false, "nothing the session wrote ran");
assert.equal(fingerprint(b.evil), evilBefore, "nothing outside the clone was written");
});
}
});
test("a root that can't be cloned refuses with the project id and leaves nothing behind", async (t) => {
for (const [name, make, why] of [
["missing", (w) => rmSync(w.root, { recursive: true }), /can't be read/],
["not a repository", (w) => rmSync(join(w.root, ".git"), { recursive: true }), /can't be read/],
["a plain directory inside another repository", (w) => {
rmSync(join(w.root, ".git"), { recursive: true });
git(w.dir, "init", "-q", "-b", "main");
git(w.dir, "commit", "-q", "--allow-empty", "-m", "outer");
}, /can't be read/],
["unreadable", (w) => chmodSync(w.root, 0o000), /can't be read/],
["on no branch", (w) => git(w.root, "checkout", "-q", "--detach"), /no branch checked out/],
["missing an object", (w) => {
// git starts writing the clone, then can't check the file out.
const blob = git(w.root, "rev-parse", "HEAD:README");
rmSync(join(w.root, ".git", "objects", blob.slice(0, 2), blob.slice(2)));
}, /git clone of .* failed/],
]) {
await t.test(name, async (t) => {
const w = world(t);
make(w);
await assert.rejects(w.ensure, (e) => e instanceof CheckoutError && e.project === "stack" && why.test(e.message));
assert.deepEqual(readdirSync(w.workspace), [], "no clone and no temp directory");
});
}
});
test("a clone whose root is on no branch, or gone, is kept and named", async (t) => {
const w = world(t);
await once(w);
git(w.root, "checkout", "-q", "--detach");
assert.equal((await once(w)).reason, "root-detached");
rmSync(join(w.root, ".git"), { recursive: true });
// The clone's config still names this path, so its check passes and the root's read fails.
assert.deepEqual(await w.ensure(), [{ project: "stack", path: w.clone, outcome: "kept", reason: "root-unreadable" }]);
});
test("no credential or forge remote of the root reaches a clone", async (t) => {
const w = world(t);
git(w.root, "remote", "add", "origin", "https://bot:[email protected]/acme/stack.git");
git(w.root, "remote", "add", "gitea", "ssh://[email protected]/acme/stack.git");
git(w.root, "config", "http.extraHeader", "Authorization: token s3cr3t-token");
const secret = () => spawnSync("grep", ["-rlE", "s3cr3t|git\\.example", join(w.clone, ".git")], { encoding: "utf8" }).stdout;
await once(w);
assert.equal(git(w.clone, "remote"), "origin");
assert.equal(git(w.clone, "remote", "get-url", "origin"), w.root);
assert.equal(secret(), "");
// And the fast-forward keeps it that way.
commit(w.root, "two\n");
assert.equal((await once(w)).outcome, "updated");
assert.equal(secret(), "");
});
test("every project in the business file gets its own clone at its own branch", async (t) => {
const dir = tmp(t);
const a = repo(dir, "a");
const b = repo(dir, "b");
git(b, "checkout", "-q", "-b", "dev");
const workspace = join(dir, "ws");
mkdirSync(workspace);
const list = await ensureCheckouts({ workspace, projects: { alpha: { root: a }, beta: { root: b } } });
assert.deepEqual(list.map((c) => [c.project, c.path, c.outcome, c.branch, c.commit]), [["alpha", join(workspace, "alpha"), "cloned", "main", git(a, "rev-parse", "HEAD")], ["beta", join(workspace, "beta"), "cloned", "dev", git(b, "rev-parse", "HEAD")]]);
assert.equal(git(join(workspace, "beta"), "symbolic-ref", "--short", "HEAD"), "dev");
});
// A git on PATH that, for `git clone`, runs `body` in place of the real one.
// Every other git is the real one.
function slowClone(w, body) {
const bin = join(w.dir, "bin");
mkdirSync(bin);
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
writeFileSync(join(bin, "git"), `#!/bin/bash\ncase " $* " in *" clone "*) ${body} ;; esac\nexec '${real}' "$@"\n`, { mode: 0o755 });
return { PATH: `${bin}:${process.env.PATH}` };
}
// True once `pid` has gone, or is a zombie waiting for init to reap it.
async function gone(pid, ms = 3000) {
const end = Date.now() + ms;
for (;;) {
let stat = null;
try {
stat = readFileSync(`/proc/${pid}/stat`, "utf8");
} catch {}
if (stat === null || stat.slice(stat.lastIndexOf(")") + 2)[0] === "Z") return true;
if (Date.now() > end) return false;
await new Promise((r) => setTimeout(r, 50));
}
}
// SIGKILL to a sleep a test left behind, if it still runs.
const kill = (pid) => {
try {
process.kill(pid, "SIGKILL");
} catch {}
};
// The pid a wrapper wrote to `file`, once it is there.
async function pidIn(file) {
for (let i = 0; i < 100 && !existsSync(file); i++) await new Promise((r) => setTimeout(r, 20));
return Number(readFileSync(file, "utf8").trim());
}
test("a clone past its time gets SIGTERM and refuses the launch, with nothing left in the workspace", async (t) => {
const w = world(t);
const env = slowClone(w, "exec sleep 30");
await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 500, graceMs: 20000 } }), (e) => e instanceof CheckoutError && /killed by SIGTERM/.test(e.message));
assert.deepEqual(readdirSync(w.workspace), []);
});
test("a clone that ignores SIGTERM gets SIGKILL once the grace period is over", { timeout: 20000 }, async (t) => {
const w = world(t);
const env = slowClone(w, "trap '' TERM; sleep 30");
const started = Date.now();
await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 300, graceMs: 700 } }), (e) => e instanceof CheckoutError && /killed by SIGKILL/.test(e.message));
assert.ok(Date.now() - started >= 1000, "SIGKILL waited out the grace period");
});
// git is the leader of its own process group, and the group gets the signal:
// a process git started (here a sleep) doesn't outlive the kill.
test("a timeout signals git's process group, so a process git started dies with it", { timeout: 20000 }, async (t) => {
const w = world(t);
const file = join(w.dir, "child.pid");
const env = slowClone(w, `sleep 30 </dev/null >/dev/null 2>&1 & echo $! > '${file}'; wait`);
await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 500, graceMs: 20000 } }), /killed by SIGTERM/);
const pid = await pidIn(file);
t.after(() => kill(pid));
assert.equal(await gone(pid), true, "the sleep git started is gone");
});
// git dies of SIGTERM, but a process it started ignores SIGTERM and holds
// none of git's output, so git's close comes at once. The group gets
// SIGKILL then, not after the grace period.
test("once a signalled git has closed, its group gets SIGKILL, so a process that ignored SIGTERM dies too", { timeout: 20000 }, async (t) => {
const w = world(t);
const file = join(w.dir, "child.pid");
const env = slowClone(w, `(trap '' TERM; exec sleep 30) </dev/null >/dev/null 2>&1 & echo $! > '${file}'; exec sleep 30`);
await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 500, graceMs: 20000 } }), /killed by SIGTERM/);
const pid = await pidIn(file);
t.after(() => kill(pid));
assert.equal(await gone(pid, 1500), true, "the sleep that ignored SIGTERM is gone well inside the grace period");
});