// The runner's git tools (src/git-tools.mjs) against a fake git socket that // records the request line. The review loop over the real host's socket is in // packages/cli/tests/git-socket.test.mjs. import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { join } from "node:path"; import { fetchReview, git as runnerGit, HAVE_MAX } from "../src/git-tools.mjs"; import { scratch } from "./helpers.mjs"; const ENV = { PATH: process.env.PATH, HOME: "/nonexistent", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", LC_ALL: "C" }; function git(cwd, args, extra = {}) { const r = spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.invalid", "-C", cwd, ...args], { env: { ...ENV, ...extra }, encoding: "utf8" }); assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`); return r.stdout.trim(); } // A clone at `dir` with `refs` (name -> commit time) pointing at commits of // their own. function clone(dir, refs) { mkdirSync(dir, { recursive: true }); git(dir, ["init", "-q"]); const tree = git(dir, ["mktree"]); const oids = {}; for (const [name, time] of Object.entries(refs)) { const date = `${time} +0000`; oids[name] = git(dir, ["commit-tree", tree, "-m", name], { GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }); git(dir, ["update-ref", name, oids[name]]); } return oids; } // A socket that keeps each request line and refuses it. async function fakeHost(t, dir) { const path = join(dir, "git.sock"); const lines = []; const server = createServer((s) => { let buf = ""; s.on("data", (b) => { buf += b; if (!buf.includes("\n")) return; lines.push(JSON.parse(buf.split("\n")[0])); s.end(`${JSON.stringify({ ok: false, error: "request-not-found" })}\n`); }); }); await new Promise((r) => server.listen(path, r)); t.after(() => new Promise((r) => server.close(r))); return { path, lines }; } test("fetch_review's have lists the working refs before origin's, and no more than HAVE_MAX", async (t) => { const dir = scratch(t); const workspace = join(dir, "ws"); // 40 origin refs, newer than every working ref, and 13 working refs in two // clones: 53 in all. const stack = {}; for (let i = 0; i < 40; i++) stack[`refs/remotes/origin/b${i}`] = 1_800_000_000 + i; for (let i = 1; i <= 10; i++) stack[`refs/remotes/working/work/vikunja-1-${i}`] = 1_700_000_000 + i; const other = {}; for (let i = 1; i <= 3; i++) other[`refs/remotes/working/work/vikunja-2-${i}`] = 1_700_000_000 + i; const a = clone(join(workspace, "stack"), stack); const b = clone(join(workspace, "other"), other); const host = await fakeHost(t, dir); await assert.rejects(fetchReview({ path: host.path, cap: "c".repeat(64), workspace, task_ref: "vikunja:1/2", request: "r1", tmp: dir, env: ENV }), (e) => e.code === "request-not-found"); const [req] = host.lines; assert.equal(req.op, "fetch"); assert.equal(HAVE_MAX, 32); assert.equal(req.have.length, HAVE_MAX); assert.equal(new Set(req.have).size, HAVE_MAX); const working = [...Object.entries(a), ...Object.entries(b)].filter(([n]) => n.startsWith("refs/remotes/working/")).map(([, oid]) => oid); assert.equal(working.length, 13); // Every working ref, ahead of any of origin's. assert.deepEqual(new Set(req.have.slice(0, 13)), new Set(working)); const origin = new Set(Object.entries(a).filter(([n]) => n.startsWith("refs/remotes/origin/")).map(([, oid]) => oid)); for (const oid of req.have.slice(13)) assert.ok(origin.has(oid), oid); }); // Past HAVE_MAX the order is per clone: each clone's working refs newest // first, one clone after another, so an earlier clone's older refs come // ahead of a later clone's newer ones. The clones' order is the workspace's // directory order, so the test takes it from the request. test("fetch_review's have orders the working refs newest first within each clone, one clone after another", async (t) => { const dir = scratch(t); const workspace = join(dir, "ws"); // 20 working refs in each clone; beta's are all newer than alpha's. // Created in a shuffled order, so the name order isn't the date order. const order = [7, 2, 15, 0, 11, 19, 4, 9, 13, 1, 17, 6, 3, 18, 10, 5, 14, 8, 16, 12]; const refs = (task, base) => Object.fromEntries(order.map((i) => [`refs/remotes/working/work/vikunja-${task}-${i + 1}`, base + i * 60])); const alpha = clone(join(workspace, "alpha"), refs(1, 1_700_000_000)); const beta = clone(join(workspace, "beta"), refs(2, 1_750_000_000)); const host = await fakeHost(t, dir); await assert.rejects(fetchReview({ path: host.path, cap: "c".repeat(64), workspace, task_ref: "vikunja:1/2", request: "r1", tmp: dir, env: ENV }), (e) => e.code === "request-not-found"); const [req] = host.lines; // Newest first: the ref for i = 19 first, i = 0 last. const newest = (oids, task) => order.slice().sort((x, y) => y - x).map((i) => oids[`refs/remotes/working/work/vikunja-${task}-${i + 1}`]); const [first, second] = new Set(Object.values(alpha)).has(req.have[0]) ? [newest(alpha, 1), newest(beta, 2)] : [newest(beta, 2), newest(alpha, 1)]; assert.deepEqual(req.have, [...first, ...second].slice(0, HAVE_MAX)); }); // A git on PATH that runs `body` in place of the real one. function fakeGit(dir, body) { const bin = join(dir, "bin"); mkdirSync(bin); writeFileSync(join(bin, "git"), `#!/bin/bash\n${body}\n`, { mode: 0o755 }); return { ...ENV, PATH: `${bin}:${process.env.PATH}` }; } // True once `pid` has gone, or is a zombie waiting to be reaped. async function gone(pid, ms) { 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)); } } 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()); } const kill = (pid) => { try { process.kill(pid, "SIGKILL"); } catch {} }; // git ignores SIGTERM and waits on a sleep it started. SIGTERM goes to the // group, so the sleep dies at the timeout; git itself lasts until SIGKILL. test("a runner git past its time: SIGTERM reaches its process group, and SIGKILL follows after the grace period", { timeout: 20000 }, async (t) => { const dir = scratch(t); const file = join(dir, "child.pid"); const env = fakeGit(dir, `sleep 30 /dev/null 2>&1 &\necho $! > '${file}'\ntrap '' TERM\nwait $!\nexec sleep 30`); const started = Date.now(); const run = runnerGit(["fetch"], env, { timeoutMs: 300, graceMs: 1500 }); const pid = await pidIn(file); t.after(() => kill(pid)); assert.equal(await gone(pid, 800), true, "the sleep git started died at SIGTERM"); assert.ok(Date.now() - started < 1500, "before the grace period was over"); const r = await run; assert.equal(r.why, "killed by SIGKILL"); assert.ok(Date.now() - started >= 1800, "SIGKILL waited out the grace period"); }); // git dies at SIGTERM; a process it started ignores SIGTERM and holds none // of git's output. Once git has closed, its group gets SIGKILL. test("once a signalled runner git has closed, its group gets SIGKILL, so a process that ignored SIGTERM dies too", { timeout: 20000 }, async (t) => { const dir = scratch(t); const file = join(dir, "child.pid"); const env = fakeGit(dir, `(trap '' TERM; exec sleep 30) /dev/null 2>&1 &\necho $! > '${file}'\nexec sleep 30`); const r = await runnerGit(["fetch"], env, { timeoutMs: 300, graceMs: 20000 }); assert.equal(r.why, "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"); });