# 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` — `checkAndCacheComplete`
- `public/ui.js:148-150` — buffer-segment scan
- `public/audioCache.js:183-187` — inside `fetchSegment`

And the flag is set here:

- `public/audioCache.js:110-112` — inside `downloadAndCacheTrack`, **after** `await 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:

```js
// 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):

```js
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`:**
```js
M.maybeStartBulkDownload(trackId);
```
(remove the old `if (...size >= SEGMENTS)` + `downloadAndCacheTrack` block.)

**`ui.js:148-150`** (buffered-range scan, after a segment is marked present):
```js
M.maybeStartBulkDownload(M.currentTrackId);
```

**`audioCache.js:183-187`** (inside `fetchSegment`, after marking the segment):
```js
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 in `maybeStartBulkDownload` when it returns `true`. 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 and `fetchSegment` to 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 `bulkDownloadStarted` is cleared so a later attempt can retry, rather than permanently preventing caching.
- **Already-cached path**: if a track is already in IndexedDB (`loadTrackBlob` returns a URL on the first line), confirm no full download is triggered and `cachedTracks` is 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 `downloadAndCacheTrack` is called from anywhere else that relied on the internal guard, that caller must be migrated to `maybeStartBulkDownload`. Grep for `downloadAndCacheTrack(` 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 `.catch` in `maybeStartBulkDownload` is the safety net — confirm it clears the flag on all rejection paths.
- Rollback = revert `audioCache.js` and the two call-site edits in `ui.js`.
