Skip to content

Switching tracks mid-playback leaks the engine that was playing #601

Description

@thcp

What happens

Loading a new track while the current one is still playing leaves the old audio engine permanently reachable, along with every decoded audio buffer it was holding. Repeat it through a listening session and the browser tab's memory only ever goes up.

Why it never recovers

destroyPlayer() (static/js/player.js) tears the engine down by calling audioEngine.destroy() directly. It does not pause first, and it has no reason to: the engine is being thrown away.

The problem is what destroy() leaves behind. Each engine registers a visibilitychange listener on document, and document outlives every engine that will ever be created. So the listener holds the engine's whole closure, which holds tracks, which holds an AudioBuffer per stem. Nothing is ever removed, so nothing can be collected.

The full-decode engine had a second half to this: destroy() never cleared playing. A dead engine therefore reported isPlaying() === true forever, and the leaked listener still believed it had work to do.

Why it is invisible

Nothing fails. Playback of the new track is completely normal. The only symptom is memory, and only after several switches, which is why it survives any amount of ordinary use and any amount of testing.

Constraints for anyone fixing this

  • The listener genuinely is needed. A pending animation frame is dropped rather than deferred when a tab hides, so a loop waiting on one has no callback left to notice it should change clocks. Removing the listener outright reintroduces [Bug]: Audio stops (after approx 15 seconds) abruptly when navigating away from tab #600.
  • A listener per engine is the wrong shape regardless of cleanup. Engines are created and destroyed on every track change; the thing being listened for is global.
  • Measuring this needs care. The JS heap does not count AudioBuffer backing memory at all, so heap numbers can look flat while hundreds of MB leak. Counting live loop registrations is the honest instrument.

Found while fixing #600.

Activity

  1. thcp commented on Sep 9, 2026

    @thcp
    CollaboratorAuthor

    Fixed in #606, landed on main as 53a6c04.

    Tick loops now deregister on dispose, and the statechange listener comes off the context too, so a torn-down engine is no longer reachable from document and its decoded buffers can be collected. audioEngine.destroy() also clears playing, which it never did.

    Verified with a browser driving six mid-playback track switches: live tick loops stayed flat at 1. With dispose deliberately broken the same harness reported 2 to 7, one per switch, which is how I know the measurement can actually fail.

  2. self-assigned this
    on Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions