mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix: batch of correctness fixes across server and client (#1375)
* fix(client): 1 defect(s) (OC-0201)
* fix(service): 1 defect(s) (OC-0202)
HandleTyping built the per-user-per-channel rate-limit key before resolving the channel or checking read permission, so forged channel ids could pin unbounded dead entries in the shared process-wide RateLimiter.
* fix(client): 2 defect(s) (OC-0203, OC-0224)
* fix(server): 1 defect(s) (OC-0204)
* fix(ws): 2 defect(s) (OC-0205, OC-0211)
* fix(admin): 2 defect(s) (OC-0209, OC-0212)
* fix(client): 1 defect(s) (OC-0210)
* fix(db): 1 defect(s) (OC-0213)
* fix(ws): 1 defect(s) (OC-0214)
Route handler-driven PresenceEvent through BroadcastToAll instead of BroadcastToAllLow so every source of a user's presence shares one ordered per-client FIFO.
* fix(admin): 1 defect(s) (OC-0215)
PATCH /users/{id} combining banned + role_id committed and broadcast the ban before authorizing the role change, so a refused role change returned an error while leaving the target banned. Authorize the role change up front via the new ModerationService.AuthorizeRoleChange.
* fix(db): 1 defect(s) (OC-0216)
LinkAttachmentsToMessage no longer claims an attachment that is a user's live avatar (users.avatar points at it). Once message_id is set, handleServeFile's avatar branch (gated on ChannelID == nil) is unreachable and the file falls under the message's channel ACL / soft-delete state, permanently disagreeing with users.avatar about who may read it.
* fix(emoji): 1 defect(s) (OC-0217)
* fix(client): 1 defect(s) (OC-0218)
The data-copy phase of an HTTP proxy tunnel was unbounded. Steps 1-2 of
handle_connection (header read, TCP connect, TLS handshake) each run under
a 10s guard, but step 3 called io::copy_bidirectional with no deadline. A
remote that completes the TLS handshake and then neither responds nor
closes parks the spawned connection task, the loopback socket and the
remote TLS session indefinitely: copy_bidirectional only resolves once
BOTH directions finish, so closing the local side alone does not free it.
Wrap the copy in copy_with_deadline, a generic helper bounded by
DATA_PHASE_TIMEOUT (600s). The bound is deliberately far looser than the
10s setup guards because this phase carries the REST body, including
attachment and avatar uploads, so it must reclaim only genuinely stuck
connections rather than merely slow ones. The helper is generic over the
stream types so it can be exercised without a live TLS connection.
Regression test drives two in-memory duplex pairs whose far ends stay
alive, so neither half ever observes EOF and raw copy_bidirectional would
block forever; the test asserts the call resolves on its own deadline with
ErrorKind::TimedOut.
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* fix(ws): 1 defect(s) (OC-0219)
* fix(client): 1 defect(s) (OC-0221)
UpdateNotifier scheduled its deferred update check with a setTimeout whose
handle was never retained, so destroy() could not cancel it. A component torn
down inside the 3s window (page swap / logout) still fired performCheck() and
issued a network update check against the old server URL. Retain the timer
handle and clear it in destroy().
* fix(dm): 1 defect(s) (OC-0222)
* fix(client): 1 defect(s) (OC-0223)
* fix(voice): 1 defect(s) (OC-0225)
The Grant-Microphone retry's .finally hardcoded grantMicBtn.disabled = false, undoing updateFrozen()'s socket-down freeze when the WS socket dropped while the mic permission request was in flight. Delegate the state back to render().
* fix(admin): 1 defect(s) (OC-0226)
handleApplyUpdate broadcasts a 'restarting in 5s' notice before the on-disk
swap. Every failure path in the swap returned silently, leaving clients
counting down to a restart that never happened. Extract the swap into
applyStagedUpdate and send a corrective 'update_aborted' broadcast from a
deferred guard on every path that does not reach the respawn.
* fix(admin): 1 defect(s) (OC-0227)
PATCH /channels/{id} accepted a blank or whitespace-only name, leaving the
channel unidentifiable in clients. updateChannelRequest.validate() now
rejects it the way handleCreateChannel already did.
* fix(identity): 1 defect(s) (OC-0228)
* fix(admin): run deferred cleanup before the update restart exits
The fix batch left three golangci-lint findings and two prettier findings
that CI gates on.
applyStagedUpdate called os.Exit(0) in the same function that defers both
staged.Close() and the corrective "update_aborted" broadcast, so neither
ran (gocritic exitAfterDefer). Return a bool instead and let the caller
exit once those defers have run — on Windows, releasing the staged binary's
file handle is the reason the restart exists at all, so this is a real fix
rather than a lint appeasement. The exported test hook calls the function as
a statement, so the added result does not affect it.
Also modernize a bulk-insert loop to range-over-int, compare backup bytes
with bytes.Equal, and reflow two test files to prettier's output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* test(ws): pin the live presence path against the invisible custom-status leak
OC-0207 and OC-0211 are the same defect at two emitters: hub_broadcast.go's
BroadcastPresence (connect/reconnect) and event.go's presenceEvents (live
presence_update). The fix for OC-0211 closed both sites in one change, but
only the hub_broadcast side got a regression test.
This pins the event.go sibling: an invisible user's real custom status must
be blanked on the PresenceOthersEvent frame while the owner's own
PresenceSelfEvent still carries it. Without it, a later change could reopen
the live path while the committed test kept passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* fix(ws): 1 defect(s) (OC-0206)
* test(ws): silence a contextcheck false positive in the reconnect race test
RefreshChannelVisibility takes no context by design — it is reached through
the admin HubBroadcaster interface, which carries none, so it builds its own
internally. contextcheck flags the call only because the test closure around
it holds a ctx for its override write, so there is nothing to propagate.
Suppress at the call site rather than widen a production interface (and its
mocks) to satisfy a lint in a test.
golangci-lint v2.11.3 (the version ci.yml pins) now reports 0 issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -447,7 +447,7 @@ async fn handle_connection<R: Runtime>(
|
||||
|
||||
// ── 3. Forward request + bidirectional copy ──────────────────────────
|
||||
tls.write_all(modified.as_bytes()).await?;
|
||||
match io::copy_bidirectional(&mut local, &mut tls).await {
|
||||
match copy_with_deadline(&mut local, &mut tls, DATA_PHASE_TIMEOUT).await {
|
||||
Ok((to_remote, from_remote)) => {
|
||||
debug!(
|
||||
"[http_proxy] connection closed: {}B sent, {}B received",
|
||||
@@ -461,6 +461,40 @@ async fn handle_connection<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bound for the data-copy phase of a tunneled connection (step 3 above).
|
||||
/// The header read, TCP connect, and TLS handshake phases all use a tight
|
||||
/// 10s guard, but this phase carries the actual REST body — including
|
||||
/// attachment/avatar uploads — so it needs a much more generous bound. 600s
|
||||
/// only reclaims a connection that is genuinely stuck (e.g. a remote that
|
||||
/// completes the TLS handshake and then neither responds nor closes), not
|
||||
/// one that is merely slow.
|
||||
const DATA_PHASE_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// Run `io::copy_bidirectional` under a deadline. Without this, a remote
|
||||
/// that completes the TLS handshake and then stalls forever (neither
|
||||
/// responding nor closing) parks the spawned connection task — and both the
|
||||
/// loopback socket and the remote TLS session — indefinitely; closing the
|
||||
/// local side alone does not free it, since `copy_bidirectional` only
|
||||
/// resolves once BOTH directions finish. Generic over the stream types so it
|
||||
/// can be unit-tested without a live TLS connection.
|
||||
async fn copy_with_deadline<A, B>(
|
||||
local: &mut A,
|
||||
remote: &mut B,
|
||||
dur: Duration,
|
||||
) -> io::Result<(u64, u64)>
|
||||
where
|
||||
A: io::AsyncRead + io::AsyncWrite + Unpin + ?Sized,
|
||||
B: io::AsyncRead + io::AsyncWrite + Unpin + ?Sized,
|
||||
{
|
||||
match timeout(dur, io::copy_bidirectional(local, remote)).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"data phase timed out",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -592,4 +626,35 @@ mod tests {
|
||||
assert!(out.contains("Content-Length: 2\r\n"));
|
||||
assert!(out.ends_with("\r\n\r\n"));
|
||||
}
|
||||
|
||||
// OC-0218: the data phase of a tunneled request (step 3 in
|
||||
// `handle_connection`) must not be able to hang forever. A remote that
|
||||
// completes the TLS handshake and then neither responds nor closes must
|
||||
// eventually be reclaimed, the same way the header-read/connect/handshake
|
||||
// phases already are (10s guards above). Simulate that stall with two
|
||||
// in-memory duplex pairs where neither peer ever writes or disconnects,
|
||||
// so raw `io::copy_bidirectional` would block forever.
|
||||
#[tokio::test]
|
||||
async fn copy_with_deadline_reclaims_a_stalled_connection() {
|
||||
// Keep both "far" ends alive (bound, not `_`) so neither duplex half
|
||||
// observes EOF — this is what makes the connection "stalled" rather
|
||||
// than "closed".
|
||||
let (mut local_near, _local_far) = tokio::io::duplex(64);
|
||||
let (mut remote_near, _remote_far) = tokio::io::duplex(64);
|
||||
|
||||
// An outer safety bound: if `copy_with_deadline` does not honor its
|
||||
// own deadline, fail fast instead of hanging the test suite forever.
|
||||
let outcome = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
copy_with_deadline(&mut local_near, &mut remote_near, Duration::from_millis(50)),
|
||||
)
|
||||
.await
|
||||
.expect(
|
||||
"copy_with_deadline must resolve on its own deadline; the data phase must not hang \
|
||||
indefinitely on a stalled remote (OC-0218)",
|
||||
);
|
||||
|
||||
let err = outcome.expect_err("a stalled remote must surface as a timeout error, not Ok");
|
||||
assert_eq!(err.kind(), io::ErrorKind::TimedOut);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +323,21 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
if (item === undefined) return `idx-${index}`;
|
||||
if (item.kind === "divider") return `div-${item.timestamp}`;
|
||||
if (item.kind === "new-divider") return "new-divider";
|
||||
// Every unconfirmed optimistic row (addOptimisticMessage) carries
|
||||
// id: 0 until confirmSend stamps the real id, so keying purely on
|
||||
// message.id would collide two or more pending rows onto the same
|
||||
// "msg-0" cache entry — measureRendered would overwrite one row's
|
||||
// measured height with another's, and the next Fenwick rebuild
|
||||
// (rebuildItems / tryAppendMessages) would seed both rows' tree slots
|
||||
// from that single, wrong value. correlationId is unique per pending
|
||||
// send and stable across the row's lifetime, so key on that instead
|
||||
// while id is still the 0 sentinel; fall back to the row's own index
|
||||
// in the vanishingly unlikely case correlationId is also absent.
|
||||
if (item.message.id === 0) {
|
||||
return item.message.correlationId !== null
|
||||
? `msg-c-${item.message.correlationId}`
|
||||
: `idx-${index}`;
|
||||
}
|
||||
return `msg-${item.message.id}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
|
||||
let container: Element | null = null;
|
||||
let banner: HTMLDivElement | null = null;
|
||||
let dismissed = false;
|
||||
let checkTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function performCheck(): Promise<void> {
|
||||
if (dismissed) return;
|
||||
@@ -123,12 +124,17 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
|
||||
function mount(target: Element): void {
|
||||
container = target;
|
||||
// Delay the check slightly so the main UI renders first
|
||||
setTimeout(() => {
|
||||
checkTimer = setTimeout(() => {
|
||||
checkTimer = null;
|
||||
void performCheck();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
if (checkTimer !== null) {
|
||||
clearTimeout(checkTimer);
|
||||
checkTimer = null;
|
||||
}
|
||||
removeBanner();
|
||||
container = null;
|
||||
}
|
||||
|
||||
@@ -21,10 +21,21 @@ import {
|
||||
import { avatarInitial, isRenderableAvatar, resolveDisplayName } from "@lib/avatar";
|
||||
import { fetchImageAsDataUrl, resolveServerUrl } from "@components/message-list/attachments";
|
||||
import type { WsClient } from "@lib/ws";
|
||||
import type { PresenceSender } from "@lib/presence";
|
||||
|
||||
export interface UserBarOptions {
|
||||
readonly onDisconnect?: () => void;
|
||||
readonly ws?: WsClient | null;
|
||||
/**
|
||||
* The session's single shared presence sender (MainPage owns the instance
|
||||
* and threads it to every producer — auto-idle, the settings Account tab,
|
||||
* and this picker). Sending straight through `ws` instead would bypass the
|
||||
* presence rate limiter's client-side token *and* its retry, so a frame
|
||||
* the server drops (1 update / 10s, keyed by user id — service/
|
||||
* channel.go) is lost for the rest of the session instead of retried
|
||||
* (OC-0210). Required, alongside `ws`, for the picker to be enabled.
|
||||
*/
|
||||
readonly presenceSender?: PresenceSender | null;
|
||||
}
|
||||
|
||||
/** Status labels for the line under the username. */
|
||||
@@ -135,11 +146,21 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
|
||||
});
|
||||
|
||||
// The picker is usable only when the socket is live (store-backed status,
|
||||
// docs/architecture/ux §3) AND a ws client was provided to send through —
|
||||
// without a send path, selecting a status would be a silent no-op.
|
||||
// docs/architecture/ux §3) AND a presence sender was provided to send
|
||||
// through — without one, selecting a status would either be a silent
|
||||
// no-op or (worse) bypass the shared presence rate limiter and its retry
|
||||
// (OC-0210). `ws` is checked too since a sender without a live socket
|
||||
// behind it is not meaningfully usable either.
|
||||
const canSetStatus = (): boolean => {
|
||||
const ws = options?.ws;
|
||||
return ws !== undefined && ws !== null && uiStore.getState().connectionStatus === "connected";
|
||||
const sender = options?.presenceSender;
|
||||
return (
|
||||
ws !== undefined &&
|
||||
ws !== null &&
|
||||
sender !== undefined &&
|
||||
sender !== null &&
|
||||
uiStore.getState().connectionStatus === "connected"
|
||||
);
|
||||
};
|
||||
|
||||
statusPicker = createStatusPicker({
|
||||
@@ -150,21 +171,20 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
|
||||
onStatusChange: (status: UserStatus) => {
|
||||
saveUserStatus(status);
|
||||
updateFromState();
|
||||
const ws = options?.ws;
|
||||
if (ws !== null && ws !== undefined && canSetStatus()) {
|
||||
const sender = options?.presenceSender;
|
||||
if (sender !== null && sender !== undefined && canSetStatus()) {
|
||||
// No custom_status field: a plain status change must leave whatever
|
||||
// text the user set standing.
|
||||
ws.send({ type: "presence_update", payload: { status } } as never);
|
||||
// text the user set standing. Routed through the shared sender
|
||||
// (not ws.send directly) so a frame the presence limiter's window
|
||||
// rejects is retried instead of lost — see @lib/presence.
|
||||
sender.send(status);
|
||||
}
|
||||
},
|
||||
onCustomStatusChange: (text: string) => {
|
||||
saveCustomStatus(text);
|
||||
const ws = options?.ws;
|
||||
if (ws !== null && ws !== undefined && canSetStatus()) {
|
||||
ws.send({
|
||||
type: "presence_update",
|
||||
payload: { status: loadUserStatus(), custom_status: text },
|
||||
} as never);
|
||||
const sender = options?.presenceSender;
|
||||
if (sender !== null && sender !== undefined && canSetStatus()) {
|
||||
sender.send(loadUserStatus(), text);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -456,8 +456,14 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
|
||||
}
|
||||
void retryMicPermission().finally(() => {
|
||||
if (grantMicBtn) {
|
||||
grantMicBtn.disabled = false;
|
||||
setText(grantMicBtn, "Grant Microphone");
|
||||
// Delegate the disabled/title state back to render(), which
|
||||
// re-runs updateFrozen() — the single authority for the
|
||||
// socket-down freeze. Hardcoding `disabled = false` here would
|
||||
// silently re-enable this button (and drop its stale title)
|
||||
// even while the WS socket is still down and every sibling
|
||||
// control remains frozen.
|
||||
render();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -68,6 +68,14 @@ export const THEMES = {
|
||||
|
||||
export type ThemeName = keyof typeof THEMES;
|
||||
|
||||
// Union of every CSS custom property any built-in theme sets. Used by
|
||||
// applyTheme to clear a previous theme's tokens before applying a new one,
|
||||
// without touching inline properties owned by other code (e.g. --accent,
|
||||
// --font-size).
|
||||
const THEME_KEYS: ReadonlySet<string> = new Set(
|
||||
Object.values(THEMES).flatMap((theme) => Object.keys(theme)),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessible toggle creation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -114,9 +122,17 @@ export function createToggle(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function applyTheme(name: ThemeName): void {
|
||||
// Apply CSS variables for the theme (keeps existing behavior for inline var overrides)
|
||||
const theme = THEMES[name];
|
||||
const root = document.documentElement;
|
||||
// Clear every key any built-in theme owns first, so switching to a theme
|
||||
// that sets fewer keys (e.g. light -> dark) doesn't leave the previous
|
||||
// theme's tokens stuck on <html>, outranking tokens.css's :root defaults
|
||||
// via inline-style specificity. Keys owned by other code (--accent,
|
||||
// --font-size) are not in THEME_KEYS and are left untouched.
|
||||
for (const key of THEME_KEYS) {
|
||||
root.style.removeProperty(key);
|
||||
}
|
||||
// Apply CSS variables for the theme (keeps existing behavior for inline var overrides)
|
||||
for (const [key, value] of Object.entries(theme)) {
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
|
||||
@@ -366,9 +366,25 @@ export function wireDispatcher(
|
||||
if (activeAfterReady !== null && getMessages !== undefined) {
|
||||
invalidateLoadedMessageWindows();
|
||||
getMessages(activeAfterReady, { limit: 50 })
|
||||
.then((resp) => setMessages(activeAfterReady, resp.messages, resp.has_more))
|
||||
.then((resp) => {
|
||||
// OC-0203: the user can switch (or the active channel can be
|
||||
// cleared) while this fetch is in flight. Writing the snapshot
|
||||
// unconditionally would re-add a channel the user already left
|
||||
// to loadedChannels with a pre-resync-era snapshot —
|
||||
// MessageController.loadMessages then short-circuits on
|
||||
// isChannelLoaded() forever, so the hole this whole resync
|
||||
// block exists to close becomes permanent instead. Only the
|
||||
// channel still on screen when the response lands may accept
|
||||
// it.
|
||||
if (channelsStore.select((s) => s.activeChannelId) !== activeAfterReady) return;
|
||||
setMessages(activeAfterReady, resp.messages, resp.has_more);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.warn("Failed to reload message history after resync", { error: String(err) });
|
||||
// Same staleness guard as the .then above — a rejection for a
|
||||
// channel the user already left must not flag it load-errored;
|
||||
// that channel's own mount/retry path owns its state now.
|
||||
if (channelsStore.select((s) => s.activeChannelId) !== activeAfterReady) return;
|
||||
// The invalidate above already dropped this channel's window,
|
||||
// so a silent catch would leave a mounted MessageList showing
|
||||
// its "no messages yet" welcome state — indistinguishable from
|
||||
@@ -1058,6 +1074,33 @@ export function wireDispatcher(
|
||||
if (id !== undefined && rollbackReaction(id)) {
|
||||
return;
|
||||
}
|
||||
// OC-0224: the sidebar/widget optimistically writes currentChannelId
|
||||
// before the server answers voice_join (VoiceCallbacks.onVoiceJoin,
|
||||
// voiceStatus="joining"). A first-time join refusal earns no
|
||||
// voice_leave (there was no previous channel to leave), so nothing
|
||||
// else ever clears that optimistic state — setVoiceStatus("idle") only
|
||||
// runs inside LiveKitSession.leaveVoice(). handleVoiceJoin can refuse
|
||||
// for CHANNEL_FULL, VOICE_ERROR, FORBIDDEN, NOT_FOUND, BAD_REQUEST,
|
||||
// RATE_LIMITED, ALREADY_JOINED, or INTERNAL — this used to only roll
|
||||
// back CHANNEL_FULL, leaving the sidebar keyed on a channel with no
|
||||
// LiveKit session for every other refusal. voice_join's error replies
|
||||
// carry no envelope id to correlate against (Server/ws/voice_join.go
|
||||
// always answers with buildErrorMsg, never buildErrorMsgWithID), so —
|
||||
// unlike the pendingSends/pendingReactions correlation above — this
|
||||
// can't be scoped to "the refusal that answered this specific join";
|
||||
// it runs once, ahead of every code-specific branch below, for any
|
||||
// error that lands while a join is outstanding. A channel *switch*
|
||||
// refusal hits the same guard: the self voice_leave for the OLD
|
||||
// channel that precedes it no longer resets voiceStatus (OC-0015 —
|
||||
// that voice_leave's channel no longer matches the already-updated
|
||||
// currentChannelId, so it must not tear down the NEW channel's
|
||||
// optimistic state either), so voiceStatus is still "joining" when
|
||||
// this error lands and the guard clears it here instead. An
|
||||
// already-established session is never in "joining", so this never
|
||||
// touches a live voice call.
|
||||
if (voiceStore.getState().voiceStatus === "joining") {
|
||||
leaveVoiceChannel();
|
||||
}
|
||||
// Voice capacity refusals. The server owns the limits (voice_max_users /
|
||||
// voice_max_video) and refuses the join or the camera; the client never
|
||||
// pre-blocks the click, because its copy of the participant list can lag
|
||||
@@ -1066,20 +1109,6 @@ export function wireDispatcher(
|
||||
// with an explanation buried in the log.
|
||||
if (payload.code === "CHANNEL_FULL") {
|
||||
showToast(payload.message || "That voice channel is full", "error");
|
||||
// The sidebar/widget optimistically writes currentChannelId before
|
||||
// the server answers (VoiceCallbacks.onVoiceJoin). A first-time join
|
||||
// refusal earns no voice_leave (there was no previous channel to
|
||||
// leave), so nothing else clears that optimistic state — the sidebar
|
||||
// is left keyed on a channel with no LiveKit session. A channel
|
||||
// *switch* refusal hits the same guard: the self voice_leave for the
|
||||
// OLD channel that precedes it no longer resets voiceStatus (OC-0015
|
||||
// — that voice_leave's channel no longer matches the already-updated
|
||||
// currentChannelId, so it must not tear down the NEW channel's
|
||||
// optimistic state either), so voiceStatus is still "joining" when
|
||||
// this error lands and the guard clears it here instead.
|
||||
if (voiceStore.getState().voiceStatus === "joining") {
|
||||
leaveVoiceChannel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload.code === "VIDEO_LIMIT") {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Shared sender for `presence_update` — the token bucket, the coalescing
|
||||
* retry, and the local optimistic update all live here exactly once so that
|
||||
* every producer (auto-idle, the settings Account tab, the UserBar status
|
||||
* picker) agrees with the server's own limiter instead of each guessing
|
||||
* independently.
|
||||
*
|
||||
* The server enforces a single per-user budget (1 update / 10s, keyed by
|
||||
* user id — service/channel.go) regardless of which client surface sent the
|
||||
* frame. A `RateLimiter` created fresh per call site cannot predict that
|
||||
* shared budget: two producers each starting from a full bucket can both
|
||||
* believe they have a free token when the server has exactly one, so the
|
||||
* second frame the server actually receives gets silently dropped
|
||||
* (ErrRateLimited, no DB write, no broadcast) with nothing left to correct
|
||||
* it (OC-0210). Callers MUST share one `PresenceSender` — built from one
|
||||
* `RateLimiter` instance — for the lifetime of a session, the same way
|
||||
* MainPage.ts's `limiters` are already shared across its chat/typing/
|
||||
* reaction/voice producers.
|
||||
*/
|
||||
|
||||
import type { WsClient } from "./ws";
|
||||
import type { RateLimiter } from "./rate-limiter";
|
||||
import type { UserStatus } from "./types";
|
||||
import { updatePresence } from "@stores/members.store";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import { loadUserStatus } from "./userStatus";
|
||||
|
||||
export interface PresenceSender {
|
||||
/**
|
||||
* Send (or, if the shared limiter's window is closed, queue) a presence
|
||||
* change. Omit `customStatus` to leave whatever custom-status text the
|
||||
* server already has standing — that is what every caller except an
|
||||
* explicit custom-status commit wants.
|
||||
*/
|
||||
send(status: UserStatus, customStatus?: string): void;
|
||||
/** Cancel any pending retry. Call on teardown of the owning session. */
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `PresenceSender` bound to one `ws` and one `RateLimiter`. Callers
|
||||
* that want to share a budget (which is every real caller — see module
|
||||
* doc) must construct this once and pass the same instance to each
|
||||
* producer, rather than calling this factory once per producer.
|
||||
*/
|
||||
export function createPresenceSender(ws: WsClient, limiter: RateLimiter): PresenceSender {
|
||||
let retry: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function send(status: UserStatus, customStatus?: string): void {
|
||||
const userId = authStore.getState().user?.id ?? 0;
|
||||
if (userId !== 0) {
|
||||
updatePresence(userId, status, customStatus);
|
||||
}
|
||||
if (retry !== null) {
|
||||
clearTimeout(retry);
|
||||
retry = null;
|
||||
}
|
||||
if (limiter.tryConsume()) {
|
||||
if (customStatus === undefined) {
|
||||
ws.send({ type: "presence_update", payload: { status } });
|
||||
} else {
|
||||
ws.send({ type: "presence_update", payload: { status, custom_status: customStatus } });
|
||||
}
|
||||
} else {
|
||||
// The window is still closed from an earlier send (any producer's) —
|
||||
// retry once it reopens instead of dropping this one silently.
|
||||
// Re-reads loadUserStatus() at fire time so a burst of calls in
|
||||
// between coalesces onto a single retry carrying the latest value.
|
||||
retry = setTimeout(() => {
|
||||
retry = null;
|
||||
send(loadUserStatus(), customStatus);
|
||||
}, limiter.getRemainingMs());
|
||||
}
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
if (retry !== null) {
|
||||
clearTimeout(retry);
|
||||
retry = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { send, destroy };
|
||||
}
|
||||
@@ -394,18 +394,26 @@ export function createWsClient() {
|
||||
// "trusted" → no action
|
||||
}
|
||||
|
||||
async function setupEventListeners(): Promise<void> {
|
||||
if (tauriListen === null) return;
|
||||
// Registers this attempt's Tauri event listeners and returns the unsub
|
||||
// handles it created, WITHOUT touching the shared `eventUnsubs` array.
|
||||
// Ownership of those handles (splicing them into `eventUnsubs`, or tearing
|
||||
// them down if this attempt turns out to be stale) is the caller's job —
|
||||
// see connect(). This keeps a still-in-flight attempt's registrations from
|
||||
// ever being visible to (and therefore clearable by) another attempt that
|
||||
// resumes around the same time; see OC-0219.
|
||||
async function setupEventListeners(): Promise<Array<() => void>> {
|
||||
if (tauriListen === null) return [];
|
||||
|
||||
// Capture generation so stale listeners from a previous connect() are no-ops.
|
||||
const gen = wsGeneration;
|
||||
const ownUnsubs: Array<() => void> = [];
|
||||
|
||||
// Server messages
|
||||
const unsubMsg = await tauriListen("ws-message", (e) => {
|
||||
if (gen !== wsGeneration) return;
|
||||
handleMessage(e.payload as string);
|
||||
});
|
||||
eventUnsubs.push(unsubMsg);
|
||||
ownUnsubs.push(unsubMsg);
|
||||
|
||||
// Connection state changes from Rust
|
||||
const unsubState = await tauriListen("ws-state", (e) => {
|
||||
@@ -454,31 +462,36 @@ export function createWsClient() {
|
||||
}
|
||||
}
|
||||
});
|
||||
eventUnsubs.push(unsubState);
|
||||
ownUnsubs.push(unsubState);
|
||||
|
||||
// Errors
|
||||
const unsubErr = await tauriListen("ws-error", (e) => {
|
||||
if (gen !== wsGeneration) return;
|
||||
log.warn("WebSocket error (proxy)", { error: e.payload });
|
||||
});
|
||||
eventUnsubs.push(unsubErr);
|
||||
ownUnsubs.push(unsubErr);
|
||||
|
||||
// Register the global cert-tofu listener on first connect (idempotent).
|
||||
// startCertListener() registers the same listener at app bootstrap so
|
||||
// first-use/mismatch events are also caught during the connect page's health
|
||||
// checks, before any WS connection exists.
|
||||
// checks, before any WS connection exists. Deliberately NOT part of
|
||||
// ownUnsubs/eventUnsubs — it is a singleton for the app's lifetime, not
|
||||
// scoped to any one connect() attempt.
|
||||
if (certListenerUnsub === null) {
|
||||
certListenerUnsub = await tauriListen("cert-tofu", (e) => {
|
||||
handleCertTofu(e.payload as CertTofuEvent);
|
||||
});
|
||||
}
|
||||
|
||||
return ownUnsubs;
|
||||
}
|
||||
|
||||
function cleanupEventListeners(): void {
|
||||
for (const unsub of eventUnsubs) {
|
||||
// Invokes each unsub handle in `unsubs`, tolerating handles that throw or
|
||||
// return a rejected promise (the Tauri resource may already have been
|
||||
// invalidated after disconnect).
|
||||
function unsubscribeAll(unsubs: ReadonlyArray<() => void>): void {
|
||||
for (const unsub of unsubs) {
|
||||
try {
|
||||
// Unsub may return a rejected promise if the Tauri resource
|
||||
// was already invalidated after disconnect — safe to ignore.
|
||||
const result = unsub() as unknown;
|
||||
if (result instanceof Promise) {
|
||||
result.catch((err) => {
|
||||
@@ -489,6 +502,10 @@ export function createWsClient() {
|
||||
// Sync errors also safe to ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupEventListeners(): void {
|
||||
unsubscribeAll(eventUnsubs);
|
||||
eventUnsubs.length = 0;
|
||||
}
|
||||
|
||||
@@ -527,17 +544,24 @@ export function createWsClient() {
|
||||
attempt: reconnectAttempt,
|
||||
});
|
||||
|
||||
// Set up event listeners before connecting
|
||||
// Set up event listeners before connecting. setupEventListeners() hands
|
||||
// back only the handles THIS attempt registered — they are not spliced
|
||||
// into the shared `eventUnsubs` until the gen check below confirms this
|
||||
// attempt is still current. That ownership split is what stops a stale
|
||||
// attempt's cleanup (just below) from ever reaching a newer attempt's
|
||||
// listeners, even if the newer attempt finished registering its own
|
||||
// listeners while this one was still suspended above (OC-0219).
|
||||
cleanupEventListeners();
|
||||
await setupEventListeners();
|
||||
const ownUnsubs = await setupEventListeners();
|
||||
if (gen !== wsGeneration) {
|
||||
// Cancelled while awaiting the Tauri IPC round trips inside
|
||||
// setupEventListeners(). Tear down the listeners this (now-stale)
|
||||
// attempt just registered instead of leaving them until the next
|
||||
// connect() happens to clean them up.
|
||||
cleanupEventListeners();
|
||||
// setupEventListeners(). Tear down only the listeners THIS (now-stale)
|
||||
// attempt just registered — never the shared eventUnsubs array, which
|
||||
// may already hold a newer attempt's live listeners by now.
|
||||
unsubscribeAll(ownUnsubs);
|
||||
return;
|
||||
}
|
||||
eventUnsubs.push(...ownUnsubs);
|
||||
|
||||
try {
|
||||
await tauriInvoke("ws_connect", { url: wsUrl });
|
||||
|
||||
@@ -19,8 +19,8 @@ import { initToast, teardownToast, showToast } from "@lib/toast";
|
||||
import { logout } from "@lib/logout";
|
||||
import { authStore, clearAuth, updateUser } from "@stores/auth.store";
|
||||
import { closeSettings, uiStore } from "@stores/ui.store";
|
||||
import { updatePresence } from "@stores/members.store";
|
||||
import { loadUserStatus } from "@lib/userStatus";
|
||||
import { createPresenceSender } from "@lib/presence";
|
||||
import { startAutoIdle, type AutoIdleController } from "@lib/autoIdle";
|
||||
import { channelsStore, getActiveChannel } from "@stores/channels.store";
|
||||
import { dmStore, dmDisplayName } from "@stores/dm.store";
|
||||
@@ -122,6 +122,16 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
|
||||
const limiters = createRateLimiterSet();
|
||||
|
||||
// The one presence_update sender for this session — owns the limiter
|
||||
// token, the drop-window retry, and the local optimistic update, so
|
||||
// every producer (auto-idle, the settings Account tab via applyPresence
|
||||
// below, and the UserBar status picker it's threaded to through
|
||||
// SidebarArea) shares the exact same budget the server enforces (1
|
||||
// update / 10s, keyed by user id — service/channel.go). A limiter created
|
||||
// per producer instead cannot predict that shared, cross-surface budget
|
||||
// (OC-0210).
|
||||
const presenceSender = createPresenceSender(ws, limiters.presence);
|
||||
|
||||
let container: Element | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
@@ -147,11 +157,6 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
* minutes. Started once the socket is up, torn down with the page. */
|
||||
let autoIdle: AutoIdleController | null = null;
|
||||
|
||||
// Pending retry for a presence_update the 1-per-10s limiter dropped (see
|
||||
// applyPresence below). Module-scoped so a second dropped frame can
|
||||
// supersede the first instead of stacking retries.
|
||||
let presenceRetry: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Toast container for user-facing error feedback
|
||||
let toast: ToastContainer | null = null;
|
||||
|
||||
@@ -196,26 +201,12 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
* online fires unthrottled milliseconds after its own idle transition
|
||||
* (autoIdle.ts) — routinely losing the token race. Dropping that frame
|
||||
* silently would leave the server, and everyone else's member list,
|
||||
* stuck on "idle" with nothing left to correct it. Retry once the window
|
||||
* reopens instead, re-reading the status at that time so a burst of
|
||||
* calls in between coalesces onto one retry carrying the latest value. */
|
||||
* stuck on "idle" with nothing left to correct it. `presenceSender`
|
||||
* retries once the window reopens instead, re-reading the status at that
|
||||
* time so a burst of calls in between coalesces onto one retry carrying
|
||||
* the latest value — see @lib/presence. */
|
||||
function applyPresence(status: UserStatus): void {
|
||||
const userId = getCurrentUserId();
|
||||
if (userId !== 0) {
|
||||
updatePresence(userId, status);
|
||||
}
|
||||
if (presenceRetry !== null) {
|
||||
clearTimeout(presenceRetry);
|
||||
presenceRetry = null;
|
||||
}
|
||||
if (limiters.presence.tryConsume()) {
|
||||
ws.send({ type: "presence_update", payload: { status } });
|
||||
} else {
|
||||
presenceRetry = setTimeout(() => {
|
||||
presenceRetry = null;
|
||||
applyPresence(loadUserStatus());
|
||||
}, limiters.presence.getRemainingMs());
|
||||
}
|
||||
presenceSender.send(status);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -385,6 +376,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
ws,
|
||||
api,
|
||||
limiters,
|
||||
presenceSender,
|
||||
getRoot: () => root,
|
||||
getToast: () => toast,
|
||||
onWatchStream: (userId) => {
|
||||
@@ -809,10 +801,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
closeActiveLightbox();
|
||||
autoIdle?.destroy();
|
||||
autoIdle = null;
|
||||
if (presenceRetry !== null) {
|
||||
clearTimeout(presenceRetry);
|
||||
presenceRetry = null;
|
||||
}
|
||||
presenceSender.destroy();
|
||||
channelCtrl?.destroyChannel();
|
||||
channelCtrl = null;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { WsClient } from "@lib/ws";
|
||||
import type { ApiClient } from "@lib/api";
|
||||
import type { RateLimiterSet } from "@lib/rate-limiter";
|
||||
import type { PresenceSender } from "@lib/presence";
|
||||
import type { ToastContainer } from "@components/Toast";
|
||||
import { createChannelSidebar } from "@components/ChannelSidebar";
|
||||
import { createDmSidebar } from "@components/DmSidebar";
|
||||
@@ -57,6 +58,11 @@ export interface SidebarAreaOptions {
|
||||
readonly ws: WsClient;
|
||||
readonly api: ApiClient;
|
||||
readonly limiters: RateLimiterSet;
|
||||
/** MainPage's single shared presence sender — threaded to UserBar so its
|
||||
* status picker shares the same limiter budget and retry as auto-idle
|
||||
* and the settings Account tab instead of sending straight through `ws`
|
||||
* (OC-0210; see @lib/presence). */
|
||||
readonly presenceSender: PresenceSender;
|
||||
readonly getRoot: () => HTMLDivElement | null;
|
||||
readonly getToast: () => ToastContainer | null;
|
||||
readonly onWatchStream?: (userId: number) => void;
|
||||
@@ -78,7 +84,7 @@ export interface SidebarAreaResult {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
const { ws, api, limiters, getRoot, getToast } = opts;
|
||||
const { ws, api, limiters, presenceSender, getRoot, getToast } = opts;
|
||||
|
||||
const children: MountableComponent[] = [];
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
@@ -785,7 +791,7 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const userBarSlot = createElement("div", {});
|
||||
const userBar = createUserBar({ onDisconnect: openQuickSwitch, ws });
|
||||
const userBar = createUserBar({ onDisconnect: openQuickSwitch, ws, presenceSender });
|
||||
userBar.mount(userBarSlot);
|
||||
children.push(userBar);
|
||||
sidebarWrapper.appendChild(userBarSlot);
|
||||
|
||||
@@ -1378,6 +1378,71 @@ describe("WS Dispatcher", () => {
|
||||
expect(isChannelLoaded(1)).toBe(true);
|
||||
});
|
||||
|
||||
// OC-0203: the refetch above is fired-and-forgotten against whatever
|
||||
// channel was active when the resync `ready` arrived — but the user can
|
||||
// switch channels before the HTTP response lands. The continuation must
|
||||
// re-check that the fetched channel is still the active one before
|
||||
// writing setMessages, or it resurrects a stale pre-resync snapshot for
|
||||
// a channel the user already left (and, worse, re-marks it "loaded" so
|
||||
// MessageController.loadMessages never refetches it again on return).
|
||||
it("does not write a stale resync snapshot for a channel the user switched away from mid-refetch", async () => {
|
||||
cleanup();
|
||||
const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [] });
|
||||
let resolveGetMessages:
|
||||
((resp: { messages: MessageResponse[]; has_more: boolean }) => void) | null = null;
|
||||
const getMessages = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise<{ messages: MessageResponse[]; has_more: boolean }>((resolve) => {
|
||||
resolveGetMessages = resolve;
|
||||
}),
|
||||
);
|
||||
cleanup = wireDispatcher(mock.ws, { listBlocks, getMessages });
|
||||
|
||||
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 }));
|
||||
setMessages(1, [storedMessage(10)], false);
|
||||
setMessages(2, [storedMessage(20, 2)], false);
|
||||
const readyChannels = [
|
||||
{ id: 1, name: "general", type: "text" as const, category: null, position: 0 },
|
||||
{ id: 2, name: "other", type: "text" as const, category: null, position: 0 },
|
||||
];
|
||||
|
||||
// First ready: initial connect.
|
||||
mock.dispatch("ready", {
|
||||
channels: readyChannels,
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
|
||||
// Second ready: a full-ready resync. The refetch for channel 1 (the
|
||||
// active channel at the time) starts but does not resolve yet.
|
||||
mock.dispatch("ready", {
|
||||
channels: readyChannels,
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
expect(getMessages).toHaveBeenCalledWith(1, { limit: 50 });
|
||||
expect(isChannelLoaded(1)).toBe(false);
|
||||
|
||||
// The user switches to channel 2 before that refetch resolves.
|
||||
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 2 }));
|
||||
|
||||
// The stale channel-1 refetch now resolves with its pre-resync-era
|
||||
// snapshot.
|
||||
resolveGetMessages!({ messages: [storedMessage(900)], has_more: false });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// Channel 1 must stay invalidated — writing the stale snapshot here
|
||||
// would re-add it to loadedChannels, permanently hiding every message
|
||||
// posted to it while the user was looking at channel 2.
|
||||
expect(isChannelLoaded(1)).toBe(false);
|
||||
expect(getChannelMessages(1)).toEqual([]);
|
||||
});
|
||||
|
||||
// BUG: invalidateLoadedMessageWindows() ran unconditionally, but the
|
||||
// refetch below it only runs when there's a resolvable active channel
|
||||
// AND api.getMessages exists (api is a Partial<...>, so it may be
|
||||
@@ -3588,6 +3653,33 @@ describe("WS Dispatcher", () => {
|
||||
expect(voiceStore.getState().currentChannelId).toBe(5);
|
||||
expect(voiceStore.getState().voiceStatus).toBe("connected");
|
||||
});
|
||||
|
||||
// OC-0224: CHANNEL_FULL is not the only refusal voice_join can get back.
|
||||
// handleVoiceJoin also answers with VOICE_ERROR (LiveKit down/unconfigured),
|
||||
// FORBIDDEN (blocked / revoked CONNECT_VOICE), NOT_FOUND, BAD_REQUEST
|
||||
// (archived channel), RATE_LIMITED, ALREADY_JOINED, and INTERNAL (token
|
||||
// mint failure) — every one of those used to fall through to the
|
||||
// catch-all with no rollback, leaving voiceStatus stuck at "joining"
|
||||
// forever (setVoiceStatus("idle") only ever runs inside
|
||||
// LiveKitSession.leaveVoice(), and a refused first-time join gets no
|
||||
// voice_leave to trigger it).
|
||||
it("rolls back the optimistic join on a voice_join refusal other than CHANNEL_FULL", () => {
|
||||
voiceStore.setState((prev) => ({ ...prev, currentChannelId: 5, voiceStatus: "joining" }));
|
||||
|
||||
mock.dispatch("error", { code: "VOICE_ERROR", message: "voice is not configured" });
|
||||
|
||||
expect(voiceStore.getState().currentChannelId).toBeNull();
|
||||
expect(voiceStore.getState().voiceStatus).toBe("idle");
|
||||
});
|
||||
|
||||
it("rolls back the optimistic join on FORBIDDEN (revoked CONNECT_VOICE / blocked)", () => {
|
||||
voiceStore.setState((prev) => ({ ...prev, currentChannelId: 5, voiceStatus: "joining" }));
|
||||
|
||||
mock.dispatch("error", { code: "FORBIDDEN", message: "missing CONNECT_VOICE permission" });
|
||||
|
||||
expect(voiceStore.getState().currentChannelId).toBeNull();
|
||||
expect(voiceStore.getState().voiceStatus).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
// A server refusal of voice_camera/voice_screenshare (FORBIDDEN,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// jsdom does not provide ResizeObserver — stub it so MessageList can mount.
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe(): void {
|
||||
/* noop */
|
||||
}
|
||||
unobserve(): void {
|
||||
/* noop */
|
||||
}
|
||||
disconnect(): void {
|
||||
/* noop */
|
||||
}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
import { createMessageList } from "@components/MessageList";
|
||||
import type { MessageListOptions } from "@components/MessageList";
|
||||
import { messagesStore } from "@stores/messages.store";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import type { Message } from "@stores/messages.store";
|
||||
|
||||
// Unique markers so the offsetHeight stub below can tell the two unconfirmed
|
||||
// (id: 0) optimistic rows apart by content, since both currently collide on
|
||||
// the cache key "msg-0".
|
||||
const MARK_A = "ZZMARKAAA";
|
||||
const MARK_B = "ZZMARKBBB";
|
||||
const HEIGHT_A = 111;
|
||||
const HEIGHT_B = 222;
|
||||
const HEIGHT_DEFAULT = 10;
|
||||
|
||||
function resetStores(): void {
|
||||
messagesStore.setState(() => ({
|
||||
messagesByChannel: new Map(),
|
||||
pendingSends: new Map(),
|
||||
loadedChannels: new Set(),
|
||||
hasMore: new Map(),
|
||||
historyLoadState: new Map(),
|
||||
detachedChannels: new Set(),
|
||||
}));
|
||||
membersStore.setState(() => ({
|
||||
members: new Map(),
|
||||
typingUsers: new Map(),
|
||||
}));
|
||||
}
|
||||
|
||||
function makeMessage(overrides: Partial<Message> & { id: number }): Message {
|
||||
return {
|
||||
channelId: 1,
|
||||
user: { id: 1, username: "Alice", avatar: null },
|
||||
content: `Message ${overrides.id}`,
|
||||
replyTo: null,
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
pinned: false,
|
||||
editedAt: null,
|
||||
deleted: false,
|
||||
timestamp: "2024-01-15T12:00:00Z",
|
||||
status: "sent",
|
||||
correlationId: null,
|
||||
errorCode: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setMessages(channelId: number, messages: Message[]): void {
|
||||
messagesStore.setState((prev) => {
|
||||
const next = new Map(prev.messagesByChannel);
|
||||
next.set(channelId, messages);
|
||||
return { ...prev, messagesByChannel: next };
|
||||
});
|
||||
}
|
||||
|
||||
describe("MessageList height cache key for unconfirmed optimistic rows", () => {
|
||||
let container: HTMLDivElement;
|
||||
let msgList: ReturnType<typeof createMessageList>;
|
||||
let options: MessageListOptions;
|
||||
let offsetHeightDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
options = {
|
||||
channelId: 1,
|
||||
channelName: "general",
|
||||
currentUserId: 1,
|
||||
onScrollTop: vi.fn(),
|
||||
onReplyClick: vi.fn(),
|
||||
onEditClick: vi.fn(),
|
||||
onDeleteClick: vi.fn(),
|
||||
onReactionClick: vi.fn(),
|
||||
onPinClick: vi.fn(),
|
||||
};
|
||||
|
||||
// jsdom never lays anything out, so offsetHeight is always 0. Stub it so
|
||||
// MessageList's real measurement path (measureRendered) has distinct,
|
||||
// deterministic heights to record for each top-level rendered item:
|
||||
// the two markers stand in for the two unconfirmed rows, everything
|
||||
// else (day dividers, confirmed messages) gets a uniform default.
|
||||
offsetHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
const text = this.textContent ?? "";
|
||||
if (text.includes(MARK_A)) return HEIGHT_A;
|
||||
if (text.includes(MARK_B)) return HEIGHT_B;
|
||||
return HEIGHT_DEFAULT;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
msgList.destroy?.();
|
||||
container.remove();
|
||||
if (offsetHeightDescriptor) {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", offsetHeightDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps two unconfirmed (id: 0) optimistic rows' measured heights distinct across a tree rebuild", () => {
|
||||
// Two unconfirmed optimistic rows, both id: 0 (as addOptimisticMessage
|
||||
// produces before confirmSend stamps a real id), distinguished only by
|
||||
// correlationId and content/measured height.
|
||||
const rowA = makeMessage({ id: 0, correlationId: "corr-a", content: MARK_A });
|
||||
const rowB = makeMessage({ id: 0, correlationId: "corr-b", content: MARK_B });
|
||||
const confirmed = Array.from({ length: 40 }, (_, i) =>
|
||||
makeMessage({ id: 1000 + i, content: `Confirmed ${i}` }),
|
||||
);
|
||||
|
||||
setMessages(1, [rowA, rowB, ...confirmed]);
|
||||
msgList = createMessageList(options);
|
||||
msgList.mount(container);
|
||||
|
||||
// Initial mount positions the render window at the tail (wasAtBottom is
|
||||
// always true in jsdom), so rowA/rowB are not yet in the DOM and not yet
|
||||
// measured. Force them into view and measured by jumping to rowA (id 0
|
||||
// resolves to the first such row) — OVERSCAN(20) around index 1 (rowA,
|
||||
// right after the single leading day divider) also covers rowB at index
|
||||
// 2, so both get real, distinct measurements written to the shared
|
||||
// height cache: heightCache["msg-0"] ends up holding rowB's height,
|
||||
// last-measured-wins, per the bug's own description.
|
||||
expect(msgList.scrollToMessage(0)).toBe(true);
|
||||
|
||||
// Now grow the channel at the tail while the render window is NOT at the
|
||||
// tail (renderedEnd stopped at rowA's OVERSCAN window, well short of the
|
||||
// 43-item list). This is a pure suffix extension, so it takes the fast
|
||||
// "tryAppendMessages" path, which re-seeds a fresh Fenwick tree from the
|
||||
// (colliding) height cache for every index WITHOUT re-rendering/
|
||||
// remeasuring rowA or rowB (they are outside the appended tail and the
|
||||
// window is not at the tail, so tryAppendMessages skips remeasurement).
|
||||
const grown = [
|
||||
rowA,
|
||||
rowB,
|
||||
...confirmed,
|
||||
...Array.from({ length: 5 }, (_, i) => makeMessage({ id: 2000 + i, content: `New ${i}` })),
|
||||
];
|
||||
setMessages(1, grown);
|
||||
messagesStore.flush();
|
||||
|
||||
// A confirmed message that was already measured (index 3..21 window,
|
||||
// i.e. one of the first 19 "confirmed" rows) sits after both rowA and
|
||||
// rowB. Its offset-before is the sum of every item ahead of it: the one
|
||||
// leading day divider (HEIGHT_DEFAULT) + rowA + rowB + N confirmed rows
|
||||
// at HEIGHT_DEFAULT each. If the cache collision corrupted rowA's slot
|
||||
// in the tree, that offset is inflated by (HEIGHT_B - HEIGHT_A).
|
||||
const target = confirmed[5]!; // id 1005, virtual index 3 + 5 = 8
|
||||
expect(msgList.scrollToMessage(target.id)).toBe(true);
|
||||
|
||||
const root = container.querySelector(".messages-container") as HTMLDivElement;
|
||||
const expectedCorrect = HEIGHT_DEFAULT + HEIGHT_A + HEIGHT_B + 5 * HEIGHT_DEFAULT;
|
||||
|
||||
// This is the assertion the bug breaks: with the shared "msg-0" cache
|
||||
// key, rowA's tree slot gets re-seeded from rowB's cached height instead
|
||||
// of its own, inflating the offset by (HEIGHT_B - HEIGHT_A) = 111.
|
||||
expect(root.scrollTop).toBe(expectedCorrect);
|
||||
});
|
||||
});
|
||||
@@ -127,6 +127,47 @@ describe("settings/helpers", () => {
|
||||
expect(root.style.getPropertyValue("--bg-primary")).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("switching away from light clears the light-only tokens instead of leaving them stuck on <html>", () => {
|
||||
// OC-0201: applyTheme only ever *sets* the keys present in the new
|
||||
// theme and never clears keys the previous theme set. THEMES.light
|
||||
// defines ~24 custom properties while dark/midnight/neon-glow define
|
||||
// only 4, so switching light -> dark must leave zero light-only
|
||||
// tokens behind on document.documentElement.
|
||||
applyTheme("light");
|
||||
const root = document.documentElement;
|
||||
expect(root.style.getPropertyValue("--bg-input")).toBe("#ebedef");
|
||||
|
||||
applyTheme("dark");
|
||||
|
||||
// The 4 keys dark actually owns must reflect dark's values.
|
||||
expect(root.style.getPropertyValue("--bg-primary")).toBe("#313338");
|
||||
expect(root.style.getPropertyValue("--text-normal")).toBe("#dbdee1");
|
||||
|
||||
// Every light-only token must be cleared, not left stuck at its
|
||||
// light-mode value (which would outrank the :root CSS default via
|
||||
// inline-style specificity).
|
||||
const lightOnlyKeys = Object.keys(THEMES.light).filter((k) => !(k in THEMES.dark));
|
||||
expect(lightOnlyKeys.length).toBeGreaterThan(0);
|
||||
for (const key of lightOnlyKeys) {
|
||||
expect(
|
||||
root.style.getPropertyValue(key),
|
||||
`${key} must be cleared after switching to dark`,
|
||||
).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not clear unrelated inline custom properties like --accent or --font-size", () => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--accent", "#00c8ff");
|
||||
root.style.setProperty("--font-size", "18px");
|
||||
|
||||
applyTheme("light");
|
||||
applyTheme("dark");
|
||||
|
||||
expect(root.style.getPropertyValue("--accent")).toBe("#00c8ff");
|
||||
expect(root.style.getPropertyValue("--font-size")).toBe("18px");
|
||||
});
|
||||
|
||||
it("light theme overrides the dark-mode input/border/interactive tokens so composer and form fields aren't dark-on-dark", () => {
|
||||
// OC-0043: the light theme only overrode 4 of ~45 tokens. --bg-input
|
||||
// (used by .message-input-box, .msg-textarea, .form-input, .reply-bar-inner)
|
||||
|
||||
@@ -308,6 +308,10 @@ function defaultOpts(): SidebarAreaOptions {
|
||||
voice: { tryConsume: vi.fn().mockReturnValue(true) },
|
||||
voiceVideo: { tryConsume: vi.fn().mockReturnValue(true) },
|
||||
} as unknown as SidebarAreaOptions["limiters"],
|
||||
presenceSender: {
|
||||
send: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
} as unknown as SidebarAreaOptions["presenceSender"],
|
||||
getRoot: vi.fn().mockReturnValue(document.createElement("div")),
|
||||
getToast: vi.fn().mockReturnValue({ show: vi.fn() }),
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ import { uiStore, setConnectionStatus } from "@stores/ui.store";
|
||||
|
||||
import { createUserBar } from "@components/UserBar";
|
||||
import { loadUserStatus, saveUserStatus } from "@lib/userStatus";
|
||||
import { createPresenceSender } from "@lib/presence";
|
||||
import { createPresenceLimiter } from "@lib/rate-limiter";
|
||||
import type { WsClient } from "@lib/ws";
|
||||
|
||||
function setAuthState(user: { username: string } | null, isAuthenticated: boolean): void {
|
||||
@@ -21,6 +23,15 @@ function setAuthState(user: { username: string } | null, isAuthenticated: boolea
|
||||
}));
|
||||
}
|
||||
|
||||
/** A ws plus a real (unconsumed) presence sender bound to it — what
|
||||
* SidebarArea actually threads into UserBar in production. */
|
||||
function userBarOptsWithPresence(ws: WsClient): {
|
||||
ws: WsClient;
|
||||
presenceSender: ReturnType<typeof createPresenceSender>;
|
||||
} {
|
||||
return { ws, presenceSender: createPresenceSender(ws, createPresenceLimiter()) };
|
||||
}
|
||||
|
||||
function createMockWs(state: "connected" | "disconnected" = "connected"): WsClient {
|
||||
let currentState = state;
|
||||
const stateListeners = new Set<(s: string) => void>();
|
||||
@@ -90,7 +101,7 @@ describe("StatusPicker wired to UserBar", () => {
|
||||
it("selecting a status sends presence_update WS message", () => {
|
||||
setAuthState({ username: "alice" }, true);
|
||||
const ws = createMockWs("connected");
|
||||
comp = createUserBar({ ws });
|
||||
comp = createUserBar(userBarOptsWithPresence(ws));
|
||||
comp.mount(container);
|
||||
|
||||
// Open picker
|
||||
@@ -108,6 +119,50 @@ describe("StatusPicker wired to UserBar", () => {
|
||||
expect(sentMsg.payload.status).toBe("idle");
|
||||
});
|
||||
|
||||
// OC-0210: every other presence producer (auto-idle, the settings Account
|
||||
// tab) shares one PresenceSender/RateLimiter through MainPage so a frame
|
||||
// the server's 1-per-10s presence limiter (service/channel.go) drops gets
|
||||
// retried instead of lost. The UserBar picker must go through that same
|
||||
// shared sender, not straight to ws.send, or a token another producer just
|
||||
// spent makes the server silently drop the picker's frame with nothing to
|
||||
// correct it.
|
||||
it("queues (does not drop) a status change when the shared presence limiter's window is already closed", () => {
|
||||
setAuthState({ username: "alice" }, true);
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const ws = createMockWs("connected");
|
||||
// The same PresenceSender instance MainPage threads to every producer
|
||||
// — pre-spend its single token exactly as auto-idle or the settings
|
||||
// tab would moments before the user opens the picker.
|
||||
const presenceSender = createPresenceSender(ws, createPresenceLimiter());
|
||||
presenceSender.send("idle");
|
||||
expect(ws.send).toHaveBeenCalledOnce();
|
||||
(ws.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
comp = createUserBar({ ws, presenceSender });
|
||||
comp.mount(container);
|
||||
|
||||
const dot = container.querySelector(".status-picker-dot") as HTMLElement;
|
||||
dot.click();
|
||||
const options = container.querySelectorAll(".status-picker-option");
|
||||
(options[0] as HTMLElement).click(); // "Online"
|
||||
|
||||
// The server's window is still closed — the frame must be queued, not
|
||||
// sent straight down the socket and lost if it's rejected.
|
||||
expect(ws.send).not.toHaveBeenCalled();
|
||||
|
||||
// Once the window reopens, the queued change must still go out.
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
expect(ws.send).toHaveBeenCalledOnce();
|
||||
const sentMsg = (ws.send as ReturnType<typeof vi.fn>).mock.calls[0]![0];
|
||||
expect(sentMsg.type).toBe("presence_update");
|
||||
expect(sentMsg.payload.status).toBe("online");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("status picker is disabled when WS is disconnected", () => {
|
||||
setAuthState({ username: "alice" }, true);
|
||||
setConnectionStatus("disconnected");
|
||||
@@ -124,7 +179,7 @@ describe("StatusPicker wired to UserBar", () => {
|
||||
it("status picker reacts to a connection status change through the store", async () => {
|
||||
setAuthState({ username: "alice" }, true);
|
||||
const ws = createMockWs("connected");
|
||||
comp = createUserBar({ ws });
|
||||
comp = createUserBar(userBarOptsWithPresence(ws));
|
||||
comp.mount(container);
|
||||
|
||||
const wrap = container.querySelector("[data-testid='status-picker-wrap']") as HTMLElement;
|
||||
|
||||
@@ -136,3 +136,45 @@ describe("createUpdateNotifier download progress", () => {
|
||||
expect(unhandled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deferred check timer lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createUpdateNotifier deferred check timer", () => {
|
||||
let host: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
mockCheckForUpdate.mockResolvedValue({ available: false, version: null, body: null });
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("does not check for updates when destroyed before the delayed check fires", async () => {
|
||||
const notifier = createUpdateNotifier({ serverUrl: "https://s.example" });
|
||||
notifier.mount(host);
|
||||
|
||||
// Page swap / logout tears the component down inside the 3s window.
|
||||
notifier.destroy?.();
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
expect(mockCheckForUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still checks for updates when the component stays mounted", async () => {
|
||||
const notifier = createUpdateNotifier({ serverUrl: "https://s.example" });
|
||||
notifier.mount(host);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
expect(mockCheckForUpdate).toHaveBeenCalledWith("https://s.example");
|
||||
notifier.destroy?.();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -849,4 +849,55 @@ describe("VoiceWidget", () => {
|
||||
|
||||
widget.destroy?.();
|
||||
});
|
||||
|
||||
// OC-0225: the Grant-Microphone retry's `.finally` used to hardcode
|
||||
// `grantMicBtn.disabled = false`, undoing updateFrozen's socket-down
|
||||
// freeze if the WS socket dropped while the permission request was in
|
||||
// flight.
|
||||
it("keeps 'Grant Microphone' frozen if the WS socket drops while a mic request is in flight", async () => {
|
||||
setVoiceChannel(1, []);
|
||||
voiceStore.setState((prev) => ({ ...prev, listenOnly: true }));
|
||||
|
||||
let resolveMic: () => void = () => {};
|
||||
mockRetryMicPermission.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveMic = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const widget = createVoiceWidget({
|
||||
onDisconnect: vi.fn(),
|
||||
onMuteToggle: vi.fn(),
|
||||
onDeafenToggle: vi.fn(),
|
||||
onCameraToggle: vi.fn(),
|
||||
onScreenshareToggle: vi.fn(),
|
||||
});
|
||||
widget.mount(container);
|
||||
|
||||
const grantBtn = container.querySelector(".vw-grant-mic") as HTMLButtonElement;
|
||||
grantBtn.click();
|
||||
expect(grantBtn.disabled).toBe(true);
|
||||
|
||||
// WS socket drops while the permission request (OS/browser prompt) is
|
||||
// still pending.
|
||||
setConnectionStatus("reconnecting");
|
||||
uiStore.flush();
|
||||
expect(grantBtn.disabled).toBe(true);
|
||||
expect(grantBtn.title).toBe("Reconnecting…");
|
||||
|
||||
// Permission request settles (retryMicPermission always resolves, even
|
||||
// on a denied prompt, per its internal try/catch).
|
||||
resolveMic();
|
||||
await vi.waitFor(() => {
|
||||
expect(grantBtn.textContent).toBe("Grant Microphone");
|
||||
});
|
||||
|
||||
// The socket is still down: the button must stay frozen with the
|
||||
// reconnecting reason, not silently re-enabled.
|
||||
expect(grantBtn.disabled).toBe(true);
|
||||
expect(grantBtn.title).toBe("Reconnecting…");
|
||||
|
||||
widget.destroy?.();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -743,6 +743,103 @@ describe("disconnect() cancelling an in-flight connect()", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("overlapping connect() attempts (OC-0219)", () => {
|
||||
let client: ReturnType<typeof createWsClient>;
|
||||
let originalMockListenImpl: (typeof mockListen)["getMockImplementation"] extends () => infer R
|
||||
? R
|
||||
: never;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockInvoke.mockReset();
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
originalMockListenImpl = mockListen.getMockImplementation()!;
|
||||
mockListen.mockClear();
|
||||
eventHandlers.clear();
|
||||
client = createWsClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockListen.mockImplementation(originalMockListenImpl!);
|
||||
client.disconnect();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// OC-0219: eventUnsubs is a single client-scoped array shared by every
|
||||
// connect() attempt. If a stale attempt A resumes inside setupEventListeners()
|
||||
// after a newer attempt B has already registered its own listeners into that
|
||||
// same shared array, A's stale-branch cleanup must tear down only the
|
||||
// listeners A itself just registered — not B's. Otherwise B's connection
|
||||
// opens with nobody listening: no auth frame is ever sent, ws-state "closed"
|
||||
// is never observed either, and the socket wedges with no reconnect.
|
||||
it("does not tear down a newer connect()'s listeners when a stale attempt's setupEventListeners resumes later", async () => {
|
||||
let releaseFirstMsgListen: (() => void) | null = null;
|
||||
let firstMsgListenSeen = false;
|
||||
|
||||
mockListen.mockImplementation(
|
||||
async (event: string, handler: (e: { payload: unknown }) => void) => {
|
||||
if (event === "ws-message" && !firstMsgListenSeen) {
|
||||
firstMsgListenSeen = true;
|
||||
// Pause attempt A here — mirrors A being suspended inside
|
||||
// setupEventListeners()'s Tauri IPC round trips while a newer
|
||||
// connect() attempt B runs all the way to completion.
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirstMsgListen = resolve;
|
||||
});
|
||||
}
|
||||
return originalMockListenImpl!(event, handler);
|
||||
},
|
||||
);
|
||||
|
||||
// Attempt A: suspends inside its first tauriListen("ws-message", ...) call.
|
||||
client.connect({ host: "localhost:8443", token: "tA" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(releaseFirstMsgListen).not.toBeNull();
|
||||
|
||||
// Attempt B supersedes A (e.g. a reconnect timer firing alongside a
|
||||
// fresh connect()) and runs to completion — registers its own listeners
|
||||
// and calls ws_connect — while A is still suspended.
|
||||
client.connect({ host: "localhost:8443", token: "tB" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Resume A. It notices it is stale (gen mismatch) and tears down
|
||||
// listeners — this must remove only the listeners it just registered,
|
||||
// not B's live ones.
|
||||
releaseFirstMsgListen!();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// B's underlying (mock) connection now reports open. If A's stale
|
||||
// cleanup wiped B's ws-state listener, nothing observes this and the
|
||||
// auth frame is never sent.
|
||||
emitTauriEvent("ws-state", "open");
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
const authSends = mockInvoke.mock.calls.filter(
|
||||
(c) =>
|
||||
c[0] === "ws_send" &&
|
||||
typeof c[1]?.message === "string" &&
|
||||
(c[1].message as string).includes('"type":"auth"'),
|
||||
);
|
||||
expect(authSends.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Complete the handshake and confirm B reaches "connected" — proof its
|
||||
// ws-message listener also survived A's stale cleanup.
|
||||
emitTauriEvent(
|
||||
"ws-message",
|
||||
JSON.stringify({
|
||||
type: "auth_ok",
|
||||
payload: {
|
||||
user: { id: 1, username: "b", avatar: null, role: "admin" },
|
||||
server_name: "S",
|
||||
motd: "",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(client.getState()).toBe("connected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("heartbeat proxyOpen guard", () => {
|
||||
let client: ReturnType<typeof createWsClient>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user