mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39: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>
80 lines
3.6 KiB
TypeScript
80 lines
3.6 KiB
TypeScript
/**
|
|
* COD-134 — Terminal WebSocket reconnect policy.
|
|
*
|
|
* `CodemanWsReconnect.plan(code, attempt)` is the pure decision behind the
|
|
* client WS `onclose` handler in app.js: given a WebSocket close code and the
|
|
* number of consecutive reconnects already attempted, it returns the action to
|
|
* take (`reconnect` | `retry-fallback` | `give-up`) and a backoff delay. It is
|
|
* exposed on `window.CodemanWsReconnect` and tested here in a plain node VM
|
|
* context (no jsdom — jsdom env setup is broken on some hosts).
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
type Plan = { action: 'reconnect' | 'retry-fallback' | 'give-up'; delayMs: number };
|
|
|
|
function loadHelper() {
|
|
const context = vm.createContext({ window: {}, globalThis: {} });
|
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
|
vm.runInContext(source, context, { filename: 'constants.js' });
|
|
return (context.window as { CodemanWsReconnect: { plan: (code: number, attempt: number) => Plan } })
|
|
.CodemanWsReconnect;
|
|
}
|
|
|
|
describe('COD-134 WS reconnect plan policy', () => {
|
|
it('reconnects immediately on the first attempt for a transient close', () => {
|
|
const { plan } = loadHelper();
|
|
expect(plan(1006, 0)).toEqual({ action: 'reconnect', delayMs: 0 });
|
|
expect(plan(1000, 0).action).toBe('reconnect');
|
|
expect(plan(1001, 0).delayMs).toBe(0);
|
|
expect(plan(1005, 0).delayMs).toBe(0);
|
|
});
|
|
|
|
it('grows the backoff exponentially with a 10s cap for transient closes', () => {
|
|
const { plan } = loadHelper();
|
|
expect(plan(1006, 0).delayMs).toBe(0);
|
|
expect(plan(1006, 1).delayMs).toBe(250);
|
|
expect(plan(1006, 2).delayMs).toBe(500);
|
|
expect(plan(1006, 3).delayMs).toBe(1000);
|
|
expect(plan(1006, 4).delayMs).toBe(2000);
|
|
expect(plan(1006, 5).delayMs).toBe(4000);
|
|
expect(plan(1006, 6).delayMs).toBe(8000);
|
|
expect(plan(1006, 7).delayMs).toBe(10000); // 16000 capped to 10000
|
|
expect(plan(1006, 8).delayMs).toBe(10000);
|
|
expect(plan(1006, 50).delayMs).toBe(10000); // stays capped no matter how many attempts
|
|
// every transient attempt is still a reconnect
|
|
for (let attempt = 0; attempt < 12; attempt++) {
|
|
expect(plan(1006, attempt).action).toBe('reconnect');
|
|
}
|
|
});
|
|
|
|
it('auto-retries the fallback on a too-many-connections (4008) close', () => {
|
|
const { plan } = loadHelper();
|
|
expect(plan(4008, 0)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
|
expect(plan(4008, 3)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
|
});
|
|
|
|
it('gives up on session-not-found (4004) and session-terminated (4009)', () => {
|
|
const { plan } = loadHelper();
|
|
expect(plan(4004, 0)).toEqual({ action: 'give-up', delayMs: 0 });
|
|
expect(plan(4004, 5)).toEqual({ action: 'give-up', delayMs: 0 });
|
|
expect(plan(4009, 0)).toEqual({ action: 'give-up', delayMs: 0 });
|
|
expect(plan(4009, 5)).toEqual({ action: 'give-up', delayMs: 0 });
|
|
});
|
|
|
|
it('auto-retries the fallback for an unknown >=4004 code (e.g. 4010, 4005)', () => {
|
|
const { plan } = loadHelper();
|
|
expect(plan(4010, 0)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
|
expect(plan(4005, 2)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
|
expect(plan(4500, 0)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
|
});
|
|
|
|
it('treats a sub-4004 close (e.g. 4003 Forbidden) as a transient reconnect', () => {
|
|
const { plan } = loadHelper();
|
|
// 4003 is < 4004, so it is NOT a give-up; it follows the transient backoff.
|
|
expect(plan(4003, 0)).toEqual({ action: 'reconnect', delayMs: 0 });
|
|
});
|
|
});
|