fix: reconnect interrupted video streams

Enable bounded FFmpeg reconnection for recorded range-seekable HTTP inputs, preserving compressed cache, byte offset and playback intent. A five-second socket timeout leaves recovery room inside the existing fifteen-second I/O deadline. Keep live/non-range replay and signed-URL refresh disabled.

Native regression failed before the fix with Reading network media: Input/output error. Real combined/split playback now survives dropped responses and socket timeout; persistent failures remain visible and retries are cancellable by seek, close, replacement and destruction.

Release, network, adaptive, media, reporting, runtime, relocated bundle and local playback checks passed. Packaged CLI/GUI playback started successfully; the full-suite keyboard smoke stopped because macOS refused foreground focus for its transparent check window.
This commit is contained in:
2026-10-12 02:06:06 +02:00
parent 31c4759c1d
commit e54cb2b549
4 changed files with 140 additions and 7 deletions
+117
View File
@@ -336,6 +336,120 @@ static void prefixBound(PlayerState state, GateState gate, bool splitAudio = fal
"retained coverage cannot bridge withheld audio/video packets or reordered B-frames");
}
static void reconnection(Player *player, const std::string &base) {
for (int input : {0, 1, 2}) {
const std::string token = "disconnect-" + std::to_string(input);
const auto interrupted = "/disconnect/" + token + "/cache.mp4?after_seconds=8";
require(openNetwork(player, base, input == 1 ? "/media/cache.mp4" : interrupted,
input == 0 ? "" : input == 1 ? interrupted : "/media/cache.mp4", 120),
"open media whose HTTP response will disconnect after a playable prefix");
require(player_play(player), "play the source before its connection drops");
auto before = until(player, [&](auto s) {
return s.status == PLAYER_PLAYING && s.position > .1 && buffered(s) &&
gateState(base, token).entered;
}, "playback and retained packets exist before the disconnect");
if (input == 0) {
require(player_pause(player), "pause while the original HTTP response is blocked");
before = until(player, [](auto s) { return s.status == PLAYER_PAUSED; },
"pause acknowledged before reconnect");
}
control(base, "/control/release/" + token);
auto recovered = until(player, [&](auto s) {
require(s.position + .001 >= before.position, "reconnection never resets the playhead");
if (input == 0)
require(s.status == PLAYER_PAUSED && std::abs(s.position - before.position) < .001,
"reconnection does not resume paused playback");
require(s.has_audio && s.has_video && s.can_seek && !s.seek_pending,
"reconnection preserves ready media and its seekability");
return buffered(s) && s.buffered_end - s.position >= 59;
}, "same-source reconnect replenishes the minute reserve");
require(recovered.buffered_start <= before.position,
"reconnection retains the previously buffered timeline");
seekCompleted(player, 6, input == 0 ? PLAYER_PAUSED : PLAYER_PLAYING);
seekCompleted(player, 30, input == 0 ? PLAYER_PAUSED : PLAYER_PLAYING);
require(player_play(player), "play across packets fetched after the disconnect");
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 30.1; },
"decoded audio/video continues beyond the original HTTP prefix");
require(player_close(player), "close reconnected media");
closed(player);
}
std::puts("PASS disconnected combined/secondary-audio/primary-video inputs: retained buffer/paused+playing intent/range resume/decoded continuation");
}
static void socketTimeout(Player *player, const std::string &base) {
const std::string token = "socket-timeout";
require(openNetwork(player, base, "/stall/" + token + "/cache.mp4?after_seconds=8", "", 120),
"open a source whose original socket never supplies the remaining body");
require(player_play(player), "play the prefix before socket timeout");
until(player, [&](auto s) {
return s.status == PLAYER_PLAYING && s.position > .1 && gateState(base, token).entered;
}, "playable prefix precedes timed-out fetch");
require(player_pause(player), "pause before socket timeout");
const auto paused = until(player, [](auto s) { return s.status == PLAYER_PAUSED; },
"native pause before timeout recovery");
until(player, [&](auto s) {
require(s.status == PLAYER_PAUSED && std::abs(s.position - paused.position) < .001,
"socket timeout recovery preserves paused intent and position");
return buffered(s) && s.buffered_end - s.position >= 59;
}, "socket timeout reconnects within the total I/O deadline");
stillBlocked(base, token);
require(player_play(player), "resume after socket timeout recovery");
seekCompleted(player, 30, PLAYER_PLAYING);
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 30.1; },
"timeout-recovered media decodes beyond the withheld prefix");
require(player_close(player), "close timeout-recovered source");
closed(player);
std::puts("PASS socket timeout: reconnect before operation deadline/retained buffer/paused intent/decoded continuation");
}
static void failedReconnect(Player *player, const std::string &base, const std::string &token) {
require(openNetwork(player, base, "/disconnect/" + token +
"/cache.mp4?after_seconds=8&fail_reconnect=1", "", 120),
"open a source with a playable prefix and persistently broken resumed responses");
require(player_play(player), "play before persistent disconnect");
until(player, [&](auto s) {
return s.status == PLAYER_PLAYING && s.position > .1 && gateState(base, token).entered;
}, "playback starts before persistent disconnect");
control(base, "/control/release/" + token);
until(player, [&](auto s) {
return s.status == PLAYER_PLAYING && s.has_audio && s.has_video &&
gateState(base, token).requests > 1;
}, "transport is retrying while the retained prefix remains playable");
}
static void retryFailures(Player *player, const std::string &base, const std::string &fixtures) {
failedReconnect(player, base, "retry-exhaustion");
const auto failed = until(player, [](auto s) { return s.status == PLAYER_ERROR; },
"persistent read failure exhausts its bounded retries", true);
require(failed.error[0] && !failed.has_video && !failed.has_audio && !failed.buffering &&
!failed.can_seek && !failed.seek_pending,
"exhausted retries surface an error instead of falsely completing media or hanging");
for (int action : {0, 1, 2}) {
failedReconnect(player, base, "retry-cancellation-" + std::to_string(action));
if (action == 0) {
seekCompleted(player, 30, PLAYER_PLAYING);
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 30.1; },
"uncached seek escapes the failed reconnect offset");
} else if (action == 2) {
require(player_open(player, (fixtures + "/audio.wav").c_str()),
"local replacement interrupts the retry");
const auto replacement = until(player, [](auto s) {
return s.status == PLAYER_PAUSED && s.has_audio && !s.has_video;
}, "replacement is not blocked by retry backoff");
require(replacement.position == 0 && !replacement.seek_pending && !replacement.buffering,
"stale retry does not overwrite replacement state");
}
require(player_close(player), "close interrupts retries and recovered media");
closed(player);
}
std::unique_ptr<Player, decltype(&player_destroy)> victim(player_create(), player_destroy);
require(bool(victim), "create a player for destruction during retry");
require(player_set_volume(victim.get(), 0), "mute destruction scenario");
failedReconnect(victim.get(), base, "retry-destruction");
victim.reset(); // The process watchdog catches a join stuck in retry backoff.
std::puts("PASS exhausted reconnects: visible failure/uncached seek/close/replacement/destruction cancellation");
}
static void cachedWindow(Player *player, const std::string &base) {
const std::string token = "cached-window";
require(openNetwork(player, base, "/stall/" + token + "/cache.mp4?after_seconds=100", "", 120),
@@ -696,6 +810,9 @@ int main(int argc, char **argv) {
styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
window.contentView = view;
[window orderBack:nil];
reconnection(player.get(), base);
socketTimeout(player.get(), base);
retryFailures(player.get(), base, fixtures);
chunkedFetch(player.get(), base, false);
chunkedFetch(player.get(), base, true);
transport(player.get(), view, base, false);