fix(web): repeindre les terminaux au retour-visible + flush ACK ciblé
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled

Un terminal caché dans une grille de groupe (ou hors-écran) n'était pas repeint
au retour — `visibilitychange` ne couvre que l'onglet — d'où l'écran noir avec
curseur ; et son ACK ne progressait plus (callback xterm.write throttlé) → le
serveur mettait le PTY en pause → sortie « par à-coups ». Ajoute un
IntersectionObserver (repaint + flushAck ciblé au retour-visible) et expose
Attachment.flushAck (factorisé avec flushAcks).
This commit is contained in:
2026-06-24 10:15:29 +02:00
parent b5236b41c8
commit 0f126cf911
3 changed files with 91 additions and 6 deletions

View File

@@ -38,6 +38,7 @@ const ended = ref(false);
let term: Terminal | null = null; let term: Terminal | null = null;
let attachment: Attachment | null = null; let attachment: Attachment | null = null;
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
let intersectionObserver: IntersectionObserver | null = null;
let onVisible: (() => void) | null = null; let onVisible: (() => void) | null = null;
let disposed = false; let disposed = false;
@@ -130,6 +131,20 @@ onMounted(async () => {
activeTerm.onData((data) => attachment?.sendStdin(data)); activeTerm.onData((data) => attachment?.sendStdin(data));
resizeObserver = new ResizeObserver(refit); resizeObserver = new ResizeObserver(refit);
resizeObserver.observe(container.value); resizeObserver.observe(container.value);
// Re-révélation RÉELLE du conteneur : cellule de grille démontée/remontée (maximize↔grille), scroll
// hors/dans le viewport, ou navigation SPA — autant de cas que `visibilitychange` (onglet only) ne
// couvre PAS. Au retour visible, on repeint (le rendu était gelé hors-écran) ET on flushe l'ACK pour
// sortir d'une éventuelle pause serveur (le callback de xterm.write était throttlé tant que caché).
intersectionObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
// évite un fit() sur conteneur de taille 0 (remontage transitoire) → déforme le TUI
if (entry.intersectionRect.width === 0 || entry.intersectionRect.height === 0) continue;
refit();
attachment?.flushAck();
}
});
intersectionObserver.observe(container.value);
// au retour en avant-plan, forcer un repaint (le renderer WebGL peut avoir « gelé » l'écran pendant // au retour en avant-plan, forcer un repaint (le renderer WebGL peut avoir « gelé » l'écran pendant
// que l'onglet était caché) ; le ws-client flushe en parallèle les ACK pour relancer le flux. // que l'onglet était caché) ; le ws-client flushe en parallèle les ACK pour relancer le flux.
onVisible = (): void => { onVisible = (): void => {
@@ -142,6 +157,7 @@ onMounted(async () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
disposed = true; disposed = true;
resizeObserver?.disconnect(); resizeObserver?.disconnect();
intersectionObserver?.disconnect();
if (onVisible) document.removeEventListener('visibilitychange', onVisible); if (onVisible) document.removeEventListener('visibilitychange', onVisible);
attachment?.detach(); attachment?.detach();
term?.dispose(); term?.dispose();

View File

@@ -100,6 +100,12 @@ export class Attachment {
this.pending = null; this.pending = null;
this.client.releaseAttachment(this); 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 { export class WsClient {
@@ -357,13 +363,23 @@ export class WsClient {
/** ACK immédiat du reliquat traité pour tous les terminaux ouverts — au retour en avant-plan, /** 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. */ * pour rattraper sans attendre le débounce un serveur éventuellement en pause. */
private flushAcks(): void { private flushAcks(): void {
for (const att of this.attachments) { for (const att of this.attachments) this.flushAttachmentAck(att);
if (att.closed || att.channel < 0) continue; }
/** 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) { if (att.processedBytes > att.lastAckBytes) {
att.lastAckBytes = att.processedBytes; att.lastAckBytes = att.processedBytes;
this.sendControl({ type: 'ack', channel: att.channel, bytes: 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 { private handleServerMessage(msg: ServerMessage): void {

View File

@@ -0,0 +1,53 @@
// Flush ACK ciblé (Attachment.flushAck) : relance un PTY mis en pause au retour-visible d'une cellule.
// On teste la logique d'ACK sans WebSocket réel (sendControl est public → stubbable).
import { describe, it, expect, vi } from 'vitest';
import { WsClient, Attachment, type TerminalSink } from '../src/lib/ws-client';
const sink: TerminalSink = {
write: () => {},
reset: () => {},
onDetached: () => {},
onControlChanged: () => {},
};
describe('ws-client — flush ACK ciblé (Attachment.flushAck)', () => {
it('envoie un unique ACK cumulatif du reliquat traité, puis est idempotent', () => {
const client = new WsClient();
const send = vi.spyOn(client, 'sendControl').mockImplementation(() => {});
const att = new Attachment(client, 'sid', 'interactive', sink, 80, 24);
att.channel = 2;
att.processedBytes = 1000;
att.flushAck();
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith({ type: 'ack', channel: 2, bytes: 1000 });
expect(att.lastAckBytes).toBe(1000);
// rien de neuf traité → aucun ACK supplémentaire (n'ACK QUE du réellement traité)
att.flushAck();
expect(send).toHaveBeenCalledTimes(1);
// nouveau reliquat traité → ACK cumulatif mis à jour
att.processedBytes = 1500;
att.flushAck();
expect(send).toHaveBeenCalledTimes(2);
expect(send).toHaveBeenLastCalledWith({ type: 'ack', channel: 2, bytes: 1500 });
});
it('no-op si le canal nest pas attaché (-1) ou si lattachment est fermé', () => {
const client = new WsClient();
const send = vi.spyOn(client, 'sendControl').mockImplementation(() => {});
const notAttached = new Attachment(client, 'a', 'interactive', sink, 80, 24); // channel = -1
notAttached.processedBytes = 500;
notAttached.flushAck();
const closed = new Attachment(client, 'b', 'interactive', sink, 80, 24);
closed.channel = 3;
closed.processedBytes = 500;
closed.closed = true;
closed.flushAck();
expect(send).not.toHaveBeenCalled();
});
});