Skip to content

feat: auto-hibernate idle background services, keeping chat apps live - #12

Merged
nicojan merged 1 commit into
nicojan:mainfrom
marcioviniciusspiridigliozzi-dot:feat/auto-idle-hibernation
Jul 23, 2026
Merged

feat: auto-hibernate idle background services, keeping chat apps live#12
nicojan merged 1 commit into
nicojan:mainfrom
marcioviniciusspiridigliozzi-dot:feat/auto-idle-hibernation

Conversation

@marcioviniciusspiridigliozzi-dot

Copy link
Copy Markdown
Contributor

First, thank you for the care you put into reviewing the earlier six. Merging five so quickly, and gating #10 on a real by-hand layout check, is exactly the kind of review I hoped for. It made the whole thing feel worthwhile.

Please treat this one as entirely optional. It scratches a personal itch more than it fixes a general problem: I work with AI tooling all day, my RAM is always under pressure, and I am the kind of person who watches Activity Monitor for sport. This grew out of that. If it doesn't fit your vision for Chorus, closing it costs me nothing — I mainly wanted to offer it in case it's useful to others like me.

What it does

The pool keeps up to maxLoaded (15) services fully resident, and switching away only soft-hibernates: it suspends media and snapshots the view, but the WebContent process keeps running. So on a typical setup every service you have ever opened this session stays live in the background, each running its web app. With ~10 services that is ten web apps' worth of CPU and RAM held at all times. Full hibernation (freeing the process) only happens when you exceed 15 services, or when you hibernate one by hand.

This adds an opt-in idle sweep: a service backgrounded longer than a threshold is fully hibernated, freeing its process, and wakes from its snapshot when you return to it.

Why it can be on without missing messages

This was the part I cared most about getting right. A fully hibernated web app cannot fire a real-time notification — it can only refresh its unread badge on the existing 60-second poll. For a chat app you rely on for work, that delay is unacceptable.

So the sweep never touches a service in the catalog's Messaging category (Slack, Teams, WhatsApp, Discord, …). Those stay fully live and notify instantly. It also skips:

  • any service marked Keep Loaded (neverHibernate), which covers custom, non-catalog chat apps you add yourself;
  • any service with an active call (reusing the same hasActiveCall guard the cap-based eviction already applies), so a hibernation pass can't drop a live huddle.

Everything else — email, docs, music, AI tabs — hibernates when idle and updates its badge within a minute. In my own use this took the resident-service count from ten down to the three chat apps plus whatever I'm actively looking at, which is a large, sustained RAM and CPU drop while losing nothing I need in real time.

How it's wired

  • No new badge plumbing. Auto-hibernation calls the existing hibernate(), which already fires onServiceHibernated and registers the service with HibernatedBadgePoller. An auto-hibernated service keeps its unread badge exactly as a manually hibernated one does.
  • Selection stays in the pool, category logic in AppState. WebViewPool.idleServiceIDs(idleFor:now:) answers only what the pool can — not active, not neverHibernate, not pinned, loaded, and idle past the threshold. AppState then applies the Messaging-category exemption (it has the catalog + model context) and the async call check before hibernating. The pool stays about web views; the policy lives where the data does.
  • A periodic sweep, mirroring the existing startQuietHoursTimer (a 60s Task loop), running only while the feature is enabled.

Preferences and migration

Two new Optional prefs — autoHibernateIdleEnabled and autoHibernateIdleMinutes — following the same pattern as autoDarkModeEnabled and the others, so SwiftData lightweight migration is a no-op on existing stores. Off by default (an upgrade changes nothing until asked). A Settings > General > Performance toggle turns it on, with an interval picker (5 minutes to 1 hour, default 10). The minutes value is clamped to 1...120 on read, so a corrupt row can't set a zero or negative sweep.

What I verified, and what I didn't

Ran the app with a 1-minute interval: the seven non-messaging services hibernated on the sweep — WebContent processes dropped from 22 to 13, main-process CPU fell to roughly zero — while Slack, Teams and WhatsApp stayed live and kept notifying. Four tests cover the preference resolution, the interval clamp, and the Messaging-category classification the exemption depends on. Full suite green (112).

I did not stress the interaction with the cap-based eviction under a rapidly changing service set, or test a service that is in a call and idle past the threshold beyond the single guard. Both look correct by reading, but I'd rather flag it than claim more than I checked.

@nicojan

nicojan commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and no need to frame it as optional. The reasoning is the strongest part: exempting the Messaging category and keeping the Keep Loaded escape hatch is the right way to hibernate hard without dropping a real-time chat alert, and reusing hibernate() so the badge poller keeps running is exactly right. It compiles on current main and the suite is green here too (112).

One real bug, and it is the interaction you flagged yourself. evictIfNeeded in the same file re-validates after its await hasActiveCall, with this note:

The await above is a suspension point: the user may have switched to this service (making it active) [...] Re-validate the eviction guards so we never hibernate the service the user is now viewing.

hibernateIdleServices awaits hasActiveCall and then calls hibernate(id) with no such re-check. hasActiveCall can suspend for up to 2 seconds on its own timeout, and in that window the user can switch to a candidate and make it active. The post-await hibernate(id) then tears down the service they are now looking at, because hibernate guards only on the web view existing. It never checks whether the service is active. So a service can hibernate out from under someone right as they open it.

The fix is the guard the eviction loop already uses: after the await, re-check id != activeServiceID (and, to match fully, pinned / neverHibernate / still-loaded) before hibernating. Doing that re-validation inside the pool is probably cleanest, so both paths share one copy.

Two smaller notes, neither blocking:

  • isNotificationCritical fetches every service each sweep; you already have fetchService(id:) for a keyed lookup.
  • Email hibernating, so its badge lags up to a minute, is a product call. It is opt-in and documented, so I think it is fine, but it is the one behaviour a user might not expect.

I am holding the merge on the active-service re-check plus a real-app run, the same bar as #10. Add the guard and I will take it through.

The pool keeps up to 15 services fully resident (`maxLoaded`), and
switching away only soft-hibernates — it pauses media but leaves the
WebContent process running. So on a typical setup every service you've
opened stays live indefinitely, each running its web app in the
background. With ~10 services that is ten web apps' worth of CPU and RAM
at all times, which drains the battery.

This adds an opt-in idle sweep: a service backgrounded longer than the
threshold is fully hibernated, freeing its process, and wakes from its
snapshot when you return. Off by default (new `Optional` prefs, so
SwiftData lightweight migration is a no-op on existing stores); a
Settings > General > Performance toggle turns it on, with an interval
picker (5 min to 1 hour, default 10).

The reason it can be on without missing messages: a hibernated web app
can't fire a real-time notification — it can only refresh its unread
badge on the existing 60s poll. So the sweep never touches a service in
the catalog's **Messaging** category (Slack, Teams, WhatsApp, Discord,
…). Those stay fully live and notify instantly. It also skips any
service marked "Keep Loaded" (covering custom, non-catalog chat apps)
and any service in an active call, reusing the guards the cap-based
eviction already applies. Everything else — email, docs, music, AI —
hibernates when idle and updates its badge within a minute.

Hibernation reuses the existing `hibernate()` path, so a hibernated
service is registered with `HibernatedBadgePoller` exactly as a manually
hibernated one is; no new badge plumbing.

Verified by running the app with a 1-minute interval: the seven
non-messaging services hibernated on the sweep (WebContent processes
dropped from 22 to 13, main-process CPU fell to ~0%), while Slack, Teams
and WhatsApp stayed live. Four tests cover the preference resolution,
the interval clamp, and the Messaging-category classification the
exemption depends on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@marcioviniciusspiridigliozzi-dot

Copy link
Copy Markdown
Contributor Author

Good catch, and thank you — that is the exact race I waved at and did not pin down. You are right that hibernate(id) guards only on the web view existing, so the post-await call could tear down a service the user just switched to.

Fixed by pulling the re-validation into the pool, as you suggested, so both paths share one copy. New hibernateIfStillIdle(_:) does the call check and then re-checks every guard (active / pinned / neverHibernate / still-loaded) after the hasActiveCall await, with no suspension before hibernate. It also participates in evictionInFlight, so the idle sweep and the cap sweep can no longer race the same id. evictIfNeeded now calls it too, dropping its own inline copy of the re-check — its cap-based webViews.count > maxLoaded break stays in the loop, since that part is specific to eviction.

hibernateIdleServices in AppState is now just: select idle candidates, skip the Messaging category, and await hibernateIfStillIdle(id). The active-service protection lives entirely in the shared pool method.

Also took the smaller note: isNotificationCritical now uses fetchService(id:) instead of fetching every service each sweep. Left the email-badge-lag behaviour as is, per your read — it is opt-in and documented.

Verified in a real run with a 1-minute interval: the seven non-messaging services hibernated (WebContent 27 → 18), the active service stayed live, and no messaging app was touched. Suite still green (112). One clean commit, force-pushed.

On the unit-test front — I could not add one for the race itself: WebViewPool needs live WebKit data-store / web-view dependencies and nothing in the suite instantiates it, so there is no seam to drive hibernateIfStillIdle from a test without mocking infrastructure that does not exist yet. The fix instead reuses the exact re-validation evictIfNeeded already relied on, and I leaned on the real-app run you asked for. Happy to add a pool test seam in a separate change if you would want one.

@nicojan

nicojan commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Reviewed the new revision, and the fix is right.

hibernateIfStillIdle re-checks every guard after the hasActiveCall await and then calls hibernate with no suspension in between. On the main actor that means nothing can interleave between the active-service check and the teardown, which closes the exact race: a service the user switches to during the call probe can no longer hibernate under them. Folding both the idle sweep and the cap sweep through the one method, with evictionInFlight living on it, means the two passes share a single guard and can't race the same id. Keeping the webViews.count > maxLoaded break in evictIfNeeded is right too, since that part is eviction-specific and belongs in the loop. The fetchService(id:) switch is in.

It builds on current main here and the suite is green (112).

On the missing unit test, your read is fair. WebViewPool has no test seam today, and the fix reuses the re-validation evictIfNeeded already relied on, so I'm satisfied without one. A pool seam is worth doing later, but it doesn't gate this.

That leaves the by-hand run, the same bar as #10. I'll take it through that and merge if it holds. Thanks for the quick, clean turnaround.

@nicojan

nicojan commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Verified end to end, and merging.

Two things blocked the obvious check. This app's SwiftUI controls aren't in the accessibility tree, so I couldn't drive the Settings toggle from automation, and the ad-hoc debug build gives me no log channel to read. A raw WebContent process count doesn't settle it either: WebKit reclaims idle renderers on its own, so with the feature off the count still fell from 27 to 22 over the same idle window.

To get a clean read I added a short-lived trace to the sweep (reverted after) and watched two passes, active service Gmail, one-minute idle threshold:

  • ChatGPT and Claude, both idle and non-messaging, were hibernated.
  • Discord was an idle candidate in both sweeps and got skipped as notification-critical each time. Slack never became a candidate at all.
  • Gmail, the active service, never entered the candidate set.

So the sweep fires on its timer, tears down idle non-chat services, spares the Messaging category even when those are idle, and leaves the service in front untouched. The app stayed up across both passes. That matches the design, and with the race fix and the green suite (116 on the merge) I'm satisfied. Merging now. Thanks again.

@nicojan
nicojan merged commit de95ad6 into nicojan:main Jul 23, 2026
nicojan added a commit that referenced this pull request Jul 23, 2026
Findings from a four-dimension review after merging external
contributions (#6-9,#11,#12) plus #13. Each verified against source.

Security:
- FaviconFetcher: re-validate every HTTP redirect hop with isFetchableIconURL
  via a per-task URLSession delegate. The guard previously checked only the
  first hop, so a public-host icon href could 302 to loopback/intranet (SSRF).
- WebViewCoordinator.belongsToService: reduce hosts with the public-suffix-aware
  captureRegistrableDomain instead of the naive effectiveDomain, so shared
  multi-tenant hosting suffixes (*.vercel.app, *.github.io, *.pages.dev,
  *.workers.dev) no longer collapse to the bare suffix and let an attacker
  sibling load in a service's authenticated web view. Aligns link routing with
  the stricter capture-trust check.
- WebViewCoordinator step 1: only hand a non-web scheme (mailto/tel/facetime/
  imessage/...) to NSWorkspace.open on a .linkActivated click, so a page can't
  spam compose/call prompts via programmatic navigation.

Correctness:
- Extend the Messaging notification-critical exemption to the LRU cap-based
  eviction path, not just the idle-timer sweep. Chat apps now stay live even
  past maxLoaded, matching the Settings promise. Pool caches the classification
  via an AppState-provided isNotificationCritical closure.
- ServiceSidebarView.moveService: guard the space's modelContext before reading
  .space.id, matching the file's other traversals, so a dangling link in the
  query snapshot can't crash Move-to-Space.
- Correct the hibernated-badge cadence copy and comments: the transient sweep is
  every few minutes (180s) and raise-only, not 'about once a minute'.

Tests: add belongsToService shared-hosting-tenant cases. Full suite 120 green.
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