Skip to content

Commit 0c1d368

Browse files
committed
SMB: Live reconnect UX with backoff cycle (frontend)
When the smb2 session for a direct-SMB volume drops, the pane swaps to a `SmbReconnectingView` that runs a 5-attempt backoff cycle (2/4/8/16/30s, total 60s). Both panes on the same share share a single cycle; success re-runs `loadDirectory` automatically; give-up swaps to the existing unreachable banner with a Disconnect button. - New `smb-reconnect-manager.svelte.ts`: per-volume Svelte store, refcounted subscriptions (`smbReconnectManager.subscribe`), backoff timer, single-flight via the backend's reconnect lock. Listens to `smb-connection-changed` events. Cycle stops when the last subscriber leaves; the connection stays Disconnected so lazy reconnect on next nav can pick up. - New `SmbReconnectingView.svelte`: title, spinner, progress bar (0→`currentDelayMs`, requestAnimationFrame-driven), body lines computed dynamically from `RECONNECT_DELAYS_MS` (the backoff array is the only place these numbers exist), three buttons (Retry now / Cancel / Disconnect) with tooltips clarifying the difference. - `FilePane.svelte`: subscribes to the manager whenever on an SMB share. Swaps to the reconnecting view while a cycle is active; swaps to `VolumeUnreachableBanner` (with a new Disconnect button) on give-up. The lazy nav-time path: if the user opens an already-Disconnected share, the effect kicks off the cycle so the view comes up immediately instead of after a listing-error round-trip. - `VolumeUnreachableBanner.svelte`: extended with an `smbGaveUp` variant that swaps the "Open home folder" button for "Disconnect" and adapts the detail line. - `volume-store.svelte.ts`: also listens for `smb-connection-changed` so `currentVolumeInfo.smbConnectionState` and the picker dot update the moment a session flips, without waiting for the next `volumes-changed`. - `SmbConnectionState` enum gains a third `Disconnected` variant. `SmbVolume::smb_connection_state()` now returns `Some(Disconnected)` instead of `None` when the session is dead, so the FE can distinguish "not an SMB volume" from "SMB volume in trouble". - `Disconnect` currently behaves like `Cancel` plus a navigation away — there's no clean per-volume "drop SMB session" backend command yet. Users can still eject via the volume picker. Wiring an explicit disconnect command is a follow-up. Tests: - 14 unit tests for the manager: backoff progression, give-up after exhausting the array, success path via the event, "Retry now" reset, "Cancel" cleanup, refcount semantics, two-subscriber sharing. - Pure-helper tests: `ordinalCount`, `reconnectProgressMessage` for every attempt index (including the no-body-2 first attempt and the "final attempt" wording). - Tier-3 a11y tests for `SmbReconnectingView` in waiting (first attempt, mid-cycle) and attempting states.
1 parent d96bc4b commit 0c1d368

14 files changed

Lines changed: 1011 additions & 22 deletions

apps/desktop/src-tauri/src/file_system/volume/mod.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,26 @@ use std::pin::Pin;
1717
use std::sync::atomic::AtomicBool;
1818
use tokio::sync::mpsc;
1919

20-
/// SMB connection state for the frontend indicator.
20+
/// SMB connection state for the frontend indicator and the reconnect UI.
2121
///
2222
/// `Direct` means Cmdr's smb2 session is active (fast path).
2323
/// `OsMount` means only the OS mount is alive (fallback path).
24+
/// `Disconnected` means an SmbVolume exists but its smb2 session is broken — the
25+
/// frontend reconnect manager owns the recovery cycle.
26+
///
27+
/// Non-SMB volumes return `None` from `Volume::smb_connection_state()` (trait
28+
/// default). The frontend uses this to distinguish "this isn't an SMB volume"
29+
/// (no value) from "this is an SMB volume in trouble" (Some(Disconnected)).
2430
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2531
#[serde(rename_all = "snake_case")]
2632
pub enum SmbConnectionState {
2733
/// smb2 session active — fast path (green indicator).
2834
Direct,
2935
/// Using OS mount only — slower fallback (yellow indicator).
3036
OsMount,
37+
/// Cmdr's smb2 session has dropped. The frontend swaps to `SmbReconnectingView`
38+
/// and the per-volume reconnect manager runs the backoff cycle.
39+
Disconnected,
3140
}
3241

3342
/// Default volume ID for the root filesystem.

apps/desktop/src-tauri/src/file_system/volume/smb.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1796,11 +1796,14 @@ impl Volume for SmbVolume {
17961796
}
17971797

17981798
fn smb_connection_state(&self) -> Option<SmbConnectionState> {
1799-
match self.connection_state() {
1800-
ConnectionState::Direct => Some(SmbConnectionState::Direct),
1801-
ConnectionState::OsMount => Some(SmbConnectionState::OsMount),
1802-
ConnectionState::Disconnected => None,
1803-
}
1799+
// SmbVolume always returns `Some` so the frontend can distinguish
1800+
// "not an SMB volume" (None) from "SMB volume in trouble"
1801+
// (Some(Disconnected)). The reconnect manager keys off the latter.
1802+
Some(match self.connection_state() {
1803+
ConnectionState::Direct => SmbConnectionState::Direct,
1804+
ConnectionState::OsMount => SmbConnectionState::OsMount,
1805+
ConnectionState::Disconnected => SmbConnectionState::Disconnected,
1806+
})
18041807
}
18051808

18061809
fn attempt_reconnect<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<(), VolumeError>> + Send + 'a>> {

apps/desktop/src/lib/file-explorer/network/CLAUDE.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ SMB network discovery UI: host list, per-host share list, login form, and a sing
44

55
## Key files
66

7-
| File | Purpose |
8-
| ------------------------------ | ------------------------------------------------------------------------- |
9-
| `network-store.svelte.ts` | Module-level `$state` singleton for all network data |
10-
| `NetworkBrowser.svelte` | Host list table — rendered when pane is on the `network` volume |
11-
| `ShareBrowser.svelte` | Share list for a specific host, handles auth flow |
12-
| `NetworkLoginForm.svelte` | Credential form rendered inside `ShareBrowser` |
13-
| `ConnectToServerDialog.svelte` | Modal dialog for manually connecting to a server by address/IP/smb:// URL |
7+
| File | Purpose |
8+
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
9+
| `network-store.svelte.ts` | Module-level `$state` singleton for all network data |
10+
| `NetworkBrowser.svelte` | Host list table — rendered when pane is on the `network` volume |
11+
| `ShareBrowser.svelte` | Share list for a specific host, handles auth flow |
12+
| `NetworkLoginForm.svelte` | Credential form rendered inside `ShareBrowser` |
13+
| `ConnectToServerDialog.svelte` | Modal dialog for manually connecting to a server by address/IP/smb:// URL |
14+
| `smb-reconnect-manager.svelte.ts` | Per-volume backoff cycle that re-establishes a Disconnected `SmbVolume`. Listens to `smb-connection-changed` from the backend, drives the FE state machine for `SmbReconnectingView`, exposes `subscribe` / `startCycle` / `retryNow` / `cancel` |
1415

1516
## `network-store.svelte.ts`
1617

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/**
2+
* Unit tests for the per-volume SMB reconnect manager.
3+
*
4+
* Covers: backoff progression on repeated failures, success path via the
5+
* `smb-connection-changed` event, "Retry now" reset semantics, "Cancel"
6+
* cleanup, refcounted subscriptions, give-up after exhausting the array,
7+
* and the pure display helpers (`ordinalCount`, `reconnectProgressMessage`).
8+
*/
9+
10+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
11+
12+
// Hoisted mocks: must run before importing the module under test.
13+
const mockReconnect = vi.fn<(volumeId: string) => Promise<void>>()
14+
const mockListen = vi.fn<(event: string, handler: (e: { payload: unknown }) => void) => Promise<() => void>>()
15+
let lastEventHandler: ((e: { payload: unknown }) => void) | null = null
16+
17+
vi.mock('$lib/tauri-commands', () => ({
18+
reconnectSmbVolume: (id: string) => mockReconnect(id),
19+
}))
20+
21+
vi.mock('@tauri-apps/api/event', () => ({
22+
listen: (event: string, handler: (e: { payload: unknown }) => void) => {
23+
lastEventHandler = handler
24+
return mockListen(event, handler)
25+
},
26+
}))
27+
28+
import {
29+
smbReconnectManager,
30+
RECONNECT_DELAYS_MS,
31+
TOTAL_ATTEMPTS,
32+
ordinalCount,
33+
reconnectProgressMessage,
34+
} from './smb-reconnect-manager.svelte'
35+
36+
/** Drives the listener as if the backend emitted the event. */
37+
function emit(volumeId: string, state: 'direct' | 'disconnected'): void {
38+
if (!lastEventHandler) throw new Error("init() was not called or didn't install a listener")
39+
lastEventHandler({ payload: { volumeId, state } })
40+
}
41+
42+
describe('smbReconnectManager', () => {
43+
beforeEach(() => {
44+
vi.useFakeTimers()
45+
mockReconnect.mockReset()
46+
mockListen.mockReset()
47+
mockListen.mockResolvedValue(() => {
48+
lastEventHandler = null
49+
})
50+
// The manager's internal map is a singleton across tests; the tests use a
51+
// fresh volumeId each so leftover entries don't interfere.
52+
})
53+
54+
afterEach(() => {
55+
vi.useRealTimers()
56+
})
57+
58+
it('does not start a cycle without subscribers', async () => {
59+
await smbReconnectManager.init()
60+
emit('vol-no-subs', 'disconnected')
61+
// Advance past every backoff delay; nothing should have fired.
62+
await vi.advanceTimersByTimeAsync(RECONNECT_DELAYS_MS.reduce((a, b) => a + b, 0) + 100)
63+
expect(mockReconnect).not.toHaveBeenCalled()
64+
expect(smbReconnectManager.getState('vol-no-subs')).toBeNull()
65+
})
66+
67+
it('starts a cycle on disconnected event when subscribed', async () => {
68+
await smbReconnectManager.init()
69+
const unsub = smbReconnectManager.subscribe('vol-1')
70+
emit('vol-1', 'disconnected')
71+
// Right after the event we're in the first "waiting" phase.
72+
const state1 = smbReconnectManager.getState('vol-1')
73+
expect(state1?.status).toBe('waiting')
74+
expect(state1?.attemptIndex).toBe(0)
75+
expect(state1?.currentDelayMs).toBe(RECONNECT_DELAYS_MS[0])
76+
77+
// Wait through the first delay → first attempt fires.
78+
mockReconnect.mockRejectedValueOnce(new Error('still down'))
79+
await vi.advanceTimersByTimeAsync(RECONNECT_DELAYS_MS[0])
80+
expect(mockReconnect).toHaveBeenCalledTimes(1)
81+
// Failed → next delay scheduled.
82+
const state2 = smbReconnectManager.getState('vol-1')
83+
expect(state2?.status).toBe('waiting')
84+
expect(state2?.attemptIndex).toBe(1)
85+
expect(state2?.currentDelayMs).toBe(RECONNECT_DELAYS_MS[1])
86+
87+
smbReconnectManager.cancel('vol-1')
88+
unsub()
89+
})
90+
91+
it('gives up after exhausting the backoff array', async () => {
92+
await smbReconnectManager.init()
93+
const unsub = smbReconnectManager.subscribe('vol-giveup')
94+
mockReconnect.mockRejectedValue(new Error('still down'))
95+
96+
smbReconnectManager.startCycle('vol-giveup')
97+
// Advance through all attempts. Each iteration: wait the delay, then fire.
98+
for (const delay of RECONNECT_DELAYS_MS) {
99+
await vi.advanceTimersByTimeAsync(delay)
100+
}
101+
const state = smbReconnectManager.getState('vol-giveup')
102+
expect(state?.status).toBe('gave-up')
103+
expect(mockReconnect).toHaveBeenCalledTimes(TOTAL_ATTEMPTS)
104+
105+
smbReconnectManager.cancel('vol-giveup')
106+
unsub()
107+
})
108+
109+
it('clears state and notifies subscribers on a `direct` event', async () => {
110+
await smbReconnectManager.init()
111+
const onSuccess = vi.fn()
112+
const unsub = smbReconnectManager.subscribe('vol-ok', onSuccess)
113+
emit('vol-ok', 'disconnected')
114+
expect(smbReconnectManager.getState('vol-ok')?.status).toBe('waiting')
115+
116+
emit('vol-ok', 'direct')
117+
expect(smbReconnectManager.getState('vol-ok')).toBeNull()
118+
expect(onSuccess).toHaveBeenCalledTimes(1)
119+
120+
unsub()
121+
})
122+
123+
it('"Retry now" fires immediately and resumes backoff at attempt 2', async () => {
124+
await smbReconnectManager.init()
125+
const unsub = smbReconnectManager.subscribe('vol-retry')
126+
smbReconnectManager.startCycle('vol-retry')
127+
// We're in attempt-0 wait. Skip ahead a bit, then click Retry now.
128+
await vi.advanceTimersByTimeAsync(500)
129+
mockReconnect.mockRejectedValueOnce(new Error('still down'))
130+
smbReconnectManager.retryNow('vol-retry')
131+
// The retry runs synchronously through the awaited reconnect call —
132+
// flush microtasks so the failure handler runs.
133+
await vi.advanceTimersByTimeAsync(0)
134+
expect(mockReconnect).toHaveBeenCalledTimes(1)
135+
// After the failure, we're scheduled for the SECOND attempt — index 1
136+
// (with delay RECONNECT_DELAYS_MS[1]), not back to index 0.
137+
const state = smbReconnectManager.getState('vol-retry')
138+
expect(state?.status).toBe('waiting')
139+
expect(state?.attemptIndex).toBe(1)
140+
expect(state?.currentDelayMs).toBe(RECONNECT_DELAYS_MS[1])
141+
142+
smbReconnectManager.cancel('vol-retry')
143+
unsub()
144+
})
145+
146+
it('"Cancel" stops the timer and clears state', async () => {
147+
await smbReconnectManager.init()
148+
const unsub = smbReconnectManager.subscribe('vol-cancel')
149+
smbReconnectManager.startCycle('vol-cancel')
150+
expect(smbReconnectManager.getState('vol-cancel')?.status).toBe('waiting')
151+
152+
smbReconnectManager.cancel('vol-cancel')
153+
expect(smbReconnectManager.getState('vol-cancel')).toBeNull()
154+
155+
// Even after waiting through every delay, no attempt fires.
156+
await vi.advanceTimersByTimeAsync(RECONNECT_DELAYS_MS.reduce((a, b) => a + b, 0) + 100)
157+
expect(mockReconnect).not.toHaveBeenCalled()
158+
159+
unsub()
160+
})
161+
162+
it('refcounts subscriptions: cycle stops when the last subscriber leaves', async () => {
163+
await smbReconnectManager.init()
164+
const unsub1 = smbReconnectManager.subscribe('vol-refcount')
165+
const unsub2 = smbReconnectManager.subscribe('vol-refcount')
166+
smbReconnectManager.startCycle('vol-refcount')
167+
expect(smbReconnectManager.getState('vol-refcount')?.status).toBe('waiting')
168+
169+
unsub1()
170+
// Still subscribed → cycle continues, state preserved.
171+
expect(smbReconnectManager.getState('vol-refcount')?.status).toBe('waiting')
172+
173+
unsub2()
174+
// Last subscriber left → entry cleared.
175+
expect(smbReconnectManager.getState('vol-refcount')).toBeNull()
176+
})
177+
178+
it('two subscribers see the same state object (one cycle, both panes)', async () => {
179+
await smbReconnectManager.init()
180+
const unsub1 = smbReconnectManager.subscribe('vol-shared')
181+
const unsub2 = smbReconnectManager.subscribe('vol-shared')
182+
smbReconnectManager.startCycle('vol-shared')
183+
const a = smbReconnectManager.getState('vol-shared')
184+
const b = smbReconnectManager.getState('vol-shared')
185+
expect(a).not.toBeNull()
186+
expect(b).toEqual(a)
187+
188+
smbReconnectManager.cancel('vol-shared')
189+
unsub1()
190+
unsub2()
191+
})
192+
})
193+
194+
describe('reconnect display helpers', () => {
195+
describe('ordinalCount', () => {
196+
it.each([
197+
[1, 'once'],
198+
[2, 'twice'],
199+
[3, '3 times'],
200+
[10, '10 times'],
201+
])('formats %i → %s', (n, expected) => {
202+
expect(ordinalCount(n)).toBe(expected)
203+
})
204+
})
205+
206+
describe('reconnectProgressMessage', () => {
207+
it('returns null for the very first attempt (no body 2)', () => {
208+
expect(reconnectProgressMessage(0)).toBeNull()
209+
})
210+
211+
it('formats the body 2 message for each attempt index', () => {
212+
// With the default 5-attempt array:
213+
// attemptIndex=1 → upcoming attempt 2, 3 more after this (3, 4, 5)
214+
expect(reconnectProgressMessage(1)).toBe('Retried once, will try it 3 times more after this.')
215+
// attemptIndex=2 → upcoming attempt 3, 2 more after this (4, 5)
216+
expect(reconnectProgressMessage(2)).toBe('Retried twice, will try it twice more after this.')
217+
// attemptIndex=3 → upcoming attempt 4, 1 more after this (5)
218+
expect(reconnectProgressMessage(3)).toBe('Retried 3 times, will try it once more after this.')
219+
// attemptIndex=4 → final attempt
220+
expect(reconnectProgressMessage(4)).toBe(
221+
'Retried 4 times, this is the final attempt — will drop the connection if it fails.',
222+
)
223+
})
224+
})
225+
})

0 commit comments

Comments
 (0)