Skip to content

Daemon-only mode with CPU/RAM reductions and page-switch/resume performance improvements - #558

Merged
Core447 merged 16 commits into
StreamController:mainfrom
morris178:pr/daemon-only-core
Aug 10, 2026
Merged

Core447 merged 16 commits into
StreamController:mainfrom
morris178:pr/daemon-only-core

Conversation

@morris178

@morris178 morris178 commented Feb 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR introduces a --daemon-only runtime mode for StreamController and a set of related CPU/RAM/performance improvements. The goal is to keep the app running as a lightweight background daemon with lower baseline resource usage while preserving existing behavior when the UI is opened again.

The PR also includes page-switch latency improvements, safer resume/background reload handling, and memory/resource cleanup fixes (including bounded background video cache
memory).

How Daemon-Only Mode Works

When started with --daemon-only, StreamController starts the backend/runtime services and deck handling without eagerly constructing the full UI.

Key behavior:

  • The GTK application remains alive so the process can keep handling decks/events.
  • The main window/UI is created lazily only when needed (reopen/present flow).
  • Startup work that is unnecessary in daemon-only mode is skipped or deferred.
  • Plugin/service loading is reduced where possible to lower startup RAM/CPU.
  • Media loop behavior is adjusted to reduce idle CPU usage while preserving responsiveness for active media.

This allows StreamController to run in the background with significantly lower overhead than the full UI mode.

What’s Included

  • Daemon-only mode (--daemon-only) with lazy UI initialization
  • Fixes to keep GTK app lifecycle correct in daemon-only mode
  • Lower daemon CPU usage via adaptive media tick behavior
  • Faster page-switch task wake/sync handling
  • Skip unchanged page assets and add phase timing logs
  • Resume/unlock background reload fixes
  • Safer page-switch serial logging (avoid crashes)
  • Memory/resource fixes:
    • bounded in-memory log buffer
    • helper backend lifecycle cleanup / duplicate prevention
    • key video cache memory retention fix
    • bounded background video cache memory + media resource cleanup

Behavior / Compatibility

  • Default (non-daemon) mode continues to work as before.
  • Daemon-only mode is opt-in via CLI flag.
  • No UI behavior changes are required for normal startup usage.
  • Performance changes are designed to preserve previous behavior while reducing idle overhead and improving page transitions.

Testing Notes

Recommended review/test coverage:

  • Start normally (no --daemon-only) and verify baseline UI behavior
  • Start with --daemon-only and verify decks remain functional
  • Reopen/show UI from daemon-only mode and verify UI initializes correctly
  • Page switching latency and responsiveness on active decks
  • Suspend/resume and screen lock/unlock background restore behavior
  • Long-running daemon process memory/CPU stability

Copilot AI review requested due to automatic review settings February 22, 2026 12:15

Copilot AI 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.

Pull request overview

This PR introduces a daemon-only mode for StreamController with significant CPU/RAM optimizations and performance improvements. The daemon-only mode allows the application to run as a lightweight background service with lazy UI initialization, reducing resource consumption while maintaining full deck functionality.

Changes:

  • Added --daemon-only CLI flag that defers UI and non-essential service initialization until the window is opened
  • Implemented adaptive media player tick rates (8 FPS idle vs 30 FPS active) to reduce CPU usage when no animations are active
  • Added bounded memory management for logs (deque with maxlen=5000) and background video cache (configurable max frames)
  • Improved page-switch performance with signature-based change detection, faster task wake/sync, and concurrent input loading
  • Enhanced resume/unlock handling with forced background reload and better device reconnection logic
  • Added backend process lifecycle tracking and proper cleanup to prevent orphaned processes

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
globals.py Added --daemon-only CLI argument and changed logs to bounded deque(maxlen=5000)
main.py Added plugin preloading logic for daemon mode, deferred service initialization, and skipped store updates in daemon mode
src/app.py Implemented lazy UI initialization with ensure_main_window/ensure_ui_services pattern for daemon-only mode
src/backend/PluginManager/PluginManager.py Added selective plugin loading and ensure_action_holder_loaded for lazy plugin loading in daemon mode
src/backend/PluginManager/PluginBase.py Added backend process tracking and lifecycle management with spawn counting
src/backend/PluginManager/ActionCore.py Added backend process tracking and cleanup, implemented on_removed_from_cache
src/backend/PageManagement/Page.py Added lazy action holder loading for daemon mode, fixed action cleanup iteration
src/backend/PageManagement/PageManagerBackend.py Added cache clearing in daemon-only mode after page loads
src/backend/DeckManagement/DeckManager.py Added check_for_errors_if_window_ready guards, improved resume handling with BetterDeck rewrap and forced reload
src/backend/DeckManagement/DeckController.py Added adaptive media tick rates, page-switch timing instrumentation, signature-based background/screensaver change detection, thread pool for input loading, and label caching
src/backend/DeckManagement/Subclasses/key_video_cache.py Added close method for proper resource cleanup
src/backend/DeckManagement/Subclasses/background_video_cache.py Implemented bounded cache with OrderedDict, max_cached_frames setting, and comprehensive close method
src/backend/DeckManagement/Subclasses/ScreenSaver.py Added force_background_reload parameter to hide method
src/backend/DeckManagement/Subclasses/KeyVideo.py Added close method for video cache cleanup

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/backend/PageManagement/Page.py Outdated
Comment thread src/backend/PageManagement/Page.py Outdated
Comment thread src/app.py
Comment thread src/backend/PluginManager/PluginManager.py Outdated
Comment thread src/backend/PluginManager/PluginManager.py Outdated
Comment thread src/backend/DeckManagement/Subclasses/background_video_cache.py Outdated
Comment thread src/backend/DeckManagement/Subclasses/background_video_cache.py Outdated
Comment thread main.py
Comment thread main.py Outdated
Comment thread src/backend/DeckManagement/DeckController.py
@morris178

morris178 commented Feb 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Follow-up fixes have been added since the initial review pass. Please re-evaluate the PR based on the latest commits on pr/daemon-only-core (especially the most recent
fixes).

@copilot please re-evaluate this PR based on the recent commits.

Summary of follow-up changes:

  • Fixed cleanup callback ordering and redundant action cleanup assignment
  • Added documentation for daemon-only lazy action holder loading
  • Tightened a background video cache load race window
  • Restored in-memory log record format (kept bounded RAM log deque behavior)
  • Serialized action_index updates/lookups during lazy plugin loading
  • Fixed daemon-only lazy plugin loading for plugins whose action ID prefix does not match the plugin folder name (e.g. normalized namespace mapping)
  • Added lazy deferred-service initialization for daemon-only on-demand plugin loads
  • Fixed media loop early-wake behavior causing sustained CPU usage on plugin-heavy pages
  • Restored cached video page-switch performance:
    • removed background video frame trimming cap behavior
    • added video hash memoization
    • reused background video object/cache across page switches (standby reuse)

Testing updates:

  • --daemon-only startup and normal GUI startup both work without noticeable functional regressions
  • Reopening the UI while daemon-only is already running works as expected
  • Plugin-heavy page CPU regression from media-loop wakeups was fixed
  • Daemon-only lazy plugin loading now works for installed plugins with non-matching action-id namespace vs folder naming (status plugin case)

@ImDevinC

ImDevinC commented Feb 23, 2026 •

Copy link
Copy Markdown
Contributor

Looks good, haven't had a chance to dig in fully yet to review but the concept seems solid. But have you tested this through flatpak? I can't get this to launch at all, I get the following error.

Traceback (most recent call last):
  File "/app/bin/StreamController/main.py", line 46, in <module>
    from src.app import App
  File "/app/bin/StreamController/src/app.py", line 44, in <module>
    from src.windows.mainWindow.mainWindow import MainWindow
  File "/app/bin/StreamController/src/windows/mainWindow/mainWindow.py", line 33, in <module>
    from src.windows.mainWindow.elements.Sidebar.Sidebar import Sidebar
  File "/app/bin/StreamController/src/windows/mainWindow/elements/Sidebar/Sidebar.py", line 20, in <module>
    from src.backend.DeckManagement.DeckController import ControllerKey, DeckController
  File "/app/bin/StreamController/src/backend/DeckManagement/DeckController.py", line 1019, in <module>
    class Background:
  File "/app/bin/StreamController/src/backend/DeckManagement/DeckController.py", line 1029, in Background
    def _park_video(self, video: "BackgroundVideo" | None) -> None:
                                 ~~~~~~~~~~~~~~~~~~^~~~~~
TypeError: unsupported operand type(s) for |: 'str' and 'NoneType'

You can test in flatpak using the following GitHub action:

on:
  workflow_dispatch:
name: Build Flatpak Artifact
jobs:
  flatpak:
    name: Flatpak
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-48
      options: --privileged
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
      - uses: flatpak/flatpak-github-actions/flatpak-builder@v6
        with:
          bundle: com.core447.StreamController.flatpak
          manifest-path: com.core447.StreamController.yml
          cache-key: flatpak-builder-${{ github.sha }}

Then just make sure to update the very bottom of com.core447.StreamController.yml:

...
    sources:
      - type: git
        url: https://github.com/morris178/StreamController.git
        branch: pr/daemon-only-core

When you run the action, it will build the flatpak and store it as an artifact

@morris178

Copy link
Copy Markdown
Contributor Author

Looks good, haven't had a chance to dig in fully yet to review but the concept seems solid. But have you tested this through flatpak? I can't get this to launch at all, I get the following error.

First of all, thank you for taking time to review this PR.

I hadn’t tested the Flatpak build before. There was indeed an issue caused by Python version compatibility in a type annotation.

The Flatpak runtime uses an older Python version, and one of the annotations introduced in this PR used a partially quoted union type ("BackgroundVideo" | None), which fails at import time there. I’ve now fixed it in 58085a7 by quoting the full deferred union type.

I also tested a local Flatpak build + launch on my system, and it starts successfully now.


Likely unrelated to this PR:

I did run into some stale plugin IDs/metadata in my local Flatpak data, which caused attribution fetch 404s. Reinstalling the affected plugins via the Store page refreshed the metadata and resolved that.

@Core447

Core447 commented Mar 5, 2026

Copy link
Copy Markdown
Member

Looks impressive. Will look through it when I have time. In the future please open separate PRs for different changes.

import sys
import threading
import time
from collections import OrderedDict

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Of note, since Python 3.7, all dicts are ordered based on insertion order. A cache might have a more pythonic way to handle it?

if tile is not None:
tile.close()
self.last_tiles = []
self.cache = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

unless you are trying to gc the cache, this could also just be "self.cache.clear()", such that you keep the same cache from the beginning

@notmugi

notmugi commented May 12, 2026

Copy link
Copy Markdown

this seems fantastic. am i correct in assuming this PR solves the issue where memory usage persistently increases and never clears?

Core447 added a commit that referenced this pull request Aug 3, 2026
…in background mode) (#626)

* Hold Gio.Application while running in background mode

With keep-running enabled, closing the main window only hides it.
Gio.Application's reference counting can then decide the app is idle
and quit the process silently: the deck goes dark with no traceback
and no log line. Holding the application while keep-running is active
(and releasing it in on_quit) pins the lifecycle to the explicit quit
path instead.

Related to the lifecycle rework in #558; this is the minimal bugfix
for current main.

* Sync Gio.Application hold with keep-running when toggled at runtime

The hold taken in __init__ only reflected the setting at startup. If
keep-running was toggled afterwards (via the settings page or the
KeepRunningDialog shown on first close), the hold state never updated,
so the original silent idle-quit could still happen after enabling
keep-running mid-session, or the app could stay held longer than
intended after disabling it. Centralize the hold/release logic in
set_keep_running_hold() and call it from both places the setting can
change at runtime.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

---------

Co-authored-by: Core447 <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
Core447 added a commit that referenced this pull request Aug 6, 2026
)

* Enhance BetterDeck context management and add image hashing for MediaPlayer tasks

* Fix: Improve UI image handling in ControllerTouchScreen for better performance

* Fix(OnboardingWindow): Problems with Gnome extension hint

* Chore(DonationDialog): Remove option to comission custom plugins

* Feat(Settings): Add option to disable label scrolling

* Chore: Remove debug scroll print

* Chore(ActionChooser): Header not aligned properly

* Fix(locales): Wrong semicolons lead to weird parsing sometimes

* Fix: use correct method name for page lookup in --change-state (#570)

on_change_state in app.py and the api_state_requests handler in
DeckController.py both call get_best_page_path_match_from_name, which
does not exist on PageManagerBackend. The correct method is
find_matching_page_path, matching on_change_page and api_page_requests.

This causes --change-state CLI commands to fail silently (AttributeError
swallowed by GTK's action handler) when SC is running, and crash at
startup when SC is not running.

* Refactor: Enhance image handling and thumbnail generation in ScreenBar for improved performance

* Refactor: Optimize image handling and update scheduling in ScreenBar and IconSelector for improved performance

* Bump to 1.5.0-beta.14

* Remove pyproject.toml

* Update changelog

* Refactor: Adjust image sizing logic in ScreenBarImage for better responsiveness based on touchscreen dimensions

* feat(hyprland): use IPC socket events instead of polling for window changes

Replace the 200ms polling loop that spawns 'hyprctl activewindow -j' (via
flatpak-spawn + xdg-dbus-proxy when running in Flatpak) with a direct
connection to Hyprland's socket2 IPC event stream.

The old approach spawns ~5 processes/second even when no window changes occur,
causing 10-30% CPU usage on the flatpak-session-helper and xdg-dbus-proxy
(see #433 and #457). The new approach uses a blocking Unix socket read with
zero CPU when idle.

Changes:
- Listen for 'activewindow>>' events on Hyprland's .socket2.sock
- Auto-detect socket path via HYPRLAND_INSTANCE_SIGNATURE env var
- Fallback to legacy polling if socket is unavailable
- Add --filesystem=xdg-run/hypr:ro to Flatpak manifest for socket access
- Reconnect automatically if the socket is closed (e.g. compositor restart)

Fixes #433 (partially — eliminates the subprocess-spawning component)
Fixes #457 (eliminates the flatpak-spawn/xdg-dbus-proxy tight loop)

* perf: throttle media player to 2 FPS when no animated content

The MediaPlayerThread runs at 30 FPS unconditionally, iterating over all
keys/dials every ~33ms even when there is no video background, no key
videos, and no scrolling labels. On a 15-key Stream Deck MK.2, this
burns ~20% CPU in pure Python overhead for no visible benefit.

Add dynamic FPS: detect whether any animated content (video, scroll
labels) or pending image tasks exist. When idle, throttle to 2 FPS
(500ms sleep). Immediately return to 30 FPS when animation starts or
tasks are queued.

This preserves full responsiveness for video/animation while reducing
idle CPU from ~20% to ~1%.

* fix: reduce idle CPU usage from ~25% to ~3% (#579)

* fix: reduce KDE window grabber CPU usage by caching window id

Only fetch window name and class when the active window id changes.
Previously, every poll cycle (200ms) spawned 3 subprocesses via
flatpak-spawn/kdotool even when the active window hadn't changed.
Now only 1 subprocess runs per cycle in idle, with the other 2 only
triggered on actual window change.

* fix: cache get_has_scroll_labels to avoid repeated font rendering

get_has_scroll_labels() calls getbbox() on every label to measure text
width. This was called 30 times per second (MediaPlayer FPS) for every
key on the deck, causing ~1350 font rendering calls/sec in idle.

Cache the result and invalidate when labels actually change
(set_page_label, set_action_label, clear_labels).

* fix: reduce idle CPU usage with adaptive FPS and render caching

Three optimizations that together reduce idle CPU from ~25% to ~3%:

1. Adaptive MediaPlayer FPS: drop from 30fps to 2fps when no animated
   content (video/GIF) is active and no pending render tasks exist.
   Ramps back to 30fps instantly when needed.

2. Skip redundant key ticks: only iterate keys in the 30fps loop when
   at least one key has video/GIF content. Checked once per second.

3. Label dirty-check: set_action_label compares all label properties
   before triggering a full re-render. Plugins like CPUTemp that set
   the same label text every tick no longer cause unnecessary renders.

4. Image hash guard in ControllerKey.update(): hash the rendered image
   and skip native format conversion + deck write when unchanged.

---------

Co-authored-by: Stefan Toczek <[email protected]>

* Bump metainfo to beta 14

* Update kofi supporter list

* Update .gitignore

* Automatically fetch github contributors

* Feat: Add uninstall button to plugin settings page

* feat(hyprland): use IPC socket events instead of polling for window changes

Replace the 200ms polling loop that spawns 'hyprctl activewindow -j' (via
flatpak-spawn + xdg-dbus-proxy when running in Flatpak) with a direct
connection to Hyprland's socket2 IPC event stream.

The old approach spawns ~5 processes/second even when no window changes occur,
causing 10-30% CPU usage on the flatpak-session-helper and xdg-dbus-proxy
(see #433 and #457). The new approach uses a blocking Unix socket read with
zero CPU when idle.

Changes:
- Listen for 'activewindow>>' events on Hyprland's .socket2.sock
- Auto-detect socket path via HYPRLAND_INSTANCE_SIGNATURE env var
- Fallback to legacy polling if socket is unavailable
- Add --filesystem=xdg-run/hypr:ro to Flatpak manifest for socket access
- Reconnect automatically if the socket is closed (e.g. compositor restart)

Fixes #433 (partially — eliminates the subprocess-spawning component)
Fixes #457 (eliminates the flatpak-spawn/xdg-dbus-proxy tight loop)

* Add based API for #548 (and other future applicaations) (#551)

The main component of this change is adding api.py, a dasbus based dbus API server with the following features:
* icon pack enumeration
* page enumeration
* addpage(name, json)
* removepage
* streamdeck enumeration
* export the datadir location (for eventual use adding other metadata/backgrounds on-the-fly)

To support this feature a small number of changes were needed elsewhere:
* change add_page so it can throw if the page already exists (the threeish usages
elsewhere in the app now have a try/catch to handle that)
* The flatpak yaml had to be updated to add dasbus (and since the tool is now newer it emitted slightly different yaml)

Co-authored-by: Core447 <[email protected]>

* Improve page swich speeds

* Bump runtime version to 50

* Bump to beta.15

* Fix syntax error in metainfo

* add logind detection for Niri (#632)

* Adding Restart option in tray.py and fixing Open button in Data Path (#617)

* Update tray.py

added an option to Quit and Restart

* Update Settings.py

Fixed the "Open" button for Settings > Developer > Data Path. Will open user default file manager at set location.

* Update Settings.py

updated for beta.15

* Update Settings.py

updated for beta.15

* Delete src/windows/Settings/Settings.py

* Add files via upload

* Fix semaphore leak (#598)

* fix: restore detached subprocess launch in HelperMethods.run_command

Agent-Logs-Url: https://github.com/gensyn/StreamController/sessions/62cf1117-382f-42a9-a4c1-934f159d8374

Co-authored-by: gensyn <[email protected]>

* Initial plan

* Fix open_web command injection: use argv list instead of shell=True

Agent-Logs-Url: https://github.com/gensyn/StreamController/sessions/d37f03d2-b8d2-4224-bec5-825a7da82f80

Co-authored-by: gensyn <[email protected]>

* Removed run_command and updated open_web

* Added try/except to open_web

* Re-introduced run_command

* Re-introduced run_command

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>

* feat(Store): Add Git Branch Selection for Plugins in Store UI (#540)

* feat(Store): Add Git branch override functionality for plugins

- Implement methods to get, set, and remove Git branch overrides for plugins in StoreBackend.
- Enhance InfoPage and SourceGroup to allow users to select between store version and specific Git branches.
- Update localization files to support new UI elements related to Git branch selection.
- Ensure proper handling of plugin data and UI updates when switching between sources.

* feat(Store): Enhance plugin source selection with Git tags support

* Make plugin info page srollable

* Chore(InfoPage): Hide plugin branch selector for custom plugins

* Chore(InfoPage): Remove apply and status rows

* Feat(InfoPage): Add branch caching to avoid rate limiting

---------

Co-authored-by: Core447 <[email protected]>

* [Bug Fix]: Stabilize resume by keeping controllers alive during transient HID errors (#526)

* Fix resume stability by keeping controller alive

* Gate touchscreen transient-error handling behind beta-resume-mode

Address review feedback: only skip the removal/reconnect fallback
when "Use new resume mode (beta)" is enabled, mirroring the existing
behavior in MediaPlayerSetImageTask. Keeps the ability to reset a
genuinely stuck deck when beta-resume mode is off, and raises the
failure threshold from 5 to 10 as suggested in review. Also fixes
n_failed_in_row being shared across all decks instead of tracked
per serial number.

---------

Co-authored-by: Core447 <[email protected]>

* Hold Gio.Application while keep-running is enabled (silent idle-quit in background mode) (#626)

* Hold Gio.Application while running in background mode

With keep-running enabled, closing the main window only hides it.
Gio.Application's reference counting can then decide the app is idle
and quit the process silently: the deck goes dark with no traceback
and no log line. Holding the application while keep-running is active
(and releasing it in on_quit) pins the lifecycle to the explicit quit
path instead.

Related to the lifecycle rework in #558; this is the minimal bugfix
for current main.

* Sync Gio.Application hold with keep-running when toggled at runtime

The hold taken in __init__ only reflected the setting at startup. If
keep-running was toggled afterwards (via the settings page or the
KeepRunningDialog shown on first close), the hold state never updated,
so the original silent idle-quit could still happen after enabling
keep-running mid-session, or the app could stay held longer than
intended after disabling it. Centralize the hold/release logic in
set_keep_running_hold() and call it from both places the setting can
change at runtime.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

---------

Co-authored-by: Core447 <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>

* fix: repair page import and reload callbacks (#633)

* fix: Add proper GIF support with transparency and per-frame timing (#595)

* fix: Add proper GIF support with transparency and per-frame timing

GIFs were previously decoded using OpenCV (cv2), which does not support
GIF transparency or animation metadata. This caused two issues:
- Transparent GIFs rendered with a white background
- All GIF frames played at a fixed FPS regardless of per-frame delays,
  causing animations to play at the wrong speed or appear cut short

Changes:
- key_video_cache.py: Detect GIFs by file extension and decode them
  using PIL instead of cv2. Convert each frame to RGBA to preserve
  the alpha channel. Cache GIF frames as PNG instead of JPEG to retain
  transparency. Read per-frame delays from GIF metadata and store them
  in frame_delays for use during playback.
- KeyVideo.py: Add _get_next_gif_frame() which advances playback based
  on per-frame delay in milliseconds rather than a fixed FPS value.
  This correctly handles GIFs with variable frame timing (e.g. a long
  first frame followed by short animation frames).

* perf: Non-blocking GIF loading with static preview, delay caching and deduplication

Loading pages with multiple animated GIFs caused multi-second freezes because
all frames were decoded synchronously on the calling thread.

- Render frame 0 synchronously in __init__ so every button shows a static
  preview immediately; all remaining work runs in a background daemon thread
- Save per-frame delays to delays.json next to the cached frames so the slow
  PIL seek-loop only runs once; subsequent loads read the JSON instantly
- Add VideoFrameCache.get_or_create() registry so the same GIF+size is only
  loaded once even when multiple buttons reference the same file; use it in
  InputVideo to avoid redundant work on page switches

* fix: Prevent cache-write race condition (segfault) and improve GIF timing accuracy

key_video_cache.py: Replace the single global registry lock with a
per-key initialization lock. The previous approach ran __init__ (including
Image.open and LANCZOS resize) outside the global lock, allowing two threads
to create instances for the same GIF simultaneously and write to the same
cache files concurrently — corrupting PNGs and causing segfaults in PIL.
The per-key lock ensures only one thread initialises a given (path, size)
at a time without blocking unrelated keys.

KeyVideo.py: Replace tick-based GIF timing with time.perf_counter(). The
previous implementation accumulated elapsed time in media player ticks,
which caused playback to drift when the media player was under CPU load.
Also replace the single if with a while loop so multiple frames are skipped
when catch-up is needed, keeping the animation in sync with real time.

* fix: include file path in video cache key to avoid collisions

_fast_cache_key() hashed only mtime+size, and the on-disk cache dir was
keyed on that hash alone (no path). Two different files sharing size and
mtime (e.g. assets bulk-extracted from a zip with identical timestamps)
would silently read/write each other's cached frames. Folding the
absolute path into the hash keeps the fast stat()-only lookup while
requiring an actual path+size+mtime match.

* fix: free video caches once no button references them anymore

_registry was a plain dict, so every distinct (path, size) VideoFrameCache
ever shown stayed fully decoded in memory for the life of the app, even
after the button using it was gone. Using a WeakValueDictionary lets an
entry disappear once nothing holds a strong reference to it, matching the
intended "shared while in use, freed when unused" behavior instead of
leaking indefinitely.

* fix: preserve transparency and correct colors for GIF backgrounds

background_video_cache.py decoded all backgrounds via cv2.VideoCapture,
which loses alpha and mangles colors for GIFs. Decode .gif backgrounds
with PIL instead, keeping the existing tick-driven frame advancement.

BackgroundVideo.crop_key_image_from_deck_sized_image (DeckController.py)
is the method that actually runs at playback time (it overrides the base
class's version), and it pasted every background frame onto an opaque
RGB key image before this, dropping alpha regardless of how the frame was
decoded. Return the cropped RGBA segment directly instead, matching how
BackgroundImage already handles static image backgrounds.

---------

Co-authored-by: Core447 <[email protected]>

* Fix GTK calls from non-main threads (SIGSEGV class of #566) (#625)

Three remaining sites make GTK4 widget calls from background threads,
which corrupts GTK state and crashes with a silent SIGSEGV and no
Python traceback, the same failure class fixed for MediaPlayerThread
in #566:

- DeckManager.remove_controller(): called from
  FlatpakDeckDisconnectThread / udev callbacks; deck_stack.remove_page()
  now goes through GLib.idle_add, mirroring how
  add_newly_connected_deck() already handles add_page().
- MainWindow.show_info_toast(): callers include background threads
  (e.g. update_assets in main.py); the Adw.Toast is now constructed and
  added on the main loop.
- MissingRow.hide_install_error(): runs on a threading.Timer thread;
  the four widget calls are now dispatched via GLib.idle_add.

* Don't attach dbus-python to the GLib main loop (fixes random SIGABRT in dbus_watch_handle) (#634)

* Don't attach dbus-python to the GLib main loop

dbus-python's GLib integration (dbus-gmain) is not thread safe. watch_toggled
implements watch enable/disable as a full GSource add/remove, and both paths
mutate an unlocked GSList on the connection from whichever thread sent the
message. The main loop dispatches those same sources, and io_handler_dispatch
calls dbus_watch_handle(handler->watch) with no NULL check. Once anything
touches D-Bus off the main thread the process eventually aborts:

  dbus[3]: arguments to dbus_watch_handle() were incorrect, assertion
  "watch != NULL" failed in file ../dbus/dbus-watch.c line 738.

The deck tick threads reach D-Bus through plugins (MediaPlugin and Battery both
call it from on_tick), so this shows up as a random SIGABRT after hours or days
of uptime, with nothing in the logs because the abort is in native code.

Setting DBusGMainLoop as the default attaches every dbus.SessionBus() in the
process, including the shared one plugins get. Without it, no GSources are
created and the crash is unreachable.

* main.py: drop DBusGMainLoop(set_as_default=True) and the unused dbus.service
  import. dbus-python is now only used for blocking method calls, which never
  needed a main loop.
* LockScreenDetector: add subscribe_to_screen_saver() built on GDBus
* Detectors/{KDE,Gnome,Cinnamon}: use it instead of add_signal_receiver, and
  stop re-installing the main loop from the LockScreenManager setup thread
* WindowGrabber/Integrations/Gnome: port to Gio.DBusProxy

All 59 plugins in the official store were checked for APIs that need the main
loop (add_signal_receiver, connect_to_signal, reply_handler, dbus.service).
None use any of them. The three that use dbus-python at all (MediaPlugin,
GnomeWindowCalls, Battery) only make blocking calls and keep working unchanged.

* Add regression guard for the dbus-python main loop crash

Static check over main.py, src/ and GtkHelper/ for dbus-python APIs that only
work on a connection attached to a GLib main loop (DBusGMainLoop, dbus.mainloop,
set_default_main_loop, add_signal_receiver, connect_to_signal, reply_handler,
dbus.service). Those are what make a background D-Bus call able to abort the
process in dbus_watch_handle(), so blocking method calls are all that is left
allowed. Fails on 129bdb5, passes now.

stdlib unittest, no new dependency:

    python3 -m unittest discover -s tests -t .

Also adds tests/manual_dbus_thread_stress.py, which reproduces the shape of the
race (attached shared connection, main loop dispatching its watches, background
threads sending large messages). Kept out of the automated suite because hitting
it depends on winning the race, it ran for 15 minutes and 9M calls here without
aborting, while the real crash took just over 5 days of uptime.

* End the files touched by this branch with a trailing newline

* Port LogindLockScreenDetector off the dbus-python main loop too

Logind.py was added by #632 after this branch was cut, so it wasn't
covered by the earlier audit here: it still called
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) and
connect_to_signal() for the Lock/Unlock signals, on the
LockScreenManager setup thread. That reinstalls the exact non-thread-safe
main loop this branch removes, and LockScreenManager tries logind before
falling back to the DE-specific detectors, so it's the active path on
most systemd-based distros.

Switch the Lock/Unlock subscriptions to Gio.DBusConnection.signal_subscribe
on the system bus, same pattern already used for the screen-saver
detectors. Session-path resolution stays on dbus-python since it's a
blocking call and works fine without an attached main loop.

---------

Co-authored-by: Core447 <[email protected]>

---------

Co-authored-by: Core447 <[email protected]>
Co-authored-by: Ty Smith <[email protected]>
Co-authored-by: Luc LETOFFE <[email protected]>
Co-authored-by: oneandonlyno1 <[email protected]>
Co-authored-by: Stefan Toczek <[email protected]>
Co-authored-by: geeksville <[email protected]>
Co-authored-by: Nicolò Maria Semprini <[email protected]>
Co-authored-by: tsunpot <[email protected]>
Co-authored-by: gensyn <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Pavel Savinov <[email protected]>
Co-authored-by: Stevo <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Pieter de Villiers <[email protected]>
Co-authored-by: David F <[email protected]>
Co-authored-by: jslay88 <[email protected]>
# Conflicts:
#	src/app.py
#	src/backend/DeckManagement/DeckController.py
#	src/backend/DeckManagement/DeckManager.py
#	src/backend/DeckManagement/Subclasses/KeyVideo.py
#	src/backend/DeckManagement/Subclasses/background_video_cache.py
#	src/backend/DeckManagement/Subclasses/key_video_cache.py
#	src/backend/PluginManager/ActionCore.py
#	src/backend/PluginManager/PluginBase.py
Autostart (native desktop entry and Flatpak background portal) now
launches with --daemon-only instead of -b, so the deferred UI/plugin
loading and idle-tick throttling this PR adds actually apply to the
common "start on login" case.

setup_autostart() runs unconditionally on every app start and always
overwrites the existing autostart desktop entry / re-requests the
Flatpak background permission, so users upgrading from an older
version pick up the new flag the next time they run the app (manually
or via their still-old autostart entry) - no separate migration step
needed. The one gap: a session that's been autostarted under an older
version and never restarted keeps running with the old flag until its
next launch.
@Core447

Core447 commented Aug 10, 2026

Copy link
Copy Markdown
Member

Amazing work! Makes the app feel way more responsive. Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

6 participants