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.
This commit is contained in:
Chris Tate
2026-10-26 10:33:02 -05:00
parent 26b2fdcc67
commit dec2b8a454
2 changed files with 61 additions and 2 deletions
@@ -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);
});
@@ -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<FrontendInputProbe, { op: "kind" }>["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