fix: separate Cast launch readiness, preserve volume intent and reuse seek inputs

This commit is contained in:
2026-10-13 21:40:19 +02:00
parent 697158aa02
commit b9e63cd760
8 changed files with 365 additions and 55 deletions
+75 -3
View File
@@ -25,6 +25,10 @@ extern "C" {
#include <unistd.h>
#include <fcntl.h>
#include <poll.h>
#include <spawn.h>
#include <sys/wait.h>
extern char **environ;
static void ok(int result) { assert(result >= 0); }
static std::vector<uint8_t> readFile(const std::string &path) {
@@ -99,6 +103,20 @@ static void fixture(const std::string &path) {
av_bsf_free(&aspect);
avio_closep(&format->pb); avformat_free_context(format); avcodec_free_context(&video); avcodec_free_context(&audio);
}
// Indexed split MP4 is the resolved YouTube VOD shape. As with check-fixtures,
// the check requires host ffmpeg for MP4 muxing; production uses only bundled libraries.
static void splitFixture(const std::string &source, const std::string &path, AVMediaType type) {
const char *arguments[] = {"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", source.c_str(), "-map", type == AVMEDIA_TYPE_VIDEO ? "0:v:0" : "0:a:0",
"-c", "copy", "-movflags", "+faststart", path.c_str(), nullptr};
pid_t child = 0;
int result = posix_spawnp(&child, arguments[0], nullptr, nullptr,
const_cast<char *const *>(arguments), environ);
if (result != 0) std::cerr << "cast-media split fixture requires host ffmpeg (same as make check-fixtures)\n";
assert(result == 0);
int status = 0; assert(waitpid(child, &status, 0) == child);
assert(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
static std::vector<uint8_t> wav() {
std::vector<uint8_t> bytes(44 + 90 * 48000 * 2);
auto put = [&](size_t at, uint32_t value, int length) { for (int i = 0; i < length; ++i) bytes[at + i] = uint8_t(value >> (8 * i)); };
@@ -128,6 +146,7 @@ static HTTP request(const std::string &url, const std::string &method = "GET", c
struct Upstream {
int listener; uint16_t port; std::thread threads[4]; std::atomic<bool> stopped{false};
std::atomic<int> foreignRequests{0}, videoRequests{0}, audioRequests{0};
std::atomic<int> videoStarts{0}, audioStarts{0};
std::map<std::string, std::vector<uint8_t>> resources;
std::string foreign;
explicit Upstream(std::vector<uint8_t> video, std::vector<uint8_t> audio) {
@@ -158,7 +177,7 @@ struct Upstream {
(path == "/public-cross.m3u8" ? "/public.ts" : "/source.ts") + "\n#EXT-X-ENDLIST\n";
data.assign(playlist.begin(), playlist.end());
} else if (auto resource = resources.find(path); resource != resources.end()) {
bool audio = path == "/audio.wav";
bool audio = path == "/audio.wav" || path == "/audio.m4a";
bool publicMedia = path == "/public.ts";
bool authorized = publicMedia || request.find(audio ? "X-Audio: audio-secret" : "X-Video: video-secret") != std::string::npos;
bool wrong = request.find(publicMedia ? "secret" : (audio ? "video-secret" : "audio-secret")) != std::string::npos;
@@ -173,6 +192,9 @@ struct Upstream {
if (low >= high) { status = 416; low = high = 0; }
else { status = 206; extra += "Content-Range: bytes " + std::to_string(low) + "-" + std::to_string(high - 1) + "/" + std::to_string(data.size()) + "\r\n"; }
}
if ((status == 200 || status == 206) && low == 0 && resources.contains(path)) {
if (path == "/audio.wav" || path == "/audio.m4a") ++audioStarts; else ++videoStarts;
}
std::string header = "HTTP/1.1 " + std::to_string(status) + " Test\r\nConnection: close\r\nAccept-Ranges: bytes\r\n" + extra + "Content-Length: " + std::to_string(high - low) + "\r\n\r\n";
send(client, header.data(), header.size(), 0);
if (!request.starts_with("HEAD")) while (high > low) { auto n = send(client, data.data() + low, high - low, 0); if (n <= 0) break; low += size_t(n); }
@@ -244,15 +266,65 @@ static CastMedia *start(CastMediaSource source, std::string &url, double &durati
char error[512]{}, bytes[512]{}; auto *media = cast_media_create(&source, error); assert(media);
assert(cast_media_start(media, "127.0.0.1", bytes, &duration, error)); url = bytes; return media;
}
static void seekWork(Upstream &upstream, const std::string &segment) {
std::string video = upstream.url("/video.mp4"), audio = upstream.url("/audio.m4a"), url;
double duration = 0;
CastMediaSource source{video.c_str(), "X-Video: video-secret\r\n", audio.c_str(), "X-Audio: audio-secret\r\n", false, false, false, 90};
auto *media = start(source, url, duration);
std::string base = url.substr(0, url.rfind('/') + 1);
int videoStarts = upstream.videoStarts, audioStarts = upstream.audioStarts;
auto distant = request(base + "segment/10.ts");
playable(distant, segment, true, 60);
int videoRequests = upstream.videoRequests, audioRequests = upstream.audioRequests;
auto repeated = request(base + "segment/10.ts");
std::cout << "seek-work repeated upstream requests: video=" << int(upstream.videoRequests) - videoRequests
<< " audio=" << int(upstream.audioRequests) - audioRequests << std::endl;
bool repeatedHasNoIO = upstream.videoRequests == videoRequests && upstream.audioRequests == audioRequests;
bool repeatedIdentical = repeated.status == 200 && repeated.bytes == distant.bytes;
auto adjacent = request(base + "segment/11.ts");
playable(adjacent, segment, true, 66);
auto before = audioSpan(distant, segment), after = audioSpan(adjacent, segment);
assert(std::abs(after.first - before.second) < 1.0 / 48000);
playable(request(base + "segment/2.ts"), segment, true, 12);
playable(request(base + "segment/12.ts"), segment, true, 72);
std::cout << "seek-work new origin-zero requests across distant/adjacent seeks: video="
<< int(upstream.videoStarts) - videoStarts << " audio=" << int(upstream.audioStarts) - audioStarts << std::endl;
bool seeksDoNotReprobe = upstream.videoStarts == videoStarts && upstream.audioStarts == audioStarts;
// A previous random segment remains independently replayable without upstream IO.
videoRequests = upstream.videoRequests; audioRequests = upstream.audioRequests;
bool replayIdentical = request(base + "segment/10.ts").bytes == distant.bytes;
auto head = request(base + "segment/10.ts", "HEAD");
auto range = request(base + "segment/10.ts", "GET", "Range: bytes=0-187\r\n");
assert(head.status == 200 && head.bytes.empty());
assert(range.status == 206 && range.bytes == std::vector<uint8_t>(distant.bytes.begin(), distant.bytes.begin() + 188));
bool replayHasNoIO = upstream.videoRequests == videoRequests && upstream.audioRequests == audioRequests;
cast_media_destroy(media);
assert(seeksDoNotReprobe);
assert(repeatedHasNoIO && repeatedIdentical && replayHasNoIO && replayIdentical);
}
int main(int argc, char **argv) {
bool liveCheck = false;
for (int argument = 1; argument < argc; ++argument) if (std::string(argv[argument]) == "--live") liveCheck = true;
bool liveCheck = false, seekCheck = false;
for (int argument = 1; argument < argc; ++argument) {
if (std::string(argv[argument]) == "--live") liveCheck = true;
if (std::string(argv[argument]) == "--seek-work") seekCheck = true;
}
char temp[] = "/tmp/terebi-cast-check.XXXXXX"; assert(mkdtemp(temp)); std::filesystem::path directory(temp);
const auto video = (directory / "source.ts").string(), audio = (directory / "source.wav").string(), segment = (directory / "segment.ts").string();
fixture(video); writeFile(audio, wav());
const auto splitVideo = (directory / "video.mp4").string(), splitAudio = (directory / "audio.m4a").string();
splitFixture(video, splitVideo, AVMEDIA_TYPE_VIDEO);
splitFixture(video, splitAudio, AVMEDIA_TYPE_AUDIO);
auto videoBytes = readFile(video), audioBytes = readFile(audio);
Upstream upstream(videoBytes, audioBytes), foreign(videoBytes, audioBytes);
upstream.resources["/video.mp4"] = readFile(splitVideo);
upstream.resources["/audio.m4a"] = readFile(splitAudio);
upstream.foreign = foreign.url(""); upstream.start(); foreign.start();
seekWork(upstream, segment);
if (seekCheck) {
std::filesystem::remove_all(directory);
std::cout << "cast-media: split VOD seek work passed\n";
return 0;
}
std::string url; double duration = 0;
CastMediaSource local{video.c_str(), "", nullptr, nullptr, true, false, false, 0};
auto *media = start(local, url, duration); assert(duration >= 89 && duration <= 92);
+5
View File
@@ -20,6 +20,11 @@ struct CastPlaybackCheck {
static func main() async throws {
_ = NSApplication.shared
NSApp.setActivationPolicy(.prohibited)
require(castVolumeSliderFeedbackCheck(), "Native slider keeps user position during tracking and applies receiver volume when idle")
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--slider-regression" {
print("PASS: native volume slider tracking preserves user input and idle receiver feedback")
return
}
let source = URL(fileURLWithPath: CommandLine.arguments[1])
let port = UInt16(CommandLine.arguments[2])!
let suite = "terebi.cast-playback-check." + UUID().uuidString
+152 -7
View File
@@ -86,6 +86,7 @@ private final class Receiver {
var dropCommands = false
var rejectCommand = false
var holdSeek = false
var holdVolume = false
var ignoreHeartbeat = false
var verifyRelay = false
var relayVerified = false
@@ -164,10 +165,17 @@ private final class Receiver {
} else { socket?.send(content: frame, completion: .contentProcessed { _ in }) }
}
private func receiverStatus(_ requestID: Int) throws {
try send(["type": "RECEIVER_STATUS", "requestId": requestID,
"status": ["applications": [["appId": "CC1AD845", "sessionId": "app-session", "transportId": transport]],
"volume": ["level": volume, "muted": muted]]], namespace: CastWire.receiver, fragmented: true)
func receiverStatus(_ requestID: Int? = nil, appID: String? = "CC1AD845",
level: Double? = nil, source: String? = nil) throws {
let apps: [[String: Any]] = appID.map { [["appId": $0, "sessionId": "app-session", "transportId": transport]] } ?? []
var json: [String: Any] = ["type": "RECEIVER_STATUS",
"status": ["applications": apps, "volume": ["level": level ?? volume, "muted": muted]]]
if let requestID { json["requestId"] = requestID }
try send(json, namespace: CastWire.receiver, source: source, fragmented: true)
}
func receiverPing() throws {
try send(["type": "PING"], namespace: CastWire.heartbeat)
}
func status(requestID: Int = 0, session override: Int? = nil, content: String? = nil,
@@ -241,9 +249,13 @@ private final class Receiver {
try status(requestID: requestID)
case "SET_VOLUME":
require(message.namespace == CastWire.receiver, "Volume controls device, not stream")
if rejectCommand {
try send(["type": "INVALID_REQUEST", "requestId": requestID], namespace: CastWire.receiver)
return
}
volume = (json["volume"] as? [String: Any])?["level"] as? Double ?? volume
muted = (json["volume"] as? [String: Any])?["muted"] as? Bool ?? muted
try receiverStatus(requestID)
if !holdVolume { try receiverStatus(requestID) }
case "STOP":
require(message.namespace == CastWire.media, "Disconnect does not stop another sender's application")
require(json["mediaSessionId"] as? Int == session, "Disconnect stops only owned media")
@@ -437,6 +449,127 @@ struct CastSessionCheck {
require(disconnected && errors.isEmpty && peer.failure == nil, "Safe disconnect without spurious cancellation errors")
}
static func launchRegressions(identity: SecIdentity) async throws {
for mode in ["cold", "asynchronous", "intermediate"] {
let peer = try Receiver(identity: identity)
peer.dropLaunch = true
try await peer.start()
let clock = CastCheckClock()
let session = CastSession(sleep: { try await clock.sleep($0) })
var outcome: String?
let task = Task {
do { try await session.connect(to: peer.device); outcome = "connected" }
catch { outcome = error.localizedDescription }
}
try await until("LAUNCH arrives before delayed receiver status") {
peer.commands.contains { $0.0 == "LAUNCH" } && clock.hasDelay(.seconds(5))
}
let launchID = peer.commands.first { $0.0 == "LAUNCH" }!.1["requestId"] as! Int
if mode == "cold" {
clock.advance(.seconds(9))
try await until("Cold launch advances past ordinary request deadline") {
outcome != nil || peer.commands.contains { $0.0 == "PING" } && clock.hasDelay(.seconds(5))
}
require(outcome == nil, "Cold receiver launch survives the ordinary eight-second control deadline")
}
let pongCount = peer.commands.filter { $0.0 == "PONG" }.count
try peer.receiverStatus(appID: "unrelated-app")
try peer.receiverStatus(source: "unrelated-transport")
if mode == "intermediate" { try peer.receiverStatus(launchID, appID: nil) }
try peer.receiverPing()
try await until("Ordered receiver barrier after unrelated and intermediate launch statuses") {
outcome != nil || peer.commands.filter { $0.0 == "PONG" }.count == pongCount + 1
}
require(outcome == nil, "Launch waits for Default Media Receiver rather than adopting another app or rejecting intermediate status")
if mode == "cold" {
clock.advance(.seconds(11))
try await until("Cold receiver remains pending beyond the TLS connection deadline") {
outcome != nil || peer.commands.filter { $0.0 == "PING" }.count >= 2 && clock.hasDelay(.seconds(5))
}
require(outcome == nil, "Successful TLS connection does not impose a twelve-second receiver launch deadline")
}
try peer.receiverStatus() // Real receivers can broadcast readiness without the LAUNCH request ID.
try await until("Asynchronous receiver launch status completes connection") { outcome != nil }
require(outcome == "connected", "Default Media Receiver readiness completes launch without the original acknowledgement")
await task.value
try peer.receiverStatus(launchID) // The original acknowledgement can arrive after readiness.
try await session.load(media(), title: "Delayed launch", position: 0, playing: false, subtitle: nil)
require(peer.failure == nil, "Late launch acknowledgement does not disturb acquired receiver ownership")
session.disconnect(stopReceiver: false)
try await until("Delayed launch cancels all clock handles") { clock.pendingCount == 0 }
peer.stop()
}
}
static func volumeRegressions(identity: SecIdentity) async throws {
let peer = try Receiver(identity: identity)
peer.holdVolume = true
try await peer.start()
defer { peer.stop() }
let clock = CastCheckClock()
let session = CastSession(sleep: { try await clock.sleep($0) })
var statuses: [CastStatus] = []
var errors: [String] = []
session.onStatus = { statuses.append($0) }
session.onError = { errors.append($0) }
try await session.connect(to: peer.device)
try await session.load(media(), title: "Volume feedback", position: 0, playing: false, subtitle: nil)
func volumeID(_ index: Int) -> Int {
peer.commands.filter { $0.0 == "SET_VOLUME" }[index].1["requestId"] as! Int
}
func barrier(_ message: String) async throws {
let pongs = peer.commands.filter { $0.0 == "PONG" }.count
try peer.status()
try peer.receiverPing()
try await until(message) { peer.commands.filter { $0.0 == "PONG" }.count == pongs + 1 }
}
require(session.setVolume(0.2) && session.setVolume(0.8), "Rapid slider commands accepted")
try await until("Both slider commands reach receiver") { peer.commands.filter { $0.0 == "SET_VOLUME" }.count == 2 }
let beforeBurst = statuses.count
try peer.receiverStatus(volumeID(0), level: 0.2)
let launchID = peer.commands.first { $0.0 == "LAUNCH" }!.1["requestId"] as! Int
try peer.receiverStatus(launchID, level: 0.1)
try peer.receiverStatus(level: 0.8)
try peer.receiverStatus(level: 0.45)
try await barrier("Media status provides barrier after stale volume feedback")
require(statuses.last?.volume == 0.8 && statuses.dropFirst(beforeBurst).allSatisfy { $0.volume == 0.8 },
"Older command and unsolicited receiver feedback cannot pull the slider away from latest user intent")
try peer.receiverStatus(volumeID(1), level: 0.79)
try await barrier("Latest slider acknowledgement supplies receiver-authoritative volume")
require(statuses.last?.volume == 0.79, "Latest acknowledgement restores the receiver's actual quantized volume")
try peer.receiverStatus(volumeID(0), level: 0.2)
try peer.receiverStatus(launchID, level: 0.1)
try await barrier("Late duplicate old volume acknowledgement is consumed")
require(statuses.last?.volume == 0.79, "Old acknowledged request cannot roll volume backward after latest command settles")
try peer.receiverStatus(level: 0.4)
try await barrier("External TV volume arrives while idle")
require(statuses.last?.volume == 0.4, "External TV changes remain authoritative when no latest slider intent is pending")
require(session.setVolume(0.6) && session.setVolume(0.9), "Second overlapping slider burst accepted")
try await until("Second slider burst reaches receiver") { peer.commands.filter { $0.0 == "SET_VOLUME" }.count == 4 }
try peer.receiverStatus(volumeID(3), level: 0.88)
try peer.receiverStatus(volumeID(2), level: 0.6)
try peer.receiverStatus(level: 0.5)
try await barrier("Reverse acknowledgement order and external change processed")
require(statuses.last?.volume == 0.5, "Settled latest intent allows external updates even after an older overlapping request")
require(session.setVolume(0.7), "Unanswered volume command accepted")
try await until("Volume timeout waits on controlled clock") {
peer.commands.filter { $0.0 == "SET_VOLUME" }.count == 5 && clock.hasDelay(.seconds(8))
}
clock.advance(.seconds(8))
try await until("Volume timeout releases latest user intent") { !errors.isEmpty }
require(statuses.last?.volume == 0.5, "Timed-out volume intent restores the last authoritative receiver value")
try peer.receiverStatus(level: 0.7)
try await barrier("External volume becomes authoritative after timeout")
require(statuses.last?.volume == 0.7, "Unsolicited TV volume resumes after bounded command failure")
peer.rejectCommand = true
let beforeRejection = errors.count
require(session.setVolume(0.4), "Rejected volume command is sent")
try await until("Explicit volume rejection releases latest intent") { errors.count > beforeRejection }
require(statuses.last?.volume == 0.7, "Rejected slider intent restores authoritative receiver volume")
session.disconnect(stopReceiver: false)
try await until("Volume regression cancels all clock handles") { clock.pendingCount == 0 }
}
static func connectionDeadline() async throws {
let listener = try NWListener(using: .tcp, on: .any)
@MainActor final class ListenerState {
@@ -494,9 +627,9 @@ struct CastSessionCheck {
} else if mode == "launch-timeout" {
let task = Task { try await session.connect(to: peer.device) }
try await until("Receiver launch waits on frozen clock") {
peer.commands.contains { $0.0 == "LAUNCH" } && clock.hasDelay(.milliseconds(400))
peer.commands.contains { $0.0 == "LAUNCH" } && clock.hasDelay(.seconds(30))
}
clock.advance(.milliseconds(400))
clock.advance(.seconds(30))
do { try await task.value; fatalError("FAIL: unanswered launch succeeded") }
catch CastError.timeout { require(disconnected, "Launch deadline releases channel") }
} else {
@@ -623,11 +756,23 @@ struct CastSessionCheck {
try codec()
let (identity, directory) = try testIdentity()
defer { try? FileManager.default.removeItem(at: directory) }
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--launch-regressions" {
try await launchRegressions(identity: identity)
print("PASS: cold and asynchronous Cast launch readiness, unrelated app protection, late acknowledgement")
return
}
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--volume-regressions" {
try await volumeRegressions(identity: identity)
print("PASS: latest slider intent, stale volume feedback, authoritative acknowledgement, external TV changes, timeout")
return
}
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" {
try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect")
} else {
try await behavior(identity: identity)
try await launchRegressions(identity: identity)
try await volumeRegressions(identity: identity)
try await failures(identity: identity)
try await connectionDeadline()
print("PASS: bounded Cast framing, self-signed TLS launch/load/control, correlation, stale ownership, queue, deadlines, heartbeat, cancellation")