Integrate live phone and watch remote prototype with guarded motion control
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
> Historical agent handoff. The integrated composition and current test status are documented in `docs/FIRST-DEVICE-PROTOTYPE.md` and `docs/VALIDATION.md`. Proposed wiring below has been incorporated or superseded.
|
||||
|
||||
# WatchBridge integration proposal
|
||||
|
||||
Status: accepted with integration decisions below. This proposal is retained as
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import CoreFoundation
|
||||
import RemoteCore
|
||||
|
||||
private func relayNSNumberIsBoolean(_ value: NSNumber) -> Bool {
|
||||
@@ -88,22 +89,24 @@ public struct RelayApplicationContext: Sendable, Equatable {
|
||||
public let schemaVersion: Int
|
||||
public let selectedTelevisionID: UUID?
|
||||
public let displayName: String?
|
||||
public let isConnectionReady: Bool
|
||||
|
||||
public init(selectedTelevisionID: UUID?, displayName: String?) {
|
||||
public init(selectedTelevisionID: UUID?, displayName: String?, isConnectionReady: Bool = false) {
|
||||
self.schemaVersion = RelayWire.schemaVersion
|
||||
self.selectedTelevisionID = selectedTelevisionID
|
||||
self.displayName = displayName
|
||||
self.isConnectionReady = isConnectionReady && selectedTelevisionID != nil
|
||||
}
|
||||
|
||||
public func dictionary() throws -> [String: Any] {
|
||||
var result: [String: Any] = ["schemaVersion": schemaVersion]
|
||||
var result: [String: Any] = ["schemaVersion": schemaVersion, "isConnectionReady": isConnectionReady]
|
||||
if let selectedTelevisionID { result["selectedTelevisionID"] = selectedTelevisionID.uuidString }
|
||||
if let displayName { result["displayName"] = String(displayName.prefix(128)) }
|
||||
return result
|
||||
}
|
||||
|
||||
public init(dictionary: [String: Any]) throws {
|
||||
let allowed: Set<String> = ["schemaVersion", "selectedTelevisionID", "displayName"]
|
||||
let allowed: Set<String> = ["schemaVersion", "selectedTelevisionID", "displayName", "isConnectionReady"]
|
||||
guard Set(dictionary.keys).isSubset(of: allowed) else { throw RelayContainerError.extraKeys }
|
||||
guard let version = dictionary["schemaVersion"] as? NSNumber,
|
||||
relayNSNumberIsInteger(version),
|
||||
@@ -127,6 +130,12 @@ public struct RelayApplicationContext: Sendable, Equatable {
|
||||
} else {
|
||||
self.displayName = nil
|
||||
}
|
||||
if let raw = dictionary["isConnectionReady"] {
|
||||
guard let number = raw as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else {
|
||||
throw RelayContainerError.invalidPayload
|
||||
}
|
||||
self.isConnectionReady = number.boolValue && selectedTelevisionID != nil
|
||||
} else { self.isConnectionReady = false }
|
||||
self.schemaVersion = version.intValue
|
||||
}
|
||||
}
|
||||
@@ -168,12 +177,13 @@ public final class RelayReplyOnce: @unchecked Sendable {
|
||||
/// a delegate context. This explicit boundary lets the native delegate hop to
|
||||
/// the main actor without pretending the dictionary itself is Sendable.
|
||||
public final class RelayInboundMessage: @unchecked Sendable {
|
||||
public let message: [String: Any]
|
||||
private let container: RelayContainer?
|
||||
public var message: [String: Any] { container?.dictionary() ?? [:] }
|
||||
public let reply: RelayReplyOnce
|
||||
|
||||
public init(message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
self.message = message
|
||||
self.container = try? RelayContainer(dictionary: message)
|
||||
self.reply = RelayReplyOnce(replyHandler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ public final class WatchRelayBridge: NSObject {
|
||||
public var onStateChange: ((State) -> Void)?
|
||||
|
||||
private let session: any RelaySessionSending
|
||||
private var grant: RemoteSessionGrant?
|
||||
public private(set) var grant: RemoteSessionGrant?
|
||||
private var generation = UUID()
|
||||
private var nextSequence: UInt64 = 0
|
||||
private var activationReady = false
|
||||
private var pendingReplies: [UUID: RelayReplyOnce] = [:]
|
||||
@@ -46,7 +47,9 @@ public final class WatchRelayBridge: NSObject {
|
||||
}
|
||||
|
||||
public func reachabilityDidChange() {
|
||||
guard activationReady, grant == nil else { return }
|
||||
guard activationReady else { return }
|
||||
if !session.isReachable { invalidatePending() }
|
||||
if grant != nil { return }
|
||||
state = .ready(reachable: session.isReachable)
|
||||
}
|
||||
|
||||
@@ -65,10 +68,13 @@ public final class WatchRelayBridge: NSObject {
|
||||
}
|
||||
|
||||
public func beginSession() {
|
||||
guard grant == nil, state != .sessionNegotiating else { return }
|
||||
guard activationReady, session.isReachable, grant == nil else {
|
||||
state = .unavailable
|
||||
return
|
||||
}
|
||||
invalidatePending()
|
||||
let requestGeneration = generation
|
||||
state = .sessionNegotiating
|
||||
let requestID = UUID()
|
||||
let request = BeginRemoteSessionRequest()
|
||||
@@ -77,7 +83,7 @@ public final class WatchRelayBridge: NSObject {
|
||||
return
|
||||
}
|
||||
send(message: message, requestID: requestID) { [weak self] response in
|
||||
guard let self else { return }
|
||||
guard let self, self.generation == requestGeneration, self.session.isReachable else { return }
|
||||
guard let container = try? RelayContainer(dictionary: response),
|
||||
container.kind == .session,
|
||||
let received = try? container.decode(RemoteSessionGrant.self,
|
||||
@@ -93,33 +99,34 @@ public final class WatchRelayBridge: NSObject {
|
||||
}
|
||||
|
||||
public func endSession() {
|
||||
guard activationReady, let grant else { return }
|
||||
let request = EndRemoteSessionRequest(sessionID: grant.sessionID)
|
||||
guard let message = try? RelayContainer(kind: .endSession, value: request).dictionary() else {
|
||||
self.grant = nil
|
||||
state = .ready(reachable: session.isReachable)
|
||||
return
|
||||
}
|
||||
let requestID = UUID()
|
||||
send(message: message, requestID: requestID) { [weak self] response in
|
||||
guard let self else { return }
|
||||
guard self.grant?.sessionID == grant.sessionID else { return }
|
||||
self.grant = nil
|
||||
self.nextSequence = 0
|
||||
self.state = .ready(reachable: self.session.isReachable)
|
||||
let oldGrant = grant
|
||||
invalidatePending()
|
||||
state = activationReady ? .ready(reachable: session.isReachable) : .inactive
|
||||
guard activationReady, session.isReachable, let oldGrant,
|
||||
let message = try? RelayContainer(kind: .endSession, value: EndRemoteSessionRequest(sessionID: oldGrant.sessionID)).dictionary() else { return }
|
||||
// Locally revoke immediately; a delayed end reply cannot revoke a newer grant.
|
||||
send(message: message, requestID: UUID()) { _ in }
|
||||
}
|
||||
|
||||
public func sendCommandResult(_ command: RemoteCommand) async -> Result<CommandReceipt, RelayFailure> {
|
||||
await withCheckedContinuation { continuation in
|
||||
sendCommand(command) { continuation.resume(returning: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// One command is one live sendMessage. A sequence is consumed before the
|
||||
/// send; ambiguous timeout/failure is never replayed.
|
||||
public func sendCommand(_ command: RemoteCommand, now: Date = Date()) {
|
||||
public func sendCommand(_ command: RemoteCommand, now: Date = Date(),
|
||||
completion: @escaping @MainActor (Result<CommandReceipt, RelayFailure>) -> Void = { _ in }) {
|
||||
guard activationReady, session.isReachable, let grant else {
|
||||
state = .unavailable
|
||||
completion(.failure(.init(code: .unavailable)))
|
||||
return
|
||||
}
|
||||
guard nextSequence < UInt64.max else {
|
||||
self.grant = nil
|
||||
state = .unavailable
|
||||
completion(.failure(.init(code: .sessionInvalidated)))
|
||||
return
|
||||
}
|
||||
let sequence = nextSequence
|
||||
@@ -132,17 +139,20 @@ public final class WatchRelayBridge: NSObject {
|
||||
expiresAt: now.addingTimeInterval(lifetime),
|
||||
command: command)
|
||||
guard let message = try? RelayContainer(kind: .command, value: envelope).dictionary() else {
|
||||
completion(.failure(.init(code: .malformedRequest)))
|
||||
return
|
||||
}
|
||||
let requestGeneration = generation
|
||||
send(message: message, requestID: UUID()) { [weak self] response in
|
||||
guard let self,
|
||||
guard let self, self.generation == requestGeneration,
|
||||
let container = try? RelayContainer(dictionary: response),
|
||||
container.kind == .receipt,
|
||||
let receipt = try? container.decode(CommandReceipt.self,
|
||||
expectedKind: .receipt),
|
||||
receipt.id == envelope.id else { return }
|
||||
receipt.id == envelope.id else { completion(.failure(.init(code: .unavailable))); return }
|
||||
completion(.success(receipt))
|
||||
// A receipt is command feedback, not evidence that the TV changed.
|
||||
if receipt.status == .unavailable { self.state = .unavailable }
|
||||
if receipt.status != .sentToTransport { self.invalidatePending(); self.state = .unavailable }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,12 +160,12 @@ public final class WatchRelayBridge: NSObject {
|
||||
response: @escaping @MainActor ([String: Any]) -> Void) {
|
||||
guard pendingReplies.count < 32 else {
|
||||
state = .unavailable
|
||||
response(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||
return
|
||||
}
|
||||
let once = RelayReplyOnce { [weak self] value in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.pendingReplies.removeValue(forKey: requestID) != nil else { return }
|
||||
guard self.activationReady else { return }
|
||||
response(value)
|
||||
}
|
||||
}
|
||||
@@ -172,8 +182,8 @@ public final class WatchRelayBridge: NSObject {
|
||||
}
|
||||
|
||||
private func invalidatePending() {
|
||||
let replies = pendingReplies.values
|
||||
pendingReplies.removeAll()
|
||||
generation = UUID()
|
||||
let replies = Array(pendingReplies.values)
|
||||
for reply in replies { reply.reply(relayErrorReply(RelayFailure(code: .sessionInvalidated))) }
|
||||
grant = nil
|
||||
nextSequence = 0
|
||||
|
||||
@@ -33,8 +33,12 @@ private final class WatchWCSessionSending: RelaySessionSending {
|
||||
@MainActor
|
||||
public final class WatchWCSessionDelegate: NSObject, WCSessionDelegate {
|
||||
public let bridge: WatchRelayBridge
|
||||
public var onContext: ((RelayApplicationContext) -> Void)?
|
||||
public private(set) var latestContext: RelayApplicationContext?
|
||||
private let session: WCSession
|
||||
|
||||
public init(session: WCSession = .default) {
|
||||
self.session = session
|
||||
bridge = WatchRelayBridge(session: WatchWCSessionSending(session: session))
|
||||
super.init()
|
||||
session.delegate = self
|
||||
@@ -48,6 +52,21 @@ public final class WatchWCSessionDelegate: NSObject, WCSessionDelegate {
|
||||
guard let self else { return }
|
||||
if case .inactive = self.bridge.state { } else { self.bridge.didDeactivate() }
|
||||
self.bridge.activationDidComplete(error: error)
|
||||
self.receiveContext(self.session.receivedApplicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveContext(_ dictionary: [String: Any]) {
|
||||
guard let context = try? RelayApplicationContext(dictionary: dictionary) else { return }
|
||||
latestContext = context
|
||||
onContext?(context)
|
||||
}
|
||||
|
||||
nonisolated public func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
guard let context = try? RelayApplicationContext(dictionary: applicationContext) else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
self?.latestContext = context
|
||||
self?.onContext?(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,21 @@ public final class PhoneRelayBridge: NSObject {
|
||||
private let coordinator: any PhoneCommandCoordinating
|
||||
private var peer: WatchPeerGeneration?
|
||||
private var activationReady = false
|
||||
private var lifecycleTask: Task<Void, Never>?
|
||||
private var pending: [UUID: RelayReplyOnce] = [:]
|
||||
private var latestContext: RelayApplicationContext?
|
||||
|
||||
public func publishContext(_ context: RelayApplicationContext) {
|
||||
latestContext = context
|
||||
flushContext()
|
||||
}
|
||||
|
||||
private func flushContext() {
|
||||
guard session.activationState == .activated, let latestContext else { return }
|
||||
if let dictionary = try? latestContext.dictionary() {
|
||||
try? session.updateApplicationContext(dictionary)
|
||||
}
|
||||
}
|
||||
|
||||
public init(session: any RelaySessionSending,
|
||||
dependency: PhoneRelayDependency) {
|
||||
@@ -31,19 +46,24 @@ public final class PhoneRelayBridge: NSObject {
|
||||
}
|
||||
|
||||
public func activationDidComplete(error: Error?) {
|
||||
guard error == nil else {
|
||||
guard error == nil, session.activationState == .activated else {
|
||||
didDeactivate()
|
||||
state = .unavailable
|
||||
activationReady = false
|
||||
return
|
||||
}
|
||||
guard peer == nil else { flushContext(); return }
|
||||
let newPeer = WatchPeerGeneration()
|
||||
peer = newPeer
|
||||
activationReady = false
|
||||
state = .ready(reachable: session.isReachable)
|
||||
Task { [weak self] in
|
||||
let prior = lifecycleTask
|
||||
lifecycleTask = Task { [weak self] in
|
||||
await prior?.value
|
||||
await self?.coordinator.watchBridgeDidActivate(peer: newPeer)
|
||||
guard let self, self.peer == newPeer else { return }
|
||||
self.activationReady = true
|
||||
self.flushContext()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +83,12 @@ public final class PhoneRelayBridge: NSObject {
|
||||
peer = nil
|
||||
activationReady = false
|
||||
state = .inactive
|
||||
Task { await coordinator.watchBridgeDidDeactivate(peer: oldPeer) }
|
||||
for reply in pending.values { reply.reply(relayErrorReply(RelayFailure(code: .unavailable))) }
|
||||
let prior = lifecycleTask
|
||||
lifecycleTask = Task {
|
||||
await prior?.value
|
||||
await coordinator.watchBridgeDidDeactivate(peer: oldPeer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry point for the native WCSession delegate. Every path completes the
|
||||
@@ -71,18 +96,36 @@ public final class PhoneRelayBridge: NSObject {
|
||||
/// out requests.
|
||||
public func receive(message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
let reply = RelayReplyOnce(replyHandler)
|
||||
let inbound = RelayInboundMessage(message: message, replyHandler: replyHandler)
|
||||
guard pending.count < 16 else {
|
||||
inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||
return
|
||||
}
|
||||
let receivedPeer = peer
|
||||
let id = UUID()
|
||||
pending[id] = inbound.reply
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else {
|
||||
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||
inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||
return
|
||||
}
|
||||
await self.route(message: message, reply: reply)
|
||||
guard self.peer == receivedPeer, receivedPeer != nil else {
|
||||
inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||
self.pending.removeValue(forKey: id)
|
||||
return
|
||||
}
|
||||
await self.route(message: inbound.message, reply: inbound.reply)
|
||||
self.pending.removeValue(forKey: id)
|
||||
}
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
guard let self, let reply = self.pending[id] else { return }
|
||||
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||
}
|
||||
}
|
||||
|
||||
private func route(message: [String: Any], reply: RelayReplyOnce) async {
|
||||
guard activationReady, let currentPeer = peer else {
|
||||
guard activationReady, session.activationState == .activated, let currentPeer = peer else {
|
||||
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||
return
|
||||
}
|
||||
@@ -101,9 +144,10 @@ public final class PhoneRelayBridge: NSObject {
|
||||
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
|
||||
return
|
||||
}
|
||||
guard let result = await withTimeout({
|
||||
let result = await ({
|
||||
await self.coordinator.beginWatchSession(request, peer: currentPeer)
|
||||
}) else {
|
||||
})()
|
||||
guard peer == currentPeer, activationReady else {
|
||||
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||
return
|
||||
}
|
||||
@@ -120,9 +164,10 @@ public final class PhoneRelayBridge: NSObject {
|
||||
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
|
||||
return
|
||||
}
|
||||
guard let result = await withTimeout({
|
||||
let result = await ({
|
||||
await self.coordinator.endWatchSession(request, peer: currentPeer)
|
||||
}) else {
|
||||
})()
|
||||
guard peer == currentPeer, activationReady else {
|
||||
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||
return
|
||||
}
|
||||
@@ -139,9 +184,10 @@ public final class PhoneRelayBridge: NSObject {
|
||||
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
|
||||
return
|
||||
}
|
||||
guard let receipt = await withTimeout({
|
||||
let receipt = await ({
|
||||
await self.coordinator.submitWatchCommand(envelope, peer: currentPeer)
|
||||
}) else {
|
||||
})()
|
||||
guard peer == currentPeer, activationReady else {
|
||||
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
|
||||
return
|
||||
}
|
||||
@@ -162,18 +208,4 @@ public final class PhoneRelayBridge: NSObject {
|
||||
relayErrorReply(RelayFailure(code: .malformedRequest))
|
||||
}
|
||||
|
||||
private func withTimeout<Value: Sendable>(
|
||||
_ operation: @escaping @Sendable () async -> Value
|
||||
) async -> Value? {
|
||||
await withTaskGroup(of: Value?.self) { group in
|
||||
group.addTask { await operation() }
|
||||
group.addTask {
|
||||
try? await Task.sleep(for: .milliseconds(1_000))
|
||||
return nil
|
||||
}
|
||||
let result = await group.next() ?? nil
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,3 +70,13 @@ import WatchBridgeCommon
|
||||
_ = try RelayApplicationContext(dictionary: executable)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func readinessContextRequiresBooleanAndSelectedIdentity() throws {
|
||||
let id = UUID()
|
||||
let context = RelayApplicationContext(selectedTelevisionID: id, displayName: "Living room", isConnectionReady: true)
|
||||
#expect(try RelayApplicationContext(dictionary: context.dictionary()) == context)
|
||||
#expect(!RelayApplicationContext(selectedTelevisionID: nil, displayName: nil, isConnectionReady: true).isConnectionReady)
|
||||
#expect(throws: RelayContainerError.self) {
|
||||
try RelayApplicationContext(dictionary: ["schemaVersion": 1, "isConnectionReady": 1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,3 +91,60 @@ private enum RelayTestError: Error { case failed }
|
||||
#expect(bridge.state == .inactive)
|
||||
#expect(fake.sent.count == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func watchReachabilityLossRevokesArmedGrantAndNeverReplays() async throws {
|
||||
let fake = FakeWatchSession()
|
||||
let bridge = WatchRelayBridge(session: fake)
|
||||
bridge.activate(); bridge.activationDidComplete(error: nil); bridge.beginSession()
|
||||
let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date())
|
||||
fake.answer(try RelayContainer(kind: .session, value: grant).dictionary())
|
||||
await Task.yield()
|
||||
#expect(bridge.grant != nil)
|
||||
fake.isReachable = false; bridge.reachabilityDidChange()
|
||||
#expect(bridge.grant == nil)
|
||||
#expect(bridge.state == .ready(reachable: false))
|
||||
fake.isReachable = true; bridge.reachabilityDidChange()
|
||||
bridge.sendCommand(.right)
|
||||
#expect(fake.sent.count == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func lateGrantAfterStopDoesNotRearmAndRepeatedConnectDoesNotDuplicate() async throws {
|
||||
let fake = FakeWatchSession()
|
||||
let bridge = WatchRelayBridge(session: fake)
|
||||
bridge.activate(); bridge.activationDidComplete(error: nil)
|
||||
bridge.beginSession(); bridge.beginSession()
|
||||
#expect(fake.sent.count == 1)
|
||||
let lateReply = fake.reply
|
||||
bridge.endSession()
|
||||
let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date())
|
||||
lateReply?(try RelayContainer(kind: .session, value: grant).dictionary())
|
||||
await Task.yield()
|
||||
#expect(bridge.grant == nil)
|
||||
#expect(bridge.state == .ready(reachable: true))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func invalidationCompletesCommandOnceAndDiscardsLateSuccess() async throws {
|
||||
let fake = FakeWatchSession()
|
||||
let bridge = WatchRelayBridge(session: fake)
|
||||
bridge.activate(); bridge.activationDidComplete(error: nil); bridge.beginSession()
|
||||
let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date())
|
||||
fake.answer(try RelayContainer(kind: .session, value: grant).dictionary())
|
||||
await Task.yield()
|
||||
var completions = 0
|
||||
var succeeded = false
|
||||
bridge.sendCommand(.right) { result in
|
||||
completions += 1
|
||||
if case .success = result { succeeded = true }
|
||||
}
|
||||
let command = try RelayContainer(dictionary: fake.sent.last!).decode(CommandEnvelope.self, expectedKind: .command)
|
||||
let lateReply = fake.reply
|
||||
bridge.didDeactivate()
|
||||
lateReply?(try RelayContainer(kind: .receipt, value: CommandReceipt(id: command.id, status: .sentToTransport)).dictionary())
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
#expect(completions == 1)
|
||||
#expect(!succeeded)
|
||||
#expect(bridge.grant == nil)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ private final class FakePhoneSession: RelaySessionSending {
|
||||
var activationState: RelayActivationState = .notActivated
|
||||
var isReachable = true
|
||||
var sent: [[String: Any]] = []
|
||||
var contexts: [[String: Any]] = []
|
||||
var reply: (([String: Any]) -> Void)?
|
||||
var error: ((Error) -> Void)?
|
||||
func activate() { activationState = .activated }
|
||||
@@ -19,7 +20,7 @@ private final class FakePhoneSession: RelaySessionSending {
|
||||
reply = replyHandler
|
||||
error = errorHandler
|
||||
}
|
||||
func updateApplicationContext(_ context: [String: Any]) throws {}
|
||||
func updateApplicationContext(_ context: [String: Any]) throws { contexts.append(context) }
|
||||
func answer(_ value: [String: Any]) { reply?(value) }
|
||||
}
|
||||
|
||||
@@ -86,3 +87,70 @@ private actor RecordingPhoneCoordinator: PhoneCommandCoordinating {
|
||||
await Task.yield()
|
||||
#expect(replies == 1)
|
||||
}
|
||||
|
||||
private actor SuspendedCoordinator: PhoneCommandCoordinating {
|
||||
var begins = 0
|
||||
var active = false
|
||||
var waiting: [CheckedContinuation<Result<RemoteSessionGrant, RelayFailure>, Never>] = []
|
||||
func watchBridgeDidActivate(peer: WatchPeerGeneration) { active = true }
|
||||
func watchBridgeDidDeactivate(peer: WatchPeerGeneration) { active = false }
|
||||
func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration) async -> Result<RemoteSessionGrant, RelayFailure> {
|
||||
begins += 1
|
||||
return await withCheckedContinuation { waiting.append($0) }
|
||||
}
|
||||
func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration) -> Result<RemoteSessionEnded, RelayFailure> { .failure(RelayFailure(code: .unavailable)) }
|
||||
func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration) -> CommandReceipt { CommandReceipt(id: envelope.id, status: .sentToTransport) }
|
||||
func release() { for continuation in waiting { continuation.resume(returning: .failure(RelayFailure(code: .unavailable))) }; waiting.removeAll() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func timeoutDoesNotWaitForUncooperativeActorAndBoundsOutstandingCalls() async throws {
|
||||
let session = FakePhoneSession()
|
||||
let coordinator = SuspendedCoordinator()
|
||||
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator))
|
||||
bridge.activate(); bridge.activationDidComplete(error: nil)
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
let message = try RelayContainer(kind: .beginSession, value: BeginRemoteSessionRequest()).dictionary()
|
||||
var replies = 0
|
||||
for _ in 0..<20 { bridge.receive(message: message) { _ in replies += 1 } }
|
||||
try await Task.sleep(for: .milliseconds(1_150))
|
||||
#expect(replies == 20)
|
||||
#expect(await coordinator.begins == 16)
|
||||
bridge.receive(message: message) { _ in replies += 1 }
|
||||
#expect(replies == 21)
|
||||
#expect(await coordinator.begins == 16)
|
||||
await coordinator.release()
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
#expect(replies == 21)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func deactivationCompletesPendingRepliesBeforeActorReturns() async throws {
|
||||
let session = FakePhoneSession()
|
||||
let coordinator = SuspendedCoordinator()
|
||||
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator))
|
||||
bridge.activate(); bridge.activationDidComplete(error: nil)
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
var replies = 0
|
||||
bridge.receive(message: try RelayContainer(kind: .beginSession, value: BeginRemoteSessionRequest()).dictionary()) { _ in replies += 1 }
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
bridge.didDeactivate()
|
||||
#expect(replies == 1)
|
||||
await coordinator.release()
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
#expect(replies == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func latestContextPublishedBeforeActivationIsFlushed() async throws {
|
||||
let session = FakePhoneSession()
|
||||
let coordinator = RecordingPhoneCoordinator()
|
||||
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator))
|
||||
let context = RelayApplicationContext(selectedTelevisionID: UUID(), displayName: "TV", isConnectionReady: true)
|
||||
bridge.publishContext(context)
|
||||
#expect(session.contexts.isEmpty)
|
||||
bridge.activate(); bridge.activationDidComplete(error: nil)
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
#expect(session.contexts.count == 1)
|
||||
#expect(try RelayApplicationContext(dictionary: session.contexts[0]) == context)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user