fix(web): repeindre les terminaux au retour-visible + flush ACK ciblé
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:
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
53
packages/web/test/ws-client.test.ts
Normal file
53
packages/web/test/ws-client.test.ts
Normal 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 n’est pas attaché (-1) ou si l’attachment 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user