feat(msg-bus): resubscribe topics on reconnect

This commit is contained in:
Edouard Vanbelle
2026-09-11 03:12:48 +02:00
parent 75a123ae6c
commit 41d25d3a3e
4 changed files with 122 additions and 0 deletions
@@ -6,6 +6,7 @@
// `RtEventKind` — the switch below fails to type-check until every
// arm is handled, keeping the FE exhaustive.
import { useReconnect } from './useReconnect.svelte';
import { useTopic } from './useTopic.svelte';
import type RtEventParams from '$lib/generated/message-bus/RtEventParams';
import type RtRevokedParams from '$lib/generated/message-bus/RtRevokedParams';
@@ -41,6 +42,13 @@ export interface FolderTopicHandlers {
/** Grant revoked or folder deleted — the subscription is gone
* server-side. Reasonable UX: toast + navigate away. */
onRevoked?: (params: RtRevokedParams) => void;
/** WS reconnected after a prior disconnect. Bus events published
* during the outage window are lost (in-memory bus, no replay),
* so the folder view has to refetch to catch up with the server.
* Typical wiring: `onReconnect: () => reload()`. Not called on
* the initial connect — the caller's own load path handles that.
* See `project_message_bus_reconnect_gap` memory. */
onReconnect?: () => void;
}
/**
@@ -61,6 +69,7 @@ export function useFolderTopic(
return id ? `folder:${id}` : null;
};
useTopic(topic, (params) => dispatch(params, handlers), handlers.onRevoked);
useReconnect(handlers.onReconnect);
}
function dispatch(params: RtEventParams, handlers: FolderTopicHandlers): void {
@@ -0,0 +1,27 @@
// Svelte 5 rune wrapper around `messageBus.onReconnect`.
//
// Fires the given callback the first time the WS reconnects after a
// prior disconnect (server restart, network blip, sleep/wake).
// **Not** called on the initial connect — the caller's own load path
// is already fetching then. Bridges the "events published during the
// disconnect window are lost" gap; consumers typically pass
// `reload()` so the view catches up with the server after the outage.
//
// See `client.svelte.ts::onReconnect` for lifecycle details and
// `project_message_bus_reconnect_gap` memory for the gap it closes.
import { messageBus } from '$lib/message-bus/client.svelte';
/**
* Register `cb` as a reconnect handler for the lifetime of the
* calling component. Auto-unregisters on destroy via `$effect`
* cleanup. Passing `null`/`undefined` is a no-op — convenient for
* conditional wiring (`useReconnect(handlers.onReconnect)`).
*/
export function useReconnect(cb: (() => void) | null | undefined): void {
$effect(() => {
if (!cb) return;
const release = messageBus.onReconnect(cb);
return () => release();
});
}
@@ -66,6 +66,15 @@ export type EventHandler = (params: RtEventParams) => void;
* fires this so the consumer can toast / redirect / whatever. */
export type RevokedHandler = (params: RtRevokedParams) => void;
/** Callback invoked when the WS reconnects AFTER a prior disconnect —
* never on the first connect. Fires after client-side sub replay has
* been kicked off (`#sendSubscribe` for every known topic), so the
* handler can safely call `reload()`-style refetches knowing the
* post-reconnect event stream is armed. Bridges the "events published
* during the disconnect window are lost" gap — see
* `project_message_bus_reconnect_gap` memory. */
export type ReconnectHandler = () => void;
/** Handle returned by `subscribe`. Call to release one refcount on the
* topic; the client unsubscribes over the wire only when the last
* refcount drops. Idempotent — calling twice from the same subscriber
@@ -134,6 +143,17 @@ export class MessageBusClient {
* `MAX_CONSECUTIVE_FAILURES` the client stops reconnecting and
* requires an explicit `reconnect()` from the caller. */
#consecutiveFailures = 0;
/** True once we've observed at least one successful `#onOpen`.
* Used to distinguish "initial connect" (don't fire onReconnect
* handlers — the initial load path is doing the fetch already)
* from "reconnect" (do fire — events during the outage window
* were lost, consumers must refetch). */
#hasConnectedBefore = false;
/** Reconnect handlers, invoked from `#onOpen` on the SECOND-onwards
* successful connect. Plain Set — internal registry, not
* reactive. Same rationale as `#subs` / `#pending`. */
// eslint-disable-next-line svelte/prefer-svelte-reactivity
#reconnectHandlers = new Set<ReconnectHandler>();
/** `topic` → `{count, handlers, revokedHandlers, acked}`. Refcount
* drives the wire: first refcount ⇒ send `rt.subscribe`; last drop
@@ -225,6 +245,35 @@ export class MessageBusClient {
});
}
/**
* Register a handler that fires when the WS reconnects AFTER a
* prior disconnect (server restart, network blip, sleep/wake).
* NOT called on the initial connect — that path is already
* handled by the consumer's own load logic. Returns an
* unsubscribe fn.
*
* Wrapped in `untrack` for the same reason `subscribe` is —
* reading `#hasConnectedBefore` etc. inside a caller's `$effect`
* would leak a reactive dep. Callers reach for this via the
* `useReconnect` composable, which manages the lifecycle.
*
* Bridges the "events lost during outage window" gap: consumers
* refetch on reconnect to bring their view back in line with the
* server, since bus publishes during the disconnect never reached
* this session. See `project_message_bus_reconnect_gap` memory.
*/
onReconnect(cb: ReconnectHandler): () => void {
return untrack(() => {
this.#reconnectHandlers.add(cb);
let released = false;
return () => {
if (released) return;
released = true;
this.#reconnectHandlers.delete(cb);
};
});
}
/** Force a fresh reconnect — for a live-updates toggle or a manual
* "reconnect" button. Rare; not part of the normal flow. Also the
* escape hatch after the circuit breaker trips: zeroes the
@@ -320,6 +369,10 @@ export class MessageBusClient {
this.state = 'connected';
this.#backoffMs = RECONNECT_MIN_MS;
this.#consecutiveFailures = 0;
// Snapshot whether this is a reconnect BEFORE we flip the
// `hasConnectedBefore` bit, so handlers only fire on 2nd+ open.
const isReconnect = this.#hasConnectedBefore;
this.#hasConnectedBefore = true;
// Replay every already-known topic. `entry.acked` is reset here
// because the fresh connection has no server-side memory of
// prior subscriptions.
@@ -329,6 +382,24 @@ export class MessageBusClient {
busLog.warn('resubscribe failed', { topic, error: err })
);
}
// Fire reconnect handlers AFTER sub replay is kicked (the
// `rt.subscribe` frames are on the socket; ack may be
// in-flight). Handlers refetching state via REST will see a
// consistent post-reconnect view; any events published between
// resubscribe and the handler's refetch race safely — a stale
// event just means one extra `reload()` on the next tick.
if (isReconnect && this.#reconnectHandlers.size > 0) {
busLog.debug('firing reconnect handlers', {
count: this.#reconnectHandlers.size
});
for (const cb of this.#reconnectHandlers) {
try {
cb();
} catch (err) {
busLog.warn('reconnect handler threw', { error: err });
}
}
}
}
#onMessage(ev: MessageEvent): void {
@@ -484,6 +484,21 @@
);
busLog.warn('folder access revoked', { topic: params.topic, reason: params.reason });
void goto(resolve('/files'));
},
onReconnect: () => {
// WS reconnected after a prior disconnect — any bus events
// published during the outage window were dropped by the
// in-memory bus (no replay). Force a refetch so the listing
// catches up with the server-authoritative state. Goes
// 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.
busLog.warn('reconnected — refetching folder');
scheduleLiveReload('');
}
});