From dec2b8a454b78da80343b83b9f0ade32d195a0af Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 10:33:02 -0500 Subject: [PATCH] Record one probe for two reads of a file that saw the same content A read within IDENTITY_SETTLE_MS of a file's last write records no identity, and a later read of the same bytes in the same frontend does. FrontendInputTracker.record compared the two probes as JSON and marked the snapshot unstable. A build or coverage run that read its entry before and after that point then published no cache entry: the exact repeat of a build lowered again, and a coverage verdict was not replayed. Two file probes with the same digest now merge into one that keeps the settled identity; any other difference still marks the snapshot unstable. --- .../src/frontend/input-tracker.test.ts | 38 +++++++++++++++++++ .../compiler/src/frontend/input-tracker.ts | 25 +++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/input-tracker.test.ts b/packages/compiler/src/frontend/input-tracker.test.ts index d6f9a91b7..a320eb2bd 100644 --- a/packages/compiler/src/frontend/input-tracker.test.ts +++ b/packages/compiler/src/frontend/input-tracker.test.ts @@ -316,3 +316,41 @@ test("a file changed shortly before the read is hashed on every check", async () expect(frontendInputsStillMatch(snapshot)).toBe(true); expect(reads.mock.calls.filter(([path]) => path === source)).toHaveLength(1); }); + +test("a read before and a read after a file settles record one probe", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-")); + scratch.push(dir); + const source = join(dir, "entry.ts"); + await writeFile(source, "export const answer = 1;\n"); + const tracker = new FrontendInputTracker(); + // The first read comes right after the write, the second once the file + // has been unchanged long enough for its identity to stand for it. + tracker.run(() => trackedReadFile(source)); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 5000); + try { + tracker.run(() => trackedReadFile(source)); + } finally { + vi.useRealTimers(); + } + const snapshot = tracker.snapshot(); + expect(snapshot.stable).toBe(true); + const probe = snapshot.probes.find((entry) => entry.op === "file"); + expect(probe?.op === "file" && probe.identity !== undefined).toBe(true); + expect(frontendInputsStillMatch(snapshot)).toBe(true); + // The identity still invalidates the snapshot on any write. + await writeFile(source, "export const answer = 2;\n"); + expect(frontendInputsStillMatch(snapshot)).toBe(false); +}); + +test("two reads of a file that saw different content prevent cache publication", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-")); + scratch.push(dir); + const source = join(dir, "entry.ts"); + await writeFile(source, "export const answer = 1;\n"); + const tracker = new FrontendInputTracker(); + tracker.run(() => trackedReadFile(source)); + await writeFile(source, "export const answer = 2;\n"); + tracker.run(() => trackedReadFile(source)); + expect(tracker.snapshot().stable).toBe(false); +}); diff --git a/packages/compiler/src/frontend/input-tracker.ts b/packages/compiler/src/frontend/input-tracker.ts index c23a87313..18081576b 100644 --- a/packages/compiler/src/frontend/input-tracker.ts +++ b/packages/compiler/src/frontend/input-tracker.ts @@ -84,6 +84,22 @@ function readWithIdentity(path: string): { text: string; identity: string | null return { text, identity: settled && after === identity ? identity : null }; } +/** Two reads of one file that saw the same content, as one probe: a read + * before the file had been unchanged for IDENTITY_SETTLE_MS records no + * identity, and a later read of the same bytes does. The probe keeps the + * identity, which the settled read proved for that content. Null when the + * reads differ in anything else. */ +function sameFileContent( + previous: FrontendInputProbe, + probe: FrontendInputProbe, +): FrontendInputProbe | null { + if (previous.op !== "file" || probe.op !== "file" || previous.digest !== probe.digest) + return null; + if (previous.identity === undefined) return probe; + if (probe.identity === undefined || probe.identity === previous.identity) return previous; + return null; +} + function pathKind(path: string): Extract["kind"] { try { const info = statSync(path); @@ -142,12 +158,17 @@ export class FrontendInputTracker { const key = `${probe.op}\0${probe.path}`; if (probe.op === "text" && this.probes.has(key)) return; const previous = this.probes.get(key); - if (previous !== undefined && JSON.stringify(previous) !== JSON.stringify(probe)) { + const merged = previous === undefined ? null : sameFileContent(previous, probe); + if ( + merged === null && + previous !== undefined && + JSON.stringify(previous) !== JSON.stringify(probe) + ) { // An input changed while this frontend was running. The build may // still finish normally, but it is not safe to publish as a cache key. this.stable = false; } - this.probes.set(key, probe); + this.probes.set(key, merged ?? probe); if (probe.op === "file") { // A successful content read supersedes earlier existence probes for the // same path only when they also observed a file. A missing/non-file