Files
arboretum/packages/server/test/pty-manager.test.ts
Johan LEROY c177eeea07 P2: découverte & reprise des sessions Claude
Arboretum découvre désormais toutes les sessions Claude de la machine
(scan ~/.claude/projects + registre ~/.claude/sessions), distingue
vivantes/mortes par pid+procStart, et permet de reprendre une morte
(--resume dans son cwd d'origine) ou forker une vivante sans la corrompre.

- shared: SessionSummary enrichi (source, claudeSessionId, pid, resumable,
  attachable, registryStatus) — additif, PROTOCOL_VERSION inchangé ;
  types REST resume/fork.
- db: migration id:2 (claude_session_id, resumed_from).
- core: jsonl-discovery (parseur tolérant, scan asynchrone non bloquant),
  session-registry (vivacité pid+procStart), discovery-service (cache +
  refresh périodique + diff/broadcast), pty-manager (resume/fork + capture
  du claudeSessionId via le registre).
- routes: /sessions/:id/resume (garde-fou 409 anti-corruption sur session
  vivante) et /fork ; GET fusionné managées + découvertes ; relais WS.
- web: badges managed/discovered + busy/idle/waiting, actions conditionnelles
  (Open/Observe/Kill vs Fork/View vs Resume/Fork), vue read-only des sessions
  externes, i18n EN/FR.
- tests: jsonl-discovery, session-registry, discovery-service + resume/fork
  (130 verts) ; acceptation E2E acceptance-p2.mjs (sans quota) ALL GREEN.

Conforme aux verdicts S1 (resume dans cwd d'origine, vivacité pid+procStart)
et S4 (munge cwd, parseur tête+queue, priorité de titre).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:20:21 +02:00

484 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { tmpdir } from 'node:os';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js';
import { openDb, type Db } from '../src/db/index.js';
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
interface FakePty {
pid: number;
file: string;
args: string[];
opts: { cwd: string; cols: number; rows: number };
write: Mock;
resize: Mock;
pause: Mock;
resume: Mock;
emitData(data: string): void;
emitExit(exitCode: number, signal?: number): void;
}
const ptyMock = vi.hoisted(() => ({ instances: [] as unknown[] }));
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
let nextPid = 100_000;
class FakePtyImpl {
pid = nextPid++;
write = vi.fn();
resize = vi.fn();
pause = vi.fn();
resume = vi.fn();
kill = vi.fn();
private dataCbs: Array<(d: string) => void> = [];
private exitCbs: Array<(e: { exitCode: number; signal?: number }) => void> = [];
constructor(
readonly file: string,
readonly args: string[],
readonly opts: unknown,
) {}
onData(cb: (d: string) => void): { dispose: () => void } {
this.dataCbs.push(cb);
return { dispose: () => {} };
}
onExit(cb: (e: { exitCode: number; signal?: number }) => void): { dispose: () => void } {
this.exitCbs.push(cb);
return { dispose: () => {} };
}
emitData(d: string): void {
for (const cb of this.dataCbs) cb(d);
}
emitExit(exitCode: number, signal?: number): void {
for (const cb of this.exitCbs) cb(signal === undefined ? { exitCode } : { exitCode, signal });
}
}
return {
default: {
spawn: (file: string, args: string[], opts: unknown): FakePtyImpl => {
const p = new FakePtyImpl(file, args, opts);
ptyMock.instances.push(p);
return p;
},
},
};
});
type BindingSpies = ClientBinding & {
sendOutput: Mock<(payload: Buffer) => void>;
sendResync: Mock<(payload: Buffer) => void>;
onDetached: Mock<(reason: 'session_exit' | 'replaced') => void>;
onControlChanged: Mock<(controlling: boolean) => void>;
};
let channelSeq = 1;
function makeBinding(mode: 'interactive' | 'observer'): BindingSpies {
return {
channel: channelSeq++,
mode,
controlling: false,
sentBytes: 0,
ackedBytes: 0,
lagging: false,
sendOutput: vi.fn(),
sendResync: vi.fn(),
onDetached: vi.fn(),
onControlChanged: vi.fn(),
};
}
function lastPty(): FakePty {
return ptyMock.instances.at(-1) as FakePty;
}
describe('PtyManager (pty mocké)', () => {
let db: Db;
let manager: PtyManager;
const cwd = tmpdir();
beforeEach(() => {
ptyMock.instances.length = 0;
db = openDb(':memory:');
manager = new PtyManager(db);
});
function spawnBash(): { summary: SessionSummary; pty: FakePty } {
const summary = manager.spawn({ cwd, command: 'bash' });
return { summary, pty: lastPty() };
}
describe('spawn', () => {
it('émet session_update, insère en base et lance le pty avec le bon spec', () => {
const updates: SessionSummary[] = [];
manager.on('session_update', (s) => updates.push(s));
const { summary, pty } = spawnBash();
expect(updates).toHaveLength(1);
expect(updates[0]).toEqual(summary);
expect(summary.status).toBe('running');
expect(summary.live).toBe(true);
expect(summary.clients).toBe(0);
expect(summary.command).toBe('bash');
expect(pty.file).toBe('bash');
expect(pty.args).toEqual(['--norc']);
expect(pty.opts.cwd).toBe(cwd);
expect(pty.opts.cols).toBe(120);
expect(pty.opts.rows).toBe(32);
const row = db.prepare('SELECT cwd, command FROM sessions WHERE id = ?').get(summary.id) as { cwd: string; command: string };
expect(row).toEqual({ cwd, command: 'bash' });
expect(manager.get(summary.id)?.id).toBe(summary.id);
});
it('cwd inexistant → erreur statusCode 400, rien en base', () => {
expect(() => manager.spawn({ cwd: '/definitely/not/a/dir', command: 'bash' })).toThrowError(
expect.objectContaining({ statusCode: 400 }),
);
const row = db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number };
expect(row.n).toBe(0);
});
it('summarize expose les champs P2 (source/attachable/resumable/pid)', () => {
const { summary } = spawnBash();
expect(summary).toMatchObject({
source: 'managed',
attachable: true,
resumable: false,
registryStatus: null,
claudeSessionId: null,
});
expect(summary.pid).toBeGreaterThan(0);
});
});
describe('resume / fork (P2)', () => {
it('resume → pty claude avec --resume, command forcé à claude, resumed_from persisté', () => {
const summary = manager.spawn({ cwd, resume: { claudeSessionId: 'abc-123' } });
const p = lastPty();
expect(summary.command).toBe('claude');
expect(summary.source).toBe('managed');
expect(summary.attachable).toBe(true);
expect(p.file).toBe('/usr/bin/claude');
expect(p.args).toEqual(['--resume', 'abc-123']);
const row = db.prepare('SELECT resumed_from, command FROM sessions WHERE id = ?').get(summary.id) as {
resumed_from: string | null;
command: string;
};
expect(row).toEqual({ resumed_from: 'abc-123', command: 'claude' });
});
it('fork → --resume <id> --fork-session', () => {
manager.spawn({ cwd, resume: { claudeSessionId: 'xyz', fork: true } });
expect(lastPty().args).toEqual(['--resume', 'xyz', '--fork-session']);
});
it('session claude morte avec claudeSessionId connu → resumable dans list()', () => {
const summary = manager.spawn({ cwd, resume: { claudeSessionId: 'sid-known' } });
// simule la capture du claudeSessionId (normalement résolue via le registre)
db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('sid-known', summary.id);
lastPty().emitExit(0);
const listed = manager.list().find((s) => s.id === summary.id);
expect(listed).toMatchObject({ live: false, resumable: true, claudeSessionId: 'sid-known' });
});
it('capture le claudeSessionId via le registre (poll par pid) → findLiveByClaudeSessionId', () => {
vi.useFakeTimers();
const sessDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
try {
const m = new PtyManager(db, sessDir);
const summary = m.spawn({ cwd, command: 'claude' });
const p = lastPty();
writeFileSync(join(sessDir, `${p.pid}.json`), JSON.stringify({ pid: p.pid, sessionId: 'captured-sid', status: 'idle' }));
expect(m.findLiveByClaudeSessionId('captured-sid')).toBeNull(); // pas encore capturé
vi.advanceTimersByTime(400);
const found = m.findLiveByClaudeSessionId('captured-sid');
expect(found?.id).toBe(summary.id);
expect(found?.claudeSessionId).toBe('captured-sid');
} finally {
vi.useRealTimers();
rmSync(sessDir, { recursive: true, force: true });
}
});
});
describe('attach', () => {
it('session inconnue → NOT_FOUND', () => {
const res = manager.attach('nope', makeBinding('interactive'), 80, 24);
expect(res).toEqual({ ok: false, code: 'NOT_FOUND' });
});
it('resync à lattach = queue du ring (REPLAY_TAIL_BYTES max)', () => {
const { summary, pty } = spawnBash();
const chunks = ['A', 'B', 'C'].map((c) => c.repeat(100 * 1024));
for (const c of chunks) pty.emitData(c);
const b = makeBinding('interactive');
const res = manager.attach(summary.id, b, 80, 24);
expect(res).toEqual({ ok: true, controlling: true });
expect(b.sendResync).toHaveBeenCalledTimes(1);
const payload = b.sendResync.mock.calls[0]![0]!;
const full = Buffer.from(chunks.join(''), 'ascii');
expect(payload.length).toBe(REPLAY_TAIL_BYTES);
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
// compteurs de flow control remis à zéro après le resync initial
expect(b.sentBytes).toBe(0);
expect(b.ackedBytes).toBe(0);
});
it('ring vide → resync avec payload vide', () => {
const { summary } = spawnBash();
const b = makeBinding('interactive');
manager.attach(summary.id, b, 80, 24);
expect(b.sendResync).toHaveBeenCalledTimes(1);
expect(b.sendResync.mock.calls[0]![0]!.length).toBe(0);
});
it('contrôle au premier interactif seulement, les observers ne comptent pas', () => {
const { summary, pty } = spawnBash();
const obs = makeBinding('observer');
const a = makeBinding('interactive');
const b = makeBinding('interactive');
expect(manager.attach(summary.id, obs, 80, 24)).toEqual({ ok: true, controlling: false });
expect(pty.resize).not.toHaveBeenCalled(); // un observer ne redimensionne pas
expect(manager.attach(summary.id, a, 100, 30)).toEqual({ ok: true, controlling: true });
expect(pty.resize).toHaveBeenCalledWith(100, 30); // le contrôleur impose sa taille
expect(manager.attach(summary.id, b, 200, 50)).toEqual({ ok: true, controlling: false });
expect(pty.resize).toHaveBeenCalledTimes(1); // pas de resize pour le non-contrôleur
expect(manager.get(summary.id)?.clients).toBe(3);
});
});
describe('write / resize', () => {
it('observer read-only : write refusé', () => {
const { summary, pty } = spawnBash();
const obs = makeBinding('observer');
manager.attach(summary.id, obs, 80, 24);
expect(manager.write(summary.id, obs, 'ls\r')).toBe('not_controlling');
expect(pty.write).not.toHaveBeenCalled();
});
it('tout interactif peut écrire (même non contrôleur), session inconnue → gone', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, b, 80, 24);
expect(manager.write(summary.id, a, 'echo a\r')).toBe('ok');
expect(manager.write(summary.id, b, 'echo b\r')).toBe('ok');
expect(pty.write).toHaveBeenCalledTimes(2);
expect(manager.write('nope', a, 'x')).toBe('gone');
});
it('resize ignoré pour les non-contrôleurs', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, b, 80, 24);
pty.resize.mockClear();
manager.resize(summary.id, b, 50, 20);
expect(pty.resize).not.toHaveBeenCalled();
manager.resize(summary.id, a, 90, 28);
expect(pty.resize).toHaveBeenCalledWith(90, 28);
});
});
describe('flow control', () => {
it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, b, 80, 24);
const chunk = 'x'.repeat(200 * 1024); // 200 Kio ASCII
pty.emitData(chunk); // a=200K, b=200K — sous HIGH (384K)
expect(pty.pause).not.toHaveBeenCalled();
manager.ack(summary.id, b, 200 * 1024); // b rattrape tout
pty.emitData(chunk); // a=400K > HIGH, b=200K < HIGH
expect(pty.pause).not.toHaveBeenCalled(); // un seul interactif au-dessus ne suffit pas
pty.emitData(chunk); // a=600K, b=400K → TOUS au-dessus de HIGH
expect(a.sentBytes - a.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
expect(b.sentBytes - b.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
expect(pty.pause).toHaveBeenCalledTimes(1);
expect(pty.resume).not.toHaveBeenCalled();
manager.ack(summary.id, b, 600 * 1024); // outstanding b = 0 < LOW
expect(pty.resume).toHaveBeenCalledTimes(1);
expect(pty.pause).toHaveBeenCalledTimes(1); // pas de re-pause intempestive
});
it('les observers ne freinent jamais le PTY', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const obs = makeBinding('observer');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, obs, 80, 24);
const chunk = 'y'.repeat(200 * 1024);
for (let i = 0; i < 3; i++) {
pty.emitData(chunk);
manager.ack(summary.id, a, a.sentBytes); // l'interactif suit le rythme
}
expect(obs.sentBytes - obs.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
expect(pty.pause).not.toHaveBeenCalled();
});
it('session sans interactif (observers seuls) : jamais de pause', () => {
const { summary, pty } = spawnBash();
const obs = makeBinding('observer');
manager.attach(summary.id, obs, 80, 24);
pty.emitData('z'.repeat(500 * 1024));
expect(pty.pause).not.toHaveBeenCalled();
});
});
describe('client lagging', () => {
it('> LAGGING_BYTES sans ACK → flux coupé, puis resync au rattrapage', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
const mib = 1024 * 1024;
const chunks = ['A', 'B', 'C', 'D'].map((c) => c.repeat(mib));
pty.emitData(chunks[0]!); // outstanding 1 Mio
pty.emitData(chunks[1]!); // outstanding 2 Mio — pas strictement > LAGGING_BYTES
expect(a.lagging).toBe(false);
expect(a.sendOutput).toHaveBeenCalledTimes(2);
pty.emitData(chunks[2]!); // outstanding 3 Mio > 2 Mio → lagging
expect(a.lagging).toBe(true);
expect(a.sendOutput).toHaveBeenCalledTimes(3); // ce chunk est parti avant le marquage
// le seul interactif est lagging : le PTY ne doit pas rester en pause
expect(pty.resume).toHaveBeenCalled();
pty.emitData(chunks[3]!); // flux coupé pour le client lagging
expect(a.sendOutput).toHaveBeenCalledTimes(3);
manager.ack(summary.id, a, 3 * mib); // rattrapage : outstanding 0 < LOW
expect(a.lagging).toBe(false);
expect(a.sendResync).toHaveBeenCalledTimes(2); // attach + rattrapage
const payload = a.sendResync.mock.calls[1]![0]!;
const full = Buffer.from(chunks.join(''), 'ascii');
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
expect(a.sentBytes).toBe(0);
expect(a.ackedBytes).toBe(0);
// le flux reprend normalement après le resync
pty.emitData('tail');
expect(a.sendOutput).toHaveBeenCalledTimes(4);
expect(a.sendOutput.mock.calls[3]![0]!.toString('ascii')).toBe('tail');
});
});
describe('detach et transfert de contrôle', () => {
it('detach du contrôleur → transfert au prochain interactif (onControlChanged)', () => {
const { summary } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
const obs = makeBinding('observer');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, obs, 80, 24);
manager.attach(summary.id, b, 80, 24);
expect(a.controlling).toBe(true);
expect(b.controlling).toBe(false);
manager.detach(summary.id, a);
expect(b.controlling).toBe(true);
expect(b.onControlChanged).toHaveBeenCalledExactlyOnceWith(true);
expect(obs.onControlChanged).not.toHaveBeenCalled();
expect(manager.get(summary.id)?.clients).toBe(2);
});
it('detach dun non-contrôleur : aucun transfert', () => {
const { summary } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, b, 80, 24);
manager.detach(summary.id, b);
expect(a.controlling).toBe(true);
expect(a.onControlChanged).not.toHaveBeenCalled();
});
it('detach du dernier interactif : un observer ne récupère jamais le contrôle', () => {
const { summary } = spawnBash();
const a = makeBinding('interactive');
const obs = makeBinding('observer');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, obs, 80, 24);
manager.detach(summary.id, a);
expect(obs.controlling).toBe(false);
expect(obs.onControlChanged).not.toHaveBeenCalled();
});
it('un nouvel interactif après detach du contrôleur reprend le contrôle', () => {
const { summary } = spawnBash();
const a = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.detach(summary.id, a);
const b = makeBinding('interactive');
expect(manager.attach(summary.id, b, 80, 24)).toEqual({ ok: true, controlling: true });
});
});
describe('exit', () => {
it('onDetached(session_exit) pour tous, DB mise à jour, événements émis', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const obs = makeBinding('observer');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, obs, 80, 24);
const exits: Array<{ sessionId: string; exitCode: number | null; signal: number | null }> = [];
const updates: SessionSummary[] = [];
manager.on('session_exit', (e) => exits.push(e));
manager.on('session_update', (s) => updates.push(s));
pty.emitExit(0);
expect(a.onDetached).toHaveBeenCalledExactlyOnceWith('session_exit');
expect(obs.onDetached).toHaveBeenCalledExactlyOnceWith('session_exit');
expect(exits).toEqual([{ sessionId: summary.id, exitCode: 0, signal: null }]);
expect(updates).toHaveLength(1);
expect(updates[0]).toMatchObject({ id: summary.id, status: 'exited', live: false, exitCode: 0 });
const row = db.prepare('SELECT ended_at, exit_code FROM sessions WHERE id = ?').get(summary.id) as { ended_at: string | null; exit_code: number | null };
expect(row.ended_at).not.toBeNull();
expect(row.exit_code).toBe(0);
expect(manager.get(summary.id)).toBeNull();
const listed = manager.list().find((s) => s.id === summary.id);
expect(listed).toMatchObject({ status: 'exited', live: false, exitCode: 0 });
});
it('exit avec signal : propagé dans session_exit', () => {
const { summary, pty } = spawnBash();
const exits: Array<{ sessionId: string; exitCode: number | null; signal: number | null }> = [];
manager.on('session_exit', (e) => exits.push(e));
pty.emitExit(1, 15);
expect(exits).toEqual([{ sessionId: summary.id, exitCode: 1, signal: 15 }]);
});
it('après exit : write → gone, attach → NOT_FOUND', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
pty.emitExit(0);
expect(manager.write(summary.id, a, 'x')).toBe('gone');
expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' });
});
});
});