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
+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)