fix: serialize P1 manual lifecycle root
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -135,6 +135,83 @@ async function listenerPids() {
|
||||
}
|
||||
}
|
||||
|
||||
test("prepare and cleanup share one external lifecycle lock for the whole transaction", { concurrency: false }, async () => {
|
||||
const repo=await fakeRepo(),realGit=(await execFileAsync("which",["git"])).stdout.trim();
|
||||
const bin=join(repo,"blocking-bin"),entered=join(repo,"prepare-entered"),release=join(repo,"prepare-release");
|
||||
await mkdir(bin); await writeFile(join(bin,"git"),`#!/bin/sh
|
||||
if [ "$1" = init ] && [ ! -e ${JSON.stringify(entered)} ]; then
|
||||
: > ${JSON.stringify(entered)}
|
||||
n=0
|
||||
while [ ! -e ${JSON.stringify(release)} ] && [ "$n" -lt 250 ]; do sleep 0.02; n=$((n + 1)); done
|
||||
[ -e ${JSON.stringify(release)} ] || exit 99
|
||||
fi
|
||||
exec ${JSON.stringify(realGit)} "$@"
|
||||
`,{mode:0o700});
|
||||
const priorPath=process.env.PATH; process.env.PATH=`${bin}:${priorPath}`;
|
||||
try {
|
||||
const preparing=prepareManual({repositoryRoot:repo,skipBuild:true});
|
||||
for(let n=0;n<200;n++){try{await lstat(entered);break;}catch{} await new Promise(r=>setTimeout(r,10));}
|
||||
await lstat(entered);
|
||||
const lock=join(repo,".artifacts/manual-acceptance/.p1.lifecycle.lock");
|
||||
const lockEntry=await lstat(lock); assert.equal(lockEntry.isFile(),true); assert.equal(lockEntry.mode&0o777,0o600);
|
||||
const lockBytes=await readFile(lock,"utf8"),lockValue=JSON.parse(lockBytes);
|
||||
assert.deepEqual(Object.keys(lockValue).sort(),["kind","lifecycleNonce","operation","repositoryRoot","root","schemaVersion"].sort());
|
||||
assert.equal(lockValue.kind,"p1-manual-lifecycle"); assert.equal(lockValue.operation,"prepare");
|
||||
assert.match(lockValue.lifecycleNonce,/^[0-9a-f]{64}$/); assert.equal(lockValue.repositoryRoot,repo); assert.equal(lockValue.root,fixedManualRoot(repo));
|
||||
assert.equal(lockBytes,`${JSON.stringify(lockValue,null,2)}\n`);
|
||||
await assert.rejects(cleanupManual({repositoryRoot:repo}),/lifecycle lock.*exists|operator inspection/i);
|
||||
await writeFile(release,"go"); const run=await preparing;
|
||||
await readManualOwnership({repositoryRoot:repo}); await assert.rejects(lstat(lock));
|
||||
await cleanupManual({repositoryRoot:repo}); await assert.rejects(lstat(run.root));
|
||||
} finally { process.env.PATH=priorPath; await writeFile(release,"go").catch(()=>{}); }
|
||||
});
|
||||
|
||||
test("the external lifecycle lock prevents old-root/new-root ABA and ownership is read only under lock", async () => {
|
||||
const source=await readFile(new URL("./p1-manual-acceptance.mjs",import.meta.url),"utf8");
|
||||
assert.match(source,/\.artifacts["'],["']manual-acceptance["'],["']\.p1\.lifecycle\.lock/);
|
||||
const repo=await fakeRepo(),run=await prepareManual({repositoryRoot:repo,skipBuild:true});
|
||||
const lockPath=join(repo,".artifacts/manual-acceptance/.p1.lifecycle.lock"),nonce="f".repeat(64),bytes=`${nonce}\n`;
|
||||
const handle=await open(lockPath,"wx",0o600); await handle.writeFile(bytes); await handle.sync();
|
||||
try {
|
||||
const old=JSON.parse(await readFile(join(run.root,"ownership.json"),"utf8"));
|
||||
await rm(run.root,{recursive:true}); await mkdir(run.root,{recursive:true});
|
||||
await writeFile(join(run.root,"ownership.json"),JSON.stringify({...old,nonce:"e".repeat(64)}),{mode:0o600});
|
||||
for(const operation of [serveManual,stopManual,cleanupManual]){
|
||||
await assert.rejects(operation({repositoryRoot:repo}),/lifecycle lock.*exists|operator inspection/i);
|
||||
assert.equal((await lstat(run.root)).isDirectory(),true);
|
||||
}
|
||||
} finally { await handle.close(); await rm(lockPath,{force:true}); }
|
||||
});
|
||||
|
||||
test("external lifecycle lock release preserves an exact-byte inode replacement", { concurrency: false }, async () => {
|
||||
const repo=await fakeRepo(),realGit=(await execFileAsync("which",["git"])).stdout.trim();
|
||||
const bin=join(repo,"replacement-bin"),entered=join(repo,"replacement-entered"),release=join(repo,"replacement-release");
|
||||
await mkdir(bin); await writeFile(join(bin,"git"),`#!/bin/sh
|
||||
if [ "$1" = init ] && [ ! -e ${JSON.stringify(entered)} ]; then
|
||||
: > ${JSON.stringify(entered)}
|
||||
n=0
|
||||
while [ ! -e ${JSON.stringify(release)} ] && [ "$n" -lt 250 ]; do sleep 0.02; n=$((n + 1)); done
|
||||
[ -e ${JSON.stringify(release)} ] || exit 99
|
||||
fi
|
||||
exec ${JSON.stringify(realGit)} "$@"
|
||||
`,{mode:0o700});
|
||||
const priorPath=process.env.PATH; process.env.PATH=`${bin}:${priorPath}`; let preparing;
|
||||
try {
|
||||
preparing=prepareManual({repositoryRoot:repo,skipBuild:true});
|
||||
for(let n=0;n<200;n++){try{await lstat(entered);break;}catch{} await new Promise(r=>setTimeout(r,10));}
|
||||
await lstat(entered);
|
||||
const lock=join(repo,".artifacts/manual-acceptance/.p1.lifecycle.lock"),bytes=await readFile(lock);
|
||||
const original=await lstat(lock),replacement=join(dirname(lock),".replacement-lifecycle-lock");
|
||||
await writeFile(replacement,bytes,{mode:0o600}); const replacementEntry=await lstat(replacement);
|
||||
assert.notEqual(replacementEntry.ino,original.ino); await rename(replacement,lock); await writeFile(release,"go");
|
||||
await assert.rejects(preparing,/lifecycle record.*unsafe|lifecycle record.*changed|operator inspection/i); preparing=undefined;
|
||||
const retained=await lstat(lock); assert.equal(retained.dev,replacementEntry.dev); assert.equal(retained.ino,replacementEntry.ino);
|
||||
assert.deepEqual(await readFile(lock),bytes);
|
||||
} finally {
|
||||
process.env.PATH=priorPath; await writeFile(release,"go").catch(()=>{}); if(preparing)await preparing.catch(()=>{});
|
||||
}
|
||||
});
|
||||
|
||||
// A delayed real listener leaves the pre-fix port-check/spawn window open long enough for every
|
||||
// overlapping call. The backend.pid reservation, rather than scheduler timing, must pick one owner.
|
||||
test("concurrent serves reserve one exact process and leave no orphan after stop", { concurrency: false }, async () => {
|
||||
|
||||
Reference in New Issue
Block a user