fix(studio): a slow thumbnail job keeps a reader that other clips still decode from (#6643)

* fix(studio): a slow thumbnail job keeps a reader that other clips still decode from

A job that timed out judged the reader by its own progress only. A poster job that got
an already-open reader while a sibling was decoding on it, and took more than its
budget to reach its one frame (a slow link, Chrome's six connections held), logged a
timeout and retired a healthy reader, so the next clip re-read the index.

The pooled reader now counts the frames every job decodes from it, and a timed-out job
retires it only when no frame came from it while the job held it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): the reader's frame count says whose frames it counts in its name, not a comment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-10-28 09:18:55 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 30df5a48af
commit fa137cdfef
3 changed files with 37 additions and 6 deletions
@@ -19,6 +19,7 @@ export interface ThumbnailSource extends OpenedSource {
release(failed: boolean): void;
/** The job waited for the reader to open: the open finishing was progress the job saw. */
waitedForOpen: boolean;
progress: { framesFromAnyJob: number };
}
interface PooledSource {
@@ -28,6 +29,7 @@ interface PooledSource {
ready: boolean;
opened: Promise<OpenedSource>;
users: number;
progress: { framesFromAnyJob: number };
idleTimer: ReturnType<typeof setTimeout> | null;
disposed: boolean;
}
@@ -100,6 +102,7 @@ function pooledSource(source: string, version: string | undefined): PooledSource
ready: false,
opened: open(source, () => entry),
users: 0,
progress: { framesFromAnyJob: 0 },
idleTimer: null,
disposed: false,
};
@@ -150,7 +153,8 @@ export async function acquireThumbnailSource(
}
};
try {
return { ...(await untilAborted(entry.opened, signal)), release, waitedForOpen };
const { progress } = entry;
return { ...(await untilAborted(entry.opened, signal)), release, waitedForOpen, progress };
} catch (error) {
// Timed out still waiting: the open made no progress in the job's whole run.
release(!isAbortError(error) || timedOut(signal));
@@ -766,6 +766,32 @@ describe("decodeVideoThumbnail's shared source", () => {
expect(input.getPrimaryVideoTrack).toHaveBeenCalledTimes(1);
});
it("keeps the reader when a clip times out while another clip decodes frames from it", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
recordDecodes([]);
await decodeVideoThumbnail(clip(0), signal);
canvasesAtTimestamps.mockImplementationOnce(async function* () {
await new Promise(() => {});
yield null;
});
canvasesAtTimestamps.mockImplementationOnce(async function* (
timestamps: AsyncIterable<number>,
) {
await timestamps[Symbol.asyncIterator]().next();
yield { canvas: document.createElement("canvas") };
await new Promise(() => {});
});
const controller = new AbortController();
void decodeVideoThumbnail(clip(6), controller.signal).catch(() => {});
void decodeVideoThumbnail(clip(12), new AbortController().signal).catch(() => {});
await vi.waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(4));
controller.abort(timedOut());
expect(warn).not.toHaveBeenCalled();
await decodeVideoThumbnail(clip(18), signal);
expect(input.getPrimaryVideoTrack).toHaveBeenCalledTimes(1);
});
it("fails a job that times out waiting for its source to open, and the next clip opens afresh", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const stalled = untilDisposed();
@@ -79,7 +79,6 @@ interface DecodedResources {
held: HeldFrame[];
canvases: Set<HTMLCanvasElement | OffscreenCanvas>;
releasedFrameBytes: number;
decodedByJob: number;
}
interface ThumbnailCanvasSink {
@@ -175,6 +174,7 @@ async function decodeFrames(
slots: { slot: number; key: string; decodedAt?: number }[],
signal: AbortSignal,
resources: DecodedResources,
progress: ThumbnailSource["progress"],
): Promise<void> {
let next = 0;
for await (const wrapped of sink.canvasesAtTimestamps(times)) {
@@ -185,7 +185,7 @@ async function decodeFrames(
const blob = await canvasToBlob(wrapped.canvas);
throwIfAborted(signal);
const frame = shareFrame(target.key, target.decodedAt, URL.createObjectURL(blob), blob.size);
resources.decodedByJob += 1;
progress.framesFromAnyJob += 1;
resources.urls[target.slot] = frame.url;
resources.held.push({ key: target.key, frame });
}
@@ -232,7 +232,6 @@ export async function decodeVideoThumbnail(
held: [],
canvases: new Set(),
releasedFrameBytes: budgets.sharedFrameCacheBytes,
decodedByJob: 0,
};
const entry = sourceInfos.get(request.source);
const known = entry?.version === request.contentVersion ? entry?.info : undefined;
@@ -271,8 +270,9 @@ async function decodeMissingFrames(
if (timedOut(signal)) reportMediaFetchFailure(timeoutWithoutProgress());
throw error;
}
const framesBefore = source.progress.framesFromAnyJob;
const releaseOnAbort = () => {
const madeProgress = source.waitedForOpen || resources.decodedByJob > 0;
const madeProgress = source.waitedForOpen || source.progress.framesFromAnyJob > framesBefore;
const failed = timedOut(signal) && !madeProgress;
if (failed) reportMediaFetchFailure(timeoutWithoutProgress());
source.release(failed);
@@ -307,7 +307,8 @@ async function decodeMissingFrames(
fit: request.fit ?? "cover",
poolSize: 1,
});
await decodeFrames(sink, decodeTimesAtNearbyKeyframes(), slots, signal, resources);
const times = decodeTimesAtNearbyKeyframes();
await decodeFrames(sink, times, slots, signal, resources, source.progress);
}
const result = loadedResult(resources, info.aspect, budgets, timestamps.length);
source.release(false);