mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
With the grid open the page's SSE filter still named the focused tile's session, so the server streamed that session's output over SSE as well. The main terminal is parked (its socket closed, _wsReady false), so every frame was JSON.parsed and then dropped by the park guard; the tile has the same output over its own socket. The filter now names TILE_GRID_SSE_FILTER (constants.js, a fixed id no session takes) while tiles own the terminal, at both places that set it: the live re-subscribe every tile focus runs (_updateSseSubscription) and the connect URL an SSE reconnect rebuilds (connectSSE), through one helper, _sseFilterSessionId(). Leaving the grid gives the filter back to the session shown (selectSession re-subscribes). Why it is safe, server side: the filter is read in exactly one place, SseStreamManager.flushSessionTerminalBatch. The connect route parses it, POST /api/events/subscribe replaces it (updateClientFilter); broadcast(), the multi-user ownership check (canDeliver), the heartbeat, the order and tab-layout frames and the shutdown notice never read it, and no push, viewing or acknowledgement logic does. The only page consumer of session:terminal is _onSSETerminal -> _onSessionTerminal, a no-op while tiles own the terminal. Measured at checkpoint 1 (6 tiles, focused tile a printing shell): 16 to 18 frames/s, 2.2 to 2.4 KB/s parsed and dropped -> 0. Live, this code (6 tiles, shells printing), SSE terminal frames per 5 s: 0 with the grid open; 0 after an SSE reconnect with the grid open (connect URL sessions=tile-grid); 86 in the single view after closing the grid and 86 after a reload into it (that connect URL names no session, as before; selectSession's re-subscribe names the shown one). Just before this commit: 18 frames/s, 2.4 KB/s. A tile focus runs no connectSSE and no handleInit; it posts the grid id. Tests: the page subscribes with the grid id on open and on every tile focus, gives the session back on close and on a reload into the single view, and connectSSE asks the same helper. Server, live, multi-user: the id is taken on the connect query and on a re-subscribe, and then withholds terminal output while session:updated and hook events still reach their owner (and only their owner). Mutation-checked five ways (helper ignoring the grid, connectSSE on the raw id, the server dropping non-UUID ids on subscribe and on connect, the filter gating every event). Scope: PR 2 (constants.js, the app.js SSE seam). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
88 lines
3.8 KiB
TypeScript
88 lines
3.8 KiB
TypeScript
/**
|
|
* @fileoverview While the tile grid owns the terminal, the page's SSE filter
|
|
* names TILE_GRID_SSE_FILTER (constants.js), which no session matches.
|
|
*
|
|
* The SSE filter gates only session:terminal batches (server side, pinned by
|
|
* test/sse-tile-grid-filter.test.ts). With the grid open those frames were for
|
|
* the focused tile alone and were only parsed to be dropped: the main terminal
|
|
* is parked and the tiles carry their own output over their own sockets. Both
|
|
* places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe
|
|
* (`_updateSseSubscription`, run by every tile focus) and the connect URL
|
|
* (`connectSSE`, rebuilt by every SSE reconnect with the grid still open).
|
|
* Leaving the grid gives the filter back to the session shown.
|
|
*
|
|
* Real code: the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import { fetchSpy, makeGridApp, resetGridHarness, windowStub, type GridApp } from './mocks/tile-grid-vm.js';
|
|
|
|
const IDS = ['s-a', 's-b', 's-c'];
|
|
const FILTER = (windowStub.CodemanTileGrid as { TILE_GRID_SSE_FILTER: string }).TILE_GRID_SSE_FILTER;
|
|
|
|
/** The app with the REAL _updateSseSubscription, and the filters it posted, in order. */
|
|
function makeApp(): GridApp {
|
|
const app = makeGridApp(IDS);
|
|
delete app._updateSseSubscription;
|
|
app._clientId = 'client-1';
|
|
return app;
|
|
}
|
|
const posted = () =>
|
|
fetchSpy.mock.calls
|
|
.filter(([url]) => url === '/api/events/subscribe')
|
|
.map(([, init]) => JSON.parse((init as { body: string }).body).sessions);
|
|
|
|
beforeEach(() => {
|
|
resetGridHarness();
|
|
fetchSpy.mockClear();
|
|
});
|
|
|
|
describe('the SSE filter while tiles own the terminal', () => {
|
|
it('is a fixed id no session can take', () => {
|
|
expect(FILTER).toBe('tile-grid');
|
|
// Session ids are UUIDs.
|
|
expect(FILTER).not.toMatch(/^[0-9a-f-]{36}$/);
|
|
});
|
|
|
|
it('opening the grid and every tile focus subscribe with it, never a session id', async () => {
|
|
const app = makeApp();
|
|
app.openTileGrid(IDS);
|
|
delete app.selectSession;
|
|
await app.selectSession('s-b');
|
|
await app.selectSession('s-c');
|
|
expect(posted().length).toBeGreaterThanOrEqual(3);
|
|
expect(new Set(posted().map((s: string[]) => s.join()))).toEqual(new Set([FILTER]));
|
|
});
|
|
|
|
it('leaving the grid gives the filter back to the session shown', () => {
|
|
const app = makeApp();
|
|
app.openTileGrid(IDS);
|
|
expect(app._sseFilterSessionId('s-a')).toBe(FILTER);
|
|
app.closeTileGrid({ keepStored: true, reselect: false });
|
|
// What the single view's selectSession (app.js) then posts.
|
|
app._updateSseSubscription('s-a');
|
|
expect(posted().at(-1)).toEqual(['s-a']);
|
|
expect(app._sseFilterSessionId('s-b')).toBe('s-b');
|
|
});
|
|
|
|
it('a page with no grid open (a reload into the single view) filters on the session as before', () => {
|
|
const app = makeApp();
|
|
expect(app._sseFilterSessionId('s-a')).toBe('s-a');
|
|
expect(app._sseFilterSessionId(null)).toBe(null);
|
|
app._updateSseSubscription('s-a');
|
|
expect(posted()).toEqual([['s-a']]);
|
|
});
|
|
|
|
it('the connect URL asks the same question, so an SSE reconnect with the grid open keeps the filter', () => {
|
|
// connectSSE builds an EventSource, which this harness has none of, so its
|
|
// URL building is read from source: one place, through the helper.
|
|
const app = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
|
const start = app.indexOf('const _sseParams = new URLSearchParams(');
|
|
expect(start).toBeGreaterThan(-1);
|
|
const block = app.slice(start, app.indexOf('this.eventSource = new EventSource(', start));
|
|
expect(block).toContain('this._sseFilterSessionId(this.activeSessionId)');
|
|
expect(block).not.toMatch(/set\('sessions', this\.activeSessionId\)/);
|
|
});
|
|
});
|