From da9952d756d2560ea707a3efeb64a21b0958d746 Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Fri, 14 Aug 2026 07:33:09 -0400 Subject: [PATCH] Fix : stopped stream bug --- README.md | 20 +++++++++ packages/server/src/hub.ts | 30 +++++++++++++- packages/server/src/recorder.ts | 69 +++++++++++++++++++++++++++++--- packages/server/src/watchlist.ts | 8 ++++ 4 files changed, 121 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f886c8a..9bdc99a 100644 --- a/README.md +++ b/README.md @@ -689,6 +689,26 @@ Plusieurs profils peuvent être en automatisme sur la même VM ; c'est la [priorité](#priorité-et-interruption) qui les départage. **Par défaut la capture en place l'emporte** : un profil qui trouve sa VM occupée passe son tour sans rien écraser. +**Une capture déjà ouverte n'est jamais remplacée par une seconde.** Avant de démarrer, le +serveur demande son état réel à l'agent (`obs.refresh`) au lieu de lire le dernier statut reçu. +La nuance est décisive : ce statut retombe à « n'enregistre pas » dès qu'une connexion se coupe, +et repart vide au redémarrage du serveur. Dans cette fenêtre, une VM occupée passait pour libre +— et l'automatisme rouvrait une capture par-dessus une autre, bien vivante, **du même +streamer**. Le symptôme : un passage hors-ligne de quelques minutes, la capture mise en pause, +puis le retour en direct qui la coupait pour en recommencer une. + +Selon ce que l'agent répond : + +- **capture du même profil en pause** → elle est **reprise**, pas relancée. Le fichier reste le + même ; en ouvrir un second aurait perdu le premier ; +- **capture du même profil en cours** → rien à faire ; +- **capture d'un autre profil** → l'arbitrage de [priorité](#priorité-et-interruption) s'applique ; +- **aucune capture** → l'enregistrement démarre, comme attendu. + +Second garde-fou en amont : tant qu'un agent n'a rien rapporté sur sa connexion courante, +l'automatisme s'abstient. « On ne sait pas encore » n'est pas « la VM est libre », et attendre +le cycle de veille suivant ne coûte rien. + **Trois tentatives par diffusion.** Les échecs restants une fois les garde-fous passés (OBS injoignable, par exemple) sont surtout persistants ; au-delà, l'automatisme abandonne jusqu'à la diffusion suivante et le dit dans le journal. Passer son tour faute de VM libre diff --git a/packages/server/src/hub.ts b/packages/server/src/hub.ts index f10711f..011c8bb 100644 --- a/packages/server/src/hub.ts +++ b/packages/server/src/hub.ts @@ -35,6 +35,15 @@ interface AgentConnection { socket: WebSocket; lastSeenAt: number; pending: Map; + /** + * Un statut a été reçu depuis l'établissement de *cette* connexion. + * + * Le dernier statut connu survit à une coupure, mais `detachAgent` y force + * « n'enregistre pas » — et au redémarrage du serveur il n'y en a aucun. Une + * VM occupée passe alors pour libre le temps que l'agent se manifeste. Ce + * drapeau distingue « la VM n'enregistre pas » de « on n'en sait encore rien ». + */ + statusReceived: boolean; } /** @@ -60,7 +69,12 @@ class Hub { } } - this.connections.set(agentId, { socket, lastSeenAt: Date.now(), pending: new Map() }); + this.connections.set(agentId, { + socket, + lastSeenAt: Date.now(), + pending: new Map(), + statusReceived: false, + }); this.statuses.set(agentId, this.statuses.get(agentId) ?? emptyStatus()); this.publishAgent(agentId); } @@ -95,6 +109,8 @@ class Hub { } updateStatus(agentId: string, status: AgentStatus): void { + const connection = this.connections.get(agentId); + if (connection) connection.statusReceived = true; this.statuses.set(agentId, status); this.markSeen(agentId); this.trackRecording(agentId, status); @@ -227,6 +243,18 @@ class Hub { return this.statuses.get(agentId) ?? emptyStatus(); } + /** + * Le statut connu de cet agent est-il digne de confiance ? + * + * Faux tant que l'agent n'a rien rapporté sur sa connexion courante. Le + * distinguer importe : après une coupure ou un redémarrage du serveur, le + * statut retenu annonce « n'enregistre pas » sans que personne ne l'ait + * vérifié — et un automatisme qui s'y fie écrase la capture en cours. + */ + hasFreshStatus(agentId: string): boolean { + return this.connections.get(agentId)?.statusReceived === true; + } + view(record: AgentRecord): AgentView { const online = this.isOnline(record.id); return { diff --git a/packages/server/src/recorder.ts b/packages/server/src/recorder.ts index 30fae2f..2f6f12f 100644 --- a/packages/server/src/recorder.ts +++ b/packages/server/src/recorder.ts @@ -29,11 +29,46 @@ export async function startTargetRecording(target: WatchTarget): Promise { if (!record) throw new Error("L'agent assigné n'existe plus"); if (!hub.isOnline(record.id)) throw new Error(`Agent « ${record.name} » hors-ligne`); - // Une VM n'enregistre qu'un flux à la fois. Écraser une capture en cours - // reste refusé par défaut — sauf si ce profil a reçu le droit d'interrompre - // et qu'il l'emporte en priorité, auquel cas on referme proprement avant de - // relancer plutôt que de laisser les deux se marcher dessus. - if (hub.statusOf(record.id).recording) { + // L'état réel se demande à l'agent, il ne se lit pas dans le cache du hub : + // celui-ci retombe à « n'enregistre pas » dès que la connexion se coupe (voir + // `detachAgent`) et repart vide au redémarrage du serveur. Dans cette fenêtre, + // une VM occupée passe pour libre — et c'est exactement ce qui écrasait une + // capture en pause pour en rouvrir une seconde sur le même streamer. + const live = (await liveRecordState(record)) ?? { + // Sans réponse exploitable, on retombe sur le dernier statut connu : il vaut + // mieux qu'aucune information, et le garde-fou côté veille empêche déjà + // l'automatisme d'agir sur un statut qu'il sait périmé. + active: hub.statusOf(record.id).recording, + paused: hub.statusOf(record.id).recordPaused === true, + }; + + if (live.active) { + // Cette VM capture-t-elle déjà *ce* profil ? Sa veille porte le seul lien + // fiable entre une capture en cours et un profil suivi. + if (record.watch.username === target.username) { + if (live.paused) { + // Le cas visé : la capture existe toujours, seulement suspendue le temps + // d'un show privé ou d'un passage hors-ligne. La reprendre garde le même + // fichier ; la relancer en ouvrirait un second et perdrait le premier. + await hub.sendCommand(record.id, 'record.resume'); + hub.log( + record.id, + 'info', + `Capture de « ${target.label ?? target.username} » déjà ouverte sur ${record.name} : ` + + 'reprise de la pause plutôt qu\'un nouvel enregistrement', + Date.now(), + 'record.resumed', + ); + return; + } + // Déjà en train de capturer ce profil, et pas en pause : il n'y a + // strictement rien à faire. + return; + } + + // Une autre capture occupe la VM. L'écraser reste refusé par défaut — sauf + // si ce profil a reçu le droit d'interrompre et qu'il l'emporte en priorité, + // auquel cas on referme proprement avant de relancer. await preemptRecording(record, target); } @@ -83,6 +118,30 @@ export async function startTargetRecording(target: WatchTarget): Promise { } } +/** + * État d'enregistrement demandé à l'agent lui-même, ou `null` s'il n'a pas su + * répondre. + * + * `obs.refresh` relit OBS et renvoie un instantané complet : c'est la seule + * source qui ne peut pas être périmée, contrairement au dernier statut reçu. + * Un échec (OBS injoignable, agent muet) n'est pas un « n'enregistre pas » — + * d'où le `null`, que l'appelant traite comme une absence d'information. + */ +async function liveRecordState( + record: AgentRecord, +): Promise<{ active: boolean; paused: boolean } | null> { + try { + const snapshot = (await hub.sendCommand(record.id, 'obs.refresh')) as { + recording?: boolean; + recordPaused?: boolean; + } | null; + if (!snapshot || typeof snapshot.recording !== 'boolean') return null; + return { active: snapshot.recording, paused: snapshot.recordPaused === true }; + } catch { + return null; + } +} + /** * Referme la capture en cours au profit de `target`, ou refuse en expliquant. * diff --git a/packages/server/src/watchlist.ts b/packages/server/src/watchlist.ts index 865dbdf..07f6984 100644 --- a/packages/server/src/watchlist.ts +++ b/packages/server/src/watchlist.ts @@ -190,6 +190,14 @@ class Watchlist { if (!hub.isOnline(target.agentId)) return; + // Tant que l'agent n'a rien rapporté sur sa connexion courante, on ne sait + // pas ce qu'il fait — et « on ne sait pas » n'est pas « il est libre ». Le + // statut retenu annonce pourtant « n'enregistre pas » après une coupure ou + // un redémarrage du serveur : s'y fier faisait relancer une capture par + // dessus une autre, bien vivante, du même streamer. Attendre le cycle + // suivant ne coûte rien. + if (!hub.hasFreshStatus(target.agentId)) return; + // VM occupée : par défaut la capture en place l'emporte, sauf si ce profil // a reçu le droit d'interrompre et le rang pour le faire. Le verdict est // recalculé par startTargetRecording — le refaire ici évite seulement de