Skip to content

fix(ui): tear down exclusion-preview listeners lost to an unmount race - #206

Merged
pmaxhogan merged 1 commit into
mainfrom
fix/macos-memory-blowup
Jul 29, 2026
Merged

pmaxhogan merged 1 commit into
mainfrom
fix/macos-memory-blowup

Conversation

@pmaxhogan

@pmaxhogan pmaxhogan commented Jul 29, 2026

Copy link
Copy Markdown
Owner

The 60GB was not Driven

Investigated as a P0 memory blowup in the app. It was not the app. Primary
evidence, from an artifact the OS wrote during the incident itself:
/Library/Logs/DiagnosticReports/JetsamEvent-2026-07-29-114254.ips, a kernel
memory-pressure snapshot that records every process's footprint.

At that moment (73.2 GB of total system footprint):

process pid footprint
node x11 77048-77118 4460-4532 MB each, 49.7 GB total
node (all 39, incl. the above) - 51.3 GB
WindowServer 422 1013 MB
driven-app 56224 45.9 MB
com.apple.WebKit.WebContent (the app's webview) 63448 42.8 MB
cargo-tauri 54805 32.1 MB
cargo x2 80745, 81134 132.5 + 123.1 MB

The report's own largestProcess field is node.

The whole cargo tauri dev tree is identifiable and contiguous in that
snapshot - zsh 54803 -> cargo-tauri 54805 (32.1 MB) -> the vite chain
node 54953/54959/55017 (62.4 + 55.8 + 167.6 MB) -> driven-app 56224
(45.9 MB) -> WebKit WebContent/GPU/Networking 63446-63448 (42.8 + 16.8 +
7.2 MB). ~431 MB for everything Driven owned, after an hour of running.

The eleven giants are pids 77048-77118, a separate burst ~53 minutes later.

The eleven big node processes were spawned in a single burst at 11:39:59 and
all died the same way: six crash reports in ~/Library/Logs/DiagnosticReports/
show SIGABRT through node::OOMErrorHandler ->
v8::internal::Heap::FatalProcessOutOfMemory, i.e. each one hit V8's ~4.5 GB
old-space ceiling. Their parent had already exited (all six report
parentProc: launchd), so they were orphaned workers of a pool whose
supervisor was gone. Crash reports do not record argv, and the burst started
almost an hour after the app did, so they are not the app's vite dev server -
that was a separate ~170 MB node in the same snapshot.

What the app actually did

~/Library/Application Support/app.driven/logs/driven.2026-07-29.log covers
the incident run exactly:

15:46:39.081Z  rolling file logs active
15:46:39.289Z  assembling per-account orchestrators accounts=0 sources=0
15:46:39.298Z  updater periodic check started interval_secs=21600
15:46:39.298Z  telemetry ping task started interval_secs=86400
15:46:39.881Z  add-account wizard session opened
   ... one hour of complete silence ...
16:47:34Z     (a different build's first line)

So the app booted with zero accounts and zero sources, parked on the
add-account wizard, and logged nothing for the next hour.

Reproduction

Ran cargo tauri dev from this worktree and reached the identical state
(accounts=0 sources=0, wizard session opened), then sampled the whole
process tree every 10s. Over ~20 minutes idle on that screen:

  • driven-app: 141 MB -> 144 MB
  • its WebContent: 76 MB -> 76 MB

Flat. No growth path exists in that state to begin with: with no accounts and
no sources there is no scanner, no FSEvents watcher, and no tray sync
animation, and the two periodic tasks that do start fire at 6h and 24h.

Suspects ruled out

  • feat(ui): instant exclusion-preview re-evaluation from an in-memory tree #177 (exclusion-preview in-memory tree) - needs a configured source; the
    incident had none. The cache is hard-capped at 4M entries and frees
    everything on overflow (preview_cache.rs:178-203). Real worst case is a
    few hundred MB, and only while the editor is open. (It did contain a
    separate, real leak - see below.)
  • feat: persist rolling backend logs and capture frontend console into diagnostics #167 (rolling logs + console capture) - frontend ring is 500 entries x
    2000 chars, ~1 MB ceiling; the backend appender is lossy-bounded at 128k
    buffered lines. On-disk log for the whole incident run was 1.1 KB.
  • Scanner / watcher - never ran (sources=0).
  • Dev-build overhead - the debug build measured 46 MB in the field and
    141 MB under my own dev run.

Also ran this repo's UI test suite (43 files, 530 tests) directly: 4.3s, no
worker anywhere near a GB. It is not the source of the eleven OOMing workers.

What this PR fixes

A real, unbounded leak found while ruling out suspect #177. It is not the
cause of the 60 GB event
- it is bounded per open/close by
NODE_STREAM_CAP and needs a lost race to trigger - but it is genuinely
unbounded over a session and it lives in exactly the code that was suspected,
so it should not be left in.

ExclusionPreviewTree subscribes in onMounted via an awaited
preview.subscribe() (three listen() round-trips) and stores the teardown
handle afterwards. onUnmounted only calls the handle if it is already set.
A component unmounted inside that window - and the editor mounts under v-if
in both SourceTable and AddSourceWizard, so open-then-close is ordinary
use - therefore tore down nothing, and the three listeners resolved into a
permanently unreachable closure.

That would be a bounded one-time cost if the listeners were scoped, but
onExclusionPreviewBatch and friends use a plain listen(name, cb)
(ipc/events.ts:153-174), which registers globally by event name. So the
orphan keeps receiving every later preview's exclusion_preview:batch. Its
currentId never resolves, so ingestBatch takes the pre-id park branch - an
array only its own start() can drain. Every batch of every future preview
accumulated there for the life of the process. The park's doc comment claimed
it "cannot grow without limit"; that was true only for a controller that goes
on to resolve an id.

Two changes:

  1. ExclusionPreviewTree.vue - guard the race with the same shape
    activity.ts:640-668 already uses: flip a subscribeWanted intent flag,
    re-check it after the await, and invoke the resolved unlisteners inline if
    it flipped. Also suppresses the restart() that would otherwise start a
    full walk for a tree nobody is rendering.
  2. exclusionPreview.ts - cap the pre-id park at PRE_ID_PARK_CAP (256, vs
    the ~125 batches one generation can legitimately produce), dropping the
    newest over the cap so the breadth-first ancestors are preserved and the
    overflow degrades to the already-handled truncated case. Defence in
    depth: it also bounds the other way to reach this state, a rejected
    previewExclusionsStart.

Regression tests

Three, all verified failing before the change (git stash of the two source
files, tests kept):

  • tears down every listener when unmounted while subscribe is still in flight - gates listen() on a promise, unmounts inside the window, asserts
    all three unlisten spies fire and that no walk is started.
    Before: expected "spy" to be called 1 times, but got 0 times.
  • caps the park so a controller that never resolves an id cannot grow without bound - drives a controller whose start rejects, fires 4x the cap in
    batches, pins the retained count at PRE_ID_PARK_CAP.
  • still parks and replays everything that arrives before a real id lands -
    the legitimate park path still drains and folds into the tree.

Plus tears down every listener on an ordinary unmount, which passes both
ways and pins the non-racing path.

Gates

vitest 530 passed, prettier --check, eslint, vue-tsc --noEmit,
cargo fmt --all --check, cargo clippy --workspace --all-targets -D warnings, cargo test --workspace - all clean.

ExclusionPreviewTree subscribes in onMounted via an awaited subscribe(),
but onUnmounted only tore down the handle if the await had already
resolved. A component unmounted inside that window (the editor opens and
closes on a v-if, so this is ordinary use) left all three listeners
registered for the life of the process.

Those listeners are global by event name, so the orphan keeps receiving
every later preview's exclusion_preview:batch. Its generation id never
resolves, so ingestBatch takes the pre-id park branch - an array only its
own start() can drain. Every batch of every future preview therefore
accumulated in an unreachable array.

Guard the race the way activity.ts already does (intent flag re-checked
after the await, resolved unlisteners invoked inline), and cap the pre-id
park so a controller that never resolves an id holds a constant amount
regardless.

Three regression tests, all failing before this change.
@pmaxhogan
pmaxhogan force-pushed the fix/macos-memory-blowup branch from 45240a3 to 9e7433d Compare July 29, 2026 17:12
@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Area main this PR delta
Rust (lib crates) 81.08% 81.08% +0.00 (OK)
UI (vue/ts) 91.39% 91.41% +0.02 (OK)

Gate: passed - no coverage regression (epsilon 0.1 pp).

@pmaxhogan
pmaxhogan merged commit 2656c9f into main Jul 29, 2026
18 checks passed
@pmaxhogan
pmaxhogan deleted the fix/macos-memory-blowup branch July 29, 2026 17:55
@github-project-automation github-project-automation Bot moved this from Todo to Done in Driven Jul 29, 2026
pmaxhogan added a commit that referenced this pull request Jul 30, 2026
🤖 I have created a release *beep* *boop*
---


## [2.5.0](v2.4.0...v2.5.0)
(2026-07-30)


### Features

* **cli:** import destinations from an existing rclone config
([#213](#213))
([baaf7bd](baaf7bd))
* **core:** enable macOS locked-file backup via the APFS snapshot broker
([#201](#201))
([ada822e](ada822e))
* **core:** local and removable-folder backup destination
([#212](#212))
([c416a24](c416a24))
* **core:** macOS APFS snapshot broker for locked files
([#196](#196))
([a5f105e](a5f105e))
* **core:** pluggable backup destination backends
([#200](#200))
([871df59](871df59))
* **core:** S3-compatible backup destination
([#207](#207))
([37acb03](37acb03))
* **core:** scheduled integrity scrub of remote objects
([#203](#203))
([049c62a](049c62a))
* **ui:** guide macOS users to grant Full Disk Access when files are
denied ([#216](#216))
([aa5327e](aa5327e))


### Bug Fixes

* **ci:** wait for MinIO readiness before the S3 integration suite
([#226](#226))
([fff471a](fff471a))
* **core:** classify macOS locked and permission-denied opens into the
skip-and-report path
([#195](#195))
([08d2864](08d2864))
* **core:** downgrade the APFS helper-dir check from fatal to advisory
([#211](#211))
([65010ac](65010ac))
* **net:** redact proxy credentials from the diagnostic bundle
([#190](#190))
([8e514f3](8e514f3))
* **net:** redact userinfo from PAC source in logs
([#208](#208))
([8692bc0](8692bc0))
* **net:** refresh stale PAC scripts instead of pinning them for the
process ([#191](#191))
([18b0d43](18b0d43))
* **scanner:** route the deep-verify hash through the platform-open
helper ([#193](#193))
([3af5c65](3af5c65))
* **ui:** do not offer versioning on destinations that cannot honour it
([#224](#224))
([857c8ba](857c8ba))
* **ui:** make the destination step backend-driven and stop copy
claiming Drive behaviour
([#219](#219))
([9d67765](9d67765))
* **ui:** tear down exclusion-preview listeners lost to an unmount race
([#206](#206))
([2656c9f](2656c9f))
* **ui:** use a template tray icon on macOS
([#202](#202))
([eaefa9a](eaefa9a))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant