fix(v0.4): mirror size-clamp bug + session manager page
The mirror looked broken because a backgrounded tab on one device still pinned the shared PTY to its (default 80x24) size — so the device actively viewing got a cramped terminal. Fix: only an ACTIVELY-VIEWING client votes on PTY size. - attachWs no longer seeds a default size vote (a join may be a hidden mirror) - new 'blur' client message + clearClientDims(): a tab going hidden withdraws its size vote (still mirrors output); show() re-casts it - PTY size = min cols/rows across clients that have actually reported dims - session/protocol tests cover hidden-mirror-doesn't-clamp + blur Session manager (separate page, for the 'too many sessions' problem): - GET stays; add DELETE /live-sessions/:id and DELETE /live-sessions[?detached=1] - manager.killById(); LiveSessionInfo gains cols/rows - public/manage.html + manage.ts: list/open/kill sessions, kill-all / kill-detached, auto-refresh; 🗂 toolbar button; bundled as a 2nd esbuild entry Verified: two concurrent clients mirror output + shared input; manage page lists/kills (3→2→0). 225 tests green, tsc clean.
This commit is contained in:
@@ -33,9 +33,8 @@ vi.mock('node-pty', () => ({
|
||||
}));
|
||||
|
||||
// Imported AFTER vi.mock so the module under test binds to the mocked spawn.
|
||||
const { createSession, attachWs, detachWs, writeInput, setClientDims, kill } = await import(
|
||||
'../src/session/session.js'
|
||||
);
|
||||
const { createSession, attachWs, detachWs, writeInput, setClientDims, clearClientDims, kill } =
|
||||
await import('../src/session/session.js');
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
const CFG: Config = {
|
||||
@@ -133,7 +132,7 @@ describe('onData', () => {
|
||||
it('broadcasts output to the attached client as an `output` message', () => {
|
||||
const s = newSession();
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws, DIMS);
|
||||
attachWs(s, ws);
|
||||
ws.sent.length = 0; // drop the replay frame from attach
|
||||
|
||||
(s.pty as MockIPty).emitData('abc');
|
||||
@@ -145,8 +144,8 @@ describe('onData', () => {
|
||||
const s = newSession();
|
||||
const a = createMockWs();
|
||||
const b = createMockWs();
|
||||
attachWs(s, a, DIMS);
|
||||
attachWs(s, b, DIMS);
|
||||
attachWs(s, a);
|
||||
attachWs(s, b);
|
||||
a.sent.length = 0;
|
||||
b.sent.length = 0;
|
||||
|
||||
@@ -165,7 +164,7 @@ describe('onData', () => {
|
||||
it('does NOT send to a client whose readyState is not OPEN (M5)', () => {
|
||||
const s = newSession();
|
||||
const ws = createMockWs(WS_OPEN);
|
||||
attachWs(s, ws, DIMS);
|
||||
attachWs(s, ws);
|
||||
ws.sent.length = 0;
|
||||
|
||||
ws.readyState = 3; // CLOSED
|
||||
@@ -182,7 +181,7 @@ describe('attachWs', () => {
|
||||
(s.pty as MockIPty).emitData('prior output');
|
||||
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws, DIMS);
|
||||
attachWs(s, ws);
|
||||
|
||||
expect(s.clients.has(ws)).toBe(true);
|
||||
expect(received(ws)).toEqual([{ type: 'output', data: '\x1b[0mprior output' }]);
|
||||
@@ -191,10 +190,10 @@ describe('attachWs', () => {
|
||||
it('a second attach JOINS (does not kick) — both clients stay connected', () => {
|
||||
const s = newSession();
|
||||
const a = createMockWs();
|
||||
attachWs(s, a, DIMS);
|
||||
attachWs(s, a);
|
||||
|
||||
const b = createMockWs();
|
||||
attachWs(s, b, DIMS);
|
||||
attachWs(s, b);
|
||||
|
||||
// Both present; the first was NOT closed.
|
||||
expect(s.clients.has(a)).toBe(true);
|
||||
@@ -209,21 +208,54 @@ describe('attachWs', () => {
|
||||
expect(received(b)).toEqual([{ type: 'output', data: 'live' }]);
|
||||
});
|
||||
|
||||
it('resizes the PTY to the MIN dims across all clients (tmux-style)', () => {
|
||||
it('resizes the PTY to the MIN dims across active viewers (tmux-style)', () => {
|
||||
const s = newSession(); // spawn dims 80x24
|
||||
const wide = createMockWs();
|
||||
const narrow = createMockWs();
|
||||
|
||||
attachWs(s, wide, { cols: 200, rows: 50 });
|
||||
// one client at 200x50 → PTY grows to 200x50
|
||||
// Attach alone does NOT vote on size (a join could be a hidden mirror).
|
||||
attachWs(s, wide);
|
||||
expect(s.pty.cols).toBe(80);
|
||||
|
||||
// A client only constrains the PTY once it reports dims (it's viewing).
|
||||
setClientDims(s, wide, 200, 50);
|
||||
expect(s.pty.cols).toBe(200);
|
||||
expect(s.pty.rows).toBe(50);
|
||||
|
||||
attachWs(s, narrow, { cols: 90, rows: 30 });
|
||||
attachWs(s, narrow);
|
||||
setClientDims(s, narrow, 90, 30);
|
||||
// min(200,90)=90, min(50,30)=30
|
||||
expect(s.pty.cols).toBe(90);
|
||||
expect(s.pty.rows).toBe(30);
|
||||
});
|
||||
|
||||
it('a hidden mirror (no dims reported) does NOT clamp the shared PTY', () => {
|
||||
const s = newSession();
|
||||
const viewer = createMockWs();
|
||||
const hidden = createMockWs();
|
||||
|
||||
attachWs(s, viewer);
|
||||
setClientDims(s, viewer, 200, 50);
|
||||
attachWs(s, hidden); // joins but never reports dims (background tab)
|
||||
|
||||
// The hidden mirror must not drag the PTY down to the 80x24 default.
|
||||
expect(s.pty.cols).toBe(200);
|
||||
expect(s.pty.rows).toBe(50);
|
||||
});
|
||||
|
||||
it('clearClientDims withdraws a vote (tab hidden) and lets the PTY grow', () => {
|
||||
const s = newSession();
|
||||
const wide = createMockWs();
|
||||
const narrow = createMockWs();
|
||||
attachWs(s, wide);
|
||||
setClientDims(s, wide, 200, 50);
|
||||
attachWs(s, narrow);
|
||||
setClientDims(s, narrow, 90, 30); // clamps to 90x30
|
||||
|
||||
clearClientDims(s, narrow); // narrow's tab hidden → withdraw vote
|
||||
expect(s.pty.cols).toBe(200);
|
||||
expect(s.pty.rows).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ── detachWs (remove one client) ──────────────────────────────────────────────
|
||||
@@ -232,8 +264,8 @@ describe('detachWs', () => {
|
||||
const s = newSession();
|
||||
const a = createMockWs();
|
||||
const b = createMockWs();
|
||||
attachWs(s, a, DIMS);
|
||||
attachWs(s, b, DIMS);
|
||||
attachWs(s, a);
|
||||
attachWs(s, b);
|
||||
|
||||
detachWs(s, a, 5_000);
|
||||
// one client remains → still "attached", no detach stamp
|
||||
@@ -252,8 +284,10 @@ describe('detachWs', () => {
|
||||
const s = newSession();
|
||||
const wide = createMockWs();
|
||||
const narrow = createMockWs();
|
||||
attachWs(s, wide, { cols: 200, rows: 50 });
|
||||
attachWs(s, narrow, { cols: 90, rows: 30 }); // clamps to 90x30
|
||||
attachWs(s, wide);
|
||||
setClientDims(s, wide, 200, 50);
|
||||
attachWs(s, narrow);
|
||||
setClientDims(s, narrow, 90, 30); // clamps to 90x30
|
||||
|
||||
detachWs(s, narrow, 5_000);
|
||||
// only the wide client remains → PTY expands back to 200x50
|
||||
@@ -264,7 +298,7 @@ describe('detachWs', () => {
|
||||
it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => {
|
||||
const s = newSession();
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws, DIMS);
|
||||
attachWs(s, ws);
|
||||
detachWs(s, ws, 5_000);
|
||||
|
||||
(s.pty as MockIPty).emitData('background work');
|
||||
@@ -283,8 +317,8 @@ describe('onExit', () => {
|
||||
|
||||
const a = createMockWs();
|
||||
const b = createMockWs();
|
||||
attachWs(s, a, DIMS);
|
||||
attachWs(s, b, DIMS);
|
||||
attachWs(s, a);
|
||||
attachWs(s, b);
|
||||
a.sent.length = 0;
|
||||
b.sent.length = 0;
|
||||
|
||||
@@ -303,7 +337,7 @@ describe('onExit', () => {
|
||||
const s = createSession(CFG, DIMS, 1_000, onExit);
|
||||
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws, DIMS);
|
||||
attachWs(s, ws);
|
||||
detachWs(s, ws, 5_000);
|
||||
ws.sent.length = 0;
|
||||
|
||||
@@ -319,7 +353,7 @@ describe('onExit', () => {
|
||||
nextPty = createMockPty();
|
||||
const s = createSession(CFG, DIMS, 1_000, () => {});
|
||||
const ws = createMockWs(WS_OPEN);
|
||||
attachWs(s, ws, DIMS);
|
||||
attachWs(s, ws);
|
||||
ws.sent.length = 0;
|
||||
ws.readyState = 3; // CLOSED
|
||||
|
||||
@@ -340,7 +374,7 @@ describe('writeInput / setClientDims', () => {
|
||||
it('setClientDims forwards to pty.resize while alive', () => {
|
||||
const s = newSession();
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws, DIMS);
|
||||
attachWs(s, ws);
|
||||
setClientDims(s, ws, 100, 40);
|
||||
expect((s.pty as MockIPty).resizes).toContainEqual({ cols: 100, rows: 40 });
|
||||
});
|
||||
@@ -348,7 +382,7 @@ describe('writeInput / setClientDims', () => {
|
||||
it('setClientDims is idempotent: same min cols/rows are skipped', () => {
|
||||
const s = newSession();
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws, DIMS); // 80x24, equals spawn dims → no resize yet
|
||||
attachWs(s, ws); // 80x24, equals spawn dims → no resize yet
|
||||
expect((s.pty as MockIPty).resizes).toHaveLength(0);
|
||||
|
||||
setClientDims(s, ws, 90, 24); // changed → applied
|
||||
@@ -367,7 +401,7 @@ describe('writeInput / setClientDims', () => {
|
||||
it('ignores setClientDims once the PTY has exited (L4)', () => {
|
||||
const s = newSession();
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws, DIMS);
|
||||
attachWs(s, ws);
|
||||
(s.pty as MockIPty).emitExit(0);
|
||||
|
||||
setClientDims(s, ws, 200, 50);
|
||||
|
||||
Reference in New Issue
Block a user