fix(engine): treat a sentineled cache entry with no frames as a miss (#3434)

lookupCacheEntry reported a hit purely on the presence of the
.hf-complete sentinel. The sentinel records that extraction finished,
not that the frames survived, so any per-file cleanup that empties the
directory leaves an entry that rehydrates with zero frames.

rehydrateCacheEntry then returns totalFrames: 0, the clip reaches the
coverage gate with nothing, and the render aborts with a message about
capture coverage. Because the poison is on disk rather than in the
composition, every later render of the project fails the same way with
nothing the user can change to fix it.

An entry now counts as a hit only when it carries the sentinel AND
still holds at least one frame file, so an emptied entry re-extracts.
The check is format-agnostic: a hit must be usable whatever extension
the frames carry.

Addresses the cache half of #3372.
This commit is contained in:
Rajan Pantha
2026-08-26 05:36:32 +00:00
committed by GitHub
parent f52ec1c25f
commit dae5b7b90b
2 changed files with 48 additions and 1 deletions
@@ -227,6 +227,7 @@ describe("lookupCacheEntry / markCacheEntryComplete", () => {
it("hits after ensureCacheEntryDir + markCacheEntryComplete", () => {
const first = lookupCacheEntry(tmpRoot, base(sourceFile));
ensureCacheEntryDir(first.entry);
writeFileSync(join(first.entry.dir, "frame_00001.jpg"), "x", "utf-8");
markCacheEntryComplete(first.entry);
const second = lookupCacheEntry(tmpRoot, base(sourceFile));
@@ -234,6 +235,30 @@ describe("lookupCacheEntry / markCacheEntryComplete", () => {
expect(second.entry.dir).toBe(first.entry.dir);
});
it("treats a sentineled entry with no frames as a miss", () => {
const first = lookupCacheEntry(tmpRoot, base(sourceFile));
ensureCacheEntryDir(first.entry);
writeFileSync(join(first.entry.dir, "frame_00001.jpg"), "x", "utf-8");
markCacheEntryComplete(first.entry);
expect(lookupCacheEntry(tmpRoot, base(sourceFile)).hit).toBe(true);
// Any per-file cleanup can empty the directory while leaving the sentinel.
// Serving that as a hit rehydrates zero frames, so every later render of
// the project fails identically at the coverage gate.
rmSync(join(first.entry.dir, "frame_00001.jpg"));
expect(lookupCacheEntry(tmpRoot, base(sourceFile)).hit).toBe(false);
});
it("counts frames of any format when deciding a hit", () => {
const first = lookupCacheEntry(tmpRoot, base(sourceFile));
ensureCacheEntryDir(first.entry);
writeFileSync(join(first.entry.dir, "frame_00001.png"), "x", "utf-8");
markCacheEntryComplete(first.entry);
expect(lookupCacheEntry(tmpRoot, base(sourceFile)).hit).toBe(true);
});
it("treats an in-progress dir without the sentinel as a miss", () => {
const lookup = lookupCacheEntry(tmpRoot, base(sourceFile));
ensureCacheEntryDir(lookup.entry);
@@ -170,16 +170,38 @@ export function cacheEntryDirName(keyHash: string): string {
return SCHEMA_PREFIX + keyHash.slice(0, KEY_HEX_CHARS);
}
/**
* Whether a sentineled entry directory still holds at least one frame file.
*
* The sentinel records that extraction finished, not that the frames survived.
* Any per-file cleanup that empties the directory leaves the sentinel behind,
* and the entry then rehydrates as a hit with zero frames. Deliberately
* format-agnostic: a hit must be usable whatever extension the frames carry.
*/
function hasFrameFiles(dir: string): boolean {
try {
return readdirSync(dir).some((file) => file.startsWith(FRAME_FILENAME_PREFIX));
} catch {
// Unreadable entry directory: treat as a miss and re-extract.
return false;
}
}
/**
* Look up a cache entry by key input. Returns the resolved entry path plus a
* `hit` flag. On miss, callers should extract frames into a
* `partialCacheEntryDir(entry)` directory and publish it with
* `publishCacheEntry` once extraction succeeds.
*
* An entry only counts as a hit when it carries the completion sentinel AND
* still has frames to serve. Without the second condition an emptied entry
* keeps rehydrating with zero frames, so every later render of that project
* fails identically at the coverage gate with no user-discoverable fix.
*/
export function lookupCacheEntry(rootDir: string, input: CacheKeyInput): CacheLookup {
const keyHash = computeCacheKey(input);
const dir = join(rootDir, cacheEntryDirName(keyHash));
const complete = existsSync(join(dir, COMPLETE_SENTINEL));
const complete = existsSync(join(dir, COMPLETE_SENTINEL)) && hasFrameFiles(dir);
return { entry: { dir, keyHash }, hit: complete };
}