fix(connectivity): drive reachability from real repository traffic
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m32s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m27s
Build & Release / Run Tests (push) Successful in 3m36s
Build & Release / Build Linux (push) Successful in 15m43s
Build & Release / Build Android (push) Successful in 18m40s
Build & Release / Create Release (push) Failing after 22s

The offline/online switch was janky because two independent systems decided
"online" and never communicated:

- ConnectivityMonitor owned is_server_reachable (drove the UI banner) but
  learned reachability only from a standalone /System/Info/Public ping loop
  and from auth/login calls.
- HybridRepository served all real data by racing cache-vs-server but never
  read or wrote reachability.

So the banner reflected a side-channel poller, not the system the user actually
experienced: a successful ping could read "online" while authenticated data
calls 401'd or timed out, and three different timeout regimes (5s ping / 30s
data / 100ms cache race) flapped against each other.

Unify into a single source of truth:

- Extract a cheap, cloneable ConnectivityReporter that owns all reachability
  transitions and event emission.
- OnlineRepository reports the outcome of every server request to the reporter,
  classified via RepoError: Ok/Authentication/NotFound/Server => reachable
  (the server answered), Network => offline candidate, Database/Offline =>
  ignored (not a server signal).
- Time-window debounce (OFFLINE_CONFIRM_WINDOW = 5s): flip offline only after
  sustained network failure; recover instantly on the first success.
- Demote the ping loop to an offline-only recovery probe (no online polling;
  real traffic is the signal when online).
- Frontend: navigator.onLine is now advisory (triggers a recheck instead of
  forcing offline); removed the dead markReachable/markUnreachable store methods.

Docs updated (README, 07-connectivity, 03-data-flow, 02-svelte-frontend) to
describe the new model and fix pre-existing drift (HTTP client is 30s timeout +
5s ping, not the documented 10s/base_url).

Tests: 12 connectivity tests (debounce, instant recovery, RepoError
classification through report_outcome). Full suite: 398 Rust + 384 frontend
passing, svelte-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 21:56:14 +02:00
co-authored by Claude Opus 4.8
parent 3faa595b76
commit 45aa029916
8 changed files with 645 additions and 326 deletions
+6 -3
View File
@@ -176,7 +176,10 @@ classDiagram
-server_url: String
-user_id: String
-access_token: String
-connectivity: Option~Arc~ConnectivityMonitor~~
+new()
+with_connectivity()
-report_outcome()
}
class OfflineRepository {
@@ -192,10 +195,9 @@ classDiagram
class HybridRepository {
-online: Arc~OnlineRepository~
-offline: Arc~OfflineRepository~
-connectivity: Arc~ConnectivityMonitor~
+new()
-parallel_query()
-has_meaningful_content()
-parallel_race()
-cache_with_timeout()
}
MediaRepository <|.. OnlineRepository
@@ -214,6 +216,7 @@ classDiagram
- Returns cache result if it has meaningful content
- Falls back to server result otherwise
- Background cache updates planned
- **Connectivity feedback**: `OnlineRepository` reports the outcome of every server request to the `ConnectivityMonitor` (classified via `RepoError`). This is the source of truth for the offline/online banner — see [07-connectivity.md](07-connectivity.md). The frontend `connectivity` store is a pure reflection of the resulting events; `navigator.onLine` is only an advisory hint that triggers an immediate recheck.
2. **Handle-Based Resource Management** (`repository.rs` commands):
```rust
+9
View File
@@ -10,6 +10,7 @@ sequenceDiagram
participant Hybrid as HybridRepository
participant Cache as OfflineRepository (SQLite)
participant Server as OnlineRepository (HTTP)
participant Conn as ConnectivityMonitor
UI->>Client: getItems(parentId)
Client->>Rust: invoke("repository_get_items", {handle, parentId})
@@ -20,6 +21,13 @@ sequenceDiagram
Hybrid->>Server: get_items() (no timeout)
end
Note over Server,Conn: Every server request reports its outcome
alt Server succeeds (or answers with 4xx/5xx)
Server->>Conn: mark_reachable() (server is up)
else Network failure / timeout
Server->>Conn: mark_unreachable() (debounced)
end
alt Cache returns with content
Cache-->>Hybrid: Result with items
Hybrid-->>Rust: Return cache result
@@ -39,6 +47,7 @@ sequenceDiagram
- Cache wins if it has meaningful content
- Automatic fallback to server if cache is empty/stale
- Background cache updates (planned)
- **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline.
## Playback Initiation Flow
+61 -26
View File
@@ -13,12 +13,13 @@ pub struct HttpClient {
}
pub struct HttpConfig {
pub base_url: String,
pub timeout: Duration, // Default: 10s
pub timeout: Duration, // Default: 30s (large library queries can be slow)
pub max_retries: u32, // Default: 3
}
```
> Note: ordinary requests use the 30s timeout above. The connectivity recovery probe (`ping`) uses a shorter, dedicated 5s timeout so an unreachable server is detected quickly while offline.
**Retry Strategy:**
- Retry delays: 1s, 2s, 4s (exponential backoff)
- Retries on: Network errors, 5xx server errors
@@ -38,39 +39,71 @@ pub enum ErrorKind {
**Location**: `src-tauri/src/connectivity/mod.rs`
The connectivity monitor tracks server reachability with adaptive polling:
The connectivity monitor is the **single source of truth** for server reachability. Its primary signal is the outcome of *real repository traffic* — every server request the user actually makes. A standalone `/System/Info/Public` probe is kept only as an offline recovery detector.
### Source of truth: repository traffic
`OnlineRepository` reports the result of each server request to the monitor, classified via `RepoError`:
| Repository outcome | Meaning | Effect on reachability |
|--------------------|---------|------------------------|
| `Ok(_)` | Server answered successfully | Mark **reachable** (instant recovery) |
| `Err(Authentication)` | Server answered with 401/403 | Mark **reachable** (server is up; request was rejected) |
| `Err(NotFound)` | Server answered with 404 | Mark **reachable** (server is up) |
| `Err(Server)` | Server answered with 5xx / bad body | Mark **reachable** (server is up) |
| `Err(Network)` | Connection failure / timeout / DNS | **Candidate for offline** (see debounce) |
| `Err(Database)` | Local cache error only | No effect (not a server signal) |
This classification fixes the previous bug where a successful `/System/Info/Public` ping reported "online" even while the user's authenticated data calls were failing — and vice versa.
### Time-window debounce (offline) + instant recovery (online)
To stop the banner from flapping on a single dropped request, the transition to **offline** is debounced over a time window:
- On the **first** `Network` failure, the monitor records `first_failure_at`.
- It flips `is_server_reachable = false` only once `Network` failures have persisted continuously for `OFFLINE_CONFIRM_WINDOW` (5s) with no intervening success.
- **Any** success (or server-answered error) clears `first_failure_at` and immediately marks reachable.
Recovery is therefore instant and asymmetric: one good response brings the app back online, but a brief blip never trips the banner.
### Offline-only recovery probe
```mermaid
flowchart TB
Monitor["ConnectivityMonitor"] --> Poller["Background Task"]
Poller --> Check{"Server<br/>Reachable?"}
Check -->|"Yes"| Online["30s Interval"]
Check -->|"No"| Offline["5s Interval"]
Online --> Emit["Emit Events"]
Offline --> Emit
Emit --> Frontend["Frontend Store"]
Repo["OnlineRepository"] -->|"success / RepoError"| Monitor["ConnectivityMonitor"]
Monitor --> State{"is_server_reachable?"}
State -->|"Online"| NoProbe["No background polling<br/>(real traffic is the signal)"]
State -->|"Offline"| Probe["5s /System/Info/Public probe<br/>(recovery detector)"]
Probe -->|"reachable again"| Monitor
Monitor -->|"on change"| Emit["Emit connectivity:changed<br/>+ connectivity:reconnected"]
Emit --> Frontend["Frontend Store → banner"]
```
While **online**, there is no background polling — real requests keep the state fresh. While **offline**, the fast 5s probe runs so an idle app still detects the server returning even when no user traffic is flowing.
**Features:**
- **Adaptive Polling**: 30s when online, 5s when offline (for quick reconnection detection)
- **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events
- **Manual Marking**: Can mark reachable/unreachable based on API call results
- **Thread-Safe**: Uses Arc<RwLock<>> for shared state
- **Traffic-driven**: Reachability follows the requests the user actually makes.
- **Time-window debounce**: Offline declared only after `OFFLINE_CONFIRM_WINDOW` (5s) of sustained network failure; recovery is instant.
- **Offline-only probe**: 5s `/System/Info/Public` probe runs only while offline.
- **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events.
- **Thread-Safe**: Uses `Arc<RwLock<>>` for shared state.
**Tauri Commands:**
| Command | Description |
|---------|-------------|
| `connectivity_check_server` | Manual reachability check |
| `connectivity_check_server` | Manual reachability check (also used by the frontend's advisory `navigator.onLine` hint) |
| `connectivity_set_server_url` | Update monitored server URL |
| `connectivity_get_status` | Get current connectivity status |
| `connectivity_start_monitoring` | Start background monitoring |
| `connectivity_stop_monitoring` | Stop monitoring |
| `connectivity_mark_reachable` | Mark server as reachable (after successful API call) |
| `connectivity_mark_unreachable` | Mark server as unreachable (after failed API call) |
| `connectivity_start_monitoring` | Start the offline recovery probe |
| `connectivity_stop_monitoring` | Stop the probe |
| `connectivity_mark_reachable` | Mark reachable — driven by `OnlineRepository` on every server success |
| `connectivity_mark_unreachable` | Mark unreachable — driven by `OnlineRepository` on `RepoError::Network` (subject to debounce) |
**Frontend Integration:**
```typescript
// TypeScript store listens to Rust events
// The store is a pure reflection of backend events — it no longer decides
// reachability itself. navigator.onLine is advisory: it triggers an immediate
// recheck rather than forcing the offline state.
listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
updateConnectivityState(event.payload.isReachable);
});
@@ -81,12 +114,14 @@ listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
The connectivity system provides resilience through multiple layers:
1. **HTTP Client Layer**: Automatic retry with exponential backoff
2. **Connectivity Monitoring**: Background reachability checks
3. **Frontend Integration**: Offline mode detection and UI updates
2. **Connectivity Monitoring**: Reachability derived from real repository traffic, with an offline-only recovery probe
3. **Frontend Integration**: Offline mode detection and UI updates (a pure reflection of backend events)
4. **Sync Queue**: Offline mutations queued for later (see [06-downloads-and-offline.md](06-downloads-and-offline.md))
**Design Principles:**
- **Fail Fast**: Don't retry 4xx errors (client errors, authentication)
- **Fail Slow**: Retry network and 5xx errors with increasing delays
- **Adaptive Polling**: Reduce polling frequency when online, increase when offline
- **Event-Driven**: Frontend reacts to connectivity changes via events
- **Single source of truth**: Reachability follows the outcome of real requests, classified via `RepoError`; the frontend store and the probe never compete to decide it.
- **Fail Fast**: Don't retry 4xx errors (client errors, authentication).
- **Fail Slow**: Retry network and 5xx errors with increasing delays.
- **Debounced offline, instant online**: Declare offline only after a sustained failure window; recover on the first success.
- **Probe only when needed**: Background polling runs only while offline, as a recovery detector.
- **Event-Driven**: Frontend reacts to connectivity changes via events.
+5 -1
View File
@@ -15,6 +15,7 @@ JellyTau uses a client-server architecture: business logic lives in a comprehens
- **Events + Polling hybrid**: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`).
- **Handle-Based Resources**: UUID handles for stateful Rust objects.
- **Cache-First**: Parallel queries with intelligent fallback.
- **Single source of truth for reachability**: Server reachability is derived from the outcome of *real repository traffic*, not a side-channel poller. The `OnlineRepository` reports each server result to the `ConnectivityMonitor` (classified via `RepoError`), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone `/System/Info/Public` probe runs *only while offline*, as a recovery detector for idle sessions.
- **Poison-tolerant locking**: Shared `std::sync` state is accessed via the `MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned lock instead of cascading a panic across the player.
- **Graceful backend init**: If a native player backend (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a `backend-init-failed` event rather than crashing.
@@ -79,9 +80,12 @@ flowchart TB
Core --> Storage
Repository --> HttpClient
Repository --> DatabaseService
Repository -->|"reports server outcome<br/>(success / RepoError)"| ConnectivityMonitor
end
```
> The `Repository --> ConnectivityMonitor` edge is the source of truth for the offline/online banner: every server request the user actually makes updates reachability. The monitor's own polling is now an offline-only recovery probe (see [07-connectivity.md](07-connectivity.md)).
---
## Detailed Documentation
@@ -187,7 +191,7 @@ src/lib/
**What moved to Rust (~3,500 lines of business logic):**
1. **HTTP Client** (338 lines) - Retry logic with exponential backoff
2. **Connectivity Monitor** (301 lines) - Adaptive polling, event emission
2. **Connectivity Monitor** (301 lines) - Reachability derived from real repository traffic, time-window debounce, offline-only recovery probe, event emission
3. **Repository Pattern** (1061 lines) - Cache-first hybrid with parallel racing
4. **Database Service** - Async wrapper preventing UI freezing
5. **Playback Mode** (303 lines) - Local/remote transfer coordination