fix: batch of 22 correctness fixes across server and client (#1371)

* fix(voice): 4 defect(s) (OC-0008, OC-0009, OC-0042, OC-0080)

Guard LiveKit session state against supersession: bump the camera/screen
generation in leaveVoice and teardownForReconnect so an in-flight enable
discards its track, bail out of restoreLocalVoiceState when a newer room
claimed _room mid-await, and recheck isStateConnected in the auto-reconnect
tail.

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

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

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

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

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

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

* fix(service): 2 defect(s) (OC-0036, OC-0128)

* fix(voice): 2 defect(s) (OC-0038, OC-0065)

OC-0038: the LiveKit participant_left webhook cleared the leaver's own
client voice state before broadcasting voice_leave, so the broadcast
audience (READ_MESSAGES holders union still-in-the-room participants)
could no longer see them. Voice membership is gated on CONNECT_VOICE
alone, so a participant without READ_MESSAGES never learned the server
had torn down their call. Extracted finishVoiceLeave's audience logic
into broadcastVoiceEventWithLeaver and used it on the webhook path.

OC-0065: handleWebhookParticipantJoined OR'd a GetVoiceState read error
into the same branch as "no matching row", so a transient DB failure
ejected a legitimate participant from the SFU mid-call. Now the read
error is logged and the check skipped, matching sweepStaleVoiceStates.

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

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

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

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

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

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

* fix(client): 2 defect(s) (OC-0111, OC-0143)

OC-0111: retry a presence_update dropped by the 1-per-10s limiter once the
window reopens, so auto-idle's return-to-online does not leave the server
and every other client stuck on idle.

OC-0143: pass apiConfig.host to the DM profile sidebar so per-user notes
are scoped per server, matching channel mutes, the NSFW gate and volume.

* test(ws): align aborted-switch test with OC-0034 no-resurrect behavior

The fix agent rewrote this pre-existing test (it locked the buggy restore
path) but the prove agent left it out of c67d25ed; committed state alone
failed go test ./ws/ without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-08-14 16:15:32 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 8579cb5d91
commit 7be9ccd2f9
41 changed files with 1569 additions and 89 deletions
@@ -32,9 +32,15 @@ window.addEventListener("owncord:pref-change", ((e: CustomEvent<{ key: string }>
/** Module-level server host for resolving relative attachment URLs. */
let _serverHost: string | null = null;
/** Set the server host (called once from MainPage on connect). */
/** Set the server host (called once from MainPage on connect).
* Strips a trailing default-HTTPS ":443" and lowercases, mirroring
* normalizeHostForCertCompare in lib/ws.ts and cert_store_key in
* src-tauri/src/tofu.rs — config hosts are stored verbatim (e.g.
* "Example.COM:443") but WHATWG URL drops the default port for https:,
* so isServerUrl's host comparison must normalize the same way or a
* ":443"-suffixed host never matches its own resolved URLs. */
export function setServerHost(host: string): void {
_serverHost = host.toLowerCase();
_serverHost = host.replace(/:443$/, "").toLowerCase();
}
/** Resolve a potentially relative URL to a full URL using the server host. */
@@ -179,7 +179,13 @@ export function renderMessage(
opts: MessageListOptions,
signal: AbortSignal,
): HTMLDivElement {
if (msg.user.username === "System") {
// id 0 is the reserved sentinel for server-synthesized system rows (DB
// user ids are AUTOINCREMENT starting at 1, so no real account can ever
// hold it). Dispatching on the username alone let any account that
// registered the display name "System" render with no author, no role
// colour and no moderation controls — indistinguishable from a genuine
// server notice.
if (msg.user.id === 0 && msg.user.username === "System") {
return renderSystemMessage(msg);
}
@@ -39,6 +39,30 @@ export const THEMES = {
"--bg-secondary": "#f2f3f5",
"--bg-tertiary": "#e3e5e8",
"--text-normal": "#313338",
// OC-0043: the 4 keys above are all this theme used to set. Every other
// surface/text/border/interactive token then fell through to tokens.css's
// dark defaults, so widgets painting --text-normal (now dark) on top of
// e.g. --bg-input (still dark) rendered as unreadable dark-on-dark.
"--bg-input": "#ebedef",
"--bg-hover": "#e8e9ed",
"--bg-active": "#dcdfe4",
"--bg-modifier-hover": "rgba(0, 0, 0, 0.06)",
"--bg-modifier-active": "rgba(0, 0, 0, 0.08)",
"--bg-modifier-selected": "rgba(0, 0, 0, 0.1)",
"--text-muted": "#5c5e66",
"--text-faint": "#747f8d",
"--text-micro": "#949ba4",
"--header-primary": "#060607",
"--header-secondary": "#4e5058",
"--interactive-normal": "#4e5058",
"--interactive-hover": "#23272a",
"--interactive-active": "#000000",
"--interactive-muted": "#c7ccd1",
"--channel-icon": "#6d6f78",
"--border": "#e3e5e8",
"--border-strong": "#cbccd1",
"--scrollbar-thin-thumb": "#cdcfd4",
"--scrollbar-auto-thumb": "#cdcfd4",
},
} as const;
@@ -36,6 +36,7 @@ import {
enableScreenshare as doEnableScreenshare,
disableScreenshare as doDisableScreenshare,
stopManualScreenTracks,
bumpGeneration,
getLocalCameraStream as doGetLocalCameraStream,
getLocalScreenshareStream as doGetLocalScreenshareStream,
getRemoteVideoStream as doGetRemoteVideoStream,
@@ -344,6 +345,14 @@ export class LiveKitSession {
this.ws.send({ type: "voice_screenshare", payload: { enabled: false } });
}
}
// OC-0080: bump first, mirroring doDisableCamera/doDisableScreenshare
// — a concurrent enableCamera()/enableScreenshare() still awaiting
// device acquisition (getUserMedia/getDisplayMedia/publishTrack) when
// an unexpected disconnect fires must detect it was superseded and
// discard its track instead of publishing onto the room about to be
// torn down for auto-reconnect.
bumpGeneration(this._cameraState);
bumpGeneration(this._screenState);
// BUG-098: Stop leaked camera/screen tracks before room is nulled.
stopManualCameraTrack(this._cameraState, this._room);
stopManualScreenTracks(this._screenState, this._room);
@@ -575,6 +584,23 @@ export class LiveKitSession {
.catch((err) => log.debug("Failed to start audio after reconnect", err));
// oxlint-disable-next-line no-await-in-loop -- sequential reconnect: must restore voice state after connect
await this.restoreLocalVoiceState("reconnect");
// OC-0009: mirrors connectAndSetup's post-connect checkpoints
// (3/4/5) — this tail keeps awaiting (restoreLocalVoiceState,
// switchActiveDevice) after already installing "connected" into the
// shared state, so `reconnectSuperseded` (which expects "reconnecting")
// can no longer tell a still-current attempt from a superseded one.
// A newer connectAndSetup()/attemptAutoReconnect() may have since
// claimed `_state` for a different channel; isStateConnected() reads
// through a method call so it always sees the live value.
if (!this.isStateConnected(channelId)) {
log.info("Auto-reconnect: superseded after restoreLocalVoiceState — aborting tail", {
channelId,
});
this.disconnectSupersededLocalRoom(newRoom);
return;
}
// BUG-099: Reapply saved audio devices after reconnect (matches initial join path).
const savedInput = loadPref<string>("audioInputDevice", "");
if (savedInput) {
@@ -584,6 +610,15 @@ export class LiveKitSession {
log.warn("Reconnect: saved input device unavailable, using default", err);
}
}
if (!this.isStateConnected(channelId)) {
log.info("Auto-reconnect: superseded after audioinput switch — aborting tail", {
channelId,
});
this.disconnectSupersededLocalRoom(newRoom);
return;
}
const savedOutput = loadPref<string>("audioOutputDevice", "");
if (savedOutput) {
try {
@@ -592,6 +627,15 @@ export class LiveKitSession {
log.warn("Reconnect: saved output device unavailable, using default", err);
}
}
if (!this.isStateConnected(channelId)) {
log.info("Auto-reconnect: superseded after audiooutput switch — aborting tail", {
channelId,
});
this.disconnectSupersededLocalRoom(newRoom);
return;
}
this._audioPipeline.setupAudioPipeline();
this.reapplyMuteGain();
this.startTokenRefreshTimer();
@@ -875,6 +919,20 @@ export class LiveKitSession {
}
}
// OC-0008: setMicrophoneEnabled above can block for seconds on the
// browser's mic-permission prompt. If a newer session claimed `_room`
// while this call was suspended there (the user switched channels, or an
// auto-reconnect installed a fresh room), the writes below re-read
// `this._room` fresh (applyMicMuteState) instead of the `room` captured
// at the top of this call — applying THIS call's stale muted/deafened
// decision to that newer room would mute/unmute or resubscribe audio on
// a session that never asked for it. Bail out once the captured room is
// no longer the live one; the newer session owns its own state from here.
if (this._room !== room) {
log.info("restoreLocalVoiceState: superseded mid-call — discarding stale mute/deafen state");
return;
}
// Always enforce mute at the track level even if no pipeline exists yet.
// setMicrophoneEnabled(false) doesn't guarantee mediaStreamTrack.enabled=false,
// and renegotiation when a new participant joins can bring a track back alive.
@@ -1381,6 +1439,13 @@ export class LiveKitSession {
this.clearTokenRefreshTimer();
this._audioPipeline.teardownAudioPipeline();
this._eventHandlers.removeAutoplayUnlock();
// OC-0042: bump first, mirroring doDisableCamera/doDisableScreenshare —
// a concurrent enableCamera()/enableScreenshare() still awaiting device
// acquisition (getUserMedia/getDisplayMedia/publishTrack) when the user
// leaves voice must detect it was superseded and discard its track
// instead of publishing onto the room this leave already disconnected.
bumpGeneration(this._cameraState);
bumpGeneration(this._screenState);
// Clean up manually published tracks.
stopManualCameraTrack(this._cameraState, this._room);
stopManualScreenTracks(this._screenState, this._room);
+4 -1
View File
@@ -161,7 +161,10 @@ interface GenerationGuarded {
generation?: number;
}
function bumpGeneration(state: GenerationGuarded): void {
/** Exported so callers that stop manual tracks outside disableCamera/
* disableScreenshare (leaveVoice, teardownForReconnect) can supersede an
* in-flight enable() the same way — see the doc comment above. */
export function bumpGeneration(state: GenerationGuarded): void {
state.generation = (state.generation ?? 0) + 1;
}
+8
View File
@@ -256,6 +256,14 @@ export interface AuthOkPayload {
readonly user: UserWithRole;
readonly server_name: string;
readonly motd: string;
/**
* Which reconnection tier served this auth_ok — "none" (fresh connect or
* full re-sync), "buffer" (ring-buffer resume), or "db" (persisted-event
* resume). Absent on some older servers. ws.ts resets its lastSeq
* watermark on "none" since a full re-sync means the server's own seq
* counter may have restarted below the client's stale watermark.
*/
readonly replay_source?: "none" | "buffer" | "db";
}
export interface AuthErrorPayload {
+11
View File
@@ -312,6 +312,17 @@ export function createWsClient() {
lastSeq,
});
}
// A full re-sync ("none") means the server built this ready state from
// scratch — its own seq counter may have restarted below our stale
// watermark (event persistence disabled, pruned events table, restored
// DB). Keeping the old watermark would make every future reconnect
// request a range the server can silently satisfy as a complete resume
// once its counter climbs back through it, dropping the events in
// between. Reset so the next sequenced frame re-adopts the server's
// current epoch via the normal seq > lastSeq update (OC-0032).
if (msg.payload.replay_source === "none") {
lastSeq = 0;
}
// Clear dedup cache — replay is complete
replayDedup = null;
setState("connected");
+29 -8
View File
@@ -147,6 +147,11 @@ 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;
@@ -181,24 +186,35 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
const status = loadUserStatus();
const serverStatus = authStore.getState().user?.status;
if (serverStatus === status) return;
const userId = getCurrentUserId();
if (userId !== 0) {
updatePresence(userId, status);
}
if (limiters.presence.tryConsume()) {
ws.send({ type: "presence_update", payload: { status } });
}
applyPresence(status);
}
/** Send a presence change and reflect it locally. Shared by the settings
* tab, the user bar and the auto-idle timer so all three agree. */
* tab, the user bar and the auto-idle timer so all three agree.
*
* The presence limiter is 1 token per 10s, and auto-idle's return-to-
* 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. */
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());
}
}
@@ -259,6 +275,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
about: null,
joinDate: null,
},
host: apiConfig.host ?? "",
onClose: () => {
dmProfileSidebar?.destroy?.();
dmProfileSidebar = null;
@@ -787,6 +804,10 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
closeActiveLightbox();
autoIdle?.destroy();
autoIdle = null;
if (presenceRetry !== null) {
clearTimeout(presenceRetry);
presenceRetry = null;
}
channelCtrl?.destroyChannel();
channelCtrl = null;
@@ -27,11 +27,13 @@ export interface Member {
export interface MembersState {
readonly members: ReadonlyMap<number, Member>;
readonly typingUsers: ReadonlyMap<number, ReadonlySet<number>>; // channelId -> Set<userId>
/** Monotonic counter bumped only when membership or a member's role changes
* (setMembers/addMember/removeMember/updateMemberRole). Subscribers that
* only care about role composition (e.g. MessageList role colors) select
* this instead of rebuilding a role map on every presence/typing update.
* Optional only so the many inline test fixtures need not restate it. */
/** Monotonic counter bumped when membership, a member's role, or a member's
* profile (username/displayName/avatar) changes (setMembers/addMember/
* removeMember/updateMemberRole/updateMemberProfile). Subscribers that only
* care about identity, not presence/typing (e.g. MessageList repainting
* author names and avatars), select this instead of rebuilding on every
* presence/typing update. Optional only so the many inline test fixtures
* need not restate it. */
readonly roleRevision?: number;
}
@@ -149,7 +151,7 @@ export function updateMemberProfile(userId: number, patch: MemberProfilePatch):
? existing.identityPublicKey
: patch.identityPublicKey,
});
return { ...prev, members: next };
return { ...prev, members: next, roleRevision: (prev.roleRevision ?? 0) + 1 };
});
}
+10 -4
View File
@@ -5227,10 +5227,16 @@ ul.md-list-nested {
}
/* Accessibility: high contrast */
.high-contrast {
--text-normal: #ffffff;
--text-muted: #cccccc;
--bg-active: rgba(255, 255, 255, 0.15);
/* applyTheme() and applyThemeByName() write theme tokens as *inline* styles
on documentElement (built-in themes) and body (custom themes) -- an
inline declaration beats a plain class rule on the same element, so these
overrides must be !important and must target both elements or they're a
silent no-op. */
.high-contrast,
.high-contrast body {
--text-normal: #ffffff !important;
--text-muted: #cccccc !important;
--bg-active: rgba(255, 255, 255, 0.15) !important;
}
/* Accessibility: large font */
+1 -1
View File
@@ -19,7 +19,7 @@ body {
body {
font-family: var(--font-body);
font-size: 14px;
font-size: var(--font-size, 14px);
color: var(--text-normal);
background: var(--bg-tertiary);
-webkit-font-smoothing: antialiased;
@@ -0,0 +1,50 @@
// jsdom never applies app.css, so a computed-style assertion here would pass
// whether or not the rules exist (see status-picker-userbar.test.ts for the
// same pattern). Instead this pins the CSS *source*.
//
// applyTheme() (components/settings/helpers.ts) writes theme tokens --
// including --text-normal -- as an *inline* style on document.documentElement.
// An inline declaration always beats a plain class rule on the same element,
// so `.high-contrast { --text-normal: ... }` can never win against it: the
// High Contrast toggle's headline promise (pure-white body text) is a no-op
// unless the override is `!important`.
//
// applyThemeByName() (lib/themes.ts) does the same thing for custom themes,
// except it writes the inline override on document.body instead of
// documentElement -- so the override also needs a selector that reaches body
// while high-contrast is active, not just one that targets html.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
describe("high-contrast CSS overrides beat inline theme styles", () => {
const css = readFileSync(join(process.cwd(), "src/styles/app.css"), "utf8");
function highContrastRuleBody(): string {
const match = /\.high-contrast[^{]*\{([^}]*)\}/.exec(css);
expect(match, "expected a `.high-contrast { ... }` rule in app.css").not.toBeNull();
return match![1]!;
}
it("declares --text-normal, --text-muted, and --bg-active with !important", () => {
const body = highContrastRuleBody();
for (const prop of ["--text-normal", "--text-muted", "--bg-active"]) {
const declaration = new RegExp(`${prop}\\s*:[^;]*;`).exec(body);
expect(declaration, `expected a declaration for ${prop}`).not.toBeNull();
expect(
declaration![0],
`${prop} must be !important -- otherwise applyTheme()'s inline style on ` +
`documentElement always wins and the toggle does nothing`,
).toMatch(/!important/);
}
});
it("also reaches document.body, where custom-theme inline overrides live", () => {
expect(
css,
"expected a selector reaching body while .high-contrast is active (e.g. " +
"`.high-contrast body`), otherwise custom themes' inline body vars never see the override",
).toMatch(/\.high-contrast[^{,]*body\s*[{,]/);
});
});
@@ -43,6 +43,7 @@ vi.stubGlobal("indexedDB", {
import {
clearAttachmentCaches,
fetchImageAsDataUrl,
isTrustedServerUrl,
setServerHost,
} from "../../src/components/message-list/attachments";
@@ -98,4 +99,24 @@ describe("attachment fetch authentication", () => {
expect(ensureHttpProxyMock).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledWith("https://cdn.external.example/image.png");
});
it("still routes through the TOFU proxy with a bearer token when the host is stored with an explicit :443", async () => {
setServerHost("chat.example.com:443");
getTokenMock.mockReturnValue("session-token");
ensureHttpProxyMock.mockResolvedValue("http://127.0.0.1:49812");
fetchMock.mockResolvedValue(imageResponse());
const result = await fetchImageAsDataUrl("https://chat.example.com/api/v1/files/abc-789");
expect(result).not.toBeNull();
expect(ensureHttpProxyMock).toHaveBeenCalledWith("chat.example.com");
expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:49812/api/v1/files/abc-789", {
headers: { Authorization: "Bearer session-token" },
});
});
it("treats a :443-suffixed stored host as trusted for the port-less resolved URL", () => {
setServerHost("chat.example.com:443");
expect(isTrustedServerUrl("https://chat.example.com/api/v1/files/abc")).toBe(true);
});
});
@@ -0,0 +1,39 @@
// jsdom never applies base.css (see appearance-high-contrast.test.ts for the
// same pattern), so this pins the CSS *source* rather than computed style.
//
// Three code paths write the `--font-size` custom property:
// - applyStoredAppearance() (lib/appearance.ts) at startup
// - the Appearance-tab Font Size slider (components/settings/AppearanceTab.ts)
// - the Accessibility-tab "Large Font" toggle, via the `.large-font` class
// (app.css: `.large-font { --font-size: 18px; }`)
// but nothing in the stylesheets ever *reads* var(--font-size) -- base.css
// hardcodes `body { font-size: 14px }` as a literal. Both controls persist
// state and change nothing rendered.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
describe("body font-size consumes the --font-size custom property", () => {
const css = readFileSync(join(process.cwd(), "src/styles/base.css"), "utf8");
function bodyRuleBody(): string {
// The bare `body { ... }` rule (not the `html,\nbody { ... }` reset above
// it) -- require a preceding closing brace so we don't match mid-selector
// "html,\nbody {".
const match = /(?<=\})\s*body\s*\{([^}]*)\}/.exec(css);
expect(match, "expected a `body { ... }` rule in base.css").not.toBeNull();
return match![1]!;
}
it("declares font-size via var(--font-size, ...), not a hardcoded literal", () => {
const body = bodyRuleBody();
const declaration = /font-size\s*:[^;]*;/.exec(body);
expect(declaration, "expected a font-size declaration on body").not.toBeNull();
expect(
declaration![0],
"body's font-size must read var(--font-size, ...) -- otherwise the Font Size " +
"slider and the Large Font accessibility toggle write --font-size and nothing " +
"renders differently",
).toMatch(/var\(--font-size\b/);
});
});
@@ -27,6 +27,7 @@ const mockRoom = vi.hoisted(() => ({
setCameraEnabled: vi.fn().mockResolvedValue(undefined),
getTrackPublication: vi.fn().mockReturnValue(undefined),
unpublishTrack: vi.fn().mockResolvedValue(undefined),
publishTrack: vi.fn().mockResolvedValue(undefined),
trackPublications: new Map(),
identity: "user-1",
},
@@ -189,6 +190,7 @@ globalThis.Worker = vi.fn(function (this: { terminate: () => void }) {
}) as unknown as typeof Worker;
// Now import
import { createLocalVideoTrack } from "livekit-client";
import {
parseUserId,
LiveKitSession,
@@ -1224,6 +1226,74 @@ describe("LiveKitSession", () => {
});
});
describe("leaveVoice camera/screenshare generation guard (OC-0042)", () => {
it("discards a camera track whose device-acquisition await resolves after leaveVoice() ran", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
let resolveTrack!: (t: { kind: string; mediaStreamTrack: unknown; stop: () => void }) => void;
(createLocalVideoTrack as any).mockReturnValueOnce(
new Promise((resolve) => {
resolveTrack = resolve;
}),
);
// enableCamera() captures room + the pre-bump generation, then blocks
// on the camera permission prompt (createLocalVideoTrack).
const enabling = session.enableCamera();
await vi.advanceTimersByTimeAsync(0);
// The user leaves voice while that prompt is still pending.
session.leaveVoice(false);
// Permission is granted after the leave.
resolveTrack({ kind: "video", mediaStreamTrack: {}, stop: vi.fn() });
await enabling;
// Without a generation bump in leaveVoice(), the stale enable would
// still publish onto the room leaveVoice() already disconnected.
expect(mockRoom.localParticipant.publishTrack).not.toHaveBeenCalled();
});
});
describe("teardownForReconnect camera/screenshare generation guard (OC-0080)", () => {
it("discards a camera track whose device-acquisition await resolves after an unexpected disconnect", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
let disconnectedHandler: ((reason?: number) => void) | undefined;
mockRoom.on.mockImplementation((event: string, handler: any) => {
if (event === "disconnected") disconnectedHandler = handler;
return mockRoom;
});
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
expect(disconnectedHandler).toBeDefined();
let resolveTrack!: (t: { kind: string; mediaStreamTrack: unknown; stop: () => void }) => void;
(createLocalVideoTrack as any).mockReturnValueOnce(
new Promise((resolve) => {
resolveTrack = resolve;
}),
);
const enabling = session.enableCamera();
await vi.advanceTimersByTimeAsync(0);
// An unexpected disconnect fires teardownForReconnect while the camera
// permission prompt is still pending.
disconnectedHandler!(/* SERVER_SHUTDOWN */ 1);
resolveTrack({ kind: "video", mediaStreamTrack: {}, stop: vi.fn() });
await enabling;
// Without a generation bump in teardownForReconnect, the stale enable
// would publish onto the room being torn down for auto-reconnect.
expect(mockRoom.localParticipant.publishTrack).not.toHaveBeenCalled();
});
});
describe("handleDisconnected during initial connect", () => {
it("does not null the room when connecting flag is true", async () => {
session.setServerHost("localhost:7880");
@@ -1991,6 +2061,67 @@ describe("LiveKitSession", () => {
});
});
describe("restoreLocalVoiceState supersession guard (OC-0008)", () => {
it("does not apply a stale mute decision to a newer session's room", async () => {
mockVoiceState.localMuted = true;
mockVoiceState.localDeafened = false;
let resolveMic!: () => void;
const roomA = {
localParticipant: {
setMicrophoneEnabled: vi.fn(
() =>
new Promise<void>((resolve) => {
resolveMic = resolve;
}),
),
},
removeAllListeners: vi.fn(),
disconnect: vi.fn().mockResolvedValue(undefined),
} as any;
const roomB = {
localParticipant: {
setMicrophoneEnabled: vi.fn().mockResolvedValue(undefined),
},
removeAllListeners: vi.fn(),
disconnect: vi.fn().mockResolvedValue(undefined),
} as any;
(session as any)._state = {
type: "connected",
room: roomA,
channelId: 1,
latestToken: "token-a",
lastUrl: "/livekit",
lastDirectUrl: undefined,
};
const restoring = (session as any).restoreLocalVoiceState("join");
await vi.advanceTimersByTimeAsync(0);
expect(roomA.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
// A newer session takes over with a DIFFERENT room (e.g. the user
// switched channels while channel A's mic-permission call was pending).
(session as any)._state = {
type: "connected",
room: roomB,
channelId: 2,
latestToken: "token-b",
lastUrl: "/livekit",
lastDirectUrl: undefined,
};
resolveMic();
await restoring;
// Channel A's stale muted=true decision must not touch room B — that
// is exactly what applyMicMuteState(true) would do by re-reading
// `this._room` fresh instead of the room this call captured.
expect(roomB.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// Mutant-killing tests: delegation methods
// -----------------------------------------------------------------------
@@ -2897,6 +3028,73 @@ describe("LiveKitSession", () => {
});
});
describe("attemptAutoReconnect tail supersession guard (OC-0009)", () => {
// reconnectSuperseded() is only checked up through room.connect(); once
// the success branch sets state to "connected" it stops being usable
// (state is legitimately no longer "reconnecting" for a still-current
// attempt too). The tail must instead recheck via isStateConnected(),
// the same helper connectAndSetup's post-connect checkpoints use.
it("does not touch a newer session's timer or send a stale token refresh when superseded mid-tail", async () => {
(session as any)._state = {
type: "reconnecting",
channelId: 5,
latestToken: "reconnect-token",
lastUrl: "/livekit",
lastDirectUrl: "ws://localhost:7880",
ac: new AbortController(),
};
session.setServerHost("localhost:7880");
const sendSpy = vi.fn();
session.setWsClient({ send: sendSpy } as any);
let resolveMic!: () => void;
mockRoom.localParticipant.setMicrophoneEnabled.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveMic = resolve;
}),
);
const startTimerSpy = vi.spyOn(session as any, "startTokenRefreshTimer");
const ac = new AbortController();
const reconnectPromise = (session as any).attemptAutoReconnect(
"reconnect-token",
"/livekit",
5,
"ws://localhost:7880",
ac.signal,
);
// Pass the reconnect delay and let the attempt reach "connected" for
// channel 5, where it stalls inside restoreLocalVoiceState's mic-permission await.
await vi.advanceTimersByTimeAsync(3100);
expect((session as any)._state.type).toBe("connected");
expect((session as any)._state.channelId).toBe(5);
// A newer join supersedes it: the user switched to channel 9 while
// channel 5's reconnect tail was still stalled.
(session as any)._state = {
type: "connected",
room: mockRoom,
channelId: 9,
latestToken: "token-9",
lastUrl: "/livekit",
lastDirectUrl: "ws://localhost:7880",
};
sendSpy.mockClear();
resolveMic();
await reconnectPromise;
// The stale channel-5 tail must not re-arm the shared timer or send a
// token-refresh request against channel 9's live session.
expect(startTimerSpy).not.toHaveBeenCalled();
expect(sendSpy).not.toHaveBeenCalledWith({ type: "voice_token_refresh", payload: {} });
startTimerSpy.mockRestore();
});
});
describe("token refresh timer", () => {
// OC-0014: the server mints LiveKit tokens with a 5-minute TTL
// (Server/ws/livekit.go tokenTTL). If the client's only periodic
@@ -55,8 +55,17 @@ vi.mock("@lib/audioElements", () => ({
setAudioVolumeHost: mockSetAudioVolumeHost,
}));
const { capturedAutoIdleOptions } = vi.hoisted(() => ({
capturedAutoIdleOptions: {
current: null as null | { onStatusChange: (status: string) => void },
},
}));
vi.mock("@lib/autoIdle", () => ({
startAutoIdle: vi.fn(() => ({ destroy: vi.fn() })),
startAutoIdle: (options: { onStatusChange: (status: string) => void }) => {
capturedAutoIdleOptions.current = options;
return { destroy: vi.fn() };
},
}));
const {
@@ -162,6 +171,7 @@ import type { WsClient, WsListener, ConnectionState } from "../../src/lib/ws";
import type { ApiClient } from "../../src/lib/api";
import type { ServerMessage } from "../../src/lib/types";
import { openImageLightbox } from "../../src/components/message-list/media";
import { saveUserStatus } from "../../src/lib/userStatus";
function resetStores(): void {
channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] }));
@@ -520,4 +530,124 @@ describe("MainPage — video grid, DM profile panel, calls, settings", () => {
expect(document.body.querySelector(".image-lightbox")).toBeNull();
});
it("scopes DM profile notes to the connected host, like channel mutes and the NSFW gate (OC-0143)", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(60, dmChannel(60, "dm-carol"));
return { ...prev, channels: ch, activeChannelId: 60 };
});
dmStore.setState(() => ({
channels: [
{
channelId: 60,
recipient: { id: 5, username: "carol", avatar: "", status: "online" },
participants: [{ id: 5, username: "carol", avatar: "", status: "online" }],
name: "carol",
isGroup: false,
lastMessageId: null,
lastMessage: "",
lastMessageAt: "",
unreadCount: 0,
mentionCount: 0,
},
],
}));
const hostedApi = {
getConfig: () => ({ host: "chat.example.com" }),
getReactionUsers: vi.fn(async () => ({ users: [] })),
} as unknown as ApiClient;
page = createMainPage({ ws: fakeWs(), api: hostedApi });
page.mount(container);
const chatAreaOpts = mockCreateChatArea.mock.calls[0]![0];
chatAreaOpts.onToggleDmProfile();
const noteEl = capturedChatAreaRef.current!.dmProfileSlot.querySelector(
'[data-testid="dps-note"]',
) as HTMLTextAreaElement;
noteEl.value = "owes me money";
noteEl.dispatchEvent(new Event("input"));
try {
// Server A's note about user 5 must land under a key scoped to server A
// — the same host scoping already applied to channel mutes, the NSFW
// gate and per-user volume (setChannelMutesHost/setNsfwGateHost/
// setAudioVolumeHost, all called with apiConfig.host above).
expect(localStorage.getItem("owncord:dm-note:chat.example.com:5")).toBe("owes me money");
// And it must not have gone to the legacy unscoped key, which server
// B's unrelated user 5 would also read from.
expect(localStorage.getItem("owncord:dm-note:5")).toBeNull();
} finally {
localStorage.removeItem("owncord:dm-note:chat.example.com:5");
localStorage.removeItem("owncord:dm-note:5");
}
});
});
describe("MainPage — presence", () => {
let container: HTMLDivElement;
let page: ReturnType<typeof createMainPage>;
beforeEach(() => {
resetStores();
// Match the client's own idea of the signed-in user's status to the
// freshly-reset (default "online") saved preference, so the mount-time
// restoreSavedPresence() call (MainPage.ts:340) is the no-op it
// documents itself as being, and doesn't spend the single presence
// token before this test gets to exercise applyPresence directly.
authStore.setState((prev) => ({
...prev,
user: prev.user === null ? null : { ...prev.user, status: "online" },
}));
capturedAutoIdleOptions.current = null;
container = document.createElement("div");
document.body.appendChild(container);
vi.useFakeTimers();
});
afterEach(() => {
page?.destroy?.();
container.remove();
vi.useRealTimers();
localStorage.removeItem("owncord:settings:userStatus");
localStorage.removeItem("owncord:settings:userStatusOrigin");
});
it("retries the return-to-online presence_update once the limiter's window reopens instead of dropping it forever (OC-0111)", () => {
const ws = fakeWs();
page = createMainPage({ ws, api: fakeApi() });
page.mount(container);
const onStatusChange = capturedAutoIdleOptions.current!.onStatusChange;
// Auto-idle fires idle after ten quiet minutes; this consumes the single
// presence token (1 per 10s, rate-limiter.ts).
saveUserStatus("idle", "auto");
onStatusChange("idle");
expect(ws.send).toHaveBeenCalledWith({
type: "presence_update",
payload: { status: "idle" },
});
(ws.send as ReturnType<typeof vi.fn>).mockClear();
// The very first mouse event after that flips back to online milliseconds
// later (autoIdle.ts's unthrottled return-to-activity path) — the token
// is still gone, so the frame cannot go out immediately.
saveUserStatus("online", "manual");
onStatusChange("online");
expect(ws.send).not.toHaveBeenCalled();
// Once the limiter's 10s window reopens, the deferred "online" frame must
// still go out — without a retry the server and every other client stay
// stuck on "idle" forever with no further trigger to correct it.
vi.advanceTimersByTime(10_000);
expect(ws.send).toHaveBeenCalledWith({
type: "presence_update",
payload: { status: "online" },
});
});
});
@@ -5,6 +5,7 @@ import {
addMember,
removeMember,
updateMemberRole,
updateMemberProfile,
updatePresence,
setTyping,
clearTyping,
@@ -191,6 +192,35 @@ describe("members store", () => {
});
});
describe("updateMemberProfile", () => {
it("bumps roleRevision so MessageList's only members subscription re-renders", () => {
// MessageList subscribes solely to roleRevision (MessageList.ts:897-903)
// to know when to repaint rendered rows. A username/avatar/nickname
// change must bump it the same as every other mutator does, or a
// rename never repaints already-rendered messages.
setMembers([MEMBER_ALICE]);
const before = membersStore.getState().roleRevision ?? 0;
updateMemberProfile(1, { username: "alice2", avatar: "alice2.png" });
expect(membersStore.getState().roleRevision ?? 0).toBe(before + 1);
});
it("updates username, avatar, and displayName of an existing member", () => {
setMembers([MEMBER_ALICE]);
updateMemberProfile(1, { username: "alice2", avatar: "alice2.png", displayName: "Al" });
const member = membersStore.getState().members.get(1)!;
expect(member.username).toBe("alice2");
expect(member.avatar).toBe("alice2.png");
expect(member.displayName).toBe("Al");
});
it("returns same state for unknown userId", () => {
setMembers([MEMBER_ALICE]);
const before = membersStore.getState();
updateMemberProfile(999, { username: "ghost", avatar: null });
expect(membersStore.getState()).toBe(before);
});
});
describe("updatePresence", () => {
it("updates status of an existing member", () => {
setMembers([MEMBER_ALICE]);
@@ -814,6 +814,28 @@ describe("renderers", () => {
ac.abort();
});
it("does not treat a real registered user named 'System' as a system notice", () => {
// Real user ids are DB autoincrement and start at 1 — only the
// synthetic sentinel row (id 0) is a genuine system message. A user
// who registered the display name "System" must render normally,
// with an author name and full moderation controls.
const msg = makeMessage({
user: { id: 42, username: "System", avatar: null },
content: "Your session was flagged — re-enter your password at evil.example",
});
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(".system-msg")).toBeNull();
expect(container.querySelector(".msg-author")).not.toBeNull();
// The hover action bar (react/reply/pin/…) is only built for real
// messages — renderSystemMessage returns before it exists at all.
expect(container.querySelector('[data-testid="msg-reply-1"]')).not.toBeNull();
ac.abort();
});
});
// ---------------------------------------------------------------------------
@@ -126,6 +126,36 @@ describe("settings/helpers", () => {
const root = document.documentElement;
expect(root.style.getPropertyValue("--bg-primary")).toBe("#ffffff");
});
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)
// was never overridden, so it kept tokens.css's dark default while
// --text-normal flipped to dark text -- unreadable dark-on-dark.
applyTheme("light");
const root = document.documentElement;
const darkDefaults: Record<string, string> = {
"--bg-input": "#383a40",
"--bg-hover": "#35373c",
"--bg-active": "#404249",
"--border": "#3f4147",
"--border-strong": "#4e5058",
"--text-muted": "#949ba4",
"--text-faint": "#80848e",
"--text-micro": "#6d6f78",
"--header-primary": "#f2f3f5",
"--header-secondary": "#b5bac1",
"--interactive-normal": "#b5bac1",
"--interactive-hover": "#dbdee1",
"--interactive-active": "#fff",
"--interactive-muted": "#4e5058",
};
for (const [token, darkValue] of Object.entries(darkDefaults)) {
const applied = root.style.getPropertyValue(token);
expect(applied, `${token} must be set by the light theme`).not.toBe("");
expect(applied, `${token} must not keep its dark-mode value`).not.toBe(darkValue);
}
});
});
describe("THEMES", () => {
@@ -208,6 +208,148 @@ describe("lastSeq tracking", () => {
expect(authMsg.payload.last_seq).toBe(25);
});
it("resets lastSeq when auth_ok reports replay_source: none (full resync)", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
// Initial connect — lastSeq climbs to 5000 via live traffic (no seq on
// auth_ok itself, matching the real server: h.buildAuthOK never sets a
// top-level "seq" field).
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
replay_source: "none",
},
}),
);
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "chat_message",
seq: 5000,
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "hi",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
}),
);
// Socket drops; server restarted meanwhile with its seq counter reset
// (event_persistence.enabled=false), so the reconnect's replay tier is a
// full resync — auth_ok comes back with replay_source: "none" again.
emitTauriEvent("ws-state", "closed");
await vi.advanceTimersByTimeAsync(1100);
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: "",
replay_source: "none",
},
}),
);
// A subsequent drop must send last_seq=0 (adopting the server's new
// epoch), not the stale 5000 watermark from the old epoch.
emitTauriEvent("ws-state", "closed");
mockInvoke.mockClear();
await vi.advanceTimersByTimeAsync(2100); // 2nd attempt = 2s backoff
emitTauriEvent("ws-state", "open");
const authCall = mockInvoke.mock.calls.find(
(c) =>
c[0] === "ws_send" &&
typeof c[1]?.message === "string" &&
(c[1].message as string).includes('"type":"auth"'),
);
expect(authCall).toBeDefined();
const authMsg = JSON.parse((authCall![1] as { message: string }).message);
expect(authMsg.payload.last_seq).toBe(0);
});
it("does NOT reset lastSeq when auth_ok reports replay_source: buffer/db (real resume)", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
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: "",
replay_source: "none",
},
}),
);
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "chat_message",
seq: 42,
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "hi",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
}),
);
emitTauriEvent("ws-state", "closed");
await vi.advanceTimersByTimeAsync(1100);
emitTauriEvent("ws-state", "open");
// A real resume from the ring buffer/DB — must NOT reset lastSeq.
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
replay_source: "buffer",
},
}),
);
emitTauriEvent("ws-state", "closed");
mockInvoke.mockClear();
await vi.advanceTimersByTimeAsync(2100);
emitTauriEvent("ws-state", "open");
const authCall = mockInvoke.mock.calls.find(
(c) =>
c[0] === "ws_send" &&
typeof c[1]?.message === "string" &&
(c[1].message as string).includes('"type":"auth"'),
);
const authMsg = JSON.parse((authCall![1] as { message: string }).message);
expect(authMsg.payload.last_seq).toBe(42);
});
it("should reset lastSeq to 0 on intentional disconnect", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);