Skip to content

Commit dae5b7b

Browse files
authored
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.
1 parent f52ec1c commit dae5b7b

2 files changed

Lines changed: 48 additions & 1 deletion

File tree

packages/engine/src/services/extractionCache.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,13 +227,38 @@ describe("lookupCacheEntry / markCacheEntryComplete", () => {
227227
it("hits after ensureCacheEntryDir + markCacheEntryComplete", () => {
228228
const first = lookupCacheEntry(tmpRoot, base(sourceFile));
229229
ensureCacheEntryDir(first.entry);
230+
writeFileSync(join(first.entry.dir, "frame_00001.jpg"), "x", "utf-8");
230231
markCacheEntryComplete(first.entry);
231232

232233
const second = lookupCacheEntry(tmpRoot, base(sourceFile));
233234
expect(second.hit).toBe(true);
234235
expect(second.entry.dir).toBe(first.entry.dir);
235236
});
236237

238+
it("treats a sentineled entry with no frames as a miss", () => {
239+
const first = lookupCacheEntry(tmpRoot, base(sourceFile));
240+
ensureCacheEntryDir(first.entry);
241+
writeFileSync(join(first.entry.dir, "frame_00001.jpg"), "x", "utf-8");
242+
markCacheEntryComplete(first.entry);
243+
expect(lookupCacheEntry(tmpRoot, base(sourceFile)).hit).toBe(true);
244+
245+
// Any per-file cleanup can empty the directory while leaving the sentinel.
246+
// Serving that as a hit rehydrates zero frames, so every later render of
247+
// the project fails identically at the coverage gate.
248+
rmSync(join(first.entry.dir, "frame_00001.jpg"));
249+
250+
expect(lookupCacheEntry(tmpRoot, base(sourceFile)).hit).toBe(false);
251+
});
252+
253+
it("counts frames of any format when deciding a hit", () => {
254+
const first = lookupCacheEntry(tmpRoot, base(sourceFile));
255+
ensureCacheEntryDir(first.entry);
256+
writeFileSync(join(first.entry.dir, "frame_00001.png"), "x", "utf-8");
257+
markCacheEntryComplete(first.entry);
258+
259+
expect(lookupCacheEntry(tmpRoot, base(sourceFile)).hit).toBe(true);
260+
});
261+
237262
it("treats an in-progress dir without the sentinel as a miss", () => {
238263
const lookup = lookupCacheEntry(tmpRoot, base(sourceFile));
239264
ensureCacheEntryDir(lookup.entry);

packages/engine/src/services/extractionCache.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,16 +170,38 @@ export function cacheEntryDirName(keyHash: string): string {
170170
return SCHEMA_PREFIX + keyHash.slice(0, KEY_HEX_CHARS);
171171
}
172172

173+
/**
174+
* Whether a sentineled entry directory still holds at least one frame file.
175+
*
176+
* The sentinel records that extraction finished, not that the frames survived.
177+
* Any per-file cleanup that empties the directory leaves the sentinel behind,
178+
* and the entry then rehydrates as a hit with zero frames. Deliberately
179+
* format-agnostic: a hit must be usable whatever extension the frames carry.
180+
*/
181+
function hasFrameFiles(dir: string): boolean {
182+
try {
183+
return readdirSync(dir).some((file) => file.startsWith(FRAME_FILENAME_PREFIX));
184+
} catch {
185+
// Unreadable entry directory: treat as a miss and re-extract.
186+
return false;
187+
}
188+
}
189+
173190
/**
174191
* Look up a cache entry by key input. Returns the resolved entry path plus a
175192
* `hit` flag. On miss, callers should extract frames into a
176193
* `partialCacheEntryDir(entry)` directory and publish it with
177194
* `publishCacheEntry` once extraction succeeds.
195+
*
196+
* An entry only counts as a hit when it carries the completion sentinel AND
197+
* still has frames to serve. Without the second condition an emptied entry
198+
* keeps rehydrating with zero frames, so every later render of that project
199+
* fails identically at the coverage gate with no user-discoverable fix.
178200
*/
179201
export function lookupCacheEntry(rootDir: string, input: CacheKeyInput): CacheLookup {
180202
const keyHash = computeCacheKey(input);
181203
const dir = join(rootDir, cacheEntryDirName(keyHash));
182-
const complete = existsSync(join(dir, COMPLETE_SENTINEL));
204+
const complete = existsSync(join(dir, COMPLETE_SENTINEL)) && hasFrameFiles(dir);
183205
return { entry: { dir, keyHash }, hit: complete };
184206
}
185207

0 commit comments

Comments
 (0)