Read once: a link passed on is fetched from the site once, for the putter's player and the relay

Before, the member passing a page link on downloaded it twice, independently:
their mpv straight from the CDN and the relay's pulls again, with a
different format pick and without yt-dlp's cookies. The two drifted (the
putter played on while viewers stalled, or the reverse) and doubled the
connections the CDN saw. Now one SharedSource per track reads the site
once (resuming after cuts, backing off on 412/429/5xx and silence) into a
bounded buffer (64 MiB ahead, 16 behind) that serves both the putter's
mpv through a loopback endpoint and the server's pulls; the putter plays
the relay's own format pick, and the relay sends yt-dlp's cookies.
Up next's warm relay uses the same reader. Pieces tracks (HLS/DASH) keep
two fetches for now. Measured in the opt-in e2e: half the CDN traffic, no
412s under a lockout CDN, viewer seeks 0.29 s instead of 4 s there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-18 02:05:03 +03:00
1 parent 7715dc479a
commit ec0849eb01
19 files changed
+1894 -101

No files matched your search

@@ -0,0 +1,427 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:test/test.dart';
// The single-fetch source (SharedSource): one upstream reader per track
// feeds this app's player (a loopback endpoint) and the upload to the
// server. Its loopback answers, a cut and a resume, seeks of either
// reader, the two kept within one window, and the memory bound.
const _mib = 1 << 20;
/// An in-memory upstream: every read logged by its start, at most
/// [rate] bytes per second when set.
class _Upstream implements RelayBytes {
final Uint8List data;
final int? rate;
final reads = <int>[];
var open = 0, maxOpen = 0, bytes = 0;
_Upstream(this.data, {this.rate});
@override
Future<int> size() async => data.length;
@override
Stream<List<int>> read(int start, int end) async* {
reads.add(start);
open++;
maxOpen = max(maxOpen, open);
try {
const chunk = 64 * 1024;
for (var pos = start; pos <= end; pos += chunk) {
final n = min(chunk, end - pos + 1);
if (rate != null) await Future<void>.delayed(Duration(microseconds: n * 1000000 ~/ rate!));
bytes += n;
yield Uint8List.sublistView(data, pos, pos + n);
}
} finally {
open--;
}
}
@override
Future<void> close() async {}
}
Uint8List _bytes(int n) => Uint8List.fromList(List.generate(n, (i) => (i * 7 + (i >> 16)) % 251));
Future<(HttpClientResponse, List<int>)> _get(String url, {String? range, String method = 'GET'}) async {
final c = HttpClient();
try {
final req = await c.openUrl(method, Uri.parse(url));
if (range != null) req.headers.set(HttpHeaders.rangeHeader, range);
final res = await req.close();
final body = <int>[];
await for (final b in res) {
body.addAll(b);
}
return (res, body);
} finally {
c.close(force: true);
}
}
Future<List<int>> _readAll(Stream<List<int>> s) async => [for (final c in await s.toList()) ...c];
/// The upload's track [i] (what the relay source reads).
Future<RelayBytes> _upload(SharedSource s, [int i = 0]) async => (await s.upload.open())[i].bytes!;
void main() {
group('loopback endpoint', () {
late SharedSource src;
late Uint8List data;
setUp(() async {
data = _bytes(3 * _mib + 123);
src = SharedSource(RelayMedia.single(_Upstream(data)));
await src.start();
});
tearDown(() => src.close());
test('answers ranges like the server (200, 206, suffix, 416), connection: close', () async {
final url = src.playerUrl;
expect(url, startsWith('http://127.0.0.1:'));
var (res, body) = await _get(url);
expect(res.statusCode, 200);
expect(res.contentLength, data.length);
expect(res.headers.value('accept-ranges'), 'bytes');
expect(res.headers.value('connection'), 'close');
expect(body, data);
(res, body) = await _get(url, range: 'bytes=1000-1999');
expect(res.statusCode, 206);
expect(res.headers.value('content-range'), 'bytes 1000-1999/${data.length}');
expect(body, data.sublist(1000, 2000));
(res, body) = await _get(url, range: 'bytes=-50');
expect(res.statusCode, 206);
expect(body, data.sublist(data.length - 50));
(res, body) = await _get(url, range: 'bytes=${2 * _mib}-');
expect(res.statusCode, 206);
expect(body, data.sublist(2 * _mib));
(res, body) = await _get(url, range: 'bytes=${data.length}-');
expect(res.statusCode, 416);
expect(res.headers.value('content-range'), 'bytes */${data.length}');
(res, body) = await _get(url, method: 'HEAD');
expect(res.statusCode, 200);
expect(res.contentLength, data.length);
expect(body, isEmpty);
});
test('a wrong secret or track is 404', () async {
final u = Uri.parse(src.playerUrl);
expect((await _get(u.replace(path: '/nope/0').toString())).$1.statusCode, 404);
expect((await _get(u.replace(path: '${u.pathSegments[0]}/1').toString())).$1.statusCode, 404);
});
test('two tracks: an EDL of one loopback URL per track, no headers', () async {
final two = SharedSource(_TwoTracks(_Upstream(_bytes(_mib)), _Upstream(_bytes(_mib ~/ 2))));
addTearDown(two.close);
await two.open();
final url = two.playerUrl;
expect(url, startsWith('edl://'));
expect(RegExp(r'http://127\.0\.0\.1:\d+/[0-9a-f]+/[01]').allMatches(url).length, 2);
expect([for (final t in await two.upload.open()) t.kind], ['video', 'audio']);
expect(await (await _upload(two, 1)).size(), _mib ~/ 2);
});
test('the player can open before the sizes are read: the endpoint waits for them', () async {
final slow = _SlowSize(_Upstream(data));
final s = SharedSource(RelayMedia.single(slow));
addTearDown(s.close);
await s.open();
final pending = _get(s.playerUrl, range: 'bytes=0-99');
await Future<void>.delayed(const Duration(milliseconds: 100));
slow.go.complete();
final (res, body) = await pending;
expect(res.statusCode, 206);
expect(body, data.sublist(0, 100));
});
});
group('ResumingBytes', () {
late HttpServer cdn;
late Uint8List data;
// Per request: cut after this many bytes (null: whole), or answer
// this status.
late List<Object?> script;
var requests = 0;
setUp(() async {
data = _bytes(_mib + 77);
requests = 0;
cdn = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
cdn.listen((req) async {
final step = requests < script.length ? script[requests] : null;
requests++;
final m = RegExp(r'bytes=(\d+)-(\d+)').firstMatch(req.headers.value('range') ?? '')!;
final start = int.parse(m[1]!), end = int.parse(m[2]!);
if (step is String) {
req.response.statusCode = int.parse(step);
await req.response.close();
return;
}
req.response
..statusCode = 206
..headers.set('content-range', 'bytes $start-$end/${data.length}')
..contentLength = end - start + 1;
final s = await req.response.detachSocket();
final cut = step as int?;
if (cut == -1) {
await s.flush();
return; // headers only, then silence
}
final stop = cut == null ? end + 1 : min(end + 1, start + cut);
s.add(data.sublist(start, stop));
await s.flush();
if (stop <= end) {
s.destroy();
} else {
await s.close();
}
});
});
tearDown(() => cdn.close(force: true));
HttpRelayBytes http() => HttpRelayBytes(Uri.parse('http://127.0.0.1:${cdn.port}/v.mp4'), chunkSize: 0);
Duration quick(int _) => const Duration(milliseconds: 20);
test('a cut goes on from the byte it reached', () async {
script = [100000, 300000, 5];
final r = ResumingBytes(http(), backoff: quick);
expect(await _readAll(r.read(0, data.length - 1)), data);
expect(r.retries, 3);
expect(requests, 4);
expect(r.bytesIn, data.length);
});
test('412, 429 and 5xx back off and try again; 403 and 404 are final', () async {
script = ['412', '429', '503', null];
final r = ResumingBytes(http(), backoff: quick);
expect(await _readAll(r.read(10, 5009)), data.sublist(10, 5010));
expect(r.retries, 3);
for (final status in ['403', '404']) {
script = [status];
requests = 0;
final f = ResumingBytes(http(), backoff: quick);
await expectLater(_readAll(f.read(0, 99)),
throwsA(isA<UpstreamHttpException>().having((e) => e.status, 'status', int.parse(status))));
expect(requests, 1, reason: status);
}
});
test('an answer that goes silent is cut after the stall limit and resumed', () async {
script = [-1, null];
final r = ResumingBytes(http(), backoff: quick, stall: const Duration(milliseconds: 300));
final sw = Stopwatch()..start();
expect(await _readAll(r.read(0, 199999)), data.sublist(0, 200000));
expect(r.retries, 1);
expect(sw.elapsedMilliseconds, greaterThanOrEqualTo(250));
});
test('cancelling stops it during a pause', () async {
script = ['503', '503', '503'];
final r = ResumingBytes(http(), backoff: (_) => const Duration(seconds: 30));
final sub = r.read(0, 99).listen((_) {});
await Future<void>.delayed(const Duration(milliseconds: 100));
final sw = Stopwatch()..start();
await sub.cancel();
expect(sw.elapsedMilliseconds, lessThan(1000));
expect(requests, 1);
});
});
group('the shared buffer: seeks, one window, memory', () {
test('cut upstream: both readers get every byte through one resuming reader', () async {
final data = _bytes(6 * _mib);
final up = _Cutting(data, every: 700 * 1024);
final s = SharedSource(RelayMedia.single(up), backoff: (_) => const Duration(milliseconds: 10));
addTearDown(s.close);
await s.start();
final upload = await _upload(s);
final results = await Future.wait([
_get(s.playerUrl).then((r) => r.$2),
_readAll(upload.read(0, data.length - 1)),
]);
expect(results[0], data);
expect(results[1], data);
expect(s.resumes, greaterThan(3));
expect(up.maxOpen, 1, reason: 'one upstream reader');
expect(up.bytes, lessThan(data.length + 3 * _mib), reason: 'each byte read about once');
});
test('the player seeks far ahead and back: the reader follows it, the head is kept', () async {
final data = _bytes(40 * _mib);
final up = _Upstream(data);
final s = SharedSource(RelayMedia.single(up), ahead: 4 * _mib, back: _mib);
addTearDown(s.close);
await s.start();
expect((await _get(s.playerUrl, range: 'bytes=0-${_mib - 1}')).$2, data.sublist(0, _mib));
final before = up.reads.length;
final at = 30 * _mib + 12345;
expect((await _get(s.playerUrl, range: 'bytes=$at-${at + _mib - 1}')).$2, data.sublist(at, at + _mib));
final seekReads = up.reads.sublist(before);
expect(seekReads.where((r) => r >= 30 * _mib - RelayConstants.relaySliceBytes && r <= at), isNotEmpty,
reason: 'a new upstream read at the seek: $seekReads');
final again = up.reads.length;
expect((await _get(s.playerUrl, range: 'bytes=1000-1999')).$2, data.sublist(1000, 2000));
expect(up.reads.sublist(again).where((r) => r < RelayConstants.relayPinBytes), isEmpty,
reason: 'the head is pinned: no upstream read for it');
});
test('player and upload reading together share one sequential read (no hand-overs)', () async {
final data = _bytes(24 * _mib);
final up = _Upstream(data, rate: 60 * _mib);
final s = SharedSource(RelayMedia.single(up));
addTearDown(s.close);
await s.start();
final upload = await _upload(s);
final out = await Future.wait([
_get(s.playerUrl).then((r) => r.$2),
_readAll(upload.read(0, data.length - 1)),
]);
expect(out[0], data);
expect(out[1], data);
// The tail pin, the head, then one sequential read (a slice-boundary
// hand-over between the two would be a new read per 256 KiB).
expect(up.reads.length, lessThanOrEqualTo(6), reason: '${up.reads}');
expect(up.bytes, lessThanOrEqualTo(data.length + 2 * RelayConstants.relayPinBytes));
});
test('the upload does not run ahead of a reading player by more than the window; free once the player stops', () async {
final data = _bytes(48 * _mib);
final up = _Upstream(data);
final s = SharedSource(RelayMedia.single(up), ahead: 4 * _mib, back: _mib);
addTearDown(s.close);
await s.start();
final buffer = s.tracks.single.buffer;
// The player has a request open at 0 and stops reading after its
// first bytes (mpv with a full cache).
final player = StreamIterator(buffer.read(SharedSource.playerViewer, 0, data.length - 1));
expect(await player.moveNext(), isTrue);
final upload = await _upload(s);
final far = 30 * _mib;
final got = <int>[];
final sub = upload.read(far, far + _mib - 1).listen(got.addAll);
await Future<void>.delayed(const Duration(milliseconds: 600));
expect(got, isEmpty, reason: 'held: 30 MiB is past the player + 4 MiB');
expect(up.reads.where((r) => r >= 20 * _mib && r < data.length - RelayConstants.relayPinBytes), isEmpty,
reason: 'nothing asked upstream for it: ${up.reads}');
await player.cancel();
final until = DateTime.now().add(const Duration(seconds: 5));
while (got.length < _mib && DateTime.now().isBefore(until)) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
await sub.cancel();
expect(got, data.sublist(far, far + _mib));
});
test('the player is held the same way when the upload lags; the tail is always served', () async {
final data = _bytes(48 * _mib);
final up = _Upstream(data);
final s = SharedSource(RelayMedia.single(up), ahead: 4 * _mib, back: _mib);
addTearDown(s.close);
await s.start();
final buffer = s.tracks.single.buffer;
final upload = StreamIterator((await _upload(s)).read(0, data.length - 1));
expect(await upload.moveNext(), isTrue);
// The tail (an mp4 index) is pinned: never held.
final tail = data.length - 1000;
expect((await _get(s.playerUrl, range: 'bytes=$tail-')).$2, data.sublist(tail));
final got = <int>[];
final sub = buffer.read(SharedSource.playerViewer, 20 * _mib, 21 * _mib - 1).listen(got.addAll);
await Future<void>.delayed(const Duration(milliseconds: 600));
expect(got, isEmpty);
await upload.cancel();
final until = DateTime.now().add(const Duration(seconds: 5));
while (got.length < _mib && DateTime.now().isBefore(until)) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
await sub.cancel();
expect(got.length, _mib);
});
test('memory stays within the budget while the player reads the whole media', () async {
final data = _bytes(64 * _mib);
final up = _Upstream(data);
const ahead = 8 * _mib, back = 2 * _mib;
final s = SharedSource(RelayMedia.single(up), ahead: ahead, back: back);
addTearDown(s.close);
await s.start();
final buffer = s.tracks.single.buffer;
var peak = 0;
var n = 0;
await for (final c in buffer.read(SharedSource.playerViewer, 0, data.length - 1)) {
n += c.length;
peak = max(peak, buffer.cachedBytes);
}
expect(n, data.length);
expect(peak, lessThanOrEqualTo(ahead + back + 2 * RelayConstants.relayPinBytes + RelayConstants.relaySliceBytes));
});
test('closing under open reads ends them plainly', () async {
final data = _bytes(8 * _mib);
final s = SharedSource(RelayMedia.single(_Upstream(data, rate: _mib)));
await s.start();
final upload = await _upload(s);
final errors = <Object>[];
final sub = upload.read(0, data.length - 1).listen((_) {}, onError: errors.add);
final player = _get(s.playerUrl).then((r) => r.$2.length, onError: (_) => -1);
await Future<void>.delayed(const Duration(milliseconds: 200));
await s.close();
await player;
await Future<void>.delayed(const Duration(milliseconds: 50));
await sub.cancel();
expect(errors.every((e) => e is RelayEnded), isTrue, reason: '$errors');
});
});
}
class _TwoTracks implements RelayMedia {
final RelayBytes v, a;
_TwoTracks(this.v, this.a);
@override
Future<List<RelayTrack>> open() async => [RelayTrack('video', v), RelayTrack('audio', a)];
@override
Future<void> close() async {}
}
class _SlowSize implements RelayBytes {
final RelayBytes inner;
final go = Completer<void>();
_SlowSize(this.inner);
@override
Future<int> size() async {
await go.future;
return inner.size();
}
@override
Stream<List<int>> read(int start, int end) => inner.read(start, end);
@override
Future<void> close() => inner.close();
}
/// An upstream whose every read is cut after [every] bytes.
class _Cutting extends _Upstream {
final int every;
_Cutting(super.data, {required this.every});
@override
Stream<List<int>> read(int start, int end) async* {
var n = 0;
await for (final c in super.read(start, end)) {
if (n + c.length > every) throw const HttpException('Connection closed while receiving data');
n += c.length;
yield c;
}
}
}
@@ -0,0 +1,519 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:test/test.dart';
// The single-fetch source against the two-fetch one: a member passes on
// a page with separate video and audio, served by a fake CDN that can cut
// connections and refuse a busy client (412). Two ways of being the
// source, the same server, real mpv players (headless, the app's pins):
//
// today the source's player reads the CDN itself (EDL + headers); the
// relay source range-reads the CDN again per server pull (what
// a page with pieces tracks still does).
// single one shared reader per track (SharedSource) feeds the
// source's player (loopback EDL) and the server's pulls.
//
// Each run plays [play] s from the source's first frame, then both
// players seek to 45 s and back to 5 s (as a shared seek does) and the
// time until each plays there is taken.
//
// Opt-in (minutes, needs mpv and ffmpeg): WT_SINGLE_FETCH=1
// dart test test/single_fetch_e2e_test.dart -r expanded
// WT_SF_SCENARIOS=clean,cuts,lockout,slow and WT_SF_PLAY=<secs> narrow a run.
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
final _enabled = Platform.environment['WT_SINGLE_FETCH'] == '1';
Future<(String, String)> _media() async {
final v = '$_tmp/single_fetch_v.mp4', a = '$_tmp/single_fetch_a.m4a';
if (!await File(v).exists()) {
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=1280x720:rate=30', '-t', '60', '-an',
'-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '4M', '-minrate', '4M', '-maxrate', '4M',
'-bufsize', '2M', '-g', '30', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', v,
]);
if (r.exitCode != 0) fail('ffmpeg video: ${r.stderr}');
}
if (!await File(a).exists()) {
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error', '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', '-t', '60',
'-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', a,
]);
if (r.exitCode != 0) fail('ffmpeg audio: ${r.stderr}');
}
return (v, a);
}
class CdnRequest {
final double at;
final String path, who, range;
final int status;
int sent = 0;
bool cut = false;
CdnRequest(this.at, this.path, this.who, this.range, this.status);
}
/// A CDN in the Bilibili mould: Range GETs of two files, Referer
/// required (403 without), a per-connection rate, an optional cut of
/// every answer after a random 0..[cutMax] bytes, and an optional
/// lockout: more than [lockoutBurst] requests in 10 s and every request
/// gets 412 for [lockoutFor].
class FakeCdn {
final Map<String, List<int>> files;
final int bytesPerSec;
final Duration firstByte;
final int? cutMax;
final int? lockoutBurst;
final Duration lockoutFor;
final _rnd = Random(42);
final clock = Stopwatch()..start();
late HttpServer _http;
final requests = <CdnRequest>[];
final open = <String, int>{}, maxOpen = <String, int>{};
double _lockedUntil = -1;
int lockouts = 0;
FakeCdn(this.files,
{this.bytesPerSec = 3 << 20,
this.firstByte = const Duration(milliseconds: 40),
this.cutMax,
this.lockoutBurst,
this.lockoutFor = const Duration(seconds: 10)});
int get port => _http.port;
String url(String path) => 'http://127.0.0.1:$port/$path?sig=abc';
static const headers = {'Referer': 'https://www.example-video.site/', 'User-Agent': 'Mozilla/5.0 test'};
Future<void> start() async {
_http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
_http.listen((req) => unawaited(_serve(req)));
}
Future<void> close() => _http.close(force: true);
double get now => clock.elapsedMicroseconds / 1e6;
Future<void> _serve(HttpRequest req) async {
final path = req.uri.pathSegments.isEmpty ? '' : req.uri.pathSegments.first;
final who = req.headers.value('x-who') ?? 'player';
final range = req.headers.value(HttpHeaders.rangeHeader) ?? '';
final data = files[path];
final t = now;
final recent = requests.where((r) => r.at > t - 10).length;
int status;
if (data == null) {
status = 404;
} else if (req.headers.value('referer') == null) {
status = 403;
} else if (t < _lockedUntil) {
status = 412;
} else if (lockoutBurst != null && recent >= lockoutBurst!) {
_lockedUntil = t + lockoutFor.inMicroseconds / 1e6;
lockouts++;
status = 412;
} else {
status = 206;
}
final log = CdnRequest(t, path, who, range, status);
requests.add(log);
final res = req.response;
if (status != 206) {
res.statusCode = status;
await res.close();
return;
}
final m = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(range);
var start = 0, end = data!.length - 1;
if (m != null) {
if (m[1]!.isEmpty) {
start = max(0, data.length - int.parse(m[2]!));
} else {
start = int.parse(m[1]!);
if (m[2]!.isNotEmpty) end = min(end, int.parse(m[2]!));
}
}
if (start >= data.length) {
res.statusCode = 416;
await res.close();
return;
}
res.statusCode = m == null ? 200 : 206;
res.headers
..set(HttpHeaders.contentTypeHeader, 'video/mp4')
..set(HttpHeaders.acceptRangesHeader, 'bytes')
..set(HttpHeaders.connectionHeader, 'close')
..contentLength = end - start + 1;
if (m != null) res.headers.set(HttpHeaders.contentRangeHeader, 'bytes $start-$end/${data.length}');
open[path] = (open[path] ?? 0) + 1;
maxOpen[path] = max(maxOpen[path] ?? 0, open[path]!);
final cutAt = cutMax == null ? null : _rnd.nextInt(cutMax!);
Socket? s;
try {
await Future<void>.delayed(firstByte);
s = await res.detachSocket(writeHeaders: true);
final sw = Stopwatch()..start();
var pos = start;
const chunk = 16 * 1024;
while (pos <= end) {
var n = min(chunk, end - pos + 1);
if (cutAt != null && log.sent + n > cutAt) {
n = cutAt - log.sent;
if (n > 0) s.add(data.sublist(pos, pos + n));
log.sent += max(0, n);
await s.flush();
log.cut = true;
s.destroy();
return;
}
s.add(data.sublist(pos, pos + n));
await s.flush();
pos += n;
log.sent += n;
final ahead = log.sent / bytesPerSec - sw.elapsedMicroseconds / 1e6;
if (ahead > 0.002) await Future<void>.delayed(Duration(microseconds: (ahead * 1e6).round()));
}
await s.close();
} catch (_) {
// The client went away (a seek, a cancelled pull).
s?.destroy();
} finally {
open[path] = open[path]! - 1;
}
}
}
/// A headless mpv with the app's pins, watched over its JSON IPC.
class Probe {
final String label;
final Stopwatch clock;
final double startedAt;
late Process _proc;
Socket? _ipc;
double? firstFrame; // seconds on [clock]
double pos = 0;
var stalls = 0;
double stalled = 0;
double? _stallSince;
final errors = <String>[];
Probe(this.label, this.clock) : startedAt = clock.elapsedMicroseconds / 1e6;
double get _now => clock.elapsedMicroseconds / 1e6;
static Future<Probe> start(String label, String url, Stopwatch clock, {Map<String, String> headers = const {}}) async {
final p = Probe(label, clock);
final sock = '$_tmp/sf-${label.hashCode}-${DateTime.now().microsecondsSinceEpoch}.sock';
p._proc = await Process.start('mpv', [
'--no-config', '--vo=null', '--ao=null', '--idle=no', '--keep-open=yes', '--input-ipc-server=$sock',
'--cache=yes', '--demuxer-max-bytes=268435456', '--demuxer-max-back-bytes=67108864',
'--demuxer-readahead-secs=150', '--cache-secs=150', '--ytdl=no', '--network-timeout=5',
'--stream-lavf-o=reconnect_streamed=1,reconnect_on_http_error=5xx,reconnect_delay_max=4,reconnect_max_retries=3',
'--msg-level=all=warn',
if (headers.isNotEmpty) '--http-header-fields=${headers.entries.map((e) => '${e.key}: ${e.value}').join(',')}',
url,
]);
p._proc.stdout.drain<void>();
p._proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((l) {
if (p.errors.length < 40) p.errors.add(l);
});
final deadline = DateTime.now().add(const Duration(seconds: 5));
while (true) {
try {
p._ipc = await Socket.connect(InternetAddress(sock, type: InternetAddressType.unix), 0);
break;
} catch (_) {
if (DateTime.now().isAfter(deadline)) rethrow;
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
p._ipc!.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).listen(p._on, onError: (_) {});
p._send(['observe_property', 1, 'time-pos']);
p._send(['observe_property', 2, 'paused-for-cache']);
return p;
}
void _send(List<Object> cmd) => _ipc?.write('${jsonEncode({'command': cmd})}\n');
void _on(String line) {
final Map j;
try {
j = jsonDecode(line) as Map;
} catch (_) {
return;
}
switch (j['event']) {
case 'playback-restart':
firstFrame ??= _now;
case 'property-change':
if (j['name'] == 'time-pos' && j['data'] is num) pos = (j['data'] as num).toDouble();
if (j['name'] == 'paused-for-cache' && firstFrame != null) {
if (j['data'] == true && _stallSince == null) {
_stallSince = _now;
stalls++;
} else if (j['data'] == false && _stallSince != null) {
stalled += _now - _stallSince!;
_stallSince = null;
}
}
case 'end-file':
if (j['reason'] == 'error') errors.add('end-file error ${j['file_error'] ?? ''}');
}
}
double get stalledNow => stalled + (_stallSince == null ? 0 : _now - _stallSince!);
/// Seeks to [to] s; the seconds until it plays there (null: not within
/// [limit]).
Future<double?> seek(double to, {Duration limit = const Duration(seconds: 20)}) async {
final t0 = _now;
_send(['seek', to, 'absolute']);
final end = DateTime.now().add(limit);
// Past the target and not waiting for the cache.
while (DateTime.now().isBefore(end)) {
await Future<void>.delayed(const Duration(milliseconds: 20));
if (pos >= to + 0.3 && pos < to + 5 && _stallSince == null) return _now - t0;
}
return null;
}
Future<void> stop() async {
_send(['quit']);
await _proc.exitCode.timeout(const Duration(seconds: 3), onTimeout: () {
_proc.kill(ProcessSignal.sigkill);
return -1;
});
await _ipc?.close().catchError((_) {});
}
}
class _Member {
final WebSocket ws;
final raw = <String>[];
late WelcomeMessage welcome;
_Member(this.ws);
static Future<_Member> join(int port, String name) async {
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
final m = _Member(ws);
final w = Completer<String>();
ws.listen((f) {
m.raw.add('$f');
if ('$f'.contains('"t":"welcome"') && !w.isCompleted) w.complete('$f');
}, onError: (_) {});
ws.add(jsonEncode(
HelloMessage(proto: RelayConstants.protoVersion, secret: 'pw', user: HelloUser(name: name)).toJson()));
m.welcome = WelcomeMessage.fromJson(
Map<String, Object?>.from(jsonDecode(await w.future.timeout(const Duration(seconds: 5))) as Map));
return m;
}
Map<String, Object?>? get lastState {
for (final f in raw.reversed) {
final d = jsonDecode(f) as Map;
if (d['t'] == 'state') return Map<String, Object?>.from(d);
}
return null;
}
Map<String, Object?>? get relay => (lastState?['media'] as Map?)?['relay'] as Map<String, Object?>?;
}
class _Tracks implements RelayMedia {
final List<RelayTrack> tracks;
_Tracks(this.tracks);
@override
Future<List<RelayTrack>> open() async => tracks;
@override
Future<void> close() async {
for (final t in tracks) {
await t.bytes?.close();
}
}
}
/// One run: [mode] `today` or `single` against [cdn] for [play] seconds
/// after the source's first frame.
Future<Map<String, Object?>> _run(String mode, FakeCdn cdn, int play) async {
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 't'));
await server.start();
final alice = await _Member.join(server.boundPort, 'alice');
final bob = await _Member.join(server.boundPort, 'bob');
alice.ws.add(jsonEncode({
't': 'swap',
'media': {
'name': 'page',
'duration': 60,
'relay': {
'tracks': [
{'kind': 'video'},
{'kind': 'audio'},
],
},
},
}));
final until = DateTime.now().add(const Duration(seconds: 5));
while (alice.relay == null) {
if (DateTime.now().isAfter(until)) fail('no relay state');
await Future<void>.delayed(const Duration(milliseconds: 20));
}
final mediaId = alice.lastState!['mediaId'] as int;
final sourceUrl = Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/${alice.welcome.token}/source');
RelayTrack upstream(String kind, String path) => RelayTrack(
kind, HttpRelayBytes(Uri.parse(cdn.url(path)), headers: {...FakeCdn.headers, 'X-Who': 'relay'}));
final upstreamMedia = _Tracks([upstream('video', 'v'), upstream('audio', 'a')]);
final clock = Stopwatch()..start();
final reqBefore = cdn.requests.length;
RelaySource source;
SharedSource? shared;
Probe sourcePlayer;
if (mode == 'today') {
// As switchTo does: the relay source first (not awaited), then the
// player on the CDN's URLs with the page's headers.
source = RelaySource(sourceUrl, upstreamMedia, name: 'page', duration: 60);
unawaited(source.start());
sourcePlayer = await Probe.start('source', mpvEdl([cdn.url('v'), cdn.url('a')]), clock,
headers: {...FakeCdn.headers, 'X-Who': 'player'});
} else {
// As the app does: the player opens at once (the loopback endpoint
// waits for the sizes), the relay source starts meanwhile.
shared = SharedSource(upstreamMedia);
await shared.open();
source = RelaySource(sourceUrl, shared.upload, name: 'page', duration: 60);
unawaited(source.start());
sourcePlayer = await Probe.start('source', shared.playerUrl, clock);
}
// Viewers open once the relay is live (relayWaitsForSource).
while (bob.relay?['live'] != true) {
if (clock.elapsed > const Duration(seconds: 20)) fail('relay never live');
await Future<void>.delayed(const Duration(milliseconds: 10));
}
final liveAt = clock.elapsedMicroseconds / 1e6;
final base = 'http://127.0.0.1:${server.boundPort}/r/default/${bob.welcome.token}/$mediaId';
final viewer = await Probe.start('viewer', mpvEdl(['$base/0/page', '$base/1/page']), clock);
// Play: both from their first frame, no sync engine (stalls show as lag).
var sharedPeak = 0;
final peakTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
if (shared != null) sharedPeak = max(sharedPeak, shared.cachedBytes);
});
final end = DateTime.now().add(Duration(seconds: play + 15));
while (DateTime.now().isBefore(end)) {
if (sourcePlayer.firstFrame != null && clock.elapsedMicroseconds / 1e6 > sourcePlayer.firstFrame! + play) break;
await Future<void>.delayed(const Duration(milliseconds: 100));
}
final t = clock.elapsedMicroseconds / 1e6;
double lag(Probe p) => p.firstFrame == null ? double.nan : (t - p.firstFrame!) - p.pos;
final lags = (lag(sourcePlayer), lag(viewer)), stalls = (sourcePlayer.stalls, viewer.stalls);
final positions = (sourcePlayer.pos, viewer.pos);
// A shared seek far ahead, then back.
final ahead = await Future.wait([sourcePlayer.seek(45), viewer.seek(45)]);
final back = await Future.wait([sourcePlayer.seek(5), viewer.seek(5)]);
final reqs = cdn.requests.sublist(reqBefore);
int count(bool Function(CdnRequest r) f) => reqs.where(f).length;
// Peak requests in any 1 s window.
var peak = 0;
for (final r in reqs) {
peak = max(peak, reqs.where((q) => q.at >= r.at && q.at < r.at + 1).length);
}
final out = <String, Object?>{
'mode': mode,
'sourceFirstFrame': sourcePlayer.firstFrame,
'live': liveAt,
'viewerFirstFrame': viewer.firstFrame,
'sourcePos': positions.$1,
'viewerPos': positions.$2,
'sourceLag': lags.$1,
'viewerLag': lags.$2,
'sourceStalls': stalls.$1,
'viewerStalls': stalls.$2,
'seekAheadSource': ahead[0],
'seekAheadViewer': ahead[1],
'seekBackSource': back[0],
'seekBackViewer': back[1],
'cdnRequests': reqs.length,
'byPlayer': count((r) => r.who == 'player'),
'byRelay': count((r) => r.who == 'relay'),
'cdnMiB': reqs.fold<int>(0, (a, r) => a + r.sent) / (1 << 20),
'cuts': count((r) => r.cut),
'refused412': count((r) => r.status == 412),
'peakReqPerSec': peak,
'maxOpenV': cdn.maxOpen['v'],
'maxOpenA': cdn.maxOpen['a'],
'resumes': shared?.resumes,
'sharedPeakMiB': shared == null ? null : sharedPeak / (1 << 20),
'sourceErrors': sourcePlayer.errors.take(4).toList(),
'viewerErrors': viewer.errors.take(4).toList(),
};
peakTimer.cancel();
await sourcePlayer.stop();
await viewer.stop();
await source.close();
await shared?.close();
if (shared == null) await upstreamMedia.close();
await alice.ws.close();
await bob.ws.close();
await server.stop();
return out;
}
void main() {
test('single fetch vs today: one upstream reader per track, both players in step', () async {
final (vPath, aPath) = await _media();
final files = {'v': await File(vPath).readAsBytes(), 'a': await File(aPath).readAsBytes()};
final play = int.tryParse(Platform.environment['WT_SF_PLAY'] ?? '') ?? 20;
final wanted = (Platform.environment['WT_SF_SCENARIOS'] ?? 'clean,cuts,lockout').split(',');
final scenarios = <String, FakeCdn Function()>{
'clean': () => FakeCdn(files),
'cuts': () => FakeCdn(files, cutMax: 2 << 20),
'lockout': () => FakeCdn(files, cutMax: 2 << 20, lockoutBurst: 40),
// A per-connection rate about 1.5x the media's: today's two
// connections get twice what one shared reader gets.
'slow': () => FakeCdn(files, bytesPerSec: 768 << 10),
};
final results = <String, Map<String, Object?>>{};
for (final name in wanted) {
for (final mode in ['today', 'single']) {
final cdn = scenarios[name]!();
await cdn.start();
// Anything raised outside a handler (server.stop() under open /r
// answers among it) is counted: there must be none.
var teardown = 0;
final teardownErrors = <String>[];
final done = Completer<Map<String, Object?>>();
runZonedGuarded(() async => done.complete(await _run(mode, cdn, play)), (e, st) {
teardown++;
if (teardownErrors.length < 3) teardownErrors.add('$e');
});
final r = {...await done.future, 'teardownErrors': teardown, 'teardownSample': teardownErrors};
await cdn.close();
results['$name/$mode'] = r;
// ignore: avoid_print
print('RESULT $name/$mode ${jsonEncode(r, toEncodable: (o) => '$o')}');
}
}
for (final e in results.entries.where((e) => e.key.endsWith('/single'))) {
final r = e.value;
expect(r['byPlayer'], 0, reason: '${e.key}: the source player never reads the CDN');
expect(r['sourceFirstFrame'], isNotNull, reason: e.key);
expect(r['viewerFirstFrame'], isNotNull, reason: e.key);
// One reader per track. The fake CDN counts a GET as open until its
// next write fails, so a cancelled one (the hand-over to the tail
// pin at the start) can overlap its successor and the next one.
expect((r['maxOpenV'] as int?) ?? 0, lessThanOrEqualTo(3), reason: e.key);
expect((r['maxOpenA'] as int?) ?? 0, lessThanOrEqualTo(3), reason: e.key);
expect(r['refused412'], 0, reason: '${e.key}: one reader never trips the lockout');
expect(r['seekAheadSource'], isNotNull, reason: '${e.key}: the source plays after a seek ahead');
expect(r['seekBackSource'], isNotNull, reason: '${e.key}: the source plays after a seek back');
expect(r['seekAheadViewer'], isNotNull, reason: e.key);
expect(r['seekBackViewer'], isNotNull, reason: e.key);
}
for (final e in results.entries) {
expect(e.value['teardownErrors'], 0, reason: '${e.key}: ${e.value['teardownSample']}');
}
}, skip: _enabled ? false : 'opt-in: WT_SINGLE_FETCH=1 (real mpv, minutes)', timeout: const Timeout(Duration(minutes: 15)));
}