Skip to content

Commit 2656c9f

Browse files
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__/exclusion-preview-store.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
createExclusionPreview,
4545
isUnconstrainedIncludePattern,
4646
unconstrainedIncludePatterns,
47+
PRE_ID_PARK_CAP,
4748
type ExclusionPreviewController,
4849
} from "../stores/exclusionPreview";
4950
import type { ExclusionPreviewBatch, ExclusionPreviewNode } from "../ipc/types";
@@ -104,6 +105,65 @@ beforeEach(() => {
104105
unlistenError.mockReset();
105106
});
106107

108+
describe("pre-generation-id park", () => {
109+
// A controller whose `start` never resolves a generation id leaves `currentId`
110+
// null FOREVER, while its `listen()` registrations - which are global by event
111+
// name - keep receiving every later preview's batches. Nothing drains the park
112+
// in that state, so the park is the one place in this controller where events
113+
// can pile up unboundedly. This pins the bound.
114+
it("caps the park so a controller that never resolves an id cannot grow without bound", async () => {
115+
const preview = createExclusionPreview();
116+
await preview.subscribe();
117+
// The start command REJECTS, so no generation id is ever adopted.
118+
invokeMock.mockRejectedValue(new Error("start failed"));
119+
await preview.start({
120+
sourceId: "src-1",
121+
respectGitignore: true,
122+
includePatterns: [],
123+
excludePatterns: [],
124+
});
125+
expect(preview.currentPreviewId()).toBeNull();
126+
127+
// Four caps' worth of traffic from OTHER previews still arrives, because the
128+
// listeners are registered by event name rather than per generation.
129+
const fired = PRE_ID_PARK_CAP * 4;
130+
for (let i = 0; i < fired; i += 1) {
131+
batchHandler!(batch(`gen-${i}`, [node(`f${i}.txt`, false, true, 1)]));
132+
}
133+
134+
expect(preview.preIdParkedCount()).toBe(PRE_ID_PARK_CAP);
135+
expect(preview.preIdParkedCount()).toBeLessThan(fired);
136+
});
137+
138+
it("still parks and replays everything that arrives before a real id lands", async () => {
139+
const preview = createExclusionPreview();
140+
await preview.subscribe();
141+
let resolveStart: (id: string) => void = () => {};
142+
invokeMock.mockReturnValue(
143+
new Promise<string>((resolve) => {
144+
resolveStart = resolve;
145+
})
146+
);
147+
const starting = preview.start({
148+
sourceId: "src-1",
149+
respectGitignore: true,
150+
includePatterns: [],
151+
excludePatterns: [],
152+
});
153+
// The walk streams before `preview_exclusions_start` resolves.
154+
batchHandler!(batch("gen-1", [node("dir", true, true), node("dir/a.txt", false, true, 7)]));
155+
expect(preview.preIdParkedCount()).toBe(1);
156+
157+
resolveStart("gen-1");
158+
await starting;
159+
preview.flush();
160+
161+
expect(preview.preIdParkedCount()).toBe(0);
162+
expect(preview.nodeAt("dir/a.txt")?.included).toBe(true);
163+
expect(preview.includedCount.value).toBe(1);
164+
});
165+
});
166+
107167
describe("anchoredPatternForPath", () => {
108168
// THE SAME TABLE the Rust `anchored_pattern_vectors_are_stable` test asserts
109169
// against `driven_core::exclude::anchored_pattern_for_path` (which is in turn

ui/src/__tests__/exclusion-preview-tree.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ vi.mock("@tauri-apps/api/core", () => ({
1818
let batchHandler: ((payload: unknown) => void) | null = null;
1919
let doneHandler: ((payload: unknown) => void) | null = null;
2020
let errorHandler: ((payload: unknown) => void) | null = null;
21+
/** Every unlisten handed out by `listen()`, so a test can assert that NONE is
22+
* left registered after the component goes away. */
23+
const unlistenSpies: Array<ReturnType<typeof vi.fn>> = [];
24+
/** When set, `listen()` awaits this before resolving - which is what lets a test
25+
* unmount the component while `subscribe()` is still in flight. */
26+
let listenGate: Promise<void> | null = null;
2127
vi.mock("@tauri-apps/api/event", () => ({
2228
listen: vi.fn(async (event: string, cb: (e: { payload: unknown }) => void) => {
2329
if (event === "exclusion_preview:batch") {
@@ -29,7 +35,10 @@ vi.mock("@tauri-apps/api/event", () => ({
2935
if (event === "exclusion_preview:error") {
3036
errorHandler = (payload: unknown) => cb({ payload });
3137
}
32-
return vi.fn();
38+
if (listenGate) await listenGate;
39+
const unlisten = vi.fn();
40+
unlistenSpies.push(unlisten);
41+
return unlisten;
3342
}),
3443
}));
3544

@@ -88,6 +97,59 @@ beforeEach(() => {
8897
batchHandler = null;
8998
doneHandler = null;
9099
errorHandler = null;
100+
unlistenSpies.length = 0;
101+
listenGate = null;
102+
});
103+
104+
describe("ExclusionPreviewTree teardown", () => {
105+
it("tears down every listener when unmounted while subscribe is still in flight", async () => {
106+
// The editor mounts under a `v-if` (SourceTable's inline editor,
107+
// AddSourceWizard's exclusions step), so opening and immediately closing it
108+
// is ordinary use. `subscribe()` is three async `listen()` round-trips; if
109+
// the component unmounts inside that window, the resolved unlisteners must
110+
// still be invoked. They cannot be recovered later: `listen` registers
111+
// GLOBALLY BY EVENT NAME, so a stranded set keeps receiving every later
112+
// preview's batches and parks them in a controller nobody can reach.
113+
let openGate: () => void = () => {};
114+
listenGate = new Promise<void>((resolve) => {
115+
openGate = resolve;
116+
});
117+
118+
const wrapper = mount(ExclusionPreviewTree, {
119+
global: globalMountOptions,
120+
props: {
121+
sourceId: "src-1",
122+
respectGitignore: true,
123+
includePatterns: [],
124+
excludePatterns: [],
125+
},
126+
});
127+
// Nothing has resolved yet - this is the race window.
128+
expect(unlistenSpies).toHaveLength(0);
129+
130+
wrapper.unmount();
131+
openGate();
132+
await flushPromises();
133+
134+
expect(unlistenSpies).toHaveLength(3);
135+
for (const unlisten of unlistenSpies) {
136+
expect(unlisten).toHaveBeenCalledTimes(1);
137+
}
138+
// ...and no walk is started for a tree that is no longer on screen.
139+
expect(invokeMock).not.toHaveBeenCalledWith("preview_exclusions_start", expect.anything());
140+
});
141+
142+
it("tears down every listener on an ordinary unmount", async () => {
143+
const wrapper = await mountWithNodes([node("a.txt", false, true, 10)]);
144+
expect(unlistenSpies).toHaveLength(3);
145+
146+
wrapper.unmount();
147+
await flushPromises();
148+
149+
for (const unlisten of unlistenSpies) {
150+
expect(unlisten).toHaveBeenCalledTimes(1);
151+
}
152+
});
91153
});
92154

93155
describe("ExclusionPreviewTree", () => {

ui/src/components/ExclusionPreviewTree.vue

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,36 @@ const expanded = ref(new Set<string>());
5858
const shownLimit = ref(new Map<string, number>());
5959
6060
let teardown: (() => void) | null = null;
61+
/** Subscription INTENT, re-checked after `subscribe()` resolves.
62+
*
63+
* `subscribe()` is async (three `listen()` round-trips), so a component that
64+
* unmounts while it is in flight would run `onUnmounted` with `teardown` still
65+
* null and tear down nothing - stranding all three listeners for the life of
66+
* the process. Those listeners are registered globally BY EVENT NAME, so the
67+
* orphan keeps receiving every later preview's `exclusion_preview:batch`, and
68+
* its controller (whose generation id never resolved) parks each one forever.
69+
* The editor opens and closes on `v-if`, so losing that race is a normal
70+
* interaction, not a pathological one.
71+
*
72+
* Same shape as `activity.ts`'s `desiredSubscribed`: flip the intent first,
73+
* then have the resolving side honour it. */
74+
let subscribeWanted = false;
6175
6276
onMounted(async () => {
63-
teardown = await preview.subscribe();
77+
subscribeWanted = true;
78+
const stop = await preview.subscribe();
79+
if (!subscribeWanted) {
80+
// Unmounted while subscribing: tear the listeners down now, and do NOT
81+
// start a walk nobody is rendering.
82+
stop();
83+
return;
84+
}
85+
teardown = stop;
6486
await restart();
6587
});
6688
6789
onUnmounted(() => {
90+
subscribeWanted = false;
6891
teardown?.();
6992
teardown = null;
7093
});

ui/src/stores/exclusionPreview.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,31 @@ const scheduleFrame: (cb: () => void) => void =
171171
setTimeout(cb, 16);
172172
};
173173

174+
/** Park at most this many pre-generation-id events of each kind (see the
175+
* `preIdBatches` declaration for why the park needs a cap at all).
176+
*
177+
* The backend caps one generation at `NODE_STREAM_CAP` (50,000) nodes in
178+
* `BATCH_MAX_NODES` (400) sized batches, so 125 FULL batches - and only the
179+
* ones arriving before `previewExclusionsStart` resolves are ever parked. That
180+
* window is a single IPC round trip: since #177 the command hands back its
181+
* generation id immediately and does the slow matcher build in the spawned
182+
* task, so at the `BATCH_MAX_INTERVAL` (100ms) partial-flush cadence only a
183+
* batch or two can land inside it. 256 is 2x the full-batch ceiling and orders
184+
* above the realistic park, while still pinning an unresolvable controller's
185+
* retention to a constant.
186+
*
187+
* Over the cap the NEWEST event is dropped rather than the oldest: the backend
188+
* streams breadth-first, so the oldest batches carry the ancestors every later
189+
* row hangs off, and truncating the tail is exactly the already-handled
190+
* `truncated` case. */
191+
export const PRE_ID_PARK_CAP = 256;
192+
193+
/** Park `event` in `park` unless it is already at [`PRE_ID_PARK_CAP`]. */
194+
function parkPreId<T>(park: T[], event: T): void {
195+
if (park.length >= PRE_ID_PARK_CAP) return;
196+
park.push(event);
197+
}
198+
174199
export type ExclusionPreviewController = ReturnType<typeof createExclusionPreview>;
175200

176201
/**
@@ -233,8 +258,16 @@ export function createExclusionPreview() {
233258
let startSeq = 0;
234259
/** The walk begins before `preview_exclusions_start` resolves, so its first
235260
* batches can legitimately arrive BEFORE we know the generation id. They are
236-
* parked here and replayed once the id lands (bounded by the backend's own
237-
* node cap, so this cannot grow without limit). */
261+
* parked here and replayed once the id lands.
262+
*
263+
* The park is capped at [`PRE_ID_PARK_CAP`]. The backend's own node cap
264+
* bounds ONE generation's stream, which is all a controller that goes on to
265+
* resolve an id can ever park - but a controller whose id never resolves
266+
* (a rejected `previewExclusionsStart`, or a controller orphaned by an
267+
* unmount that raced `subscribe`) leaves `currentId` null forever while its
268+
* globally-registered listeners keep receiving EVERY later preview's
269+
* batches. Nothing drains the park in that state, so without a cap it grows
270+
* for the life of the process. The cap makes that retention constant. */
238271
let preIdBatches: ExclusionPreviewBatch[] = [];
239272
let preIdDone: ExclusionPreviewDone[] = [];
240273
let preIdErrors: ExclusionPreviewError[] = [];
@@ -398,7 +431,7 @@ export function createExclusionPreview() {
398431
/** Take a batch from the event stream (or from the pre-id park). */
399432
function ingestBatch(batch: ExclusionPreviewBatch): void {
400433
if (currentId === null) {
401-
preIdBatches.push(batch);
434+
parkPreId(preIdBatches, batch);
402435
return;
403436
}
404437
// A superseded walk's in-flight events must never touch the live tree.
@@ -409,7 +442,7 @@ export function createExclusionPreview() {
409442

410443
function ingestDone(done: ExclusionPreviewDone): void {
411444
if (currentId === null) {
412-
preIdDone.push(done);
445+
parkPreId(preIdDone, done);
413446
return;
414447
}
415448
if (done.previewId !== currentId) return;
@@ -427,7 +460,7 @@ export function createExclusionPreview() {
427460
* user fixes the rule the next generation swaps a real tree back in. */
428461
function ingestError(error: ExclusionPreviewError): void {
429462
if (currentId === null) {
430-
preIdErrors.push(error);
463+
parkPreId(preIdErrors, error);
431464
return;
432465
}
433466
if (error.previewId !== currentId) return;
@@ -536,5 +569,9 @@ export function createExclusionPreview() {
536569
/** Look up a node of the tree ON SCREEN by path - which during a recompute
537570
* is still the previous generation's. Test/diagnostic seam. */
538571
nodeAt: (path: string) => displayedIndex.get(path),
572+
/** Events parked awaiting a generation id, capped at `PRE_ID_PARK_CAP`.
573+
* Test/diagnostic seam: this is the retention of a controller whose id
574+
* never resolves, so it is a memory bound, not a queue length. */
575+
preIdParkedCount: (): number => preIdBatches.length + preIdDone.length + preIdErrors.length,
539576
};
540577
}

0 commit comments

Comments
 (0)