diff --git a/src/web/public/app.js b/src/web/public/app.js index 6ba0945d..903228ae 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -867,6 +867,11 @@ class CodemanApp { // writes when the freshly computed descriptor is identical (COD-136). this._lastIndicatorDescriptor = null; this._postDraining = new Set(); // sessionIds with an in-flight POST drainer + // Terminal sockets OTHER than the primary one (`this._ws`), keyed by the + // session they are bound to: the split pane's second terminal registers its + // socket here so its input rides the same exactly-once queue. Values are + // `{ ws, lastRecvAt }` handles owned by that terminal (see _inputSocketFor). + this._extraInputSockets = new Map(); this._persistReliableTimer = null; this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog @@ -3397,7 +3402,7 @@ class CodemanApp { } else if (msg.t === 'ia') { // Input ACK — the server applied (or deduped) this seq; drop it from // the durable queue so it can never be re-delivered/lost. - this._onWsInputAck(msg.seq, msg); + this._onWsInputAck(msg.seq, msg, sessionId); } else if (msg.t === 'zc') { // Resize confirm — the geometry the PTY actually holds, which is not // always the one this client asked for (issue #464). @@ -3579,6 +3584,49 @@ class CodemanApp { this._reliableSend(sessionId, input, useMux); } + /** + * The OPEN terminal socket bound to `sessionId`, or null: the primary socket + * first, then one registered by a second terminal (the split pane). The + * delivery layer below asks this instead of reading `this._ws` directly, so a + * second terminal's input gets the same exactly-once queue, ACKs and + * half-open detection as the primary's. + * + * Returns `{ ws, lastRecvAt }`: `lastRecvAt` is the socket's last received + * frame, which `_redeliverSweep` reads to tell a dead socket from a slow ACK. + */ + _inputSocketFor(sessionId) { + if (!sessionId) return null; + if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) { + return { ws: this._ws, lastRecvAt: this._wsLastRecvAt }; + } + const handle = this._extraInputSockets?.get(sessionId); + if (handle && handle.ws && handle.ws.readyState === WebSocket.OPEN) return handle; + return null; + } + + /** + * Register a second terminal's socket for `sessionId` (call from its onopen). + * `handle` is `{ ws, lastRecvAt }`, owned by the caller, which must bump + * `handle.lastRecvAt` on every received frame. Stamped here so a socket that + * just opened does not look silent to the redelivery sweep, which would + * otherwise force-close it as half-open on the first stale record. + */ + _registerInputSocket(sessionId, handle) { + if (!sessionId || !handle) return; + handle.lastRecvAt = Date.now(); + if (!this._extraInputSockets) this._extraInputSockets = new Map(); + this._extraInputSockets.set(sessionId, handle); + } + + /** + * Drop a registration, but only the one `handle` made: a replacement socket + * registers its own handle before the old one's close lands, and that late + * close must not unregister the socket that replaced it. + */ + _unregisterInputSocket(sessionId, handle) { + if (this._extraInputSockets?.get(sessionId) === handle) this._extraInputSockets.delete(sessionId); + } + /** * Fire-and-forget input for EPHEMERAL, loss-tolerant streams (e.g. wheel-scroll * reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue, @@ -3590,9 +3638,10 @@ class CodemanApp { */ _sendInputEphemeral(sessionId, input) { if (!sessionId || !input) return; - if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) { + const sock = this._inputSocketFor(sessionId); + if (sock) { try { - this._ws.send(JSON.stringify({ t: 'i', d: input })); + sock.ws.send(JSON.stringify({ t: 'i', d: input })); return; } catch { // socket died mid-send — fall through to a best-effort POST @@ -3646,11 +3695,12 @@ class CodemanApp { // over the single ordered stream. They stay pending until the server ACKs // them ({t:'ia'}); a frame swallowed by a half-open socket is re-sent after // the sweep force-reconnects (which resets sentAt=0 in _onWsReady). - if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) { + const sock = this._inputSocketFor(sessionId); + if (sock) { for (const rec of list) { if (rec.sentAt !== 0) continue; try { - this._ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId })); + sock.ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId })); rec.sentAt = Date.now(); rec.tries++; } catch { @@ -3671,7 +3721,7 @@ class CodemanApp { if (!cur || cur.length === 0) break; // If the WebSocket came back mid-drain, yield to it (the acked stream) // so we don't redundantly re-POST what onopen is already re-sending. - if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) { + if (this._inputSocketFor(sessionId)) { break; } const rec = cur[0]; @@ -3759,8 +3809,10 @@ class CodemanApp { * called a duplicate is the mechanism working as designed — the original did * land — and re-sending it would type the same thing twice. */ - _onWsInputAck(seq, msg) { - const sessionId = this._wsSessionId; + _onWsInputAck(seq, msg, sessionId = this._wsSessionId) { + // `sessionId` is the session of the socket the ACK arrived on: `{t:'ia'}` + // frames carry none, and with a second terminal's socket in play + // `this._wsSessionId` is no longer the only candidate. if (!sessionId || !Number.isInteger(seq)) return; if (msg && msg.err) { // Refused for good (e.g. over the size limit): retrying cannot help. @@ -3805,19 +3857,20 @@ class CodemanApp { for (const sessionId of [...this._pendingDeliveries.keys()]) { const list = this._pendingDeliveries.get(sessionId); if (!list || list.length === 0) continue; - const isActiveWs = - this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId; - if (isActiveWs) { + const sock = this._inputSocketFor(sessionId); + if (sock) { const oldest = list[0]; // Only tear the socket down when the oldest unacked frame is stale AND the // socket has been silent for the timeout: a connection still delivering // output/ACKs is alive (the ACK is just behind), so force-closing it would // cause needless WS↔HTTP flapping. A truly half-open socket goes quiet. + // Silence is measured on THIS socket: the primary's last frame says + // nothing about a second terminal's connection, and the reverse. const stale = oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs; - const silent = Date.now() - this._wsLastRecvAt > this._reliableAckTimeoutMs; + const silent = Date.now() - (sock.lastRecvAt || 0) > this._reliableAckTimeoutMs; if (stale && silent) { try { - this._ws.close(); // half-open: never recovers on its own — force reconnect + sock.ws.close(); // half-open: never recovers on its own — force reconnect } catch { /* ignore */ } diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js index 009e8c38..d2518fc4 100644 --- a/src/web/public/image-input.js +++ b/src/web/public/image-input.js @@ -50,8 +50,13 @@ Object.assign(CodemanApp.prototype, { // Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown. // Creates a hidden paste trap, lets the browser paste into it, then inspects // the result for images. Works on plain HTTP (no Clipboard API needed). - _handleImagePaste() { + // `target` names the terminal the Ctrl+V came from and its session; both + // default to the primary pane. A second terminal (the split pane) passes its + // own, so text pastes into THAT xterm and images upload to THAT session. + _handleImagePaste(target = {}) { const self = this; + const terminal = target.terminal || this.terminal; + const sessionId = target.sessionId || this.activeSessionId; // Create a hidden contenteditable div to receive the paste const trap = document.createElement('div'); @@ -93,11 +98,11 @@ Object.assign(CodemanApp.prototype, { setTimeout(function() { if (trap.parentNode) trap.parentNode.removeChild(trap); // Refocus the terminal - if (self.terminal) self.terminal.focus(); + if (terminal) terminal.focus(); }, 0); if (imageFiles.length > 0) { - self._uploadAndInsertImages(imageFiles); + self._uploadAndInsertImages(imageFiles, { sessionId: sessionId }); } else { // No image -- route text through xterm's paste() so bracketed-paste // markers (CSI 200~ ... CSI 201~) survive when the inner application @@ -106,7 +111,7 @@ Object.assign(CodemanApp.prototype, { // indistinguishable from typed input, weakening the CLI's // prompt-injection defenses. var text = e.clipboardData ? e.clipboardData.getData('text/plain') : ''; - if (text && self.terminal) self.terminal.paste(text); + if (text && terminal) terminal.paste(text); } }); @@ -126,9 +131,10 @@ Object.assign(CodemanApp.prototype, { /** Upload a batch and normally insert its paths into the active terminal. * The prompt composer passes `{ insert: false }` so it can put those paths - * into its textarea instead. Returns successful paths in selection order. */ + * into its textarea instead. `options.sessionId` names the session to upload + * to (default: the active one). Returns successful paths in selection order. */ async _uploadAndInsertImages(fileList, options = {}) { - const sessionId = this.activeSessionId; + const sessionId = options.sessionId || this.activeSessionId; if (!sessionId) return []; let files = Array.from(fileList || []); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index a03bc4d1..be4528b3 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1416,7 +1416,7 @@ Object.assign(CodemanApp.prototype, { // without this call Pane B never learned about a window resize, an // Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently // stayed at whatever size it was last dragged to. - this._splitPane?.fit(); + this._forEachTile?.((tile) => tile.fit()); }, 300); // Trailing-edge: only fire after 300ms of no resize events }; @@ -1827,9 +1827,24 @@ Object.assign(CodemanApp.prototype, { * Register a custom link provider for xterm.js that detects file paths * in terminal output and makes them clickable. * When clicked, opens a floating log viewer window with live streaming. + * + * `target` defaults to the primary terminal and the active session. A second + * terminal (the split pane) passes its own `{ terminal, getSessionId, + * setHovered }`, so a path printed there opens against THAT pane's session and + * hovering it never flips the primary pane's `_linkHovered`. Only the primary + * registration is kept on `_terminalLinkProvider`, which the touch path reads. + * Returns the provider. */ - registerFilePathLinkProvider() { + registerFilePathLinkProvider(target = {}) { const self = this; + const terminal = target.terminal || this.terminal; + const getSessionId = target.getSessionId || (() => this.activeSessionId); + const setHovered = + target.setHovered || + ((hovered) => { + this._linkHovered = hovered; + }); + const isPrimary = terminal === this.terminal; // Debug: Track if provider is being invoked let lastInvokedLine = -1; @@ -1842,7 +1857,7 @@ Object.assign(CodemanApp.prototype, { console.debug('[LinkProvider] Checking line:', bufferLineNumber); } - const buffer = self.terminal.buffer.active; + const buffer = terminal.buffer.active; // provideLinks passes 1-based line number, getLine expects 0-based const line = buffer.getLine(bufferLineNumber - 1); @@ -1866,7 +1881,7 @@ Object.assign(CodemanApp.prototype, { const logical = window.CodemanTerminalLines?.terminalLogicalLine( buffer, bufferLineNumber - 1, - self.terminal.cols, + terminal.cols, MAX_STITCHED_ROWS ); if (!logical) { @@ -1919,10 +1934,10 @@ Object.assign(CodemanApp.prototype, { window.open(text, '_blank', 'noopener,noreferrer'); }, hover() { - self._linkHovered = true; + setHovered(true); }, leave() { - self._linkHovered = false; + setHovered(false); }, }); }; @@ -1978,17 +1993,18 @@ Object.assign(CodemanApp.prototype, { // path clicked in the response viewer previewed fine. The preview // reads those through the guarded attachment routes, so external // paths route there and the two surfaces agree. - if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) { - self.openFilePreview(text, self.activeSessionId); + const sessionId = getSessionId(); + if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) { + self.openFilePreview(text, sessionId); return; } - self.openLogViewerWindow(text, self.activeSessionId); + self.openLogViewerWindow(text, sessionId); }, hover() { - self._linkHovered = true; + setHovered(true); }, leave() { - self._linkHovered = false; + setHovered(false); }, }); }; @@ -2031,10 +2047,11 @@ Object.assign(CodemanApp.prototype, { // produce), so the tap path asks this SAME provider what is under the finger // rather than growing a second, driftable copy of the patterns. // See _terminalLinkAtPoint. - this._terminalLinkProvider = provider; - this.terminal.registerLinkProvider(provider); + if (isPrimary) this._terminalLinkProvider = provider; + terminal.registerLinkProvider(provider); console.log('[LinkProvider] File path link provider registered'); + return provider; }, /** @@ -4480,6 +4497,28 @@ Object.assign(CodemanApp.prototype, { // Terminal Controls // ═══════════════════════════════════════════════════════════════ + /** + * The terminal the keyboard is in, as `{ terminal, sessionId, isPrimary }`. + * + * The ONE place a shortcut, voice or paste should ask "which pane?", rather + * than reading `this.terminal` / `this.activeSessionId`, which always mean the + * primary pane. Today it always answers with the primary pane; the split + * pane's second terminal plugs in here once it tracks its own focus. + */ + _focusedPane() { + return { terminal: this.terminal, sessionId: this.activeSessionId, isPrimary: true }; + }, + + /** + * Run `fn(tile)` for every secondary terminal pane on screen: today the split + * pane's second terminal, when one is open. Font, weight, family and skin + * changes go through here so they reach every pane without a special case + * per pane kind. Agent Teams terminals size themselves and are not tiles. + */ + _forEachTile(fn) { + if (this._splitPane?.terminal) fn(this._splitPane); + }, + clearTerminal() { this.terminal.clear(); }, @@ -4639,15 +4678,18 @@ Object.assign(CodemanApp.prototype, { * terminal._core for cell dimensions, and falls back to cleaning normally if * a future xterm renames it. SelectionMode.COLUMN is 3. */ - cleanedTerminalSelection(text) { - const raw = text ?? (this.terminal?.hasSelection?.() ? this.terminal.getSelection() : ''); + cleanedTerminalSelection(text, target = {}) { + // `target` names a second terminal (the split pane) and its session; both + // default to the primary pane, whose `this.terminal` this file otherwise reads. + const terminal = target.terminal || this.terminal; + const raw = text ?? (terminal?.hasSelection?.() ? terminal.getSelection() : ''); if (!raw) return ''; - if (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw; + if (terminal?._core?._selectionService?._activeSelectionMode === 3) return raw; const clean = window.CodemanCopySelection?.clean; if (!clean) return raw; - const range = this._normalisedSelectionRange(); + const range = this._normalisedSelectionRange(terminal); return clean(raw, { - margin: this._cliGutterColumns(), + margin: this._cliGutterColumns(target.sessionId), firstLinePartial: !!range && range.start.x > 0, }); }, @@ -4717,8 +4759,11 @@ Object.assign(CodemanApp.prototype, { // Copy the current terminal selection. Goes through _copyText (Clipboard API, // then a hidden-textarea + execCommand fallback) because install.sh's LAN // option serves plain HTTP, where navigator.clipboard is undefined. - async copyTerminalSelection(text) { - const selection = this.cleanedTerminalSelection(text); + async copyTerminalSelection(text, target = {}) { + // Every terminal touched below is the TARGET one: clearing or refocusing the + // primary after copying from the split pane would hit the wrong pane. + const terminal = target.terminal || this.terminal; + const selection = this.cleanedTerminalSelection(text, target); // trim(), not emptiness: a multi-row drag across padding cleans to newlines // alone, which are truthy, and a bare newline pasted into a chat composer // or a shell submits the line. decideAutoCopy applies the same rule. @@ -4727,7 +4772,7 @@ Object.assign(CodemanApp.prototype, { // selection, so a padding-only selection left set can no longer swallow a // later interrupt; it cleans to '' and the press reaches the PTY. What the // clear avoids is a highlight that sits there having copied nothing. - this.terminal?.clearSelection?.(); + terminal?.clearSelection?.(); this.showToast('Nothing to copy', 'warning'); return false; } @@ -4735,14 +4780,14 @@ Object.assign(CodemanApp.prototype, { if (ok) { // Clearing is what makes a second Ctrl+C an interrupt (and xterm already // drops the selection on any keypress, so this matches existing feel). - this.terminal.clearSelection?.(); + terminal.clearSelection?.(); this.showToast('Copied to clipboard', 'success'); } else { this.showToast('Failed to copy', 'error'); } // The execCommand fallback focuses a temp textarea, so hand focus back. This // is the CJK-aware focus router, not xterm's raw focus(). - this.terminal.focus(); + terminal.focus(); return ok; }, @@ -5688,10 +5733,10 @@ Object.assign(CodemanApp.prototype, { // Update overlay font cache and re-render at new cell dimensions this._localEchoOverlay?.refreshFont(); this._predictiveEcho?.refreshFont(); - if (this._splitPane?.terminal) { - this._splitPane.terminal.options.fontSize = size; - this._splitPane.fitAddon?.fit(); - } + this._forEachTile?.((tile) => { + tile.terminal.options.fontSize = size; + tile.localFit(); + }); }, /** @@ -5717,10 +5762,10 @@ Object.assign(CodemanApp.prototype, { this._refitAfterCellSizeChange(); this._localEchoOverlay?.refreshFont(); this._predictiveEcho?.refreshFont(); - if (this._splitPane?.terminal) { - this._splitPane.terminal.options.fontFamily = resolved; - this._splitPane.fitAddon?.fit(); - } + this._forEachTile?.((tile) => { + tile.terminal.options.fontFamily = resolved; + tile.localFit(); + }); }, /** @@ -5772,11 +5817,11 @@ Object.assign(CodemanApp.prototype, { /* pane not laid out yet — its own resize observer refits it */ } } - if (this._splitPane?.terminal) { - this._splitPane.terminal.options.fontWeight = fontWeight; - this._splitPane.terminal.options.fontWeightBold = fontWeightBold; - this._splitPane.fitAddon?.fit(); - } + this._forEachTile?.((tile) => { + tile.terminal.options.fontWeight = fontWeight; + tile.terminal.options.fontWeightBold = fontWeightBold; + tile.localFit(); + }); }, loadFontSize() { @@ -6245,13 +6290,13 @@ Object.assign(CodemanApp.prototype, { } } } - if (this._splitPane?.terminal) { - this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio; - this._splitPane.terminal.options.theme = { ...theme }; + this._forEachTile?.((tile) => { + tile.terminal.options.minimumContrastRatio = minimumContrastRatio; + tile.terminal.options.theme = { ...theme }; try { - this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1); + tile.terminal.refresh(0, tile.terminal.rows - 1); } catch {} - } + }); }, }); diff --git a/test/input-size-limit.test.ts b/test/input-size-limit.test.ts index 6c7c5269..e5fa55cc 100644 --- a/test/input-size-limit.test.ts +++ b/test/input-size-limit.test.ts @@ -97,7 +97,7 @@ describe('the client never queues or keeps an undeliverable frame', () => { }); it('drops a frame the WebSocket refused with an error ACK', () => { - const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg) {')); + const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg, sessionId = this._wsSessionId) {')); expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/); }); diff --git a/test/input-socket-map.test.ts b/test/input-socket-map.test.ts new file mode 100644 index 00000000..47c83220 --- /dev/null +++ b/test/input-socket-map.test.ts @@ -0,0 +1,281 @@ +/** + * @fileoverview The durable input layer delivers over ANY registered terminal + * socket, not only the primary one (`_inputSocketFor`, app.js). + * + * The exactly-once queue (`_sendInputAsync` → `_reliableSend` → `_drainSession`, + * ACKed by `{t:'ia'}`, swept by `_redeliverSweep`) used to read the single + * primary socket (`this._ws` / `this._wsSessionId`) directly. A second terminal + * bound to another session (the split pane's Pane B, later a grid tile) had no + * way in, so its keystrokes went out as seq-less frames with no ACK, no retry + * and no idle-alert acknowledgement. These tests pin the seam: a registered + * socket gets seq frames, its ACK lands on ITS session's queue, a stale + * registration can never evict the socket that replaced it, and the sweep + * judges a socket's silence by that socket's own last frame. + * + * Loaded via `vm` with a stubbed context (no jsdom), like input-send-order. + */ +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +function loadCodemanAppClass() { + const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); + const context = vm.createContext({ + console, + performance, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + WebSocket: { OPEN: 1 }, + fetch: (...args: Parameters) => global.fetch(...args), + document: { addEventListener: vi.fn() }, + localStorage: { + length: 0, + key: vi.fn(), + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + MobileDetection: {}, + }); + vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context); + return (context as { __CodemanApp: new () => unknown }).__CodemanApp; +} + +const CodemanApp = loadCodemanAppClass(); + +type Frame = { t: string; d: string; seq?: number; cid?: string }; +type FakeSocket = { readyState: number; send: (data: string) => void; close: ReturnType }; +type Handle = { ws: FakeSocket; lastRecvAt: number }; +type Rec = { seq: number; data: string; sentAt: number; tries: number }; + +type App = { + _sendInputAsync: (sessionId: string, input: string, opts?: { useMux?: boolean }) => void; + _sendInputEphemeral: (sessionId: string, input: string) => void; + _inputSocketFor: (sessionId: string) => { ws: FakeSocket; lastRecvAt: number } | null; + _registerInputSocket: (sessionId: string, handle: Handle) => void; + _unregisterInputSocket: (sessionId: string, handle: Handle) => void; + _onWsInputAck: (seq: number, msg: Record, sessionId?: string) => void; + _onWsReady: (sessionId: string) => void; + _redeliverSweep: () => void; + _pendingDeliveries: Map; + _seqCounters: Map; + _ws: FakeSocket | null; + _wsSessionId: string | null; + _wsLastRecvAt: number; + _reliableAckTimeoutMs: number; + markIdleAlertSeen: ReturnType; +}; + +function makeApp(): App { + const app = Object.create((CodemanApp as { prototype: object }).prototype) as App & Record; + app._clientId = 'c-test'; + app._seqCounters = new Map(); + app._pendingDeliveries = new Map(); + app._postDraining = new Set(); + app._persistReliableState = vi.fn(); + app._persistReliableNow = vi.fn(); + app._updateConnectionIndicator = vi.fn(); + app.markIdleAlertSeen = vi.fn(); + app.activeSessionId = 'primary'; + app._ws = null; + app._wsSessionId = null; + app._wsLastRecvAt = 0; + app._reliableAckTimeoutMs = 4000; + return app as unknown as App; +} + +function fakeSocket(frames: Frame[]): FakeSocket { + return { readyState: 1, send: (d: string) => frames.push(JSON.parse(d) as Frame), close: vi.fn() }; +} + +describe('a registered second socket joins the exactly-once queue', () => { + it('delivers seq-tagged frames over the registered socket for its session', () => { + const app = makeApp(); + const primaryFrames: Frame[] = []; + const tileFrames: Frame[] = []; + app._ws = fakeSocket(primaryFrames); + app._wsSessionId = 'primary'; + app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 }); + + app._sendInputAsync('other', 'x'); + app._sendInputAsync('other', 'y'); + + expect(tileFrames.map((f) => [f.d, f.seq, f.cid])).toEqual([ + ['x', 1, 'c-test'], + ['y', 2, 'c-test'], + ]); + // Nothing for the other session leaks onto the primary socket. + expect(primaryFrames).toEqual([]); + }); + + it('still prefers the primary socket for the primary session', () => { + const app = makeApp(); + const primaryFrames: Frame[] = []; + app._ws = fakeSocket(primaryFrames); + app._wsSessionId = 'primary'; + + app._sendInputAsync('primary', 'a'); + + expect(primaryFrames.map((f) => f.d)).toEqual(['a']); + }); + + it('ignores a registered socket that is not OPEN (falls back to POST)', async () => { + const app = makeApp(); + const posts: Array<{ input: string; seq: number }> = []; + global.fetch = vi.fn(async (_url, init) => { + posts.push(JSON.parse(String(init?.body))); + return new Response('{}', { status: 200 }); + }); + const closing = { ...fakeSocket([]), readyState: 3 }; + app._registerInputSocket('other', { ws: closing, lastRecvAt: 0 }); + + expect(app._inputSocketFor('other')).toBeNull(); + app._sendInputAsync('other', 'z'); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + + expect(posts.map((p) => [p.input, p.seq])).toEqual([['z', 1]]); + }); + + it('routes ephemeral input over the registered socket too', () => { + const app = makeApp(); + const tileFrames: Frame[] = []; + app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 }); + + app._sendInputEphemeral('other', '\x1b[<64;1;1M'); + + expect(tileFrames).toEqual([{ t: 'i', d: '\x1b[<64;1;1M' }]); + }); +}); + +describe('ACK routing', () => { + it('drops the record from the queue of the session the ACK arrived for', () => { + const app = makeApp(); + app._ws = fakeSocket([]); + app._wsSessionId = 'primary'; + app._registerInputSocket('other', { ws: fakeSocket([]), lastRecvAt: 0 }); + app._sendInputAsync('primary', 'p'); + app._sendInputAsync('other', 'o'); + + // Both sessions issued seq 1. The ACK names no session, so the socket's own + // session decides which queue it drains; the primary's must be untouched. + app._onWsInputAck(1, { t: 'ia', seq: 1 }, 'other'); + + expect(app._pendingDeliveries.get('other')).toBeUndefined(); + expect(app._pendingDeliveries.get('primary')?.map((r) => r.data)).toEqual(['p']); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith('other'); + }); + + it('defaults to the primary socket session when no session is passed', () => { + const app = makeApp(); + app._ws = fakeSocket([]); + app._wsSessionId = 'primary'; + app._sendInputAsync('primary', 'p'); + + app._onWsInputAck(1, { t: 'ia', seq: 1 }); + + expect(app._pendingDeliveries.get('primary')).toBeUndefined(); + }); + + it('re-sends everything pending over a freshly registered socket on ready', () => { + const app = makeApp(); + const first: Frame[] = []; + const firstHandle = { ws: fakeSocket(first), lastRecvAt: 0 }; + app._registerInputSocket('other', firstHandle); + app._sendInputAsync('other', 'a'); + expect(first.map((f) => f.d)).toEqual(['a']); + + // The socket dies without an ACK; a replacement opens and asks for a flush. + firstHandle.ws.readyState = 3; + const second: Frame[] = []; + app._registerInputSocket('other', { ws: fakeSocket(second), lastRecvAt: 0 }); + app._onWsReady('other'); + + expect(second.map((f) => [f.d, f.seq])).toEqual([['a', 1]]); + }); +}); + +describe('registration ownership', () => { + it('a stale handle cannot unregister the socket that replaced it', () => { + const app = makeApp(); + const oldHandle = { ws: fakeSocket([]), lastRecvAt: 0 }; + const newFrames: Frame[] = []; + const newHandle = { ws: fakeSocket(newFrames), lastRecvAt: 0 }; + app._registerInputSocket('other', oldHandle); + app._registerInputSocket('other', newHandle); + + // The old socket's close lands late and tries to clean up after itself. + app._unregisterInputSocket('other', oldHandle); + + app._sendInputAsync('other', 'still-here'); + expect(newFrames.map((f) => f.d)).toEqual(['still-here']); + }); + + it('the owning handle does unregister', () => { + const app = makeApp(); + const handle = { ws: fakeSocket([]), lastRecvAt: 0 }; + app._registerInputSocket('other', handle); + app._unregisterInputSocket('other', handle); + + expect(app._inputSocketFor('other')).toBeNull(); + }); + + it('stamps lastRecvAt at registration so a fresh socket never looks silent', () => { + const app = makeApp(); + const handle = { ws: fakeSocket([]), lastRecvAt: 0 }; + const before = Date.now(); + app._registerInputSocket('other', handle); + + expect(handle.lastRecvAt).toBeGreaterThanOrEqual(before); + }); +}); + +describe('the redelivery sweep judges each socket by its own last frame', () => { + function staleRecord(app: App, sessionId: string) { + const rec = app._pendingDeliveries.get(sessionId)![0]; + rec.sentAt = Date.now() - (app._reliableAckTimeoutMs + 1000); + } + + it('force-closes a silent registered socket with a stale record', () => { + const app = makeApp(); + const handle = { ws: fakeSocket([]), lastRecvAt: 0 }; + app._registerInputSocket('other', handle); + app._sendInputAsync('other', 'a'); + staleRecord(app, 'other'); + handle.lastRecvAt = Date.now() - (app._reliableAckTimeoutMs + 1000); + // The primary socket is chatty; that must not vouch for the other one. + app._ws = fakeSocket([]); + app._wsSessionId = 'primary'; + app._wsLastRecvAt = Date.now(); + + app._redeliverSweep(); + + expect(handle.ws.close).toHaveBeenCalledTimes(1); + expect(app._ws.close).not.toHaveBeenCalled(); + }); + + it('re-drives (does not close) a registered socket that is still receiving', () => { + const app = makeApp(); + const frames: Frame[] = []; + const handle = { ws: fakeSocket(frames), lastRecvAt: 0 }; + app._registerInputSocket('other', handle); + app._sendInputAsync('other', 'a'); + staleRecord(app, 'other'); + handle.lastRecvAt = Date.now(); + // A SILENT primary must not condemn the live registered socket either. + app._wsLastRecvAt = 0; + + app._redeliverSweep(); + + expect(handle.ws.close).not.toHaveBeenCalled(); + expect(frames.map((f) => f.d)).toEqual(['a', 'a']); + }); +}); diff --git a/test/reliable-input-recovery.test.ts b/test/reliable-input-recovery.test.ts index fe56168a..54842539 100644 --- a/test/reliable-input-recovery.test.ts +++ b/test/reliable-input-recovery.test.ts @@ -42,7 +42,7 @@ describe('the duplicate ACK carries what the client needs', () => { describe('the client lifts itself over the watermark', () => { const handler = appSource.slice( - appSource.indexOf('_onWsInputAck(seq, msg)'), + appSource.indexOf('_onWsInputAck(seq, msg, sessionId'), appSource.indexOf('/** Called from ws.onopen') ); diff --git a/test/terminal-font-weight.test.ts b/test/terminal-font-weight.test.ts index e04efc6e..15b5eb92 100644 --- a/test/terminal-font-weight.test.ts +++ b/test/terminal-font-weight.test.ts @@ -63,7 +63,13 @@ function fakeTerminal(options: Record = {}) { return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } }; } -function makeApp(opts: { teammates?: number; terminal?: ReturnType | null } = {}) { +function makeApp( + opts: { + teammates?: number; + terminal?: ReturnType | null; + splitPane?: { terminal: ReturnType; localFit: () => void } | null; + } = {} +) { const fit = vi.fn(); const teammateFits: ReturnType[] = []; const teammateTerminals = new Map; fitAddon: unknown }>(); @@ -74,6 +80,10 @@ function makeApp(opts: { teammates?: number; terminal?: ReturnType { + it('reaches an open split pane: same weights, refit in place', () => { + const splitTerminal = fakeTerminal(); + const localFit = vi.fn(); + const { app } = makeApp({ splitPane: { terminal: splitTerminal, localFit } }); + + (app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({ + terminalFontWeight: '300', + terminalFontWeightBold: '700', + }); + + expect(splitTerminal.options.fontWeight).toBe(300); + expect(splitTerminal.options.fontWeightBold).toBe(700); + expect(localFit).toHaveBeenCalledTimes(1); + }); + it('writes both slots to the live terminal', () => { const { app, fit } = makeApp();