forked from Foxyz/sync_replay_project
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:
1 parent
7715dc479a
commit
ec0849eb01
19 files changed
+1894
-101
No files matched your search
@@ -38,6 +38,11 @@ abstract class RelayConstants {
|
||||
/// first bytes, under mpv's 5 s `network-timeout`: a header that never
|
||||
/// comes in time costs ffmpeg the file's size for good.
|
||||
static const int relayHeaderWaitMs = 4000;
|
||||
/// The source's own buffer of a link it reads once for its player and
|
||||
/// the upload (`SharedSource`), all tracks together besides the pins:
|
||||
/// the two read within [sharedAheadBytes] of each other.
|
||||
static const int sharedAheadBytes = 64 << 20;
|
||||
static const int sharedBackBytes = 16 << 20;
|
||||
/// The warm relay (Up next's first relayed item, before it goes up)
|
||||
/// keeps this much ahead of and behind its viewers' preloads, besides
|
||||
/// the pins: about what a preload player reads ahead (48 MiB), never the
|
||||
|
||||
@@ -65,15 +65,21 @@ class YtDlpFormat {
|
||||
/// Relayed as pieces (see relay_pieces.dart), not as a byte stream.
|
||||
bool get isPieces => _piecesProtocols.contains(protocol);
|
||||
|
||||
/// From one of yt-dlp's formats: its `http_headers` plus its `cookies`
|
||||
/// as a Cookie header, the same a player gets ([YtDlpPlay.parse]).
|
||||
static YtDlpFormat _of(Map f) {
|
||||
final headers = f['http_headers'];
|
||||
final opts = f['downloader_options'];
|
||||
final chunk = opts is Map ? opts['http_chunk_size'] : null;
|
||||
final h = f['height'] is num ? (f['height'] as num).toInt() : 0;
|
||||
final cookie = cookieHeaderOf(f['cookies']);
|
||||
return YtDlpFormat(
|
||||
id: '${f['format_id'] ?? ''}',
|
||||
url: Uri.parse(f['url'] as String),
|
||||
headers: headers is Map ? {for (final e in headers.entries) '${e.key}': '${e.value}'} : const {},
|
||||
headers: {
|
||||
if (headers is Map) for (final e in headers.entries) '${e.key}': '${e.value}',
|
||||
if (cookie.isNotEmpty) 'Cookie': cookie,
|
||||
},
|
||||
height: h == 0 ? null : h,
|
||||
ext: '${f['ext']}',
|
||||
chunkSize: chunk is num && chunk > 0 ? chunk.toInt() : null,
|
||||
|
||||
@@ -262,6 +262,25 @@ void main() {
|
||||
expect(pick(['avc1.640033', 'vp09.00.40.08']), 'v1');
|
||||
});
|
||||
|
||||
test('what a relay reads sends what a player sends: each format\'s headers with its cookies', () {
|
||||
const cookies = 'SESSDATA=abc; Domain=.bilibili.com; Path=/; Secure; Expires=1999999999; buvid3=xyz; Domain=.bilibili.com';
|
||||
Map<String, Object?> f(String id, String vcodec, String acodec, int? height) => {
|
||||
'format_id': id, 'url': 'https://cdn/$id.m4s', 'protocol': 'https', 'ext': 'mp4',
|
||||
'vcodec': vcodec, 'acodec': acodec, 'height': height,
|
||||
'http_headers': {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA'},
|
||||
'cookies': cookies,
|
||||
};
|
||||
final v = f('30080', 'avc1.640032', 'none', 1080), a = f('30280', 'none', 'mp4a.40.2', null);
|
||||
final info = YtDlpInfo.parse({'title': 'Page', 'formats': [v, a], 'requested_formats': [v, a]});
|
||||
const want = {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA', 'Cookie': 'SESSDATA=abc; buvid3=xyz'};
|
||||
expect([for (final t in info.tracks) t.format.id], ['30080', '30280']);
|
||||
for (final t in info.tracks) {
|
||||
expect(t.format.headers, want, reason: t.kind);
|
||||
}
|
||||
expect(info.play!.headers, want);
|
||||
expect(info.formats['30080']!.describe(), isNot(contains('SESSDATA')), reason: 'the log names headers only');
|
||||
});
|
||||
|
||||
test('what a player opens: yt-dlp\'s pick, an EDL for video + audio, headers with the cookies, subtitles but danmaku', () {
|
||||
final split = YtDlpInfo.parse({
|
||||
'title': 'Page',
|
||||
|
||||
@@ -8,3 +8,4 @@ export 'src/relay_buffer.dart';
|
||||
export 'src/relay_routes.dart';
|
||||
export 'src/relay_stats.dart';
|
||||
export 'src/session_log.dart';
|
||||
export 'src/shared_source.dart';
|
||||
@@ -158,11 +158,13 @@ class _Pull {
|
||||
}
|
||||
|
||||
class _Viewer {
|
||||
final String name;
|
||||
int pos = 0;
|
||||
int open = 0; // requests in flight
|
||||
int newest = 0; // sequence of its newest request
|
||||
(int slice, int req)? blocking; // where its newest request waits
|
||||
DateTime lastActive = DateTime.now();
|
||||
_Viewer(this.name);
|
||||
}
|
||||
|
||||
/// The server side of the relay for one media: an in-memory buffer of
|
||||
@@ -181,6 +183,9 @@ class _Viewer {
|
||||
/// - Only a viewer's newest request moves its position or asks the feed
|
||||
/// for anything; older ones still get bytes once they are buffered, and
|
||||
/// stop holding the feed once superseded (a seek elsewhere).
|
||||
/// - [together]: two viewers kept within [ahead] of each other (see the
|
||||
/// field).
|
||||
/// - Read-ahead never fetches a slice that would be the next one evicted.
|
||||
/// Everything lives in memory: dropping the buffer is the cleanup.
|
||||
class RelayBuffer implements TrackBuffer {
|
||||
/// Set by the relay when a media's tracks share the budget.
|
||||
@@ -190,11 +195,22 @@ class RelayBuffer implements TrackBuffer {
|
||||
final Duration waitLimit;
|
||||
/// Pull ids; the tracks of one media share one sequence (one channel).
|
||||
final int Function() _newId;
|
||||
/// Two viewers that read one stream at about one position (the source's
|
||||
/// own player and its upload to the server, see [SharedSource]) and
|
||||
/// must not pull the feed two ways: while both have a request open, a
|
||||
/// read of the one more than [ahead] past the other waits without
|
||||
/// asking the feed for anything (and without the wait limit), and its
|
||||
/// position does not count for read-ahead or eviction. So one
|
||||
/// sequential feed serves both, from one window, instead of handing
|
||||
/// over at every slice boundary. Pinned slices are always read. Null:
|
||||
/// every viewer drives on its own (the server's viewers).
|
||||
final (String, String)? together;
|
||||
RelayBuffer({
|
||||
this.ahead = RelayConstants.relayAheadBytes,
|
||||
this.back = RelayConstants.relayBackBytes,
|
||||
this.pin = RelayConstants.relayPinBytes,
|
||||
this.waitLimit = const Duration(seconds: RelayConstants.relayWaitSecs),
|
||||
this.together,
|
||||
int Function()? nextId,
|
||||
}) : _newId = nextId ?? _counter();
|
||||
|
||||
@@ -213,6 +229,8 @@ class RelayBuffer implements TrackBuffer {
|
||||
// The driving requests waiting on each blocked slice.
|
||||
final _blockedBy = <int, Set<int>>{};
|
||||
final _viewers = <String, _Viewer>{};
|
||||
// Reads held back by [together], woken when a position moves.
|
||||
var _moved = Completer<void>();
|
||||
@override
|
||||
FeedClock clock = FeedClock();
|
||||
_Pull? _pullNow;
|
||||
@@ -282,12 +300,30 @@ class RelayBuffer implements TrackBuffer {
|
||||
_waiters.clear();
|
||||
if (!_ready.isCompleted) _ready.complete();
|
||||
_wake();
|
||||
_wakeHeld();
|
||||
}
|
||||
|
||||
void _wake() {
|
||||
if (!_kick.isCompleted) _kick.complete();
|
||||
}
|
||||
|
||||
void _wakeHeld() {
|
||||
if (together == null) return;
|
||||
final m = _moved;
|
||||
_moved = Completer<void>();
|
||||
m.complete();
|
||||
}
|
||||
|
||||
/// [v] is one of [together] and more than [ahead] past the other while
|
||||
/// that one has a request open: it does not drive.
|
||||
bool _held(_Viewer v) {
|
||||
final t = together;
|
||||
if (t == null) return false;
|
||||
final other = v.name == t.$1 ? t.$2 : (v.name == t.$2 ? t.$1 : null);
|
||||
final o = other == null ? null : _viewers[other];
|
||||
return o != null && o.open > 0 && v.pos > o.pos + ahead;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ feed input
|
||||
|
||||
@override
|
||||
@@ -314,6 +350,8 @@ class RelayBuffer implements TrackBuffer {
|
||||
_slices[s] = pl.takeSlice();
|
||||
// A whole slice came: the source works again.
|
||||
_failures = 0;
|
||||
// A long pull stays within the budget as it goes.
|
||||
_evict();
|
||||
}
|
||||
// Whole, or more of it: whoever waits on it reads on.
|
||||
for (final c in _waiters.remove(s) ?? const <Completer<void>>[]) {
|
||||
@@ -362,7 +400,24 @@ class RelayBuffer implements TrackBuffer {
|
||||
return true;
|
||||
}
|
||||
if (_slices.containsKey(next)) return true;
|
||||
return !_wanted(next);
|
||||
if (!_wanted(next)) return true;
|
||||
// Read-ahead nobody waits on stops where the next slice would be the
|
||||
// next one evicted (else a full buffer fetches and drops it in turn).
|
||||
final s = next - 1;
|
||||
return !_blocked.containsKey(s) && !_blocked.containsKey(next) && !_roomFor(next);
|
||||
}
|
||||
|
||||
/// Slice [s] can come in without being the next one evicted: the
|
||||
/// buffer has room, or holds a slice farther from the viewers.
|
||||
bool _roomFor(int s) {
|
||||
if ((_slices.length + 1) * slice <= ahead + back + 2 * pin) return true;
|
||||
final at = _positions();
|
||||
final d = _distance(s, at);
|
||||
if (d < 0) return true;
|
||||
for (final c in _slices.keys) {
|
||||
if (!_waiters.containsKey(c) && _distance(c, at) > d) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _endPull(_Pull pl) {
|
||||
@@ -374,8 +429,8 @@ class RelayBuffer implements TrackBuffer {
|
||||
|
||||
Iterable<_Viewer> get _active {
|
||||
final now = DateTime.now();
|
||||
return _viewers.values
|
||||
.where((v) => v.open > 0 || now.difference(v.lastActive) < const Duration(seconds: 60));
|
||||
return _viewers.values.where(
|
||||
(v) => (v.open > 0 || now.difference(v.lastActive) < const Duration(seconds: 60)) && !_held(v));
|
||||
}
|
||||
|
||||
bool _pinned(int s) => s * slice < pin || s * slice >= size - pin;
|
||||
@@ -388,13 +443,17 @@ class RelayBuffer implements TrackBuffer {
|
||||
return off >= pos.reduce(min) - back && off <= pos.reduce(max) + ahead;
|
||||
}
|
||||
|
||||
int _distance(int s) {
|
||||
/// The active viewers' positions, for [_distance].
|
||||
List<int> _positions() => [for (final v in _active) v.pos];
|
||||
|
||||
/// How far slice [s] is from the viewers at [positions]; -1 pinned.
|
||||
int _distance(int s, List<int> positions) {
|
||||
if (_pinned(s)) return -1;
|
||||
final off = s * slice;
|
||||
var d = 1 << 62;
|
||||
for (final v in _active) {
|
||||
for (final p in positions) {
|
||||
// Behind a viewer costs more than ahead (it was already watched).
|
||||
d = min(d, off >= v.pos ? off - v.pos : (v.pos - off) * 4);
|
||||
d = min(d, off >= p ? off - p : (p - off) * 4);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
@@ -403,9 +462,11 @@ class RelayBuffer implements TrackBuffer {
|
||||
final limit = ahead + back + 2 * pin;
|
||||
var total = _slices.length * slice;
|
||||
if (total <= limit) return;
|
||||
final order = _slices.keys.toList()..sort((a, b) => _distance(b).compareTo(_distance(a)));
|
||||
final at = _positions();
|
||||
final dist = {for (final s in _slices.keys) s: _distance(s, at)};
|
||||
final order = dist.keys.toList()..sort((a, b) => dist[b]!.compareTo(dist[a]!));
|
||||
for (final s in order) {
|
||||
if (total <= limit || _distance(s) < 0) break;
|
||||
if (total <= limit || dist[s]! < 0) break;
|
||||
if (_waiters.containsKey(s)) continue;
|
||||
_slices.remove(s);
|
||||
total -= slice;
|
||||
@@ -427,7 +488,7 @@ class RelayBuffer implements TrackBuffer {
|
||||
final starts = active.map((v) => v.pos).toList()..sort();
|
||||
for (final p in starts) {
|
||||
for (var s = p ~/ slice; s * slice <= p + ahead && s < _nSlices; s++) {
|
||||
if (!_slices.containsKey(s)) return s;
|
||||
if (!_slices.containsKey(s)) return _roomFor(s) ? s : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -481,7 +542,7 @@ class RelayBuffer implements TrackBuffer {
|
||||
/// request is its viewer's newest ([driving] says while it still is), so
|
||||
/// it may move the feed.
|
||||
Future<Uint8List?> _waitBytes(_Viewer v, int s, int from, int req, bool Function() driving) async {
|
||||
final deadline = DateTime.now().add(waitLimit);
|
||||
var deadline = DateTime.now().add(waitLimit);
|
||||
try {
|
||||
while (true) {
|
||||
final whole = _slices[s];
|
||||
@@ -492,6 +553,13 @@ class RelayBuffer implements TrackBuffer {
|
||||
if (part != null) return part;
|
||||
}
|
||||
if (_disposed || DateTime.now().isAfter(deadline)) return null;
|
||||
if (!_pinned(s) && driving() && _held(v)) {
|
||||
// Too far past its partner: waits for it, asking nothing.
|
||||
_unblock(s, req);
|
||||
deadline = DateTime.now().add(waitLimit);
|
||||
await _moved.future.timeout(const Duration(seconds: 2), onTimeout: () {});
|
||||
continue;
|
||||
}
|
||||
if (driving()) {
|
||||
_blocked.putIfAbsent(s, () => DateTime.now().microsecondsSinceEpoch);
|
||||
_blockedBy.putIfAbsent(s, () => {}).add(req);
|
||||
@@ -539,7 +607,7 @@ class RelayBuffer implements TrackBuffer {
|
||||
/// viewer went away) ends it; a slice that never arrives ends it with an
|
||||
/// error after [waitLimit]; the buffer's [dispose] with [RelayEnded].
|
||||
Stream<Uint8List> read(String viewer, int start, int end, {void Function(int n)? onSent}) async* {
|
||||
final v = _viewers.putIfAbsent(viewer, _Viewer.new);
|
||||
final v = _viewers.putIfAbsent(viewer, () => _Viewer(viewer));
|
||||
final tail = start >= size - pin;
|
||||
final me = ++_reqSeq;
|
||||
if (!tail) {
|
||||
@@ -553,6 +621,7 @@ class RelayBuffer implements TrackBuffer {
|
||||
v.open++;
|
||||
v.lastActive = DateTime.now();
|
||||
_wake();
|
||||
_wakeHeld();
|
||||
try {
|
||||
var off = start;
|
||||
while (off <= end) {
|
||||
@@ -569,10 +638,12 @@ class RelayBuffer implements TrackBuffer {
|
||||
v.lastActive = DateTime.now();
|
||||
if (!tail && v.newest == me) v.pos = off;
|
||||
_wake();
|
||||
_wakeHeld();
|
||||
}
|
||||
} finally {
|
||||
v.open--;
|
||||
v.lastActive = DateTime.now();
|
||||
_wakeHeld();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -592,74 +592,88 @@ void mountRelayRoutes(
|
||||
app.get('/r/:sid/:token/:mediaId/:seg/p/:piece', (final req) => viewer(req, tracked: true, piece: true));
|
||||
}
|
||||
|
||||
/// A relayed byte range written on the raw connection: the status line
|
||||
/// and headers go out with the first body bytes, or after [headerWait]
|
||||
/// without them (dart:io would hold them until body bytes come, and an
|
||||
/// ffmpeg whose request gets no headers within its 5 s timeout has lost
|
||||
/// the file's size: every later seek fails with "size -38"). Then the
|
||||
/// body as the buffer has it; a read waiting on missing bytes keeps the
|
||||
/// connection open meanwhile. Always `connection: close` and closed after
|
||||
/// the body: ffmpeg asks for close, and one that is not told so reuses the
|
||||
/// A relayed byte range on the raw connection ([writeRelayRange]).
|
||||
Hijack _rangeResponse(int status, Map<String, String> headers, int length, Stream<Uint8List>? body,
|
||||
{required void Function(Object e) onBroke}) =>
|
||||
Hijack((ch) => writeRelayRange(ch.stream, ch.sink, status, headers, length, body, onBroke: onBroke));
|
||||
|
||||
/// Writes a relayed byte range on a raw connection ([input] / [out]: the
|
||||
/// server's `/r` answers, and the source's own loopback endpoint in
|
||||
/// shared_source.dart): the status line and headers go out with the
|
||||
/// first body bytes, or after [headerWait] without them (an HTTP stack
|
||||
/// would hold them until body bytes come, and an ffmpeg whose request
|
||||
/// gets no headers within its 5 s timeout has lost the file's size:
|
||||
/// every later seek fails with "size -38"). Then the body as the buffer
|
||||
/// has it; a read waiting on missing bytes keeps the connection open
|
||||
/// meanwhile. Always `connection: close` and closed after the body:
|
||||
/// ffmpeg asks for close, and one that is not told so reuses the
|
||||
/// connection the server closed for its next seek and fails it the same
|
||||
/// way. A relay that ends ([RelayEnded]) closes the connection plainly;
|
||||
/// any other failure ([onBroke]) resets it. The client going away cancels
|
||||
/// the read.
|
||||
Hijack _rangeResponse(int status, Map<String, String> headers, int length, Stream<Uint8List>? body,
|
||||
{required void Function(Object e) onBroke, Duration headerWait = const Duration(milliseconds: RelayConstants.relayHeaderWaitMs)}) {
|
||||
return Hijack((ch) async {
|
||||
final out = ch.sink;
|
||||
final head = StringBuffer('HTTP/1.1 $status ${status == 206 ? 'Partial Content' : 'OK'}\r\n')
|
||||
..write('date: ${HttpDate.format(DateTime.now())}\r\n');
|
||||
headers.forEach((k, v) {
|
||||
if (k != 'content-length' && k != 'connection') head.write('$k: $v\r\n');
|
||||
});
|
||||
head.write('content-length: $length\r\nconnection: close\r\n\r\n');
|
||||
final it = body == null ? null : StreamIterator(body);
|
||||
// A read cancelled while it waits ends with what it was waiting for
|
||||
// (its relay ending, say): moot then.
|
||||
Future<void> stop() async => it?.cancel().catchError((_) {});
|
||||
// The client closing (a seek elsewhere, the player gone) ends the read.
|
||||
final input = ch.stream.listen(null,
|
||||
onDone: () => unawaited(stop()), onError: (_) => unawaited(stop()), cancelOnError: true);
|
||||
var reset = false;
|
||||
// The first bytes go out with the headers when they come in time.
|
||||
Future<bool>? first = it?.moveNext();
|
||||
if (first != null) {
|
||||
await Future.any([first.then((_) {}, onError: (_) {}), Future<void>.delayed(headerWait)]);
|
||||
}
|
||||
try {
|
||||
out.add(latin1.encode('$head'));
|
||||
if (out is IOSink) await out.flush();
|
||||
while (it != null) {
|
||||
final bool more;
|
||||
try {
|
||||
final next = first ?? it.moveNext();
|
||||
first = null;
|
||||
more = await next;
|
||||
} catch (e) {
|
||||
onBroke(e);
|
||||
reset = e is! RelayEnded;
|
||||
break;
|
||||
}
|
||||
if (!more) break;
|
||||
out.add(it.current);
|
||||
if (out is IOSink) await out.flush();
|
||||
}
|
||||
} catch (_) {
|
||||
// The client went away mid-write: nothing to say.
|
||||
reset = true;
|
||||
} finally {
|
||||
await input.cancel().catchError((_) {});
|
||||
await stop();
|
||||
if (reset && out is Socket) {
|
||||
out.destroy();
|
||||
} else {
|
||||
await out.close().catchError((_) {});
|
||||
}
|
||||
}
|
||||
/// the read. Never throws.
|
||||
Future<void> writeRelayRange(Stream<List<int>> input, StreamSink<List<int>> out, int status,
|
||||
Map<String, String> headers, int length, Stream<Uint8List>? body,
|
||||
{required void Function(Object e) onBroke,
|
||||
Duration headerWait = const Duration(milliseconds: RelayConstants.relayHeaderWaitMs)}) async {
|
||||
final head = StringBuffer('HTTP/1.1 $status ${_reason(status)}\r\n')
|
||||
..write('date: ${HttpDate.format(DateTime.now())}\r\n');
|
||||
headers.forEach((k, v) {
|
||||
if (k != 'content-length' && k != 'connection') head.write('$k: $v\r\n');
|
||||
});
|
||||
head.write('content-length: $length\r\nconnection: close\r\n\r\n');
|
||||
final it = body == null ? null : StreamIterator(body);
|
||||
// A read cancelled while it waits ends with what it was waiting for
|
||||
// (its relay ending, say): moot then.
|
||||
Future<void> stop() async => it?.cancel().catchError((_) {});
|
||||
// The client closing (a seek elsewhere, the player gone) ends the read.
|
||||
final listening = input.listen(null,
|
||||
onDone: () => unawaited(stop()), onError: (_) => unawaited(stop()), cancelOnError: true);
|
||||
var reset = false;
|
||||
// The first bytes go out with the headers when they come in time.
|
||||
Future<bool>? first = it?.moveNext();
|
||||
if (first != null) {
|
||||
await Future.any([first.then((_) {}, onError: (_) {}), Future<void>.delayed(headerWait)]);
|
||||
}
|
||||
try {
|
||||
out.add(latin1.encode('$head'));
|
||||
if (out is IOSink) await out.flush();
|
||||
while (it != null) {
|
||||
final bool more;
|
||||
try {
|
||||
final next = first ?? it.moveNext();
|
||||
first = null;
|
||||
more = await next;
|
||||
} catch (e) {
|
||||
onBroke(e);
|
||||
reset = e is! RelayEnded;
|
||||
break;
|
||||
}
|
||||
if (!more) break;
|
||||
out.add(it.current);
|
||||
if (out is IOSink) await out.flush();
|
||||
}
|
||||
} catch (_) {
|
||||
// The client went away mid-write: nothing to say.
|
||||
reset = true;
|
||||
} finally {
|
||||
await listening.cancel().catchError((_) {});
|
||||
await stop();
|
||||
if (reset && out is Socket) {
|
||||
out.destroy();
|
||||
} else {
|
||||
await out.close().catchError((_) {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _reason(int status) => switch (status) {
|
||||
200 => 'OK',
|
||||
206 => 'Partial Content',
|
||||
404 => 'Not Found',
|
||||
416 => 'Range Not Satisfiable',
|
||||
_ => 'Status',
|
||||
};
|
||||
|
||||
Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
|
||||
|
||||
final _readFailThrottle = LogThrottle(burst: 3);
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'media_endpoint.dart' show resolveMediaRequest;
|
||||
import 'relay_buffer.dart';
|
||||
import 'relay_routes.dart' show relayBudgets, writeRelayRange;
|
||||
|
||||
// The single-fetch source: the member who passes a link on reads it from
|
||||
// the site once. Per track, one [RelayBuffer] filled by one sequential
|
||||
// upstream reader ([LocalRelayFeed] over [ResumingBytes]) serves two
|
||||
// readers: this app's own player, through a loopback HTTP endpoint
|
||||
// ([SharedSource.playerUrl]), and the server's pulls
|
||||
// ([SharedSource.upload], handed to the relay's [RelaySource]). The two
|
||||
// are kept within one window ([RelayBuffer.together]). Byte tracks only:
|
||||
// a page with pieces tracks (HLS/DASH) keeps the two fetches.
|
||||
|
||||
final _log = createLogger('relay');
|
||||
|
||||
/// Upstream errors worth another try after a pause: a cut connection, a
|
||||
/// timeout, a 5xx, and the refusals a CDN gives a client it thinks is too
|
||||
/// busy (412, 429). 403/410 (an expired URL) is the page's job
|
||||
/// ([PageRelayMedia] resolves again once); anything else is final.
|
||||
bool retryableUpstream(Object e) {
|
||||
if (e is UpstreamHttpException) return e.status == 412 || e.status == 429 || e.status >= 500;
|
||||
return e is HttpException || e is SocketException || e is TimeoutException;
|
||||
}
|
||||
|
||||
/// [inner] read with resume: a read that breaks goes on from the byte it
|
||||
/// reached. After a cut that brought bytes it goes on after 100 ms; after
|
||||
/// an answer without a byte (a refusal, a dead connection) after
|
||||
/// [backoff] (attempt n, reset by any byte). A read that brings nothing
|
||||
/// for [stall] is cut and resumed the same way. It fails only on a final
|
||||
/// error or after [giveUp] without a byte. Cancelling stops it at once,
|
||||
/// also during a pause.
|
||||
class ResumingBytes implements RelayBytes {
|
||||
final RelayBytes inner;
|
||||
final Duration Function(int attempt) backoff;
|
||||
final Duration giveUp;
|
||||
final Duration stall;
|
||||
ResumingBytes(this.inner,
|
||||
{Duration Function(int attempt)? backoff,
|
||||
this.giveUp = const Duration(seconds: 60),
|
||||
this.stall = const Duration(seconds: 20)})
|
||||
: backoff = backoff ?? defaultBackoff;
|
||||
|
||||
static final _rnd = Random();
|
||||
|
||||
/// 0.5 s doubling to 8 s, with up to 25% jitter.
|
||||
static Duration defaultBackoff(int attempt) {
|
||||
final ms = min(8000, 500 * (1 << min(attempt, 4)));
|
||||
return Duration(milliseconds: ms + _rnd.nextInt(ms ~/ 4 + 1));
|
||||
}
|
||||
|
||||
/// Resumes so far, for logs and tests.
|
||||
int retries = 0;
|
||||
|
||||
/// Bytes read from [inner], for logs and tests.
|
||||
int bytesIn = 0;
|
||||
|
||||
@override
|
||||
Future<int> size() async {
|
||||
var attempt = 0;
|
||||
final since = DateTime.now();
|
||||
while (true) {
|
||||
try {
|
||||
return await inner.size();
|
||||
} catch (e) {
|
||||
if (!retryableUpstream(e) || DateTime.now().difference(since) > giveUp) rethrow;
|
||||
retries++;
|
||||
await Future<void>.delayed(backoff(attempt++));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> read(int start, int end) {
|
||||
late final StreamController<List<int>> out;
|
||||
var cancelled = false;
|
||||
StreamSubscription<List<int>>? sub;
|
||||
Completer<void>? pause;
|
||||
Future<void> run() async {
|
||||
var pos = start, attempt = 0;
|
||||
var lastByte = DateTime.now();
|
||||
while (!cancelled && pos <= end) {
|
||||
var got = 0;
|
||||
final done = Completer<Object?>();
|
||||
Timer? quiet;
|
||||
void watch() {
|
||||
quiet?.cancel();
|
||||
quiet = Timer(stall, () {
|
||||
if (done.isCompleted) return;
|
||||
unawaited(sub?.cancel().catchError((_) {}));
|
||||
done.complete(TimeoutException('upstream sent nothing', stall));
|
||||
});
|
||||
}
|
||||
|
||||
watch();
|
||||
sub = inner.read(pos, end).listen((c) {
|
||||
if (done.isCompleted) return;
|
||||
pos += c.length;
|
||||
got += c.length;
|
||||
bytesIn += c.length;
|
||||
lastByte = DateTime.now();
|
||||
watch();
|
||||
out.add(c);
|
||||
}, onError: (Object e) {
|
||||
if (!done.isCompleted) done.complete(e);
|
||||
}, onDone: () {
|
||||
if (!done.isCompleted) done.complete(null);
|
||||
}, cancelOnError: true);
|
||||
final err = await done.future;
|
||||
quiet?.cancel();
|
||||
sub = null;
|
||||
if (cancelled) return;
|
||||
if (err == null) {
|
||||
if (pos <= end) continue; // ended short without an error: go on
|
||||
break;
|
||||
}
|
||||
if (!retryableUpstream(err) || DateTime.now().difference(lastByte) > giveUp) {
|
||||
out.addError(err);
|
||||
break;
|
||||
}
|
||||
// A cut after some bytes goes on almost at once (what mpv's
|
||||
// reconnect does); an answer without a byte backs off, doubling.
|
||||
if (got > 0) attempt = 0;
|
||||
final wait = got > 0 ? const Duration(milliseconds: 100) : backoff(attempt++);
|
||||
retries++;
|
||||
_log.fine('upstream read broke at $pos ($err); again in ${wait.inMilliseconds} ms');
|
||||
final p = pause = Completer<void>();
|
||||
final t = Timer(wait, () {
|
||||
if (!p.isCompleted) p.complete();
|
||||
});
|
||||
await p.future;
|
||||
t.cancel();
|
||||
pause = null;
|
||||
}
|
||||
if (!cancelled) await out.close();
|
||||
}
|
||||
|
||||
out = StreamController<List<int>>(
|
||||
onListen: () => unawaited(run()),
|
||||
onPause: () => sub?.pause(),
|
||||
onResume: () => sub?.resume(),
|
||||
onCancel: () async {
|
||||
cancelled = true;
|
||||
final p = pause;
|
||||
if (p != null && !p.isCompleted) p.complete();
|
||||
await sub?.cancel().catchError((_) {});
|
||||
},
|
||||
);
|
||||
return out.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() => inner.close();
|
||||
}
|
||||
|
||||
/// A media a [SharedSource] cannot share (a pieces track): the caller
|
||||
/// keeps the player and the relay on their own fetches.
|
||||
class SharedSourceUnsupported implements Exception {
|
||||
final String reason;
|
||||
const SharedSourceUnsupported(this.reason);
|
||||
@override
|
||||
String toString() => reason;
|
||||
}
|
||||
|
||||
/// One track of a [SharedSource]: its buffer and its single upstream
|
||||
/// reader.
|
||||
class SharedTrack {
|
||||
final String kind;
|
||||
final int size;
|
||||
final RelayBuffer buffer;
|
||||
final LocalRelayFeed feed;
|
||||
final ResumingBytes upstream;
|
||||
SharedTrack(this.kind, this.size, this.buffer, this.feed, this.upstream);
|
||||
}
|
||||
|
||||
/// A media read from upstream once and served to this app's player and to
|
||||
/// the relay. [open] opens it (a page's tracks, the loopback endpoint) and
|
||||
/// starts reading the tracks' sizes; [ready] completes with them. The
|
||||
/// player can open [playerUrl] right after [open]: the endpoint waits for
|
||||
/// the sizes. Nothing is read upstream while neither reader asks.
|
||||
class SharedSource {
|
||||
final RelayMedia media;
|
||||
|
||||
/// The local buffer's budgets (all tracks together, split by size like
|
||||
/// the server's, [relayBudgets]). It only has to hold what the player
|
||||
/// and the upload have not both read yet: they read within [ahead] of
|
||||
/// each other ([RelayBuffer.together]).
|
||||
final int ahead, back;
|
||||
final Duration Function(int attempt)? backoff;
|
||||
|
||||
/// A read waiting for missing bytes gives up after this.
|
||||
final Duration waitLimit;
|
||||
SharedSource(this.media,
|
||||
{this.ahead = RelayConstants.sharedAheadBytes,
|
||||
this.back = RelayConstants.sharedBackBytes,
|
||||
this.backoff,
|
||||
this.waitLimit = const Duration(seconds: RelayConstants.relayWaitSecs)});
|
||||
|
||||
/// The tracks, once [ready].
|
||||
final tracks = <SharedTrack>[];
|
||||
List<RelayTrack> _upstream = const [];
|
||||
HttpServer? _http;
|
||||
final _secret = List.generate(16, (_) => Random.secure().nextInt(16).toRadixString(16)).join();
|
||||
Future<void>? _opening;
|
||||
final _ready = Completer<void>();
|
||||
var _closed = false;
|
||||
|
||||
/// Opens the media and binds the loopback endpoint; the sizes are read
|
||||
/// meanwhile ([ready]). Throws [SharedSourceUnsupported] for a media
|
||||
/// with pieces tracks, or why the media cannot be opened.
|
||||
Future<void> open() => _opening ??= _open();
|
||||
|
||||
Future<void> _open() async {
|
||||
final upstream = await media.open();
|
||||
if (upstream.any((t) => t.bytes == null)) {
|
||||
throw const SharedSourceUnsupported('a segmented stream (HLS/DASH) is not shared');
|
||||
}
|
||||
_upstream = upstream;
|
||||
final http = _http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
http.listen((req) => unawaited(_serve(req)), onError: (_) {});
|
||||
// Nobody may be waiting on [ready] yet: its error is not unhandled.
|
||||
_ready.future.ignore();
|
||||
unawaited(_readSizes());
|
||||
}
|
||||
|
||||
Future<void> _readSizes() async {
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
final wrapped = [for (final t in _upstream) ResumingBytes(t.bytes!, backoff: backoff)];
|
||||
final sizes = await Future.wait([for (final w in wrapped) w.size()]);
|
||||
if (_closed) throw const RelayEnded();
|
||||
final budgets = relayBudgets(sizes, ahead: ahead, back: back);
|
||||
var ids = 0;
|
||||
for (var i = 0; i < _upstream.length; i++) {
|
||||
final b = RelayBuffer(
|
||||
ahead: budgets[i].$1,
|
||||
back: budgets[i].$2,
|
||||
waitLimit: waitLimit,
|
||||
together: (playerViewer, uploadViewer),
|
||||
nextId: () => ++ids);
|
||||
final f = LocalRelayFeed(RelayTrack(_upstream[i].kind, wrapped[i]), b);
|
||||
b.attach(f, sizes[i]);
|
||||
tracks.add(SharedTrack(_upstream[i].kind, sizes[i], b, f, wrapped[i]));
|
||||
}
|
||||
_log.info('shared source: ${tracks.map((t) => '${t.kind.isEmpty ? 'file' : t.kind} ${t.size} bytes').join(' + ')} '
|
||||
'in ${sw.elapsedMilliseconds} ms, the player on loopback port ${_http?.port}');
|
||||
_ready.complete();
|
||||
} catch (e, st) {
|
||||
_ready.completeError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes once every track's size is known; throws why it could not
|
||||
/// be read.
|
||||
Future<void> get ready => _ready.future;
|
||||
|
||||
/// [open], then [ready].
|
||||
Future<void> start() async {
|
||||
await open();
|
||||
await ready;
|
||||
}
|
||||
|
||||
/// The two readers' names in each track's [RelayBuffer].
|
||||
static const playerViewer = 'player', uploadViewer = 'upload';
|
||||
|
||||
/// What this app's own player opens (after [open]): one loopback URL, or
|
||||
/// an mpv EDL of one per track. No headers: the site's stay in this
|
||||
/// process.
|
||||
String get playerUrl {
|
||||
final http = _http;
|
||||
if (http == null) throw StateError('shared source not open');
|
||||
final urls = [for (var i = 0; i < _upstream.length; i++) 'http://127.0.0.1:${http.port}/$_secret/$i'];
|
||||
return urls.length == 1 ? urls.single : mpvEdl(urls);
|
||||
}
|
||||
|
||||
/// What [RelaySource] serves: the same buffers, read as the viewer
|
||||
/// `upload`. Closing it leaves the shared source open (the player may
|
||||
/// still read it).
|
||||
RelayMedia get upload => _Upload(this);
|
||||
|
||||
/// Upstream reads resumed so far, all tracks (for logs and tests).
|
||||
int get resumes => tracks.fold(0, (a, t) => a + t.upstream.retries);
|
||||
|
||||
/// Bytes read upstream so far, all tracks.
|
||||
int get bytesIn => tracks.fold(0, (a, t) => a + t.upstream.bytesIn);
|
||||
|
||||
/// Bytes the local buffers hold now.
|
||||
int get cachedBytes => tracks.fold(0, (a, t) => a + t.buffer.cachedBytes);
|
||||
|
||||
Future<void> _serve(HttpRequest req) async {
|
||||
final res = req.response;
|
||||
Future<void> plain(int status, [Map<String, String> headers = const {}]) async {
|
||||
res.statusCode = status;
|
||||
headers.forEach(res.headers.set);
|
||||
res.headers.set(HttpHeaders.connectionHeader, 'close');
|
||||
await res.close().catchError((_) {});
|
||||
}
|
||||
|
||||
final seg = req.uri.pathSegments;
|
||||
final i = seg.length == 2 && seg[0] == _secret ? int.tryParse(seg[1]) : null;
|
||||
if (i == null || i < 0 || i >= _upstream.length || (req.method != 'GET' && req.method != 'HEAD')) {
|
||||
return plain(404);
|
||||
}
|
||||
try {
|
||||
await ready.timeout(waitLimit);
|
||||
} catch (_) {
|
||||
return plain(_closed ? 404 : 502);
|
||||
}
|
||||
if (_closed) return plain(404);
|
||||
final t = tracks[i];
|
||||
final r = resolveMediaRequest(
|
||||
rangeHeader: req.headers.value(HttpHeaders.rangeHeader), size: t.size, filename: 'track');
|
||||
if (r.status != 200 && r.status != 206) return plain(r.status, r.headers);
|
||||
final end = r.end ?? t.size - 1;
|
||||
final Socket socket;
|
||||
try {
|
||||
socket = await res.detachSocket(writeHeaders: false);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
// The server's own answer code: headers in time, connection: close,
|
||||
// a read that breaks resets the connection (the player reconnects).
|
||||
await writeRelayRange(socket, socket, r.status, r.headers, end - r.start + 1,
|
||||
req.method == 'HEAD' ? null : t.buffer.read(playerViewer, r.start, end),
|
||||
onBroke: (e) => _log.fine('shared source: the player\'s read of track $i broke: $e'));
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
await _http?.close(force: true);
|
||||
for (final t in tracks) {
|
||||
t.buffer.dispose();
|
||||
await t.feed.close();
|
||||
}
|
||||
if (tracks.isNotEmpty) _log.info('shared source closed: $bytesIn bytes read upstream, $resumes resumes');
|
||||
await media.close();
|
||||
}
|
||||
}
|
||||
|
||||
class _Upload implements RelayMedia {
|
||||
final SharedSource src;
|
||||
_Upload(this.src);
|
||||
|
||||
@override
|
||||
Future<List<RelayTrack>> open() async {
|
||||
await src.open();
|
||||
return [for (var i = 0; i < src._upstream.length; i++) RelayTrack(src._upstream[i].kind, _BufferBytes(src, i))];
|
||||
}
|
||||
|
||||
/// The shared source owns the upstream.
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
|
||||
class _BufferBytes implements RelayBytes {
|
||||
final SharedSource src;
|
||||
final int i;
|
||||
_BufferBytes(this.src, this.i);
|
||||
|
||||
@override
|
||||
Future<int> size() async {
|
||||
await src.ready;
|
||||
return src.tracks[i].size;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> read(int start, int end) async* {
|
||||
await src.ready;
|
||||
if (src._closed) throw const RelayEnded();
|
||||
yield* src.tracks[i].buffer.read(SharedSource.uploadViewer, start, end);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
Reference in new issue
Block a user