Fix : stopped stream bug
All checks were successful
release / build (push) Successful in 29s
release / verify-windows (push) Successful in 2m57s

This commit is contained in:
jeanotx32
2026-08-14 07:33:09 -04:00
parent b45ad4bca3
commit da9952d756
4 changed files with 121 additions and 6 deletions

View File

@@ -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 [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. 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 **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 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 jusqu'à la diffusion suivante et le dit dans le journal. Passer son tour faute de VM libre

View File

@@ -35,6 +35,15 @@ interface AgentConnection {
socket: WebSocket; socket: WebSocket;
lastSeenAt: number; lastSeenAt: number;
pending: Map<string, PendingCommand>; pending: Map<string, PendingCommand>;
/**
* 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.statuses.set(agentId, this.statuses.get(agentId) ?? emptyStatus());
this.publishAgent(agentId); this.publishAgent(agentId);
} }
@@ -95,6 +109,8 @@ class Hub {
} }
updateStatus(agentId: string, status: AgentStatus): void { updateStatus(agentId: string, status: AgentStatus): void {
const connection = this.connections.get(agentId);
if (connection) connection.statusReceived = true;
this.statuses.set(agentId, status); this.statuses.set(agentId, status);
this.markSeen(agentId); this.markSeen(agentId);
this.trackRecording(agentId, status); this.trackRecording(agentId, status);
@@ -227,6 +243,18 @@ class Hub {
return this.statuses.get(agentId) ?? emptyStatus(); 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 { view(record: AgentRecord): AgentView {
const online = this.isOnline(record.id); const online = this.isOnline(record.id);
return { return {

View File

@@ -29,11 +29,46 @@ export async function startTargetRecording(target: WatchTarget): Promise<void> {
if (!record) throw new Error("L'agent assigné n'existe plus"); if (!record) throw new Error("L'agent assigné n'existe plus");
if (!hub.isOnline(record.id)) throw new Error(`Agent « ${record.name} » hors-ligne`); 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 // L'état réel se demande à l'agent, il ne se lit pas dans le cache du hub :
// reste refusé par défaut — sauf si ce profil a reçu le droit d'interrompre // celui-ci retombe à « n'enregistre pas » dès que la connexion se coupe (voir
// et qu'il l'emporte en priorité, auquel cas on referme proprement avant de // `detachAgent`) et repart vide au redémarrage du serveur. Dans cette fenêtre,
// relancer plutôt que de laisser les deux se marcher dessus. // une VM occupée passe pour libre — et c'est exactement ce qui écrasait une
if (hub.statusOf(record.id).recording) { // 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); await preemptRecording(record, target);
} }
@@ -83,6 +118,30 @@ export async function startTargetRecording(target: WatchTarget): Promise<void> {
} }
} }
/**
* É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. * Referme la capture en cours au profit de `target`, ou refuse en expliquant.
* *

View File

@@ -190,6 +190,14 @@ class Watchlist {
if (!hub.isOnline(target.agentId)) return; 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 // 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 // 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 // recalculé par startTargetRecording — le refaire ici évite seulement de