Skip to content
Open
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
9 changes: 5 additions & 4 deletions extensions/shared/setup-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
writeFile,
} from "node:fs/promises";
import { basename, join } from "node:path";
import { performance } from "node:perf_hooks";
import { isDeepStrictEqual } from "node:util";
import { Worker } from "node:worker_threads";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
Expand Down Expand Up @@ -1405,7 +1406,7 @@ const lockTimeoutError = () =>
);

function waitForSetupConfigLock(deadline: number) {
const remaining = deadline - Date.now();
const remaining = deadline - performance.now();
if (remaining <= 0) return Promise.resolve();

return new Promise<void>((resolve, reject) => {
Expand Down Expand Up @@ -1578,7 +1579,7 @@ async function withSetupConfigLock<A>(action: () => Promise<A>) {
flag: "wx",
mode: 0o600,
});
const started = Date.now();
const started = performance.now();
const patienceDeadline = started + SETUP_CONFIG_LOCK_TIMEOUT_MS;
const hardDeadline = patienceDeadline + SETUP_CONFIG_LOCK_STALE_MS;
let acquired = false;
Expand All @@ -1596,7 +1597,7 @@ async function withSetupConfigLock<A>(action: () => Promise<A>) {
if (!isErrno(error, "EEXIST")) throw error;
if (await recoverStaleSetupConfigLock()) continue;

const now = Date.now();
const now = performance.now();
if (now >= hardDeadline) throw lockTimeoutError();
const lockRead = await readLockOwner(SETUP_CONFIG_LOCK_PATH);
if (lockRead.kind === "missing") continue;
Expand All @@ -1619,7 +1620,7 @@ async function withSetupConfigLock<A>(action: () => Promise<A>) {
// A quiet owner keeps the lease it already published. A holder
// that refreshed during this wait is still working. A stale lock
// we could not prove from its companion stops here too.
if (holderStillRefreshing || isStaleLock(mtimeMs, now)) {
if (holderStillRefreshing || isStaleLock(mtimeMs)) {
throw lockTimeoutError();
}
}
Expand Down
60 changes: 60 additions & 0 deletions tests/extensions/shared/setup-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,66 @@ process.stdout.write("preserved\\n");
await Promise.all(runs);
});

test("setup-config lock deadlines use monotonic time", async () => {
const source = `
const { performance } = await import("node:perf_hooks");
const { join } = await import("node:path");
const { readFileSync, rmSync, writeFileSync } = await import("node:fs");
const { updateSetupConfig } = await import(process.env.SETUP_CONFIG_MODULE_URL);
const agentDir = process.env.PI_CODING_AGENT_DIR;
const lockPath = join(agentDir, "my-pi-setup.json.lock");
const lockBody = "not trustworthy\\n";
writeFileSync(lockPath, lockBody);
const realDateNow = Date.now;
const realPerformanceNow = performance.now;
const realSetTimeout = globalThis.setTimeout;
const wallStart = realDateNow();
const wallJump = Number(process.env.SETUP_CONFIG_WALL_CLOCK_JUMP);
let elapsed = 0;
Date.now = () => wallStart + elapsed + (elapsed > 0 ? wallJump : 0);
performance.now = () => 1000 + elapsed;
globalThis.setTimeout = (callback, delay, ...args) => {
if (delay <= 500) {
elapsed += delay;
if (elapsed > 5000) throw new Error("lock deadline did not use monotonic time");
return realSetTimeout(callback, 0, ...args);
}
return realSetTimeout(callback, delay, ...args);
};
let mutated = false;
try {
try {
await updateSetupConfig((current) => {
mutated = true;
return current;
});
throw new Error("an uncertain lock was ignored");
} catch (error) {
if (!(error instanceof Error) || !error.message.includes("Timed out waiting")) throw error;
}
if (elapsed !== 5000) throw new Error("lock deadline changed with wall time: " + elapsed);
if (mutated) throw new Error("config changed under an uncertain lock");
if (readFileSync(lockPath, "utf8") !== lockBody) throw new Error("uncertain lock was changed");
} finally {
Date.now = realDateNow;
performance.now = realPerformanceNow;
globalThis.setTimeout = realSetTimeout;
rmSync(agentDir, { recursive: true, force: true });
}
process.stdout.write("preserved\\n");
`;

const runs = [0, 60_000, -60_000].map((jump) =>
runSetupConfigChildScenario({
source,
tempPrefix: `my-pi-setup-config-clock-${jump}-`,
env: { SETUP_CONFIG_WALL_CLOCK_JUMP: String(jump) },
failureLabel: `setup-config lock deadline with wall-clock jump ${jump}`,
}),
);
await Promise.all(runs);
});

test("a stored value that had to be normalized is reported, not hidden", async () => {
writeFileSync(
SETUP_CONFIG_PATH,
Expand Down
Loading