mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
fix(review): WS state machine, per-tab supersede key, backoff, dot CSS (PR #149)
- _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>
This commit is contained in:
+25
-9
@@ -302,6 +302,14 @@ class CodemanApp {
|
||||
this._clientId = (typeof crypto !== 'undefined' && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: 'c-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
// Per-TAB nonce for the WS registry key (COD-137). _loadReliableState()
|
||||
// later replaces _clientId with the browser-wide localStorage identity
|
||||
// (shared by every tab/window of this profile), so the WS upgrade sends
|
||||
// `clientId:nonce` instead — a same-tab reconnect still supersedes its own
|
||||
// socket, but two tabs on one session coexist instead of evicting each
|
||||
// other in a 4010 ping-pong. Input frames keep the bare clientId for seq
|
||||
// dedup.
|
||||
this._wsTabNonce = this._clientId;
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
this.activeSessionId = null;
|
||||
@@ -436,10 +444,7 @@ class CodemanApp {
|
||||
this._wsSessionId = null; // Session ID the WS is connected to
|
||||
this._wsReady = false; // True when WS is open and ready for I/O
|
||||
this._wsState = 'disconnected'; // connecting | connected | reconnecting | fallback | disconnected
|
||||
this._wsLastClose = null; // { code, reason, at } for transport diagnostics
|
||||
this._wsLastRecvAt = 0; // ms timestamp of the last frame received on the active WS
|
||||
this._wsInputSendCount = 0;
|
||||
this._httpFallbackSendCount = 0;
|
||||
|
||||
// Terminal write batching with DEC 2026 sync support
|
||||
this.pendingWrites = [];
|
||||
@@ -1972,14 +1977,20 @@ class CodemanApp {
|
||||
*/
|
||||
_connectWs(sessionId) {
|
||||
this._disconnectWs();
|
||||
this._wsState = 'connecting';
|
||||
this._updateConnectionIndicator();
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// Pass the stable per-browser clientId on the upgrade URL so the server's
|
||||
// connection registry scopes the per-session limit by client (COD-137): a
|
||||
// Pass a per-TAB identity on the upgrade URL so the server's connection
|
||||
// registry scopes the per-session limit by connection (COD-137): a same-tab
|
||||
// reconnect supersedes its own socket instead of consuming a new slot and
|
||||
// tripping a spurious 4008. Omitted if clientId is unavailable (server then
|
||||
// treats the upgrade as anonymous — still admitted up to the limit).
|
||||
const cidQuery = this._clientId ? `?cid=${encodeURIComponent(this._clientId)}` : '';
|
||||
// tripping a spurious 4008, while two tabs of the same browser (which share
|
||||
// the localStorage clientId) each keep their own socket. The bare clientId
|
||||
// still rides the input frames for seq dedup. Omitted if clientId is
|
||||
// unavailable (server then treats the upgrade as anonymous — still admitted
|
||||
// up to the limit).
|
||||
const cid = this._clientId ? `${this._clientId}:${this._wsTabNonce}` : '';
|
||||
const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : '';
|
||||
const url = `${proto}//${location.host}/ws/sessions/${sessionId}/terminal${cidQuery}`;
|
||||
const ws = new WebSocket(url);
|
||||
this._ws = ws;
|
||||
@@ -1989,6 +2000,7 @@ class CodemanApp {
|
||||
// Only mark ready if this is still the intended session
|
||||
if (this._ws === ws) {
|
||||
this._wsReady = true;
|
||||
this._wsState = 'connected';
|
||||
this._wsReconnectAttempts = 0;
|
||||
this._updateConnectionIndicator();
|
||||
// Send a typed resize over the fresh socket: syncs PTY dims after
|
||||
@@ -2092,7 +2104,11 @@ class CodemanApp {
|
||||
/** Close the active WebSocket connection (if any). */
|
||||
_disconnectWs() {
|
||||
this._clearTimer('_wsReconnectTimer');
|
||||
this._wsReconnectAttempts = 0;
|
||||
// Deliberately do NOT reset _wsReconnectAttempts here: _connectWs() calls
|
||||
// this first, so a reset would restart the exponential backoff ladder at
|
||||
// attempt 0 on every retry (≈0ms tight reconnect loop during an outage).
|
||||
// ws.onopen zeroes the counter once a connection actually succeeds.
|
||||
this._wsState = 'disconnected';
|
||||
this._stopMobileResizeRetry();
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null; // Prevent re-entrant cleanup
|
||||
|
||||
@@ -126,38 +126,6 @@ function shouldAutoWrapTabs(input) {
|
||||
return scrollWidth > clientWidth + 1;
|
||||
}
|
||||
|
||||
// COD-122: Monitor "Tmux Sessions" row label policy. A row carries up to three
|
||||
// identifiers — the tab name (user-facing L1 label), the tmux session name
|
||||
// (e.g. "codeman-1fac7304"), and the agent (Claude/Codex) session id. The tab name
|
||||
// must win as the primary label; the other two are secondary metadata shown only
|
||||
// when known and not redundant with the primary, so the row degrades gracefully
|
||||
// when identifiers are missing.
|
||||
function resolveMonitorRowLabels(input) {
|
||||
const clean = (v) => (typeof v === 'string' ? v.trim() : '');
|
||||
const tabName = clean(input && input.tabName);
|
||||
const storedName = clean(input && input.storedName);
|
||||
const muxName = clean(input && input.muxName);
|
||||
const agentId = clean(input && input.agentSessionId);
|
||||
const sessionId = clean(input && input.sessionId);
|
||||
|
||||
// Tab name is the L1 label; fall back through the persisted mux name → tmux name
|
||||
// → a generic placeholder so the primary is never blank.
|
||||
const primary = tabName || storedName || muxName || 'session';
|
||||
|
||||
const secondaries = [];
|
||||
// tmux name — skip when it's already serving as the primary fallback (no dup).
|
||||
if (muxName && muxName !== primary) {
|
||||
secondaries.push({ kind: 'tmux', label: muxName });
|
||||
}
|
||||
// agent session id — show only when genuinely known: non-empty, not the codeman
|
||||
// sessionId placeholder (claudeSessionId is seeded with the session id until a
|
||||
// real agent message arrives), and not redundant with the primary label.
|
||||
if (agentId && agentId !== sessionId && agentId !== primary) {
|
||||
secondaries.push({ kind: 'agent', label: agentId });
|
||||
}
|
||||
return { primary, secondaries };
|
||||
}
|
||||
|
||||
// COD-134 — Terminal WebSocket reconnect policy.
|
||||
//
|
||||
// Decide what to do after a terminal WebSocket closes, given the close `code`
|
||||
@@ -188,9 +156,6 @@ if (typeof window !== 'undefined') {
|
||||
window.CodemanTabOverflow = {
|
||||
shouldAutoWrapTabs,
|
||||
};
|
||||
window.CodemanMonitorLabels = {
|
||||
resolveMonitorRowLabels,
|
||||
};
|
||||
window.CodemanWsReconnect = {
|
||||
plan: planWsReconnect,
|
||||
};
|
||||
|
||||
@@ -626,6 +626,15 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.connection-dot.connected {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.connection-dot.fallback {
|
||||
background: var(--yellow);
|
||||
box-shadow: 0 0 6px var(--yellow);
|
||||
}
|
||||
|
||||
.connection-dot.offline {
|
||||
background: var(--red);
|
||||
box-shadow: 0 0 6px var(--red);
|
||||
|
||||
@@ -12,12 +12,15 @@
|
||||
* 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 clientId (`cid`,
|
||||
* parsed from the upgrade URL query). The reliable-input protocol
|
||||
* (`session.shouldApplyInput(cid, seq)`) already assumes ONE logical client per
|
||||
* `cid` per session, so 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).
|
||||
* 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
|
||||
|
||||
Reference in New Issue
Block a user