# Bug Report: WebSocket connections + subscriptions not restored on app foreground

**Reporter:** tele (mobile coordinator)
**Date:** 2026-04-19
**Discovered via:** synthetic-data integration test (PR #295 backend + `test/local-backend-integration` mobile)
**Severity:** medium-high — silent loss of live prices after any app switch on weekends or off-hours

## Reproduction

1. Open mobile app while connected to backend (real or synthetic)
2. Observe live prices ticking on watchlist / stock detail
3. Switch to another app (e.g., Telegram), then return to NanoStreet
4. **Bug:** prices freeze. WS connection not restored, subscriptions not re-issued. Manual app reload required to recover.

Reproduces 100% on synthetic backend (weekend test setup). Likely also reproduces on production weekends (markets closed) and possibly during pre-/after-hours windows depending on `marketStatus` semantics.

## Root cause

`useWsAppStateSync` in `src/features/market/presentation/providers/MarketWebSocketProvider.tsx:98-114` guards the foreground-reconnect path on `shouldConnectRef.current`:

```ts
if (nextState === 'active' && shouldConnectRef.current && !connectedRef.current) {
  livePrice.connect().catch(() => {});
}
```

`shouldConnectRef.current` is set by `useWsMarketStatusTeardown` (lines 75-96) based on the `useMarketStatusQuery` result:

```ts
const shouldConnect = marketStatus == null || ACTIVE_MARKET_STATUSES.has(marketStatus);
```

`ACTIVE_MARKET_STATUSES = {'open', 'pre-market', 'after-hours'}`. So when `marketStatus === 'closed'` (weekend, overnight) → `shouldConnect = false` → AppState foreground handler **silently refuses to reconnect**.

But the initial-mount connect (lines 50-52) is **unconditional**:

```ts
useEffect(() => {
  if (!connectedRef.current) livePrice.connect().catch(() => {});
}, [livePrice]);
```

This asymmetry is the bug. Cold start always connects regardless of market status; once you background the app, the foreground gate refuses to reconnect when market is closed.

## Why initial mount works but foreground restore doesn't

| Path | Gated by `marketStatus`? |
|---|---|
| Mount effect (line 50-52) | ❌ No — unconditional |
| AppState `active` handler (line 105) | ✅ Yes — gated on `shouldConnectRef.current` |
| `useWsMarketStatusTeardown` (line 81-95) | Tears down with 10s grace if `marketStatus` becomes inactive |

So on cold start during market closed:
1. Mount effect connects unconditionally → WS up
2. `useWsMarketStatusTeardown` sees `marketStatus='closed'` → arms 10s timer → tears down
3. Within those 10s the user sees live prices (synthetic ticks arrive)
4. After 10s, WS is intentionally disconnected
5. User backgrounds the app
6. User foregrounds the app
7. AppState handler checks `shouldConnectRef.current` → false (market still closed) → no reconnect
8. **Silent failure** — UI shows last cached prices forever, no indicator anything is wrong

(The user's observation matches step 1-7 if they backgrounded BEFORE the 10s teardown landed — even within the "live prices working" window, foregrounding still respects the closed status.)

## Secondary observations

1. **No user-visible indicator of disconnected state.** `useLivePrice` returns `null` when no tick is in the store, but components render the last cached value or fall back to REST snapshot — no "WS disconnected" UI exists. Combined with the silent gate, this is a particularly invisible failure mode.

2. **Synthetic backend doesn't override `/market-status` endpoint.** The backend synthetic data source (PR #295) replaces the WS feed only. The `marketStatus` REST endpoint still returns the real value, which on weekends/off-hours = `closed`. This makes the synthetic test environment hit the bug whether or not we want it to. Either:
   - (a) Synthetic backend should also override market-status (return `open` when synthetic mode is on) → cleaner test environment, exposes the bug less
   - (b) Mobile should treat the AppState foreground path differently → fixes the actual production bug
   - We probably want both, but (b) is the user-visible fix.

3. **Reconnection on the `connected` state-change event would resubscribe.** Lines 56-64 do hold the contract:
   ```ts
   if (!wasConnected && state === 'connected') {
     resubscribeAll(livePrice, subsRef.current);
   }
   ```
   So IF the foreground reconnect actually fires, the subscriptions WILL be re-issued. The bug is purely "reconnect doesn't fire" — not a separate "subscriptions are lost" bug. One fix unblocks both observed symptoms.

4. **`useWsMarketStatusTeardown`'s 10s grace timer isn't cleaned up across status flips.** Reading lines 81-95 again — the cleanup `clearTimeout(timer)` is correct for one effect run, but if `marketStatus` flips closed → active → closed quickly, the prior teardown timer's `if (!shouldConnectRef.current && connectedRef.current)` check protects it. That's fine. Not a bug, just confirming.

## Suggested fix scope

**Minimum fix (recommended):** Make foreground reconnect path symmetric with cold-start mount path.

Option A — drop the `shouldConnectRef` gate from the foreground handler:
```ts
if (nextState === 'active' && !connectedRef.current) {
  livePrice.connect().catch(() => {});
}
```
Let `useWsMarketStatusTeardown` handle its own teardown logic post-reconnect. The user gets ~10s of live prices on every foreground regardless of market state, then the teardown kicks in if appropriate. Matches cold-start behavior.

Option B — keep the gate but ALSO trigger initial mount-style connect on every foreground:
```ts
if (nextState === 'active' && !connectedRef.current) {
  livePrice.connect().catch(() => {});
  // teardown timer will dispatch if shouldConnectRef.current is false
}
```
Functionally same as Option A, just a different mental model.

**Better fix:** in addition to the above, distinguish "off-hours but user is actively using the app" from "off-hours and app is backgrounded for a long time". Off-hours WS connection is cheap if the user is actively foregrounded — the teardown was probably premature. Consider:
- Reconnect on foreground regardless of status
- Only tear down if backgrounded long enough OR market is closed AND user has been idle for N minutes
- Surface a small UI indicator when WS is intentionally disconnected ("Markets closed — prices paused")

**Non-fix observation:** the synthetic backend MAY also want to override market-status for cleaner testing, but that's a separate test-environment polish, not a production bug fix.

## Files involved

- `src/features/market/presentation/providers/MarketWebSocketProvider.tsx` — primary file, all the lifecycle hooks live here
- `src/features/market/presentation/hooks/useMarketStatusQuery.ts` — defines the `marketStatus` source (read-only)
- `src/infrastructure/websocket/WebSocketClient.ts` — confirmed not the source of the bug; reconnect logic is sound, just doesn't get invoked
- `src/features/market/data/repositories/LivePriceRepositoryImpl.ts` — passthrough wrapper, not a fix candidate

## Suggested PR scope (separate branch, NOT this test branch)

**Branch name:** `fix/ws-foreground-restore`

Single small PR addressing the symmetry bug. Acceptance:
1. After backgrounding then foregrounding the app, WS reconnects within 1-2s regardless of market status
2. After reconnect, all previously-subscribed tickers resume receiving ticks (resubscribe via `onStateChange` listener)
3. If market is genuinely closed, the 10s teardown still kicks in afterward (no behavior regression for the closed-market case)
4. Existing tests still green; add 1-2 new tests for the foreground-reconnect path (jest fake timers + AppState mock)

Estimated effort: 1-2 hours. Small, well-scoped, easy CR review.

## Out of scope for the immediate fix
- "Markets closed — prices paused" UI indicator (separate UX decision)
- Synthetic backend market-status override (separate spec / tracker)
- Long-idle disconnect heuristic (future enhancement, not a regression)

---

**Recommendation:** open `fix/ws-foreground-restore` branch + PR with Option A (drop the gate from foreground handler) + foreground-reconnect test. Backend synthetic environment exposes this nicely so we can validate the fix end-to-end on weekends.
