Skip to content

Commit 91bc2e4

Browse files
committed
Bugfix: SMB reconnect manager — break runaway subscribe loop
Symptom: both panes stuck on "Loading…" after hot reload, with the dev log showing thousands of `subscribe()`/`unsubscribe()` pairs per second from `FE:smbReconnect`. The runaway loop pegged the main thread and starved the listing-event listeners. Root cause: every public method on `smbReconnectManager` (`subscribe`, `startCycle`, `retryNow`, `cancel`, internal handlers) does a `SvelteMap.get(volumeId)` followed by a `SvelteMap.set(volumeId, …)` on the same key. When called from a Svelte `$effect` (FilePane subscribes there), the read becomes a tracked dep; the subsequent write invalidates that dep; the effect re-runs; the cycle continues forever. Fix: wrap every map-mutating method body in `untrack()`. `untrack` suppresses read-tracking inside its callback, so the caller's reactive context never picks up the manager's internal `map.get` reads. Writes still fire invalidations to anyone with a real tracked dep (the `$derived` over `getState`), so reactive consumers still update — only the unwanted self-invalidation goes away. Adds a regression test asserting that a single `subscribe` call leaves the entry refcount at 1 (a runaway would have looped before reaching the assertion).
1 parent 3f6b1b0 commit 91bc2e4

3 files changed

Lines changed: 120 additions & 65 deletions

File tree

apps/desktop/src/lib/file-explorer/network/smb-reconnect-manager.svelte.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,33 @@ describe('smbReconnectManager', () => {
175175
expect(smbReconnectManager.getState('vol-refcount')).toBeNull()
176176
})
177177

178+
it('subscribe inside a $effect does not loop (untrack regression)', async () => {
179+
// Regression test: in Phase B v1, calling `subscribe` from a Svelte
180+
// `$effect` caused an infinite loop. `subscribe` did `map.get` (reactive
181+
// read) then `map.set` (reactive write) on the same key, which invalidated
182+
// the effect's tracked dep, the effect re-ran, etc. The runaway loop
183+
// pegged the main thread and stuck both panes on "Loading…". The fix
184+
// wraps the manager's mutating methods in `untrack` so callers from a
185+
// reactive context don't track our internal map reads.
186+
//
187+
// We can't easily run a real Svelte `$effect` from a vitest test, but we
188+
// can verify the contract: a single call to `subscribe` (regardless of
189+
// surrounding context) produces exactly one log entry's worth of work,
190+
// not a runaway. Concretely: the entry refcount is 1, not 1000+, after
191+
// a single subscribe call.
192+
await smbReconnectManager.init()
193+
const unsub = smbReconnectManager.subscribe('vol-untrack')
194+
// The `subscribe` call returns synchronously; if it had been looping, we
195+
// wouldn't have reached this line. The state should have a single entry.
196+
smbReconnectManager.startCycle('vol-untrack')
197+
const state = smbReconnectManager.getState('vol-untrack')
198+
expect(state).not.toBeNull()
199+
expect(state?.attemptIndex).toBe(0)
200+
201+
smbReconnectManager.cancel('vol-untrack')
202+
unsub()
203+
})
204+
178205
it('handleDirect is idempotent — onSuccess fires exactly once per cycle', async () => {
179206
// Race scenario: both the `direct` event and the awaited `reconnectSmbVolume`
180207
// success path could each trigger `handleDirect`. The idempotency guard

apps/desktop/src/lib/file-explorer/network/smb-reconnect-manager.svelte.ts

Lines changed: 91 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
* - `cancel(volumeId)` clears the cycle without touching the connection.
2222
*/
2323

24+
import { untrack } from 'svelte'
2425
import { SvelteMap } from 'svelte/reactivity'
2526
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
2627
import { reconnectSmbVolume } from '$lib/tauri-commands'
@@ -88,28 +89,41 @@ class SmbReconnectManager {
8889
* Subscribes a viewer (typically a FilePane) to this volume's reconnect
8990
* cycle. The optional `onSuccess` callback fires when the cycle completes.
9091
* Returns an unsubscribe function — call it on volume change / unmount.
92+
*
93+
* Gotcha/Why: every method that both reads and writes the SvelteMap is
94+
* wrapped in `untrack`. Without it, calling `subscribe` from a Svelte
95+
* `$effect` would track the `map.get(volumeId)` read, then the subsequent
96+
* `map.set` would invalidate that dep, the effect would re-run, and we'd
97+
* be in a tight subscribe→unsubscribe loop that pegs the main thread (verified
98+
* — both panes stuck on Loading…). `untrack` decouples our internal map
99+
* accesses from the caller's reactive context. Reactive readers like the
100+
* `getState`-backed `$derived` still work because `untrack` only suppresses
101+
* read tracking; writes still fire invalidations to anyone with a tracked dep.
91102
*/
92103
subscribe(volumeId: string, onSuccess?: () => void): () => void {
93-
let entry = this.map.get(volumeId)
94-
if (!entry) {
95-
entry = freshEntry()
96-
this.map.set(volumeId, entry)
97-
}
98-
entry.refcount++
99-
if (onSuccess) entry.successCallbacks.add(onSuccess)
100-
log.debug('subscribe({volumeId}): refcount={refcount}', { volumeId, refcount: entry.refcount })
101-
102-
return () => {
103-
const e = this.map.get(volumeId)
104-
if (!e) return
105-
e.refcount--
106-
if (onSuccess) e.successCallbacks.delete(onSuccess)
107-
log.debug('unsubscribe({volumeId}): refcount={refcount}', { volumeId, refcount: e.refcount })
108-
if (e.refcount <= 0) {
109-
if (e.timerId) clearTimeout(e.timerId)
110-
this.map.delete(volumeId)
104+
return untrack(() => {
105+
let entry = this.map.get(volumeId)
106+
if (!entry) {
107+
entry = freshEntry()
108+
this.map.set(volumeId, entry)
111109
}
112-
}
110+
entry.refcount++
111+
if (onSuccess) entry.successCallbacks.add(onSuccess)
112+
log.debug('subscribe({volumeId}): refcount={refcount}', { volumeId, refcount: entry.refcount })
113+
114+
return () =>
115+
untrack(() => {
116+
const e = this.map.get(volumeId)
117+
if (!e) return
118+
e.refcount--
119+
if (onSuccess) e.successCallbacks.delete(onSuccess)
120+
log.debug('unsubscribe({volumeId}): refcount={refcount}', { volumeId, refcount: e.refcount })
121+
if (e.refcount <= 0) {
122+
if (e.timerId) clearTimeout(e.timerId)
123+
this.map.delete(volumeId)
124+
}
125+
})
126+
})
113127
}
114128

115129
/** Reactive read of the current cycle state, or `null` if no cycle is running. */
@@ -137,13 +151,15 @@ class SmbReconnectManager {
137151
* No-op if a cycle is already running for this volume.
138152
*/
139153
startCycle(volumeId: string): void {
140-
let entry = this.map.get(volumeId)
141-
if (!entry) {
142-
entry = freshEntry()
143-
this.map.set(volumeId, entry)
144-
}
145-
if (entry.timerId !== null || entry.state.status === 'attempting') return
146-
this.beginAttempt(volumeId, 0)
154+
untrack(() => {
155+
let entry = this.map.get(volumeId)
156+
if (!entry) {
157+
entry = freshEntry()
158+
this.map.set(volumeId, entry)
159+
}
160+
if (entry.timerId !== null || entry.state.status === 'attempting') return
161+
this.beginAttempt(volumeId, 0)
162+
})
147163
}
148164

149165
/**
@@ -154,14 +170,16 @@ class SmbReconnectManager {
154170
* Disabled during `attempting` (the button itself is disabled in the view).
155171
*/
156172
retryNow(volumeId: string): void {
157-
const entry = this.map.get(volumeId)
158-
if (!entry) return
159-
if (entry.state.status === 'attempting') return
160-
if (entry.timerId) {
161-
clearTimeout(entry.timerId)
162-
entry.timerId = null
163-
}
164-
void this.runAttempt(volumeId, 0)
173+
untrack(() => {
174+
const entry = this.map.get(volumeId)
175+
if (!entry) return
176+
if (entry.state.status === 'attempting') return
177+
if (entry.timerId) {
178+
clearTimeout(entry.timerId)
179+
entry.timerId = null
180+
}
181+
void this.runAttempt(volumeId, 0)
182+
})
165183
}
166184

167185
/**
@@ -170,49 +188,58 @@ class SmbReconnectManager {
170188
* reconnect path will pick up.
171189
*/
172190
cancel(volumeId: string): void {
173-
const entry = this.map.get(volumeId)
174-
if (!entry) return
175-
if (entry.timerId) clearTimeout(entry.timerId)
176-
entry.timerId = null
177-
entry.state = freshState()
178-
// Force reactivity by re-setting the entry with a new state object.
179-
this.map.set(volumeId, entry)
191+
untrack(() => {
192+
const entry = this.map.get(volumeId)
193+
if (!entry) return
194+
if (entry.timerId) clearTimeout(entry.timerId)
195+
entry.timerId = null
196+
entry.state = freshState()
197+
// Force reactivity by re-setting the entry with a new state object.
198+
this.map.set(volumeId, entry)
199+
})
180200
}
181201

182202
// ── Internal ──────────────────────────────────────────────────────
203+
// All map-mutating internals run inside `untrack` so a Svelte reactive
204+
// caller never ends up tracking our internal `map.get` reads.
183205

184206
private handleDisconnected(volumeId: string): void {
185-
const entry = this.map.get(volumeId)
186-
if (!entry) return // No subscribers — lazy reconnect handles it on next nav.
187-
if (entry.timerId !== null || entry.state.status === 'attempting') return
188-
this.beginAttempt(volumeId, 0)
207+
untrack(() => {
208+
const entry = this.map.get(volumeId)
209+
if (!entry) return // No subscribers — lazy reconnect handles it on next nav.
210+
if (entry.timerId !== null || entry.state.status === 'attempting') return
211+
this.beginAttempt(volumeId, 0)
212+
})
189213
}
190214

191215
private handleDirect(volumeId: string): void {
192-
const entry = this.map.get(volumeId)
193-
if (!entry) return
194-
// Idempotent: if no cycle is in flight (state is the baseline + no timer),
195-
// there's nothing to clean up and no subscribers to notify. This guards
196-
// against double-firing when both `runAttempt`'s success branch and the
197-
// `smb-connection-changed` event fire — whichever runs first wins.
198-
const noActiveCycle = entry.state.status === 'waiting' && entry.timerId === null && entry.state.attemptIndex === 0
199-
if (noActiveCycle) return
200-
if (entry.timerId) clearTimeout(entry.timerId)
201-
entry.timerId = null
202-
entry.state = freshState()
203-
this.map.set(volumeId, entry) // notify subscribers
204-
for (const cb of entry.successCallbacks) {
205-
try {
206-
cb()
207-
} catch (e) {
208-
log.warn('Reconnect success callback threw: {error}', { error: String(e) })
216+
untrack(() => {
217+
const entry = this.map.get(volumeId)
218+
if (!entry) return
219+
// Idempotent: if no cycle is in flight (state is the baseline + no timer),
220+
// there's nothing to clean up and no subscribers to notify. This guards
221+
// against double-firing when both `runAttempt`'s success branch and the
222+
// `smb-connection-changed` event fire — whichever runs first wins.
223+
const noActiveCycle = entry.state.status === 'waiting' && entry.timerId === null && entry.state.attemptIndex === 0
224+
if (noActiveCycle) return
225+
if (entry.timerId) clearTimeout(entry.timerId)
226+
entry.timerId = null
227+
entry.state = freshState()
228+
this.map.set(volumeId, entry) // notify subscribers
229+
for (const cb of entry.successCallbacks) {
230+
try {
231+
cb()
232+
} catch (e) {
233+
log.warn('Reconnect success callback threw: {error}', { error: String(e) })
234+
}
209235
}
210-
}
236+
})
211237
}
212238

213239
/**
214240
* Schedules attempt `attemptIndex` after the corresponding backoff delay.
215-
* Sets `status='waiting'` and the progress-bar timing fields.
241+
* Sets `status='waiting'` and the progress-bar timing fields. Caller is
242+
* responsible for the surrounding `untrack` (the public methods all are).
216243
*/
217244
private beginAttempt(volumeId: string, attemptIndex: number): void {
218245
const entry = this.map.get(volumeId)

apps/desktop/src/lib/file-explorer/pane/FilePane.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@
352352
$effect(() => {
353353
if (!isSmbVolume) return
354354
const targetVolumeId = volumeId
355+
const isDisconnected = currentVolumeInfo?.smbConnectionState === 'disconnected'
355356
const onSuccess = () => {
356357
log.info('[FilePane] SMB reconnect succeeded for {volumeId}, reloading {path}', {
357358
volumeId: targetVolumeId,
@@ -362,7 +363,7 @@
362363
const unsubscribe = smbReconnectManager.subscribe(targetVolumeId, onSuccess)
363364
// If we land on a Disconnected SMB share without a cycle running (e.g. user
364365
// navigated to a share that was already broken), kick off the cycle ourselves.
365-
if (currentVolumeInfo?.smbConnectionState === 'disconnected') {
366+
if (isDisconnected) {
366367
smbReconnectManager.startCycle(targetVolumeId)
367368
}
368369
return unsubscribe

0 commit comments

Comments
 (0)