mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user