refactor: concentrate Cast ownership and preserve intent on disconnect
This commit is contained in:
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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