diff --git a/docs/plans/BRIEF-TEMPLATE.md b/docs/plans/BRIEF-TEMPLATE.md index c5ece4ba..1b0facef 100644 --- a/docs/plans/BRIEF-TEMPLATE.md +++ b/docs/plans/BRIEF-TEMPLATE.md @@ -13,7 +13,8 @@ Rules the queue enforces (queue-as-data plan 8.13): refuse until the lead re-pins it. `queued` means the brief exists, not that it is accepted. The row moves to -`briefed` when its owner accepts it. +`briefed` when a privileged actor (jason or sage) accepts it; the owner +can't. --- diff --git a/packages/queue/README.md b/packages/queue/README.md index 74956816..1c8774fa 100644 --- a/packages/queue/README.md +++ b/packages/queue/README.md @@ -44,7 +44,18 @@ Exit codes: 0 ok; 1 the operation failed; 2 invalid data or refused; Until piece D, `move ID in-review` needs `--candidate`: an existing file is read as a manifest (one ` ` line per file), anything else as a commit reachable from `refs/heads` or `refs/tags`. The candidate is frozen -for the round. The review's issue is the row's first issue. +for the round. + +The review's issue follows lead decision 23. A row with no issues can't +request review. A row with one issue uses it. A row with several needs +`--issue N`, one of its issues. Later rounds keep the previous round's issue +unless `--issue` names another; if the row no longer lists the kept issue, +the request refuses until `--issue` names one. + +`move ID done` from in-review needs `--evidence +comment=,round=,candidate=`. The round must be the current +one and the digest its candidate's, so a comment from an earlier round +can't close a later one, even when the candidate is the same. ## Where the files live diff --git a/packages/queue/src/cli.mjs b/packages/queue/src/cli.mjs index c5517747..e8df80f7 100644 --- a/packages/queue/src/cli.mjs +++ b/packages/queue/src/cli.mjs @@ -4,7 +4,7 @@ // Reads: list | show ID | next [SEAT] // Changes: add --piece TEXT --gate TEXT --brief PATH#ANCHOR [--issue N]... [--note TEXT] // [--owner SEAT] [--gate-owner SEAT] [--after ID[:settled]]... [--reviewer SEAT]... [--required] -// move ID STATE [--reason TEXT] [--candidate COMMIT|MANIFEST] [--evidence TEXT] +// move ID STATE [--reason TEXT] [--candidate COMMIT|MANIFEST] [--issue N] [--evidence TEXT] // release ID | assign ID SEAT | note ID TEXT | set ID FIELD VALUE [--reason TEXT] // genesis --root PATH --branch NAME --map PATH // accept-history --reason TEXT --yes @@ -23,7 +23,7 @@ import { list, mutate, next, renderView, show, snapshot, sync, unlock, verify, v const USAGE = [ "usage: queue list | show ID | next [SEAT]", " queue add --op ID --piece TEXT --gate TEXT --brief PATH#ANCHOR [--issue N]... [--note TEXT] [--owner SEAT] [--gate-owner SEAT] [--after ID[:settled]]... [--reviewer SEAT]... [--required]", - " queue move ID STATE --op ID [--reason TEXT] [--candidate COMMIT|MANIFEST] [--evidence TEXT]", + " queue move ID STATE --op ID [--reason TEXT] [--candidate COMMIT|MANIFEST] [--issue N] [--evidence TEXT]", " queue release ID --op ID | assign ID SEAT --op ID | note ID TEXT --op ID", ` queue set ID FIELD VALUE --op ID [--reason TEXT] (fields: ${SET_FIELDS.join(", ")})`, " queue genesis --op ID --root PATH --branch NAME --map PATH", @@ -134,8 +134,12 @@ export function run(argv, opts = {}) { }); } case "move": - allow(flags, [...CHANGE, "--reason", "--candidate", "--evidence"]); positional(pos, 2, "move ID STATE"); - return change("move", { id: intArg(pos[0], "ID"), to: pos[1], reason: f("--reason"), candidate: f("--candidate"), evidence: f("--evidence") }); + allow(flags, [...CHANGE, "--reason", "--candidate", "--issue", "--evidence"]); positional(pos, 2, "move ID STATE"); + if ((flags.get("--issue") ?? []).length > 1) throw usage("move takes one --issue"); + return change("move", { + id: intArg(pos[0], "ID"), to: pos[1], reason: f("--reason"), candidate: f("--candidate"), evidence: f("--evidence"), + issue: flags.has("--issue") ? intArg(flags.get("--issue")[0].replace(/^#/, ""), "--issue") : null, + }); case "release": allow(flags, CHANGE); positional(pos, 1, "release ID"); return change("release", { id: intArg(pos[0], "ID") }); diff --git a/packages/queue/src/lock.mjs b/packages/queue/src/lock.mjs index 56c43f0f..34e88e3b 100644 --- a/packages/queue/src/lock.mjs +++ b/packages/queue/src/lock.mjs @@ -70,6 +70,7 @@ function describe(c) { function publish(io, target, bytes, hook, waitMs, stepMs) { const tmp = `${target}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; let fd; + let st; try { fd = io.openExcl(tmp, 0o600); } catch (err) { @@ -82,6 +83,9 @@ function publish(io, target, bytes, hook, waitMs, stepMs) { fd = null; const back = io.readFile(tmp); if (!back.equals(bytes)) throw Object.assign(new Error("read-back differs from the record"), { code: "EREADBACK" }); + // The link gives the target this inode, so read it before linking: + // nothing that can fail runs between a successful link and the return. + st = io.stat(tmp); } catch (err) { if (fd !== null) { try { io.close(fd); } catch { /* already failing */ } } unlinkQuiet(io, tmp); @@ -99,7 +103,6 @@ function publish(io, target, bytes, hook, waitMs, stepMs) { sleepMs(stepMs); continue; } - const st = io.stat(tmp); return { linked: true, dev: st.dev, ino: st.ino, bytes }; } } finally { @@ -122,8 +125,14 @@ export function acquire({ gitDir, io, proc = realProc, op = null, verb, waitMs = const handle = { path, dev: got.dev, ino: got.ino, bytes: got.bytes }; hook("lock-linked"); const gate = join(gitDir, GATE_NAME); - if (lstatOrNull(io, gate) !== null) { - const c = classify(readOrNull(io, gate), proc); + let c = null; + try { + if (lstatOrNull(io, gate) !== null) c = classify(readOrNull(io, gate), proc); + } catch (err) { + const left = release(handle, io); + throw new QueueError(`cannot check the unlock gate ${gate}: ${errno(err)}; ${left ?? "lock released"}`, 1); + } + if (c !== null) { release(handle, io); throw new QueueError(`unlock gate ${gate} is present (${describe(c)}); check it with \`scripts/mosaic queue unlock --check-gate\``, 2); } @@ -154,18 +163,29 @@ export function unlock({ gitDir, io, proc = realProc, hook = () => {} }) { } const gate = { path: gatePath, dev: got.dev, ino: got.ino, bytes }; hook("gate-held"); + let result; + let failure = null; try { const lockBytes = readOrNull(io, lockPath); - if (lockBytes === null) return "no queue lock present; nothing removed"; - const c = classify(lockBytes, proc); - if (c.state !== "dead" && c.state !== "mismatch") { - throw new QueueError(`queue lock owner is ${describe(c)}; unlock refuses`, 2); + if (lockBytes === null) { + result = "no queue lock present; nothing removed"; + } else { + const c = classify(lockBytes, proc); + if (c.state !== "dead" && c.state !== "mismatch") { + throw new QueueError(`queue lock owner is ${describe(c)}; unlock refuses`, 2); + } + io.unlink(lockPath); + result = `removed queue lock (${describe(c)}): ${lockBytes.toString("utf8").trim()}`; } - io.unlink(lockPath); - return `removed queue lock (${describe(c)}): ${lockBytes.toString("utf8").trim()}`; - } finally { - release(gate, io); + } catch (err) { + failure = err; } + let msg; + try { msg = release(gate, io); } catch (err) { msg = `cannot release the unlock gate (${errno(err)})`; } + // Like the lock, a swapped gate is reported on success and on refusal (8.4). + if (msg && failure instanceof Error) failure.message += `\nwarning: ${msg}`; + if (failure) throw failure; + return msg ? `${result}\nwarning: ${msg}` : result; } export function checkGate({ gitDir, io, proc = realProc }) { diff --git a/packages/queue/src/queue.mjs b/packages/queue/src/queue.mjs index 9999ba18..06e77c17 100644 --- a/packages/queue/src/queue.mjs +++ b/packages/queue/src/queue.mjs @@ -208,7 +208,7 @@ export function validateRow(row) { checkNames(row.reviewers, `${w} reviewers`); if (row.review !== null) { keysExactly(row.review, ["issue", "rounds"], `${w} review`); - if (row.review.issue !== null) checkId(row.review.issue, `${w} review issue`); + checkId(row.review.issue, `${w} review issue`); if (!Array.isArray(row.review.rounds) || row.review.rounds.length === 0) throw refuse(`${w} review needs at least one round`); row.review.rounds.forEach((r, i) => checkRound(r, i + 1)); } @@ -338,6 +338,7 @@ export function canonArgs(verb, a) { reason: nullable(a.reason, (v) => checkText(v, "reason")), candidate: nullable(a.candidate, (v) => checkText(v, "candidate", { max: 300 })), evidence: nullable(a.evidence, (v) => checkText(v, "evidence")), + issue: nullable(a.issue, (v) => checkId(v, "issue")), }; case "release": return { id: checkId(a.id) }; @@ -393,11 +394,30 @@ function afterSatisfied(rows, row) { return missing; } -// `comment=,candidate=`: the J5 evidence before Piece D. +// `comment=,round=,candidate=`: the J5 evidence before Piece D. export function parseReviewEvidence(text) { - const m = /^comment=([1-9][0-9]{0,19}),candidate=([0-9a-f]{40}|[0-9a-f]{64})$/.exec(text ?? ""); - if (!m) throw refuse("in-review to done needs --evidence comment=,candidate= for the current round"); - return { comment: m[1], candidate: m[2] }; + const m = /^comment=([1-9][0-9]{0,19}),round=([1-9][0-9]{0,5}),candidate=([0-9a-f]{40}|[0-9a-f]{64})$/.exec(text ?? ""); + if (!m) throw refuse("in-review to done needs --evidence comment=,round=,candidate= for the current round"); + return { comment: m[1], round: Number(m[2]), candidate: m[3] }; +} + +// The issue a review round posts to (lead decision 23). The row must list +// one; with several, --issue names it. A later round keeps the previous +// round's issue unless --issue names another, and the kept issue must still +// be one of the row's. +function reviewIssue(row, issue) { + const list = row.issues.map((n) => `#${n}`).join(", "); + if (row.issues.length === 0) throw refuse(`row ${row.id} lists no issues; a privileged actor sets one before review`); + if (issue !== null) { + if (!row.issues.includes(issue)) throw refuse(`--issue #${issue} is not one of row ${row.id}'s issues (${list})`); + return issue; + } + if (row.review) { + if (!row.issues.includes(row.review.issue)) throw refuse(`row ${row.id}'s review issue #${row.review.issue} is no longer one of its issues (${list}); name one with --issue`); + return row.review.issue; + } + if (row.issues.length > 1) throw refuse(`row ${row.id} lists several issues (${list}); name the review's issue with --issue`); + return row.issues[0]; } function touch(row, entry) { @@ -424,7 +444,7 @@ function getRow(rows, id) { } function applyMove(rows, row, entry, resolved) { - const { to, reason, candidate, evidence } = entry.args; + const { to, reason, candidate, evidence, issue } = entry.args; const by = entry.by; const from = row.state; const illegal = () => refuse(`row ${row.id}: ${from}→${to} is not a transition`); @@ -432,9 +452,11 @@ function applyMove(rows, row, entry, resolved) { if (reason !== null && to !== "blocked") throw refuse("--reason applies only to a move to blocked"); if (candidate !== null && !(from === "in-progress" && to === "in-review")) throw refuse("--candidate applies only to in-progress→in-review"); if (evidence !== null && to !== "done") throw refuse("--evidence applies only to a move to done"); + if (issue !== null && !(from === "in-progress" && to === "in-review")) throw refuse("--issue applies only to in-progress→in-review"); let next = { ...row, state: to }; let round = null; let cand = null; + let revIssue = null; if (to === "blocked") { if (from === "blocked") throw refuse(`row ${row.id} is already blocked; update the reason with note`); if (!NON_TERMINAL.has(from)) throw illegal(); @@ -457,11 +479,12 @@ function applyMove(rows, row, entry, resolved) { } else if (from === "in-progress" && to === "in-review") { if (row.claim === null || by !== row.claim.seat) throw refuse(`only the claimant (${row.claim?.seat ?? "nobody"}) may request review of row ${row.id}`); if (candidate === null) throw refuse("in-progress→in-review needs --candidate "); + revIssue = reviewIssue(row, issue); cand = checkCandidate(resolved.candidate); const rounds = row.review ? row.review.rounds : []; round = rounds.length + 1; next.review = { - issue: row.review ? row.review.issue : (row.issues[0] ?? null), + issue: revIssue, rounds: [...rounds, { n: round, op: entry.op, by, at: entry.at, candidate: cand, request: "none" }], }; } else if (from === "in-review" && (to === "in-progress" || to === "waiting-on-jason")) { @@ -479,6 +502,7 @@ function applyMove(rows, row, entry, resolved) { const ev = parseReviewEvidence(evidence); const cur = row.review?.rounds.at(-1); if (!cur) throw refuse(`row ${row.id} has no review round to cite`); + if (ev.round !== cur.n) throw refuse(`evidence names round ${ev.round}; row ${row.id} is in round ${cur.n}`); if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate ${ev.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`); round = cur.n; next.claim = null; @@ -491,7 +515,7 @@ function applyMove(rows, row, entry, resolved) { throw illegal(); } next = touch(next, entry); - return { row: next, result: { row: row.id, from, to, round, candidate: cand } }; + return { row: next, result: { row: row.id, from, to, round, issue: revIssue, candidate: cand } }; } function applySet(rows, row, entry, resolved) { @@ -588,7 +612,7 @@ export function applyEntry(state, entry, resolved) { const out = applyMove(rows, row, entry, resolved); rows.set(row.id, out.row); const r = out.result; - result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.from}→${r.to}${r.round ? ` round ${r.round}` : ""}`) }; + result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.from}→${r.to}${r.round ? ` round ${r.round}` : ""}${r.issue ? ` on #${r.issue}` : ""}`) }; break; } case "release": { diff --git a/packages/queue/src/store.mjs b/packages/queue/src/store.mjs index 97f8784c..c3a66b8c 100644 --- a/packages/queue/src/store.mjs +++ b/packages/queue/src/store.mjs @@ -242,7 +242,11 @@ function writeWitness(ctx, loc, doc, bytes) { unlinkQuiet(ctx.io, tmp); throw err; } - ctx.io.fsyncDir(loc.gitDir); + try { + ctx.io.fsyncDir(loc.gitDir); + } catch (err) { + throw Object.assign(new Error(errno(err)), { code: err.code, renamed: true }); + } } function confirmTail(ctx, loc, cur) { @@ -316,6 +320,7 @@ function writeView(ctx, loc, before, parts, body, tag) { const now = readOrNull(ctx.io, loc.viewPath); if (now === null || !now.equals(before)) return stale; const tmp = `${loc.viewPath}.${tag}.tmp`; + let renamed = false; try { const mode = Number(ctx.io.stat(loc.viewPath).mode & 0o777n); unlinkQuiet(ctx.io, tmp); @@ -326,9 +331,11 @@ function writeView(ctx, loc, before, parts, body, tag) { return stale; } ctx.io.rename(tmp, loc.viewPath); + renamed = true; ctx.io.fsyncDir(loc.docsDir); return null; } catch (err) { + if (renamed) return `the view is written but not confirmed durable (${errno(err)}); the op stands; after a host crash, check the table with \`${FIX} verify\``; unlinkQuiet(ctx.io, tmp); return `the view write failed (${errno(err)}); the op stands and the view is stale; run \`${FIX} render\``; } @@ -390,7 +397,8 @@ function commitWrite(ctx, loc, cur, doc, bytes, op, view, body, exclusive) { try { writeWitness(ctx, loc, doc, bytes); } catch (err) { - throw new QueueError(`uncertain ${op} rev ${rev}: durable, witness not updated (${errno(err)})`, 3); + const what = err.renamed ? "witness written, its directory fsync failed" : "witness not updated"; + throw new QueueError(`uncertain ${op} rev ${rev}: durable, ${what} (${errno(err)})`, 3); } ctx.hook("witnessed"); const warn = writeView(ctx, loc, view.bytes, view.parts, body, op); @@ -402,14 +410,19 @@ function withLock(ctx, loc, { op = null, verb }, fn) { checkPlatform(ctx.io, [loc.docsDir, loc.gitDir]); const handle = acquire({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc, op, verb, waitMs: ctx.lockWaitMs, stepMs: ctx.lockStepMs, hook: ctx.hook }); const res = { out: [], err: [], code: 0 }; + let failure = null; try { ctx.hook("locked"); fn(res); - } finally { - let msg; - try { msg = release(handle, ctx.io); } catch (err) { msg = `cannot release the queue lock (${errno(err)})`; } - if (msg) res.err.push(`warning: ${msg}`); + } catch (err) { + failure = err; } + let msg; + try { msg = release(handle, ctx.io); } catch (err) { msg = `cannot release the queue lock (${errno(err)})`; } + // A refusal still reports what release found (8.4). + if (msg && failure instanceof Error) failure.message += `\nwarning: ${msg}`; + else if (msg) res.err.push(`warning: ${msg}`); + if (failure) throw failure; return res; } @@ -763,5 +776,6 @@ export function unlock(opts, { checkGateOnly = false } = {}) { const ctx = makeCtx(opts); const loc = unlockLoc(ctx); if (checkGateOnly) return { out: [checkGate({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc }).line], err: [], code: 0 }; - return { out: [unlockLock({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc, hook: ctx.hook })], err: [], code: 0 }; + const [line, ...warnings] = unlockLock({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc, hook: ctx.hook }).split("\n"); + return { out: [line], err: warnings, code: 0 }; } diff --git a/packages/queue/tests/commit.test.mjs b/packages/queue/tests/commit.test.mjs index 55105163..9a1b6ec1 100644 --- a/packages/queue/tests/commit.test.mjs +++ b/packages/queue/tests/commit.test.mjs @@ -152,7 +152,12 @@ fi`); assert.equal(r.blob("HEAD", "src.txt"), "src\n"); }); -test("F1: a commit whose guard ran before update-ref fails at its own HEAD update", async (t) => { +// A commit paused in its editor after its guard passed against H. Whether +// git holds index.lock during the editor depends on the form: git 2.55 +// doesn't for plain `commit -e` and does for `commit -e -- path`. Step 8 +// reconciles when the lock is free and exits 3 when it isn't; either way +// the paused commit loses at its own HEAD update. +async function pausedCommit(t, form) { const r = ready(t); note(r); stageFile(r, "src.txt"); @@ -161,27 +166,38 @@ test("F1: a commit whose guard ran before update-ref fails at its own HEAD updat const go = join(r.ctl, "editor-go"); const editor = join(r.ctl, "editor.sh"); writeFileSync(editor, `#!/bin/sh\n: > ${q(started)}\nwhile [ ! -e ${q(go)} ]; do sleep 0.05; done\necho "ordinary" > "$1"\n`, { mode: 0o755 }); - const child = spawn("git", ["-C", r.root, "commit", "-e", "-q"], { env: { ...r.env, GIT_EDITOR: editor }, stdio: ["ignore", "pipe", "pipe"] }); + const child = spawn("git", ["-C", r.root, "commit", "-e", "-q", ...form], { env: { ...r.env, GIT_EDITOR: editor }, stdio: ["ignore", "pipe", "pipe"] }); let childErr = ""; child.stderr.on("data", (d) => { childErr += d; }); const exited = new Promise((resolve) => child.on("exit", resolve)); for (let i = 0; i < 200 && !existsSync(started); i++) sleepMs(50); assert.ok(existsSync(started), "the editor never started"); - // The paused commit ran its guard against H and holds index.lock. + const locked = existsSync(join(r.gitDir, "index.lock")); + t.diagnostic(`git commit -e${form.map((a) => ` ${a}`).join("")}: index.lock ${locked ? "held" : "free"} during the editor`); const res = r.qc(["-m", "queue rev 1"]); writeFileSync(go, ""); const code = await exited; - // git 2.55 does not hold index.lock while the editor runs, so step 8 - // reconciles; the paused commit then loses at its HEAD update. - assert.equal(res.code, 0, res.err); + assert.equal(res.code, locked ? 3 : 0, `index.lock ${locked ? "held" : "free"} during the editor: ${res.err}`); + if (locked) assert.match(res.err, /another git process holds \.git\/index\.lock/); const c = r.head(); assert.equal(r.g("rev-parse", "HEAD^").trim(), h); + assert.equal(r.revAt(c), 1); assert.notEqual(code, 0); assert.match(childErr, new RegExp(`cannot lock ref 'HEAD': is at ${c} but expected ${h}`)); assert.equal(r.head(), c, "the old queue landed on top of C"); + if (locked) r.g("reset", "-q", "--", "docs/plans/queue.json", "docs/plans/QUEUE.md"); assert.equal(r.g("diff", "--cached", "--name-only").trim(), "src.txt"); r.g("commit", "-q", "-m", "ordinary"); assert.equal(r.revAt("HEAD"), 1); + return locked; +} + +test("F1: a plain `commit -e` whose guard ran before update-ref fails at its own HEAD update", async (t) => { + await pausedCommit(t, []); +}); + +test("F1: a `commit -e -- path` whose guard ran before update-ref fails at its own HEAD update", async (t) => { + await pausedCommit(t, ["--", "src.txt"]); }); test("F1: step 8 with index.lock held exits 3, and ordinary commits stay refused until the printed command runs", (t) => { diff --git a/packages/queue/tests/data.test.mjs b/packages/queue/tests/data.test.mjs index 2e6c019f..99080d95 100644 --- a/packages/queue/tests/data.test.mjs +++ b/packages/queue/tests/data.test.mjs @@ -3,8 +3,8 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { - CALLER_OP_RE, LOG_OP_RE, applyEntry, buildDoc, canonArgs, classifyView, countHeading, genesisReceipt, genesisRows, - gitBlobId, loadDoc, nextFor, parseManifest, parseMigrationMap, render, rowsArray, serialize, sha256, splitView, + CALLER_OP_RE, LOG_OP_RE, STATES, applyEntry, buildDoc, canonArgs, classifyView, countHeading, genesisReceipt, genesisRows, + gitBlobId, loadDoc, nextFor, parseManifest, parseMigrationMap, render, rowsArray, serialize, sha256, splitView, validateRow, } from "../src/queue.mjs"; import { QueueError } from "../src/errors.mjs"; import { MAP_ROWS, mapText } from "./helpers.mjs"; @@ -49,7 +49,7 @@ function refused(fn, re) { assert.throws(fn, (err) => err instanceof QueueError && err.code === 2 && re.test(err.message)); } -const mv = (id, to, extra = {}) => ({ id, to, reason: null, candidate: null, evidence: null, ...extra }); +const mv = (id, to, extra = {}) => ({ id, to, reason: null, candidate: null, evidence: null, issue: null, ...extra }); const row = (doc, id) => doc.rows.find((r) => r.id === id); const MANIFEST = `${"c".repeat(64)} packages/queue/src/queue.mjs\n`; const CAND = { kind: "manifest", digest: sha256(MANIFEST), text: MANIFEST }; @@ -156,7 +156,7 @@ test("matrix: release, review round, changes requested and waiting-on-jason", () const rv = row(d, 9).review; assert.equal(rv.issue, 1508); assert.deepEqual(rv.rounds.map((r) => [r.n, r.request, r.candidate.digest]), [[1, "none", CAND.digest]]); - assert.match(d.log.at(-1).result.receipt, /in-progress→in-review round 1$/); + assert.match(d.log.at(-1).result.receipt, /in-progress→in-review round 1 on #1508$/); refused(() => step(d, "move", mv(9, "in-progress"), "dewey"), /claimed by darkwing/); d = step(d, "move", mv(9, "in-progress"), "darkwing"); assert.equal(row(d, 9).claim.seat, "darkwing"); @@ -176,9 +176,11 @@ test("matrix: release, review round, changes requested and waiting-on-jason", () test("matrix J5: in-review→done by the gate owner with evidence naming the current round", () => { let d = row9Started(); d = step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND }); - const ev = `comment=4242,candidate=${CAND.digest}`; - refused(() => step(d, "move", mv(9, "done"), "filbert"), /--evidence comment=,candidate=/); - refused(() => step(d, "move", mv(9, "done", { evidence: `comment=1,candidate=${"d".repeat(64)}` }), "filbert"), /is not round 1's candidate/); + const ev = `comment=4242,round=1,candidate=${CAND.digest}`; + refused(() => step(d, "move", mv(9, "done"), "filbert"), /--evidence comment=,round=,candidate=/); + refused(() => step(d, "move", mv(9, "done", { evidence: `comment=4242,candidate=${CAND.digest}` }), "filbert"), /round=/); + refused(() => step(d, "move", mv(9, "done", { evidence: `comment=1,round=1,candidate=${"d".repeat(64)}` }), "filbert"), /is not round 1's candidate/); + refused(() => step(d, "move", mv(9, "done", { evidence: `comment=4242,round=2,candidate=${CAND.digest}` }), "filbert"), /evidence names round 2; row 9 is in round 1/); refused(() => step(d, "move", mv(9, "done", { evidence: ev }), "rocko"), /only the gate owner \(filbert\)/); const done = step(d, "move", mv(9, "done", { evidence: ev }), "filbert"); assert.equal(row(done, 9).state, "done"); @@ -187,6 +189,155 @@ test("matrix J5: in-review→done by the gate owner with evidence naming the cur let e = step(genesisDoc(), "move", mv(11, "in-progress"), "dewey"); e = step(e, "move", mv(11, "in-review", { candidate: "x" }), "dewey", { candidate: CAND }); refused(() => step(e, "move", mv(11, "done", { evidence: ev }), "jason"), /gate is Jason's/); + // Changes requested, then the same candidate again: round 1's comment + // does not close round 2 (8.7, R3). + d = step(d, "move", mv(9, "in-progress"), "darkwing"); + d = step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND }); + assert.deepEqual(row(d, 9).review.rounds.map((r) => r.candidate.digest), [CAND.digest, CAND.digest]); + refused(() => step(d, "move", mv(9, "done", { evidence: ev }), "filbert"), /evidence names round 1; row 9 is in round 2/); + const done2 = step(d, "move", mv(9, "done", { evidence: `comment=4343,round=2,candidate=${CAND.digest}` }), "filbert"); + assert.equal(done2.log.at(-1).result.round, 2); +}); + +// A row 12 owned by darkwing with the given issues, started, so the next +// move is the review request. +function row12Started(issues, { gateOwner = "filbert" } = {}) { + let d = step(genesisDoc(), "add", { piece: "N", gate: "g", brief: "docs/plans/brief-b.md#Queue", issues, owner: "darkwing", gateOwner }, "sage", { brief: brief() }); + d = step(d, "move", mv(12, "briefed"), "sage"); + return step(d, "move", mv(12, "in-progress"), "darkwing"); +} + +const review = (d, extra = {}) => step(d, "move", mv(12, "in-review", { candidate: "x", ...extra }), "darkwing", { candidate: CAND }); +const again = (d) => step(d, "move", mv(12, "in-progress"), "darkwing"); + +test("review issue, lead decision 23: none refuses, one is used, several need --issue, later rounds keep it", () => { + // No issues: refused before the round opens. + refused(() => review(row12Started([])), /row 12 lists no issues; a privileged actor sets one before review/); + // One issue: used without --issue; --issue may name it; any other refuses. + const one = review(row12Started([1508])); + assert.equal(row(one, 12).review.issue, 1508); + assert.match(one.log.at(-1).result.receipt, /round 1 on #1508$/); + assert.equal(one.log.at(-1).result.issue, 1508); + refused(() => review(row12Started([1508]), { issue: 1495 }), /--issue #1495 is not one of row 12's issues \(#1508\)/); + // Several: --issue is required and must be one of the row's; the lowest + // number is not a default. + const several = row12Started([1495, 1508]); + refused(() => review(several), /row 12 lists several issues \(#1495, #1508\); name the review's issue with --issue/); + refused(() => review(several, { issue: 1600 }), /--issue #1600 is not one of row 12's issues \(#1495, #1508\)/); + let d = review(several, { issue: 1508 }); + assert.equal(row(d, 12).review.issue, 1508); + // Later rounds keep the previous round's issue unless --issue names another. + d = review(again(d)); + assert.deepEqual([row(d, 12).review.issue, row(d, 12).review.rounds.length], [1508, 2]); + d = review(again(d), { issue: 1495 }); + assert.deepEqual([row(d, 12).review.issue, row(d, 12).review.rounds.length], [1495, 3]); + // A kept issue the row no longer lists refuses until --issue names one. + d = step(again(d), "set", { id: 12, field: "issues", value: [1508, 1600] }, "sage"); + refused(() => review(d), /review issue #1495 is no longer one of its issues \(#1508, #1600\); name one with --issue/); + assert.equal(row(review(d, { issue: 1600 }), 12).review.issue, 1600); + // --issue belongs to the review request only. + refused(() => step(several, "move", mv(12, "blocked", { reason: "x", issue: 1508 }), "darkwing"), /--issue applies only to in-progress→in-review/); +}); + +test("the row schema refuses a review with a null issue", () => { + const r = structuredClone(row(review(row12Started([1508])), 12)); + validateRow(r); + r.review.issue = null; + refused(() => validateRow(r), /review issue must be a positive integer/); +}); + +// R1: every state × target × actor class against 8.7's table, written from +// the spec rather than from queue.mjs. Row 12 is owned by darkwing; the +// gate owner is filbert, jason or the owner; rocko is any other seat. +const ACTORS = ["darkwing", "filbert", "rocko", "sage", "jason"]; +const PRIV = new Set(["sage", "jason"]); + +function specAllows({ from, prev, to, by, gateOwner, required }) { + const own = by === "darkwing"; + const priv = PRIV.has(by); + if (from === "done") return false; + if (to === "blocked") return ["queued", "briefed", "in-progress", "in-review", "waiting-on-jason"].includes(from) && (own || priv); + if (from === "blocked") return to === prev && (own || priv); + const edge = `${from}→${to}`; + switch (edge) { + case "queued→briefed": return priv; + case "briefed→in-progress": return own; + case "in-progress→in-review": return own; // the claimant is the owner + case "in-review→in-progress": case "in-review→waiting-on-jason": return own || priv; + case "waiting-on-jason→done": return by === "jason" || by === "sage"; // sage with evidence, which the case supplies + case "in-review→done": return gateOwner !== "jason" && (by === gateOwner || priv); + case "queued→parked": case "briefed→parked": return by === "jason" && !required; + case "parked→queued": return by === "jason"; + default: return false; // in-progress→briefed is `release`, not `move` + } +} + +function matrixStates(gateOwner, required) { + let q = step(genesisDoc(), "add", { + piece: "M", gate: "g", brief: "docs/plans/brief-b.md#Queue", issues: [1508], owner: "darkwing", gateOwner, required, + }, "sage", { brief: brief() }); + const out = [{ from: "queued", doc: q }]; + if (!required) out.push({ from: "parked", doc: step(q, "move", mv(12, "parked"), "jason") }); + const b = step(q, "move", mv(12, "briefed"), "sage"); + const p = step(b, "move", mv(12, "in-progress"), "darkwing"); + const r = review(p); + const w = step(r, "move", mv(12, "waiting-on-jason"), "sage"); + out.push({ from: "briefed", doc: b }, { from: "in-progress", doc: p }, { from: "in-review", doc: r }, { from: "waiting-on-jason", doc: w }); + out.push({ from: "done", doc: step(w, "move", mv(12, "done"), "jason") }); + for (const s of [...out]) { + if (!["done", "parked"].includes(s.from)) out.push({ from: "blocked", prev: s.from, doc: step(s.doc, "move", mv(12, "blocked", { reason: "r" }), "sage") }); + } + return out; +} + +function matrixArgs(from, to) { + const extra = {}; + if (to === "blocked") extra.reason = "r"; + if (from === "in-progress" && to === "in-review") extra.candidate = "x"; + if (to === "done" && from === "in-review") extra.evidence = `comment=1,round=1,candidate=${CAND.digest}`; + if (to === "done" && from === "waiting-on-jason") extra.evidence = "Jason approved in thread X"; + return mv(12, to, extra); +} + +test("matrix R1: every state × target × actor class matches 8.7, gate owner jason or not, required or not", () => { + let allowed = 0; + let refusals = 0; + for (const gateOwner of ["filbert", "jason", "darkwing"]) { + for (const required of [false, true]) { + for (const { from, prev = null, doc } of matrixStates(gateOwner, required)) { + assert.equal(row(doc, 12).state, from); + for (const to of STATES) { + for (const by of ACTORS) { + const c = { from, prev, to, by, gateOwner, required }; + const label = JSON.stringify(c); + let got; + try { + got = step(doc, "move", matrixArgs(from, to), by, { candidate: CAND }); + } catch (err) { + assert.ok(err instanceof QueueError && err.code === 2, `${label}: ${err.stack}`); + assert.equal(specAllows(c), false, `${label} refused: ${err.message}`); + refusals++; + continue; + } + assert.equal(specAllows(c), true, `${label} was allowed`); + const r = row(got, 12); + assert.equal(r.state, to, label); + if (to === "blocked") assert.equal(r.previousState, from, label); + if (from === "blocked") assert.deepEqual([r.previousState, r.blockedReason], [null, null], label); + allowed++; + } + } + // release: the claimant or a privileged actor, from in-progress only. + for (const by of ACTORS) { + const ok = from === "in-progress" && (by === "darkwing" || PRIV.has(by)); + const label = JSON.stringify({ release: from, by, gateOwner, required }); + if (ok) assert.deepEqual([row(step(doc, "release", { id: 12 }, by), 12).state], ["briefed"], label); + else assert.throws(() => step(doc, "release", { id: 12 }, by), (err) => err instanceof QueueError && err.code === 2, label); + } + } + } + } + assert.ok(allowed > 100 && refusals > 1000, `allowed ${allowed}, refused ${refusals}`); }); test("matrix: blocked keeps the claim and returns only to previousState", () => { @@ -301,7 +452,7 @@ test("next: resume, then review, then start, then wait, then nothing; lowest id let d = genesisDoc(); const add = (owner, extra = {}) => ({ piece: `p-${owner}`, gate: "g", brief: "docs/plans/brief-b.md#Queue", owner, ...extra }); d = step(d, "add", add("rocko"), "sage", { brief: brief() }); // 12 - d = step(d, "add", add("rocko", { reviewers: ["darkwing"] }), "sage", { brief: brief() }); // 13 + d = step(d, "add", add("rocko", { reviewers: ["darkwing"], issues: [1508] }), "sage", { brief: brief() }); // 13 d = step(d, "add", add("darkwing"), "sage", { brief: brief() }); // 14 for (const id of [12, 13, 14]) d = step(d, "move", mv(id, "briefed"), "sage"); const rows = () => loadDoc(Buffer.from(serialize(d))).state.rows; diff --git a/packages/queue/tests/lock.test.mjs b/packages/queue/tests/lock.test.mjs index 9b6b2174..f1b109d9 100644 --- a/packages/queue/tests/lock.test.mjs +++ b/packages/queue/tests/lock.test.mjs @@ -89,6 +89,26 @@ test("a link error other than EEXIST refuses", (t) => { assert.deepEqual(readdirSync(d), []); }); +test("an error after the link releases the lock: unreadable gate, failing temp stat", (t) => { + const d = dir(t); + writeFileSync(join(d, GATE_NAME), record({ verb: "unlock", op: null })); + const denied = { ...realIo, readFile: (p) => (p.endsWith(GATE_NAME) ? (() => { throw Object.assign(new Error("denied"), { code: "EACCES" }); })() : realIo.readFile(p)) }; + refused(() => acquire({ gitDir: d, io: denied, verb: "move" }), /cannot check the unlock gate .*EACCES; lock released$/, 1); + assert.deepEqual(readdirSync(d), [GATE_NAME]); + rmSync(join(d, GATE_NAME)); + const badStat = { ...realIo, stat: () => { throw Object.assign(new Error("io"), { code: "EIO" }); } }; + refused(() => acquire({ gitDir: d, io: badStat, verb: "move" }), /cannot write the lock record .*EIO; no lock taken/, 1); + assert.deepEqual(readdirSync(d), []); + // A stat that fails from its second call on: publish stats once, before the + // link, so nothing after the link can fail and strand the lock. + let stats = 0; + const lateStat = { ...realIo, stat: (p) => { if (++stats > 1) throw Object.assign(new Error("io"), { code: "EIO" }); return realIo.stat(p); } }; + const h = acquire({ gitDir: d, io: lateStat, verb: "move" }); + assert.equal(stats, 1); + assert.equal(release(h, realIo), null); + assert.deepEqual(readdirSync(d), []); +}); + test("a paused holder: another writer waits 10 s, then refuses naming it live", async (t) => { const d = dir(t); const child = spawn(process.execPath, [join(HERE, "fixtures", "lock-child.mjs"), d, "hold"], { stdio: ["ignore", "pipe", "ignore"] }); @@ -150,6 +170,22 @@ test("a writer publishing during an unlock, gate first: the writer releases and assert.deepEqual(readdirSync(d), []); }); +test("a gate swapped while held is left in place and reported, on success and on refusal (N1)", (t) => { + const d = dir(t); + const gate = join(d, GATE_NAME); + // A copy renamed over the gate: same bytes, a new inode. + const swap = () => { writeFileSync(`${gate}.copy`, readFileSync(gate)); renameSync(`${gate}.copy`, gate); }; + const out = unlock({ gitDir: d, io: realIo, hook: (name) => { if (name === "gate-held") swap(); } }); + assert.match(out, /^no queue lock present; nothing removed\nwarning: lock .*mosaic-queue\.unlock is not the one this process took; left in place$/); + assert.equal(existsSync(gate), true); + rmSync(gate); + writeFileSync(join(d, LOCK_NAME), record({})); + refused(() => unlock({ gitDir: d, io: realIo, hook: (name) => { if (name === "gate-held") swap(); } }), + /owner is live: .*; unlock refuses\nwarning: lock .*mosaic-queue\.unlock is not the one this process took; left in place$/); + assert.equal(existsSync(gate), true); + assert.equal(existsSync(join(d, LOCK_NAME)), true); +}); + test("a reused pid within one boot is mismatch; unlock removes the lock and never signals the process", async (t) => { const d = dir(t); const s = await sleeper(t); @@ -177,6 +213,12 @@ test("a foreign host is unknown whatever the local pid says; unlock refuses", as refused(() => acquire({ gitDir: d, io: realIo, verb: "move", waitMs: 0 }), /unknown: .*recorded on host some-other-host; unlock refuses this too/); refused(() => unlock({ gitDir: d, io: realIo }), /owner is unknown: .*; unlock refuses/); assert.equal(existsSync(join(d, LOCK_NAME)), true); + // A real foreign host has its own boot id. Host is tested before boot, so + // this is still unknown, never mismatch, and unlock still refuses. + writeFileSync(join(d, LOCK_NAME), record({ pid: await deadPid(), host: "some-other-host", boot: OTHER_BOOT })); + assert.equal(classify(readFileSync(join(d, LOCK_NAME)), realProc).state, "unknown"); + refused(() => unlock({ gitDir: d, io: realIo }), /owner is unknown: .*recorded on host some-other-host/); + assert.equal(existsSync(join(d, LOCK_NAME)), true); }); test("unreadable /proc: classification is unknown and acquire refuses", (t) => { diff --git a/packages/queue/tests/store.test.mjs b/packages/queue/tests/store.test.mjs index 7033b076..70425a73 100644 --- a/packages/queue/tests/store.test.mjs +++ b/packages/queue/tests/store.test.mjs @@ -166,10 +166,28 @@ test("Rocko's S4 schedule: a lost result, another writer, then the retry opens n const review = ["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00001"]; cli(repo, review, { by: "darkwing" }); // result lost ok(cli(repo, ["note", "9", "looking now", "--op", "note-9-000001"], { by: "filbert" })); - ok(cli(repo, review, { by: "darkwing" }), /round 1 \(already recorded at rev 3\)/); + ok(cli(repo, review, { by: "darkwing" }), /round 1 on #1508 \(already recorded at rev 3\)/); assert.equal(row(repo, 9).review.rounds.length, 1); }); +test("the review issue and the evidence round through the CLI (lead decision 23, 8.7)", (t) => { + const repo = ready(t); + ok(cli(repo, ["move", "6", "blocked", "--reason", "paused", "--op", "block-6-00001"], { by: "darkwing" })); + ok(cli(repo, ["set", "9", "issues", "1495,1508", "--op", "issues-9-0001"], { by: "sage" })); + ok(cli(repo, ["move", "9", "in-progress", "--op", "start-9-00001"], { by: "darkwing" })); + const review = (op, ...extra) => ["move", "9", "in-review", "--candidate", "HEAD", "--op", op, ...extra]; + no(cli(repo, review("review-9-00001"), { by: "darkwing" }), 2, /lists several issues \(#1495, #1508\); name the review's issue with --issue/); + no(cli(repo, review("review-9-00001", "--issue", "1495", "--issue", "1508"), { by: "darkwing" }), 4, /one --issue/); + no(cli(repo, review("review-9-00001", "--issue", "#1600"), { by: "darkwing" }), 2, /--issue #1600 is not one of row 9's issues/); + ok(cli(repo, review("review-9-00001", "--issue", "#1508"), { by: "darkwing" }), /in-progress→in-review round 1 on #1508$/m); + const head = repo.g("rev-parse", "HEAD").trim(); + no(cli(repo, ["move", "9", "done", "--evidence", `comment=7,candidate=${head}`, "--op", "done-9-000001"], { by: "filbert" }), 2, /round=/); + ok(cli(repo, ["move", "9", "in-progress", "--op", "changes-9-0001"], { by: "darkwing" })); + ok(cli(repo, review("review-9-00002"), { by: "darkwing" }), /round 2 on #1508$/m); + no(cli(repo, ["move", "9", "done", "--evidence", `comment=7,round=1,candidate=${head}`, "--op", "done-9-000001"], { by: "filbert" }), 2, /evidence names round 1; row 9 is in round 2/); + ok(cli(repo, ["move", "9", "done", "--evidence", `comment=8,round=2,candidate=${head}`, "--op", "done-9-000002"], { by: "filbert" }), /in-review→done round 2$/m); +}); + test("claims and add defaults through the CLI; candidates are manifests or reachable commits", (t) => { const repo = ready(t); ok(cli(repo, ["add", "--op", "add-by-dewey-1", "--piece", "Mine", "--gate", "tests", "--brief", "docs/plans/brief-b.md#Template"], { by: "dewey" })); diff --git a/packages/queue/tests/write.test.mjs b/packages/queue/tests/write.test.mjs index 0d0781c0..35e9829a 100644 --- a/packages/queue/tests/write.test.mjs +++ b/packages/queue/tests/write.test.mjs @@ -3,7 +3,7 @@ // racing a writer (8.4, F2). import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; -import { readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { test } from "node:test"; import { cli, genesisCommitted, load, scratchRepo } from "./helpers.mjs"; @@ -41,6 +41,7 @@ function faultIo(m, name, match, code) { return { ...real, openExcl: (p, mode) => { const fd = real.openExcl(p, mode); paths.set(fd, p); return fd; }, + openRead: (p) => { const fd = real.openRead(p); paths.set(fd, p); return fd; }, close: (fd) => { paths.delete(fd); real.close(fd); }, write: (fd, b, off, len) => (hit("write", paths.get(fd)) ? (code === "SHORT" ? 0 : fail()) : real.write(fd, b, off, len)), fsync: (fd) => (hit("fsync", paths.get(fd)) ? fail() : real.fsync(fd)), @@ -114,6 +115,65 @@ test("a witness write failure: uncertain, durable, exit 3; the view is untouched assert.match(m.store.mutate(o(repo), note(9, "x", "note-9-000001")).out[0], /already recorded at rev 1/); }); +test("the .git fsync after the witness rename fails: uncertain, exit 3, the witness says so", async (t) => { + const { repo, m } = await ready(t); + const io = faultIo(m, "fsyncDir", (d) => d === repo.gitDir, "EIO"); + throwsCode(() => m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001")), 3, /^uncertain note-9-000001 rev 1: durable, witness written, its directory fsync failed \(EIO\)$/); + assert.equal(revOf(repo), 1); + assert.equal(witness(repo).revision, 1); + assert.equal(shownRev(repo), 0); + assert.match(m.store.mutate(o(repo), note(9, "x", "note-9-000001")).out[0], /already recorded at rev 1/); +}); + +test("confirming a tail fsyncs queue.json and docs/plans before the witness; either failure changes nothing", async (t) => { + const { repo, m } = await ready(t); + const docs = join(repo.root, "docs/plans"); + throwsCode(() => m.store.mutate(o(repo, { io: faultIo(m, "fsyncDir", (d) => d === docs, "EIO") }), note(9, "x", "note-9-000001")), 3, /uncertain/); + for (const io of [faultIo(m, "fsync", (p) => p === repo.queuePath, "EIO"), faultIo(m, "fsyncDir", (d) => d === docs, "EIO")]) { + throwsCode(() => m.store.sync(o(repo, { io })), 1, /^cannot confirm rev 1 durable \(EIO\); nothing changed$/); + assert.equal(witness(repo).revision, 0); + } + assert.match(m.store.sync(o(repo)).out.join("\n"), /durable now, never acknowledged: note-9-000001/); + assert.equal(witness(repo).revision, 1); +}); + +test("the docs/plans fsync after the view rename fails: the op stands, the view is written, a warning says so", async (t) => { + const { repo, m } = await ready(t); + const docs = join(repo.root, "docs/plans"); + let calls = 0; + const io = { ...m.io.realIo, fsyncDir: (d) => { if (d === docs && ++calls === 2) throw Object.assign(new Error("EIO"), { code: "EIO" }); m.io.realIo.fsyncDir(d); } }; + const r = m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001")); + assert.equal(calls, 2); + assert.match(r.out[0], /^ok note-9-000001 rev 1/); + assert.match(r.err.join("\n"), /the view is written but not confirmed durable \(EIO\); the op stands/); + assert.equal(shownRev(repo), 1); + assert.deepEqual(tmps(repo), []); +}); + +test("a lock swapped while held is left in place and reported, on a receipt and on a refusal", async (t) => { + const { repo, m } = await ready(t); + const lock = join(repo.gitDir, "mosaic-queue.lock"); + // Another inode with the same bytes, as a delayed unlock and relock would leave. + const swap = (name) => { if (name === "locked") { writeFileSync(`${lock}.copy`, readFileSync(lock)); renameSync(`${lock}.copy`, lock); } }; + const done = m.store.mutate(o(repo, { hook: swap }), note(9, "x", "note-9-000001")); + assert.match(done.out[0], /^ok note-9-000001 rev 1/); + assert.match(done.err.join("\n"), /warning: lock .* is not the one this process took; left in place/); + unlinkSync(lock); + throwsCode(() => m.store.mutate(o(repo, { hook: swap }), note(9, "y", "note-9-000002", "rocko")), 2, + /may note row 9[^]*\nwarning: lock .* is not the one this process took; left in place$/); + unlinkSync(lock); +}); + +test("unlock prints a swapped gate's warning on stderr, the result on stdout", async (t) => { + const { repo, m } = await ready(t); + const gate = join(repo.gitDir, "mosaic-queue.unlock"); + const swap = (name) => { if (name === "gate-held") { writeFileSync(`${gate}.copy`, readFileSync(gate)); renameSync(`${gate}.copy`, gate); } }; + const r = m.store.unlock(o(repo, { hook: swap })); + assert.deepEqual(r.out, ["no queue lock present; nothing removed"]); + assert.match(r.err.join("\n"), /^warning: lock .*mosaic-queue\.unlock is not the one this process took; left in place$/); + unlinkSync(gate); +}); + test("a view write that fails keeps the op and reports a stale view", async (t) => { const { repo, m } = await ready(t); const io = faultIo(m, "rename", (p) => p === repo.viewPath, "EIO");