fix: separate Cast launch readiness, preserve volume intent and reuse seek inputs
This commit is contained in:
+152
-7
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user