Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-session-subscription-cap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix the web UI becoming sluggish after opening many sessions.
13 changes: 8 additions & 5 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export interface UseWorkspaceStateDeps {
nextOptimisticMsgId: () => string;
getEventConn: () => KimiEventConnection | null;
syncSessionFromSnapshot: (sessionId: string) => Promise<SyncSessionResult>;
subscribeToSessionEvents: (sessionId: string) => void;
reopenSession: (sessionId: string) => Promise<SyncSessionResult>;
hasLoadedMessages: (sessionId: string) => boolean;
refreshSessionStatus: (sessionId: string) => Promise<void>;
persistSessionProfile: (patch: PersistSessionProfilePatch) => void;
Expand Down Expand Up @@ -166,7 +166,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
nextOptimisticMsgId,
getEventConn,
syncSessionFromSnapshot,
subscribeToSessionEvents,
reopenSession,
hasLoadedMessages,
refreshSessionStatus,
persistSessionProfile,
Expand Down Expand Up @@ -959,9 +959,12 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
const result = await syncSessionFromSnapshot(sessionId);
if (result === 'not-found') return;
} else {
// Re-open: resume from the tracked cursor; the daemon replays any
// missed durable events (or answers resync_required → snapshot).
subscribeToSessionEvents(sessionId);
// Re-open: if the session was evicted from the subscription cap since
// last open, reopenSession rebuilds it from a snapshot (the kept cursor
// may have skipped per-session events); otherwise it re-subscribes from
// the tracked cursor and the daemon replays any missed durable events.
const result = await reopenSession(sessionId);
if (result === 'not-found') return;
}

// Refresh sidecars AFTER the snapshot settles so status/usage updates
Expand Down
84 changes: 83 additions & 1 deletion apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ function forgetSession(sessionId: string): void {
// buffered event for this id would otherwise be reduced and recreate the very
// per-session maps we are about to delete.
eventConn?.unsubscribe(sessionId);
dropWsSubscription(sessionId);
// Drain the streaming-event batcher too. unsubscribe() stops future server
// frames, but events already queued for the next animation frame would
// otherwise survive and be reduced AFTER the maps below are cleared —
Expand Down Expand Up @@ -1158,7 +1159,9 @@ async function syncSessionFromSnapshot(sessionId: string): Promise<SyncSessionRe
// before live deltas (aligned by wire offset) start appending to it.
eventConn.seedSnapshot(sessionId, snap);
eventConn.subscribe(sessionId, { seq: snap.asOfSeq, epoch: snap.epoch });
retainWsSubscription(sessionId);
}
sessionsWithStaleCursor.delete(sessionId);
void pullSessionWarnings(sessionId);
return 'ok';
} catch (err) {
Expand All @@ -1181,6 +1184,65 @@ function hasLoadedMessages(sessionId: string): boolean {
return Object.prototype.hasOwnProperty.call(rawState.messagesBySession, sessionId);
}

// ---------------------------------------------------------------------------
// WS subscription cap (LRU eviction)
// ---------------------------------------------------------------------------
//
// Every opened session subscribes to its WS event stream, and the socket keeps
// subscriptions across reconnects (re-sending them in `client_hello`). Without
// a cap, a user who has opened hundreds of sessions stays subscribed to all of
// them: every background session's status/meta/usage event then flows through
// the reducer and dirties the sidebar computeds — the root cause of "the UI
// gets sluggish once I have a lot of sessions".
//
// Keep only the most-recently-opened sessions subscribed (MRU order, index 0 =
// newest). The active session is always retained.
//
// Eviction drops the live WS subscription but keeps the session's cursor so a
// quick re-open can resume cheaply. However, a cursor kept across an eviction
// can go stale: some session events (`event.session.status_changed`,
// `session.meta.updated`, ...) are broadcast to EVERY connection (see
// `isGlobalSessionEvent` on the server) and still advance `lastSeqBySession`
// for an unsubscribed session. If a session emits per-session durable events
// while evicted and then a global event, the cursor jumps past the missed
// events. Evicted sessions are therefore tracked in `sessionsWithStaleCursor`;
// when one is re-opened we rebuild from a snapshot (see `reopenSession`) rather
// than resume from a cursor that may have skipped events.
const MAX_WS_SUBSCRIPTIONS = 4;
const wsSubscriptionOrder: string[] = [];
const sessionsWithStaleCursor = new Set<string>();

function retainWsSubscription(sessionId: string): void {
const idx = wsSubscriptionOrder.indexOf(sessionId);
if (idx !== -1) wsSubscriptionOrder.splice(idx, 1);
wsSubscriptionOrder.unshift(sessionId);
// Evict the oldest entries past the cap, skipping the active session. The
// active session is NOT guaranteed to sit at the front: first-time opens only
// retain after an awaited snapshot, so rapid clicks can complete out of order
// and leave the active session at the tail. Skipping it (rather than breaking
// when the tail is active) keeps the cap effective.
while (wsSubscriptionOrder.length > MAX_WS_SUBSCRIPTIONS) {
let victimIdx = -1;
for (let i = wsSubscriptionOrder.length - 1; i >= 0; i--) {
if (wsSubscriptionOrder[i] !== rawState.activeSessionId) {
victimIdx = i;
break;
}
}
if (victimIdx === -1) break;
const [victim] = wsSubscriptionOrder.splice(victimIdx, 1);
if (victim === undefined) break;
eventConn?.unsubscribe(victim);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep blocking request events for evicted sessions

When a user has opened more than four sessions, this removes the LRU session from the server-side subscription entirely. The server only broadcasts isGlobalSessionEvent frames to all connections (packages/server/src/services/gateway/wsBroadcastService.ts:112-114), and approval/question request frames are not in that global set (packages/server/src/services/gateway/wsBroadcastService.ts:239-256), so an evicted background session that later reaches event.question.requested or event.approval.requested will not populate pending badges or trigger onQuestionRequested notifications/sounds until the user manually reopens it. That can leave an agent blocked without the existing attention signal in exactly the many-session scenario this cap targets.

Useful? React with 👍 / 👎.

sessionsWithStaleCursor.add(victim);
}
}

function dropWsSubscription(sessionId: string): void {
const idx = wsSubscriptionOrder.indexOf(sessionId);
if (idx !== -1) wsSubscriptionOrder.splice(idx, 1);
sessionsWithStaleCursor.delete(sessionId);
}

function subscribeToSessionEvents(sessionId: string): void {
connectEventsIfNeeded();
if (eventConn) {
Expand All @@ -1192,7 +1254,27 @@ function subscribeToSessionEvents(sessionId: string): void {
const seq = rawState.lastSeqBySession[sessionId] ?? 0;
const epoch = epochBySession[sessionId];
eventConn.subscribe(sessionId, { seq, epoch });
retainWsSubscription(sessionId);
}
}

/** Re-open an already-loaded session. If it was evicted from the subscription
* cap since it was last open, rebuild from a snapshot: resuming from the kept
* cursor would skip the per-session events that arrived while unsubscribed,
* and replaying from seq 0 would make the projector regenerate message ids and
* duplicate the already-loaded transcript. Otherwise just re-subscribe from the
* tracked cursor.
*
* The stale marker is only read here; `syncSessionFromSnapshot` clears it once
* the snapshot succeeds. If the snapshot fails transiently the marker stays, so
* the next re-open retries the snapshot instead of falling back to a cursor
* that may have skipped events while unsubscribed. */
async function reopenSession(sessionId: string): Promise<SyncSessionResult> {
if (sessionsWithStaleCursor.has(sessionId)) {
return syncSessionFromSnapshot(sessionId);
}
subscribeToSessionEvents(sessionId);
return 'ok';
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2120,7 +2202,7 @@ const workspaceState = useWorkspaceState(rawState, {
nextOptimisticMsgId,
getEventConn: () => eventConn,
syncSessionFromSnapshot,
subscribeToSessionEvents,
reopenSession,
hasLoadedMessages,
refreshSessionStatus,
persistSessionProfile,
Expand Down
Loading