refactor: concentrate Cast ownership and preserve intent on disconnect

This commit is contained in:
2026-10-16 21:06:53 +02:00
parent f1fbcf30b5
commit 367afdfb07
8 changed files with 600 additions and 205 deletions
+2 -2
View File
@@ -108,8 +108,8 @@ struct AutomaticQualityPlaybackChecks {
model.youtubeHeight == 720 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING &&
!model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false)
}
require(model.state.position >= position && model.state.position < position + 3,
"automatic quality reopening preserves current playback progress")
require(model.state.position >= position,
"automatic quality reopening does not reset acknowledged progress")
polled(model)
clock.now = 2063
until(model, "sustained health automatically restores actual 1080p media") {
+152
View File
@@ -17,6 +17,153 @@ struct CastPlaybackCheck {
require(predicate(), message)
}
static func interrupted(_ model: PlayerModel, source: URL, port: UInt16,
scenario: String, gates: URL, defaults: UserDefaults) async throws {
func signal(_ name: String) throws {
try Data().write(to: gates.appendingPathComponent(name), options: .atomic)
}
func awaitSignal(_ name: String) async throws {
try await until(model, "\(scenario): fixture signals \(name)", permitError: true) {
FileManager.default.fileExists(atPath: gates.appendingPathComponent(name).path)
}
}
let replacement = gates.appendingPathComponent("replacement.mp4")
try FileManager.default.copyItem(at: source, to: replacement)
let active = scenario.hasSuffix("-active")
let expectedError = scenario == "reject-load" || scenario == "disconnect-active"
model.setVolume(0)
model.open(source)
try await until(model, "interruption source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
require(model.setPlayback(playing: false), "initial local pause accepted")
try await until(model, "initial local pause acknowledged") {
model.state.status == PLAYER_PAUSED && model.playbackIntent == nil
}
require(model.seekRelative(9.25 - model.state.position), "initial local position accepted")
try await until(model, "initial local position acknowledged") {
!model.state.seek_pending && abs(model.state.position - 9.25) < 0.15
}
if !active {
require(model.setPlayback(playing: true), "local playback starts before pending handoff")
try await until(model, "local playback acknowledged before handoff") {
model.state.status == PLAYER_PLAYING && model.playbackIntent == nil
}
}
let beforeConnect = model.state.position
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
if active {
try await until(model, "active receiver acknowledges handoff") {
model.castDevice != nil && !model.castConnecting && model.canSeek && model.state.status == PLAYER_PAUSED
}
require(abs(model.state.position - 9.25) < 0.15, "active receiver starts at acknowledged local position")
require(model.volume == 1, "active receiver volume is visible before local rollback")
if scenario == "disconnect-active" {
require(model.setPlayback(playing: true), "initial receiver PLAY accepted")
try await until(model, "initial receiver PLAY acknowledged") {
model.state.status == PLAYER_PLAYING && model.playbackIntent == nil
}
}
try signal("active")
}
try await awaitSignal("held")
if !active {
require(model.castDevice != nil && model.castConnecting, "held handoff remains pending")
if scenario == "cancel-connect" {
try await until(model, "pending connection does not pause local playback") {
model.state.status == PLAYER_PLAYING && model.state.position > beforeConnect
}
} else {
try await until(model, "pending LOAD follows acknowledged local pause") { model.state.status == PLAYER_PAUSED }
}
}
if scenario == "reject-load" {
try signal("observed")
try await until(model, "LOAD rejection restores original local playback", permitError: true) {
model.castDevice == nil && !model.castConnecting && model.commandError != nil &&
model.state.status == PLAYER_PLAYING && model.canSeek
}
} else {
switch scenario {
case "cancel-connect", "cancel-load":
model.stopCasting()
case "replace-load", "replace-active":
model.open(replacement)
case "close-load", "close-active":
model.closeFile()
case "shutdown-load", "shutdown-active":
model.shutdown()
case "disconnect-active":
require(model.setPlayback(playing: false) && model.setPlayback(playing: true) &&
model.setPlayback(playing: false), "latest unacknowledged receiver PAUSE accepted")
require(model.playbackIntent == false && model.state.status == PLAYER_PLAYING,
"latest intent remains distinct from acknowledged receiver playback")
require(model.seekRelative(10), "receiver seek preview accepted before disconnect")
require(model.state.seek_pending, "receiver seek remains pending before disconnect")
default:
fatalError("Unknown interruption scenario: " + scenario)
}
try signal("interrupted")
}
try await awaitSignal("released")
if scenario.hasPrefix("shutdown-") {
require(model.castDevice == nil && !model.castConnecting && !model.canOpen && !model.canClose,
"shutdown releases Cast ownership and permanently disables the local player")
require(!model.setPlayback(playing: true) && !model.seekRelative(5) && model.volume == 0,
"late receiver traffic cannot resume a destroyed player or replace local volume")
print("PASS: " + scenario)
return
}
if scenario == "disconnect-active" {
try await until(model, "disconnect restores latest intent at acknowledged position", permitError: true) {
model.castDevice == nil && !model.castConnecting && !model.isBusy &&
model.state.status == PLAYER_PAUSED && !model.state.seek_pending && model.playbackIntent == nil &&
abs(model.state.position - 9.25) < 0.15
}
require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 9.25) < 0.15,
"unacknowledged remote seek preview never replaces saved acknowledged position")
} else if scenario.hasPrefix("close-") {
try await until(model, "close acknowledges empty decoder after late receiver traffic") {
model.state.status == PLAYER_EMPTY && model.fileName == nil && model.castDevice == nil && !model.isBusy
}
require(model.playbackIntent == nil, "close does not leave an accidental resume intent")
model.open(replacement)
} else if scenario.hasPrefix("replace-") {
try await until(model, "replacement acknowledges its own local playback after late receiver traffic") {
model.castDevice == nil && !model.castConnecting && !model.isBusy &&
model.fileName == replacement.lastPathComponent && model.state.status == PLAYER_PLAYING && model.canSeek
}
} else {
try await until(model, "cancelled or rejected handoff leaves usable local playback", permitError: expectedError) {
model.castDevice == nil && !model.castConnecting && model.state.status == PLAYER_PLAYING && model.canSeek
}
}
require(model.volume == 0, "every interrupted handoff restores local volume")
if scenario != "disconnect-active" {
// A native acknowledgement is the barrier after released late receiver traffic;
// never sleep and infer that stale callbacks did nothing.
try await until(model, "local decoder is usable after interruption", permitError: expectedError) {
model.canSeek && !model.isBusy
}
require(model.setPlayback(playing: false), "post-interruption local pause accepted")
try await until(model, "post-interruption local pause acknowledged", permitError: expectedError) {
model.state.status == PLAYER_PAUSED && model.playbackIntent == nil
}
require(model.seekRelative(4.25 - model.state.position), "post-interruption local seek accepted")
try await until(model, "local seek acknowledges after obsolete receiver replies", permitError: expectedError) {
!model.state.seek_pending && abs(model.state.position - 4.25) < 0.15
}
require(model.castDevice == nil && !model.castConnecting && model.volume == 0,
"obsolete receiver callbacks cannot reclaim playback or volume")
if scenario.hasPrefix("replace-") || scenario.hasPrefix("close-") {
require(model.fileName == replacement.lastPathComponent, "interruption preserves replacement media identity")
}
}
model.closeFile()
try await until(model, "interrupted scenario closes all playback", permitError: expectedError) {
model.state.status == PLAYER_EMPTY && model.castDevice == nil && !model.castConnecting
}
print("PASS: " + scenario)
}
static func main() async throws {
_ = NSApplication.shared
NSApp.setActivationPolicy(.prohibited)
@@ -32,6 +179,11 @@ struct CastPlaybackCheck {
defer { defaults.removePersistentDomain(forName: suite) }
let model = PlayerModel(defaults: defaults)
defer { model.shutdown() }
if CommandLine.arguments.count == 5 {
try await interrupted(model, source: source, port: port, scenario: CommandLine.arguments[3],
gates: URL(fileURLWithPath: CommandLine.arguments[4]), defaults: defaults)
return
}
model.setVolume(0)
model.open(source)
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
+123 -8
View File
@@ -87,6 +87,11 @@ private final class Receiver {
var rejectCommand = false
var holdSeek = false
var holdVolume = false
var holdLaunch = false
var holdLoad = false
var holdPlayback = false
private var heldLaunch: Int?
private var heldLoad: [String: Any]?
var ignoreHeartbeat = false
var verifyRelay = false
var relayVerified = false
@@ -138,6 +143,33 @@ private final class Receiver {
}
func stop() { socket?.cancel(); listener.cancel() }
func disconnect() { socket?.cancel() }
func releaseLaunch() throws {
guard let requestID = heldLaunch else { return }
heldLaunch = nil
try receiverStatus(requestID)
}
func releaseLoad(reject: Bool = false) async throws {
guard let command = heldLoad else { return }
heldLoad = nil
if reject {
try send(["type": "LOAD_FAILED", "requestId": command["requestId"] as? Int ?? 0], namespace: CastWire.media)
} else {
try await load(command)
}
}
private func load(_ json: [String: Any]) async throws {
media = json["media"] as! [String: Any]
session += 1
playing = json["autoplay"] as? Bool ?? true
position = json["currentTime"] as? Double ?? 0
activeTracks = json["activeTrackIds"] as? [Int] ?? []
if verifyRelay { try await fetchRelay() }
try status(requestID: json["requestId"] as? Int ?? 0)
}
private func receive(_ socket: NWConnection) {
socket.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] data, _, ended, error in
@@ -219,20 +251,17 @@ private final class Receiver {
case "CLOSE": break
case "LAUNCH":
require(json["appId"] as? String == "CC1AD845", "Default Media Receiver, no registration")
if holdLaunch { heldLaunch = requestID; return }
if dropLaunch { return }
if rejectLaunch { try send(["type": "LAUNCH_ERROR", "requestId": requestID], namespace: CastWire.receiver) }
else { try receiverStatus(requestID) }
case "LOAD":
if holdLoad { heldLoad = json; return }
if rejectLoad { try send(["type": "LOAD_FAILED", "requestId": requestID], namespace: CastWire.media); return }
if dropLoad { return }
media = json["media"] as! [String: Any]
session += 1
playing = json["autoplay"] as? Bool ?? true
position = json["currentTime"] as? Double ?? 0
activeTracks = json["activeTrackIds"] as? [Int] ?? []
if verifyRelay { try await fetchRelay() }
try status(requestID: requestID)
try await load(json)
case "PLAY", "PAUSE", "SEEK", "GET_STATUS", "EDIT_TRACKS_INFO":
if holdPlayback, type == "PLAY" || type == "PAUSE" { return }
if dropCommands { return }
if rejectCommand {
try send(["type": "INVALID_PLAYER_STATE", "requestId": requestID], namespace: CastWire.media)
@@ -607,7 +636,7 @@ struct CastSessionCheck {
static func failures(identity: SecIdentity) async throws {
for mode in ["launch", "launch-timeout", "load", "deadline", "command", "rejection", "cancel", "frame",
"ownership", "media-error", "media-cancel", "media-interrupt", "heartbeat", "queue"] {
"ownership", "media-error", "media-cancel", "media-interrupt", "heartbeat", "queue", "pending-disconnect"] {
let peer = try Receiver(identity: identity)
try await peer.start()
let clock = CastCheckClock()
@@ -671,6 +700,15 @@ struct CastSessionCheck {
require(session.setPlaying(true), "Rejected command sent")
try await until("Command rejection reports recoverable error") { !errors.isEmpty }
require(!disconnected, "Command rejection is not channel failure")
case "pending-disconnect":
peer.dropCommands = true
require(session.setPlaying(true) && session.seek(12), "Playback and seek accepted before disconnect")
try await until("Pending controls reach receiver before disconnect") {
peer.commands.contains { $0.0 == "PLAY" } && peer.commands.contains { $0.0 == "SEEK" }
}
peer.disconnect()
try await until("Terminal failure is reported through disconnect") { disconnected }
require(errors.isEmpty, "Terminal failure does not masquerade as recoverable command rejection")
case "media-error": try peer.status(state: "IDLE", idle: "ERROR")
case "media-cancel": try peer.status(state: "IDLE", idle: "CANCELLED")
case "media-interrupt": try peer.status(state: "IDLE", idle: "INTERRUPTED")
@@ -724,6 +762,81 @@ struct CastSessionCheck {
controls[2].1["currentTime"] as? Double == 24.25, "Rapid skips accumulate from the pending target")
}
static func interruptedPlayback(identity: SecIdentity, executable: String, file: String) async throws {
for scenario in ["cancel-connect", "cancel-load", "reject-load", "replace-load", "close-load",
"replace-active", "close-active", "shutdown-load", "shutdown-active", "disconnect-active"] {
let peer = try Receiver(identity: identity)
let active = scenario.hasSuffix("-active")
peer.holdLaunch = scenario == "cancel-connect"
peer.holdLoad = !active && scenario != "cancel-connect"
peer.verifyRelay = active
try await peer.start()
defer { peer.stop() }
let gates = FileManager.default.temporaryDirectory.appendingPathComponent("terebi-cast-interruption-" + UUID().uuidString)
try FileManager.default.createDirectory(at: gates, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: gates) }
func exists(_ name: String) -> Bool {
FileManager.default.fileExists(atPath: gates.appendingPathComponent(name).path)
}
func signal(_ name: String) throws {
try Data().write(to: gates.appendingPathComponent(name), options: .atomic)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = [file, String(peer.port), scenario, gates.path]
try process.run()
defer { if process.isRunning { process.terminate() } }
func awaitStage(_ message: String, _ predicate: () -> Bool) async throws {
try await until("\(scenario): \(message)", attempts: 18000) {
predicate() || !process.isRunning || peer.failure != nil
}
require(peer.failure == nil, peer.failure ?? "Receiver accepts interrupted playback")
require(process.isRunning, "\(scenario): child remains alive until fixture stage")
require(predicate(), "\(scenario): \(message)")
}
if active {
try await awaitStage("remote playback active") { exists("active") }
require(peer.relayVerified, "\(scenario): active receiver fetched real relay bytes")
if scenario == "disconnect-active" {
peer.holdPlayback = true
peer.holdSeek = true
}
} else {
let command = scenario == "cancel-connect" ? "LAUNCH" : "LOAD"
try await awaitStage("handoff reaches held \(command)") { peer.commands.contains { $0.0 == command } }
}
try signal("held")
if scenario == "reject-load" {
try await awaitStage("consumer observes paused pending LOAD") { exists("observed") }
try await peer.releaseLoad(reject: true)
} else {
try await awaitStage("consumer interrupts handoff") { exists("interrupted") }
if scenario == "disconnect-active" {
try await awaitStage("latest playback and seek reach receiver") {
peer.commands.filter { ["PLAY", "PAUSE"].contains($0.0) }.count == 4 &&
peer.commands.contains { $0.0 == "SEEK" }
}
peer.disconnect()
} else if scenario == "cancel-connect" {
try peer.releaseLaunch()
} else if !active {
// A cancelled LOAD can still produce a late receiver acknowledgement.
// Skip relay fetching: the sender has intentionally stopped that relay.
try await peer.releaseLoad()
} else {
try peer.status(state: "PLAYING")
try peer.receiverStatus(level: 0.9)
}
}
try signal("released")
try await until("\(scenario): subprocess completes", attempts: 18000) {
!process.isRunning || peer.failure != nil
}
require(peer.failure == nil, peer.failure ?? "Receiver accepts interrupted playback")
require(!process.isRunning && process.terminationStatus == 0, "\(scenario): consumer-visible restoration succeeds")
}
}
static func realPeer(_ arguments: [String]) async throws {
guard let port = UInt16(arguments[3]), let url = URL(string: arguments[4]) else {
fatalError("Usage: cast-session-check --peer HOST PORT MEDIA_URL CONTENT_TYPE")
@@ -769,6 +882,8 @@ struct CastSessionCheck {
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")
try await interruptedPlayback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
print("PASS: interrupted Cast connect/LOAD, rejection, replacement/close, latest intent and acknowledged-position recovery")
} else {
try await behavior(identity: identity)
try await launchRegressions(identity: identity)