Compare commits

...
Author SHA1 Message Date
MichaelandClaude Opus 5.5 6812a4964d Lecture : ne couper que les flux orphelins, jamais un flux regardé
La règle « le dernier flux gagne » coupait aussi un flux en cours de
lecture. Deux lecteurs ouverts sur un compte à une connexion se coupaient
alors en boucle (chacun relance aussitôt) : plus aucune image nulle part.
Constaté en prod : tant qu'un autre onglet lisait, chaque ouverture
échouait après 5 s sans un octet.

Une connexion n'est désormais coupée que si son spectateur ne donne plus
signe de vie depuis 15 s : aucune requête de playlist/segment (HLS, film)
ni octet remis au client (turbo.ts). Les orphelins partent toujours, un
second lecteur actif est refusé par le panneau comme avant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:13:58 +02:00
3 changed files with 81 additions and 10 deletions

No files matched your search

+25 -1
View File
@@ -48,17 +48,31 @@ Future<void> _claimUpstream(PlaylistConfig playlist, String id) async {
await Future<void>.delayed(const Duration(seconds: 1)); await Future<void>.delayed(const Duration(seconds: 1));
} }
/// Délai sans consommation au-delà duquel un flux est tenu pour orphelin.
///
/// Un lecteur HLS redemande sa playlist toutes les 2 à 4 s ; un flux
/// `turbo.ts` reçoit au moins une rafale toutes les ~7 s même sur un panneau
/// qui livre par à-coups. 15 s laissent une marge à ces deux rythmes.
const _viewerGrace = Duration(seconds: 15);
/// Inscrit [process] comme connexion amont de [playlist] tant qu'il vit. /// Inscrit [process] comme connexion amont de [playlist] tant qu'il vit.
///
/// [lastActivity] : dernier signe de vie du spectateur ; la connexion n'est
/// coupée pour en ouvrir une autre que s'il remonte à plus de
/// [_viewerGrace].
void _registerUpstream( void _registerUpstream(
PlaylistConfig playlist, PlaylistConfig playlist,
String id, String id,
Process process, { Process process, {
required void Function() release, required void Function() release,
required DateTime Function() lastActivity,
}) { }) {
final token = _upstreamSlots.register( final token = _upstreamSlots.register(
id: id, id: id,
account: accountKeyOf(playlist), account: accountKeyOf(playlist),
release: release, release: release,
isActive: () =>
DateTime.now().difference(lastActivity()) < _viewerGrace,
); );
process.exitCode.then((_) => _upstreamSlots.unregister(id, token: token)); process.exitCode.then((_) => _upstreamSlots.unregister(id, token: token));
} }
@@ -274,6 +288,8 @@ Future<_SessionAttempt> _runSession({
id, id,
session.process, session.process,
release: () => sessionManager.killSession(id), release: () => sessionManager.killSession(id),
// Touchée à chaque requête de playlist ou de segment.
lastActivity: () => session.lastAccess,
); );
} }
final outcome = final outcome =
@@ -580,11 +596,16 @@ Handler createLiveStreamHandler(
return Response(503, body: 'FFmpeg start failed'); return Response(503, body: 'FFmpeg start failed');
} }
// Dernier octet remis au client : un lecteur en vie en reçoit à chaque
// rafale ; un client parti (zap mal détecté derrière le proxy) n'en
// reçoit plus, la contre-pression bloquant FFmpeg.
var lastDelivered = DateTime.now();
_registerUpstream( _registerUpstream(
playlist, playlist,
upstreamId, upstreamId,
process, process,
release: () => process.kill(ProcessSignal.sigterm), release: () => process.kill(ProcessSignal.sigterm),
lastActivity: () => lastDelivered,
); );
// Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux. // Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux.
@@ -602,7 +623,10 @@ Handler createLiveStreamHandler(
final body = pipeWithBackpressure( final body = pipeWithBackpressure(
process.stdout, process.stdout,
onStop: () => process.kill(ProcessSignal.sigterm), onStop: () => process.kill(ProcessSignal.sigterm),
); ).map((chunk) {
lastDelivered = DateTime.now();
return chunk;
});
return Response( return Response(
200, 200,
+32 -9
View File
@@ -15,10 +15,18 @@ import '../models/playlist_config.dart';
/// le panneau répondait `HTTP 551` ou ne répondait pas (timeout), d'où les /// le panneau répondait `HTTP 551` ou ne répondait pas (timeout), d'où les
/// films « indisponibles » et les zaps qui échouent. /// films « indisponibles » et les zaps qui échouent.
/// ///
/// Règle : le dernier flux demandé gagne. Avant d'ouvrir une nouvelle /// Règle : avant d'ouvrir une nouvelle connexion amont, on coupe les
/// connexion amont, on coupe les plus anciennes du même compte jusqu'à lui /// connexions ORPHELINES du même compte (plus personne ne les consomme),
/// faire de la place. Les enregistrements n'y sont pas inscrits : ils ne /// les plus anciennes d'abord, jusqu'à lui faire de la place.
/// sont jamais coupés. ///
/// Un flux encore regardé n'est jamais coupé. Une première version coupait
/// sans distinction (« le dernier gagne ») : deux lecteurs ouverts sur un
/// compte à une connexion se coupaient alors en boucle, chacun relançant
/// aussitôt — plus aucune image nulle part (constaté en prod). Le second
/// lecteur est désormais refusé par le panneau, comme avant, et le premier
/// continue.
///
/// Les enregistrements n'y sont pas inscrits : ils ne sont jamais coupés.
class UpstreamSlots { class UpstreamSlots {
final DateTime Function() _now; final DateTime Function() _now;
final Map<String, _Slot> _slots = {}; final Map<String, _Slot> _slots = {};
@@ -31,13 +39,17 @@ class UpstreamSlots {
/// Renvoie un jeton à repasser à [unregister] : une session relancée sous /// Renvoie un jeton à repasser à [unregister] : une session relancée sous
/// le même identifiant ne doit pas être désinscrite par la fin de /// le même identifiant ne doit pas être désinscrite par la fin de
/// l'ancien processus. /// l'ancien processus.
///
/// [isActive] : vrai tant qu'un spectateur consomme le flux. Une
/// connexion active n'est jamais coupée par [makeRoom].
int register({ int register({
required String id, required String id,
required String account, required String account,
required void Function() release, required void Function() release,
bool Function()? isActive,
}) { }) {
final token = ++_nextToken; final token = ++_nextToken;
_slots[id] = _Slot(token, account, _now(), release); _slots[id] = _Slot(token, account, _now(), release, isActive);
return token; return token;
} }
@@ -60,14 +72,16 @@ class UpstreamSlots {
if (max <= 0) return const []; if (max <= 0) return const [];
final others = _slots.entries final others = _slots.entries
.where((e) => e.value.account == account && e.key != keep) .where((e) => e.value.account == account && e.key != keep)
.toList() .toList();
..sort((a, b) => a.value.since.compareTo(b.value.since));
final excess = others.length - (max - 1); final excess = others.length - (max - 1);
if (excess <= 0) return const []; if (excess <= 0) return const [];
final orphans = others.where((e) => !e.value.active).toList()
..sort((a, b) => a.value.since.compareTo(b.value.since));
final freed = <String>[]; final freed = <String>[];
for (final entry in others.take(excess)) { for (final entry in orphans.take(excess)) {
_slots.remove(entry.key); _slots.remove(entry.key);
try { try {
entry.value.release(); entry.value.release();
@@ -83,7 +97,16 @@ class _Slot {
final String account; final String account;
final DateTime since; final DateTime since;
final void Function() release; final void Function() release;
_Slot(this.token, this.account, this.since, this.release); final bool Function()? isActive;
_Slot(this.token, this.account, this.since, this.release, this.isActive);
bool get active {
try {
return isActive?.call() ?? false;
} catch (_) {
return false;
}
}
} }
/// Clé de compte : deux playlists sur les mêmes identifiants partagent le /// Clé de compte : deux playlists sur les mêmes identifiants partagent le
+24
View File
@@ -53,6 +53,30 @@ void main() {
expect(slots.countFor('acc'), 1); expect(slots.countFor('acc'), 1);
}); });
test('un flux encore regardé n\'est jamais coupé (pas de ping-pong entre deux lecteurs)', () {
slots.register(
id: 'watched',
account: 'acc',
release: () => released.add('watched'),
isActive: () => true,
);
expect(slots.makeRoom('acc', max: 1, keep: 'new'), isEmpty);
expect(released, isEmpty);
expect(slots.countFor('acc'), 1);
});
test('seuls les orphelins partent, même plus récents qu\'un flux regardé', () {
slots.register(
id: 'watched',
account: 'acc',
release: () => released.add('watched'),
isActive: () => true,
);
add('orphan', 'acc');
expect(slots.makeRoom('acc', max: 1, keep: 'new'), ['orphan']);
expect(released, ['orphan']);
});
test('quota inconnu ou nul : rien n\'est coupé', () { test('quota inconnu ou nul : rien n\'est coupé', () {
add('a', 'acc'); add('a', 'acc');
expect(slots.makeRoom('acc', max: 0, keep: 'new'), isEmpty); expect(slots.makeRoom('acc', max: 0, keep: 'new'), isEmpty);