Skip to content

feat(memory-saver): Add plugin that reloads the page to reclaim leaked memory - #4617

Open
danielchalmers wants to merge 4 commits into
pear-devs:masterfrom
danielchalmers:claude/memory-saver-plugin
Open

feat(memory-saver): Add plugin that reloads the page to reclaim leaked memory#4617
danielchalmers wants to merge 4 commits into
pear-devs:masterfrom
danielchalmers:claude/memory-saver-plugin

Conversation

@danielchalmers

@danielchalmers danielchalmers commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Adds an opt-in plugin that reloads the page when the renderer's memory crosses a threshold, working around the web player's memory leak behind the long-session slowdown and crash reports (#4450, #1722, #1158, #797, #322, #295).

🤖 Claude measured over the DevTools protocol against the real app: Performance.getMetrics sampled after every track change on a 50-track playlist, with GC forced before each sample so growth reflects actual retention.

configuration nodes/track listeners/track heap/track
default plugins 128 33 0.47 MB
all 40 plugins disabled 132 33 0.43 MB
this app's renderer script not injected 125 32 0.32 MB

The rate is unchanged with our code completely absent, so the leak is upstream and can't be fixed here. The retained nodes are detached (the attached DOM grew by 6 elements while total nodes grew ~1,500), which is why the UI degrades rather than just growing. Over a week of continuous playback that's roughly 370k nodes and over 1 GB. A reload reclaims all of it: in one test, 83 to 73 MB of heap and 43.8k to 41.6k nodes.

Behavior

  • The backend checks the renderer's working set once a minute, and only acts while the user is away from the keyboard (powerMonitor.getSystemIdleTime()), since a reload lands on the song page. The threshold is thresholdMB in config.json (default 1200).
  • The renderer reloads at the next gap in playback: immediately if paused (staying paused afterwards), otherwise at the next pause or track change.
  • The song and position survive. The queue is rebuilt from the watch URL, so only playlist and radio queues reload; hand-built queues are left alone.
  • While shuffle is on, the plugin holds off entirely, since the rebuilt queue would come back unshuffled. A follow-up can lift this once there is a way to restore the shuffle after a reload.
  • Reloads are spaced at least an hour apart.
  • Play next / Add to queue are lost on reload, and radio queues may regenerate different upcoming tracks.

Summary by CodeRabbit

  • New Features
    • Added the Memory Saver plugin.
    • Automatically reloads the page during idle periods when renderer memory usage exceeds a configured threshold.
    • Preserves the current song, playback position, queue, shuffle state, and pause state across the reload.
    • Smarter reload timing: reloads respect resume-shuffle behavior and only trigger when safe to restore playback.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Updates Memory Saver’s shuffle-aware reload gating by caching resume-shuffle state when an IPC reload request arrives, resets that state during teardown, and adds English plugin metadata.

Changes

Memory Saver Plugin

Layer / File(s) Summary
Reload state and shuffle gating
src/plugins/memory-saver/index.ts
Stores shuffle-plugin state in renderer state, uses it for reload eligibility, resolves it asynchronously during IPC handling, and clears it during teardown. Existing playback-position and pause-state restoration remains unchanged.
Plugin metadata
src/i18n/resources/en.json
Adds the Memory Saver name and description in English.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding a memory-saver plugin that reloads the page to reclaim leaked memory.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/plugins/memory-saver/index.ts`:
- Around line 85-86: Update the reload flow around lastRequestAt and
ipc.send(RELOAD_CHANNEL) so the cooldown starts when the renderer actually
performs the reload rather than when it is requested. Either acknowledge
reload() completion back to the backend before updating lastRequestAt, or
conservatively add GRACE_MS to the next eligible-time calculation.
- Around line 102-107: Update the video-data handling around getVideoData so the
pending state is cleared and graceTimeout is cancelled and reset before
returning when videoId is missing. Preserve the existing cleanup behavior for
valid video data and ensure later IPC requests are not blocked by a stale
pending state.
- Around line 149-175: Store the RELOAD_CHANNEL callback registered in the
plugin startup flow as a renderer-state listener, rather than an inline
anonymous function. In stop(), remove that stored callback with
ipc.off(RELOAD_CHANNEL, reloadListener) and clear the stored reference so
disabling and re-enabling does not retain stale listeners.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8b35f4d8-c8ce-487f-bb8b-67acfb32e448

📥 Commits

Reviewing files that changed from the base of the PR and between 1a84f1d and d635723.

📒 Files selected for processing (2)
  • src/i18n/resources/en.json
  • src/plugins/memory-saver/index.ts

Comment thread src/plugins/memory-saver/index.ts
Comment thread src/plugins/memory-saver/index.ts Outdated
Comment on lines +102 to +107
const { video_id: videoId, list } = this.api?.getVideoData() ?? {};
if (!videoId) return;

this.pending = false;
if (this.graceTimeout) clearTimeout(this.graceTimeout);
this.graceTimeout = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear pending state before returning for missing video data.

If video_id is unavailable, this returns with pending still true and the grace timer retained. Later IPC requests are ignored at line 150 until a dataloaded event happens.

Proposed fix
       const { video_id: videoId, list } = this.api?.getVideoData() ?? {};
-      if (!videoId) return;
-
       this.pending = false;
       if (this.graceTimeout) clearTimeout(this.graceTimeout);
       this.graceTimeout = null;
+      if (!videoId) return;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { video_id: videoId, list } = this.api?.getVideoData() ?? {};
if (!videoId) return;
this.pending = false;
if (this.graceTimeout) clearTimeout(this.graceTimeout);
this.graceTimeout = null;
const { video_id: videoId, list } = this.api?.getVideoData() ?? {};
this.pending = false;
if (this.graceTimeout) clearTimeout(this.graceTimeout);
this.graceTimeout = null;
if (!videoId) return;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/plugins/memory-saver/index.ts` around lines 102 - 107, Update the
video-data handling around getVideoData so the pending state is cleared and
graceTimeout is cancelled and reset before returning when videoId is missing.
Preserve the existing cleanup behavior for valid video data and ensure later IPC
requests are not blocked by a stale pending state.

Comment thread src/plugins/memory-saver/index.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/plugins/memory-saver/index.ts (1)

106-126: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Clear the interval when the window is destroyed.

The cooldown fix here (COOLDOWN_MS + GRACE_MS) correctly resolves the previously flagged anchoring issue. One small leftover: when window.isDestroyed() is true, the interval keeps ticking indefinitely (harmless early-return) instead of being cleared. Consider clearing it here too, rather than relying on a future start() call to replace it.

♻️ Optional cleanup
       this.interval = setInterval(async () => {
-        if (window.isDestroyed()) return;
+        if (window.isDestroyed()) {
+          clearInterval(this.interval);
+          return;
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/plugins/memory-saver/index.ts` around lines 106 - 126, Update the
interval callback in start so that when window.isDestroyed() is true, it clears
this.interval before returning; preserve the existing early-return behavior for
live windows.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/plugins/memory-saver/index.ts`:
- Around line 106-126: Update the interval callback in start so that when
window.isDestroyed() is true, it clears this.interval before returning; preserve
the existing early-return behavior for live windows.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bdb29f5f-8512-4b9f-bf7f-caa94c7cfda9

📥 Commits

Reviewing files that changed from the base of the PR and between d635723 and 27c816e.

📒 Files selected for processing (2)
  • src/i18n/resources/en.json
  • src/plugins/memory-saver/index.ts

@SomeAspy

Copy link
Copy Markdown
Collaborator

How much of this PR was actually done by you
Was this whole PR written by Claude?
Did you have any hand in making this?

@danielchalmers

Copy link
Copy Markdown
Contributor Author

How much of this PR was actually done by you Was this whole PR written by Claude? Did you have any hand in making this?

The vast bulk of this was Fable. I ran it to investigate the problem for my own use and wanted to post the results in case the findings would help anyone else as it took a few hours to get through.

Would like to see if this is on the right track so I can help plan/pivot/diagnose further if it's a direction we want to take so I can become more familiar with the project as a whole.

Is there a repo rule against that? I can close it if so and keep it forked instead, no problem. Will update the description in the meantime.

…d memory

The web player itself leaks memory over long sessions, so the app gets
progressively slower and eventually runs out of memory. Measured on a
real playlist, the renderer accumulates ~130 detached DOM nodes, ~33
event listeners and ~0.45 MB of heap per track change, which works out
to roughly 370k nodes and over 1 GB across a week of continuous
playback. Reloading the page reclaims all of it.

This plugin watches the renderer's working set from the backend and,
once it is over a configurable threshold, reloads at the least
disruptive moment: only while the user is away from the keyboard, and
only in a gap in playback, keeping the current song, position and
shuffle state intact.
@danielchalmers
danielchalmers force-pushed the claude/memory-saver-plugin branch from 27c816e to 65433f4 Compare July 30, 2026 18:57

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/plugins/memory-saver/index.ts (1)

161-186: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: DOM listeners aren't defensively cleared on re-entry like the IPC channel is.

Line 164 defensively clears the IPC listener because "this can run again on plugin re-enable," but the pause/videodatachange listeners added at lines 182-185 aren't similarly guarded. In practice, stop() already removes them on disable, and any duplicate reload() call is cut short by the immediate location.replace, so impact is minimal — but for consistency with the IPC defensive pattern, consider removing before re-adding.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/plugins/memory-saver/index.ts` around lines 161 - 186, Update
onPlayerApiReady to defensively remove the existing video pause listener and
document videodatachange listener before adding them again, matching the IPC
listener cleanup pattern and preventing duplicate handlers on re-entry.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/plugins/memory-saver/index.ts`:
- Around line 171-180: In the RELOAD_CHANNEL handler and the pending reload
flow, add a 30-minute force-reload timer so pending requests eventually invoke
reload() even without a pause or track change. Store the timer in
forceReloadTimeout, avoid duplicate timers while pending, and clear it in stop()
alongside the existing cleanup.

---

Nitpick comments:
In `@src/plugins/memory-saver/index.ts`:
- Around line 161-186: Update onPlayerApiReady to defensively remove the
existing video pause listener and document videodatachange listener before
adding them again, matching the IPC listener cleanup pattern and preventing
duplicate handlers on re-entry.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 68fd5eb4-16b0-4565-89bb-741f58f1e8be

📥 Commits

Reviewing files that changed from the base of the PR and between 27c816e and 65433f4.

📒 Files selected for processing (2)
  • src/i18n/resources/en.json
  • src/plugins/memory-saver/index.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/i18n/resources/en.json

Comment thread src/plugins/memory-saver/index.ts
Reading the raw store misses plugin config defaults; isEnabled() is the
API every other cross-plugin check in the repo uses. Resolved once per
reload request so the reload gate itself stays synchronous.

Also drops trailing periods from comments to match the surrounding code.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/plugins/memory-saver/index.ts`:
- Around line 165-176: Update the RELOAD_CHANNEL handler and stop() lifecycle in
the memory-saver plugin to use a generation token that invalidates in-flight
reload requests during teardown. Capture the token when handling a request, then
after await window.mainConfig.plugins.isEnabled('resume-shuffle') resolves,
verify the token still matches the active lifecycle before assigning
resumeShuffleEnabled, setting pending, or calling tryReload; increment the token
during stop() so stale handlers cannot affect a later enabled instance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6fc16a5c-3a88-4b4d-951f-3e16efd59970

📥 Commits

Reviewing files that changed from the base of the PR and between fd3d02c and c8047ba.

📒 Files selected for processing (1)
  • src/plugins/memory-saver/index.ts

Comment thread src/plugins/memory-saver/index.ts Outdated
…fle plugin

The reload gate referenced a plugin that only exists in another open PR,
which complicates reviewing this one on its own. Shuffle safety stays:
the plugin simply never reloads while shuffle is on. A small follow-up
can allow those reloads again once a way to restore the shuffle exists.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants