fix: correctness fixes across LiveKit voice, client session and transport paths (#1374)

* fix: enhance bugfix workflow documentation with detailed clustering and staging instructions

* fix(voice): 6 defect(s) (OC-0001, OC-0006, OC-0009, OC-0010, OC-0015, OC-0029)

* fix(voice): 1 defect(s) (OC-0005)

* fix(client): 1 defect(s) (OC-0007)

* fix(client): 1 defect(s) (OC-0011)

* fix(client): 1 defect(s) (OC-0012)

* fix(admin): 1 defect(s) (OC-0013)

* fix(client): 3 defect(s) (OC-0014, OC-0024, OC-0031)

* fix(voice): 1 defect(s) (OC-0018)

* fix(voice): 1 defect(s) (OC-0019)

* fix(client): 1 defect(s) (OC-0021)

* fix(client): 1 defect(s) (OC-0025)

* fix(ws): 1 defect(s) (OC-0026)

* fix(client): 1 defect(s) (OC-0027)

* fix(client): 1 defect(s) (OC-0028)

* fix(identity): 1 defect(s) (OC-0030)

* fix(voice): 1 defect(s) (OC-0016)

* fix(client): 2 defect(s) (OC-0002, OC-0020)

OC-0002: chain offer handling behind the announce chain so an offer that
arrives immediately behind its sender's announce is not dropped as an
unknown peer.

OC-0020: retire a departing peer's ECDH key on participant-left so a
replayed pre-leave announce cannot overwrite the fresh key they rejoined
with.

* fix(voice): 1 defect(s) (OC-0008)

handleVoiceJoin handed the client its LiveKit token before checking whether
the join had been superseded by a concurrent eviction (moderator kick/move,
the CONNECT_VOICE revocation sweep, CleanupVoiceForChannel). Those evictors
delete the voice_states row, clear the client's in-memory state, and call
RemoveParticipant — which no-ops because the join has not reached the SFU
yet. The client was left holding a live 5-minute RoomJoin credential for a
membership the server had just torn down.

Re-check the client's voice state immediately after GenerateToken and
withhold the credential if the join was superseded, with a best-effort
RemoveParticipant to match every other eviction path.

* fix(ws): 2 defect(s) (OC-0017, OC-0022)

OC-0017: sweepStaleVoiceStates re-checks the live client immediately before
deleting a snapshotted-stale voice_states row. voice_join commits the row
before calling c.setVoiceState, so a join that lands inside that window was
snapshotted as a ghost and had its just-committed row deleted, leaving the
client in voice in memory with no DB row.

OC-0022: CleanupVoiceForChannel resolves its voice_leave audience with a
variant of channelReadAudience that skips the archived short-circuit. Both
production callers archive the channel before evicting, so the plain
resolver always returned an empty audience and only the evicted
participants learned the call ended.

* fix(voice): 1 defect(s) (OC-0023)

Camera and screenshare now draw from the same per-channel voice_max_video
budget. handleVoiceScreenshareV2 performed no cap check at all, and the
camera gate's slot-count subquery counted only `camera = 1` rows, so a
screensharing occupant was invisible to it. Both gates now count
`camera = 1 OR screenshare = 1` via a shared enableVideoSlot helper.

* fix(client): 2 defect(s) (OC-0032, OC-0033)

OC-0033: voice_disconnected staleness guard swallowed the kick toast when
the sibling voice_leave had already cleared currentChannelId. Treat a
cleared store as not-stale.

OC-0032: VIDEO_LIMIT rollback assumed the camera, tearing down a working
camera and leaving refused screen tracks published. Correlate by envelope
id and roll back the kind that was actually refused.

* fix(voice): 1 defect(s) (OC-0034)

* fix(client): 1 defect(s) (OC-0035)

A superseded video-enable id makes rollbackPendingVideo return undefined.
The dispatcher's ternary treated undefined as "not screen" and called
disableCamera(), tearing down a working camera the user never touched.
Return early instead: undefined means there is nothing to roll back.

* fix(voice): 1 defect(s) (OC-0036)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-08-15 12:57:51 +02:00
committed by GitHub
co-authored by Claude
parent 079f59d06d
commit b8b7a2a1f9
46 changed files with 3038 additions and 192 deletions
+81 -14
View File
@@ -272,6 +272,51 @@ fn rewrite_request_headers(raw: &[u8], remote_host: &str) -> String {
modified
}
/// Bracket-aware split of a `remote_host` string into (hostname, port).
/// Defaults to port 443 (standard HTTPS) when none is specified.
///
/// A leading `[` consumes up to the matching `]` as the hostname, so a
/// bracketed IPv6 literal parses correctly whether or not it carries an
/// explicit port (`[::1]`, `[::1]:8443`). Without brackets, a single
/// trailing colon is a `host:port` split — but a *bare* (unbracketed) IPv6
/// literal contains more than one colon, and RFC 3986 gives it no way to
/// carry a port without brackets, so that case is returned whole with the
/// default port instead of being mis-split on its last colon.
fn split_host_port(remote_host: &str) -> Result<(&str, &str), String> {
if let Some(rest) = remote_host.strip_prefix('[') {
let (host, tail) = rest
.split_once(']')
.ok_or_else(|| format!("unterminated '[' in remote_host '{remote_host}'"))?;
let port = tail.strip_prefix(':').unwrap_or("443");
Ok((host, port))
} else {
match remote_host.rsplit_once(':') {
Some((host, port)) if !host.contains(':') => Ok((host, port)),
_ => Ok((remote_host, "443")),
}
}
}
/// Derive the TLS `ServerName` (SNI) and the TCP dial target from a
/// `remote_host` string. Mirrors `livekit_proxy::parse_server_name`'s
/// bracket handling.
fn resolve_remote_target(remote_host: &str) -> Result<(ServerName<'static>, String), String> {
let (hostname, port) = split_host_port(remote_host)?;
let server_name = if let Ok(ip) = hostname.parse::<IpAddr>() {
ServerName::IpAddress(ip.into())
} else {
ServerName::try_from(hostname.to_string())
.map_err(|e| format!("invalid server name '{hostname}': {e}"))?
};
let dial_target = if hostname.contains(':') {
format!("[{hostname}]:{port}")
} else {
format!("{hostname}:{port}")
};
Ok((server_name, dial_target))
}
/// Handle one proxied connection:
/// 1. Read the request headers from the loopback side
/// 2. TLS-connect to the remote and run the TOFU check (store/emit/reject)
@@ -321,20 +366,7 @@ async fn handle_connection<R: Runtime>(
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
let (raw_hostname, _port) = remote_host.rsplit_once(':').unwrap_or((remote_host, "443"));
let hostname = raw_hostname.trim_start_matches('[').trim_end_matches(']');
let server_name = if let Ok(ip) = hostname.parse::<IpAddr>() {
ServerName::IpAddress(ip.into())
} else {
ServerName::try_from(hostname.to_string())
.map_err(|e| format!("invalid server name '{hostname}': {e}"))?
};
let dial_target = if remote_host.contains(':') {
remote_host.to_string()
} else {
format!("{remote_host}:443")
};
let (server_name, dial_target) = resolve_remote_target(remote_host)?;
let tcp = timeout(Duration::from_secs(10), TcpStream::connect(&dial_target))
.await
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??;
@@ -496,6 +528,41 @@ mod tests {
assert!(validate_remote_host("[::1]:8443").is_ok());
}
// OC-0021: IPv6 hosts that are not in the exact `[addr]:port` shape must
// still resolve to a valid ServerName and a dialable host:port target.
#[test]
fn resolve_remote_target_handles_bracketed_ipv6_without_port() {
let (server_name, dial_target) = resolve_remote_target("[2001:db8::1]")
.expect("bracketed IPv6 without a port must parse");
assert!(matches!(server_name, ServerName::IpAddress(_)));
assert_eq!(dial_target, "[2001:db8::1]:443");
}
#[test]
fn resolve_remote_target_handles_bare_ipv6_without_port() {
let (server_name, dial_target) =
resolve_remote_target("2001:db8::1").expect("bare IPv6 without a port must parse");
assert!(matches!(server_name, ServerName::IpAddress(_)));
assert_eq!(dial_target, "[2001:db8::1]:443");
}
#[test]
fn resolve_remote_target_still_handles_bracketed_ipv6_with_port() {
let (server_name, dial_target) = resolve_remote_target("[2001:db8::1]:8443")
.expect("bracketed IPv6 with a port must parse");
assert!(matches!(server_name, ServerName::IpAddress(_)));
assert_eq!(dial_target, "[2001:db8::1]:8443");
}
#[test]
fn resolve_remote_target_still_handles_plain_hostname_and_port() {
let (server_name, dial_target) =
resolve_remote_target("example.com:8443").expect("hostname:port must parse");
assert!(matches!(server_name, ServerName::DnsName(_)));
assert_eq!(dial_target, "example.com:8443");
}
#[test]
fn rewrite_replaces_host_and_forces_close() {
let raw = b"GET /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1:5000\r\nAccept: */*\r\n\r\n";
@@ -984,6 +984,15 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
renderedStart = -1;
renderWindow();
// renderWindow's own rapid-rebuild breaker can return before reassigning
// renderedStart (it stays -1, the value forced above) when too many
// rebuilds have fired in the last 2s. When that happens the DOM was never
// rebuilt for this target — report a failed jump rather than computing a
// localIdx against a sentinel and flashing/reporting success for a row
// that never rendered. This lets callers (e.g. MessageJump) fall back to
// fetching the around-window instead of treating this as a landed jump.
if (renderedStart < 0) return false;
// Briefly highlight the target message element
if (contentContainer !== null) {
const localIdx = idx - renderedStart;
+109 -19
View File
@@ -83,6 +83,21 @@ import { addDmToChannelsStore } from "@pages/main-page/SidebarDmHelpers";
const log = createLogger("dispatcher");
/**
* OC-0024: serverClockSkewMs (see wireDispatcher) starts at 0 and is only
* ever sampled from a frame the replay check itself already accepted as
* live — so if the channel is quiet between login and the first reconnect,
* the skew is never sampled, and a lagging/skewed server clock then makes
* every genuinely live message look like a replay for as long as real
* elapsed time takes to exceed the drift (which can be unbounded). A
* replayed burst is delivered as a burst immediately after auth_ok, so cap
* how long a frame can be classified as a replay by wall-clock distance from
* the handshake as well as by timestamp — that bounds a cold (unsampled)
* skew's worst case to this window instead of the whole drift, while still
* covering the burst's actual delivery window with room to spare.
*/
const REPLAY_GATE_WINDOW_MS = 5_000;
/** Lazily import the LiveKit session module. livekit-client (~1.3 MB) is kept
* out of the entry chunk; voice handlers load it on first use. Once a voice
* flow has started the module is cached, so this resolves in a microtask. */
@@ -90,6 +105,33 @@ function livekitSession(): Promise<typeof import("@lib/livekitSession")> {
return import("@lib/livekitSession");
}
/**
* Honor a moderator's mute/deafen locally. Mute is also enforced at the SFU,
* but deafen governs what WE play back, so the client is the only place it
* can take effect (Server/ws/voice_moderation.go: "enforced by the target's
* client honoring server_deafened"). Both apply through one lazy import so
* the two effects cannot land in different ticks.
*
* Called from both the incremental VOICE_STATE path and the full-resync
* READY path (a WS drop that outlives the LiveKit session can mean a
* moderator mute/deafen issued while disconnected is only ever delivered via
* `ready`'s voice_states, never a voice_state the client could have missed).
* The `!voice.localMuted`/`!voice.localDeafened` guards make it safe to call
* from either path — or both, on the same session — without redundantly
* re-invoking the livekit calls once already applied.
*/
function enforceModeratorAudioState(serverMuted: boolean, serverDeafened: boolean): void {
const voice = voiceStore.getState();
const applyDeafen = serverDeafened && !voice.localDeafened;
const applyMute = serverMuted && !voice.localMuted;
if (applyDeafen || applyMute) {
void livekitSession().then(({ setDeafened, setMuted }) => {
if (applyDeafen) setDeafened(true);
if (applyMute) setMuted(true);
});
}
}
/** Map one DM participant from the wire shape to the store's. */
function mapDmUser(u: DmChannelPayload["recipient"]): DmChannel["recipient"] {
return {
@@ -238,13 +280,26 @@ export function wireDispatcher(
// reload always starts idle — exactly the stale case), while any other
// status means livekitSession is driving a session right now.
const currentUserId = authStore.getState().user?.id ?? 0;
const inVoicePerReady =
currentUserId !== 0 && payload.voice_states.some((vs) => vs.user_id === currentUserId);
const selfVoiceState =
currentUserId !== 0
? payload.voice_states.find((vs) => vs.user_id === currentUserId)
: undefined;
const voiceSessionActive = voiceStore.getState().voiceStatus !== "idle";
if (inVoicePerReady && !voiceSessionActive) {
if (selfVoiceState !== undefined && !voiceSessionActive) {
log.warn("Stale voice state detected in ready payload — sending voice_leave");
ws.send({ type: "voice_leave", payload: {} });
leaveVoiceChannel();
} else if (selfVoiceState !== undefined) {
// A LiveKit session survived a WS drop that outlived it (nothing
// tears voice down on a socket drop alone) — OC-0014: this full
// resync is the only place a moderator mute/deafen issued while we
// were disconnected ever reaches us, since the mustFullResync tier
// that produced this `ready` never replays the voice_state that
// would otherwise have carried it.
enforceModeratorAudioState(
selfVoiceState.server_muted === true,
selfVoiceState.server_deafened === true,
);
}
// F3: publish our long-term identity public key so peers can pin+verify
@@ -535,8 +590,11 @@ export function wireDispatcher(
// preceded it, unlike a genuinely new live message — compared in
// server-clock terms (see serverClockSkewMs above) so a lagging or
// skewed server clock cannot make a live message look like a replay.
// The wall-clock window additionally bounds a cold (never-sampled)
// skew's damage — see REPLAY_GATE_WINDOW_MS.
const isReplayFrame =
lastReconnectHandshakeAt !== null &&
Date.now() - lastReconnectHandshakeAt < REPLAY_GATE_WINDOW_MS &&
Date.parse(payload.timestamp) < lastReconnectHandshakeAt - serverClockSkewMs;
if (!isReplayFrame) {
notifyIncomingMessage(payload);
@@ -752,19 +810,7 @@ export function wireDispatcher(
const currentUserId = authStore.getState().user?.id ?? 0;
if (payload.user_id !== currentUserId) return;
joinVoiceChannel(payload.channel_id);
// Honor a moderator's mute/deafen locally. Mute is also enforced at the
// SFU, but deafen governs what WE play back, so the client is the only
// place it can take effect. Both apply through one lazy import so the
// two effects cannot land in different ticks.
const voice = voiceStore.getState();
const applyDeafen = payload.server_deafened === true && !voice.localDeafened;
const applyMute = payload.server_muted === true && !voice.localMuted;
if (applyDeafen || applyMute) {
void livekitSession().then(({ setDeafened, setMuted }) => {
if (applyDeafen) setDeafened(true);
if (applyMute) setMuted(true);
});
}
enforceModeratorAudioState(payload.server_muted === true, payload.server_deafened === true);
}),
);
@@ -789,6 +835,29 @@ export function wireDispatcher(
// cleared the store; this only surfaces the reason.
unsubs.push(
ws.on(S.VOICE_DISCONNECTED, (payload) => {
// OC-0031: this can arrive well after the kick already tore the
// session down at the SFU (queued behind a backed-up outbound send
// buffer) — by the time it's delivered the user may have already
// rejoined this channel or another. Guard on channel match, same as
// the sibling VOICE_LEAVE handler below (shouldTeardownSession) — a
// stale frame for a channel already left must not kill a newer join.
// Read the store before leaveVoiceChannel() below clears
// currentChannelId.
//
// OC-0033: on the ordinary kick path, the server sends voice_leave to
// the leaver (finishVoiceLeave) BEFORE handleVoiceModKickV2 sends this
// voice_disconnected, so the sibling VOICE_LEAVE handler has typically
// already nulled currentChannelId by the time this arrives. That's not
// the OC-0031 staleness (a rejoin into a *different* channel) — treat
// a cleared store as not-stale so the kick reason still gets shown.
const cur = voiceStore.getState().currentChannelId;
const stale = cur !== null && cur !== payload.channel_id;
if (stale) {
log.info("Ignoring stale voice_disconnected for a channel already left", {
channelId: payload.channel_id,
});
return;
}
log.info("Disconnected from voice by a moderator", { channelId: payload.channel_id });
void livekitSession().then(({ leaveVoice }) => leaveVoice(false));
leaveVoiceChannel();
@@ -1016,9 +1085,30 @@ export function wireDispatcher(
if (payload.code === "VIDEO_LIMIT") {
showToast(payload.message || "That voice channel has reached its video limit", "error");
// max_video has no SFU-level enforcement — the server only refuses the
// DB write. Without this rollback the already-published camera track
// keeps streaming to everyone while voice_state says camera=false.
void livekitSession().then(({ disableCamera }) => disableCamera());
// DB write. Without this rollback the already-published track keeps
// streaming to everyone while voice_state says camera/screenshare is
// off. voice_controls.go routes both a refused voice_camera AND a
// refused voice_screenshare enable through the same shared
// enableVideoSlot cap check, so this code is not camera-specific —
// correlate by envelope id, exactly like the generic rollback below,
// instead of assuming it's always the camera. A bare VIDEO_LIMIT with
// no id (older server / no correlation available) still falls back
// to the camera, the only kind this branch used to handle.
if (id !== undefined) {
void import("@lib/screenShare").then(({ rollbackPendingVideo }) => {
const kind = rollbackPendingVideo(id);
// undefined means this id no longer correlates to anything
// pending (superseded by a later enable of the same kind) — the
// refusal is stale and there is nothing to roll back. It must
// never be treated as "it was the camera".
if (kind === undefined) return;
void livekitSession().then(({ disableCamera, disableScreenshare }) =>
kind === "screen" ? disableScreenshare() : disableCamera(),
);
});
} else {
void livekitSession().then(({ disableCamera }) => disableCamera());
}
return;
}
// Every remaining code has no dedicated handler above (not a pending
+31 -4
View File
@@ -826,13 +826,26 @@ export class E2EEManager {
* Handle a voice_e2ee_offer from the server — the key holder has sent us
* the encrypted room key. Unwrap it and apply to the E2EE key provider.
* Offers are applied one at a time, in delivery order.
*
* Chained through _announceChain first (OC-0002): handleAnnounceInner only
* stores the sender's ECDH key after several awaits (identity-pin lookup,
* signature verification, key import), while handleOfferInner's first
* statement is a synchronous _peerPublicKeys lookup. An offer dispatched
* immediately behind that same sender's announce — the OC-0098 send order
* guarantees exactly this WS delivery order — would otherwise reach the
* lookup before the announce applied, and be dropped as "unknown peer"
* with no retry until the next 5-minute rotation. Waiting on the announce
* chain reproduces WS delivery order exactly: the announce is enqueued on
* it before the offer's frame is even dispatched. No deadlock risk:
* handleAnnounceInner never awaits the offer chain and never rejects (it
* catches internally), and clearState() resets both chains together.
*/
handleOffer(fromUserId: number, encryptedKeyBase64: string, ivBase64: string): Promise<void> {
// handleOfferInner never rejects (it catches internally), so the chain
// cannot wedge on a failed offer.
const run = this._offerChain.then(() =>
this.handleOfferInner(fromUserId, encryptedKeyBase64, ivBase64),
);
const run = this._offerChain
.then(() => this._announceChain)
.then(() => this.handleOfferInner(fromUserId, encryptedKeyBase64, ivBase64));
this._offerChain = run;
return run;
}
@@ -1042,9 +1055,23 @@ export class E2EEManager {
* insertion order (which is not guaranteed to match server join order).
*/
async handleParticipantLeft(userId: number): Promise<void> {
const hadPeerKey = this._peerPublicKeys.has(userId);
const departingKey = this._peerPublicKeys.get(userId);
const hadPeerKey = departingKey !== undefined;
this._peerPublicKeys.delete(userId);
clearPeerVerification(userId);
// Retire the departing peer's key (OC-0020): _retiredPeerKeys is the only
// defense against replay of a validly-signed announce (the signed
// message carries no channel/epoch/nonce, F3) and handleAnnounceInner
// only records a retirement on an in-session key CHANGE. Without this, a
// peer that leaves and rejoins with a fresh key is neither live nor
// retired on their pre-leave key — a replay of the recorded old announce
// then passes both guards and overwrites the peer's live key with one
// whose private half no longer exists (blackholing them). A genuine
// rejoin always mints a fresh ECDH pair (setupKeyExchange,
// reannounceForReconnect), so this never rejects a legitimate re-announce.
if (departingKey) {
this.retirePeerKey(userId, await exportPublicKey(departingKey));
}
const channelId = this._channelId ?? this.deps.getCurrentChannelId();
if (!channelId) return;
+113 -23
View File
@@ -139,6 +139,13 @@ export class LiveKitSession {
private tokenRefreshTimer: ReturnType<typeof setTimeout> | null = null;
/** BUG-146: Guard timer — fires if the server never responds to voice_token_refresh. */
private tokenRefreshTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
/** OC-0029: epoch ms of the last voice_token_refresh actually sent. The
* server budgets this request to 1 per 60s per user (Server/ws/voice_join.go);
* requestTokenRefresh() has multiple independent callers (the 4-minute
* timer AND attemptAutoReconnect's post-recovery refresh) that can land
* within seconds of each other, so this shared entry point — not each
* caller — is what enforces the budget. 0 (never sent) never blocks. */
private _lastTokenRefreshSentAt = 0;
/** Max auto-reconnect attempts before giving up and showing error. */
private static readonly MAX_RECONNECT_ATTEMPTS = 2;
private static readonly RECONNECT_DELAY_MS = 3000;
@@ -800,7 +807,19 @@ export class LiveKitSession {
log.debug("Skipping token refresh — no active session");
return;
}
// OC-0029: the server refuses more than 1 voice_token_refresh per 60s
// per user (ErrCodeRateLimited). requestTokenRefresh() is called both by
// the routine 4-minute timer and by attemptAutoReconnect's unconditional
// post-recovery refresh, which can land only seconds after the timer's
// own refresh — without this guard the second request is rejected and
// surfaces as a bare "token refresh rate limited" error toast right as
// the user's call recovers.
if (Date.now() - this._lastTokenRefreshSentAt < 60_000) {
log.debug("Skipping token refresh — one was already sent within the last 60s");
return;
}
log.info("Requesting voice token refresh");
this._lastTokenRefreshSentAt = Date.now();
this.ws.send({ type: "voice_token_refresh", payload: {} });
// NOTE: startTokenRefreshTimer is called from handleVoiceTokenRefresh
// (the server response handler), not here, to avoid scheduling two
@@ -978,18 +997,31 @@ export class LiveKitSession {
this.onRemoteVideoRemovedCallback = null;
}
/** Post-connect-checkpoint cleanup for a superseded connectAndSetup attempt
* (checkpoints 3-5, after this attempt already installed its room into the
* shared "connected" state). By the time one of these fires, a NEWER
* attempt may have already claimed `_state` (and torn down THIS attempt's
* room via its own entry-point leaveVoice(false)) — so this must disconnect
* only the passed-in localRoom, mirroring checkpoint 2, and must never call
/** Checkpoint cleanup for a superseded connectAndSetup attempt, used at
* every "return \"superseded\"" site in that function. By the time one of
* these fires, a NEWER attempt may have already claimed `_state` (and torn
* down THIS attempt's room via its own entry-point leaveVoice(false)) — so
* this must disconnect only the passed-in localRoom and must never call
* the global leaveVoice()/touch `_state`, or it tears down whichever
* session currently occupies `_state`, which now belongs to the newer
* attempt. */
* attempt.
*
* OC-0006: also re-syncs the extracted modules (DeviceManager/AudioPipeline
* /AudioElements) when nobody newer owns `_state`. Earlier checkpoints
* (1/2, the key-exchange failure, and the retry-backoff check) fire before
* this attempt ever reaches "connected" — if the supersession was a plain
* leaveVoice() (state now "idle") that landed while this attempt's own
* lines above had already wired the modules to `localRoom`, that leave's
* own syncModuleRooms() ran too early and got undone by the later wiring,
* leaving DeviceManager's devicechange listener armed on a Room that will
* never connect. The condition is required — an unconditional sync would
* null the modules out from under a newer attempt that already ran its own
* wiring but has not yet reached "connected" (it never re-wires after that
* point). */
private disconnectSupersededLocalRoom(localRoom: Room): void {
localRoom.removeAllListeners();
localRoom.disconnect().catch((err) => log.debug("Failed to disconnect superseded room", err));
if (this._state.type === "idle") this.syncModuleRooms();
}
/** Shared connect-with-retry + post-connect setup used by both the primary
@@ -1011,7 +1043,19 @@ export class LiveKitSession {
// below inherits the PREVIOUS channel's residual _isKeyHolder via its
// OR-with-server-value guard, joining the new channel as a phantom key
// holder the server never elected (OC-0020).
if (this._room !== null || this._state.type === "reconnecting") this.leaveVoice(false);
if (this._room !== null || this._state.type === "reconnecting") {
this.leaveVoice(false);
} else if (this._state.type === "connecting") {
// OC-0001: the pending-join drain loop (handleVoiceToken) re-enters
// this function while `_state` is still "connecting" — there is no
// room to disconnect and no reconnect AC to abort, so the branch above
// never fires, but a discarded prior attempt (e.g. the e2ee_timeout /
// checkpoint-2 queued-join paths below) can still leave residual E2EE
// state (_isKeyHolder, keypair, peer keys) behind for THIS attempt to
// inherit via setupKeyExchange's OR-with-server-value guard. Clear it
// explicitly since leaveVoice() itself never runs on this path.
this._e2ee.clearState();
}
// Draw the next generation from the monotonic instance counter (never
// re-derived from `_state`) and embed it into the "connecting" state.
// Any newer call to connectAndSetup() will produce a strictly larger
@@ -1044,6 +1088,7 @@ export class LiveKitSession {
myGeneration,
currentGeneration: this._state.type === "connecting" ? this._state.joinGeneration : "n/a",
});
this.disconnectSupersededLocalRoom(localRoom);
return "superseded";
}
@@ -1070,17 +1115,35 @@ export class LiveKitSession {
channelId,
myGeneration,
});
this.disconnectSupersededLocalRoom(localRoom);
return "superseded";
}
this.onErrorCallback?.("e2ee_timeout");
// The exchange timed out BEFORE room.connect(): no SFU participant
// exists, so no LiveKit webhook will ever clean up, and the server
// registered the join when it sent voice_token. Send voice_leave and
// leave the store's voice channel (like the reconnect-exhausted give-up
// path) or the stale row ghosts forever and can wedge the channel's
// key-holder election.
this.leaveVoice(true);
leaveVoiceChannel();
// OC-0010: a channel switch queued during the wait (handleVoiceToken's
// pendingJoin branch) preserves this attempt's type/joinGeneration, so
// the ownership check above cannot distinguish it from "no newer join
// is coming". Treating it as a genuine failure here would send
// voice_leave with no channel id — deleting the QUEUED join's
// voice_states row, not this timed-out attempt's — and would drop the
// pendingJoin itself by transitioning to idle before the drain loop
// ever reads it. Only run the give-up cleanup when nothing is queued.
if (this._state.pendingJoin === null) {
this.onErrorCallback?.("e2ee_timeout");
// The exchange timed out BEFORE room.connect(): no SFU participant
// exists, so no LiveKit webhook will ever clean up, and the server
// registered the join when it sent voice_token. Send voice_leave and
// leave the store's voice channel (like the reconnect-exhausted give-up
// path) or the stale row ghosts forever and can wedge the channel's
// key-holder election.
this.leaveVoice(true);
leaveVoiceChannel();
} else {
// Leave state as "connecting" with pendingJoin intact so the finally
// block and handleVoiceToken's drain loop can run the queued join.
// Clear this attempt's own E2EE residue (keypair/_isKeyHolder/etc.)
// so the queued join does not inherit it — entry-point leaveVoice(false)
// never runs for that next call since `_room` is null here (OC-0001).
this._e2ee.clearState();
}
return false;
}
@@ -1097,10 +1160,7 @@ export class LiveKitSession {
currentGeneration:
this._state.type === "connecting" ? this._state.joinGeneration : "n/a",
});
localRoom.removeAllListeners();
localRoom
.disconnect()
.catch((err) => log.debug("Failed to disconnect superseded room", err));
this.disconnectSupersededLocalRoom(localRoom);
return "superseded";
}
@@ -1147,6 +1207,7 @@ export class LiveKitSession {
channelId,
attempt,
});
if (localRoom !== null) this.disconnectSupersededLocalRoom(localRoom);
return "superseded";
}
if (localRoom === null) throw connectErr;
@@ -1301,7 +1362,16 @@ export class LiveKitSession {
isKeyHolder?: boolean,
): Promise<void> {
const s = this._state;
if (s.type === "connected" && s.channelId === channelId && s.room.state === "connected") {
// OC-0015: livekit-client's own internal reconnect (network blip on the
// SFU signal socket) moves Room.state through "signalReconnecting" /
// "reconnecting" without ever emitting RoomEvent.Disconnected — the only
// event this session listens for — so `_state` stays "connected" the
// whole time. A routine 4-minute refresh token landing in that window
// must still take the lightweight refresh path instead of falling
// through to a full teardown+rejoin of a session that is about to
// recover on its own; only a room the SDK has fully given up on
// ("disconnected") should be treated as needing a real reconnect here.
if (s.type === "connected" && s.channelId === channelId && s.room.state !== "disconnected") {
this.handleVoiceTokenRefresh(token);
return;
}
@@ -1317,6 +1387,22 @@ export class LiveKitSession {
log.warn("handleVoiceToken: already connecting, queued latest join request", { channelId });
return;
}
// OC-0009: a voice_token can arrive after the user already left this
// channel (e.g. Disconnect fired before the voice_join/voice_token round
// trip returned) — `_state` alone cannot tell, since a leave that landed
// before any connectAndSetup() ever started leaves `_state` at "idle"
// either way. voiceStore.currentChannelId is the one place the leave is
// recorded independent of this session's own lifecycle: joinVoiceChannel()
// always sets it before the request that produced this token was sent,
// and leaveVoiceChannel() nulls it, so a mismatch here means the token is
// stale. Connecting anyway would silently rejoin the SFU and republish
// the mic for a call the UI, store, and server all consider ended.
if (voiceStore.getState().currentChannelId !== channelId) {
log.info("handleVoiceToken: voice_token for a channel we already left — ignoring", {
channelId,
});
return;
}
await this.connectAndSetup(token, url, channelId, directUrl, isKeyHolder);
// Drain pending joins iteratively to avoid unbounded recursion when
// rapid channel switches queue multiple requests.
@@ -1338,7 +1424,7 @@ export class LiveKitSession {
if (
cur.type === "connected" &&
cur.channelId === pChannelId &&
cur.room.state === "connected"
cur.room.state !== "disconnected"
) {
this.handleVoiceTokenRefresh(pToken);
} else {
@@ -1437,6 +1523,10 @@ export class LiveKitSession {
}
this._pendingReconnectFields = null;
this.clearTokenRefreshTimer();
// OC-0029: a fresh join must never inherit the outgoing session's refresh
// budget — otherwise a rejoin shortly after a leave could get silently
// throttled for up to 60s with no refresh sent at all.
this._lastTokenRefreshSentAt = 0;
this._audioPipeline.teardownAudioPipeline();
this._eventHandlers.removeAutoplayUnlock();
// OC-0042: bump first, mirroring doDisableCamera/doDisableScreenshare —
@@ -271,6 +271,15 @@ export async function enableCamera(state: CameraTrackState, deps: VideoTrackDeps
deps.reapplyAudioPipeline();
log.info("Camera enabled", { quality, maxBitrate: CAMERA_PUBLISH_BITRATES[quality] });
} catch (err) {
if ((state.generation ?? 0) !== generation) {
// A disableCamera (and possibly a newer enableCamera) already ran to
// completion while this attempt's device acquisition/publish was in
// flight. This attempt's own track, if any, was already released by
// that disable — touching shared state now would stop/clear a live
// track that belongs to a newer, successful enable.
log.warn("Superseded camera enable failed — leaving newer attempt's state alone", err);
return;
}
// BUG-100: Stop the created track to release the camera if publish failed.
if (state.manualCameraTrack !== null) {
state.manualCameraTrack.stop();
@@ -410,6 +419,15 @@ export async function enableScreenshare(
deps.reapplyAudioPipeline();
log.info("Screenshare enabled", { quality, fps: effectiveFps, maxBitrate });
} catch (err) {
if ((state.generation ?? 0) !== generation) {
// A disableScreenshare (and possibly a newer enableScreenshare) already
// ran to completion while this attempt's capture/publish was in
// flight. This attempt's own tracks, if any, were already released by
// that disable — calling stopManualScreenTracks now would unpublish
// and stop tracks that belong to a newer, successful enable.
log.warn("Superseded screenshare enable failed — leaving newer attempt's state alone", err);
return;
}
// BUG-100 (+ partial-publish-failure hardening): release every created
// track, not just stop() it — a track already published before a later
// one in the batch fails (all quality presets request audio alongside
+9
View File
@@ -542,6 +542,15 @@ export function createWsClient() {
try {
await tauriInvoke("ws_connect", { url: wsUrl });
} catch (err) {
if (gen !== wsGeneration) {
// A disconnect() (or a newer connect()) landed while we were
// suspended on the Tauri IPC round trip — this rejection belongs to
// a superseded attempt (the Rust proxy deliberately rejects a
// handshake it displaced with "superseded by a newer connection").
// The newer attempt may already be connected; do not act on it.
log.debug("ws_connect rejection from superseded attempt, ignoring", err);
return;
}
log.error("ws_connect failed", err);
proxyOpen = false;
+12 -2
View File
@@ -655,6 +655,17 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
runHealthChecks(connectPage, getProfileList());
}
// Consume any pending skip-auto-login flag on THIS mount regardless of
// which branch below returns early. It is the single source of truth
// for "an explicit logout just happened, don't auto-login" (set by the
// isAuthenticated subscriber further down), and every connect-page
// mount — quick-switch included — must clear it here or it survives in
// sessionStorage and goes on to suppress an unrelated, later
// clearAuth("server_shutdown") auto-login that deliberately does NOT
// re-set it (OC-0028).
const skipAutoLogin = sessionStorage.getItem("owncord:skip-auto-login") !== null;
sessionStorage.removeItem("owncord:skip-auto-login");
// Quick-switch: if the user switched servers via the overlay, auto-select
// the target server profile so they can reconnect with one click.
const quickSwitchTarget = sessionStorage.getItem("owncord:quick-switch-target");
@@ -677,8 +688,7 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
// Suppressing the attempt removes the race instead of relying on the
// delete being dispatched early enough to win it — and an auto-login
// immediately after an explicit logout is wrong regardless of timing.
if (sessionStorage.getItem("owncord:skip-auto-login") !== null) {
sessionStorage.removeItem("owncord:skip-auto-login");
if (skipAutoLogin) {
return;
}
+1 -1
View File
@@ -619,7 +619,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
const ringing = ringCtrl?.current();
if (ringing === null || ringing === undefined) return;
if (payload.user_id === ringing.fromUserId) {
ringCtrl?.cancel(ringing.channelId);
ringCtrl?.cancel(payload.channel_id);
}
}),
);
@@ -672,6 +672,12 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
try {
await onTotpSubmit(code);
// Verify succeeded — the challenge is resolved, so drop the latch.
// Otherwise any later, unrelated error (e.g. the post-auth WS connect
// failing) would hit the `formState === "error" && totpPending` branch
// in updateTotpOverlay() and re-open this now-dead overlay, whose
// partial token has already been consumed by main.ts.
totpPending = false;
} catch (err) {
const message = err instanceof Error ? err.message : "Verification failed.";
transitionTo("error", message);
@@ -53,6 +53,14 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid
let localTileAdded = false;
let localScreenshareTileAdded = false;
let focusedTileId: number | null = null;
/** The channel currentChannelId was on the previous checkVideoMode() call.
* A voice channel switch (A -> B) moves currentChannelId directly from A
* to B without ever passing through null (joinVoiceChannel is
* optimistic), so clearStreams() must key off any change of channel id,
* not just the transition to null — otherwise remote tiles from the old
* channel persist as dead MediaStreams and keep hasStreams() true
* forever (OC-0012). */
let lastChannelId: number | null = null;
/** Set when the user explicitly dismisses the grid while local video is
* still on (switching to a text channel). Without this, checkVideoMode()
* re-opens the grid the moment any remote peer's camera/screenshare
@@ -104,17 +112,23 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid
function checkVideoMode(): void {
const voice = voiceStore.getState();
const channelId = voice.currentChannelId;
// Clear stale remote tiles on ANY change of channel — a real leave
// (channelId -> null) and a direct A -> B switch both need it, since
// VoiceCallbacks.onVoiceJoin moves currentChannelId straight from the
// old channel to the new one without ever passing through null.
// currentChannelId stays unchanged across auto-reconnect, so this does
// not fire on reconnect (B1-8, OC-0012).
if (channelId !== lastChannelId) {
if (lastChannelId !== null) {
videoGrid.clearStreams();
}
lastChannelId = channelId;
}
if (channelId === null) {
// Not a dismissal: leaving voice can clear currentChannelId before
// localCamera/localScreenshare go false, and this early return skips
// the reset below — showChat() here would strand userDismissedVideo
// set and suppress auto-open for the next session.
//
// This is a real leave (not a reconnect — currentChannelId stays set
// during auto-reconnect), so clear any remote tiles left over from the
// ended session too (B1-8) — otherwise they persist as dead
// MediaStreams and keep hasStreams() true for the next join.
videoGrid.clearStreams();
closeVideoGrid();
return;
}
@@ -222,6 +236,7 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid
localTileAdded = false;
localScreenshareTileAdded = false;
userDismissedVideo = false;
lastChannelId = null;
}
return {
@@ -281,6 +281,49 @@ describe("WS Dispatcher", () => {
expect(voiceStore.getState().voiceUsers.size).toBe(1);
});
// OC-0014: a moderator deafen/mute has no SFU equivalent — the client is
// the only place it takes effect (via setDeafened/setMuted, which gate
// remote-audio subscription). The VOICE_STATE handler enforces this, but a
// full-ready resync (a WS drop that outlives the LiveKit session, followed
// by a mustFullResync reconnect) only ever delivers the mute/deafen via
// `ready`'s voice_states, never a voice_state the client can miss. Without
// enforcement on this path too, the widget shows the user as deafened while
// every remote publication stays subscribed and audible.
it("enforces a moderator server-deafen/mute from the ready (full-resync) payload", async () => {
authStore.setState((prev) => ({
...prev,
user: { id: 5, username: "me", avatar: null, role: "member" },
}));
// The LiveKit session survived the WS drop (nothing tears voice down on a
// socket drop alone) — voiceStatus is NOT idle, so the "stale voice
// state" defense-in-depth branch below must not fire and swallow this.
voiceStore.setState((prev) => ({
...prev,
currentChannelId: 3,
voiceStatus: "connected",
}));
mock.dispatch("ready", {
channels: [],
members: [{ id: 5, username: "me", avatar: null, role: "member", status: "online" }],
voice_states: [
{
channel_id: 3,
user_id: 5,
muted: true,
deafened: true,
server_muted: true,
server_deafened: true,
},
],
roles: [],
});
await vi.runAllTimersAsync();
expect(vi.mocked(mockSetDeafened)).toHaveBeenCalledWith(true);
expect(vi.mocked(mockSetMuted)).toHaveBeenCalledWith(true);
});
it("wires chat_message to messages store", () => {
mock.dispatch("chat_message", {
id: 100,
@@ -480,6 +523,56 @@ describe("WS Dispatcher", () => {
expect.objectContaining({ content: "live after reconnect, server clock still lagging" }),
);
});
// OC-0024: serverClockSkewMs starts at 0 and is only ever sampled inside
// the "accepted as live" branch it itself gates — so if the channel is
// quiet between login and the first reconnect, nothing ever seeds it. A
// few seconds later a genuinely live message shows up with a lagging
// server timestamp and gets misclassified as a replay forever (the
// classification that misfires is the same one that would have fixed
// it). This must not depend on the reconnect having happened recently —
// a real fix bounds exposure by wall-clock distance from the handshake,
// not by ever successfully sampling the skew on this connection.
it("does not permanently blackhole live messages after a reconnect when the skew was never sampled (cold start)", () => {
const driftMs = 30 * 60 * 1000; // server clock reads 30 minutes behind
const t0 = Date.now();
// First connect — the channel is quiet, so no chat_message arrives and
// serverClockSkewMs is never sampled.
mock.dispatch("auth_ok", {
user: { id: 1, username: "alex", avatar: null, role: "admin" },
server_name: "TestServer",
motd: "",
});
// Wi-Fi blips a few seconds later; ws.ts reconnects.
vi.setSystemTime(t0 + 5000);
const handshakeAt = Date.now();
mock.dispatch("auth_ok", {
user: { id: 1, username: "alex", avatar: null, role: "admin" },
server_name: "TestServer",
motd: "",
});
// A genuinely live message arrives well after the reconnect burst
// would have finished — its server timestamp is 30 minutes behind
// because the self-hosted server has no NTP.
vi.setSystemTime(handshakeAt + 10_000);
mock.dispatch("chat_message", {
id: 1,
channel_id: 1,
user: { id: 2, username: "bob", avatar: null },
content: "live well after reconnect, skew was never sampled",
reply_to: null,
attachments: [],
timestamp: new Date(Date.now() - driftMs).toISOString(),
});
expect(mockNotifyIncomingMessage).toHaveBeenCalledTimes(1);
expect(mockNotifyIncomingMessage).toHaveBeenCalledWith(
expect.objectContaining({ content: "live well after reconnect, skew was never sampled" }),
);
});
});
describe("mention counts", () => {
@@ -2175,6 +2268,53 @@ describe("WS Dispatcher", () => {
expect(voiceStore.getState().currentChannelId).toBeNull();
});
// OC-0031: voice_disconnected can arrive from behind a backed-up outbound
// queue well after the kick already tore the LiveKit session down at the
// SFU — by the time it's finally delivered, the user may have already
// rejoined (this channel or another). Its sibling VOICE_LEAVE handler
// guards on channel match for exactly this staleness; voice_disconnected
// must too, or the stale frame kills the freshly established session.
it("does not tear down a fresher voice session for a stale queued voice_disconnected", async () => {
vi.mocked(mockLeaveVoice).mockClear();
mockShowToast.mockClear();
authStore.setState((prev) => ({
...prev,
user: { id: 5, username: "me", avatar: null, role: "member" },
}));
// A newer session is live in channel 9; the queued voice_disconnected is
// for the old channel 3 the kick already evicted us from.
voiceStore.setState((prev) => ({ ...prev, currentChannelId: 9 }));
mock.dispatch("voice_disconnected", { channel_id: 3, reason: "kicked" });
await vi.runAllTimersAsync();
expect(mockLeaveVoice).not.toHaveBeenCalled();
expect(voiceStore.getState().currentChannelId).toBe(9);
expect(mockShowToast).not.toHaveBeenCalled();
});
// OC-0033: on the ordinary kick path, the server's finishVoiceLeave
// broadcasts voice_leave to the leaver BEFORE handleVoiceModKickV2 ever
// sends voice_disconnected, so the sibling VOICE_LEAVE handler above has
// already nulled currentChannelId by the time this event lands. A cleared
// store is not the same staleness OC-0031 guards against (a rejoin into a
// *different* channel) and must not swallow the kick toast — it's the only
// explanation the user gets for being dropped from the call.
it("still surfaces the kick toast after the sibling voice_leave already cleared the store", async () => {
mockShowToast.mockClear();
authStore.setState((prev) => ({
...prev,
user: { id: 5, username: "me", avatar: null, role: "member" },
}));
// voice_leave for this same kick already cleared the store.
voiceStore.setState((prev) => ({ ...prev, currentChannelId: null }));
mock.dispatch("voice_disconnected", { channel_id: 3, reason: "kicked by a moderator" });
await vi.runAllTimersAsync();
expect(mockShowToast).toHaveBeenCalledWith("kicked by a moderator", "error");
});
it("wires voice_config to voice store", () => {
mock.dispatch("voice_config", {
channel_id: 3,
@@ -3502,6 +3642,40 @@ describe("WS Dispatcher", () => {
expect(mockShowToast).toHaveBeenCalledWith("nope", "error");
expect(uiStore.getState().transientError).toBeNull();
});
// OC-0032: voice_controls.go now routes a refused voice_screenshare
// enable through the same enableVideoSlot cap check as the camera, so
// VIDEO_LIMIT can correlate to a "screen" pending enable, not just
// "camera". Rolling back the camera unconditionally would tear down a
// working camera and leave the refused screen tracks published.
it("rolls back the screenshare publish, not the camera, when VIDEO_LIMIT refuses a screenshare enable", async () => {
vi.mocked(mockRollbackPendingVideo).mockReturnValue("screen");
mock.dispatch("error", { code: "VIDEO_LIMIT", message: "" }, "vid-screen-1");
await vi.runAllTimersAsync();
expect(mockRollbackPendingVideo).toHaveBeenCalledWith("vid-screen-1");
expect(mockDisableScreenshare).toHaveBeenCalled();
expect(mockDisableCamera).not.toHaveBeenCalled();
});
// OC-0035: a screenshare enable can be superseded (disable, re-enable)
// before its VIDEO_LIMIT refusal arrives — registerPendingVideoEnable
// deletes the prior entry for that kind, so rollbackPendingVideo(id)
// returns undefined for the now-stale id. undefined means "roll back
// nothing" (the refusal no longer correlates to anything pending), never
// "it was the camera" — falling through to disableCamera() would tear
// down a working camera the user never touched.
it("does nothing when VIDEO_LIMIT correlates to a superseded id (kind undefined)", async () => {
vi.mocked(mockRollbackPendingVideo).mockReturnValue(undefined);
mock.dispatch("error", { code: "VIDEO_LIMIT", message: "" }, "vid-superseded-1");
await vi.runAllTimersAsync();
expect(mockRollbackPendingVideo).toHaveBeenCalledWith("vid-superseded-1");
expect(mockDisableCamera).not.toHaveBeenCalled();
expect(mockDisableScreenshare).not.toHaveBeenCalled();
});
});
});
@@ -1125,4 +1125,90 @@ describe("E2EEManager", () => {
vi.useRealTimers();
}
});
// ── Ledger findings OC-0002 / OC-0020 ─────────────────────────────────
it("[OC-0002] applies an offer that arrives while its sender's own announce is still verifying, instead of dropping it as an unknown peer", async () => {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
// We were previously key holder (mirrors B in the repro): own keypair +
// room key already established.
await mgr.setupKeyExchange(true, 1);
mockSetKey.mockClear();
vi.mocked(unwrapRoomKey).mockClear();
// Stall the identity-pin lookup inside verifyPeerAnnounce so the
// announce is still mid-flight (queued on _announceChain, not yet
// applied to _peerPublicKeys) when the offer from the SAME sender
// arrives right behind it — exactly the WS delivery order OC-0098
// guarantees the sender used.
let releasePin!: (v: { status: "unpinned" }) => void;
const stalledPin = new Promise<{ status: "unpinned" }>((resolve) => {
releasePin = resolve;
});
vi.mocked(getIdentityPin).mockReturnValueOnce(stalledPin);
const announcePromise = mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
await vi.waitFor(() => expect(getIdentityPin).toHaveBeenCalled());
// The offer is dispatched immediately behind the announce, before the
// announce has stored the peer's ECDH key.
const offerPromise = mgr.handleOffer(PEER_ID, "enc", "iv");
releasePin({ status: "unpinned" });
await Promise.all([announcePromise, offerPromise]);
// The offer must have been applied once the announce (which arrived
// first) finished verifying — not silently dropped as "unknown peer"
// with no retry until the next 5-minute rotation.
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true);
expect(unwrapRoomKey).toHaveBeenCalled();
expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64");
});
it("[OC-0020] retires a departed peer's key on leave, so a replay of it after they rejoin with a fresh key cannot resurrect it", async () => {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
await mgr.setupKeyExchange(true, 1); // establishes our keypair, holder
// Make import/export round-trip faithfully on the announced base64
// string (the shared mock default returns a fixed constant regardless
// of input, which would mask this bug).
vi.mocked(importPublicKey).mockImplementation(
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
);
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
(key as unknown as { type: string }).type.replace("peer-key-", ""),
);
const KEY_A = "b2xk";
const KEY_B = "bmV3";
try {
// Peer announces key A — accepted as their first (live) key.
await mgr.handleAnnounce(PEER_ID, KEY_A, "sigA");
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_A}` });
// Peer leaves the channel.
await mgr.handleParticipantLeft(PEER_ID);
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false);
// Peer rejoins and announces a fresh key B.
await mgr.handleAnnounce(PEER_ID, KEY_B, "sigB");
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_B}` });
// A malicious relay re-emits the pre-leave, still validly-signed
// announce for key A. No channel/epoch/nonce binds the signed
// message, so it verifies cleanly — it must still be rejected as a
// replay of a retired key, not overwrite the live key B.
await mgr.handleAnnounce(PEER_ID, KEY_A, "sigA");
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_B}` });
} finally {
vi.mocked(importPublicKey).mockImplementation(
async () => ({ type: "public" }) as unknown as CryptoKey,
);
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
}
});
});
@@ -10,6 +10,13 @@ const mockVoiceState = vi.hoisted(() => ({
localCamera: false,
localScreenshare: false,
pttGated: false,
// OC-0009: the real store's currentChannelId is set by joinVoiceChannel()
// before the voice_join/voice_token round trip that produces the token a
// test then hands to handleVoiceToken — default it to the channel id used
// by the overwhelming majority of existing calls (1) so those tests don't
// each need to restate it; tests that exercise a different channel (or the
// "already left" guard itself) set this explicitly.
currentChannelId: 1 as number | null,
}));
/** Backing cell for the mocked voice.store PTT-poller-live flag. Boxed so the
@@ -318,6 +325,7 @@ describe("LiveKitSession", () => {
mockVoiceState.localCamera = false;
mockVoiceState.localScreenshare = false;
mockVoiceState.pttGated = false;
mockVoiceState.currentChannelId = 1;
session = new LiveKitSession();
// Reset mockRoom state
mockRoom.state = "connected";
@@ -1013,7 +1021,9 @@ describe("LiveKitSession", () => {
// The user switches to channel 2, which already has a lower-uid
// participant — the server elects someone else and sends
// is_key_holder=false.
// is_key_holder=false. joinVoiceChannel(2) always runs before this
// token's round trip in the real app (OC-0009's guard reads it back).
mockVoiceState.currentChannelId = 2;
const joinPromise = session.handleVoiceToken(
"token-2",
"/livekit",
@@ -1574,6 +1584,7 @@ describe("LiveKitSession", () => {
it("sets currentChannelId to null after leave", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
mockVoiceState.currentChannelId = 5;
await session.handleVoiceToken("tok", "/lk", 5, "ws://localhost:7880", true);
expect((session as any)._state.channelId).toBe(5);
@@ -1947,6 +1958,7 @@ describe("LiveKitSession", () => {
session.setOnError(errorCb);
mockVoiceState.localMuted = false;
mockVoiceState.localDeafened = false;
mockVoiceState.currentChannelId = 7;
await session.handleVoiceToken("tok", "/lk", 7, "ws://localhost:7880", true);
vi.clearAllMocks();
@@ -3499,4 +3511,274 @@ describe("LiveKitSession", () => {
expect(offerSends(ws)).toHaveLength(0);
});
});
// -----------------------------------------------------------------------
// bughunt-fix wave 2: OC-0001, OC-0006, OC-0009, OC-0010, OC-0015, OC-0029
// -----------------------------------------------------------------------
describe("[OC-0001] pending-join drain re-enters connectAndSetup without E2EE teardown", () => {
it("does not carry a stale key-holder promotion into a join drained while still 'connecting'", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
// Channel A: connect() stalls so we can queue a join for channel B
// before A's attempt reaches its own supersession checkpoint.
const connectA = createDeferred<void>();
mockRoom.connect
.mockImplementationOnce(() => connectA.promise)
.mockResolvedValueOnce(undefined);
const joinPromise = session.handleVoiceToken(
"token-a",
"/livekit",
1,
"ws://localhost:7880",
true, // key holder for channel A
);
// Let E2EE key-exchange (key-holder path — no wait) and room.connect()
// start; A's attempt is now stalled inside room.connect().
await vi.advanceTimersByTimeAsync(0);
expect((session as any)._e2ee["_isKeyHolder"]).toBe(true);
// The user switches to channel B before A's connect() resolves. The
// server elects a different key holder for B.
mockVoiceState.currentChannelId = 2;
await session.handleVoiceToken("token-b", "/livekit", 2, "ws://localhost:7880", false);
expect((session as any)._state.type).toBe("connecting");
expect((session as any)._state.pendingJoin?.channelId).toBe(2);
// Observe _isKeyHolder at the exact moment channel B's own key exchange
// begins — before any later leaveVoice()/timeout path could mask a
// residual value left over from A by resetting it via a different route.
let isKeyHolderAtBStart: boolean | undefined;
const keyExchangeSpy = vi
.spyOn((session as any)._e2ee, "setupKeyExchange")
.mockImplementation(async () => {
isKeyHolderAtBStart = (session as any)._e2ee["_isKeyHolder"];
return true; // fast-path: pretend the room key is already available
});
// A's connect() now resolves — connectAndSetup(A) discards its own
// room in favor of the queued B join (the queuedJoin branch) and
// returns false, leaving state "connecting" with pendingJoin=B intact
// so handleVoiceToken's drain loop runs it next.
connectA.resolve(undefined);
await joinPromise;
// The residual key-holder promotion from channel A must not have
// leaked into channel B's own (correctly non-holder) election.
expect(isKeyHolderAtBStart).toBe(false);
keyExchangeSpy.mockRestore();
});
});
describe("[OC-0006] connectAndSetup supersession checkpoints re-sync module room wiring", () => {
it("unwires DeviceManager/AudioPipeline/AudioElements when checkpoint 1 fires after a concurrent leaveVoice that landed during room creation", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
// Stall createRoom()'s own `await newRoom.setE2EEEnabled(true)` — this
// is BEFORE connectAndSetup's module-wiring lines (which run right
// after createRoom() resolves) have executed at all.
const e2eeDeferred = createDeferred<void>();
mockRoom.setE2EEEnabled.mockImplementationOnce(() => e2eeDeferred.promise);
const resultPromise = (session as any).connectAndSetup(
"token-1",
"/livekit",
1,
"ws://localhost:7880",
true,
);
await vi.advanceTimersByTimeAsync(0);
// A concurrent Disconnect click runs leaveVoice() here. `_room` reads
// null (state is "connecting", no room installed yet), so there is
// nothing to unwire — this only proves the leave itself ran cleanly.
session.leaveVoice(false);
expect((session as any)._deviceManager.room).toBeNull();
// createRoom() now resolves. connectAndSetup's module-wiring lines run
// UNCONDITIONALLY right after, re-wiring the modules to a room that
// state ("idle", from the leave above) says nobody wants any more.
// resolveLiveKitUrl resolves synchronously for a local host, so
// checkpoint 1 fires immediately after — it must detect the leave and
// leave the modules unwired rather than stranding them on a room that
// will never connect.
e2eeDeferred.resolve(undefined);
const result = await resultPromise;
expect(result).toBe("superseded");
expect((session as any)._deviceManager.room).toBeNull();
});
});
describe("[OC-0009] handleVoiceToken ignores a voice_token for a channel already left", () => {
it("does not connect when the token arrives after the user left before connectAndSetup ever started", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
// The user clicked Disconnect (leaveVoiceChannel nulls this) before the
// voice_join/voice_token round trip for the earlier join returned.
mockVoiceState.currentChannelId = null;
await session.handleVoiceToken("stale-token", "/livekit", 1, "ws://localhost:7880", true);
expect(mockRoom.connect).not.toHaveBeenCalled();
expect((session as any)._state.type).toBe("idle");
});
it("does not connect when the token is for a channel the user has since switched away from", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
mockVoiceState.currentChannelId = 2;
await session.handleVoiceToken("stale-token", "/livekit", 1, "ws://localhost:7880", true);
expect(mockRoom.connect).not.toHaveBeenCalled();
expect((session as any)._state.type).toBe("idle");
});
it("still connects when the token matches the channel the user currently wants", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
mockVoiceState.currentChannelId = 1;
await session.handleVoiceToken("fresh-token", "/livekit", 1, "ws://localhost:7880", true);
expect(mockRoom.connect).toHaveBeenCalledWith("ws://localhost:7880", "fresh-token");
expect((session as any)._state.type).toBe("connected");
});
});
describe("[OC-0010] e2ee-timeout cleanup honors a queued pendingJoin", () => {
it("does not send voice_leave / leaveVoiceChannel and preserves the queued join when the key exchange times out with a join queued", async () => {
const ws = { send: vi.fn() };
session.setServerHost("localhost:7880");
session.setWsClient(ws as any);
// Force setupKeyExchange to report a genuine timeout while a newer
// join has ALREADY been queued — mirrors handleVoiceToken's queuing
// branch, which preserves this attempt's type/joinGeneration.
const keyExchangeSpy = vi
.spyOn((session as any)._e2ee, "setupKeyExchange")
.mockImplementation(async () => {
(session as any)._state = {
...(session as any)._state,
pendingJoin: {
token: "token-b",
url: "/livekit-b",
channelId: 2,
directUrl: undefined,
},
};
return false;
});
const result = await (session as any).connectAndSetup(
"token-a",
"/livekit",
1,
"ws://localhost:7880",
false,
);
expect(result).toBe(false);
// Must NOT run the give-up cleanup — that would send a voice_leave
// that carries no channel id (acting on whichever channel the queued
// join is about to occupy) and would discard the queued join entirely.
expect(ws.send).not.toHaveBeenCalledWith({ type: "voice_leave", payload: {} });
expect(leaveVoiceChannel).not.toHaveBeenCalled();
// The queued join must survive so handleVoiceToken's drain loop can run it.
expect((session as any)._state.type).toBe("connecting");
expect((session as any)._state.pendingJoin?.channelId).toBe(2);
keyExchangeSpy.mockRestore();
});
});
describe("[OC-0015] token refresh survives livekit-client's own internal reconnect", () => {
it("takes the refresh fast path when Room.state is mid-internal-reconnect instead of tearing down the session", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
mockRoom.connect.mockResolvedValue(undefined);
await session.handleVoiceToken("token-1", "/livekit", 1, "ws://localhost:7880", true);
expect((session as any)._state.type).toBe("connected");
// livekit-client's own internal reconnect (a signal-socket blip) —
// RoomEvent.Disconnected is NOT emitted for this, so `_state` stays
// "connected", but Room.state moves off "connected".
mockRoom.state = "signalReconnecting";
mockRoom.connect.mockClear();
const refreshSpy = vi.spyOn(session, "handleVoiceTokenRefresh");
await session.handleVoiceToken("token-2", "/livekit", 1, "ws://localhost:7880", true);
expect(refreshSpy).toHaveBeenCalledWith("token-2");
// Must NOT re-run the full teardown+rejoin — that would drop the E2EE
// session and could eject the user if no offer arrives in time.
expect(mockRoom.connect).not.toHaveBeenCalled();
refreshSpy.mockRestore();
});
});
describe("[OC-0029] requestTokenRefresh throttles to the server's 1-per-60s budget", () => {
it("does not resend voice_token_refresh within 60s of the previous one", async () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
session.setServerHost("localhost:7880");
mockRoom.connect.mockResolvedValue(undefined);
await session.handleVoiceToken("token", "/livekit", 1, "ws://localhost:7880", true);
mockWs.send.mockClear();
(session as any).requestTokenRefresh();
expect(mockWs.send).toHaveBeenCalledWith({ type: "voice_token_refresh", payload: {} });
mockWs.send.mockClear();
// Mirrors OC-0029's repro: auto-reconnect's unconditional post-recovery
// refresh landing ~13s after the 4-minute timer's own refresh.
await vi.advanceTimersByTimeAsync(13_000);
(session as any).requestTokenRefresh();
expect(mockWs.send).not.toHaveBeenCalled();
});
it("allows a refresh again once the 60s budget window has passed", async () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
session.setServerHost("localhost:7880");
mockRoom.connect.mockResolvedValue(undefined);
await session.handleVoiceToken("token", "/livekit", 1, "ws://localhost:7880", true);
mockWs.send.mockClear();
(session as any).requestTokenRefresh();
expect(mockWs.send).toHaveBeenCalledTimes(1);
mockWs.send.mockClear();
await vi.advanceTimersByTimeAsync(60_100);
(session as any).requestTokenRefresh();
expect(mockWs.send).toHaveBeenCalledWith({ type: "voice_token_refresh", payload: {} });
});
it("does not throttle a fresh join shortly after leaveVoice resets the budget", async () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
session.setServerHost("localhost:7880");
mockRoom.connect.mockResolvedValue(undefined);
await session.handleVoiceToken("token", "/livekit", 1, "ws://localhost:7880", true);
(session as any).requestTokenRefresh();
session.leaveVoice(false);
mockVoiceState.currentChannelId = 1;
await session.handleVoiceToken("token-2", "/livekit", 1, "ws://localhost:7880", true);
mockWs.send.mockClear();
(session as any).requestTokenRefresh();
expect(mockWs.send).toHaveBeenCalledWith({ type: "voice_token_refresh", payload: {} });
});
});
});
@@ -0,0 +1,80 @@
// Regression test for OC-0027: the LoginForm `totpPending` latch is set by
// showTotp() and is only cleared by handleTotpCancel()/resetToIdle() — never
// on a *successful* verify. If a later, unrelated error arrives (e.g. the WS
// auth handshake fails after a successful TOTP verify), updateTotpOverlay()'s
// `formState === "error" && totpPending` branch re-opens the dead 2FA overlay
// over the error banner, and the user is stuck: onTotpSubmit's guard in
// main.ts silently no-ops because the partial token was already consumed.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createConnectPage } from "../../src/pages/ConnectPage";
import type { ConnectPageCallbacks, SimpleProfile } from "../../src/pages/ConnectPage";
vi.mock("../../src/lib/credentials", () => ({
loadCredential: vi.fn().mockResolvedValue(null),
}));
vi.mock("../../src/components/SettingsOverlay", () => ({
createSettingsOverlay: () => ({
mount: vi.fn(),
destroy: vi.fn(),
}),
}));
function makeCallbacks(overrides: Partial<ConnectPageCallbacks> = {}): ConnectPageCallbacks {
return {
onLogin: vi.fn().mockResolvedValue(undefined),
onRegister: vi.fn().mockResolvedValue(undefined),
onTotpSubmit: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
const testProfiles: SimpleProfile[] = [{ name: "Test Server", host: "localhost:8443" }];
describe("LoginForm TOTP latch after a successful verify", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("does not re-open the TOTP overlay for an error that arrives after a successful verify", async () => {
const onTotpSubmit = vi.fn().mockResolvedValue(undefined);
const page = createConnectPage(makeCallbacks({ onTotpSubmit }), testProfiles);
page.mount(container);
page.showTotp();
const totpOverlay = container.querySelector(".totp-overlay") as HTMLDivElement;
const totpInput = container.querySelector(".totp-overlay input") as HTMLInputElement;
const verifyBtn = container.querySelector(".totp-overlay .btn-primary") as HTMLButtonElement;
totpInput.value = "111111";
verifyBtn.click();
await vi.waitFor(() => expect(onTotpSubmit).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(verifyBtn.disabled).toBe(false));
// Verify succeeded — nothing rejected. The caller (main.ts) would now be
// driving the post-auth WS connect, e.g. via wirePostAuth. That connect
// can still fail (rotated JWT key, revoked token, ban) and surface an
// unrelated error through the same showError() path ConnectPage wires up
// to the uiStore transientError subscription.
page.showError("Connection failed: unauthorized");
// The dead 2FA overlay must NOT come back — the token it would collect
// can never be submitted again (the partial token was already consumed
// on success), so re-showing it strands the user with no working control
// except Cancel.
expect(totpOverlay.classList.contains("totp-overlay--hidden")).toBe(true);
const errorBanner = container.querySelector(".error-banner") as HTMLDivElement;
expect(errorBanner.classList.contains("visible")).toBe(true);
page.destroy?.();
});
});
@@ -501,6 +501,33 @@ describe("MainPage — video grid, DM profile panel, calls, settings", () => {
expect(banner.style.display).toBe("none");
});
it("does not cancel an incoming ring on a voice_leave for a different channel from the ringer (OC-0011)", () => {
const ws = fakeWs();
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
page = createMainPage({ ws, api: fakeApi() });
page.mount(container);
// Alice (10) rings this client's DM (channel 50).
ws.emit("call_incoming", { channel_id: 50, from_user: 10, username: "alice" });
const banner = document.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement;
expect(banner.style.display).not.toBe("none");
// Alice also happens to be sitting in an unrelated server voice channel
// (99) and leaves it. Same user, wrong channel: this must not silence
// the DM ring — only a voice_leave for the ring's own channel (50) may.
ws.emit("voice_leave", { channel_id: 99, user_id: 10 });
expect(banner.style.display).not.toBe("none");
// Alice leaving the ring's own channel (the DM she was calling from)
// still cancels it.
ws.emit("voice_leave", { channel_id: 50, user_id: 10 });
expect(banner.style.display).toBe("none");
});
it("clears settingsOpen on destroy so the next page (e.g. ConnectPage after logout) doesn't inherit a stale open overlay", () => {
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
page.mount(container);
+69 -4
View File
@@ -20,7 +20,7 @@
* run the dispatcher's own auth_ok handler (which is what actually writes
* authStore).
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Tauri API mocks — reuse the ws-mocks.ts event-registry helper so ws.ts's
@@ -76,12 +76,18 @@ vi.mock("@lib/profiles", () => ({
}));
// api.ts — only login() is exercised (it drives wirePostAuth); nothing else
// in this flow touches the REST client.
// in this flow touches the REST client. getConfig()/setConfig() track a real
// host so OC-0028's test can reproduce the isAuthenticated subscriber's
// `api.getConfig().host` read (main.ts:776) after a login sets it via
// `api.setConfig({ host })` (main.ts:515).
const mockLogin = vi.fn();
const mockApiState = { host: "" };
vi.mock("@lib/api", () => ({
createApiClient: vi.fn(() => ({
setConfig: vi.fn(),
getConfig: vi.fn(() => ({ host: "" })),
setConfig: vi.fn((cfg: { host?: string; token?: string }) => {
if (cfg.host !== undefined) mockApiState.host = cfg.host;
}),
getConfig: vi.fn(() => ({ host: mockApiState.host })),
login: (...args: unknown[]) => mockLogin(...args),
getHealth: vi.fn().mockResolvedValue({ version: null, online_users: null }),
})),
@@ -115,6 +121,15 @@ vi.mock("@pages/ConnectPage", () => ({
}),
}));
// MainPage.ts pulls in the whole chat/voice UI stack. OC-0028 needs a real
// "main" -> "connect" round trip through the router (main.ts only navigates
// away from "connect" via the connected overlay's onReady -> router.navigate
// ("main") callback), but doesn't care what MainPage renders, so stand in
// with the same lightweight shape main-page.test.ts's own mocks return.
vi.mock("@pages/MainPage", () => ({
createMainPage: vi.fn(() => ({ mount: vi.fn(), destroy: vi.fn() })),
}));
// dispatcher.ts pulls in nearly every store/service in the app. Stand in
// with a slim replacement that reproduces the one behavior these tests must
// stay faithful to: the real dispatcher's auth_ok handler calls setAuth() on
@@ -221,3 +236,53 @@ describe("main.ts connected overlay (OC-0063)", () => {
expect(iconEl?.textContent).toBe("M"); // first letter of "My Guild", not "1" (host) or "" (blank auth)
});
});
describe("main.ts connect-page skip-auto-login flag (OC-0028)", () => {
afterEach(() => {
sessionStorage.clear();
mockApiState.host = "";
});
it("consumes owncord:skip-auto-login on a quick-switch mount, not just on a plain logout", async () => {
// Reach "main" for host A via an ordinary login — the quick-switch
// overlay (SidebarArea.ts) can only fire from a live session.
await loginAndReachAuthOk("server-a.example:8443", "alex", {
user: { id: 1, username: "alex", avatar: null, role: "member" },
server_name: "Server A",
motd: "",
});
emitTauriEvent("ws-message", JSON.stringify({ type: "ready", payload: {} }));
// ConnectedOverlay.markReady() fires onReady after READY_DELAY_MS (800ms),
// which calls router.navigate("main") — main.ts's only route away from
// "connect", needed so a later navigate("connect") is a real transition
// and not a same-page no-op.
await vi.advanceTimersByTimeAsync(800);
// Quick-switch overlay's flow (SidebarArea.ts:756-760): stash the target
// host, then log out via a bare clearAuth() (reason defaults to "user").
// The isAuthenticated subscriber below (main.ts:750-788) turns that into
// a stored "owncord:skip-auto-login" flag, since host is set and
// logoutReason !== "server_shutdown".
sessionStorage.setItem("owncord:quick-switch-target", "server-b.example:8443");
clearAuth();
// authStore notifications are microtask-deferred (see store.ts), and the
// connect page's own load-profiles IIFE awaits a mocked (but still
// native-Promise) loadProfiles() before reaching the quick-switch check —
// flush both hops.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
// Quick-switch consumed its own key...
expect(sessionStorage.getItem("owncord:quick-switch-target")).toBeNull();
// ...and per OC-0028 must ALSO consume skip-auto-login on this same
// mount. Before the fix, the quick-switch branch returns early (line 669)
// without ever reaching the skip-auto-login read/remove at line 680-683,
// so the flag set by the clearAuth() above survives indefinitely — and
// would go on to suppress the auto-login that a later, unrelated
// clearAuth("server_shutdown") deliberately relies on.
expect(sessionStorage.getItem("owncord:skip-auto-login")).toBeNull();
});
});
@@ -720,4 +720,40 @@ describe("MessageList", () => {
expect(rowAfterReset!.textContent).toContain("v25");
});
});
describe("scrollToMessage vs renderWindow rebuild breaker", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("does not report success when renderWindow's own >30-in-2s breaker drops the rebuild", () => {
const many = Array.from({ length: 100 }, (_, i) => makeMessage({ id: i + 1 }));
setMessages(1, many);
msgList.mount(container); // mount's renderAll -> renderWindow: rebuild count = 1
// Every scrollToMessage call forces renderedStart = -1 and calls
// renderWindow() directly, bypassing renderAll's own (lower) rapid-fire
// limit. 29 more calls bring the shared renderWindow rebuild counter to
// 30 (still under the >30 breaker), each one landing normally.
for (let i = 0; i < 29; i++) {
expect(msgList.scrollToMessage(i + 1)).toBe(true);
}
// The 30th call pushes the counter to 31 and trips the breaker inside
// renderWindow: it returns before reassigning renderedStart/renderedEnd
// or touching the DOM, so the target (far outside the last rendered
// window) never actually renders.
const result = msgList.scrollToMessage(90);
// The rebuild did not happen — the row is not in the DOM — so this must
// be reported as a failed jump (matching the "false if the message is
// not in the loaded window" contract), not a false "true".
expect(container.querySelector('[data-testid="message-90"]')).toBeNull();
expect(result).toBe(false);
});
});
});
@@ -360,6 +360,42 @@ describe("enableCamera", () => {
expect(state.manualCameraTrack).toBeNull();
expect(voiceStore.getState().localCamera).toBe(false);
});
it("does not clear a newer attempt's track when a superseded enable's device acquisition rejects (OC-0007)", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room);
const trackB = fakeVideoTrack();
let rejectA!: (err: unknown) => void;
const promiseA = new Promise<LocalVideoTrack>((_, reject) => {
rejectA = reject;
});
createLocalVideoTrack.mockReturnValueOnce(promiseA).mockResolvedValueOnce(trackB);
const state = { manualCameraTrack: null as LocalVideoTrack | null };
// Attempt A starts (generation 0) and is stuck awaiting device acquisition.
const enablingA = enableCamera(state, deps);
await vi.waitFor(() => {
expect(createLocalVideoTrack).toHaveBeenCalledTimes(1);
});
// The user cycles the camera off and back on while A is still pending:
// disable bumps the generation, then a fresh enable (B) captures the new
// generation, resolves immediately, and goes fully live.
await disableCamera(state, deps);
await enableCamera(state, deps);
expect(state.manualCameraTrack).toBe(trackB);
expect(voiceStore.getState().localCamera).toBe(true);
// Now A's stale createLocalVideoTrack call finally rejects (e.g. device
// contention). A's catch block must recognise it was superseded and
// leave B's live track and state alone.
rejectA(new DOMException("busy", "NotReadableError"));
await enablingA;
expect(trackB.stop).not.toHaveBeenCalled();
expect(state.manualCameraTrack).toBe(trackB);
expect(voiceStore.getState().localCamera).toBe(true);
});
});
// ── disableCamera ──────────────────────────────────────────────────────────
@@ -669,6 +705,42 @@ describe("enableScreenshare", () => {
expect(state.manualScreenTracks).toEqual([]);
expect(voiceStore.getState().localScreenshare).toBe(false);
});
it("does not tear down a newer share when a superseded enable's capture rejects (OC-0007)", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room);
const videoB = fakeVideoTrack();
let rejectA!: (err: unknown) => void;
const promiseA = new Promise<LocalTrack[]>((_, reject) => {
rejectA = reject;
});
createLocalScreenTracks.mockReturnValueOnce(promiseA).mockResolvedValueOnce([videoB]);
const state = { manualScreenTracks: [] as LocalTrack[] };
// Attempt A starts (generation 0) and is stuck awaiting the OS picker.
const enablingA = enableScreenshare(state, deps);
await vi.waitFor(() => {
expect(createLocalScreenTracks).toHaveBeenCalledTimes(1);
});
// The user cycles the share off and back on while A is still pending:
// disable bumps the generation, then a fresh enable (B) captures the new
// generation, resolves immediately, and goes fully live.
await disableScreenshare(state, deps);
await enableScreenshare(state, deps);
expect(state.manualScreenTracks).toEqual([videoB]);
expect(voiceStore.getState().localScreenshare).toBe(true);
// Now A's stale createLocalScreenTracks call finally rejects. A's catch
// block must recognise it was superseded and leave B's live tracks and
// state alone.
rejectA(new Error("capture failed"));
await enablingA;
expect(videoB.stop).not.toHaveBeenCalled();
expect(state.manualScreenTracks).toEqual([videoB]);
expect(voiceStore.getState().localScreenshare).toBe(true);
});
});
// ── disableScreenshare ─────────────────────────────────────────────────────
@@ -683,6 +683,63 @@ describe("createVideoModeController", () => {
expect(vg.clearStreams).not.toHaveBeenCalled();
});
it("clears videoGrid streams on a voice channel switch (A -> B) even though currentChannelId never passes through null (OC-0012)", () => {
// VoiceCallbacks.onVoiceJoin moves currentChannelId straight from the
// old channel to the new one (joinVoiceChannel is optimistic), so the
// channelId === null branch never runs on a switch. Remote tiles left
// over from channel A must still be cleared when we land in channel B.
const usersA = new Map([
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({ currentChannelId: 10, voiceUsers: new Map([[10, usersA]]) }),
);
const vg = makeVideoGrid();
const ctrl = createVideoModeController({
slots: makeSlots(),
videoGrid: vg,
getCurrentUserId: () => 1,
});
ctrl.checkVideoMode();
expect(vg.clearStreams).not.toHaveBeenCalled();
// Switch straight to channel B — currentChannelId goes 10 -> 20, never
// through null.
const usersB = new Map([
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({ currentChannelId: 20, voiceUsers: new Map([[20, usersB]]) }),
);
ctrl.checkVideoMode();
expect(vg.clearStreams).toHaveBeenCalledTimes(1);
});
it("does not clear videoGrid streams across repeated checkVideoMode calls for the same channel (auto-reconnect)", () => {
const users = new Map([
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({ currentChannelId: 10, voiceUsers: new Map([[10, users]]) }),
);
const vg = makeVideoGrid();
const ctrl = createVideoModeController({
slots: makeSlots(),
videoGrid: vg,
getCurrentUserId: () => 1,
});
ctrl.checkVideoMode();
ctrl.checkVideoMode();
ctrl.checkVideoMode();
expect(vg.clearStreams).not.toHaveBeenCalled();
});
it("closeVideoGrid clears the videoGrid's own focus state, not just the controller's", () => {
const vg = makeVideoGrid();
const ctrl = createVideoModeController({
@@ -595,6 +595,88 @@ describe("wsGeneration stale listener guard", () => {
});
});
describe("connect() catch guards against a superseded ws_connect rejection", () => {
let client: ReturnType<typeof createWsClient>;
beforeEach(() => {
vi.useFakeTimers();
mockInvoke.mockReset();
mockListen.mockClear();
eventHandlers.clear();
client = createWsClient();
});
afterEach(() => {
client.disconnect();
vi.useRealTimers();
});
// OC-0026: connect()'s two earlier await points (ensureTauriApis,
// setupEventListeners) both guard with `if (gen !== wsGeneration) return;`
// before acting on their resumption, but the `await tauriInvoke("ws_connect")`
// catch did not. The Rust proxy deliberately rejects a superseded handshake
// with "superseded by a newer connection" (ws_proxy.rs install_sender), so a
// stale attempt A's rejection — arriving after a newer attempt B already
// reached "connected" — must not tear B's live connection back down.
it("does not flip a live connection back to reconnecting when a stale attempt's ws_connect rejects with 'superseded'", async () => {
let rejectStaleWsConnect: ((err: Error) => void) | null = null;
let staleCallSeen = false;
mockInvoke.mockImplementation(async (cmd: string) => {
if (cmd === "ws_connect" && !staleCallSeen) {
staleCallSeen = true;
return new Promise((_resolve, reject) => {
rejectStaleWsConnect = reject;
});
}
return undefined;
});
// Attempt A: connect() suspends on the Rust handshake (ws_connect pends
// up to CONNECT_TIMEOUT in the real proxy).
client.connect({ host: "localhost:8443", token: "tA" });
await vi.advanceTimersByTimeAsync(10);
expect(rejectStaleWsConnect).not.toBeNull();
// Attempt B supersedes A before A's handshake settles — e.g. a
// cert-accept retry, or logout immediately followed by re-login.
client.connect({ host: "localhost:8443", token: "tB" });
await vi.advanceTimersByTimeAsync(10);
// B completes its handshake and reaches "connected".
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
expect(client.getState()).toBe("connected");
// A's stale ws_connect now rejects with the real error the Rust proxy
// sends for a superseded handshake.
rejectStaleWsConnect!(new Error("superseded by a newer connection"));
await vi.advanceTimersByTimeAsync(10);
// The live connection must not be knocked back into "reconnecting" by
// the superseded attempt's rejection.
expect(client.getState()).toBe("connected");
// No reconnect timer should have been armed by the stale catch —
// advancing well past any backoff must not trigger a redial of the
// still-healthy connection.
mockInvoke.mockClear();
await vi.advanceTimersByTimeAsync(60_000);
const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
expect(reconnectCalls).toHaveLength(0);
});
});
describe("disconnect() cancelling an in-flight connect()", () => {
let client: ReturnType<typeof createWsClient>;
let originalMockListenImpl: (typeof mockListen)["getMockImplementation"] extends () => infer R