mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
37dfb73b1e
commit
9714ee79e8
3 files changed
+108
-9
No files matched your search
+1
-1
@@ -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 … · <state> <duration>` 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)
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -41,7 +41,7 @@ function stubEl(): Record<string, unknown> {
|
||||
}
|
||||
|
||||
/** The module is a classic script that mixes into CodemanApp.prototype. */
|
||||
function loadApp(window: Record<string, unknown> = {}) {
|
||||
function loadApp(window: Record<string, unknown> = {}, page: Record<string, unknown> = {}) {
|
||||
const host = stubEl();
|
||||
const ctx: Record<string, unknown> = {
|
||||
console,
|
||||
@@ -56,11 +56,13 @@ function loadApp(window: Record<string, unknown> = {}) {
|
||||
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<string, unknown> = {}) {
|
||||
_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<string, unknown> = {
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user