feat: visualize retained network playback buffer

This commit is contained in:
2026-10-11 02:54:01 +02:00
parent 4d4c3e6b0a
commit 5087a0a0f0
8 changed files with 326 additions and 39 deletions
+146 -23
View File
@@ -14,6 +14,29 @@ static void require(bool condition, const char *message) {
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
}
static bool buffered(PlayerState state) {
return state.buffered_end > state.buffered_start;
}
static void noBuffer(PlayerState state, const char *message) {
require(state.buffered_start == 0 && state.buffered_end == 0, message);
}
static void validBuffer(PlayerState state) {
require(std::isfinite(state.buffered_start) && std::isfinite(state.buffered_end),
"consumer receives finite buffered timestamps");
if (state.status == PLAYER_EMPTY || state.status == PLAYER_OPENING ||
state.status == PLAYER_ERROR || state.status == PLAYER_ENDED || state.seek_pending) {
noBuffer(state, "unavailable/opening/error/ended/pending timeline has no retained range");
}
if (state.buffered_start == 0 && state.buffered_end == 0) return;
require(std::isfinite(state.duration) && state.duration > 0 &&
state.buffered_start >= 0 && buffered(state) && state.buffered_end <= state.duration,
"retained range is nonempty and bounded by finite media duration");
require(std::abs(state.buffered_start - state.position) < .00001,
"retained range follows the authoritative playback position, not a loaded prefix");
}
// Only these deadlines use elapsed time: a stuck command/read must fail the check,
// not hang CI. Playback assertions use fixture presentation timestamps and events.
static PlayerState until(Player *player, const std::function<bool(PlayerState)> &ready,
@@ -21,6 +44,7 @@ static PlayerState until(Player *player, const std::function<bool(PlayerState)>
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
while (std::chrono::steady_clock::now() < deadline) {
auto state = player_get_state(player);
validBuffer(state);
if (state.status == PLAYER_ERROR && !expectError) {
std::fprintf(stderr, "FAIL %s: %s\n", scenario, state.error);
std::exit(1);
@@ -29,8 +53,9 @@ static PlayerState until(Player *player, const std::function<bool(PlayerState)>
CFRunLoopRunInMode(kCFRunLoopDefaultMode, .005, false);
}
auto state = player_get_state(player);
std::fprintf(stderr, "FAIL %s: command deadline (status=%d position=%.4f duration=%.4f pending=%d buffering=%d)\n",
scenario, state.status, state.position, state.duration, state.seek_pending, state.buffering);
std::fprintf(stderr, "FAIL %s: command deadline (status=%d position=%.4f duration=%.4f pending=%d buffering=%d retained=%.4f..%.4f)\n",
scenario, state.status, state.position, state.duration, state.seek_pending, state.buffering,
state.buffered_start, state.buffered_end);
std::exit(1);
}
@@ -64,6 +89,7 @@ static NSDictionary *control(const std::string &base, const std::string &path) {
struct GateState {
bool entered, released;
uint64_t waiting, requests, ranges, bytes, total;
double audioEnd, videoEnd;
};
static GateState gateState(const std::string &base, const std::string &token) {
@@ -71,7 +97,8 @@ static GateState gateState(const std::string &base, const std::string &token) {
return {[state[@"entered"] boolValue], [state[@"released"] boolValue],
[state[@"waiting"] unsignedLongLongValue], [state[@"requests"] unsignedLongLongValue],
[state[@"ranges"] unsignedLongLongValue], [state[@"bytes"] unsignedLongLongValue],
[state[@"total"] unsignedLongLongValue]};
[state[@"total"] unsignedLongLongValue],
[state[@"prefix_ends"][@"audio"] doubleValue], [state[@"prefix_ends"][@"video"] doubleValue]};
}
static void stillBlocked(const std::string &base, const std::string &token) {
@@ -106,6 +133,7 @@ static void closed(Player *player) {
require(state.position == 0 && state.duration == 0 && !state.has_audio && !state.has_video &&
!state.can_seek && !state.buffering && !state.seek_pending && !state.error[0],
"close clears all network/media/seek/error state");
noBuffer(state, "close clears retained network coverage");
require(std::abs(player_get_volume(player) - .37f) < .00001f, "volume survives network close");
}
@@ -113,6 +141,7 @@ static PlayerState seekCompleted(Player *player, double target, PlayerStatus sta
auto revision = player_get_state(player).revision;
require(player_seek(player, target), "network seek accepted");
auto pending = player_get_state(player);
validBuffer(pending);
require(pending.seek_pending || std::abs(pending.position - target) < .03,
"network seek remains pending until target is authoritative");
return until(player, [=](auto s) {
@@ -144,6 +173,7 @@ static void transport(Player *player, MTKView *view, const std::string &base, bo
[view draw];
require(player_seek(player, .8) && player_seek(player, 1.7), "queued seeks accepted on both inputs");
auto pending = player_get_state(player);
validBuffer(pending);
require(pending.seek_pending || std::abs(pending.position - 1.7) < .03,
"earlier seek cannot acknowledge final split-input seek");
until(player, [](auto s) {
@@ -166,6 +196,7 @@ static void transport(Player *player, MTKView *view, const std::string &base, bo
static PlayerState step(Player *player, int direction, double pts) {
auto revision = player_get_state(player).revision;
require(player_step_frame(player, direction), "split-source adjacent frame step accepted");
validBuffer(player_get_state(player));
auto state = until(player, [revision](auto s) {
return !s.seek_pending && s.revision != revision && s.status == PLAYER_PAUSED;
}, "split frame-step acknowledgement");
@@ -201,9 +232,10 @@ static void frameSteps(Player *player, MTKView *view, const std::string &base) {
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause split VFR after resume");
seekCompleted(player, 1.5, PLAYER_PAUSED);
require(player_play(player), "play past independent video EOF");
auto beyondVideo = until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.2; },
"audio continues after independent video EOF");
require(beyondVideo.position < 3.9, "video EOF does not end longer audio source");
auto beyondVideo = until(player, [](auto s) {
return s.status == PLAYER_PLAYING && s.position > 2.2 && buffered(s);
}, "audio retains playable coverage after independent video EOF");
require(beyondVideo.position < 3.9, "video EOF does not end or cap longer audio coverage");
auto ended = until(player, [](auto s) { return s.status == PLAYER_ENDED; }, "longer audio input drains");
require(ended.position >= 3.9 && ended.position < 4.2, "split EOF follows last audio sample");
require(player_close(player), "close split stepped source");
@@ -215,8 +247,9 @@ static void frameSteps(Player *player, MTKView *view, const std::string &base) {
require(player_play(player), "queued play with shorter audio");
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; }, "asymmetric split autoplay");
seekCompleted(player, 1.5, PLAYER_PLAYING);
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.3; },
"video continues after independent audio EOF");
until(player, [](auto s) {
return s.status == PLAYER_PLAYING && s.position > 2.3 && buffered(s);
}, "video retains playable coverage after independent audio EOF");
require(player_pause(player), "pause in independent audio tail");
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause longer video after audio EOF");
seekCompleted(player, 3.2, PLAYER_PAUSED);
@@ -250,15 +283,21 @@ static void nativeHLS(Player *player, MTKView *view, const std::string &base) {
static void liveAndReplacement(Player *player, const std::string &base, const std::string &fixtures) {
require(openNetwork(player, base, "/media/av.mp4", "", 4, true), "live-flag network open");
auto live = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "live-flag open");
auto live = until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
"live-flag source retains media before local replacement");
require(!live.can_seek && !player_seek(player, 1) && !player_step_frame(player, 1) &&
!player_step_frame(player, -1), "live source rejects seeks and both frame-step directions");
require(player_open(player, (fixtures + "/video.mp4").c_str()), "ordinary local source replaces network live source");
auto local = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "local replacement");
require(local.can_seek && !local.buffering && local.has_video && !local.has_audio && !local.seek_pending,
"local replacement restores seekability and clears network flags");
noBuffer(local, "local replacement does not inherit network buffered coverage");
require(std::abs(player_get_volume(player) - .37f) < .00001f, "local replacement keeps volume");
seekCompleted(player, 1.5, PLAYER_PAUSED);
noBuffer(seekCompleted(player, 1.5, PLAYER_PAUSED), "ordinary local seek never reports network coverage");
require(player_play(player), "play ordinary local replacement");
local = until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.6; },
"local playback after replacing a buffered network source");
noBuffer(local, "ordinary local playback never reports network coverage");
require(player_close(player), "close local replacement");
closed(player);
require(openNetwork(player, base, "/no-range/streaming.mp4", "", 30), "non-range HTTP open");
@@ -280,16 +319,78 @@ static void blockedOpening(Player *player, const std::string &base, const std::s
}, "opening response-body read underway");
}
static double prefixEnd(GateState gate, bool splitAudio, bool splitVideo) {
return splitAudio ? gate.audioEnd : splitVideo ? gate.videoEnd : std::fmin(gate.audioEnd, gate.videoEnd);
}
static void prefixBound(PlayerState state, GateState gate, bool splitAudio = false, bool splitVideo = false) {
const double end = prefixEnd(gate, splitAudio, splitVideo);
require(end > 0 && end < 30, "gated thirty-second fixture withholds known presentation timestamps");
require(!buffered(state) || state.buffered_end <= end + .002,
"retained coverage cannot bridge withheld audio/video packets or reordered B-frames");
}
static void bufferCoverage(Player *player, const std::string &base) {
require(openNetwork(player, base, "/media/streaming.mp4", "", 30),
"open long network fixture for bounded retained coverage");
auto initial = until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
"paused network source exposes actual retained media");
require(initial.buffered_end < initial.duration,
"bounded queues do not report an entire downloaded movie as retained");
require(player_play(player), "play to advance retained network coverage");
until(player, [=](auto s) {
return s.status == PLAYER_PLAYING && s.position > initial.buffered_end && buffered(s) &&
s.buffered_end > initial.buffered_end;
}, "retained horizon advances with real playback and refills");
require(player_close(player), "close advancing buffered source");
closed(player);
const std::string token = "buffer-release";
require(openNetwork(player, base, "/stall/" + token + "/streaming.mp4", "", 30),
"open event-gated retained coverage");
auto paused = until(player, [&](auto s) {
auto gate = gateState(base, token);
if (gate.entered) prefixBound(s, gate);
return s.status == PLAYER_PAUSED && buffered(s) && gate.entered && gate.waiting > 0;
}, "paused retained horizon is bounded by supplied fixture timestamps");
const auto gate = gateState(base, token);
prefixBound(paused, gate);
stillBlocked(base, token);
require(player_play(player), "play gated retained prefix");
until(player, [&](auto s) {
prefixBound(s, gate);
return s.status == PLAYER_PLAYING && s.position > .1 && buffered(s);
}, "gated prefix has real playable-ahead coverage");
until(player, [&](auto s) {
prefixBound(s, gate);
return s.status == PLAYER_PLAYING && s.buffering;
}, "gated coverage cannot advance into unavailable packets");
const double suppliedEnd = prefixEnd(gate, false, false);
stillBlocked(base, token);
control(base, "/control/release/" + token);
until(player, [=](auto s) {
return s.status == PLAYER_PLAYING && !s.buffering && buffered(s) && s.buffered_end > suppliedEnd;
}, "released server gate advances retained coverage beyond withheld timestamps");
require(player_close(player), "close released buffered source");
closed(player);
std::puts("PASS retained coverage: bounded queues/advancing clock/stalled timestamp bounds/B-frame dependencies/gate-release refill");
}
static void blockedPlayback(Player *player, const std::string &base, const std::string &token,
bool splitAudio = false) {
bool splitAudio = false, bool splitVideo = false) {
auto stalled = "/stall/" + token + "/streaming.mp4";
require(openNetwork(player, base, splitAudio ? "/media/streaming.mp4" : stalled,
splitAudio ? stalled : "", 30), "open bounded streaming prefix");
splitAudio ? stalled : splitVideo ? "/media/streaming.mp4" : "", 30),
"open bounded streaming prefix");
require(player_play(player), "queue play before gated source opens");
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; },
"stream starts before entire file arrives");
until(player, [&](auto s) {
auto gate = gateState(base, token);
if (gate.entered) prefixBound(s, gate, splitAudio, splitVideo);
return s.status == PLAYER_PLAYING && s.position > .1 && buffered(s) && gate.entered;
}, "stream retains playable media before entire file arrives");
until(player, [&](auto s) {
auto gate = gateState(base, token);
if (gate.entered) prefixBound(s, gate, splitAudio, splitVideo);
return s.status == PLAYER_PLAYING && s.buffering && gate.entered && gate.waiting > 0 && !gate.released;
}, "real demux read blocks at server gate");
auto gate = gateState(base, token);
@@ -297,28 +398,42 @@ static void blockedPlayback(Player *player, const std::string &base, const std::
}
static void cancellation(Player *player, const std::string &base, const std::string &fixtures) {
for (bool splitAudio : {false, true}) {
const std::string token = splitAudio ? "split-playing-seek" : "pause-seek";
blockedPlayback(player, base, token, splitAudio);
if (!splitAudio) {
for (int input : {0, 1, 2}) {
const bool splitAudio = input == 1, splitVideo = input == 2;
const std::string token = splitAudio ? "split-playing-seek" : splitVideo ? "split-video-seek" : "pause-seek";
blockedPlayback(player, base, token, splitAudio, splitVideo);
if (!splitAudio && !splitVideo) {
require(player_pause(player), "pause while actual network read is blocked");
auto paused = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause does not wait for network read");
stillBlocked(base, token);
require(std::abs(player_get_state(player).position - paused.position) < .0001, "buffered pause freezes native clock");
}
seekCompleted(player, 28, splitAudio ? PLAYER_PLAYING : PLAYER_PAUSED);
seekCompleted(player, 28, (splitAudio || splitVideo) ? PLAYER_PLAYING : PLAYER_PAUSED);
stillBlocked(base, token);
auto afterSeek = gateState(base, token);
require(afterSeek.requests > 1 && afterSeek.ranges > 1, "seek replaces stalled read with an actual new byte-range request");
require(player_play(player), "resume at seek target with stale read still blocked");
until(player, [](auto s) { return s.status == PLAYER_PLAYING && !s.seek_pending && s.position > 28.1; },
"resume after stalled-read seek");
until(player, [](auto s) {
return s.status == PLAYER_PLAYING && !s.seek_pending && s.position > 28.1 && buffered(s);
}, "retained coverage follows new stalled-read seek target");
stillBlocked(base, token);
require(player_pause(player), "pause high-timestamp coverage before backward seek");
until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
"retain high-timestamp media before invalidating it");
seekCompleted(player, .5, PLAYER_PAUSED);
auto rewound = until(player, [](auto s) {
return s.status == PLAYER_PAUSED && !s.seek_pending && buffered(s);
}, "backward seek rebuilds retained coverage at the new target");
require(rewound.buffered_end < 28,
"backward seek drops the old high-water horizon instead of retaining distant media");
stillBlocked(base, token);
require(player_close(player), "close resumed formerly stalled source");
closed(player);
std::puts(splitAudio
? "PASS stalled secondary audio reader: split playing seek/both-input acknowledgement/new range/resume without server release"
: "PASS stalled demux read: buffering/bounded prefix/pause/paused seek acknowledgement/new range/resume without server release");
? "PASS stalled secondary audio: required-track bound/seek invalidation/new-target coverage/backward high-water clearing"
: splitVideo
? "PASS stalled primary video: required-track bound/seek invalidation/new-target coverage/backward high-water clearing"
: "PASS stalled combined input: bounded prefix/pause/seek invalidation/new-target coverage/backward high-water clearing");
}
blockedOpening(player, base, "opening-close");
@@ -331,6 +446,7 @@ static void cancellation(Player *player, const std::string &base, const std::str
"local replacement cancels blocked opening read");
require(local.can_seek && !local.buffering && !local.seek_pending && local.position == 0,
"stale opening read cannot publish over replacement timeline");
noBuffer(local, "local audio replacement clears retained network media");
stillBlocked(base, "opening-replace");
require(player_close(player), "close audio-only replacement");
closed(player);
@@ -347,6 +463,8 @@ static void cancellation(Player *player, const std::string &base, const std::str
"network replacement cancels blocked playing read");
require(replacement.position == 0 && !replacement.buffering && !replacement.seek_pending && replacement.can_seek,
"replacement ignores stale stream data and buffering state");
require(!buffered(replacement) || replacement.buffered_end <= 4.2,
"new network source cannot retain the previous thirty-second horizon");
stillBlocked(base, "playing-replace");
require(player_close(player), "close split network replacement");
closed(player);
@@ -354,6 +472,10 @@ static void cancellation(Player *player, const std::string &base, const std::str
}
static void failures(Player *player, const std::string &base) {
require(openNetwork(player, base, "/media/streaming.mp4", "", 30),
"open retained network media before failure replacement");
until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
"failure replacement starts with retained coverage to invalidate");
require(openNetwork(player, base, "/media/av.mp4", "", 4, false, false), "header-denied request accepted asynchronously");
auto denied = until(player, [](auto s) { return s.status == PLAYER_ERROR; }, "required header denial", true);
require(denied.error[0] && !denied.has_audio && !denied.has_video && !denied.can_seek &&
@@ -393,6 +515,7 @@ int main(int argc, char **argv) {
frameSteps(player.get(), view, base);
nativeHLS(player.get(), view, base);
liveAndReplacement(player.get(), base, fixtures);
bufferCoverage(player.get(), base);
cancellation(player.get(), base, fixtures);
failures(player.get(), base);
blockedPlayback(player.get(), base, "destroy-playing");
+38 -4
View File
@@ -14,8 +14,9 @@ from urllib.parse import parse_qs, unquote, urlsplit
class _Gate:
def __init__(self, total: int):
def __init__(self, total: int, prefix_ends: dict[str, float]):
self.total = total
self.prefix_ends = prefix_ends
self.entered = threading.Event()
self.release = threading.Event()
self.claimed = False
@@ -45,6 +46,7 @@ class StreamingServer:
self.lock = threading.Lock()
self.gates: dict[str, _Gate] = {}
self.stats: dict[str, dict[str, int]] = {}
self.prefixes: dict[tuple[str, int], dict[str, float]] = {}
self.stopping = threading.Event()
owner = self
@@ -103,6 +105,35 @@ class StreamingServer:
end = min(int(last), size - 1) if last else size - 1
return (start, end) if 0 <= start <= end < size else None
def _prefix_ends(self, name: str, offset: int) -> dict[str, float]:
"""Timestamp upper bounds, never a byte fraction interpreted as playback time."""
key = (name, offset)
if key not in self.prefixes:
probe = subprocess.run(
["ffprobe", "-v", "error", "-show_packets", "-show_streams", "-show_format",
"-show_entries", "format=start_time:stream=index,codec_type:packet=stream_index,pts_time,duration_time,pos,size",
"-of", "json", str(self.fixtures / name)],
capture_output=True, text=True, check=True, timeout=30,
)
metadata = json.loads(probe.stdout)
tracks = {stream["index"]: stream["codec_type"] for stream in metadata["streams"]}
origin = float(metadata.get("format", {}).get("start_time", 0))
ends = {"audio": 0.0, "video": 0.0}
first_missing = {"audio": float("inf"), "video": float("inf")}
for packet in metadata["packets"]:
kind = tracks.get(packet["stream_index"])
if kind not in ends or "pts_time" not in packet or "pos" not in packet:
continue
# A packet crossing the gate is not playable, even if its header arrived.
if int(packet["pos"]) + int(packet["size"]) <= offset:
end = float(packet["pts_time"]) - origin + float(packet.get("duration_time", 0))
ends[kind] = max(ends[kind], end)
else:
first_missing[kind] = min(first_missing[kind], max(0, float(packet["pts_time"]) - origin))
# A reordered reference packet's later PTS cannot bridge an undelivered B-frame.
self.prefixes[key] = {kind: min(end, first_missing[kind]) for kind, end in ends.items()}
return self.prefixes[key]
def _get(self, request):
parsed = urlsplit(request.path)
parts = unquote(parsed.path).strip("/").split("/")
@@ -116,13 +147,13 @@ class StreamingServer:
gate = self.gates.get(parts[2])
if gate is None:
body = {"entered": False, "released": False, "waiting": 0, "requests": 0,
"ranges": 0, "bytes": 0, "total": 0}
"ranges": 0, "bytes": 0, "total": 0, "prefix_ends": {}}
else:
if parts[1] == "release":
gate.release.set()
body = {"entered": gate.entered.is_set(), "released": gate.release.is_set(),
"waiting": gate.waiting, "requests": gate.requests, "ranges": gate.ranges,
"bytes": gate.bytes, "total": gate.total}
"bytes": gate.bytes, "total": gate.total, "prefix_ends": gate.prefix_ends}
self._reply(request, 200, json.dumps(body).encode())
return
@@ -180,7 +211,10 @@ class StreamingServer:
self._reply(request, 400, b"Invalid gate offset", "text/plain")
return
with self.lock:
gate = self.gates.setdefault(token, _Gate(size))
if token not in self.gates:
prefix = self._prefix_ends(name, gate_at) if mode == "stall" else {}
self.gates[token] = _Gate(size, prefix)
gate = self.gates[token]
gate.requests += 1
gate.ranges += int(range_header is not None)
# Only the original prefix connection is gated: a seek can create a new
+7
View File
@@ -67,7 +67,14 @@ struct SystemPlaybackChecks {
require(model.setPlayback(playing: true), "repeated play accepted")
until("repeated play keeps playing") { model.state.status == PLAYER_PLAYING && model.state.position > 0.2 }
// Closing must hide the last polled retained range before the next state poll.
var retainedSnapshot = model.state
retainedSnapshot.buffered_start = retainedSnapshot.position
retainedSnapshot.buffered_end = min(retainedSnapshot.duration, retainedSnapshot.position + 1)
model.applyState(retainedSnapshot)
require(model.bufferedRange != nil, "loaded selection exposes retained media")
model.closeFile()
require(model.bufferedRange == nil, "accepted close immediately hides a stale retained-media snapshot")
require(center.nowPlayingInfo == nil && center.playbackState == .stopped,
"accepted close releases system playback immediately")
require(!commands.togglePlayPauseCommand.isEnabled && !commands.nextTrackCommand.isEnabled,