diff --git a/packages/web/src/components/TerminalView.vue b/packages/web/src/components/TerminalView.vue index 739784c..984ea39 100644 --- a/packages/web/src/components/TerminalView.vue +++ b/packages/web/src/components/TerminalView.vue @@ -38,6 +38,7 @@ const ended = ref(false); let term: Terminal | null = null; let attachment: Attachment | null = null; let resizeObserver: ResizeObserver | null = null; +let intersectionObserver: IntersectionObserver | null = null; let onVisible: (() => void) | null = null; let disposed = false; @@ -130,6 +131,20 @@ onMounted(async () => { activeTerm.onData((data) => attachment?.sendStdin(data)); resizeObserver = new ResizeObserver(refit); 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 // que l'onglet était caché) ; le ws-client flushe en parallèle les ACK pour relancer le flux. onVisible = (): void => { @@ -142,6 +157,7 @@ onMounted(async () => { onBeforeUnmount(() => { disposed = true; resizeObserver?.disconnect(); + intersectionObserver?.disconnect(); if (onVisible) document.removeEventListener('visibilitychange', onVisible); attachment?.detach(); term?.dispose(); diff --git a/packages/web/src/lib/ws-client.ts b/packages/web/src/lib/ws-client.ts index 195aaeb..6a74e75 100644 --- a/packages/web/src/lib/ws-client.ts +++ b/packages/web/src/lib/ws-client.ts @@ -100,6 +100,12 @@ export class Attachment { 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 { @@ -357,15 +363,25 @@ export class WsClient { /** 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) { - if (att.closed || att.channel < 0) continue; - if (att.processedBytes > att.lastAckBytes) { - att.lastAckBytes = att.processedBytes; - this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes }); - } + 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': { diff --git a/packages/web/test/ws-client.test.ts b/packages/web/test/ws-client.test.ts new file mode 100644 index 0000000..b05e3ef --- /dev/null +++ b/packages/web/test/ws-client.test.ts @@ -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(); + }); +});