mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
- _wsState now transitions through the full lifecycle: _connectWs() sets 'connecting', ws.onopen (inside the this._ws === ws guard) sets 'connected', _disconnectWs() resets to 'disconnected' — the connection chip's "WS" state was previously unreachable (stuck on "WS…"/"HTTP" forever). - WS registry supersede is now keyed per TAB: the upgrade URL sends cid = clientId + ':' + per-page nonce (reusing the constructor's page UUID), while input frames keep the bare browser clientId for seq dedup — two tabs/windows on one session coexist instead of 4010-evicting each other in a perpetual 5s ping-pong; a genuine same-tab reconnect still supersedes. - Exponential backoff engages: _disconnectWs() no longer zeroes _wsReconnectAttempts (it's called at the top of _connectWs, so every retry replanned at attempt 0 → ~0ms tight reconnect loop during outages); onopen resets the counter on success. - styles.css: add .connection-dot.connected (green) and .connection-dot.fallback (yellow) — both states rendered an invisible dot (no rule existed). - Remove smuggled dead code: resolveMonitorRowLabels/CodemanMonitorLabels (COD-122, no consumer, referenced test doesn't exist) and the never-written _wsLastClose/_wsInputSendCount/_httpFallbackSendCount diagnostics. - Tests: new test/ws-state-lifecycle.test.ts drives the REAL _connectWs/onopen/onclose/timer cycle (state transitions, escalating backoff delays, composite cid on the upgrade URL); registry two-tab coexistence test; static check that every emitted connection-dot class has a styles.css rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
124 lines
5.0 KiB
TypeScript
124 lines
5.0 KiB
TypeScript
/**
|
|
* @fileoverview Per-session WebSocket connection registry (COD-137).
|
|
*
|
|
* Replaces the bare `Map<sessionId, number>` counter that previously gated
|
|
* `MAX_WS_PER_SESSION`. That counter had two defects:
|
|
*
|
|
* 1. Transient over-count on reconnect: a client that drops and immediately
|
|
* reconnects could land its new upgrade BEFORE the old socket's async
|
|
* `close` fired, so the count briefly exceeded the live connection number.
|
|
* A reconnect burst could hit the cap and the next upgrade was rejected
|
|
* with 4008 → the client fell back to HTTP. (The real spurious-4008 defect.)
|
|
* 2. No clientId scoping: the limit counted raw sockets, so a reconnecting
|
|
* client consumed a NEW slot instead of replacing its own.
|
|
*
|
|
* This registry tracks the live socket(s) per session keyed by a per-TAB
|
|
* connection identity (`cid`, parsed from the upgrade URL query). The browser
|
|
* sends `clientId:tabNonce`, NOT the bare localStorage clientId — that one is
|
|
* shared by every tab/window of a profile, so keying on it would make two tabs
|
|
* on one session evict each other in a 4010 ping-pong. A new upgrade for a
|
|
* `cid` that already holds a socket is a SUPERSEDE — the registry evicts the
|
|
* stale socket and reuses its slot, which makes a reconnect reclaim rather
|
|
* than double-count (fixes #1 and #2). The cid is opaque here; input-frame
|
|
* dedup uses the bare clientId separately (`session.shouldApplyInput`).
|
|
*
|
|
* Backward-compat: an upgrade with NO `cid` (legacy clients, other tools) is
|
|
* admitted anonymously — it counts toward the limit but never evicts another
|
|
* client, and several anonymous sockets can coexist up to the cap.
|
|
*
|
|
* The class is pure (no `ws`/Fastify imports) and generic over a minimal socket
|
|
* shape so it can be unit-tested with plain fakes. The route owns the actual
|
|
* socket close/terminate; the registry only decides admit/evict and tracks slots.
|
|
*/
|
|
|
|
/** Minimal socket shape the registry needs — satisfied by `ws` WebSocket. */
|
|
export interface RegistrableSocket {
|
|
/** Identity comparison only; never dereferenced beyond `===`. */
|
|
readonly readyState?: number;
|
|
}
|
|
|
|
export interface RegisterResult<S> {
|
|
/** Whether the new socket was admitted (false → caller should reject with 4008). */
|
|
admitted: boolean;
|
|
/**
|
|
* A stale socket whose slot the new socket reclaimed (same `cid`). The caller
|
|
* should close it. Present only on a keyed supersede; never set for anonymous
|
|
* upgrades or fresh slots.
|
|
*/
|
|
evictedSocket?: S;
|
|
}
|
|
|
|
/** A single live entry: the socket plus its clientId (null = anonymous). */
|
|
interface Entry<S> {
|
|
socket: S;
|
|
cid: string | null;
|
|
}
|
|
|
|
export class WsConnectionRegistry<S extends RegistrableSocket = RegistrableSocket> {
|
|
/** sessionId → live entries (keyed + anonymous). */
|
|
private readonly bySession = new Map<string, Entry<S>[]>();
|
|
|
|
constructor(private readonly maxPerSession: number) {}
|
|
|
|
/**
|
|
* Attempt to register a new socket for `(sessionId, cid)`.
|
|
*
|
|
* - cid present and already holds a socket → SUPERSEDE: evict the old one,
|
|
* reuse its slot, always admit.
|
|
* - otherwise → admit iff distinct-entry count < maxPerSession.
|
|
*
|
|
* A null/empty `cid` is anonymous: it never matches an existing entry and so
|
|
* never evicts; it just consumes a slot.
|
|
*/
|
|
register(sessionId: string, cid: string | null, socket: S): RegisterResult<S> {
|
|
const entries = this.bySession.get(sessionId) ?? [];
|
|
|
|
if (cid) {
|
|
const existingIdx = entries.findIndex((e) => e.cid === cid);
|
|
if (existingIdx !== -1) {
|
|
const evicted = entries[existingIdx].socket;
|
|
// Reuse the slot in place — no net change to the live count, so a
|
|
// reconnect can never be rejected by the cap.
|
|
entries[existingIdx] = { socket, cid };
|
|
this.bySession.set(sessionId, entries);
|
|
return { admitted: true, evictedSocket: evicted === socket ? undefined : evicted };
|
|
}
|
|
}
|
|
|
|
if (entries.length >= this.maxPerSession) {
|
|
return { admitted: false };
|
|
}
|
|
|
|
entries.push({ socket, cid: cid || null });
|
|
this.bySession.set(sessionId, entries);
|
|
return { admitted: true };
|
|
}
|
|
|
|
/**
|
|
* Remove a socket from its session. Idempotent — safe to call on `close`,
|
|
* `error`, AND eagerly on `terminate()` (the over-count fix relies on eager
|
|
* removal freeing the slot before the async `close` fires).
|
|
*
|
|
* Matches by socket identity, so a socket that was already superseded
|
|
* (replaced in-slot by a same-cid reconnect) is NOT removed by its late
|
|
* `close` — the new socket keeps the slot.
|
|
*/
|
|
unregister(sessionId: string, socket: S): void {
|
|
const entries = this.bySession.get(sessionId);
|
|
if (!entries) return;
|
|
const idx = entries.findIndex((e) => e.socket === socket);
|
|
if (idx === -1) return;
|
|
entries.splice(idx, 1);
|
|
if (entries.length === 0) {
|
|
this.bySession.delete(sessionId);
|
|
} else {
|
|
this.bySession.set(sessionId, entries);
|
|
}
|
|
}
|
|
|
|
/** Number of live entries for a session (0 if none). */
|
|
liveCount(sessionId: string): number {
|
|
return this.bySession.get(sessionId)?.length ?? 0;
|
|
}
|
|
}
|