Skip to content

Fix #488: re-arm plugin refresh timer after cancellation#489

Open
melonamin wants to merge 2 commits into
mainfrom
fix/issue-488
Open

Fix #488: re-arm plugin refresh timer after cancellation#489
melonamin wants to merge 2 commits into
mainfrom
fix/issue-488

Conversation

@melonamin
Copy link
Copy Markdown
Member

Summary

Closes #488. The reported "memory leak when displaying images" turned out to not be a leak at all — a 4-hour run of the user's homeweather.2m.py plugin on macOS 26.5 with a freshly built SwiftBar showed flat fd count (42-44) and RSS slightly decreasing (71 → 60 MB). What actually happens is that the plugin's refresh timer dies and the plugin stops being executed entirely; the stale dropdown image and error icon are downstream consequences. Reproduced exactly: zero plugin child processes in any 130-second window at end of run, and swiftbar://refreshallplugins + swiftbar://refreshplugin?name=... did not revive it. Only an app restart helped — matching the user's report verbatim.

What breaks the timer

refresh() disables the timer before scheduling a new RunPluginOperation and relies on that operation calling enableTimer() at the end of main() to re-arm it. Two paths can leave the plugin permanently dormant:

  • pluginInvokeQueue.cancelAllOperations() (called by refreshAllPlugins/startAllPlugins/terminateAllPlugins) can mark the operation cancelled before main() runs, so the existing post-invoke enableTimer() call is skipped.
  • A cancellation observed between invoke() and the next guard !isCancelled makes main() return early without re-arming either.

The fix

  • Move enableTimer() into a defer at the top of RunPluginOperation.main() so every exit path re-arms.
  • Call enableTimer() in refresh() itself before enqueueing the operation so the timer survives even if the operation is removed from the queue before main() executes. The cancellable.isEmpty guard inside enableTimer() prevents duplicate subscriptions.

Test plan

  • Existing testRunPluginOperation_rearmsTimersForTimerArmingPlugins still passes
  • New testRunPluginOperation_rearmsTimerWhenCancelledBeforeMain verifies the new guarantee
  • User-reported homeweather.2m.py scenario should now stay alive past the previous failure window

Introduce a configurable execution timeout (default 30s) that terminates
long-running plugin scripts and their child process trees. Enhance logging
with execution duration, stderr capture, content previews, and visibility
change tracking. Read stdout/stderr concurrently to prevent pipe deadlock.
refresh() disables the timer before scheduling a new RunPluginOperation
and relies on that operation calling enableTimer() at the end of main()
to re-arm it. Two paths could leave the plugin permanently dormant:

- pluginInvokeQueue.cancelAllOperations() can mark the operation
  cancelled before main() runs, so the existing post-invoke enableTimer()
  call is skipped.
- A cancellation observed between invoke() and the next guard makes
  main() return early without re-arming either.

Move the enableTimer() call into a defer at the top of main() so every
exit path re-arms, and call enableTimer() in refresh() itself before
enqueueing the operation so the timer survives even if the operation is
removed from the queue before main() executes. The cancellable.isEmpty
guard inside enableTimer() prevents duplicate subscriptions.

Add a regression test that cancels the operation before calling main()
and verifies the timer is still armed.
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.

possible memory leak when displaying images?

1 participant