From dae5b7b90b7eefceffc9e64cc532b5cc74eeeedd Mon Sep 17 00:00:00 2001 From: Rajan Pantha Date: Wed, 26 Aug 2026 11:21:32 +0545 Subject: [PATCH] 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. --- .../src/services/extractionCache.test.ts | 25 +++++++++++++++++++ .../engine/src/services/extractionCache.ts | 24 +++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/services/extractionCache.test.ts b/packages/engine/src/services/extractionCache.test.ts index 0842204c1..ee80038ff 100644 --- a/packages/engine/src/services/extractionCache.test.ts +++ b/packages/engine/src/services/extractionCache.test.ts @@ -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); diff --git a/packages/engine/src/services/extractionCache.ts b/packages/engine/src/services/extractionCache.ts index ba5dcb70b..ae045a37e 100644 --- a/packages/engine/src/services/extractionCache.ts +++ b/packages/engine/src/services/extractionCache.ts @@ -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 }; }