fix(msg-bus): prevent race on reconnect

and update plan
This commit is contained in:
Edouard Vanbelle
2026-09-11 03:20:32 +02:00
parent 41d25d3a3e
commit 899bbd13a6
3 changed files with 183 additions and 57 deletions
+159 -35
View File
@@ -16,6 +16,78 @@ editing is one consumer on top; folder-live updates, notifications,
job progress, presence, and sync-client push invalidation follow with
almost no extra scaffolding.
## Status — 2026-09-11
The `feat/message-bus` branch delivers **D + F + follow-ups shipped
end-to-end** on the FE and BE, verified by S1–S11 in the api-test
smoke suite plus manual multi-user E2E. Live today:
- **Bus core** — `MessageBus` port + `InProcessMessageBus` +
`NoopReplicator`. `📤 bus publish` trace under
`RUST_LOG=oxicloud::message_bus=debug`.
- **WS handler** (`/api/rt/ws`) — JSON-RPC 2.0, `rt.subscribe /
unsubscribe / event / revoked / ping / pong`, server-initiated RFC
6455 keepalive Ping.
- **Auth for the WS upgrade** — **F ticket flow shipped**.
`POST /api/rt/ticket` mints a one-shot 30 s ticket under the full
auth+DPoP+CSRF chain; the browser passes it via
`Sec-WebSocket-Protocol: oxi.ticket.<uuid>`. Also accepts
`Authorization: Bearer <jwt>` for programmatic clients
(`rt-hurl-helper`). Route mounted OUTSIDE `protected_api` so the
standard DPoP-required middleware doesn't 401 browsers that can't
attach a `DPoP:` header to `new WebSocket()`. See
`handlers/rt_ws.rs` module doc.
- **Events firing end-to-end** — every `MessageBusEvent` variant
except CRDT-flavoured ones:
- `FileCreated / Renamed / Moved / Deleted` (via
`FileUploadService` + `FileManagementService`)
- `FolderCreated / Renamed / Moved / Deleted` (via `FolderService`
for direct paths; `TrashService` publishes `FolderDeleted` on the
trash-first path — the FE hits that path via
`DELETE /api/folders/{id}`)
- `AuthzChanged` (via `ShareService::revoke_grant`) drives the
grant-revocation eviction cascade.
- **Grant-revocation eviction (Slice C)** — WS handler
auto-subscribes each session to `user:{caller}:authz`; on
`AuthzChanged` the reader translates to `SessionOut::EvictFolders`
and emits `rt.revoked` per evicted topic. Scope is per-topic; the
session itself and unrelated subscriptions survive.
- **FE composables** — `useTopic` (generic), `useFolderTopic`
(folder-view sugar with per-verb + `onRevoked` + `onReconnect`
handlers), `useReconnect` (session-level, fires after 2nd+ open).
Types generated from AsyncAPI via `@asyncapi/modelina` in
`frontend/src/lib/generated/message-bus/`; `check-message-bus-spec`
CI + `pre-pull-request` block on drift.
- **Folder-view live refresh** — `+page.svelte` wires every
`onFile*`/`onFolder*` handler to `scheduleLiveReload`, 100 ms
coalesce. Revocation → toast + `goto('/files')`. Reconnect →
refetch via `onReconnect` (bridges the "events lost during outage
window" gap; see `project_message_bus_reconnect_gap` memory).
Actor-echo skip was REMOVED for multi-tab correctness — refetch is
idempotent, ~30 ms per self-mutation.
- **Client-side resilience** — jittered exponential backoff
(250 ms → 30 s cap), circuit breaker at 20 consecutive failures
(~5 minutes of retry — covers a cargo-release restart), `untrack`
in every mutation entry point so `$state` reads don't leak into
caller `$effect` deps.
- **AuthZ tested** — S3 (folder no_read), S4 (nonexistent folder =
anti-enum parity), S9 (cross-user identity topic → `topic_forbidden`).
- **Ticket tested** — S10 (happy path), S11 (single-use replay
rejected).
Deferred and still open — see the Roadmap section and the
`project_message_bus_reconnect_gap` memory:
- **Notifications table + bell** (E) — topic + producer + auto-sub
land here. Same pattern as `:authz`.
- **Presence** (Phase B) — `folder:{id}:presence` topic + awareness
frames.
- **Yjs collab** — `docs/plan/markdown-collab.md`, depends on the
binary-frame routing this plan sketches but doesn't ship.
- **Broker replicator** (Postgres LISTEN/NOTIFY or Redis) — for
multi-instance and durable event log. `BusReplicator` port
declared, `NoopReplicator` wired today.
## Non-goals
- Persistent event log with "you missed these" replay. Durable state
@@ -352,25 +424,61 @@ everywhere.
## Frontend components
### 1. Singleton client (`lib/stores/message-bus.svelte.ts`)
### 1. Singleton client (`lib/message-bus/client.svelte.ts`)
- Fetches a ticket via `POST /api/rt/ticket` (through `apiFetch`, so
DPoP is applied).
- Opens `wss:///api/rt/ws?ticket=…`.
Location is `lib/message-bus/` (subsystem dir, mirrors `lib/auth/` and
`lib/upload/` — see `frontend/AGENTS.md`), NOT `lib/stores/` — the
client is subsystem-scoped plumbing, not global reactive state that
routes read from.
- **Fetches a ticket** via `POST /api/rt/ticket` (through `apiFetch`,
so DPoP is applied; `getCsrfHeaders()` merged in for the state-
changing POST). Ticket then passes on the WS upgrade via
`Sec-WebSocket-Protocol: oxi.ticket.<uuid>` (NOT a query param —
keeps the token off access logs and out of Referer / URL bar).
- Opens `wss://<same-origin>/api/rt/ws` with the subprotocol.
- **Refcounted subscriptions**:
`subs: Map<TopicKey, { count, listeners: Set<Handler> }>`.
- On subscribe by first component: send frame; on last unsubscribe:
send frame.
- On reconnect: reissue ticket, re-establish WS, re-send `subscribe`
for every live topic — components don't care.
- Backoff: exponential (250 ms → 30 s), full-jitter.
- Health: `$state({ connected, latencyMs, subscribedTopics })`
exposed for a debug indicator.
`#subs: Map<TopicKey, { count, handlers, revokedHandlers, acked }>`.
- On first refcount of a topic: send `rt.subscribe`; on last drop:
send `rt.unsubscribe`.
- **On reconnect**: replay every already-known topic (client-side
state survives the disconnect); fire `onReconnect` handlers so
consumers refetch and catch up on events dropped during the
outage window.
- **Backoff**: exponential (250 ms → 30 s), full-jitter. Circuit
breaker at 20 consecutive failures (~5 minutes of retry —
comfortably covers a cargo-release restart); trip logs one `error`
line and stops until `messageBus.reconnect()` is called or the
page reloads.
- **Reactive-safety rule**: every mutation entry point
(`subscribe`, `onReconnect`, `reconnect`, `close`, `#call`) wraps
its `$state` reads in `untrack(() => …)`. Without this a caller's
`$effect` inherits a hidden dep on `state`, and each transition
(idle → connecting → connected → disconnected → …) re-fires the
effect — an observed 1000+/s loop on server-down. See the
`feedback-svelte5-untrack-mutation-methods` memory for the
general rule and the docstring on `subscribe` for the concrete
case.
- **Health**: `state = $state<ConnectionState>` +
`latencyMs = $state<number | null>` exposed for a future debug
indicator (no UI consumes them yet — silent MVP).
### 2. Composable (`lib/composables/useTopic.ts`)
### 2. Composables
```ts
useTopic(`folder:${folderId}`, (evt) => { /* mutate local $state */ });
// Generic — subscribe to any topic.
useTopic(topic, onEvent, onRevoked?)
// Folder-view sugar — per-verb handlers + reconnect hook.
useFolderTopic(() => folderId, {
onFileCreated, onFileRenamed, onFileMoved, onFileDeleted,
onFolderCreated, onFolderRenamed, onFolderMoved, onFolderDeleted,
onRevoked, // grant revoked, subscription evicted server-side
onReconnect, // WS came back; consumers refetch to catch up
})
// Session-level reconnect (fires on 2nd+ open, never initial).
useReconnect(cb)
```
Handles `$effect` lifecycle (subscribe on mount, unsubscribe on
@@ -1047,28 +1155,44 @@ this baseline once the baseline is green.
Ships the infrastructure and the two most visible consumers together.
- Bus port + `InProcessMessageBus` + `NoopReplicator` + WS handler
+ ticket endpoint.
- Frontend singleton + `useTopic` composable.
- Topics live: `folder:{id}`, `user:{u}:notifications`, `job:{id}`,
`collab:{file_id}`, `collab:{file_id}:awareness`.
- **Folder-live updates**: `FolderService` / `FileManagementService`
publish `file.created` / `file.deleted` / `file.renamed` /
`file.moved` after commit; FE folder view subscribes and mutates
local state — no manual refresh.
- **Job dashboard live**: `JobRegistry` publishes step progress and
terminal state; FE job dashboard subscribes and replaces the
current polling.
- **Notifications table + bell**: new `notifications` table +
`NotificationService` port; initial ingesters for `share-granted`,
`new-login-from-new-device`, `job-completed-for-you`,
`storage-quota-threshold`. FE bell with unread count, slide-out
panel, toast pop on receive.
- **MD collab editor**: see companion plan
`docs/plan/markdown-collab.md` — depends on this phase's WS
handler + binary frame routing.
- **✅ Bus port** + `InProcessMessageBus` + `NoopReplicator` + WS
handler + **ticket endpoint (F)**.
- **✅ Frontend singleton** + `useTopic` + `useFolderTopic` +
`useReconnect` composables. `oxi:message-bus` logger namespace.
- **Topics live today**: `folder:{id}`, `user:{u}:authz`.
- **Topics reserved but not producing**: `user:{u}:notifications`,
`job:{id}`, `collab:{file_id}`, `collab:{file_id}:awareness` —
land with their consumers below.
- **✅ Folder-live updates**: `FolderService` / `FileUploadService` /
`FileManagementService` / `TrashService` publish `file_created /
renamed / moved / deleted` and `folder_created / renamed / moved /
deleted` after commit; FE folder view refetches on receipt (100 ms
coalesce, idempotent). Multi-user + multi-tab verified.
- **✅ Grant-revocation eviction** (Slice C): `AuthzChanged` →
per-topic `rt.revoked`; folder view toasts + navigates to
`/files`. Session survives; unrelated subs unaffected.
- **✅ Refetch-on-reconnect**: `messageBus.onReconnect(cb)` →
`useReconnect` composable → folder view refetches after WS comes
back. Bridges the in-memory-bus "events lost during outage" gap
(see `project_message_bus_reconnect_gap` memory).
- **Job dashboard live** — TODO. `JobRegistry` publishes step
progress and terminal state; FE job dashboard subscribes and
replaces polling.
- **Notifications table + bell** — TODO (Slice E). New
`notifications` table + `NotificationService` port; initial
ingesters for `share-granted`, `new-login-from-new-device`,
`job-completed-for-you`, `storage-quota-threshold`. FE bell with
unread count, slide-out panel, toast pop on receive. Auto-subscribe
to `user:{u}:notifications` server-side, same pattern as
`user:{u}:authz` today.
- **MD collab editor** — TODO. See companion plan
`docs/plan/markdown-collab.md`. Depends on binary-frame routing
which this plan sketches but doesn't ship (`rt_ws.rs` today drops
binary frames with a debug log).
Deliverables sized ~4 weeks end-to-end.
Deliverables sized ~4 weeks end-to-end. Slice D (folder-live) and
Slice F (ticket flow) landed 2026-09-11. Slices E + collab are the
open work in Phase A.
### Phase B — Presence + comments
@@ -104,10 +104,11 @@ const RECONNECT_MAX_MS = 30_000;
* give up and stay `disconnected` until the caller explicitly asks
* to `reconnect()`. Prevents an unrecoverable auth state (revoked
* session, wrong CSRF cookie, missing DPoP nonce) from flooding
* logs. Ten attempts × exponential-backoff-with-jitter is roughly a
* minute of trying — long enough for a transient blip, short enough
* to stop before it's noise. */
const MAX_CONSECUTIVE_FAILURES = 10;
* logs. Twenty attempts × exponential-backoff-with-jitter caps
* around 5 minutes of retrying — comfortably covers a cargo-release
* server restart on a hot machine while still short-circuiting a
* genuine permanent failure before it becomes noise. */
const MAX_CONSECUTIVE_FAILURES = 20;
interface SubEntry {
count: number;
@@ -438,23 +438,27 @@
}
// ── Live folder updates (message bus) ────────────────────────────
// Subscribe to `folder:{currentId}` and refresh when another tab —
// or another user with a share — mutates something in this folder.
// The refresh call is coalesced through `#reloadScheduled` so a
// burst of events (e.g. a multi-file upload) collapses to a single
// fetch. Local mutations trigger `reload()` themselves, so events
// authored by this same user are dropped as echo (the `actor` on
// the event is the caller UUID from the server).
// Subscribe to `folder:{currentId}` and refresh when THIS session's
// tabs, another tab of the same user, or another user with a share
// mutates something in this folder. Refetch is coalesced through
// `reloadScheduled` so a burst of events (multi-file upload) collapses
// to a single fetch.
//
// Actor-echo skip was REMOVED: previously we skipped events whose
// `actor` equalled `session.user.id`, on the assumption "this tab
// already updated its state via the local mutation path". That is
// true for the ACTIVE tab, but it also silenced updates from OTHER
// TABS of the same user. Since `reload()` is idempotent (replaces
// `listing.files` with the same server state) the extra fetch on
// self-authored events costs one round-trip (~30 ms locally, never
// visible) and gains multi-tab correctness. The `reloadScheduled`
// coalescer already prevents redundant work when the local mutation
// path and the bus event race.
//
// See `docs/plan/message-bus.md § D` and the `useFolderTopic`
// composable for the wiring.
let reloadScheduled = false;
function scheduleLiveReload(actor: string): void {
// Actor echo: this same session's mutations already updated the
// listing through their own success path, so a re-fetch would
// only cost a round-trip. Other tabs of the same user still see
// the change (they render from their own state, not this one).
if (session.user?.id && actor === session.user.id) return;
function scheduleLiveReload(_actor: string): void {
if (reloadScheduled) return;
reloadScheduled = true;
// Coalesce a burst; 100 ms is enough for the tail of a multi-
@@ -493,12 +497,9 @@
// through the same `scheduleLiveReload` coalescer as event-
// driven refreshes so a burst of reconnects (rare, but the
// circuit breaker can produce one) collapses to a single
// fetch. Passing an actor of `null`-equivalent — use an
// empty string so the echo-skip's `actor === user.id`
// check never matches. See
// `project_message_bus_reconnect_gap` memory.
// fetch. See `project_message_bus_reconnect_gap` memory.
busLog.warn('reconnected — refetching folder');
scheduleLiveReload('');
scheduleLiveReload('reconnect');
}
});