6.2 KiB
Fix Duplicate Full-Track Download Race in Client Cache
Priority: Medium · Effort: Low · Risk: Low
Problem
M.bulkDownloadStarted is a per-track flag meant to ensure downloadAndCacheTrack runs at most once per track. But the flag is set inside downloadAndCacheTrack after an await, while multiple callers check it before that await resolves. When the last two segments complete near-simultaneously — one from the browser's buffered-range scan in ui.js, one from the explicit fetchSegment in audioCache.js — both callers observe trackCache.size >= SEGMENTS and both pass the bulkDownloadStarted guard before the first caller sets the flag. Result: the same track is fetched in full twice, wasting bandwidth and racing on the IndexedDB write.
Affected Locations
The check+call pattern appears in three places, all racy:
public/audioCache.js:99-107—checkAndCacheCompletepublic/ui.js:148-150— buffer-segment scanpublic/audioCache.js:183-187— insidefetchSegment
And the flag is set here:
public/audioCache.js:110-112— insidedownloadAndCacheTrack, afterawait M.loadTrackBlob(trackId).
Root Cause
Guard-then-do where the guard write is not atomic with respect to the awaits surrounding it. JavaScript's single-threaded execution means a synchronous "check-and-set" is race-free, but here the set happens after the first await inside the function being guarded, so a second caller can enter the function and pass the check before the first caller reaches the set.
Implementation Plan
Step 1 — Move the flag set to be synchronous with the check
Refactor so the check and the set happen in the same synchronous tick, before any await. Create a single entry point:
// public/audioCache.js
M.maybeStartBulkDownload = function (trackId) {
if (M.bulkDownloadStarted.get(trackId)) return false; // already started
if (M.cachedTracks.has(trackId)) return false; // already cached
const trackCache = M.trackCaches.get(trackId);
if (!trackCache || trackCache.size < SEGMENTS) return false; // not fully buffered
// All conditions met — claim the slot synchronously:
M.bulkDownloadStarted.set(trackId, true);
// Fire the async work without awaiting here:
M.downloadAndCacheTrack(trackId).catch((err) => {
console.warn("[audioCache] bulk download failed for", trackId, err);
M.bulkDownloadStarted.delete(trackId); // allow a later retry
});
return true;
};
Then make downloadAndCacheTrack assume the flag is already set (remove the guard inside it):
M.downloadAndCacheTrack = async function (trackId) {
// Precondition: M.bulkDownloadStarted.get(trackId) === true (set by maybeStartBulkDownload)
const cachedUrl = await M.loadTrackBlob(trackId);
if (cachedUrl) { // already cached under us
M.cachedTracks.add(trackId);
M.bulkDownloadStarted.delete(trackId);
return cachedUrl;
}
// ... existing download + TrackStorage.set + URL.createObjectURL logic ...
M.cachedTracks.add(trackId);
M.bulkDownloadStarted.delete(trackId); // clear after success
M.renderQueue && M.renderQueue();
M.renderLibrary && M.renderLibrary();
return blobUrl;
};
Step 2 — Replace all three call sites
Each caller becomes a single synchronous call:
audioCache.js checkAndCacheComplete:
M.maybeStartBulkDownload(trackId);
(remove the old if (...size >= SEGMENTS) + downloadAndCacheTrack block.)
ui.js:148-150 (buffered-range scan, after a segment is marked present):
M.maybeStartBulkDownload(M.currentTrackId);
audioCache.js:183-187 (inside fetchSegment, after marking the segment):
M.maybeStartBulkDownload(trackId);
Because the check-and-claim is now synchronous, even if all three callers fire in the same tick, only the first will start the download.
Step 3 — Guard the blob-URL swap during playback
downloadAndCacheTrack swaps M.audio.src to the blob URL mid-playback (audioCache.js:213-220). After the refactor, confirm this swap still checks M.currentTrackId === trackId and that wasPlaying is captured at the moment of the swap (not earlier). Keep the existing play().catch(()=>{}).
Step 4 — (Related, recommended in same pass) Revoke blob URLs
While in this file, address the related memory leak: every URL.createObjectURL stored in M.trackBlobs is never revoked. See todo/client-cache-correctness/overview.md — that plan covers it. If doing both together, the downloadAndCacheTrack success path is the natural place to track the URL for later revocation.
Validation
- Race reproduction: this is hard to trigger deterministically. Add a temporary
console.log("[bulk]", trackId)at the top of the download body and another inmaybeStartBulkDownloadwhen it returnstrue. Play a track, let it fully buffer, and confirm exactly one "started" log and one completion per track — even when forcing the buffered-range scan andfetchSegmentto both fire (e.g. seek near the end then back). - Error path: temporarily make the download throw (e.g. point the track URL at a 404) and confirm
bulkDownloadStartedis cleared so a later attempt can retry, rather than permanently preventing caching. - Already-cached path: if a track is already in IndexedDB (
loadTrackBlobreturns a URL on the first line), confirm no full download is triggered andcachedTracksis updated. - UI: confirm the buffer bar fills, the cache indicator turns green, and queue/library re-render exactly once per completed cache (not twice).
Risk / Rollback
- The refactor centralizes three near-duplicate code paths into one — net simplification.
- Risk: if
downloadAndCacheTrackis called from anywhere else that relied on the internal guard, that caller must be migrated tomaybeStartBulkDownload. Grep fordownloadAndCacheTrack(before merging. - If a download fails after the flag is set and the catch handler fails to clear it, the track becomes uncachable until reload. The
.catchinmaybeStartBulkDownloadis the safety net — confirm it clears the flag on all rejection paths. - Rollback = revert
audioCache.jsand the two call-site edits inui.js.