Skip to content
Merged
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: 8 additions & 1 deletion src/audit/audit-journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,18 +683,25 @@ async function inspectLocalLockPort(port: number, key: string): Promise<LocalLoc
const socket = connect({ host: "127.0.0.1", port });
let settled = false;
let response = "";
let timeoutImmediate: ReturnType<typeof setImmediate> | undefined;
const settle = (state: LocalLockPortState): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (timeoutImmediate !== undefined) clearImmediate(timeoutImmediate);
socket.destroy();
resolve(state);
};
// An interrupted or incomplete probe may be a holder that is releasing or
// acquiring this journal rather than an unrelated listener. Treat it as
// unknown so a contender retries this candidate instead of selecting a
// different port and bypassing the same lock.
const timeout = setTimeout(() => settle("unknown"), localLockProbeMilliseconds);
const timeout = setTimeout(() => {
// Under host scheduling pressure, a local connection result can already
// be queued when the timer phase runs. Give that result one check phase
// to settle before treating the holder as incomplete and failing closed.
timeoutImmediate = setImmediate(() => settle("unknown"));
}, localLockProbeMilliseconds);
socket.setEncoding("utf8");
socket.on("data", (chunk: string) => {
response += chunk;
Expand Down
60 changes: 60 additions & 0 deletions tests/audit-integrity.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { EventEmitter } from "node:events";
import { copyFile, link, mkdtemp, open, readFile, readdir, rename, symlink, unlink, writeFile } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { createServer } from "node:net";
Expand Down Expand Up @@ -157,6 +158,65 @@ describe("audit journal integrity", () => {
}
});

it("does not mistake a queued local refusal for an incomplete lock holder", async () => {
const directory = await mkdtemp(join(tmpdir(), "miftah-audit-integrity-delayed-lock-probe-"));
const path = join(directory, "audit.jsonl");
class ProbeSocket extends EventEmitter {
setEncoding(): this {
return this;
}

destroy(): this {
return this;
}
}

const socket = new ProbeSocket();
const originalSetTimeout = global.setTimeout;
let probeTimedOut = false;
let probeTimerIntercepted = false;
vi.resetModules();
vi.doMock("node:net", async () => {
const actual = await vi.importActual<typeof import("node:net")>("node:net");
return { ...actual, connect: () => socket };
});
const { AuditLogger: IsolatedAuditLogger } = await import("../src/audit/audit-logger.js");
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => (probeTimedOut ? 5_000 : 0));
const setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation(
((callback: (...args: never[]) => void, delay?: number, ...args: never[]) => {
if (!probeTimerIntercepted && delay === 100) {
probeTimerIntercepted = true;
const timer = originalSetTimeout(() => undefined, 1_000);
queueMicrotask(() => {
probeTimedOut = true;
callback(...args);
socket.emit("error", Object.assign(new Error("connection refused"), { code: "ECONNREFUSED" }));
});
return timer;
}
return originalSetTimeout(callback, delay, ...args);
}) as unknown as typeof setTimeout
);

try {
const logger = new IsolatedAuditLogger(path, { integrity: { algorithm: "sha256-chain" } });
await expect(logger.log({
wrapper: "github",
profile: "work",
operation: "tools/call",
name: "writes-after-delayed-local-lock-probe",
status: "success",
durationMs: 1
})).resolves.toBeUndefined();
expect(probeTimerIntercepted).toBe(true);
} finally {
setTimeoutSpy.mockRestore();
nowSpy.mockRestore();
vi.doUnmock("node:net");
vi.resetModules();
}
});

it("identifies the first tampered chained record without returning record content", async () => {
const directory = await mkdtemp(join(tmpdir(), "miftah-audit-integrity-"));
const path = join(directory, "audit.jsonl");
Expand Down
Loading