Files
arboretum/packages/web/src/lib/ws-client.ts
T
johanleroy 8aea0ae32d 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.
2026-08-05 10:56:42 +02:00

570 lines
21 KiB
TypeScript

// Client WebSocket multiplexé : UNE seule connexion pour toute l'app.
// Handshake hello/hello_ok, reconnexion avec backoff (ré-attache des terminaux
// ouverts + re-sub), corrélation FIFO des 'attached', flow control par ACK
// (cumul d'octets traités PAR CANAL, remis à zéro à chaque RESYNC).
import { ref, type Ref } from 'vue';
import {
BINARY_FRAME,
FLOW,
PROTOCOL_VERSION,
decodeBinaryFrame,
type ClientMessage,
type ServerMessage,
} from '@arboretum/shared';
export type WsStatus = 'idle' | 'connecting' | 'open' | 'reconnecting';
export type DetachReason = 'client' | 'session_exit' | 'replaced';
/** Interface minimale attendue côté terminal (implémentée par xterm). */
export interface TerminalSink {
/** le callback est invoqué quand xterm a réellement traité le chunk (base du flow control) */
write(data: Uint8Array, callback?: () => void): void;
/** reset complet avant le replay d'une frame RESYNC */
reset(): void;
onDetached(reason: DetachReason): void;
onControlChanged(controlling: boolean): void;
/**
* Erreur serveur portant SUR ce canal (frappe refusée, canal inconnu). Optionnel, mais sans lui
* ces refus se perdaient dans un `console.warn` : l'utilisateur tapait et rien ne se passait.
*/
onChannelError?(code: string, message: string): void;
}
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' | 'session_archived' }>;
export type WorktreeEvent = Extract<ServerMessage, { type: 'repo_update' | 'repo_removed' | 'worktree_update' | 'worktree_removed' }>;
export type GroupEvent = Extract<ServerMessage, { type: 'group_update' | 'group_removed' }>;
/** P11 : un réglage a changé (diffusé au topic 'settings'). */
export type SettingsUpdateEvent = Extract<ServerMessage, { type: 'settings_update' }>;
/** P12 : progression/fin d'un clone (diffusée au topic 'clones'). */
export type CloneEvent = Extract<ServerMessage, { type: 'clone_update' }>;
/** P7 : signal ciblé « le détail (changes/diff) d'un worktree regardé a changé ». */
export type WorktreeChangesEvent = Extract<ServerMessage, { type: 'worktree_changes' }>;
export interface AttachOptions {
sessionId: string;
mode: 'interactive' | 'observer';
cols: number;
rows: number;
sink: TerminalSink;
/** false = attache d'écriture seule, sans affichage (cf. le champ `screen` du protocole). */
screen?: boolean;
}
const BACKOFF_MIN_MS = 500;
const BACKOFF_MAX_MS = 10_000;
export class Attachment {
/** canal courant : réassigné à chaque reconnexion, -1 tant que non attaché */
channel = -1;
controlling = false;
closed = false;
cols: number;
rows: number;
/** époque de resync : invalide les callbacks d'écritures encore en vol après un reset */
epoch = 0;
/** octets de payload réellement traités par le terminal depuis le dernier resync */
processedBytes = 0;
/** dernier cumul envoyé en ack (repart à 0 à chaque resync, comme côté serveur) */
lastAckBytes = 0;
/** ACK traînant : garantit l'envoi du reliquat même quand le flux s'arrête (anti-deadlock) */
trailingAckTimer: ReturnType<typeof setTimeout> | null = null;
/** résolution du premier 'attached' de ce canal (corrélation FIFO) */
pending: { resolve: (a: Attachment) => void; reject: (err: Error) => void } | null = null;
constructor(
private readonly client: WsClient,
readonly sessionId: string,
readonly mode: 'interactive' | 'observer',
readonly sink: TerminalSink,
cols: number,
rows: number,
readonly screen = true,
) {
this.cols = cols;
this.rows = rows;
}
sendStdin(data: string): void {
if (this.closed || this.channel < 0) return;
this.client.sendControl({ type: 'stdin', channel: this.channel, data });
}
/** Répond à un dialogue Claude (P4-A) sans clavier ; le serveur traduit en keystrokes. */
answer(action: 'select' | 'confirm' | 'deny', optionN?: number): void {
if (this.closed || this.channel < 0) return;
this.client.sendControl(
action === 'select' && optionN !== undefined
? { type: 'answer', channel: this.channel, action, optionN }
: { type: 'answer', channel: this.channel, action },
);
}
/** mémorise toujours la taille (réutilisée à la ré-attache) ; n'envoie que si controlling */
resize(cols: number, rows: number): void {
this.cols = cols;
this.rows = rows;
if (this.closed || this.channel < 0 || !this.controlling) return;
this.client.sendControl({ type: 'resize', channel: this.channel, cols, rows });
}
detach(): void {
if (this.closed) return;
this.closed = true;
this.pending = null;
this.client.releaseAttachment(this);
}
/** Flush ACK ciblé : relance immédiatement le flux si le serveur avait mis ce PTY en pause
* (ex. retour-visible de la cellule, dont le rendu était gelé hors-écran). N'ACK que du traité. */
flushAck(): void {
this.client.flushAck(this);
}
}
export class WsClient {
readonly status: Ref<WsStatus> = ref('idle');
private socket: WebSocket | null = null;
private ready = false;
private stopped = true;
private visibilityBound = false;
private backoffMs = BACKOFF_MIN_MS;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
/** terminaux ouverts, ré-attachés à chaque reconnexion (ordre d'insertion = ordre FIFO) */
private readonly attachments = new Set<Attachment>();
private readonly byChannel = new Map<number, Attachment>();
/** FIFO des attaches en attente : le serveur répond aux 'attach' dans l'ordre */
private awaitingAttached: Attachment[] = [];
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>();
private readonly groupListeners = new Set<(e: GroupEvent) => void>();
private readonly settingsListeners = new Set<(e: SettingsUpdateEvent) => void>();
private readonly cloneListeners = new Set<(e: CloneEvent) => void>();
/** P7 : abonnements ciblés au détail d'un worktree (clé repoId\0path → listeners). */
private readonly worktreeChangesListeners = new Map<string, Set<(e: WorktreeChangesEvent) => void>>();
connect(): void {
this.stopped = false;
// Au retour en avant-plan, le rendu se débloque : on flushe les ACK pour ne pas attendre le
// débounce si le serveur s'était mis en pause pendant que l'onglet/PWA était caché.
if (!this.visibilityBound && typeof document !== 'undefined') {
this.visibilityBound = true;
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') this.flushAcks();
});
}
if (this.socket || this.reconnectTimer) return;
if (this.status.value === 'idle') this.status.value = 'connecting';
this.openSocket();
}
disconnect(): void {
this.stopped = true;
this.clearReconnectTimer();
const socket = this.socket;
this.socket = null;
this.ready = false;
this.status.value = 'idle';
for (const att of this.attachments) {
att.closed = true;
att.pending?.reject(new Error('disconnected'));
att.pending = null;
att.sink.onDetached('client');
}
this.attachments.clear();
this.byChannel.clear();
this.awaitingAttached = [];
socket?.close();
}
/** résout sur le prochain 'attached' corrélé ; rejette si la session est introuvable/terminée */
attach(opts: AttachOptions): Promise<Attachment> {
const att = new Attachment(this, opts.sessionId, opts.mode, opts.sink, opts.cols, opts.rows, opts.screen ?? true);
const promise = new Promise<Attachment>((resolve, reject) => {
att.pending = { resolve, reject };
});
this.attachments.add(att);
this.connect();
if (this.ready) this.sendAttach(att);
return promise;
}
/** topics actifs = union des abonnements courants (une seule connexion partagée). */
private activeTopics(): Array<'sessions' | 'worktrees' | 'groups' | 'settings' | 'clones'> {
const t: Array<'sessions' | 'worktrees' | 'groups' | 'settings' | 'clones'> = [];
if (this.sessionListeners.size > 0) t.push('sessions');
if (this.worktreeListeners.size > 0) t.push('worktrees');
if (this.groupListeners.size > 0) t.push('groups');
if (this.settingsListeners.size > 0) t.push('settings');
if (this.cloneListeners.size > 0) t.push('clones');
return t;
}
private sendSub(): void {
this.sendControl({ type: 'sub', topics: this.activeTopics() });
}
subscribeSessions(listener: (e: SessionEvent) => void): () => void {
this.sessionListeners.add(listener);
this.connect();
this.sendSub();
return () => {
this.sessionListeners.delete(listener);
this.sendSub();
};
}
subscribeWorktrees(listener: (e: WorktreeEvent) => void): () => void {
this.worktreeListeners.add(listener);
this.connect();
this.sendSub();
return () => {
this.worktreeListeners.delete(listener);
this.sendSub();
};
}
subscribeGroups(listener: (e: GroupEvent) => void): () => void {
this.groupListeners.add(listener);
this.connect();
this.sendSub();
return () => {
this.groupListeners.delete(listener);
this.sendSub();
};
}
subscribeSettings(listener: (e: SettingsUpdateEvent) => void): () => void {
this.settingsListeners.add(listener);
this.connect();
this.sendSub();
return () => {
this.settingsListeners.delete(listener);
this.sendSub();
};
}
subscribeClones(listener: (e: CloneEvent) => void): () => void {
this.cloneListeners.add(listener);
this.connect();
this.sendSub();
return () => {
this.cloneListeners.delete(listener);
this.sendSub();
};
}
/**
* P7 : observe le détail (changes/diff) d'un worktree précis : envoie `watch`, route les
* `worktree_changes` correspondants vers `listener`, et ré-arme automatiquement après reconnexion.
* La désinscription envoie `unwatch` quand plus aucun listener ne regarde ce worktree.
*/
watchWorktree(repoId: string, path: string, listener: (e: WorktreeChangesEvent) => void): () => void {
const key = `${repoId}\0${path}`;
let set = this.worktreeChangesListeners.get(key);
if (!set) {
set = new Set();
this.worktreeChangesListeners.set(key, set);
this.connect();
this.sendControl({ type: 'watch', repoId, path });
}
set.add(listener);
return () => {
const s = this.worktreeChangesListeners.get(key);
if (!s) return;
s.delete(listener);
if (s.size === 0) {
this.worktreeChangesListeners.delete(key);
this.sendControl({ type: 'unwatch', repoId, path });
}
};
}
sendControl(msg: ClientMessage): void {
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) return;
this.socket.send(JSON.stringify(msg));
}
/** détache côté serveur et oublie l'attache (appelé par Attachment.detach) */
releaseAttachment(att: Attachment): void {
this.attachments.delete(att);
if (att.channel >= 0) {
this.byChannel.delete(att.channel);
this.sendControl({ type: 'detach', channel: att.channel });
att.channel = -1;
}
// si l'attache attend encore son 'attached', elle reste dans la FIFO pour
// préserver la corrélation : closed=true la fera détacher à la réception
}
// ---- interne ----
private openSocket(): void {
const socket = new WebSocket(`${location.origin.replace(/^http/, 'ws')}/ws`);
socket.binaryType = 'arraybuffer';
this.socket = socket;
socket.onopen = () => {
const hello: ClientMessage = { type: 'hello', protocol: PROTOCOL_VERSION };
socket.send(JSON.stringify(hello));
};
socket.onmessage = (ev: MessageEvent) => this.handleMessage(ev);
socket.onclose = () => this.handleClose(socket);
socket.onerror = () => {
// onclose suit systématiquement : la reconnexion se joue là-bas
};
}
private handleClose(socket: WebSocket): void {
if (this.socket !== socket) return;
this.socket = null;
this.ready = false;
this.byChannel.clear();
this.awaitingAttached = [];
for (const att of this.attachments) {
att.channel = -1;
att.epoch += 1;
}
if (this.stopped) {
this.status.value = 'idle';
return;
}
this.status.value = 'reconnecting';
this.scheduleReconnect();
}
private scheduleReconnect(): void {
this.clearReconnectTimer();
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.stopped) this.openSocket();
}, this.backoffMs);
this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
private sendAttach(att: Attachment): void {
this.awaitingAttached.push(att);
this.sendControl({ type: 'attach', sessionId: att.sessionId, mode: att.mode, cols: att.cols, rows: att.rows, screen: att.screen });
}
private handleMessage(ev: MessageEvent): void {
if (ev.data instanceof ArrayBuffer) {
this.handleBinary(new Uint8Array(ev.data));
return;
}
let msg: ServerMessage;
try {
msg = JSON.parse(String(ev.data)) as ServerMessage;
} catch {
return;
}
this.handleServerMessage(msg);
}
private handleBinary(data: Uint8Array): void {
if (data.byteLength < BINARY_FRAME.HEADER_BYTES) return;
const frame = decodeBinaryFrame(data);
const att = this.byChannel.get(frame.channel);
if (!att || att.closed) return;
if (frame.type === BINARY_FRAME.RESYNC) {
// le serveur repart de sentBytes=0 après un resync : reset terminal, replay
// du ring, et compteurs d'octets remis à zéro (le payload du replay ne compte pas)
att.epoch += 1;
att.processedBytes = 0;
att.lastAckBytes = 0;
att.sink.reset();
if (frame.payload.byteLength > 0) att.sink.write(frame.payload);
// filet anti-stall : le replay ne passe PAS de callback de write → on arme quand même l'ACK
// traînant pour que le flux suivant ne puisse pas rester gelé (serveur en pause).
this.scheduleTrailingAck(att, att.epoch);
return;
}
if (frame.type !== BINARY_FRAME.OUTPUT) return;
const epoch = att.epoch;
const length = frame.payload.byteLength;
att.sink.write(frame.payload, () => {
// compté seulement si aucun resync/reconnexion n'est passé entre temps
if (att.closed || att.epoch !== epoch || att.channel < 0) return;
att.processedBytes += length;
if (att.processedBytes - att.lastAckBytes >= FLOW.ACK_EVERY_BYTES) {
att.lastAckBytes = att.processedBytes;
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
}
this.scheduleTrailingAck(att, epoch);
});
// Filet anti-stall, INDÉPENDANT du callback de xterm.write : ce callback est throttlé quand
// l'onglet/PWA passe en arrière-plan (rAF + setTimeout ralentis) → sans cela l'ACK ne partait
// plus, le serveur dépassait HIGH_WATERMARK et mettait le PTY en pause, et la sortie « chargeait
// par à-coups » jusqu'à ce que l'utilisateur bouge. On arme donc l'ACK traînant dès la réception.
this.scheduleTrailingAck(att, epoch);
}
/**
* (Ré)arme l'ACK traînant débouncé : si rien d'autre n'arrive, il envoie le reliquat d'octets
* réellement traités par xterm (`processedBytes`) pour que le serveur sorte toujours de pause.
* Appelé à la fois dans le callback de write ET à la réception d'une frame (le callback peut être
* indéfiniment retardé quand le rendu est gelé). Sémantique inchangée : on n'ACK que du traité.
*/
private scheduleTrailingAck(att: Attachment, epoch: number): void {
if (att.trailingAckTimer) clearTimeout(att.trailingAckTimer);
att.trailingAckTimer = setTimeout(() => {
att.trailingAckTimer = null;
if (att.closed || att.epoch !== epoch || att.channel < 0) return;
if (att.processedBytes > att.lastAckBytes) {
att.lastAckBytes = att.processedBytes;
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
}
}, 200);
}
/** ACK immédiat du reliquat traité pour tous les terminaux ouverts : au retour en avant-plan,
* pour rattraper sans attendre le débounce un serveur éventuellement en pause. */
private flushAcks(): void {
for (const att of this.attachments) this.flushAttachmentAck(att);
}
/** ACK immédiat du reliquat traité d'UN attachment : n'ACK que du réellement traité (invariant
* préservé). Factorisé : utilisé par flushAcks() et par le flush ciblé flushAck(). */
private flushAttachmentAck(att: Attachment): void {
if (att.closed || att.channel < 0) return;
if (att.processedBytes > att.lastAckBytes) {
att.lastAckBytes = att.processedBytes;
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
}
}
/** Flush ACK ciblé d'un seul attachment : relance un PTY mis en pause par le serveur quand la
* cellule était cachée (cf. Attachment.flushAck, appelé au retour-visible). */
flushAck(att: Attachment): void {
this.flushAttachmentAck(att);
}
private handleServerMessage(msg: ServerMessage): void {
switch (msg.type) {
case 'hello_ok': {
this.ready = true;
this.backoffMs = BACKOFF_MIN_MS;
this.status.value = 'open';
this.sendSub();
// P7 : ré-arme les watchers de worktree (clé repoId\0path) après reconnexion.
for (const key of this.worktreeChangesListeners.keys()) {
const sep = key.indexOf('\0');
this.sendControl({ type: 'watch', repoId: key.slice(0, sep), path: key.slice(sep + 1) });
}
// ré-attache des terminaux encore ouverts (nouveaux canaux côté serveur)
for (const att of this.attachments) {
if (!att.closed) this.sendAttach(att);
}
return;
}
case 'attached': {
const att = this.awaitingAttached.shift();
if (!att) return;
if (att.closed) {
// détaché pendant l'attente : on libère le canal côté serveur
this.sendControl({ type: 'detach', channel: msg.channel });
return;
}
const wasControlling = att.controlling;
att.channel = msg.channel;
att.controlling = msg.controlling;
this.byChannel.set(msg.channel, att);
if (att.pending) {
att.pending.resolve(att);
att.pending = null;
} else if (wasControlling !== msg.controlling) {
// ré-attache après reconnexion : le statut controlling peut avoir changé
att.sink.onControlChanged(msg.controlling);
}
return;
}
case 'detached': {
const att = this.byChannel.get(msg.channel);
if (!att) return;
this.byChannel.delete(msg.channel);
att.channel = -1;
if (msg.reason === 'client') return;
att.closed = true;
this.attachments.delete(att);
att.sink.onDetached(msg.reason);
return;
}
case 'control_changed': {
const att = this.byChannel.get(msg.channel);
if (!att || att.closed) return;
att.controlling = msg.controlling;
att.sink.onControlChanged(msg.controlling);
return;
}
case 'session_update':
case 'session_exit':
case 'session_archived': {
for (const cb of this.sessionListeners) cb(msg);
return;
}
case 'repo_update':
case 'repo_removed':
case 'worktree_update':
case 'worktree_removed': {
for (const cb of this.worktreeListeners) cb(msg);
return;
}
case 'group_update':
case 'group_removed': {
for (const cb of this.groupListeners) cb(msg);
return;
}
case 'settings_update': {
for (const cb of this.settingsListeners) cb(msg);
return;
}
case 'clone_update': {
for (const cb of this.cloneListeners) cb(msg);
return;
}
case 'worktree_changes': {
const set = this.worktreeChangesListeners.get(`${msg.repoId}\0${msg.path}`);
if (set) for (const cb of set) cb(msg);
return;
}
case 'error': {
if (msg.channel !== undefined) {
console.warn(`[ws] channel ${msg.channel}: ${msg.code}: ${msg.message}`);
// Remonté au terminal concerné : une frappe refusée doit se voir, pas seulement en console.
this.byChannel.get(msg.channel)?.sink.onChannelError?.(msg.code, msg.message);
return;
}
// un échec d'attach est la seule erreur sans canal corrélable à une requête
if ((msg.code === 'NOT_FOUND' || msg.code === 'SESSION_EXITED') && this.awaitingAttached.length > 0) {
const att = this.awaitingAttached.shift();
if (!att) return;
this.attachments.delete(att);
if (att.pending) {
att.pending.reject(new Error(msg.code));
att.pending = null;
} else if (!att.closed) {
att.closed = true;
att.sink.onDetached('session_exit');
}
return;
}
console.warn(`[ws] ${msg.code}: ${msg.message}`);
return;
}
case 'pong':
return;
}
}
}
export const wsClient = new WsClient();