fix(server, web, vscode): les terminaux ne restent plus noirs, panes côte à côte, git scopé au terminal
Un terminal pouvait rester tout noir alors que sa session tournait. Le PTY était vivant et avait bien écrit sa sortie : la panne était dans le transport. Le replay d'attache est une frame BINAIRE, mais un client n'apprend son numéro de canal qu'avec le message `attached` ; le serveur envoyait le replay AVANT, donc tout client le jetait sur un canal inconnu. Rien n'était peint, et un TUI au repos (Claude à son prompt) ne réémet jamais rien de lui-même. `attach()` renvoie désormais le replay et la gateway l'émet APRÈS `attached` : un seul correctif serveur répare le web, l'app de bureau et l'extension VS Code, qui portaient le même défaut client. Le resize de l'attache masquait le bug en provoquant un SIGWINCH, d'où son apparence intermittente. Seconde moitié du symptôme (« je tape et rien ne se passe ») : le dock montait avant la liste des sessions, en déduisait « non attachable » et s'attachait en observateur, à vie et en silence. Un pane n'attache plus avant de connaître sa session (`sessions.loaded`). Attaches sans écran : le message `attach` accepte un `screen` optionnel (défaut true). Un client qui n'affiche rien et veut seulement répondre à un dialogue ne prend plus le contrôle de la session, ne lui impose plus ses dimensions (ce qui figeait la géométrie du vrai terminal) et ne reçoit plus le flux pour le jeter. Rendre les pannes visibles : la raison d'un exit est écrite dans le terminal (`[arboretum] bash exited with code 3`) avant le détachement ; un repaint est forcé si rien n'arrive 1,2 s après l'attache, puis annoncé avec « Refresh screen » ; les refus de canal remontent à l'écran au lieu d'un console.warn ; le chemin du CLI claude est revalidé (périmé après une bascule nvm/asdf, le PTY mourait sans un octet). Colonnes de terminaux : le dock devient une rangée de colonnes redimensionnables (3 max), chacune avec ses onglets. Algèbre pure dans lib/dock-model.ts, cinq invariants documentés, ratios plutôt que pixels. `dockSessionIds` et `activeDockSessionId` deviennent des computed dérivés : aucun consommateur ni test existant ne change. Alt+clic ouvre à côté depuis les quatre panneaux. Le plafond de hauteur du dock suit le viewport au lieu d'un 640 px figé. Correctif préexistant au passage : PanelSplitter passait ses bornes par valeur, figées au premier rendu, alors que le clavier les relisait. Portée git : la vue Changements suit le worktree du terminal focalisé, ou tous les dépôts de son groupe pour une session de groupe, avec « tout voir » à un clic. L'index Git de la sidebar reste global (c'est la sortie d'une portée étroite) et le badge d'activité aussi (il sert à signaler le travail qu'on ne regarde pas). Seul le TERMINAL impose le contexte : le repli sur l'onglet éditeur, essayé d'abord, rétrécissait la vue multi-projet dès qu'on ouvrait un fichier. Vérifications : acceptance-p17.mjs prouve l'ordre des trames sur un vrai WebSocket (avec l'ancien ordre : 0 octet rejoué, échec), verify-terminals.mjs prouve par interaction réelle que le terminal peint, que deux colonnes coexistent, que la frappe atteint le bon PTY (fichier témoin par cwd) et que la vue suit le terminal.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env node
|
||||
// Acceptation P17 : « le terminal reste tout noir alors que la session tourne ».
|
||||
//
|
||||
// Cause racine reproduite ici : le replay d'attache est une frame BINAIRE, et le client n'apprend le
|
||||
// numéro de canal qu'avec le message `attached`. Émis AVANT, le replay tombait sur un canal inconnu et
|
||||
// était jeté en silence : rien à l'écran jusqu'au prochain octet spontané du PTY, c'est-à-dire jamais
|
||||
// pour un TUI au repos. Ce script vérifie l'ordre réel des trames sur un VRAI WebSocket, et couvre au
|
||||
// passage les attaches sans écran et l'épilogue de sortie.
|
||||
//
|
||||
// Aucun quota Claude consommé : commande `bash`.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const PORT = 7549;
|
||||
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const results = [];
|
||||
const check = (name, ok, detail = '') => {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
|
||||
};
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'arb-p17-'));
|
||||
const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--no-discover'], {
|
||||
env: { ...process.env, ARBORETUM_LOG: 'warn' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let srvOut = '';
|
||||
srv.stdout.on('data', (d) => (srvOut += d));
|
||||
srv.stderr.on('data', (d) => (srvOut += d));
|
||||
|
||||
/**
|
||||
* Client WS qui conserve la CHRONOLOGIE des trames (`frames`), texte et binaire mêlés : c'est le seul
|
||||
* moyen de tester un ordre. Les frames binaires sont décodées en {type, channel, payload}.
|
||||
*/
|
||||
function wsClient(cookie) {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||
ws.binaryType = 'arraybuffer';
|
||||
const frames = [];
|
||||
const msgs = [];
|
||||
ws.on('message', (data, isBinary) => {
|
||||
if (!isBinary) {
|
||||
const msg = JSON.parse(String(data));
|
||||
msgs.push(msg);
|
||||
frames.push({ kind: 'text', msg });
|
||||
return;
|
||||
}
|
||||
const buf = Buffer.from(data);
|
||||
frames.push({ kind: 'binary', type: buf.readUInt8(0), channel: buf.readUInt32LE(1), payload: buf.subarray(5) });
|
||||
});
|
||||
const waitMsg = async (pred, timeout = 8000) => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeout) {
|
||||
const m = msgs.find(pred);
|
||||
if (m) return m;
|
||||
await sleep(50);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
/**
|
||||
* Sortie telle que le VRAI client la peindrait : il n'enregistre un canal qu'en recevant `attached`
|
||||
* et jette toute frame binaire arrivée avant. On imite ce comportement, sinon ce script verrait un
|
||||
* écran que le navigateur, lui, n'affiche pas.
|
||||
*/
|
||||
const outputOf = (channel) => {
|
||||
const known = frames.findIndex((f) => f.kind === 'text' && f.msg.type === 'attached' && f.msg.channel === channel);
|
||||
if (known < 0) return '';
|
||||
return frames
|
||||
.slice(known)
|
||||
.filter((f) => f.kind === 'binary' && f.channel === channel)
|
||||
.map((f) => f.payload.toString('latin1'))
|
||||
.join('');
|
||||
};
|
||||
return { ws, frames, msgs, waitMsg, outputOf, send: (m) => ws.send(JSON.stringify(m)) };
|
||||
}
|
||||
|
||||
try {
|
||||
await sleep(1500);
|
||||
const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0];
|
||||
check('boot + token bootstrap', !!token);
|
||||
|
||||
const login = await fetch(`${ORIGIN}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? '';
|
||||
check('login → cookie', login.status === 200 && cookie.startsWith('arb_session='));
|
||||
|
||||
const api = (path, init = {}) =>
|
||||
fetch(`${ORIGIN}${path}`, { ...init, headers: { 'Content-Type': 'application/json', Origin: ORIGIN, Cookie: cookie, ...(init.headers ?? {}) } });
|
||||
|
||||
const created = await api('/api/v1/sessions', { method: 'POST', body: JSON.stringify({ cwd: tmp, command: 'bash' }) });
|
||||
const sid = (await created.json()).session.id;
|
||||
check('spawn bash', created.status === 201 && !!sid);
|
||||
|
||||
// --- 1. Première attache : de la sortie existe déjà dans le ring ---
|
||||
const c1 = wsClient(cookie);
|
||||
await new Promise((res, rej) => (c1.ws.on('open', res), c1.ws.on('error', rej)));
|
||||
c1.send({ type: 'hello', protocol: 1 });
|
||||
await c1.waitMsg((m) => m.type === 'hello_ok');
|
||||
|
||||
c1.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 120, rows: 32 });
|
||||
const att1 = await c1.waitMsg((m) => m.type === 'attached');
|
||||
check('attach interactif + controlling', att1?.controlling === true);
|
||||
await sleep(300);
|
||||
c1.send({ type: 'stdin', channel: att1.channel, data: 'echo MARQUEUR-ECRAN-1\r' });
|
||||
await sleep(800);
|
||||
check('stdin → output', c1.outputOf(att1.channel).includes('MARQUEUR-ECRAN-1'));
|
||||
|
||||
// --- 2. Ré-attache (nouvelle connexion, PTY silencieux) : l'écran DOIT revenir ---
|
||||
// C'est le scénario vécu : l'app est rechargée, Claude est à son prompt et n'émet plus rien.
|
||||
const c2 = wsClient(cookie);
|
||||
await new Promise((res, rej) => (c2.ws.on('open', res), c2.ws.on('error', rej)));
|
||||
c2.send({ type: 'hello', protocol: 1 });
|
||||
await c2.waitMsg((m) => m.type === 'hello_ok');
|
||||
c2.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 120, rows: 32 });
|
||||
const att2 = await c2.waitMsg((m) => m.type === 'attached');
|
||||
await sleep(400);
|
||||
|
||||
const idxAttached = c2.frames.findIndex((f) => f.kind === 'text' && f.msg.type === 'attached');
|
||||
const idxResync = c2.frames.findIndex((f) => f.kind === 'binary' && f.type === 0x02);
|
||||
check('ORDRE : `attached` précède le replay binaire', idxAttached >= 0 && idxResync > idxAttached, `attached@${idxAttached}, resync@${idxResync}`);
|
||||
check('le replay porte le canal annoncé', c2.frames[idxResync]?.channel === att2.channel);
|
||||
const replay = c2.outputOf(att2.channel);
|
||||
check('l’écran se reconstitue à la ré-attache (fin de l’écran noir)', replay.includes('MARQUEUR-ECRAN-1'), `${replay.length} octets rejoués`);
|
||||
|
||||
// --- 3. Un observateur peint aussi : il doit recevoir son replay ---
|
||||
const c3 = wsClient(cookie);
|
||||
await new Promise((res, rej) => (c3.ws.on('open', res), c3.ws.on('error', rej)));
|
||||
c3.send({ type: 'hello', protocol: 1 });
|
||||
await c3.waitMsg((m) => m.type === 'hello_ok');
|
||||
c3.send({ type: 'attach', sessionId: sid, mode: 'observer', cols: 100, rows: 30 });
|
||||
const att3 = await c3.waitMsg((m) => m.type === 'attached');
|
||||
await sleep(400);
|
||||
check('un observateur reçoit son replay (non-controlling)', att3?.controlling === false && c3.outputOf(att3.channel).includes('MARQUEUR-ECRAN-1'));
|
||||
|
||||
// --- 4. Attache SANS écran : ne vole pas le contrôle, ne reçoit rien ---
|
||||
// Régression : le DialogPrompt attachait en interactif avec des dimensions bidon, prenait le
|
||||
// `controlling` et figeait la géométrie du TUI pour le terminal ouvert ensuite.
|
||||
const c4 = wsClient(cookie);
|
||||
await new Promise((res, rej) => (c4.ws.on('open', res), c4.ws.on('error', rej)));
|
||||
c4.send({ type: 'hello', protocol: 1 });
|
||||
await c4.waitMsg((m) => m.type === 'hello_ok');
|
||||
c4.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 80, rows: 24, screen: false });
|
||||
const att4 = await c4.waitMsg((m) => m.type === 'attached');
|
||||
await sleep(300);
|
||||
check('attache sans écran : jamais controlling', att4?.controlling === false);
|
||||
check('attache sans écran : aucune frame binaire', !c4.frames.some((f) => f.kind === 'binary'));
|
||||
|
||||
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo APRES-AVEUGLE\r' });
|
||||
await sleep(800);
|
||||
check('attache sans écran : ne reçoit pas la sortie du PTY', !c4.frames.some((f) => f.kind === 'binary'));
|
||||
check('le terminal à écran garde le contrôle et fonctionne', c2.outputOf(att2.channel).includes('APRES-AVEUGLE'));
|
||||
// elle garde en revanche le droit d'écrire (c'est sa seule raison d'être)
|
||||
c4.send({ type: 'stdin', channel: att4.channel, data: 'echo ECRIT-PAR-AVEUGLE\r' });
|
||||
await sleep(800);
|
||||
check('attache sans écran : peut écrire', c2.outputOf(att2.channel).includes('ECRIT-PAR-AVEUGLE'));
|
||||
|
||||
// --- 5. Épilogue de sortie : la raison de la mort est visible DANS le terminal ---
|
||||
const dying = await api('/api/v1/sessions', { method: 'POST', body: JSON.stringify({ cwd: tmp, command: 'bash' }) });
|
||||
const dsid = (await dying.json()).session.id;
|
||||
const c5 = wsClient(cookie);
|
||||
await new Promise((res, rej) => (c5.ws.on('open', res), c5.ws.on('error', rej)));
|
||||
c5.send({ type: 'hello', protocol: 1 });
|
||||
await c5.waitMsg((m) => m.type === 'hello_ok');
|
||||
c5.send({ type: 'attach', sessionId: dsid, mode: 'interactive', cols: 80, rows: 24 });
|
||||
const att5 = await c5.waitMsg((m) => m.type === 'attached');
|
||||
await sleep(300);
|
||||
c5.send({ type: 'stdin', channel: att5.channel, data: 'exit 3\r' });
|
||||
const detached5 = await c5.waitMsg((m) => m.type === 'detached' && m.channel === att5.channel, 8000);
|
||||
const epilogue = c5.outputOf(att5.channel);
|
||||
check('épilogue : le code de sortie est écrit dans le terminal', epilogue.includes('[arboretum]') && epilogue.includes('exited with code 3'), epilogue.slice(-60).replace(/[\r\n]+/g, ' '));
|
||||
check('épilogue reçu AVANT le detached', !!detached5);
|
||||
|
||||
// --- 6. Le PTY n'a pas été redimensionné par les attaches sans écran ---
|
||||
const listed = await (await api('/api/v1/sessions')).json();
|
||||
check('session toujours vivante après tout ça', listed.sessions.some((s) => s.id === sid && s.live));
|
||||
|
||||
for (const c of [c1, c2, c3, c4, c5]) c.ws.close();
|
||||
} catch (err) {
|
||||
check('exception', false, String(err));
|
||||
} finally {
|
||||
srv.kill('SIGTERM');
|
||||
await sleep(1500);
|
||||
check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.exitCode === null);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
if (failed.length > 0) console.log(`\n--- sortie du daemon ---\n${srvOut.slice(-2000)}`);
|
||||
console.log(failed.length === 0 ? '\nACCEPTANCE P17: ALL GREEN' : `\nACCEPTANCE P17: ${failed.length} FAILURE(S)`);
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
}
|
||||
Reference in New Issue
Block a user