Skip to content

Commit 988649b

Browse files
committed
docs: add offline recovery vs session gate design
1 parent 8ceeb46 commit 988649b

1 file changed

Lines changed: 259 additions & 0 deletions

File tree

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
# Offline Recovery Vs Session Gate Design
2+
3+
> Status: Draft
4+
> Date: 2026-05-14
5+
> Scope: `packages/web/src/hooks/use-activation.ts`, `packages/web/src/hooks/use-bootstrap.ts`, `packages/web/src/app/providers.tsx`, `packages/web/src/shells/shared/connection-status-banner.tsx`, related tests
6+
7+
## Goal
8+
9+
Stop routing normal connection recovery failures to `/session-gate`.
10+
11+
The app should only show `session-gate` when the current tab is explicitly displaced by another active tab. Ordinary websocket disconnects, reconnect backoff, and slow recovery should keep the user on the current workspace and use the existing top connection banner instead.
12+
13+
## Problem
14+
15+
The current implementation mixes two different failure classes into the same `activationStatus === "gated"` outcome:
16+
17+
- true single-active displacement
18+
- ordinary reconnect or activation claim failure
19+
20+
That creates two product problems:
21+
22+
- users see a page that says another tab took over even when no other tab exists
23+
- the app abandons the current workspace UI during connection recovery, even though a reconnect banner already exists
24+
25+
The result is both misleading and unnecessarily disruptive.
26+
27+
## Decision
28+
29+
Split activation and connection recovery into separate user-facing states.
30+
31+
- `session-gate` is reserved for explicit displacement only
32+
- offline recovery stays in place on the current route
33+
- the top connection banner becomes the sole UI for reconnecting, disconnected, and slow-recovery states
34+
35+
This keeps the meaning of each state narrow:
36+
37+
- displacement means another tab actually replaced this one
38+
- offline recovery means this tab is still the intended active tab, but transport recovery is in progress or degraded
39+
40+
## Current Behavior Summary
41+
42+
Today the frontend navigates to `/session-gate` whenever `activationStatus === "gated"`.
43+
44+
That `gated` state is currently reached from:
45+
46+
- `activation.revoked` handling in `AppProviders`
47+
- `wsClient.connect()` failure inside `useActivation.claim()`
48+
- `activation.claim` command failure inside `useActivation.claim()`
49+
50+
Only the first case is a real displacement signal. The latter two are transport or recovery failures and should not be represented as tab displacement.
51+
52+
## Proposed State Model
53+
54+
### Activation
55+
56+
Activation should model whether this tab still owns the single-active lease.
57+
58+
Required activation outcomes:
59+
60+
- `active`
61+
- this tab owns the lease
62+
- `displaced`
63+
- the server explicitly revoked this tab because another tab took over
64+
65+
`gated` should no longer be used as a catch-all for reconnect and claim failures.
66+
67+
If the existing atom names are kept for a smaller patch, the implementation may continue storing `gated`, but only for true displacement. In that case, reconnect and claim failures must stop writing that value.
68+
69+
### Connection Recovery
70+
71+
Connection recovery should remain fully driven by websocket connection state:
72+
73+
- `connecting`
74+
- `connected`
75+
- `reconnecting`
76+
- `disconnected`
77+
- `rejected`
78+
79+
Normal websocket recovery should not mutate activation into a displaced-like state.
80+
81+
## Routing Rules
82+
83+
### When To Enter `/session-gate`
84+
85+
Navigate to `/session-gate` only when the tab is explicitly displaced.
86+
87+
Accepted triggers:
88+
89+
- receipt of `activation.revoked` with displacement semantics
90+
- a websocket close reason that unambiguously means displacement, such as `single_active_displaced`
91+
92+
### When To Stay On The Current Route
93+
94+
Remain on the current page for:
95+
96+
- temporary websocket disconnect
97+
- reconnect backoff
98+
- recovery probe failure
99+
- `activation.claim` failure caused by transport or request unavailability
100+
- long-running reconnect attempts
101+
102+
The current workspace route, settings route, and other in-app routes should stay mounted so the user keeps context while recovery continues.
103+
104+
## Banner Behavior
105+
106+
The existing `ConnectionStatusBanner` remains the main recovery surface.
107+
108+
### Primary Message
109+
110+
When the app is recovering from a normal disconnect, show a top banner with the primary line:
111+
112+
- `连接已断开,正在重新连接...`
113+
114+
This replaces the current split phrasing where `reconnecting` shows one string and `disconnected` shows a harsher terminal message. During active automatic recovery, the banner should communicate progress, not final failure.
115+
116+
### Slow Recovery Hint
117+
118+
If recovery remains unresolved for roughly 25 seconds, add a second line beneath the primary message:
119+
120+
- `连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。`
121+
122+
This hint should appear only after sustained failure, not immediately on the first reconnect attempt.
123+
124+
### Reset Behavior
125+
126+
The slow-recovery timer resets when:
127+
128+
- connection returns to `connected`
129+
- an explicit displacement occurs and the app transitions to `session-gate`
130+
131+
### Auto Retry
132+
133+
Automatic reconnect attempts continue indefinitely under the existing reconnect strategy, subject to the current bounded backoff ceiling.
134+
135+
The UI should not present a manual retry button in this phase.
136+
137+
## Session Gate Behavior
138+
139+
`SessionGatePage` remains a full-page displacement shell.
140+
141+
Its meaning becomes narrower:
142+
143+
- this tab was actively displaced by another tab
144+
- the current websocket was intentionally revoked because another holder took control
145+
146+
This page should no longer be used for:
147+
148+
- reconnect failure
149+
- offline network conditions
150+
- backend restart during recovery
151+
- temporary request metadata gaps
152+
153+
## Error Semantics
154+
155+
### Claim Failure
156+
157+
If `activation.claim` fails during reconnect, treat it as a recovery error first, not a displacement event, unless the server explicitly reports displacement.
158+
159+
Expected user-visible behavior:
160+
161+
- stay on the current route
162+
- keep the connection banner visible
163+
- allow the reconnect loop to continue
164+
165+
### Explicit Displacement
166+
167+
If the server explicitly displaces the client:
168+
169+
- set activation to the displacement state
170+
- clear projected workspace/session state as the app already does
171+
- disconnect the websocket intentionally
172+
- navigate to `/session-gate`
173+
174+
## Implementation Strategy
175+
176+
### Option Chosen
177+
178+
Preferred implementation is a semantic split rather than a route-layer exception.
179+
180+
That means:
181+
182+
- remove reconnect and claim failures as causes of activation gating
183+
- keep `/session-gate` tied to explicit displacement signals only
184+
- extend the banner to support a slow-recovery secondary line
185+
186+
### Rejected Alternative
187+
188+
A smaller patch could keep the current state model and merely avoid routing to `/session-gate` when `activationReason` is `reconnect_failed` or `claim_failed`.
189+
190+
That approach is not preferred because:
191+
192+
- it preserves ambiguous activation semantics
193+
- future contributors can accidentally reintroduce the bug
194+
- the route logic becomes a policy exception table instead of reflecting clear state meaning
195+
196+
## Testing Requirements
197+
198+
Add or update tests for the following cases:
199+
200+
1. reconnect failure does not navigate to `/session-gate`
201+
2. `activation.claim` failure does not navigate to `/session-gate`
202+
3. explicit `activation.revoked` still navigates to `/session-gate`
203+
4. the connection banner appears during reconnecting recovery
204+
5. the slow-recovery secondary hint appears only after the configured duration
205+
6. the slow-recovery hint resets after a successful reconnect
206+
207+
## Risks
208+
209+
### Risk: stale route remains visible too long
210+
211+
Mitigation:
212+
213+
- keep current projected-state reset behavior for explicit displacement only
214+
- use clear reconnect messaging in the banner during degraded transport states
215+
216+
### Risk: transport failures that actually mask displacement remain on the workspace briefly
217+
218+
Mitigation:
219+
220+
- only explicit server displacement should trigger the displacement shell
221+
- once the server sends a real revoke event, the app still transitions immediately
222+
223+
### Risk: banner messaging feels indefinite
224+
225+
Mitigation:
226+
227+
- add the 25-second slow-recovery hint
228+
- keep retry behavior automatic so the user is not forced into immediate action
229+
230+
## Non-Goals
231+
232+
This change does not:
233+
234+
- redesign the visual style of `session-gate`
235+
- add a manual retry button
236+
- change websocket backoff math beyond existing limits
237+
- introduce a modal offline blocker
238+
- solve unrelated multi-tab fencing behavior
239+
240+
## Verification
241+
242+
After implementation, verify:
243+
244+
1. placing the app idle long enough to trigger websocket recovery no longer routes to `/session-gate`
245+
2. normal reconnect flow keeps the user on `/workspace`
246+
3. a true displacement still shows `session-gate`
247+
4. the banner shows the primary reconnect line immediately
248+
5. the slow-recovery hint appears after the configured threshold and disappears after reconnect
249+
250+
## Implementation Boundary
251+
252+
Expected files to change:
253+
254+
- `packages/web/src/hooks/use-activation.ts`
255+
- `packages/web/src/hooks/use-bootstrap.ts`
256+
- `packages/web/src/app/providers.tsx`
257+
- `packages/web/src/shells/shared/connection-status-banner.tsx`
258+
- relevant desktop/mobile shell tests
259+
- relevant provider lifecycle tests

0 commit comments

Comments
 (0)