From 9714ee79e8b0b9e70010f71997c071082c64505c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 08:41:15 +0100 Subject: [PATCH] fix: stop the home screen's tmux scan in a hidden tab (release review) The poll had no hidden-tab gate, and the local scan's 5 s cache sits under the 8 s interval, so a single tab never hit it: every tick was a full socket sweep (1.1 s and 772 forks on a box with 212 sockets) for a list nobody was reading. `_foreignPollTick()` is now the one gate for the first load, the timer and the return to a visible tab. The `visibilitychange` listener is stored and removed in `stopForeignPolling()`, since this runs on every trip to the home screen. Checked in real chromium with document.hidden stubbed: 2 polls in 9.5 s visible, 0 in 17.5 s hidden, 1 on return, and a tab opened in the background makes none until it is shown. Co-Authored-By: Claude Opus 5.5 --- src/web/CLAUDE.md | 2 +- src/web/public/foreign-sessions.js | 27 +++++++-- test/foreign-sessions-ui.test.ts | 88 +++++++++++++++++++++++++++++- 3 files changed, 108 insertions(+), 9 deletions(-) diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 50800cf5..409274d5 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -46,7 +46,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · ` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](../../docs/architecture-invariants.md#desktop-home-tab-rail) -**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module). +**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module). **Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order) diff --git a/src/web/public/foreign-sessions.js b/src/web/public/foreign-sessions.js index 24386752..5eb099be 100644 --- a/src/web/public/foreign-sessions.js +++ b/src/web/public/foreign-sessions.js @@ -21,6 +21,11 @@ * tab keeps sweeping tmux sockets forever. `startForeignPolling` / * `stopForeignPolling` are called from `showWelcome`/`hideWelcome`. * + * A tab hidden while it shows the home screen skips its ticks as well. The TTL sits + * under the poll interval, so one tab never hits the cache: every tick is a full + * sweep, one fork or more per socket, for a list nobody is reading. It catches up + * on `visibilitychange`, and the cache is what bounds a flurry of tab switches. + * * ⚠️ Docker and remote locations are NOT in that poll. Each costs a `docker exec` * or a full ssh handshake per target, each with a ~12s timeout, so riding them * along on an 8s tick stacks connections on a slow host, the one cost the @@ -78,11 +83,14 @@ Object.assign(CodemanApp.prototype, { */ startForeignPolling() { if (this._foreignPollTimer || this._foreignRefused) return; - void this.loadForeignSessions(); - this._foreignPollTimer = setInterval(() => { - // Local only: see @fileoverview. The wide scan never rides the timer. - void this.loadForeignSessions(); - }, this._foreignPollMs || FOREIGN_POLL_FALLBACK_MS); + this._foreignPollTick(); + this._foreignPollTimer = setInterval( + () => this._foreignPollTick(), + this._foreignPollMs || FOREIGN_POLL_FALLBACK_MS + ); + // Stored, unlike a poller made once: this runs on every trip to the home screen. + this._foreignOnVisible = () => this._foreignPollTick(); + document.addEventListener('visibilitychange', this._foreignOnVisible); }, /** Stop polling. MUST run on leaving the home screen, see @fileoverview. */ @@ -90,6 +98,15 @@ Object.assign(CodemanApp.prototype, { if (!this._foreignPollTimer) return; clearInterval(this._foreignPollTimer); this._foreignPollTimer = null; + document.removeEventListener('visibilitychange', this._foreignOnVisible); + this._foreignOnVisible = null; + }, + + /** One poll, and the one gate every poll passes: a hidden tab skips it, see @fileoverview. */ + _foreignPollTick() { + if (document.hidden) return; + // Local only: see @fileoverview. The wide scan never rides the timer. + void this.loadForeignSessions(); }, /** Whether the user asked for the expensive docker/remote scan too. */ diff --git a/test/foreign-sessions-ui.test.ts b/test/foreign-sessions-ui.test.ts index ad743457..a9c8e068 100644 --- a/test/foreign-sessions-ui.test.ts +++ b/test/foreign-sessions-ui.test.ts @@ -41,7 +41,7 @@ function stubEl(): Record { } /** The module is a classic script that mixes into CodemanApp.prototype. */ -function loadApp(window: Record = {}) { +function loadApp(window: Record = {}, page: Record = {}) { const host = stubEl(); const ctx: Record = { console, @@ -56,11 +56,13 @@ function loadApp(window: Record = {}) { clearInterval() {}, codemanT: null, window, - document: { + document: Object.assign(page, { createElement: () => stubEl(), getElementById: (id: string) => (id === 'foreignSessions' ? host : null), addEventListener() {}, - }, + removeEventListener() {}, + ...page, + }), CodemanApp: function () {} as unknown, }; vm.createContext(ctx); @@ -74,6 +76,8 @@ function loadApp(window: Record = {}) { _allForeignRows(): Array<{ id: string }>; renderAllForeignSessions(): void; startForeignPolling(): void; + stopForeignPolling(): void; + _foreignPollTick(): void; renderForeignSessions(el: unknown): void; _foreignRow(row: object): { children: Array<{ children: Array<{ textContent: string }> }> }; }; @@ -161,6 +165,84 @@ describe('the wide scan is one-shot', () => { }); }); +describe('a hidden tab does not sweep tmux sockets', () => { + // One scan forks once per socket and pane, and a tab left on the home screen in the + // background has nobody reading the list: 772 forks a tick was measured on a real box. + function harness(hidden: boolean) { + const listeners = new Set<() => void>(); + const page: Record = { + hidden, + addEventListener(type: string, listener: () => void) { + if (type === 'visibilitychange') listeners.add(listener); + }, + removeEventListener(type: string, listener: () => void) { + if (type === 'visibilitychange') listeners.delete(listener); + }, + }; + const app = loadApp({}, page); + const calls: string[] = []; + app._api = vi.fn(async (path: string) => { + calls.push(path); + return ok({ sessions: [LOCAL], notes: [], canScanWide: false }); + }); + return { app, calls, page, listeners }; + } + + it('makes no first load for a home screen that opened in the background', () => { + const { app, calls } = harness(true); + app.startForeignPolling(); + expect(calls).toHaveLength(0); + }); + + it('skips the tick while hidden and runs it again once visible', () => { + const { app, calls, page } = harness(false); + app.startForeignPolling(); + expect(calls).toHaveLength(1); + + page.hidden = true; + app._foreignPollTick(); + expect(calls).toHaveLength(1); + + page.hidden = false; + app._foreignPollTick(); + expect(calls).toHaveLength(2); + }); + + it('catches up the moment the tab comes back, without waiting out the interval', () => { + const { app, calls, page, listeners } = harness(true); + app.startForeignPolling(); + expect(listeners.size).toBe(1); + + page.hidden = false; + for (const listener of listeners) listener(); + + expect(calls).toEqual(['/api/mux/foreign']); + }); + + it('does not scan on the visibility event that hides the tab', () => { + const { app, calls, page, listeners } = harness(false); + app.startForeignPolling(); + expect(listeners.size).toBe(1); + + page.hidden = true; + for (const listener of listeners) listener(); + + expect(calls).toHaveLength(1); + }); + + it('holds one listener over repeated home screens and none after leaving', () => { + // showWelcome/hideWelcome run this on every trip, unlike a poller created once. + const { app, listeners } = harness(false); + for (let trip = 0; trip < 3; trip++) { + app.startForeignPolling(); + app.startForeignPolling(); + expect(listeners.size).toBe(1); + app.stopForeignPolling(); + expect(listeners.size).toBe(0); + } + }); +}); + describe('an adoption failure reports what the server said', () => { function adoptHarness(status: number, body: unknown) { const app = loadApp();