Commit 2656c9f
authored
fix(ui): tear down exclusion-preview listeners lost to an unmount race (#206)
## 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
- **#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.)
- **#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.1 parent eaefa9a commit 2656c9f
4 files changed
Lines changed: 189 additions & 7 deletions
File tree
- ui/src
- __tests__
- components
- stores
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
| 47 | + | |
47 | 48 | | |
48 | 49 | | |
49 | 50 | | |
| |||
104 | 105 | | |
105 | 106 | | |
106 | 107 | | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
107 | 167 | | |
108 | 168 | | |
109 | 169 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
21 | 27 | | |
22 | 28 | | |
23 | 29 | | |
| |||
29 | 35 | | |
30 | 36 | | |
31 | 37 | | |
32 | | - | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
33 | 42 | | |
34 | 43 | | |
35 | 44 | | |
| |||
88 | 97 | | |
89 | 98 | | |
90 | 99 | | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
91 | 153 | | |
92 | 154 | | |
93 | 155 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
58 | 58 | | |
59 | 59 | | |
60 | 60 | | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
61 | 75 | | |
62 | 76 | | |
63 | | - | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
64 | 86 | | |
65 | 87 | | |
66 | 88 | | |
67 | 89 | | |
| 90 | + | |
68 | 91 | | |
69 | 92 | | |
70 | 93 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
171 | 171 | | |
172 | 172 | | |
173 | 173 | | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
174 | 199 | | |
175 | 200 | | |
176 | 201 | | |
| |||
233 | 258 | | |
234 | 259 | | |
235 | 260 | | |
236 | | - | |
237 | | - | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
238 | 271 | | |
239 | 272 | | |
240 | 273 | | |
| |||
398 | 431 | | |
399 | 432 | | |
400 | 433 | | |
401 | | - | |
| 434 | + | |
402 | 435 | | |
403 | 436 | | |
404 | 437 | | |
| |||
409 | 442 | | |
410 | 443 | | |
411 | 444 | | |
412 | | - | |
| 445 | + | |
413 | 446 | | |
414 | 447 | | |
415 | 448 | | |
| |||
427 | 460 | | |
428 | 461 | | |
429 | 462 | | |
430 | | - | |
| 463 | + | |
431 | 464 | | |
432 | 465 | | |
433 | 466 | | |
| |||
536 | 569 | | |
537 | 570 | | |
538 | 571 | | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
539 | 576 | | |
540 | 577 | | |
0 commit comments