Relay: viewers keep the file size (headers within 4 s, connection: close), retries back off, 412 re-resolves

Two server behaviours left ffmpeg (8.1/9.0) without the file size, after
which every seek failed with "size -38" until a reload:
- dart:io holds response headers until body bytes exist: a stalled
  source meant no headers within mpv's 5 s timeout. Relay byte responses
  are now written on the raw connection, headers with the first bytes or
  after 4 s, and a swap ends waiting reads with a plain close.
- every response closed its socket without saying connection: close, so
  ffmpeg 8.x reused a dead connection for its next seek (pol's X clips).
  The server now says it whenever the request asks.
A failed pull waits 250 ms doubling to 8 s instead of re-pulling at once
(the ~130 ms storm that drew Bilibili's 412/403); 412 counts as an expired
URL and re-resolves the page, at most once per 60 s. The app reloads a
player once per media when ffmpeg reports the lost size anyway. P37: a
read that waits past ffmpeg's retry budget still ends the file early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-18 01:27:34 +03:00
1 parent 5e3834e6ba
commit e91539d113
17 files changed
+575 -63

No files matched your search

+157
View File
@@ -393,6 +393,64 @@ void main() {
expect(carol.welcome.mediaUrl, mpvEdl(['$carolBase/0/$name.m3u8', '$carolBase/1/$name']));
});
test('a slow source: the headers still come in time, the bytes once they arrive', () async {
final alice = await join('alice');
final bob = await join('bob');
await swapRelay(alice);
final gated = _GatedBytes(FileRelayBytes(file));
await startSource(alice, from: RelayMedia.single(gated));
await _until(() => server.hub.relay!.live, 'live');
final url = Uri.parse(viewerUrl(bob, mediaIdOf(alice)));
final start = 2 * RelayBuffer.slice + 10;
final sw = Stopwatch()..start();
final res = await _RawResponse.get(url, range: 'bytes=$start-');
// No bytes: the headers go out on their own before mpv's 5 s timeout.
final head = await res.head.timeout(const Duration(seconds: 6));
expect(sw.elapsedMilliseconds, lessThan(5000));
expect(head, startsWith('HTTP/1.1 206'));
expect(head, contains('content-range: bytes $start-${bytes.length - 1}/${bytes.length}'));
expect(head, contains('content-length: ${bytes.length - start}'));
expect(head, contains('connection: close'));
expect(res.body, isEmpty);
gated.open();
expect(await res.done.timeout(const Duration(seconds: 10)), isTrue, reason: 'closed plainly');
expect(res.body, bytes.sublist(start));
});
test('a range read to its end says connection: close (ffmpeg 8 reuses it otherwise)', () async {
final alice = await join('alice');
final bob = await join('bob');
await swapRelay(alice);
await startSource(alice);
final url = Uri.parse(viewerUrl(bob, mediaIdOf(alice)));
// The 58-byte tail box of an mp4 whose moov comes first, as ffmpeg asks.
final res = await _RawResponse.get(url, range: 'bytes=${bytes.length - 58}-');
expect(await res.head, contains('connection: close'));
expect(await res.done.timeout(const Duration(seconds: 5)), isTrue);
expect(res.body, bytes.sublist(bytes.length - 58));
// Any other answer to a request that asks for close says so too.
final health = await _RawResponse.get(Uri.parse('http://127.0.0.1:${server.boundPort}/health'));
expect(await health.head, contains('connection: close'));
await health.done;
});
test('a swap ends a viewer\'s waiting read plainly', () async {
final alice = await join('alice');
final bob = await join('bob');
await swapRelay(alice);
await startSource(alice, from: RelayMedia.single(_GatedBytes(FileRelayBytes(file))));
await _until(() => server.hub.relay!.live, 'live');
final res = await _RawResponse.get(Uri.parse(viewerUrl(bob, mediaIdOf(alice))), range: 'bytes=1000-');
await res.head.timeout(const Duration(seconds: 6));
alice.send({
't': 'swap',
'media': {'name': 'x', 'size': 0, 'duration': 0, 'url': 'http://example.invalid/x.mkv'},
});
// Closed, not reset.
expect(await res.done.timeout(const Duration(seconds: 5)), isTrue);
expect(res.body, isEmpty);
});
test('Jellyfin swap can carry a relay', () async {
final alice = await join('alice');
final before = alice.raw.length;
@@ -704,6 +762,28 @@ void main() {
await old.cancel();
});
test('a source that keeps failing is asked again with backoff, at once again after bytes', () async {
final failing = _FailingBytes(FileRelayBytes(file));
final b = RelayBuffer(ahead: 2 * RelayBuffer.slice, back: RelayBuffer.slice, pin: RelayBuffer.slice);
final f = LocalRelayFeed(RelayTrack('', failing), b);
b.attach(f, bytes.length);
addTearDown(() async {
b.dispose();
await f.close();
});
final reader = b.read('a', 0, 99).toList();
await Future<void>.delayed(const Duration(milliseconds: 2500));
// 0, 0.25, 0.75, 1.75 s: not one pull per round trip.
expect(failing.reads, inInclusiveRange(3, 5));
failing.failing = false;
final got = await reader.timeout(const Duration(seconds: 10));
expect([for (final c in got) ...c], bytes.sublist(0, 100));
expect(relayRetryDelay(0), Duration.zero);
expect(relayRetryDelay(1), const Duration(milliseconds: 250));
expect(relayRetryDelay(3), const Duration(seconds: 1));
expect(relayRetryDelay(50), const Duration(milliseconds: RelayConstants.relayRetryMaxMs));
});
test('dispose frees everything and ends waiting readers', () async {
await read('a', 0, 10);
buf.dispose();
@@ -762,3 +842,80 @@ class _FakeYtDlp extends YtDlp {
Future<YtDlpInfo> resolve(String url, {Duration timeout = const Duration(seconds: 25)}) async =>
YtDlpInfo(title: 'page', tracks: tracks, formats: {for (final t in tracks) t.format.id: t.format});
}
/// Bytes that come only once [open] is called (a source whose upstream
/// stalls).
class _GatedBytes implements RelayBytes {
final RelayBytes inner;
final _gate = Completer<void>();
_GatedBytes(this.inner);
void open() => _gate.complete();
@override
Future<int> size() => inner.size();
@override
Stream<List<int>> read(int start, int end) async* {
await _gate.future;
yield* inner.read(start, end);
}
@override
Future<void> close() => inner.close();
}
/// Reads that fail at once while [failing] (an upstream dropping every
/// connection); [reads] counts them.
class _FailingBytes implements RelayBytes {
final RelayBytes inner;
var failing = true;
var reads = 0;
_FailingBytes(this.inner);
@override
Future<int> size() => inner.size();
@override
Stream<List<int>> read(int start, int end) {
reads++;
if (failing) return Stream.error(const HttpException('Connection closed while receiving data'));
return inner.read(start, end);
}
@override
Future<void> close() => inner.close();
}
/// One HTTP/1.1 GET on its own socket, as ffmpeg sends it
/// (`Connection: close`): the header block as soon as it comes, the body
/// as it comes, and how the connection ended ([done]: true when the
/// server closed it plainly, false on a reset).
class _RawResponse {
final _head = Completer<String>();
Future<String> get head => _head.future;
final body = <int>[];
final _done = Completer<bool>();
Future<bool> get done => _done.future;
static Future<_RawResponse> get(Uri url, {String? range}) async {
final r = _RawResponse();
final s = await Socket.connect(url.host, url.port);
final buf = <int>[];
s.listen((d) {
if (r._head.isCompleted) {
r.body.addAll(d);
return;
}
buf.addAll(d);
final text = latin1.decode(buf);
final at = text.indexOf('\r\n\r\n');
if (at < 0) return;
r._head.complete(text.substring(0, at).toLowerCase().replaceFirst('http/1.1', 'HTTP/1.1'));
r.body.addAll(buf.sublist(at + 4));
}, onError: (_) {
if (!r._done.isCompleted) r._done.complete(false);
}, onDone: () {
if (!r._done.isCompleted) r._done.complete(true);
s.destroy();
});
s.write('GET ${url.path} HTTP/1.1\r\nHost: ${url.host}\r\n${range == null ? '' : 'Range: $range\r\n'}'
'Connection: close\r\n\r\n');
return r;
}
}