fix(ui): tear down exclusion-preview listeners lost to an unmount race - #206
Merged
Merged
Conversation
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
force-pushed
the
fix/macos-memory-blowup
branch
from
July 29, 2026 17:12
45240a3 to
9e7433d
Compare
Contributor
Coverage
Gate: passed - no coverage regression (epsilon 0.1 pp). |
This was referenced 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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 kernelmemory-pressure snapshot that records every process's footprint.
At that moment (73.2 GB of total system footprint):
nodex11node(all 39, incl. the above)driven-appcom.apple.WebKit.WebContent(the app's webview)cargo-tauricargox2The report's own
largestProcessfield isnode.The whole
cargo tauri devtree is identifiable and contiguous in thatsnapshot -
zsh54803 ->cargo-tauri54805 (32.1 MB) -> the vite chainnode54953/54959/55017 (62.4 + 55.8 + 167.6 MB) ->driven-app56224(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
nodeprocesses were spawned in a single burst at 11:39:59 andall died the same way: six crash reports in
~/Library/Logs/DiagnosticReports/show
SIGABRTthroughnode::OOMErrorHandler->v8::internal::Heap::FatalProcessOutOfMemory, i.e. each one hit V8's ~4.5 GBold-space ceiling. Their parent had already exited (all six report
parentProc: launchd), so they were orphaned workers of a pool whosesupervisor 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
nodein the same snapshot.What the app actually did
~/Library/Application Support/app.driven/logs/driven.2026-07-29.logcoversthe incident run exactly:
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 devfrom this worktree and reached the identical state(
accounts=0 sources=0, wizard session opened), then sampled the wholeprocess tree every 10s. Over ~20 minutes idle on that screen:
driven-app: 141 MB -> 144 MBWebContent: 76 MB -> 76 MBFlat. 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
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 afew hundred MB, and only while the editor is open. (It did contain a
separate, real leak - see below.)
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.
sources=0).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_CAPand needs a lost race to trigger - but it is genuinelyunbounded over a session and it lives in exactly the code that was suspected,
so it should not be left in.
ExclusionPreviewTreesubscribes inonMountedvia an awaitedpreview.subscribe()(threelisten()round-trips) and stores the teardownhandle afterwards.
onUnmountedonly calls the handle if it is already set.A component unmounted inside that window - and the editor mounts under
v-ifin both
SourceTableandAddSourceWizard, so open-then-close is ordinaryuse - 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
onExclusionPreviewBatchand friends use a plainlisten(name, cb)(
ipc/events.ts:153-174), which registers globally by event name. So theorphan keeps receiving every later preview's
exclusion_preview:batch. ItscurrentIdnever resolves, soingestBatchtakes the pre-id park branch - anarray only its own
start()can drain. Every batch of every future previewaccumulated 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:
ExclusionPreviewTree.vue- guard the race with the same shapeactivity.ts:640-668already uses: flip asubscribeWantedintent flag,re-check it after the await, and invoke the resolved unlisteners inline if
it flipped. Also suppresses the
restart()that would otherwise start afull walk for a tree nobody is rendering.
exclusionPreview.ts- cap the pre-id park atPRE_ID_PARK_CAP(256, vsthe ~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
truncatedcase. Defence indepth: it also bounds the other way to reach this state, a rejected
previewExclusionsStart.Regression tests
Three, all verified failing before the change (
git stashof the two sourcefiles, tests kept):
tears down every listener when unmounted while subscribe is still in flight- gateslisten()on a promise, unmounts inside the window, assertsall 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 whosestartrejects, fires 4x the cap inbatches, 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 bothways and pins the non-racing path.
Gates
vitest530 passed,prettier --check,eslint,vue-tsc --noEmit,cargo fmt --all --check,cargo clippy --workspace --all-targets -D warnings,cargo test --workspace- all clean.