diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a6dc7e64..e73e49a8 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -809,7 +809,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`. -⚠️ **One load queue.** Every capture a tile fetches (initial load, refresh after a reconnect, server `{t:'r'}` refresh, shell history pull) goes through the grid's ONE `TileLoadQueue` (terminal-tile.js, the tile's `scheduleLoad` option): concurrency 1, a history pull first, then the focused tile, then reading order; a destroyed tile's waiting loads are dropped unrun and destroy() aborts its running fetch. A load holds the slot only while xterm parses its replay: `writeChunked` queues every 32 KB slice at once (one 1 MiB window at a time, since xterm's write queue throws past 50 MB) and settles on the callback of a write queued behind them, never one slice per animation frame (that pacing held the slot about a second per 1 MiB tile). ⚠️ destroy() settles a replay in progress (`_cancelReplay`): a disposed xterm never runs that callback, and an unsettled replay would hold the tile's flag and the grid's queue forever. Each `GET /api/sessions/:id/terminal` runs synchronous tmux calls, so N tiles loading at once (after a deploy restart all N reopen within a second) would stall every WebSocket and SSE stream on the server back to back. Grid tiles load the BOUNDED window (`full=1&tail=TERMINAL_TAIL_SIZE` for a TUI, `tail=` for a shell; the `boundedLoad` option), and every full capture of theirs (a TUI load, a shell history pull) also sends `lines=`, so tmux reads no more history than the tile keeps instead of the whole history limit (the route's optional `lines` bound, docs/api-reference.md; the split's Pane B sends none), keep `TILE_SCROLLBACK` lines and their own per-device font (`codeman-tile-font-size`, Ctrl +/- while the grid is open). A refresh clears at its turn, not when asked, so a waiting tile keeps its last frame. +⚠️ **One load queue.** Every capture a tile fetches (initial load, refresh after a reconnect, server `{t:'r'}` refresh, server `{t:'c'}` refresh, dropped-output recovery refresh, shell history pull) goes through the grid's ONE `TileLoadQueue` (terminal-tile.js, the tile's `scheduleLoad` option): concurrency 1, a history pull first, then the focused tile, then reading order; a destroyed tile's waiting loads are dropped unrun and destroy() aborts its running fetch. A load holds the slot only while xterm parses its replay: `writeChunked` queues every 32 KB slice at once (one 1 MiB window at a time, since xterm's write queue throws past 50 MB) and settles on the callback of a write queued behind them, never one slice per animation frame (that pacing held the slot about a second per 1 MiB tile). ⚠️ destroy() settles a replay in progress (`_cancelReplay`): a disposed xterm never runs that callback, and an unsettled replay would hold the tile's flag and the grid's queue forever. Each `GET /api/sessions/:id/terminal` runs synchronous tmux calls, so N tiles loading at once (after a deploy restart all N reopen within a second) would stall every WebSocket and SSE stream on the server back to back. Grid tiles load the BOUNDED window (`full=1&tail=TERMINAL_TAIL_SIZE` for a TUI, `tail=` for a shell; the `boundedLoad` option), and every full capture of theirs (a TUI load, a shell history pull) also sends `lines=`, so tmux reads no more history than the tile keeps instead of the whole history limit (the route's optional `lines` bound, docs/api-reference.md; the split's Pane B sends none), keep `TILE_SCROLLBACK` lines and their own per-device font (`codeman-tile-font-size`, Ctrl +/- while the grid is open). A refresh fetches at its turn, not when asked, and resets the screen with the queued in-stream `\x1bc` only once its capture is in hand, right before the replay (never xterm's `clear()` before the fetch), so a tile keeps its last frame through its wait and its own round trip; a failed, aborted or empty fetch writes nothing and resets nothing, and the tile keeps its last frame and every held live frame. ⚠️ **Selections.** The tile branch of `selectSession` sits right after its "already active" early return: a tiled id is FOCUSED (`_selectTiledSession`: no cleanup, replay, resize or main socket; the shared `_refreshSessionPanels`; only a user-initiated pick acknowledges the idle alert). A USER-initiated pick of a session that is not tiled leaves the grid for the single view, remembered (decision 1), and so does `leaveTiles` (a followed `#session=` link); an `auto` pick never collapses it. Whoever calls `closeTileGrid({ reselect: false })` and then selects must null `activeSessionId` first, or `_cleanupPreviousSession` saves the parked terminal's stale content as a snapshot. App-driven fallbacks pick a tile: `closeSession` captures the neighbouring tile BEFORE its await (like `wasActive`; the delete broadcast may already have removed the tile) and focuses it with `auto`; the `_onSessionDeleted` wrapper does the same for a delete from elsewhere, and leaves a close from this tab (`_closingSessions`) to `closeSession`. Ctrl+Tab and Alt+[ ] cycle the tiles. A popped-out (detached) session leaves the grid. Moving focus off a zoomed tile restores the grid (tmux `select-pane`); an automatic zoom (window too small for the minimum tile) follows focus instead. diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index 4c375a27..d91f89ca 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -30,8 +30,11 @@ or settled a question the spec left open. The invariants as built are in - **Zoom follows tmux.** Moving focus to another tile restores the grid; an automatic zoom (window too small for the minimum tile) follows focus instead. - **Tile loads are bounded** (`boundedLoad`), carry a fetch deadline covering the body (Pane - B too), and a refresh clears the screen at its turn in the queue, so a waiting tile keeps - its last frame. + B too), and a refresh fetches at its turn in the queue: the tile keeps its last frame + through its wait and its own round trip, and is reset with the queued in-stream `\x1bc` + only once the capture is in hand, right before the replay (never xterm's `clear()` before + the fetch). A failed, aborted or empty fetch writes nothing and resets nothing: the tile + keeps its last frame and every held live frame. - **4009 lands on the Attach overlay**, and 4003/4004/4010 remove the tile. - **Tile header buttons are 26px targets with 16 to 19px glyphs** (owner feedback: the first build's 12px glyphs read as tiny next to the name), the size of the app header's own diff --git a/test/terminal-tile-input.test.ts b/test/terminal-tile-input.test.ts index 41231a77..a670228c 100644 --- a/test/terminal-tile-input.test.ts +++ b/test/terminal-tile-input.test.ts @@ -1040,12 +1040,33 @@ describe('TerminalTile live-output flow control: a flood cannot pile up in xterm fetchMock.mockClear(); term.throwOnWrite = 'boom'; out(ws, 'boom'); + const flow = tile as unknown as { _dropRecoveryTimer: unknown; _liveDropped: boolean }; + expect(flow._dropRecoveryTimer).not.toBeNull(); // the drop armed one recovery tile.destroy(); + // Cleared by destroy() itself, read before any timer runs: the timer's own + // callback nulls the field and its destroyed guard skips the fetch, so the + // fetch check below alone cannot tell a cleared timer from a leaked one. + expect(flow._dropRecoveryTimer).toBeNull(); + expect(flow._liveDropped).toBe(false); await vi.advanceTimersByTimeAsync(5000); expect(fetchMock).not.toHaveBeenCalled(); }); + + it('destroy() starts the count over: a write callback xterm still owed counts nothing', async () => { + TileStatics.LIVE_BACKLOG_BUDGET = 100; + const { tile, ws, term } = await connectTile(makeApp()); + ws.open(); + term.holdParse = true; + out(ws, 'a'.repeat(60)); + expect(inFlight(tile)).toBe(60); + + tile.destroy(); + expect(inFlight(tile)).toBe(0); + term.parse(); // a callback from before destroy() carries the old epoch + expect(inFlight(tile)).toBe(0); + }); }); describe('the server coming back kicks Pane B', () => {