refactor: concentrate Cast ownership and preserve intent on disconnect
This commit is contained in:
+123
-8
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user