From 76fb9aa92553a7dcad34392b66867ff13e9ea7f1 Mon Sep 17 00:00:00 2001 From: David Bishop Date: Wed, 9 Sep 2026 18:22:14 -0700 Subject: [PATCH 1/3] Implement pairing rounds and strengthen Sendspin delivery semantics Update round-bound CPace identifiers and stable-code retries, add client/leave, and support a pairing connection alongside playback with isolated delivery and safe promotion. Apply output delay in local time with drift-aware correction, bound visualizer delivery and lifecycle cleanup, and expand protocol, crypto, timing, and concurrency regressions. --- README.md | 21 +- .../Articles/GettingStarted.md | 14 + Sources/SendspinKit/Audio/AudioEngine.swift | 86 +++- Sources/SendspinKit/Audio/AudioOutput.swift | 4 + Sources/SendspinKit/Audio/AudioPlayer.swift | 75 +++- .../SendspinKit/Audio/AudioScheduler.swift | 17 +- .../SendspinKit/Audio/DataPlaneCommand.swift | 2 +- .../SendspinKit/Audio/NoOpAudioOutput.swift | 2 + .../SendspinKit/Audio/SyncCorrection.swift | 9 + Sources/SendspinKit/Client/ClientTypes.swift | 6 + .../Client/ConnectionActivationGate.swift | 60 +++ .../Client/ConnectionDataDelivery.swift | 68 +++ .../Client/SendspinClient+Commands.swift | 33 +- .../Client/SendspinClient+MultiServer.swift | 21 + .../SendspinClient+PairingCoordinator.swift | 173 +++++++ .../SendspinKit/Client/SendspinClient.swift | 222 +++++++-- .../Client/SendspinConnection+Leave.swift | 14 + .../Client/SendspinConnection+Lifecycle.swift | 10 + .../SendspinConnection+MessageHandling.swift | 254 ++++++++--- .../Client/SendspinConnection+Outbound.swift | 19 +- .../SendspinConnection+PairingQuery.swift | 59 +++ .../Client/SendspinConnection.swift | 16 +- .../Client/SendspinPersistenceProvider.swift | 28 +- .../Client/SessionValidityToken.swift | 8 + .../Client/VisualizerDataDelivery.swift | 322 +++++++++++++ Sources/SendspinKit/Crypto/CPace.swift | 17 +- Sources/SendspinKit/Models/LeaveMessage.swift | 19 + .../SendspinKit/Models/PairingMessages.swift | 13 +- .../Audio/AudioEngineTests.swift | 26 +- .../Audio/AudioPlayerTests.swift | 79 ++-- .../Audio/SyncCorrectionTests.swift | 32 ++ .../AudioSchedulerTests.swift | 51 ++- .../Client/ConcurrentPairingTests.swift | 348 ++++++++++++++ .../Client/DigitAudioPairingTests.swift | 6 +- .../DynamicPairingTranscriptTests.swift | 112 +++-- .../Client/RehandshakeTests.swift | 24 + .../Client/SendspinClientTests.swift | 5 +- .../Client/VisualizerDataDeliveryTests.swift | 425 ++++++++++++++++++ .../SendspinKitTests/Crypto/CPaceTests.swift | 11 +- .../Integration/LeaveGroupTests.swift | 170 +++++++ .../Integration/ProtocolBoundaryTests.swift | 337 ++++++++++++++ .../Models/MessageEncodingTests.swift | 11 + .../Resources/cpace-mcf-known-answer.json | 36 +- .../TimeFilterSnapshotTests.swift | 12 + docs/VISUALIZER_DELIVERY.md | 23 + docs/audio-timing-model.md | 7 +- 46 files changed, 3028 insertions(+), 279 deletions(-) create mode 100644 Sources/SendspinKit/Client/ConnectionActivationGate.swift create mode 100644 Sources/SendspinKit/Client/ConnectionDataDelivery.swift create mode 100644 Sources/SendspinKit/Client/SendspinClient+PairingCoordinator.swift create mode 100644 Sources/SendspinKit/Client/SendspinConnection+Leave.swift create mode 100644 Sources/SendspinKit/Client/VisualizerDataDelivery.swift create mode 100644 Sources/SendspinKit/Models/LeaveMessage.swift create mode 100644 Tests/SendspinKitTests/Client/ConcurrentPairingTests.swift create mode 100644 Tests/SendspinKitTests/Client/VisualizerDataDeliveryTests.swift create mode 100644 Tests/SendspinKitTests/Integration/LeaveGroupTests.swift create mode 100644 Tests/SendspinKitTests/Integration/ProtocolBoundaryTests.swift create mode 100644 docs/VISUALIZER_DELIVERY.md diff --git a/README.md b/README.md index 7afac61..b88a98f 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,20 @@ to select a supported audio format, and `setArtworkChannelPreference(channel:pre `ArtworkChannelPreference.set(source:format:width:height:)` or `.disable` to update an artwork channel. These preferences can be changed while connected and apply to the active or next stream. +### Group membership and external sources + +A client can leave its current group without requiring the controller role: + +```swift +try await client.leaveGroup() +``` + +This sends `client/leave` with an empty payload. The server stops playback for this client and +places it in a solo group; the client does not invent or clear local group state, and returning to +the previous group requires an explicit server-directed group change. For non-interruptible local +playback, use `enterExternalSource()` and `exitExternalSource()` instead. Exiting an external source +makes the client available again but does not automatically rejoin its previous group. + ## Pairing codes Pairing-code flows are app-facing setup hooks. Enable a method in `PairingConfiguration`, then @@ -203,9 +217,10 @@ rate, conditional `tracks_downbeats`, and spectrum parameters are exposed by the delivered through `client.visualizerData`; each `VisualizerData` includes its `type`, raw payload, local display deadline, and a stream-generation validity token. Consumers must check `frame.isRenderable` immediately before drawing: this rejects frames invalidated by -`stream/clear`, `stream/end`, configuration changes, or session replacement, and also rejects -frames whose display deadline has become stale while queued. Frames whose translated deadline -has already passed at arrival are discarded. +`stream/clear`, `stream/end`, or session replacement, and also rejects frames whose display +deadline has become stale while queued. Each frame retains the negotiated configuration that +validated it, including across an in-place configuration update. Frames whose translated +deadline has already passed at arrival are discarded. ```swift let visualizer = try SendspinClient( diff --git a/Sources/SendspinKit.docc/Articles/GettingStarted.md b/Sources/SendspinKit.docc/Articles/GettingStarted.md index 54b1882..f250657 100644 --- a/Sources/SendspinKit.docc/Articles/GettingStarted.md +++ b/Sources/SendspinKit.docc/Articles/GettingStarted.md @@ -47,6 +47,20 @@ include ``SpectrumConfiguration`` whenever the requested types contain ``Visuali These role configurations seed the initial `client/state` snapshot; dynamic preference changes use the corresponding state-preference APIs. +## Leave a group + +Any client role can leave its current server group: + +```swift +try await client.leaveGroup() +``` + +This sends `client/leave` with an empty payload. The server places the client in a stopped solo group; +SendspinKit does not invent or clear local group state, and returning to the previous group requires an +explicit server-directed group change. For non-interruptible local playback, use +``SendspinClient/enterExternalSource()`` and ``SendspinClient/exitExternalSource()``. Exiting an external +source makes the client available again but does not automatically rejoin its previous group. + ## Connect to a server There are two connection patterns: diff --git a/Sources/SendspinKit/Audio/AudioEngine.swift b/Sources/SendspinKit/Audio/AudioEngine.swift index 2d17196..cfd9f40 100644 --- a/Sources/SendspinKit/Audio/AudioEngine.swift +++ b/Sources/SendspinKit/Audio/AudioEngine.swift @@ -49,9 +49,26 @@ actor AudioEngine { private var playbackTimeline = AudioChunkPlaybackTimeline() private var playbackTimelineTransitionEnabled = false - /// Output delay in milliseconds (subtracted from scheduled timestamps) + /// Output delay in milliseconds, applied in the local scheduling domain. private var outputDelayMs: Int = 0 + /// Map a server timestamp into the local scheduling domain, applying output delay once. + /// Server-domain metadata and correction cursors continue to use the original timestamp. + static func localPlayTime(mappedLocalTime: Int64, outputDelayMicroseconds: Int64) -> Int64? { + let result = mappedLocalTime.subtractingReportingOverflow(max(0, outputDelayMicroseconds)) + return result.overflow ? nil : result.partialValue + } + + private static func outputDelayMicroseconds(_ milliseconds: Int) -> Int64 { + let result = Int64(max(0, milliseconds)).multipliedReportingOverflow(by: 1_000) + return result.overflow ? Int64.max : result.partialValue + } + + private static func localDelayShift(from oldDelayUs: Int64, to newDelayUs: Int64) -> Int64 { + let result = oldDelayUs.subtractingReportingOverflow(newDelayUs) + return result.overflow ? (oldDelayUs >= newDelayUs ? Int64.max : Int64.min) : result.partialValue + } + // Task tracking for shutdown private var drainTask: Task? private var startupCoordinatorTask: Task? @@ -201,7 +218,7 @@ actor AudioEngine { private struct StartupBufferedChunk { let pcmData: Data - let playTimeMicroseconds: Int64 + var playTimeMicroseconds: Int64 let originalTimestamp: Int64 let generation: UInt64 } @@ -385,6 +402,8 @@ actor AudioEngine { self.reportContinuation = reportContinuation startupBufferingEnabled = true startupMinBufferUs = Int64(config.minBufferMs) * 1_000 + outputDelayMs = config.initialOutputDelayMs + _commandsSink.enqueue(.setOutputDelay(config.initialOutputDelayMs)) } // MARK: - Public interface @@ -713,7 +732,13 @@ actor AudioEngine { await applyStreamEnd(roles: roles) case let .setOutputDelay(delayMs): + let oldDelayUs = Self.outputDelayMicroseconds(outputDelayMs) + let newDelayUs = Self.outputDelayMicroseconds(delayMs) outputDelayMs = delayMs + await audioScheduler.rebaseOutputDelay(from: oldDelayUs, to: newDelayUs) + rebaseStartupChunks(from: oldDelayUs, to: newDelayUs) + playbackTimeline.rebaseOutputDelay(from: oldDelayUs, to: newDelayUs) + await output.setOutputDelayMicroseconds(newDelayUs) } } @@ -772,6 +797,22 @@ actor AudioEngine { } } + private static func rebase(_ chunks: inout [StartupBufferedChunk], from oldDelayUs: Int64, to newDelayUs: Int64) { + let shift = localDelayShift(from: oldDelayUs, to: newDelayUs) + guard shift != 0 else { return } + for index in chunks.indices { + chunks[index].playTimeMicroseconds = chunks[index].playTimeMicroseconds.saturatingAdding(shift) + } + } + + private func rebaseStartupChunks(from oldDelayUs: Int64, to newDelayUs: Int64) { + if var startupBuffer { + Self.rebase(&startupBuffer.chunks, from: oldDelayUs, to: newDelayUs) + self.startupBuffer = startupBuffer + } + Self.rebase(&startupReleaseDeferredChunks, from: oldDelayUs, to: newDelayUs) + } + /// Schedule a chunk for playback. private func applyChunk(data: Data, ts: Int64, generation: UInt64?, routeEpoch: UInt64? = nil) async { if let generation, generation < streamGeneration { @@ -791,14 +832,9 @@ actor AudioEngine { sampleRate: format.sampleRate ) } - // `ts` is unvalidated wire data; a hostile or buggy server can put it near - // the Int64 bounds where the delay adjustment would trap. - let delayed = ts.subtractingReportingOverflow(Int64(outputDelayMs) * 1_000) - guard !delayed.overflow else { - Log.audio.warning("Dropping chunk with an unrepresentable timestamp") - return - } - let adjustedTs = delayed.partialValue + // `ts` stays in the server domain for cursor, metadata, and cadence diagnostics. + // Output delay is local-domain correction applied only after clock mapping. + let localDelayUs = Self.outputDelayMicroseconds(outputDelayMs) let frameSize = chunkTimingFormat.map { $0.channels * ($0.effectiveOutputBitDepth / 8) } ?? 1 @@ -806,11 +842,18 @@ actor AudioEngine { let decodedDurationUs = Int64( (Double(pcm.count / max(1, frameSize)) * 1_000_000.0 / Double(sampleRate)).rounded() ) - let wirePlayTime = await clock.serverTimeToLocal(adjustedTs) + let mappedLocalTime = await clock.serverTimeToLocal(ts) + guard let wirePlayTime = Self.localPlayTime( + mappedLocalTime: mappedLocalTime, + outputDelayMicroseconds: localDelayUs + ) else { + Log.audio.warning("Dropping chunk with an unrepresentable local play time") + return + } let playTime: Int64 if playbackTimelineTransitionEnabled { let timeline = playbackTimeline.playTime( - wireTimestampUs: adjustedTs, + wireTimestampUs: ts, wirePlayTimeUs: wirePlayTime, decodedDurationUs: max(1, decodedDurationUs) ) @@ -828,7 +871,7 @@ actor AudioEngine { startupReleaseDeferredChunks.append(StartupBufferedChunk( pcmData: pcm, playTimeMicroseconds: playTime, - originalTimestamp: adjustedTs, + originalTimestamp: ts, generation: chunkGeneration )) return @@ -843,7 +886,7 @@ actor AudioEngine { startupBuffer?.chunks.append(StartupBufferedChunk( pcmData: pcm, playTimeMicroseconds: playTime, - originalTimestamp: adjustedTs, + originalTimestamp: ts, generation: chunkGeneration )) if !startupReleaseInProgress { @@ -852,13 +895,18 @@ actor AudioEngine { } else { await audioScheduler.schedule( pcm: pcm, - serverTimestamp: adjustedTs, + serverTimestamp: ts, playTimeMicroseconds: playTime, generation: chunkGeneration ) } } else { - await audioScheduler.schedule(pcm: pcm, serverTimestamp: adjustedTs, generation: chunkGeneration) + await audioScheduler.schedule( + pcm: pcm, + serverTimestamp: ts, + playTimeMicroseconds: playTime, + generation: chunkGeneration + ) } } catch { // Per-chunk decode failures are silent; stream-start failures are reported separately. @@ -891,6 +939,7 @@ actor AudioEngine { // Claim the buffer before the first await. This is the single-flight boundary: later // chunk arrivals go to `startupReleaseDeferredChunks`, never to a second release. startupBuffer = nil + var bufferDelayUs = Self.outputDelayMicroseconds(outputDelayMs) buffer.chunks.sort { $0.playTimeMicroseconds < $1.playTimeMicroseconds } // Releasing into a device that has not begun producing hands PCM to a pipeline that // is not consuming. The coordinator waits for the device transition once, then resumes @@ -911,6 +960,9 @@ actor AudioEngine { var currentBuffer = startupBuffer ?? buffer currentBuffer.chunks.append(contentsOf: startupReleaseDeferredChunks) startupReleaseDeferredChunks.removeAll(keepingCapacity: true) + let currentDelayUs = Self.outputDelayMicroseconds(outputDelayMs) + Self.rebase(¤tBuffer.chunks, from: bufferDelayUs, to: currentDelayUs) + bufferDelayUs = currentDelayUs buffer = currentBuffer guard startupReleaseInProgress, startupSequence == sequence, !outputHasStarted else { let invalidatedLog = "startup release invalidated engine=\(engineID) sequence=\(sequence) invocation=\(invocation) stage=device-probe" @@ -1004,6 +1056,8 @@ actor AudioEngine { var deferred: [StartupBufferedChunk] = [] do { + Self.rebase(&buffer.chunks, from: bufferDelayUs, to: Self.outputDelayMicroseconds(outputDelayMs)) + bufferDelayUs = Self.outputDelayMicroseconds(outputDelayMs) for chunk in buffer.chunks where chunk.playTimeMicroseconds <= releaseHorizon { guard startupReleaseInProgress, startupSequence == sequence else { let invalidatedLog = "startup priming invalidated engine=\(engineID) sequence=\(sequence) invocation=\(invocation) stage=pcm" diff --git a/Sources/SendspinKit/Audio/AudioOutput.swift b/Sources/SendspinKit/Audio/AudioOutput.swift index bda553f..bc5badd 100644 --- a/Sources/SendspinKit/Audio/AudioOutput.swift +++ b/Sources/SendspinKit/Audio/AudioOutput.swift @@ -630,6 +630,10 @@ protocol AudioOutput: Actor, Sendable { /// device path. Valid once `prepare(format:codecHeader:)` has run. func pipelineLatencyMicroseconds() -> Int64 + /// Additional local-domain output delay applied to scheduled chunks. + /// This is included in render-correction equilibrium but never changes server timestamps. + func setOutputDelayMicroseconds(_ delay: Int64) + /// Wait until the output device has actually begun producing. Releasing before this /// buffers audio into a pipeline that is not yet consuming. func waitUntilOutputDeviceIsLive() async throws diff --git a/Sources/SendspinKit/Audio/AudioPlayer.swift b/Sources/SendspinKit/Audio/AudioPlayer.swift index 59575bc..9afccc6 100644 --- a/Sources/SendspinKit/Audio/AudioPlayer.swift +++ b/Sources/SendspinKit/Audio/AudioPlayer.swift @@ -106,6 +106,10 @@ private struct LockedState: @unchecked Sendable { /// The audio thread cannot query the HAL, so the value is read once and stored here. var deviceLatencyUs: Int64 = 0 + /// Runtime delay beyond the local output port, in the local clock domain. + /// It shifts scheduling earlier; cursor timestamps remain in server time. + var outputDelayUs: Int64 = 0 + /// Absolute time `AudioQueueStart` was called, or 0 before it has been. var queueStartAbsoluteUs: Int64 = 0 @@ -510,6 +514,10 @@ actor AudioPlayer { return queueDepthUs + lockedState.withLock { $0.deviceLatencyUs } } + func setOutputDelayMicroseconds(_ delay: Int64) { + lockedState.withLock { $0.outputDelayUs = max(0, delay) } + } + /// Wait until the device has delivered its first callback. Before that, PCM released on /// schedule sits in the ring and plays stale; spin-up is usually sub-second but has been /// measured at 13 seconds. @@ -979,6 +987,21 @@ actor AudioPlayer { let enqueue: Bool } + /// Server-time cursor target for a frame handed to hardware at `localNow`. + /// All latency is local-domain time before one exact snapshot inverse mapping. Saturating + /// arithmetic keeps malformed snapshots or extreme latency values from trapping the callback. + static func correctionEquilibriumServerTime( + snapshot: TimeFilterSnapshot, + localNow: Int64, + pipelineLatencyUs: Int64, + outputDelayUs: Int64 + ) -> Int64 { + let audibleLocalTime = localNow + .saturatingAdding(max(0, pipelineLatencyUs)) + .saturatingAdding(max(0, outputDelayUs)) + return snapshot.localTimeToServer(audibleLocalTime) + } + private static func updateCorrectionSchedule( state: inout LockedState, capacity: Int, @@ -987,19 +1010,22 @@ actor AudioPlayer { ) { guard state.cursorMicroseconds > 0, let snapshot = state.timeSnapshot else { return } let nowAbsolute = MonotonicClock.absoluteMicroseconds() - let expectedServerTime = snapshot.localTimeToServer(nowAbsolute) // Everything between pulling a frame here and hearing it: every primed buffer — so this // must use the same count `prepare()` primes — plus the device path beyond them. let queueDepthUs = Int64(audioQueueBufferCount * capacity) * 1_000_000 / Int64(sampleRate * frameSize) - let aqLatencyUs = queueDepthUs + state.deviceLatencyUs + let equilibriumServerTime = correctionEquilibriumServerTime( + snapshot: snapshot, + localNow: nowAbsolute, + pipelineLatencyUs: queueDepthUs + state.deviceLatencyUs, + outputDelayUs: state.outputDelayUs + ) // A frame pulled here is audible `aqLatencyUs` from now, and must be audible at - // `local(cursor)` — so in equilibrium the cursor LEADS `expectedServerTime` by that - // latency. Subtracting it instead of adding puts the reported error `2 * aqLatencyUs` - // from the truth, which makes every change to the latency model move the equilibrium - // by twice the change. Positive means late: the cursor is behind where it should be. - let syncErrorUs = (expectedServerTime + aqLatencyUs) - state.cursorMicroseconds + // `local(cursor)` — so in equilibrium the cursor LEADS the mapped local instant by + // that latency. The shared helper maps the complete local target through the same + // nonzero-drift snapshot used by scheduling. + let syncErrorUs = equilibriumServerTime - state.cursorMicroseconds state.lastSyncErrorUs = syncErrorUs let framesInBuffer = capacity / frameSize @@ -1016,8 +1042,10 @@ actor AudioPlayer { // callback correct only real drift. state.startupOffsetUs = syncErrorUs state.cursorMicroseconds = graceExpiryRebaselineCursor( - expectedServerTime: expectedServerTime, - audioQueueLatencyUs: aqLatencyUs + snapshot: snapshot, + localNow: nowAbsolute, + pipelineLatencyUs: queueDepthUs + state.deviceLatencyUs, + outputDelayUs: state.outputDelayUs ) state.cursorRemainder = 0 state.correctionSchedule = CorrectionSchedule() @@ -1041,12 +1069,13 @@ actor AudioPlayer { if newSchedule.reanchor { // Can't reset the ring buffer and cursor here safely while iterating, // so signal the actor to handle it on the next poll. - // The cursor leads by the pipeline latency in equilibrium, so a reanchor must - // target that, not bare `expectedServerTime` — which would leave the very next - // callback reporting the latency itself as error. + // The cursor leads by the physical and local delay in equilibrium, so a reanchor + // must use the shared mapped target rather than a bare current-time conversion. state.pendingReanchorServerTime = graceExpiryRebaselineCursor( - expectedServerTime: expectedServerTime, - audioQueueLatencyUs: aqLatencyUs + snapshot: snapshot, + localNow: nowAbsolute, + pipelineLatencyUs: queueDepthUs + state.deviceLatencyUs, + outputDelayUs: state.outputDelayUs ) state.reanchorRequested = true state.correctionSchedule = CorrectionSchedule() @@ -1276,8 +1305,8 @@ actor AudioPlayer { /// Cursor position that makes the sync-error formula evaluate to equilibrium /// at the startup correction-grace handoff. /// - /// Cursor position at which the sync-error formula reads zero — the equilibrium in - /// which the cursor leads `expectedServerTime` by the pipeline latency. + /// Cursor position at which the sync-error formula reads zero: the shared snapshot mapping + /// of the local time after physical pipeline and commanded local output delay. /// /// While startup grace is open, correction is intentionally disabled so AudioQueue /// callback/cursor bookkeeping can settle without pitch-shifting output. On the @@ -1287,8 +1316,18 @@ actor AudioPlayer { /// This *asserts* the equilibrium rather than measuring it, so whatever misalignment /// exists at grace expiry becomes permanent and subsequently reads as perfect sync. /// `TelemetrySnapshot.startupOffsetUs` is the only place that misalignment is visible. - static func graceExpiryRebaselineCursor(expectedServerTime: Int64, audioQueueLatencyUs: Int64) -> Int64 { - expectedServerTime + audioQueueLatencyUs + static func graceExpiryRebaselineCursor( + snapshot: TimeFilterSnapshot, + localNow: Int64, + pipelineLatencyUs: Int64, + outputDelayUs: Int64 + ) -> Int64 { + correctionEquilibriumServerTime( + snapshot: snapshot, + localNow: localNow, + pipelineLatencyUs: pipelineLatencyUs, + outputDelayUs: outputDelayUs + ) } /// Convert linear volume (0.0-1.0) to perceptual amplitude. diff --git a/Sources/SendspinKit/Audio/AudioScheduler.swift b/Sources/SendspinKit/Audio/AudioScheduler.swift index a32f62f..360cbc1 100644 --- a/Sources/SendspinKit/Audio/AudioScheduler.swift +++ b/Sources/SendspinKit/Audio/AudioScheduler.swift @@ -16,8 +16,9 @@ struct SchedulerStats: Equatable { /// via `generation` for seamless format transitions. struct ScheduledChunk { let pcmData: Data - /// Local absolute time in microseconds (from MonotonicClock) when this chunk should play - let playTimeMicroseconds: Int64 + /// Local absolute time in microseconds (from MonotonicClock) when this chunk should play. + /// Output delay is already included; `originalTimestamp` remains in server time. + var playTimeMicroseconds: Int64 let originalTimestamp: Int64 /// Stream generation — incremented on format changes so the output loop /// can distinguish old-format from new-format chunks. @@ -184,6 +185,18 @@ actor AudioScheduler { chunkContinuation.finish() } + /// Shift all queued local play times when the local output delay changes. + /// Wire timestamps stay unchanged; a uniform shift preserves queue order. Chunks already + /// yielded through `scheduledChunks` are immutable and are corrected by the render path. + func rebaseOutputDelay(from oldDelayUs: Int64, to newDelayUs: Int64) { + let shift = oldDelayUs.subtractingReportingOverflow(newDelayUs) + let delta = shift.overflow ? (oldDelayUs >= newDelayUs ? Int64.max : Int64.min) : shift.partialValue + guard delta != 0 else { return } + for index in readIndex ..< queue.count { + queue[index].playTimeMicroseconds = queue[index].playTimeMicroseconds.saturatingAdding(delta) + } + } + /// Clear all queued chunks. Values already yielded by AsyncStream are rejected by the /// engine lifecycle token; the scheduler itself has no format commit gate. func clear() { diff --git a/Sources/SendspinKit/Audio/DataPlaneCommand.swift b/Sources/SendspinKit/Audio/DataPlaneCommand.swift index 60b8145..f8a5440 100644 --- a/Sources/SendspinKit/Audio/DataPlaneCommand.swift +++ b/Sources/SendspinKit/Audio/DataPlaneCommand.swift @@ -119,7 +119,7 @@ enum DataPlaneCommand { /// Change format at an explicitly announced input generation. case formatChangeAtGeneration(AudioFormatSpec, codecHeader: Data?, generation: UInt64) - /// Set output delay in milliseconds (subtracted from scheduled timestamps). + /// Set output delay in milliseconds (subtracted from local scheduled play times). case setOutputDelay(Int) } diff --git a/Sources/SendspinKit/Audio/NoOpAudioOutput.swift b/Sources/SendspinKit/Audio/NoOpAudioOutput.swift index 7b77ed9..a4e0e4b 100644 --- a/Sources/SendspinKit/Audio/NoOpAudioOutput.swift +++ b/Sources/SendspinKit/Audio/NoOpAudioOutput.swift @@ -39,6 +39,8 @@ actor NoOpAudioOutput: AudioOutput { 0 } + func setOutputDelayMicroseconds(_: Int64) {} + func startupLeadMicroseconds() -> Int64 { 0 } diff --git a/Sources/SendspinKit/Audio/SyncCorrection.swift b/Sources/SendspinKit/Audio/SyncCorrection.swift index 23b3164..8ea9786 100644 --- a/Sources/SendspinKit/Audio/SyncCorrection.swift +++ b/Sources/SendspinKit/Audio/SyncCorrection.swift @@ -32,6 +32,15 @@ struct AudioChunkPlaybackTimeline { private var previousDecodedDurationUs: Int64? private(set) var usesDecodedTimeline = false + /// Shift the local anchor when output delay changes. Wire timestamps and decoded cadence remain + /// untouched; only the local instant carried by this timeline moves once. + mutating func rebaseOutputDelay(from oldDelayUs: Int64, to newDelayUs: Int64) { + let delta = oldDelayUs.subtractingReportingOverflow(newDelayUs) + let shift = delta.overflow ? (oldDelayUs >= newDelayUs ? Int64.max : Int64.min) : delta.partialValue + guard shift != 0 else { return } + previousPlayTimeUs = previousPlayTimeUs?.saturatingAdding(shift) + } + /// Return the local play time for one decoded chunk. Once a material cadence mismatch is /// observed, remain on the decoded-duration timeline for the rest of this stream generation; /// switching back to wire timestamps mid-buffer would reintroduce a discontinuity. diff --git a/Sources/SendspinKit/Client/ClientTypes.swift b/Sources/SendspinKit/Client/ClientTypes.swift index 4101eac..23a6655 100644 --- a/Sources/SendspinKit/Client/ClientTypes.swift +++ b/Sources/SendspinKit/Client/ClientTypes.swift @@ -171,6 +171,9 @@ public struct VisualizerData: Sendable, Equatable { public let data: Data /// Local absolute display time in microseconds. public let localDisplayTime: Int64 + /// The negotiated configuration used to validate this frame. Consumers should + /// use this snapshot when rendering queued frames after a configuration update. + public let streamConfiguration: VisualizerStreamConfiguration? /// Stream-generation validity. Check this immediately before rendering. public let validity: VisualizerFrameValidity @@ -188,16 +191,19 @@ public struct VisualizerData: Sendable, Equatable { type: VisualizerType, data: Data, localDisplayTime: Int64, + streamConfiguration: VisualizerStreamConfiguration? = nil, validity: VisualizerFrameValidity = VisualizerFrameValidity() ) { self.type = type self.data = data self.localDisplayTime = localDisplayTime + self.streamConfiguration = streamConfiguration self.validity = validity } public static func == (lhs: VisualizerData, rhs: VisualizerData) -> Bool { lhs.type == rhs.type && lhs.data == rhs.data && lhs.localDisplayTime == rhs.localDisplayTime + && lhs.streamConfiguration == rhs.streamConfiguration && lhs.validity === rhs.validity } } diff --git a/Sources/SendspinKit/Client/ConnectionActivationGate.swift b/Sources/SendspinKit/Client/ConnectionActivationGate.swift new file mode 100644 index 0000000..e07c030 --- /dev/null +++ b/Sources/SendspinKit/Client/ConnectionActivationGate.swift @@ -0,0 +1,60 @@ +import Foundation + +struct ConnectionActivationProposal: Sendable { + let token: UUID + let activities: Set + let activeRoles: Set +} + +enum ConnectionActivationVerdict: Sendable { + case admit + case reject(GoodbyeReason) +} + +/// A continuation-backed admission boundary for activations that can change a +/// parked pairing connection into a competing playback connection. +final class ConnectionActivationGate: @unchecked Sendable { + private let lock = NSLock() + private let requestContinuation: AsyncStream.Continuation + let requests: AsyncStream + private var pending: [UUID: CheckedContinuation] = [:] + + init() { + (requests, requestContinuation) = AsyncStream.makeStream() + } + + func request( + activities: Set, + activeRoles: Set + ) async -> ConnectionActivationVerdict { + let token = UUID() + return await withCheckedContinuation { continuation in + lock.lock() + pending[token] = continuation + lock.unlock() + requestContinuation.yield(ConnectionActivationProposal( + token: token, + activities: activities, + activeRoles: activeRoles + )) + } + } + + func resolve(_ token: UUID, verdict: ConnectionActivationVerdict) { + lock.lock() + let continuation = pending.removeValue(forKey: token) + lock.unlock() + continuation?.resume(returning: verdict) + } + + func cancel() { + lock.lock() + let continuations = pending.values + pending.removeAll() + lock.unlock() + for continuation in continuations { + continuation.resume(returning: .reject(.concurrentAttempt)) + } + requestContinuation.finish() + } +} diff --git a/Sources/SendspinKit/Client/ConnectionDataDelivery.swift b/Sources/SendspinKit/Client/ConnectionDataDelivery.swift new file mode 100644 index 0000000..fca13bf --- /dev/null +++ b/Sources/SendspinKit/Client/ConnectionDataDelivery.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Selects whether a connection's role data is visible on the facade. +enum ConnectionDeliveryMode: Sendable { + case parked + case primary +} + +/// Owns role-data destinations for one connection. A parked connection consumes +/// protocol data but suppresses public delivery until the facade promotes it. +final class ConnectionDataDelivery: @unchecked Sendable { + private let lock = NSLock() + private var mode: ConnectionDeliveryMode = .parked + + private let audio: AsyncStream.Continuation + private let artwork: AsyncStream.Continuation + private let visualizer: VisualizerDataMailbox + private let artworkObserver: (@Sendable (ArtworkData) -> Void)? + + init( + audio: AsyncStream.Continuation, + artwork: AsyncStream.Continuation, + visualizer: VisualizerDataMailbox, + artworkObserver: (@Sendable (ArtworkData) -> Void)? + ) { + self.audio = audio + self.artwork = artwork + self.visualizer = visualizer + self.artworkObserver = artworkObserver + } + + func promoteToPrimary() { + lock.withLock { mode = .primary } + } + + func park() { + lock.withLock { mode = .parked } + } + + func yieldAudioIfValid(_ value: AudioChunk, validity: SessionValidityToken) { + lock.withLock { + guard mode == .primary else { return } + validity.yieldIfValid(value, to: audio) + } + } + + func yieldArtworkIfValid(_ value: ArtworkData, validity: SessionValidityToken) { + lock.withLock { + guard mode == .primary else { return } + artworkObserver?(value) + validity.yieldIfValid(value, to: artwork) + } + } + + func offerVisualizerIfValid(_ value: VisualizerData, validity: SessionValidityToken) { + lock.withLock { + guard mode == .primary else { return } + validity.offerIfValid(value, to: visualizer) + } + } + + func clearVisualizer() { + lock.withLock { + guard mode == .primary else { return } + visualizer.clear() + } + } +} diff --git a/Sources/SendspinKit/Client/SendspinClient+Commands.swift b/Sources/SendspinKit/Client/SendspinClient+Commands.swift index 07bd3a9..f97b7d9 100644 --- a/Sources/SendspinKit/Client/SendspinClient+Commands.swift +++ b/Sources/SendspinKit/Client/SendspinClient+Commands.swift @@ -18,6 +18,9 @@ public extension SendspinClient { /// /// Call ``exitExternalSource()`` to return to normal operation. /// + /// This is the non-interruptible external-source path: the client remains + /// unavailable while the external activity owns its output. + /// /// - Throws: ``SendspinClientError/notConnected`` if not connected, /// or ``SendspinClientError/sendFailed(_:)`` if the server notification fails. @MainActor @@ -30,9 +33,9 @@ public extension SendspinClient { /// Return to normal synchronized operation after ``enterExternalSource()``. /// - /// Tells the server this client is ready to receive audio again. - /// The server will typically move the client back into its previous group - /// via `group/update`. + /// Tells the server this client is ready to receive audio again. The server + /// does not automatically rejoin the previous group; rejoining requires an + /// explicit group switch or another server-directed group change. /// /// The local state is rolled back if the server notification fails /// (see ``enterExternalSource()`` for rationale). @@ -48,6 +51,26 @@ public extension SendspinClient { } } +// MARK: - Group membership + +public extension SendspinClient { + /// Leave the current server group without changing local group state. + /// + /// This operation is available to every client role. The server moves the + /// client to a stopped solo group; returning to the previous group requires + /// an explicit server-directed switch or group update. + /// + /// - Throws: ``SendspinClientError/notConnected`` when disconnected, + /// ``SendspinClientError/handshakeIncomplete`` during re-handshake, or + /// ``SendspinClientError/sendFailed(_:)`` when the encrypted send fails. + @MainActor + func leaveGroup() async throws { + try requireOpen() + guard let connection else { throw SendspinClientError.notConnected } + try await connection.leaveGroup() + } +} + // MARK: - Dynamic player capabilities and format preference public extension SendspinClient { @@ -150,7 +173,7 @@ public extension SendspinClient { @MainActor func openPairingWindow() async throws { try requireOpen() - guard let connection else { throw SendspinClientError.notConnected } + guard let connection = pairingTargetConnection() else { throw SendspinClientError.notConnected } await connection.openPairingWindow() } @@ -158,7 +181,7 @@ public extension SendspinClient { @MainActor func cancelPairingAttempt() async throws { try requireOpen() - guard let connection else { throw SendspinClientError.notConnected } + guard let connection = pairingTargetConnection() else { throw SendspinClientError.notConnected } await connection.cancelPairingAttempt() } diff --git a/Sources/SendspinKit/Client/SendspinClient+MultiServer.swift b/Sources/SendspinKit/Client/SendspinClient+MultiServer.swift index a77b34a..9867a57 100644 --- a/Sources/SendspinKit/Client/SendspinClient+MultiServer.swift +++ b/Sources/SendspinKit/Client/SendspinClient+MultiServer.swift @@ -102,6 +102,27 @@ extension SendspinClient { activities: existingSnapshot.activities, isPairingAttempt: existingSnapshot.isPairingAttempt ) + // The one coexistence exception is a first incoming pairing session + // beside an admitted playback holder. Keep it parked until a later + // activation proves that it has become playback-capable. + if incomingCandidate.activities == [.pairing] { + if pairingConnection != nil { + await HandshakeDriver.reject(outcome, reason: .concurrentAttempt, on: transport) + return + } + if existingCandidate.activities == [.playback], !existingCandidate.isPairingAttempt { + await setupConnection( + with: transport, + outcome: outcome, + negotiation: negotiation, + runtimeConfiguration: runtimeConfiguration, + setupEpoch: arbitrationEpoch, + installAsPairingSide: true + ) + return + } + } + switch MultiServerAdmission.arbitrate( incoming: incomingCandidate, existing: existingCandidate, diff --git a/Sources/SendspinKit/Client/SendspinClient+PairingCoordinator.swift b/Sources/SendspinKit/Client/SendspinClient+PairingCoordinator.swift new file mode 100644 index 0000000..ea3e3e8 --- /dev/null +++ b/Sources/SendspinKit/Client/SendspinClient+PairingCoordinator.swift @@ -0,0 +1,173 @@ +import Foundation + +extension SendspinClient { + @MainActor + func pairingTargetConnection() -> SendspinConnection? { + pairingConnection ?? connection + } + + @MainActor + func applyPairingConnectionEvent(_ event: ConnectionEvent) { + guard pairingConnection != nil else { return } + switch event { + case let .paired(serverId): + emitEvent(.paired(serverId: serverId)) + case let .pairingCodeChanged(emission): + emitEvent(.pairingCodeChanged(emission)) + case let .pairingAttemptEnded(reason): + emitEvent(.pairingAttemptEnded(reason)) + case .disconnected: + if let side = pairingConnection { + dropPairingConnection(side) + } + case .serverConnected, .audioOutputChanged, .outputFormatStatusChanged, + .metadataReceived, .metadataCleared, .controllerStateUpdated, + .controllerStateCleared, .colorStateUpdated, .colorStateCleared, + .groupUpdated, .artworkStreamStarted, .visualizerStreamStarted, + .streamAccepted, .streamStarted, .streamFormatChanged, .streamEnded, + .streamCleared, .outputDelayChanged, .lastPlayedServerChanged, + .streamError, .playerVolumeChanged, .playerMutedChanged, + .serverActivated, .operationalState, .clockSyncEstablished: + break + } + } + + @MainActor + func observePairingActivations( + from gate: ConnectionActivationGate, + connection side: SendspinConnection + ) { + Task { @MainActor [weak self] in + for await proposal in gate.requests { + guard let self, pairingConnection === side else { + gate.resolve(proposal.token, verdict: .reject(.concurrentAttempt)) + continue + } + let verdict = await resolvePairingActivation(proposal, side: side, gate: gate) + if case .admit = verdict { + gate.cancel() + } else { + // The connection actor owns the rejection response and must + // send its goodbye before the facade retires the side. Its + // terminal disconnected event performs the eventual detach. + gate.resolve(proposal.token, verdict: verdict) + } + } + } + } + + @MainActor + private func resolvePairingActivation( + _ proposal: ConnectionActivationProposal, + side: SendspinConnection, + gate: ConnectionActivationGate + ) async -> ConnectionActivationVerdict { + guard pairingConnection === side, let primary = connection else { + return .reject(.concurrentAttempt) + } + let primarySnapshot = await primary.admissionSnapshot() + let incoming = await MultiServerAdmission.Candidate( + serverId: side.admissionSnapshot().serverId, + activities: proposal.activities, + isPairingAttempt: false + ) + let existing = MultiServerAdmission.Candidate( + serverId: primarySnapshot.serverId, + activities: primarySnapshot.activities, + isPairingAttempt: primarySnapshot.isPairingAttempt + ) + let lastPlayback = await persistenceProvider?.loadLastPlayedServerId() + switch MultiServerAdmission.arbitrate( + incoming: incoming, + existing: existing, + lastPlaybackServerId: lastPlayback + ) { + case .keepExisting: + return .reject(.concurrentAttempt) + case .acceptIncoming: + guard await promotePairingConnection(side, primary: primary, gate: gate, token: proposal.token) else { + return .reject(.concurrentAttempt) + } + return .admit + } + } + + @MainActor + private func promotePairingConnection( + _ side: SendspinConnection, + primary: SendspinConnection, + gate: ConnectionActivationGate, + token: UUID + ) async -> Bool { + guard !isTerminated, pairingConnection === side, connection === primary else { return false } + // Capture the parked connection's complete projection before retiring the + // primary. The side remains in its pairing activation while the gate is + // unresolved, so this is the stable state that promotion must inherit. + let snapshot = await side.projectionSnapshot() + guard !isTerminated, pairingConnection === side, connection === primary else { return false } + sessionEpoch += 1 + let promotionEpoch = sessionEpoch + let sideDelivery = pairingDataDelivery + let sideValidity = pairingSessionValidity + pairingPromotionInProgress = true + deferredPairingEvents.removeAll(keepingCapacity: true) + + // Keep ownership pointers unchanged until the incumbent goodbye completes + // and the promoted projection is installed; its terminal event must not + // retire the new owner. + await primary.disconnect(reason: .anotherServer) + guard !isTerminated, sessionEpoch == promotionEpoch, connection === primary, + pairingConnection === side else { + pairingPromotionInProgress = false + deferredPairingEvents.removeAll() + return false + } + + sessionValidity?.invalidate() + sessionValidity = sideValidity + pairingSessionValidity = nil + connection = side + pairingConnection = nil + pairingActivationGate = nil + pairingDataDelivery = nil + drainConnectionEventsTask?.cancel() + drainConnectionEventsTask = pairingConnectionDrainTask + pairingConnectionDrainTask = nil + + applyPromotedProjection(snapshot) + updateConnectionState(.connected) + gate.resolve(token, verdict: .admit) + sideDelivery?.promoteToPrimary() + pairingPromotionInProgress = false + let deferredEvents = deferredPairingEvents + deferredPairingEvents.removeAll(keepingCapacity: true) + for event in deferredEvents { + guard !isTerminated, connection === side else { return false } + applyConnectionEvent(event) + } + return true + } + + @MainActor + func detachPairingConnection() -> SendspinConnection? { + pairingConnectionDrainTask?.cancel() + pairingConnectionDrainTask = nil + pairingActivationGate?.cancel() + pairingActivationGate = nil + pairingPromotionInProgress = false + deferredPairingEvents.removeAll() + pairingSessionValidity?.invalidate() + pairingSessionValidity = nil + pairingDataDelivery = nil + let side = pairingConnection + pairingConnection = nil + return side + } + + @MainActor + func dropPairingConnection(_ side: SendspinConnection) { + guard pairingConnection === side else { return } + _ = detachPairingConnection() + Task { await side.shutdown() } + } +} diff --git a/Sources/SendspinKit/Client/SendspinClient.swift b/Sources/SendspinKit/Client/SendspinClient.swift index 6b6876a..d6def18 100644 --- a/Sources/SendspinKit/Client/SendspinClient.swift +++ b/Sources/SendspinKit/Client/SendspinClient.swift @@ -122,6 +122,14 @@ public final class SendspinClient { /// The active connection, or nil if disconnected. /// When a new connection replaces the old one, the old is shutdown. var connection: SendspinConnection? + /// At most one pairing connection may be parked beside a playback holder. + var pairingConnection: SendspinConnection? + var pairingConnectionDrainTask: Task? + var pairingActivationGate: ConnectionActivationGate? + var pairingDataDelivery: ConnectionDataDelivery? + var pairingSessionValidity: SessionValidityToken? + var pairingPromotionInProgress = false + var deferredPairingEvents: [ConnectionEvent] = [] /// Client-lifetime audio-output capability service. The facade owns exactly /// one provider; connections consume later session snapshots but never own it. @@ -146,7 +154,7 @@ public final class SendspinClient { /// `retireSession()` can invalidate it synchronously — before old-connection /// teardown is awaited — per the design's retire contract (both guards must /// reject a dying connection's late events *during* teardown, not after). - private(set) var sessionValidity: SessionValidityToken? + var sessionValidity: SessionValidityToken? /// Exact player catalog advertised by the active session. Cleared on reusable disconnect. private(set) var effectivePlayerFormats: [AudioFormatSpec]? @@ -190,9 +198,14 @@ public final class SendspinClient { /// Most recent artwork payload received from the artwork data stream. public private(set) var currentArtwork: ArtworkData? - let visualizerDataContinuation: AsyncStream.Continuation + let visualizerDataMailbox: VisualizerDataMailbox /// Visualizer bytes from the visualizer data stream. - public let visualizerData: AsyncStream + /// + /// Delivery is FIFO among retained frames and bounded by the configured + /// visualizer `bufferCapacity` using the wire frame size (9 + payload bytes). + /// Periodic types are requested with the shared `rateMax` scalar; beat and + /// peak remain event-driven as defined by the visualizer role. + public let visualizerData: VisualizerDataStream public convenience init( identity: SendspinIdentity, @@ -294,7 +307,8 @@ public final class SendspinClient { (audioChunks, audioChunksContinuation) = AsyncStream.makeStream() (artwork, artworkContinuation) = AsyncStream.makeStream() - (visualizerData, visualizerDataContinuation) = AsyncStream.makeStream() + visualizerDataMailbox = VisualizerDataMailbox(capacityBytes: visualizerConfig?.bufferCapacity ?? 1) + visualizerData = VisualizerDataStream(mailbox: visualizerDataMailbox) if roleSet.contains(.playerV1) { startAudioOutputCapabilityMonitoring() @@ -321,13 +335,19 @@ public final class SendspinClient { eventSubscribers.removeAll() audioChunksContinuation.finish() artworkContinuation.finish() - visualizerDataContinuation.finish() + visualizerDataMailbox.finish() // Safety net: dropping a connected client must not leak a live, playing // connection graph. Capture the connection into a local — do NOT capture // self. (`isolated deinit` runs on the MainActor, so reading the isolated // stored property is legal.) let conn = connection - Task { await conn?.shutdown() } + let side = pairingConnection + Task { + await conn?.shutdown() + if side !== conn { + await side?.shutdown() + } + } } /// Create a fresh control-event stream for one caller. @@ -351,7 +371,7 @@ public final class SendspinClient { return stream } - private func emitEvent(_ event: ClientEvent) { + func emitEvent(_ event: ClientEvent) { for continuation in eventSubscribers.values { continuation.yield(event) } @@ -396,7 +416,7 @@ public final class SendspinClient { currentStreamFormat = format } - private func updateMetadata(_ metadata: TrackMetadata?) { + func updateMetadata(_ metadata: TrackMetadata?) { currentMetadata = metadata } @@ -416,6 +436,30 @@ public final class SendspinClient { currentCodecHeader = header } + func applyPromotedProjection(_ snapshot: SendspinConnection.ProjectionSnapshot) { + resetServerSessionState() + playerStreamActive = snapshot.playerStreamActive + artworkStreamActive = snapshot.artworkStreamActive + currentVisualizerStreamConfiguration = snapshot.visualizerConfiguration + updateStreamFormat(snapshot.streamFormat) + updateCodecHeader(snapshot.codecHeader) + currentArtwork = nil + updateMetadata(snapshot.metadata) + updateGroup(snapshot.group) + updateControllerState(snapshot.controller) + updateColorState(snapshot.color) + clientOperationalState = snapshot.operationalState + isClockSynced = snapshot.clockSynced + currentOutputFormatStatus = snapshot.outputFormatStatus + currentServerId = snapshot.serverId + currentActivities = snapshot.activities + trustLevel = snapshot.trustLevel + currentVolume = snapshot.volume + currentMuted = snapshot.muted + outputDelayMs = snapshot.outputDelayMs + shouldEmitRawAudio = playerConfig?.emitRawAudioEvents ?? false + } + // MARK: - Connection lifecycle /// Connect to a Sendspin server at the given URL (client-initiated connection). @@ -587,6 +631,7 @@ public final class SendspinClient { drainConnectionEventsTask?.cancel() drainConnectionEventsTask = nil sessionValidity?.invalidate() + visualizerDataMailbox.clear() let retired = connection connection = nil return retired @@ -603,7 +648,8 @@ public final class SendspinClient { outcome: consuming HandshakeDriver.Result, negotiation: SessionFormatNegotiation, runtimeConfiguration: PairingManagementConfiguration, - setupEpoch: Int + setupEpoch: Int, + installAsPairingSide: Bool = false ) async { guard !isTerminated else { await transport.disconnect() @@ -615,32 +661,52 @@ public final class SendspinClient { await transport.disconnect() return } - // A new connection is a new session: drop any server-reported state carried - // over from a prior connection (notably one lost without an explicit - // disconnect) before the first server/state update is applied. - // Placed here, not in handleServerHello — that also fires on a same-connection - // re-hello, where the accumulated state is still valid. - resetServerSessionState() - isClockSynced = false - effectivePlayerFormats = negotiation.effectivePlayerFormats - - // Retire the old session synchronously (token + identity guards both - // reject its late events from this point), then await its teardown. - // `oldConnection` is nil for current callers. Re-check the epoch after - // that suspension: teardown can take arbitrarily long. - let oldConnection = retireSession() - if let oldConnection { - await oldConnection.shutdown() - } - guard sessionEpoch == setupEpoch else { - await transport.disconnect() - return + if !installAsPairingSide { + // A new primary session drops server-reported state carried over from + // a prior connection before the first server/state update is applied. + resetServerSessionState() + isClockSynced = false + effectivePlayerFormats = negotiation.effectivePlayerFormats + + // Retire the old primary and any parked pairing side synchronously, + // then await both teardowns before installing the replacement. + let oldConnection = retireSession() + let oldPairingConnection = detachPairingConnection() + if let oldConnection { + await oldConnection.shutdown() + } + if let oldPairingConnection { + await oldPairingConnection.shutdown() + } + guard sessionEpoch == setupEpoch else { + await transport.disconnect() + return + } } // Build the SendspinConnection with configuration from this facade let validity = SessionValidityToken() - sessionValidity = validity + if !installAsPairingSide { + sessionValidity = validity + } let clockSync = ClockSynchronizer() + let deliveryArtworkObserver: (@Sendable (ArtworkData) -> Void) = { [weak self] artwork in + Task { @MainActor [weak self] in + validity.performIfValid { + self?.currentArtwork = artwork.clearsArtwork ? nil : artwork + } + } + } + let dataDelivery = ConnectionDataDelivery( + audio: audioChunksContinuation, + artwork: artworkContinuation, + visualizer: visualizerDataMailbox, + artworkObserver: deliveryArtworkObserver + ) + if !installAsPairingSide { + dataDelivery.promoteToPrimary() + } + let activationGate = installAsPairingSide ? ConnectionActivationGate() : nil let audioEngine = makeAudioEngine(clock: clockSync, validity: validity) @@ -706,20 +772,11 @@ public final class SendspinClient { outputNegotiationSleep: outputNegotiationSleep, audioSink: audioChunksContinuation, artworkSink: artworkContinuation, - visualizerSink: visualizerDataContinuation, + visualizerDelivery: nil, + dataDelivery: dataDelivery, + activationGate: activationGate, emitRawAudio: playerConfig?.emitRawAudioEvents ?? false, - artworkObserver: { [weak self] artwork in - Task { @MainActor [weak self] in - // Same session-validity contract as the public artwork - // stream's yieldIfValid: a retired connection's in-flight - // artwork must not mutate facade state. The token check and - // write happen under the token lock, closing the snapshot/use - // window that a separate `isValid` read would leave open. - validity.performIfValid { - self?.currentArtwork = artwork.clearsArtwork ? nil : artwork - } - } - }, + artworkObserver: nil, validity: validity, advertisedCommands: advertisedCommands, roles: roleSet, @@ -751,8 +808,36 @@ public final class SendspinClient { ) // No suspension occurs between the re-check above and this install. - connection = newConnection - currentOutputFormatStatus = nil + if installAsPairingSide { + pairingConnection = newConnection + pairingActivationGate = activationGate + pairingDataDelivery = dataDelivery + pairingSessionValidity = validity + pairingConnectionDrainTask?.cancel() + pairingConnectionDrainTask = Task { @MainActor [weak self] in + if let activationGate { + self?.observePairingActivations(from: activationGate, connection: newConnection) + } + for await event in newConnection.events { + newConnection.controlSink.decrementDepth() + guard let self else { return } + guard !isTerminated, + pairingConnection === newConnection || connection === newConnection else { return } + if pairingConnection === newConnection || pairingPromotionInProgress { + if pairingPromotionInProgress { + deferredPairingEvents.append(event) + } else { + applyPairingConnectionEvent(event) + } + } else { + applyConnectionEvent(event) + } + } + } + } else { + connection = newConnection + currentOutputFormatStatus = nil + } // Pairing setup sends its first protocol message. Mark the connection // running for that handoff send, but defer the supervisor until setup is @@ -764,6 +849,7 @@ public final class SendspinClient { // Drain control events without retaining the client: upgrade weak `self` per event. // Otherwise a parked task prevents deinit and its cleanup safety net. + guard !installAsPairingSide else { return } drainConnectionEventsTask = Task { [weak self] in guard newConnection === self?.connection else { return } if let sequence = self?.audioOutputSnapshotSequence, @@ -781,10 +867,16 @@ public final class SendspinClient { guard let self else { return } // Identity guard: if connection was replaced, ignore this stale event. guard newConnection === connection else { return } + // Promotion deliberately awaits the incumbent's graceful teardown + // before swapping facade ownership. Its terminal event is not a + // session loss; applying it here would retire the parked winner. + guard !pairingPromotionInProgress else { continue } applyConnectionEvent(event) } } + guard !installAsPairingSide else { return } + // Set should-emit-raw-audio flag shouldEmitRawAudio = playerConfig?.emitRawAudioEvents ?? false @@ -859,6 +951,17 @@ public final class SendspinClient { sessionValidity?.invalidate() sessionValidity = nil let retiredConnection = connection + let retiredPairingConnection = pairingConnection + pairingConnectionDrainTask?.cancel() + pairingConnectionDrainTask = nil + pairingActivationGate?.cancel() + pairingActivationGate = nil + pairingPromotionInProgress = false + deferredPairingEvents.removeAll() + pairingDataDelivery = nil + pairingSessionValidity?.invalidate() + pairingSessionValidity = nil + pairingConnection = nil connection = nil let candidates = Array(pendingTransports.values) pendingTransports.removeAll() @@ -874,6 +977,9 @@ public final class SendspinClient { if let retiredConnection { await retiredConnection.disconnect(reason: .shutdown) } + if let retiredPairingConnection { + await retiredPairingConnection.disconnect(reason: .shutdown) + } await capabilityTask?.value await capabilityProvider.stopMonitoring() self?.finishClose() @@ -899,7 +1005,7 @@ public final class SendspinClient { eventSubscribers.removeAll() audioChunksContinuation.finish() artworkContinuation.finish() - visualizerDataContinuation.finish() + visualizerDataMailbox.finish() } /// Record the host application's audio-session activation state. @@ -943,6 +1049,9 @@ public final class SendspinClient { sessionEpoch += 1 guard let conn = connection else { + if let side = pairingConnection { + dropPairingConnection(side) + } // Mid-dial: there is no connection to say goodbye to, but the caller's // intent must still land, or `connectionState` stays `.connecting` forever. if connectionState != .disconnected { @@ -950,6 +1059,18 @@ public final class SendspinClient { } return } + // Promotion owns the incumbent goodbye. Retire facade state immediately if + // a concurrent disconnect invalidates that promotion; finish teardown in the + // background rather than waiting on the gated send. + let promotionWasInProgress = pairingPromotionInProgress + if let side = pairingConnection { + dropPairingConnection(side) + } + if promotionWasInProgress { + applyConnectionEvent(.disconnected(reason: .explicit(reason))) + Task { await conn.disconnect(reason: reason) } + return + } await conn.disconnect(reason: reason) if connection === conn { applyDisconnected(reason: .explicit(reason)) @@ -961,7 +1082,7 @@ public final class SendspinClient { /// event to the public stream. Called per event by the drain /// task, which holds `self` only for the duration of the call. @MainActor - private func applyConnectionEvent(_ event: ConnectionEvent) { // swiftlint:disable:this function_body_length + func applyConnectionEvent(_ event: ConnectionEvent) { // swiftlint:disable:this function_body_length guard !isTerminated else { return } switch event { case let .paired(serverId): @@ -1129,6 +1250,12 @@ public final class SendspinClient { private func applyDisconnected(reason: DisconnectReason) { guard connection != nil || connectionState != .disconnected else { return } // Terminal event: retire the connection and apply reconnect logic. + // A parked pairing side belongs to the same session and must not outlive + // a lost primary transport. + let retiredPairingConnection = detachPairingConnection() + if let retiredPairingConnection { + Task { await retiredPairingConnection.shutdown() } + } // Volume/mute/outputDelay deliberately survive (device-user state, // like the spec's output-delay persistence): the next session is // seeded from facade state and re-applies them to its fresh engine. @@ -1169,6 +1296,7 @@ public final class SendspinClient { playerStreamActive = false artworkStreamActive = false currentVisualizerStreamConfiguration = nil + visualizerDataMailbox.clear() } /// Clear server-reported state that is scoped to a single connection. A diff --git a/Sources/SendspinKit/Client/SendspinConnection+Leave.swift b/Sources/SendspinKit/Client/SendspinConnection+Leave.swift new file mode 100644 index 0000000..c6faeb3 --- /dev/null +++ b/Sources/SendspinKit/Client/SendspinConnection+Leave.swift @@ -0,0 +1,14 @@ +import Foundation + +extension SendspinConnection { + /// Ask the server to remove this client from its current group. + func leaveGroup() async throws { + do { + try await sendWrapped(ClientLeaveMessage(), requireRunningLifecycle: true) + } catch let error as SendspinClientError { + throw error + } catch { + throw SendspinClientError.sendFailed(error.localizedDescription) + } + } +} diff --git a/Sources/SendspinKit/Client/SendspinConnection+Lifecycle.swift b/Sources/SendspinKit/Client/SendspinConnection+Lifecycle.swift index f23f91e..1c576a2 100644 --- a/Sources/SendspinKit/Client/SendspinConnection+Lifecycle.swift +++ b/Sources/SendspinKit/Client/SendspinConnection+Lifecycle.swift @@ -182,6 +182,11 @@ extension SendspinConnection { // Invalidate both the session and any queued visualizer frames. validity.invalidate() visualizerFrameValidity.invalidate() + if let dataDelivery { + dataDelivery.clearVisualizer() + } else { + visualizerDelivery?.clear() + } pairingAttemptActive = false pendingPairingPsk = nil if dynamicPairingAttempt != nil { @@ -213,6 +218,11 @@ extension SendspinConnection { lifecycle = .stopped validity.invalidate() visualizerFrameValidity.invalidate() + if let dataDelivery { + dataDelivery.clearVisualizer() + } else { + visualizerDelivery?.clear() + } controlSink.finish() await transport.disconnect() } diff --git a/Sources/SendspinKit/Client/SendspinConnection+MessageHandling.swift b/Sources/SendspinKit/Client/SendspinConnection+MessageHandling.swift index c1b3505..7081300 100644 --- a/Sources/SendspinKit/Client/SendspinConnection+MessageHandling.swift +++ b/Sources/SendspinKit/Client/SendspinConnection+MessageHandling.swift @@ -51,7 +51,7 @@ extension SendspinConnection { let abort = try JSONDecoder().decode(PairAbortMessage.self, from: data) clearPairingAttempt(reason: abort.payload.reason) - case "client/pair-pending", "client/pair-init", "client/pair-auth", "client/pair-confirm": + case "client/pair-pending", "client/pair-init", "client/pair-auth", "client/pair-retry", "client/pair-confirm": throw PairingProtocolError.invalidSequence case "server/time": @@ -92,6 +92,7 @@ extension SendspinConnection { || msgType == "client/pair-pending" || msgType == "client/pair-init" || msgType == "client/pair-auth" + || msgType == "client/pair-retry" || msgType == "client/pair-confirm" || msgType == "pair/abort" || (msgType == ServerHelloMessage.typeString && awaitingRehandshakeActivation) { @@ -304,6 +305,20 @@ extension SendspinConnection { session: sessionContext ) { case .admit: + if activities == [.pairing], nextActivities != [.pairing], let activationGate { + let verdict = await activationGate.request( + activities: nextActivities, + activeRoles: nextRoles + ) + guard case .admit = verdict else { + if case let .reject(reason) = verdict { + try? await sendWrapped(ClientGoodbyeMessage(payload: GoodbyePayload(reason: reason))) + } + disconnectReason = .explicit(.concurrentAttempt) + await transport.disconnect() + return + } + } activities = nextActivities pairingAttemptActive = nextActivities == [.pairing] let completedRehandshake = awaitingRehandshakeActivation @@ -322,8 +337,7 @@ extension SendspinConnection { if !activeRoles.contains(.visualizerV1) { visualizerStreamActive = false visualizerStreamConfiguration = nil - visualizerFrameValidity.invalidate() - visualizerFrameValidity = VisualizerFrameValidity() + resetVisualizerDelivery(resetTimestampFloor: true) } } try? await publishClientState(bypassRehandshakeGate: completedRehandshake) @@ -440,7 +454,6 @@ extension SendspinConnection { #else let pairingHandshakeHash = channel.handshakeHash #endif - let sid = CPaceSessionIdentifier.make(handshakeHash: pairingHandshakeHash, counter: pairingActivateCounter) let advertisement = await livePairingAdvertisement() let dynamicDescriptor = advertisement.supportedPairMethods[PairMethod.dynamicPairingCode] let digitAudioDescriptor = selectedFormat == .digits && dynamicDescriptor?.outChannels?.contains("speaker") == true @@ -449,21 +462,22 @@ extension SendspinConnection { dynamicPairingAttempt = DynamicPairingAttempt( format: selectedFormat, pairingIndex: pairingActivateCounter, - sid: sid, nonceB: nonceB, commitB: commitB, digitAudioDescriptor: digitAudioDescriptor, digitAudioValidator: digitAudioDescriptor.map { DigitAudioPackValidator(descriptor: $0) }, nonceA: nil, + prs: nil, + round: 0, + sid: nil, pairInitSent: false, serverShare: nil, cpace: nil, secrets: nil, clientConfirmationSent: false ) - let count = await pairingStore?.dynamicPairingFailureCount() ?? 0 - let escalated = count >= dynamicPairingFailureEscalationThreshold - if escalated, !pairingWindowOpen { + let roundCount = await pairingStore?.dynamicPairingRoundCount() ?? 0 + if roundCount >= dynamicPairingRoundLimit, !pairingWindowOpen { try? await sendWrapped(ClientPairPendingMessage( payload: ClientPairPendingPayload(pairingIndex: pairingActivateCounter) )) @@ -504,7 +518,7 @@ extension SendspinConnection { #else let pairingHandshakeHash = channel.handshakeHash #endif - let sid = CPaceSessionIdentifier.make(handshakeHash: pairingHandshakeHash, counter: pairingActivateCounter) + let sid = CPaceSessionIdentifier.make(handshakeHash: pairingHandshakeHash, counter: pairingActivateCounter, round: 1) staticPairingAttempt = StaticPairingAttempt( pairingIndex: pairingActivateCounter, sid: sid, @@ -528,13 +542,18 @@ extension SendspinConnection { pairingWindowOpen = false pairingWindowTask?.cancel() pairingWindowTask = nil - pairingAttemptTask?.cancel() - pairingAttemptTask = Task { [weak self] in - try? await Task.sleep(for: self?.pairingAttemptTimeout ?? .seconds(120)) - guard !Task.isCancelled else { return } - await self?.pairingAttemptTimedOut() + if attempt.round == 0 { + pairingAttemptTask = Task { [weak self] in + try? await Task.sleep(for: self?.pairingAttemptTimeout ?? .seconds(120)) + guard !Task.isCancelled else { return } + await self?.pairingAttemptTimedOut() + } } - attempt.nonceA = nil + attempt.pairInitSent = false + attempt.serverShare = nil + attempt.cpace = nil + attempt.secrets = nil + attempt.clientConfirmationSent = false dynamicPairingAttempt = attempt do { try await sendWrapped(ClientPairInitMessage(payload: ClientPairInitPayload( @@ -550,6 +569,8 @@ extension SendspinConnection { func openPairingWindow() async { guard !pairingWindowOpen else { return } + await pairingStore?.resetDynamicPairingFailureCount() + await pairingStore?.resetDynamicPairingRoundCount() pairingWindowOpen = true pairingWindowTask?.cancel() pairingWindowTask = Task { [weak self] in @@ -621,51 +642,100 @@ extension SendspinConnection { } func handleServerPairInit(_ message: ServerPairInitMessage) async throws { - // A server message left over after an ended attempt is discarded silently. guard dynamicPairingAttempt != nil || staticPairingAttempt != nil else { return } - guard var attempt = dynamicPairingAttempt, attempt.pairInitSent, attempt.nonceA == nil, - let nonceA = Base64URL.decode(message.payload.nonceA, count: 32) + guard var attempt = dynamicPairingAttempt, attempt.pairInitSent, + attempt.cpace == nil, attempt.serverShare == nil else { throw PairingProtocolError.invalidSequence } - let digitAudioPack: DigitAudioPack? = if let validator = attempt.digitAudioValidator { - try validator.finish() - } else { - nil + let nextRound = attempt.round == 0 ? 1 : attempt.round + 1 + if let pairingStore { + guard await pairingStore.dynamicPairingRoundCount() < dynamicPairingRoundLimit else { + clearPairingAttempt(reason: .pairingCodeMismatch) + try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .pairingCodeMismatch))) + return + } + guard await pairingStore.incrementDynamicPairingRoundCount() <= dynamicPairingRoundLimit else { + clearPairingAttempt(reason: .pairingCodeMismatch) + try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .pairingCodeMismatch))) + return + } + } else if nextRound > dynamicPairingRoundLimit { + clearPairingAttempt(reason: .pairingCodeMismatch) + try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .pairingCodeMismatch))) + return } - attempt.nonceA = nonceA - var input = Data("sendspin-pairing-code-derive-v1".utf8) + attempt.round = nextRound + if attempt.prs == nil { + guard let encodedNonceA = message.payload.nonceA, + let nonceA = Base64URL.decode(encodedNonceA, count: 32) + else { throw PairingProtocolError.invalidSequence } + let digitAudioPack: DigitAudioPack? = if let validator = attempt.digitAudioValidator { + try validator.finish() + } else { + nil + } + attempt.nonceA = nonceA + var input = Data("sendspin-pairing-code-derive-v1".utf8) + #if DEBUG + input.append(pairingHandshakeHashOverride ?? channel.handshakeHash) + #else + input.append(channel.handshakeHash) + #endif + input.append(nonceA); input.append(attempt.nonceB) + let digest = Data(SHA256.hash(data: input)) + let prs: Data + let emission: PairingCodeEmission + switch attempt.format { + case .digits: + var value: UInt64 = 0 + for byte in digest { + value = (value * 256 + UInt64(byte)) % 1_000_000 + } + prs = Data(String(format: "%06llu", value).utf8) + emission = PairingCodeEmission( + format: .digits, + payload: String(data: prs, encoding: .utf8)!, + digitAudioPack: digitAudioPack + ) + case .qrCode: + prs = digest.prefix(24) + emission = PairingCodeEmission(format: .qrCode, payload: PairingToken.dynamicCodeToken(Data(prs))) + } + attempt.prs = prs + attempt.emission = emission + controlSink.enqueue(.pairingCodeChanged(emission)) + } else { + guard message.payload.nonceA == nil, let prs = attempt.prs else { + throw PairingProtocolError.invalidSequence + } + if let emission = attempt.emission { + controlSink.enqueue(.pairingCodeChanged(emission)) + } + attempt.clientConfirmationSent = false + attempt.serverShare = nil + attempt.secrets = nil + attempt.sid = nil + attempt.cpace = nil + _ = prs + } + guard let prs = attempt.prs else { throw PairingProtocolError.invalidSequence } #if DEBUG - input.append(pairingHandshakeHashOverride ?? channel.handshakeHash) + let pairingHandshakeHash = pairingHandshakeHashOverride ?? channel.handshakeHash #else - input.append(channel.handshakeHash) + let pairingHandshakeHash = channel.handshakeHash #endif - input.append(nonceA); input.append(attempt.nonceB) - let digest = Data(SHA256.hash(data: input)) - let prs: Data - let emission: PairingCodeEmission - switch attempt.format { - case .digits: - var value: UInt64 = 0 - for byte in digest { - value = (value * 256 + UInt64(byte)) % 1_000_000 - } - prs = Data(String(format: "%06llu", value).utf8) - emission = PairingCodeEmission( - format: .digits, - payload: String(data: prs, encoding: .utf8)!, - digitAudioPack: digitAudioPack - ) - case .qrCode: - prs = digest.prefix(24) - emission = PairingCodeEmission(format: .qrCode, payload: PairingToken.dynamicCodeToken(Data(prs))) - } + let sid = CPaceSessionIdentifier.make( + handshakeHash: pairingHandshakeHash, + counter: attempt.pairingIndex, + round: attempt.round + ) + attempt.sid = sid attempt.cpace = try CPace( role: .responder, prs: prs, - sid: attempt.sid, + sid: sid, scalarOverride: pairingScalarBOverride ) dynamicPairingAttempt = attempt - controlSink.enqueue(.pairingCodeChanged(emission)) } func handleServerPairAuth(_ message: ServerPairAuthMessage) async throws { @@ -745,29 +815,41 @@ extension SendspinConnection { private func handleDynamicServerPairConfirm(_ message: ServerPairConfirmMessage) async throws { guard var attempt = dynamicPairingAttempt, !attempt.clientConfirmationSent, + let sid = attempt.sid, let cpace = attempt.cpace, let secrets = attempt.secrets, let serverShare = attempt.serverShare, let tag = Base64URL.decode(message.payload.serverKc, count: 64) else { throw PairingProtocolError.invalidSequence } - let expected = CPaceX25519.mcfTag(isk: secrets.isk, sid: attempt.sid, share: serverShare, associatedData: CPaceX25519.defaultInitiatorAD) + let expected = CPaceX25519.mcfTag(isk: secrets.isk, sid: sid, share: serverShare, associatedData: CPaceX25519.defaultInitiatorAD) guard CPaceX25519.constantTimeEqual(tag, expected) else { _ = await pairingStore?.incrementDynamicPairingFailureCount() - clearPairingAttempt(reason: .pairingCodeMismatch) - try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .pairingCodeMismatch))) + let globalRounds = await pairingStore?.dynamicPairingRoundCount() ?? attempt.round + if attempt.round < dynamicPairingRoundLimit, globalRounds < dynamicPairingRoundLimit { + attempt.serverShare = nil + attempt.cpace = nil + attempt.secrets = nil + attempt.sid = nil + dynamicPairingAttempt = attempt + try? await sendWrapped(ClientPairRetryMessage(payload: ClientPairRetryPayload())) + } else { + clearPairingAttempt(reason: .pairingCodeMismatch) + try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .pairingCodeMismatch))) + } return } await pairingStore?.resetDynamicPairingFailureCount() + await pairingStore?.resetDynamicPairingRoundCount() let clientTag = CPaceX25519.mcfTag( isk: secrets.isk, - sid: attempt.sid, + sid: sid, share: cpace.publicShare, associatedData: CPaceX25519.defaultResponderAD ) let wrappedNonce = try PairingWrap.wrap( plaintext: attempt.nonceB, label: Data("sendspin-pair-nonce-wrap-v1".utf8), - sid: attempt.sid, + sid: sid, isk: secrets.isk, suite: suite ) @@ -784,7 +866,7 @@ extension SendspinConnection { let wrappedPsk = try PairingWrap.wrap( plaintext: generated.bytes, label: Data("sendspin-pair-psk-wrap-v1".utf8), - sid: attempt.sid, + sid: sid, isk: secrets.isk, suite: suite ) @@ -1045,11 +1127,12 @@ extension SendspinConnection { Log.client.warning("Discarding invalid visualizer stream configuration") visualizerStreamActive = false visualizerStreamConfiguration = nil - visualizerFrameValidity.invalidate() - visualizerFrameValidity = VisualizerFrameValidity() + resetVisualizerDelivery(resetTimestampFloor: true) } else { - visualizerFrameValidity.invalidate() - visualizerFrameValidity = VisualizerFrameValidity() + let startsNewStream = !visualizerStreamActive + if startsNewStream { + resetVisualizerDelivery(resetTimestampFloor: true) + } visualizerStreamConfiguration = VisualizerStreamConfiguration( types: visualizerInfo.types, rateMax: visualizerInfo.rateMax, @@ -1164,8 +1247,7 @@ extension SendspinConnection { audioEngine.commands.enqueue(.streamClear(roles: roles)) } if roles == nil || roles?.contains("visualizer") == true { - visualizerFrameValidity.invalidate() - visualizerFrameValidity = VisualizerFrameValidity() + resetVisualizerDelivery(resetTimestampFloor: true) } // stream/clear invalidates queued visualizer frames without ending the negotiated stream. @@ -1191,8 +1273,7 @@ extension SendspinConnection { if endedRoles == nil || endedRoles?.contains("visualizer") == true { visualizerStreamActive = false visualizerStreamConfiguration = nil - visualizerFrameValidity.invalidate() - visualizerFrameValidity = VisualizerFrameValidity() + resetVisualizerDelivery(resetTimestampFloor: true) } // Per spec, entering external_source causes the server to end active streams. @@ -1282,7 +1363,11 @@ extension SendspinConnection { serverTimestamp: message.timestamp, sendAhead: message.sendAhead ) - validity.yieldIfValid(chunk, to: audioSink) + if let dataDelivery { + dataDelivery.yieldAudioIfValid(chunk, validity: validity) + } else { + validity.yieldIfValid(chunk, to: audioSink) + } } // Only enqueue to engine if clock is synced. Tag the frame at ingress so a format @@ -1354,8 +1439,12 @@ extension SendspinConnection { artworkPending[result.channel] = nil artworkScheduleTasks[result.channel]?.cancel() artworkScheduleTasks[result.channel] = nil - artworkObserver?(artwork) - validity.yieldIfValid(artwork, to: artworkSink) + if let dataDelivery { + dataDelivery.yieldArtworkIfValid(artwork, validity: validity) + } else { + artworkObserver?(artwork) + validity.yieldIfValid(artwork, to: artworkSink) + } } else { artworkPending[result.channel] = ScheduledArtwork(artwork: artwork, localDisplayTime: localTime) artworkScheduleTasks[result.channel]?.cancel() @@ -1373,8 +1462,12 @@ extension SendspinConnection { guard let pending = artworkPending[channel], pending.localDisplayTime <= scheduleNow() else { return } artworkPending[channel] = nil artworkScheduleTasks[channel] = nil - artworkObserver?(pending.artwork) - validity.yieldIfValid(pending.artwork, to: artworkSink) + if let dataDelivery { + dataDelivery.yieldArtworkIfValid(pending.artwork, validity: validity) + } else { + artworkObserver?(pending.artwork) + validity.yieldIfValid(pending.artwork, to: artworkSink) + } } private func recordArrivalDelay(message: BinaryMessage, arrival: Int64) async { @@ -1461,12 +1554,37 @@ extension SendspinConnection { Log.client.warning("Discarding stale visualizer binary") return } + if let floor = visualizerTimestampFloor, localDisplayTime < floor { + Log.client.warning("Discarding out-of-order visualizer binary") + return + } + visualizerTimestampFloor = localDisplayTime let visualizerData = VisualizerData( type: type, data: message.data, localDisplayTime: localDisplayTime, + streamConfiguration: configuration, validity: visualizerFrameValidity ) - validity.yieldIfValid(visualizerData, to: visualizerSink) + if let dataDelivery { + dataDelivery.offerVisualizerIfValid(visualizerData, validity: validity) + } else if let visualizerDelivery { + validity.offerIfValid(visualizerData, to: visualizerDelivery) + } else { + validity.yieldIfValid(visualizerData, to: visualizerSink) + } + } + + private func resetVisualizerDelivery(resetTimestampFloor: Bool) { + visualizerFrameValidity.invalidate() + visualizerFrameValidity = VisualizerFrameValidity() + if let dataDelivery { + dataDelivery.clearVisualizer() + } else { + visualizerDelivery?.clear() + } + if resetTimestampFloor { + visualizerTimestampFloor = nil + } } } diff --git a/Sources/SendspinKit/Client/SendspinConnection+Outbound.swift b/Sources/SendspinKit/Client/SendspinConnection+Outbound.swift index 6e5ba66..26088f5 100644 --- a/Sources/SendspinKit/Client/SendspinConnection+Outbound.swift +++ b/Sources/SendspinKit/Client/SendspinConnection+Outbound.swift @@ -35,21 +35,28 @@ extension SendspinConnection { await transport.disconnect() } - func sendWrapped(_ message: some Codable & Sendable, bypassRehandshakeGate: Bool = false) async throws { + func sendWrapped( + _ message: some Codable & Sendable, + bypassRehandshakeGate: Bool = false, + requireRunningLifecycle: Bool = false + ) async throws { await acquireOutboundSlot() defer { releaseOutboundSlot() } guard !outboundFailed else { throw SendspinClientError.sendFailed("outbound channel is dead") } - // Gate check comes after acquisition: a sender that parked during the - // exchange must not encrypt under pre-swap keys. + // Gate checks come after acquisition: a sender parked during an exchange + // or shutdown must not proceed under stale keys or a closing session. guard bypassRehandshakeGate || !rehandshakeInProgress else { throw SendspinClientError.handshakeIncomplete } - guard lifecycle == .running || lifecycle == .shuttingDown else { - // `.shuttingDown` permits the intentional goodbye; everything else - // on a stopped connection is rejected. + let lifecycleAllowsSend = requireRunningLifecycle + ? lifecycle == .running + : lifecycle == .running || lifecycle == .shuttingDown + guard lifecycleAllowsSend else { + // `.shuttingDown` permits the intentional goodbye; leave opts out so + // a queued leave cannot follow that goodbye onto a closing transport. throw SendspinClientError.notConnected } if Task.isCancelled { diff --git a/Sources/SendspinKit/Client/SendspinConnection+PairingQuery.swift b/Sources/SendspinKit/Client/SendspinConnection+PairingQuery.swift index bcf5df9..8a8b72d 100644 --- a/Sources/SendspinKit/Client/SendspinConnection+PairingQuery.swift +++ b/Sources/SendspinKit/Client/SendspinConnection+PairingQuery.swift @@ -10,6 +10,40 @@ extension SendspinConnection { let isPairingAttempt: Bool } + struct ProjectionSnapshot: Sendable { + let serverId: String + let activities: Set + let trustLevel: TrustLevel + let activeRoles: Set + let streamFormat: AudioFormatSpec? + let codecHeader: Data? + let playerStreamActive: Bool + let artworkStreamActive: Bool + let visualizerConfiguration: VisualizerStreamConfiguration? + let metadata: TrackMetadata? + let group: GroupInfo? + let controller: ControllerState? + let color: ColorState? + let operationalState: EngineSyncState + let clockSynced: Bool + let outputFormatStatus: OutputFormatStatus? + let volume: Int + let muted: Bool + let outputDelayMs: Int + + var serverInfo: ServerInfo { + ServerInfo( + serverId: serverId, + name: serverName, + trustLevel: trustLevel, + activeRoles: activeRoles, + activities: activities + ) + } + + let serverName: String + } + func admissionSnapshot() -> AdmissionSnapshot { AdmissionSnapshot( serverId: currentServerId ?? "", @@ -20,4 +54,29 @@ extension SendspinConnection { || staticPairingAttempt != nil ) } + + func projectionSnapshot() -> ProjectionSnapshot { + ProjectionSnapshot( + serverId: currentServerId ?? "", + activities: activities, + trustLevel: pskCategory == .longTerm ? .user : .none, + activeRoles: activeRoles, + streamFormat: announcedPlayerStream?.format, + codecHeader: announcedPlayerStream?.codecHeader, + playerStreamActive: playerStreamActive, + artworkStreamActive: artworkStreamActive, + visualizerConfiguration: visualizerStreamConfiguration, + metadata: currentMetadata, + group: currentGroup, + controller: currentControllerState, + color: currentColorState, + operationalState: clientOperationalState, + clockSynced: isClockSynced, + outputFormatStatus: outputFormatStatus, + volume: currentVolume, + muted: currentMuted, + outputDelayMs: currentOutputDelayMs, + serverName: serverName + ) + } } diff --git a/Sources/SendspinKit/Client/SendspinConnection.swift b/Sources/SendspinKit/Client/SendspinConnection.swift index 9d90186..34a634c 100644 --- a/Sources/SendspinKit/Client/SendspinConnection.swift +++ b/Sources/SendspinKit/Client/SendspinConnection.swift @@ -24,6 +24,9 @@ actor SendspinConnection { let audioSink: AsyncStream.Continuation let artworkSink: AsyncStream.Continuation let visualizerSink: AsyncStream.Continuation + let visualizerDelivery: VisualizerDataMailbox? + let dataDelivery: ConnectionDataDelivery? + let activationGate: ConnectionActivationGate? let emitRawAudio: Bool let artworkObserver: (@Sendable (ArtworkData) -> Void)? let validity: SessionValidityToken @@ -85,6 +88,8 @@ actor SendspinConnection { var visualizerStreamConfiguration: VisualizerStreamConfiguration? /// Invalidates queued public frames when the visualizer stream boundary advances. var visualizerFrameValidity = VisualizerFrameValidity() + /// Local display timestamps must not rewind during an in-place stream/start. + var visualizerTimestampFloor: Int64? var artworkStateSent = false var artworkStreamChannels: [StreamArtworkChannelConfig] = [] var artworkTransfer: ArtworkTransfer? @@ -155,12 +160,15 @@ actor SendspinConnection { struct DynamicPairingAttempt { let format: PairingCodeFormat let pairingIndex: UInt32 - let sid: Data let nonceB: Data let commitB: Data let digitAudioDescriptor: DigitAudioDescriptor? var digitAudioValidator: DigitAudioPackValidator? var nonceA: Data? + var prs: Data? + var emission: PairingCodeEmission? + var round: UInt32 + var sid: Data? var pairInitSent: Bool var serverShare: Data? var cpace: CPace? @@ -275,6 +283,9 @@ actor SendspinConnection { audioSink: AsyncStream.Continuation = AsyncStream.makeStream().1, artworkSink: AsyncStream.Continuation = AsyncStream.makeStream().1, visualizerSink: AsyncStream.Continuation = AsyncStream.makeStream().1, + visualizerDelivery: VisualizerDataMailbox? = nil, + dataDelivery: ConnectionDataDelivery? = nil, + activationGate: ConnectionActivationGate? = nil, emitRawAudio: Bool = true, artworkObserver: (@Sendable (ArtworkData) -> Void)? = nil, validity: SessionValidityToken, @@ -339,6 +350,9 @@ actor SendspinConnection { self.audioSink = audioSink self.artworkSink = artworkSink self.visualizerSink = visualizerSink + self.visualizerDelivery = visualizerDelivery + self.dataDelivery = dataDelivery + self.activationGate = activationGate self.emitRawAudio = emitRawAudio self.artworkObserver = artworkObserver self.validity = validity diff --git a/Sources/SendspinKit/Client/SendspinPersistenceProvider.swift b/Sources/SendspinKit/Client/SendspinPersistenceProvider.swift index aa1d604..2e0f863 100644 --- a/Sources/SendspinKit/Client/SendspinPersistenceProvider.swift +++ b/Sources/SendspinKit/Client/SendspinPersistenceProvider.swift @@ -67,8 +67,8 @@ public struct PairingStorageAccounting: Sendable, Equatable { } } -/// Dynamic pairing failures required before a code attempt waits for the host gesture. -let dynamicPairingFailureEscalationThreshold = 5 +/// Maximum dynamic pairing rounds allowed globally since the last verified confirmation or operator reset. +let dynamicPairingRoundLimit: UInt32 = 20 /// Host-local pairing settings shared by handshake candidates and active sessions. public struct PairingManagementConfiguration: Sendable, Equatable { @@ -164,6 +164,16 @@ public protocol PairingRecordStore: Sendable { /// Reset the dynamic pairing failure count atomically. func resetDynamicPairingFailureCount() async + + /// Return the global number of dynamic pairing rounds since the last verified key confirmation. + /// Implementations must persist this counter globally, not partition it by server or address. + func dynamicPairingRoundCount() async -> UInt32 + + /// Record an emitted dynamic pairing round and return the new global count. + func incrementDynamicPairingRoundCount() async -> UInt32 + + /// Reset the global dynamic pairing round count after successful confirmation or operator action. + func resetDynamicPairingRoundCount() async } public extension PairingRecordStore { @@ -197,6 +207,7 @@ public enum PairingRecordStoreError: Error, Sendable, Equatable { public actor InMemoryPairingRecordStore: PairingRecordStore { private var records: [PairingRecord] private var dynamicPairingFailureCount = 0 + private var dynamicPairingRoundCount = 0 private let reservedPskIds: Set public init(pairingPsk: Psk? = nil, preProvisionedRecord: PairingRecord? = nil) { @@ -258,6 +269,19 @@ public actor InMemoryPairingRecordStore: PairingRecordStore { public func resetDynamicPairingFailureCount() async { dynamicPairingFailureCount = 0 } + + public func dynamicPairingRoundCount() async -> UInt32 { + UInt32(dynamicPairingRoundCount) + } + + public func incrementDynamicPairingRoundCount() async -> UInt32 { + dynamicPairingRoundCount += 1 + return UInt32(dynamicPairingRoundCount) + } + + public func resetDynamicPairingRoundCount() async { + dynamicPairingRoundCount = 0 + } } /// Client-side Pairing PSK configuration. diff --git a/Sources/SendspinKit/Client/SessionValidityToken.swift b/Sources/SendspinKit/Client/SessionValidityToken.swift index 945bfb0..50674dd 100644 --- a/Sources/SendspinKit/Client/SessionValidityToken.swift +++ b/Sources/SendspinKit/Client/SessionValidityToken.swift @@ -75,4 +75,12 @@ final class SessionValidityToken: Sendable { continuation.yield(element) } } + + /// Atomically check validity before offering a frame to bounded delivery. + func offerIfValid(_ element: VisualizerData, to mailbox: VisualizerDataMailbox) { + lock.withLock { isValidNow in + guard isValidNow else { return } + mailbox.offer(element) + } + } } diff --git a/Sources/SendspinKit/Client/VisualizerDataDelivery.swift b/Sources/SendspinKit/Client/VisualizerDataDelivery.swift new file mode 100644 index 0000000..9263d6f --- /dev/null +++ b/Sources/SendspinKit/Client/VisualizerDataDelivery.swift @@ -0,0 +1,322 @@ +import Foundation + +/// A bounded async sequence for visualizer frames. +/// The mailbox has one pending consumer and a configured wire-byte budget. +/// Oldest retained frames are discarded when a new frame does not fit. +/// +/// A stream has a single-consumer contract: only one iterator may consume it at +/// a time. A second live iterator returns `nil` rather than replacing the +/// current consumer. If the owning iterator is abandoned or cancelled, a later +/// iterator may take ownership. +public struct VisualizerDataStream: AsyncSequence, Sendable { + public typealias Element = VisualizerData + + private let mailbox: VisualizerDataMailbox + + init(mailbox: VisualizerDataMailbox) { + self.mailbox = mailbox + } + + public struct Iterator: AsyncIteratorProtocol, Sendable { + private let mailbox: VisualizerDataMailbox + private let token: VisualizerIteratorToken + + fileprivate init(mailbox: VisualizerDataMailbox) { + self.mailbox = mailbox + token = VisualizerIteratorToken(mailbox: mailbox) + } + + public mutating func next() async -> VisualizerData? { + await mailbox.next(owner: token) + } + } + + public func makeAsyncIterator() -> Iterator { + Iterator(mailbox: mailbox) + } +} + +/// Identity for the one iterator allowed to consume a mailbox. +private final class VisualizerIteratorLease: @unchecked Sendable {} + +private final class VisualizerIteratorToken: @unchecked Sendable { + weak var mailbox: VisualizerDataMailbox? + let lease = VisualizerIteratorLease() + + init(mailbox: VisualizerDataMailbox) { + self.mailbox = mailbox + } + + deinit { + mailbox?.cancel(lease: lease) + } +} + +/// Lock-based delivery storage keeps the message loop non-blocking and avoids +/// an unbounded task or `AsyncStream` buffer when the host does not consume. +final class VisualizerDataMailbox: @unchecked Sendable { + private enum ReadResult { + case value(VisualizerData) + case end + case retry + } + + private final class QueueNode { + let value: VisualizerData + var next: QueueNode? + + init(_ value: VisualizerData) { + self.value = value + } + } + + private final class Waiter { + let lease: VisualizerIteratorLease + let continuation: CheckedContinuation + + init(owner: VisualizerIteratorToken, continuation: CheckedContinuation) { + lease = owner.lease + self.continuation = continuation + } + } + + private let lock = NSLock() + private let capacityBytes: Int + private let now: @Sendable () -> Int64 + private var queueHead: QueueNode? + private var queueTail: QueueNode? + private var queuedBytes = 0 + private var ownerLease: VisualizerIteratorLease? + private var waiter: Waiter? + private var finished = false + + var retainedByteCount: Int { + lock.withLock { queuedBytes } + } + + init( + capacityBytes: Int, + now: @escaping @Sendable () -> Int64 = { MonotonicClock.absoluteMicroseconds() } + ) { + precondition(capacityBytes > 0) + self.capacityBytes = capacityBytes + self.now = now + } + + func offer(_ value: VisualizerData, now arrivalNow: Int64? = nil) { + lock.lock() + guard !finished else { + lock.unlock() + return + } + + let currentNow = arrivalNow ?? now() + discardExpiredLocked(now: currentNow) + guard value.localDisplayTime > currentNow else { + lock.unlock() + return + } + + let bytes = value.frameByteCount + guard bytes <= capacityBytes else { + lock.unlock() + return + } + + if let waiter { + self.waiter = nil + lock.unlock() + waiter.continuation.resume(returning: .value(value)) + return + } + + while bytes > capacityBytes - queuedBytes { + guard dequeueHeadLocked() != nil else { break } + } + appendLocked(value) + lock.unlock() + } + + fileprivate func next(owner iterator: VisualizerIteratorToken) async -> VisualizerData? { + while true { + guard !Task.isCancelled else { + cancel(lease: iterator.lease) + return nil + } + + enum Immediate { + case value(VisualizerData) + case empty + case finished + case notOwner + } + + let immediate: Immediate = lock.withLock { + if let ownerLease, ownerLease !== iterator.lease { + return .notOwner + } + if self.ownerLease == nil { + self.ownerLease = iterator.lease + } + discardExpiredLocked(now: now()) + if let value = dequeueHeadLocked() { + return .value(value) + } + if finished { + return .finished + } + return .empty + } + + switch immediate { + case let .value(value): + // A frame can be invalidated or expire after it was dequeued. + // Never deliver it merely because it was fresh at dequeue time. + if value.isRenderable(at: now()) { + return value + } + continue + case .finished, .notOwner: + return nil + case .empty: + break + } + + let result = await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + park(owner: iterator, continuation: continuation) + } + } onCancel: { + cancel(lease: iterator.lease) + } + switch result { + case let .value(value): + // Cancellation owns the handoff decision. A frame already + // resumed to a canceled read is dropped; requeueing it would + // transfer ownership across iterator lifetimes and can strand + // a new waiter's continuation. + guard !Task.isCancelled else { + cancel(lease: iterator.lease) + return nil + } + if value.isRenderable(at: now()) { + return value + } + // The directly delivered frame expired or was invalidated + // while this task was waking. Wait for a fresh frame. + // Loop to wait for the next frame rather than returning stale data. + case .retry: + continue + case .end: + return nil + } + } + } + + func clear() { + lock.withLock { + clearQueueLocked() + } + } + + func finish() { + let pending: CheckedContinuation? = lock.withLock { + guard !finished else { return nil } + finished = true + clearQueueLocked() + let pending = waiter?.continuation + waiter = nil + ownerLease = nil + return pending + } + pending?.resume(returning: .end) + } + + private func park( + owner iterator: VisualizerIteratorToken, + continuation: CheckedContinuation + ) { + let result: ReadResult? = lock.withLock { + guard !finished, ownerLease == nil || ownerLease === iterator.lease else { return .end } + ownerLease = iterator.lease + discardExpiredLocked(now: now()) + // A frame can arrive between the immediate check and installing the + // continuation. Recheck under the same lock so it cannot be hidden + // behind a newly parked waiter. + guard queueHead == nil, waiter == nil else { + return .retry + } + guard !Task.isCancelled else { + // Cancellation may run before this closure gets the lock. Do + // not leave a dead iterator owning the mailbox. + ownerLease = nil + return .end + } + waiter = Waiter(owner: iterator, continuation: continuation) + return nil + } + if let result { + continuation.resume(returning: result) + } + } + + fileprivate func cancel(lease: VisualizerIteratorLease) { + let pending: CheckedContinuation? = lock.withLock { + guard ownerLease === lease else { return nil } + let pending = waiter?.lease === lease ? waiter?.continuation : nil + if waiter?.lease === lease { + waiter = nil + } + // Do not requeue a value that raced with cancellation. The value + // was handed to this read and is intentionally dropped. + ownerLease = nil + return pending + } + pending?.resume(returning: .end) + } + + private func appendLocked(_ value: VisualizerData) { + let node = QueueNode(value) + if let queueTail { + queueTail.next = node + } else { + queueHead = node + } + queueTail = node + queuedBytes += value.frameByteCount + } + + @discardableResult + private func dequeueHeadLocked() -> VisualizerData? { + guard let node = queueHead else { return nil } + queueHead = node.next + node.next = nil + if queueHead == nil { + queueTail = nil + } + queuedBytes -= node.value.frameByteCount + return node.value + } + + private func clearQueueLocked() { + queueHead = nil + queueTail = nil + queuedBytes = 0 + } + + private func discardExpiredLocked(now: Int64) { + // Server visualizer timestamps are non-decreasing, so expired frames form + // a prefix. Each dequeued node releases its Data immediately. + while let value = queueHead?.value, value.localDisplayTime <= now { + _ = dequeueHeadLocked() + } + } +} + +extension VisualizerData { + /// The capacity accounting size required by the visualizer wire contract. + var frameByteCount: Int { + let (bytes, overflow) = BinaryMessage.headerSize.addingReportingOverflow(data.count) + return overflow ? .max : bytes + } +} diff --git a/Sources/SendspinKit/Crypto/CPace.swift b/Sources/SendspinKit/Crypto/CPace.swift index 52a75c1..9646c98 100644 --- a/Sources/SendspinKit/Crypto/CPace.swift +++ b/Sources/SendspinKit/Crypto/CPace.swift @@ -8,18 +8,23 @@ enum CPaceSessionIdentifier { static let handshakeHashLength = 32 static let counterLength = 4 - static func make(handshakeHash: Data, counter: UInt32) -> Data { + static func make(handshakeHash: Data, counter: UInt32, round: UInt32) -> Data { precondition(handshakeHash.count == handshakeHashLength) var result = Data() - result.reserveCapacity(label.count + handshakeHash.count + counterLength) + result.reserveCapacity(label.count + handshakeHash.count + counterLength * 2) result.append(label) result.append(handshakeHash) - result.append(UInt8((counter >> 24) & 0xFF)) - result.append(UInt8((counter >> 16) & 0xFF)) - result.append(UInt8((counter >> 8) & 0xFF)) - result.append(UInt8(counter & 0xFF)) + appendBigEndian(counter, to: &result) + appendBigEndian(round, to: &result) return result } + + private static func appendBigEndian(_ value: UInt32, to data: inout Data) { + data.append(UInt8((value >> 24) & 0xFF)) + data.append(UInt8((value >> 16) & 0xFF)) + data.append(UInt8((value >> 8) & 0xFF)) + data.append(UInt8(value & 0xFF)) + } } enum CPaceRole: Sendable { diff --git a/Sources/SendspinKit/Models/LeaveMessage.swift b/Sources/SendspinKit/Models/LeaveMessage.swift new file mode 100644 index 0000000..03cf4bd --- /dev/null +++ b/Sources/SendspinKit/Models/LeaveMessage.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Client request to leave the current server group. +struct ClientLeaveMessage: SendspinMessage, Equatable { + static let typeString = "client/leave" + let type = Self.typeString + let payload: ClientLeavePayload + + init(payload: ClientLeavePayload = ClientLeavePayload()) { + self.payload = payload + } + + private enum CodingKeys: String, CodingKey { case type, payload } +} + +/// Empty payload for `client/leave`. +struct ClientLeavePayload: Codable, Equatable, Sendable { + private enum CodingKeys: CodingKey {} +} diff --git a/Sources/SendspinKit/Models/PairingMessages.swift b/Sources/SendspinKit/Models/PairingMessages.swift index 6c399aa..1406408 100644 --- a/Sources/SendspinKit/Models/PairingMessages.swift +++ b/Sources/SendspinKit/Models/PairingMessages.swift @@ -53,10 +53,21 @@ struct ServerPairInitMessage: SendspinMessage, Equatable { } struct ServerPairInitPayload: Codable, Equatable, Sendable { - let nonceA: String + let nonceA: String? enum CodingKeys: String, CodingKey { case nonceA = "nonce_A" } } +struct ClientPairRetryMessage: SendspinMessage, Equatable { + static let typeString = "client/pair-retry" + let type = Self.typeString + let payload: ClientPairRetryPayload + private enum CodingKeys: String, CodingKey { case type, payload } +} + +struct ClientPairRetryPayload: Codable, Equatable, Sendable { + private enum CodingKeys: CodingKey {} +} + struct ServerPairAuthMessage: SendspinMessage, Equatable { static let typeString = "server/pair-auth" let type = Self.typeString diff --git a/Tests/SendspinKitTests/Audio/AudioEngineTests.swift b/Tests/SendspinKitTests/Audio/AudioEngineTests.swift index 0c310bf..6c91bdb 100644 --- a/Tests/SendspinKitTests/Audio/AudioEngineTests.swift +++ b/Tests/SendspinKitTests/Audio/AudioEngineTests.swift @@ -78,6 +78,7 @@ actor SpyAudioOutput: AudioOutput { /// Stands in for the device path a real output would measure. Zero keeps engine timing /// dependent only on buffer depth, which is what the startup-release tests reason about. var stubDeviceLatencyUs: Int64 = 0 + var outputDelayUs: Int64 = 0 var forcedStartThrow: Error? var forcedStartPreparedThrow: Error? var forcedSwapThrow: Error? @@ -173,6 +174,10 @@ actor SpyAudioOutput: AudioOutput { return depth + stubDeviceLatencyUs } + func setOutputDelayMicroseconds(_ delay: Int64) { + outputDelayUs = delay + } + /// Tests drive release timing directly; no real device to wait on. func waitUntilOutputDeviceIsLive() async throws { outputDeviceProbeCount += 1 @@ -361,8 +366,27 @@ struct AudioEngineTests { #expect(received, "Expected the chunk to reach the scheduler") let chunk = try #require(queued.first) - #expect(chunk.originalTimestamp == serverTimestamp - Int64(delayMs) * 1_000) + #expect(chunk.originalTimestamp == serverTimestamp) #expect(chunk.playTimeMicroseconds == serverTimestamp - Int64(delayMs) * 1_000) + let appliedDelayUs = await output.outputDelayUs + #expect(appliedDelayUs == Int64(delayMs) * 1_000) + } + + @Test("local output delay is applied after clock mapping exactly once") + func localOutputDelayIsIndependentOfClockOffsetAndDrift() { + let mappedLocalTime: Int64 = 9_876_543 + let delayUs: Int64 = 237_000 + #expect( + AudioEngine.localPlayTime(mappedLocalTime: mappedLocalTime, outputDelayMicroseconds: delayUs) + == mappedLocalTime - delayUs + ) + #expect( + AudioEngine.localPlayTime(mappedLocalTime: mappedLocalTime, outputDelayMicroseconds: -1) + == mappedLocalTime + ) + #expect( + AudioEngine.localPlayTime(mappedLocalTime: .min, outputDelayMicroseconds: 1) == nil + ) } @Test("send_ahead never changes timestamp-based scheduling") diff --git a/Tests/SendspinKitTests/Audio/AudioPlayerTests.swift b/Tests/SendspinKitTests/Audio/AudioPlayerTests.swift index 425867a..94877cc 100644 --- a/Tests/SendspinKitTests/Audio/AudioPlayerTests.swift +++ b/Tests/SendspinKitTests/Audio/AudioPlayerTests.swift @@ -181,44 +181,63 @@ struct AudioPlayerTests { // MARK: - Perceptual volume @Test - func graceExpiryRebaselineCursorAbsorbsStartupBias() { - let formatSampleRate = 44_100 - let formatChannels = 2 - let expectedServerTime: Int64 = 10_000_000 - // Only an input to the helper under test, not the thing being guarded — built from the - // primed buffer count so it stays a representative depth if that count changes. - let audioQueueLatencyUs = Int64(audioQueueBufferCount) * Int64(audioQueueBufferByteSize) - * 1_000_000 / Int64(formatSampleRate * formatChannels * 2) - let biasedRawCursor = expectedServerTime - - /// The error a frame handed over now reports: it becomes audible one pipeline latency - /// from now, so in equilibrium the cursor must LEAD by that latency. - func syncError(cursor: Int64) -> Int64 { - (expectedServerTime + audioQueueLatencyUs) - cursor - } - - let biasedSyncError = syncError(cursor: biasedRawCursor) - #expect(biasedSyncError > CorrectionPlanner.defaultEngageUs) - #expect(CorrectionPlanner().plan( - errorMicroseconds: biasedSyncError, - sampleRate: UInt32(formatSampleRate), - currentlyCorrecting: false - ).dropEveryNFrames > 0) - + func graceExpiryRebaselineUsesDriftAwareSharedEquilibrium() { + let snapshot = TimeFilterSnapshot( + offset: 5_000, + drift: 0.1, + lastUpdate: 500_000, + useDrift: true, + clientProcessStartAbsolute: 1_000_000 + ) + let localNow: Int64 = 2_000_000 + let pipelineLatencyUs: Int64 = 150_000 + let outputDelayUs: Int64 = 237_000 + let expectedLocalTarget = localNow + pipelineLatencyUs + outputDelayUs + // Independent calculation of localTimeToServer for this deliberately amplified drift. + let clientRelative = expectedLocalTarget - snapshot.clientProcessStartAbsolute + let expectedOffset = snapshot.offset + snapshot.drift * (Double(clientRelative) - Double(snapshot.lastUpdate)) + let independentlyMappedTarget = clientRelative + Int64(expectedOffset.rounded()) + + let correctionTarget = AudioPlayer.correctionEquilibriumServerTime( + snapshot: snapshot, + localNow: localNow, + pipelineLatencyUs: pipelineLatencyUs, + outputDelayUs: outputDelayUs + ) let rebaselinedCursor = AudioPlayer.graceExpiryRebaselineCursor( - expectedServerTime: expectedServerTime, - audioQueueLatencyUs: audioQueueLatencyUs + snapshot: snapshot, + localNow: localNow, + pipelineLatencyUs: pipelineLatencyUs, + outputDelayUs: outputDelayUs ) - #expect(rebaselinedCursor == expectedServerTime + audioQueueLatencyUs) - #expect(syncError(cursor: rebaselinedCursor) == 0) + #expect(correctionTarget == independentlyMappedTarget) + #expect(rebaselinedCursor == independentlyMappedTarget) + #expect(correctionTarget != snapshot.localTimeToServer(localNow) + pipelineLatencyUs + outputDelayUs) #expect(CorrectionPlanner().plan( - errorMicroseconds: syncError(cursor: rebaselinedCursor), - sampleRate: UInt32(formatSampleRate), + errorMicroseconds: independentlyMappedTarget - rebaselinedCursor, + sampleRate: 48_000, currentlyCorrecting: false ) == CorrectionSchedule()) } + @Test + func correctionEquilibriumSaturatesLocalLatencyBeforeSnapshotMapping() { + let snapshot = TimeFilterSnapshot( + offset: 0, + drift: 0, + lastUpdate: 0, + useDrift: false, + clientProcessStartAbsolute: 0 + ) + #expect(AudioPlayer.correctionEquilibriumServerTime( + snapshot: snapshot, + localNow: .max - 10, + pipelineLatencyUs: 100, + outputDelayUs: 100 + ) == .max) + } + @Test func perceptualGainAtBoundaries() { #expect(AudioPlayer.perceptualGain(0.0) == 0.0) diff --git a/Tests/SendspinKitTests/Audio/SyncCorrectionTests.swift b/Tests/SendspinKitTests/Audio/SyncCorrectionTests.swift index 59f103d..945c7af 100644 --- a/Tests/SendspinKitTests/Audio/SyncCorrectionTests.swift +++ b/Tests/SendspinKitTests/Audio/SyncCorrectionTests.swift @@ -49,6 +49,23 @@ struct SyncCorrectionTests { #expect(!timeline.usesDecodedTimeline) } + @Test + func decodedTimelineRebasesLocalDelayOnceAndKeepsWireCadence() { + var timeline = AudioChunkPlaybackTimeline() + _ = timeline.playTime(wireTimestampUs: 1_000_000, wirePlayTimeUs: 10_000_000, decodedDurationUs: 96_000) + _ = timeline.playTime(wireTimestampUs: 1_104_490, wirePlayTimeUs: 10_104_490, decodedDurationUs: 96_000) + timeline.rebaseOutputDelay(from: 100_000, to: 350_000) + let third = timeline.playTime( + wireTimestampUs: 1_200_490, + wirePlayTimeUs: 10_200_490 - 250_000, + decodedDurationUs: 96_000 + ) + + #expect(third.playTimeUs == 10_192_000 - 250_000) + #expect(third.playTimeUs > 10_096_000 - 250_000) + #expect(timeline.usesDecodedTimeline) + } + @Test func chunkTimingExactCadenceHasNoMismatch() { var diagnostics = ChunkTimingDiagnostics() @@ -112,6 +129,21 @@ struct SyncCorrectionTests { #expect(schedule == CorrectionSchedule()) } + @Test + func correctionIntervalsStayBoundedAtSupportedSampleRates() { + let planner = CorrectionPlanner() + for sampleRate in [24_000, 32_000, 44_100, 48_000, 96_000] { + let schedule = planner.plan( + errorMicroseconds: 100_000, + sampleRate: UInt32(sampleRate), + currentlyCorrecting: false + ) + #expect(schedule.dropEveryNFrames > 0) + let maxInterval = UInt32(Double(sampleRate) / (Double(sampleRate) * CorrectionPlanner.defaultMaxSpeedCorrection)) + #expect(schedule.dropEveryNFrames >= maxInterval - 1) + } + } + @Test func positiveErrorProducesDropSchedule() { let planner = CorrectionPlanner() diff --git a/Tests/SendspinKitTests/AudioSchedulerTests.swift b/Tests/SendspinKitTests/AudioSchedulerTests.swift index ee37e01..e3eb817 100644 --- a/Tests/SendspinKitTests/AudioSchedulerTests.swift +++ b/Tests/SendspinKitTests/AudioSchedulerTests.swift @@ -37,6 +37,49 @@ struct AudioSchedulerTests { #expect(chunks[0].playTimeMicroseconds == 1_000_000) } + @Test + func schedulerMapsNonzeroOffsetAndDriftDeterministically() async { + let clockSync = MockClockSynchronizer(offset: 125_000, drift: 0.002) + let scheduler = AudioScheduler(clockSync: clockSync) + let serverTimestamp: Int64 = 2_000_000 + + await scheduler.schedule(pcm: Data([0x01]), serverTimestamp: serverTimestamp) + + let chunks = await scheduler.queuedChunks + let chunk = chunks[0] + let expected = Int64((Double(serverTimestamp - 125_000) / 1.002).rounded()) + #expect(chunk.originalTimestamp == serverTimestamp) + #expect(chunk.playTimeMicroseconds == expected) + } + + @Test + func schedulerRebasesPendingDelayWithoutChangingWireMetadataOrOrder() async { + let clockSync = MockClockSynchronizer(offset: 0, drift: 0.0) + let scheduler = AudioScheduler(clockSync: clockSync) + let first: Int64 = 10_000_000 + let second: Int64 = 10_100_000 + await scheduler.schedule(pcm: Data([1]), serverTimestamp: first, playTimeMicroseconds: first) + await scheduler.schedule(pcm: Data([2]), serverTimestamp: second, playTimeMicroseconds: second) + + await scheduler.rebaseOutputDelay(from: 100_000, to: 350_000) + let rebased = await scheduler.queuedChunks + + #expect(rebased.map(\.originalTimestamp) == [first, second]) + #expect(rebased.map(\.playTimeMicroseconds) == [first - 250_000, second - 250_000]) + #expect(rebased[0].playTimeMicroseconds < rebased[1].playTimeMicroseconds) + } + + @Test + func schedulerRebaseDoesNotShiftAlreadyYieldedChunk() async { + let clockSync = MockClockSynchronizer(offset: 0, drift: 0.0) + let scheduler = AudioScheduler(clockSync: clockSync) + let playTime = MonotonicClock.absoluteMicroseconds() + await scheduler.schedule(pcm: Data([1]), serverTimestamp: playTime, playTimeMicroseconds: playTime) + await scheduler.checkQueue() + await scheduler.rebaseOutputDelay(from: 0, to: 250_000) + #expect(await scheduler.queuedChunks.isEmpty) + } + @Test func schedulerMaintainsSortedQueue() async { let clockSync = MockClockSynchronizer(offset: 0, drift: 0.0) @@ -248,13 +291,15 @@ struct AudioSchedulerTests { /// Mock ClockSynchronizer for testing actor MockClockSynchronizer: ClockSyncProtocol { private let offset: Int64 + private let drift: Double var hasSynced: Bool { true } - init(offset: Int64, drift _: Double) { + init(offset: Int64, drift: Double) { self.offset = offset + self.drift = drift } func processServerTime( @@ -265,11 +310,11 @@ actor MockClockSynchronizer: ClockSyncProtocol { ) {} func serverTimeToLocal(_ serverTime: Int64) -> Int64 { - serverTime - offset + Int64(((Double(serverTime) - Double(offset)) / (1.0 + drift)).rounded()) } func localTimeToServer(_ localTime: Int64) -> Int64 { - localTime + offset + Int64((Double(localTime) * (1.0 + drift)) + Double(offset)) } func snapshot() -> TimeFilterSnapshot? { diff --git a/Tests/SendspinKitTests/Client/ConcurrentPairingTests.swift b/Tests/SendspinKitTests/Client/ConcurrentPairingTests.swift new file mode 100644 index 0000000..c7555f0 --- /dev/null +++ b/Tests/SendspinKitTests/Client/ConcurrentPairingTests.swift @@ -0,0 +1,348 @@ +import Foundation +@testable import SendspinKit +import Testing + +/// End-to-end coverage for a playback holder and one concurrently parked pairing side. +@MainActor +@Suite("Concurrent pairing", .timeLimit(.minutes(1))) +struct ConcurrentPairingTests { + @Test("pairing side is admitted beside playback and emits its dynamic code without changing primary UI") + func pairingSideRetainsPrimary() async throws { + let session = try await makeSession() + let primaryId = session.client.currentServerId + let primaryConnection = session.client.connection + try await session.primary.injectText(metadataStateJSON(title: "Primary Track")) + #expect(await waitUntil { await MainActor.run { session.client.currentMetadata?.title == "Primary Track" } }) + let primaryMetadata = session.client.currentMetadata + + let codeTask = Task { + await collectClientEvent(from: session.events) { + if case .pairingCodeChanged(.some) = $0 { + return true + } + return false + } + } + let side = try await admitPairingSide(to: session.client) + _ = try await waitForClientJSON(side, type: ClientPairInitMessage.typeString) + let nonceA = Base64URL.encode(Data(repeating: 0, count: 32)) + let pairInit = ServerPairInitMessage(payload: ServerPairInitPayload(nonceA: nonceA)) + try await side.sendJSON(#require(String(data: JSONEncoder().encode(pairInit), encoding: .utf8))) + let code = await codeTask.value + + #expect(code != nil) + #expect(session.client.connection === primaryConnection) + #expect(session.client.pairingConnection != nil) + #expect(session.client.currentServerId == primaryId) + #expect(session.client.currentMetadata == primaryMetadata) + #expect(session.client.currentActivities == [.playback]) + #expect(await side.disconnectCalled == false) + + await session.client.disconnect() + } + + @Test("a second pairing side is rejected while the first side is parked") + func secondPairingIsRejected() async throws { + let session = try await makeSession() + _ = try await admitPairingSide(to: session.client) + let secondTransport = MockTransport() + let second = MockNoiseServer(transport: secondTransport, psk: .sentinel) + + let accepted = Task { + try? await session.client.acceptConnection(secondTransport) + } + try await second.beginAdmission(name: "Second Pairing") + try await sendDynamicPairingActivation(to: second) + _ = await accepted.value + + let abort = try await waitForClientJSON(second, type: PairAbortMessage.typeString) + let decoded = try JSONDecoder().decode(PairAbortMessage.self, from: abort) + #expect(decoded.payload.reason == .concurrentAttempt) + #expect(await second.disconnectCalled) + #expect(session.client.connection != nil) + #expect(session.client.pairingConnection != nil) + + await session.client.disconnect() + } + + @Test("playback activation promotes the pairing side at the equal playback rank") + func pairingSidePromotesWithoutRehandshake() async throws { + let session = try await makeSession() + try await session.primary.injectText(metadataStateJSON(title: "Primary Track")) + #expect(await waitUntil { await MainActor.run { session.client.currentMetadata?.title == "Primary Track" } }) + let side = try await admitPairingSide(to: session.client) + let sideConnection = session.client.pairingConnection + let primary = session.primary + let primaryConnection = session.client.connection + let primaryGoodbyesBefore = await primary.clientJSONMessages(ofType: ClientGoodbyeMessage.typeString).count + let sideHelloCountBefore = await side.clientJSONMessages(ofType: ClientHelloMessage.typeString).count + let sideHandshakeMessagesBefore = await side.clientJSONMessages(ofType: ClientInitMessage.typeString).count + let audio = ConcurrentCollectedValues() + let audioTask = Task { + for await chunk in session.client.audioChunks { + await audio.append(chunk) + if await audio.count == 1 { + break + } + } + } + + try await side.sendActivation(activities: [.playback], activeRoles: [.playerV1]) + + #expect(await waitUntil(timeout: .seconds(3)) { + await MainActor.run { + session.client.connection === sideConnection && session.client.pairingConnection == nil + } + }) + #expect(session.client.connection !== primaryConnection) + #expect(session.client.currentMetadata == nil) + let sideServerId = await side.serverId + #expect(session.client.currentServerId == sideServerId) + #expect(await side.clientJSONMessages(ofType: ClientHelloMessage.typeString).count == sideHelloCountBefore) + #expect(await side.clientJSONMessages(ofType: ClientInitMessage.typeString).count == sideHandshakeMessagesBefore) + #expect(await primary.clientJSONMessages(ofType: ClientGoodbyeMessage.typeString).count == primaryGoodbyesBefore + 1) + #expect(await sentGoodbyeReasons(from: primary).last == .anotherServer) + #expect(await primary.disconnectCalled) + + try await side.injectText(metadataStateJSON(title: "Promoted Track")) + #expect(await waitUntil { await MainActor.run { session.client.currentMetadata?.title == "Promoted Track" } }) + try await side.injectText(streamStartPCMJSON()) + try await establishClockSync(session.client, via: side) + await side.injectBinary(audioChunkFrame(index: 1)) + #expect(await waitUntil(timeout: .seconds(3)) { await audio.count == 1 }) + #expect(await audio.all.first?.serverTimestamp == audioChunkTimestamp(index: 1)) + audioTask.cancel() + + await session.client.disconnect() + } + + @Test("promotion cannot resurrect a parked side after disconnect during primary shutdown") + func disconnectDuringPromotionTeardownDoesNotResurrectSide() async throws { + let session = try await makeSession() + let side = try await admitPairingSide(to: session.client) + let primary = session.primary + await primary.enableGoodbyeGate() + let promotion = Task { + try await sideTransportActivation(from: side) + } + + #expect(await waitUntil { await primary.isGoodbyeGateWaiting }) + await session.client.disconnect(reason: .userRequest) + await primary.releaseGoodbyeGate() + _ = try await promotion.value + + #expect(session.client.connection == nil) + #expect(session.client.pairingConnection == nil) + #expect(session.client.connectionState == .disconnected) + #expect(await side.disconnectCalled) + } + + @Test("empty activation is rejected by the pairing side without affecting playback") + func emptyActivationRejectsSide() async throws { + let session = try await makeSession() + let side = try await admitPairingSide(to: session.client) + let primaryConnection = session.client.connection + let primaryId = session.client.currentServerId + let goodbyeTask = Task { + try? await waitForClientJSON(side, type: ClientGoodbyeMessage.typeString) + } + + try await side.sendActivation(activities: [], activeRoles: []) + + let goodbye = try #require(await goodbyeTask.value) + let decoded = try JSONDecoder().decode(ClientGoodbyeMessage.self, from: goodbye) + #expect(decoded.payload.reason == .concurrentAttempt) + #expect(await side.disconnectCalled) + #expect(await waitUntil { await MainActor.run { session.client.pairingConnection == nil } }) + #expect(session.client.connection === primaryConnection) + #expect(session.client.currentServerId == primaryId) + #expect(session.client.connectionState == .connected) + + await session.client.disconnect() + } + + @Test("cancelPairingAttempt targets the parked side and leaves playback connected") + func cancelTargetsPairingSide() async throws { + let session = try await makeSession() + let side = try await admitPairingSide(to: session.client) + _ = await collectClientEvent(from: session.events) { + if case .pairingCodeChanged(.some) = $0 { + return true + } + return false + } + + try await session.client.cancelPairingAttempt() + + let abort = try await waitForClientJSON(side, type: PairAbortMessage.typeString) + let decoded = try JSONDecoder().decode(PairAbortMessage.self, from: abort) + #expect(decoded.payload.reason == .userCancelled) + #expect(session.client.connection === session.primaryConnection) + #expect(session.client.pairingConnection != nil) + #expect(await side.disconnectCalled == false) + #expect(session.client.connectionState == .connected) + + await session.client.disconnect() + } + + @Test("pairing-side transport failure does not disconnect playback") + func sideFailureDoesNotDisconnectPrimary() async throws { + let session = try await makeSession() + let side = try await admitPairingSide(to: session.client) + let primaryConnection = session.client.connection + let primaryId = session.client.currentServerId + + await side.simulateClose(.failed(description: "pairing side failed")) + + #expect(await waitUntil { await MainActor.run { session.client.pairingConnection == nil } }) + #expect(session.client.connection === primaryConnection) + #expect(session.client.currentServerId == primaryId) + #expect(session.client.connectionState == .connected) + #expect(await session.primary.disconnectCalled == false) + + await session.client.disconnect() + } + + @Test("disconnecting the primary closes both the primary and parked pairing side") + func primaryDisconnectClosesBoth() async throws { + let session = try await makeSession() + let side = try await admitPairingSide(to: session.client) + + await session.primary.simulateClose(.peerClosed(code: nil)) + + #expect(await waitUntil { await MainActor.run { + session.client.connection == nil && session.client.pairingConnection == nil + } }) + #expect(session.client.connectionState == .disconnected) + #expect(await side.disconnectCalled) + } + + private struct Session { + let client: SendspinClient + let primary: MockNoiseServer + let events: AsyncStream + let primaryConnection: SendspinConnection? + } + + private func makeSession() async throws -> Session { + let pairingPsk = Psk.generate() + let store = InMemoryPairingRecordStore(pairingPsk: pairingPsk) + let client = try SendspinClient( + identity: .generate(), + name: "Concurrent Pairing Client", + roles: [.playerV1, .controllerV1], + playerConfig: PlayerConfiguration( + bufferCapacity: 65_536, + supportedFormats: [AudioFormatSpec(codec: .pcm, channels: 1, sampleRate: 8_000, bitDepth: 16)], + volumeMode: .none, + emitRawAudioEvents: true + ), + pairing: PairingConfiguration( + pairingPsk: pairingPsk, + store: store, + enabled: false, + dynamicPairingCodeEnabled: true + ), + audioOutputCapabilityProvider: makeInertAudioOutputCapabilityProvider(), + handshakeTimeout: .seconds(3), + pairingAttemptTimeout: .seconds(30), + pairingWindowLifetime: .seconds(30) + ) + let events = client.events() + let primary = try await connectClient(client, activeRoles: [.playerV1, .controllerV1], activities: [.playback]) + let primaryConnection = client.connection + return Session(client: client, primary: primary, events: events, primaryConnection: primaryConnection) + } + + private func admitPairingSide(to client: SendspinClient) async throws -> MockNoiseServer { + let transport = MockTransport() + let side = MockNoiseServer(transport: transport, psk: .sentinel) + async let accepted: Void = client.acceptConnection(transport) + try await side.beginAdmission(name: "Pairing Side") + try await sendDynamicPairingActivation(to: side) + try await accepted + #expect(await waitUntil { await MainActor.run { client.pairingConnection != nil } }) + return side + } +} + +private func sideTransportActivation(from side: MockNoiseServer) async throws { + try await side.sendActivation(activities: [.playback], activeRoles: [.playerV1]) +} + +private func sendDynamicPairingActivation(to server: MockNoiseServer) async throws { + let activation = ServerActivateMessage( + payload: ServerActivatePayload( + activities: [.pairing], + activeRoles: [], + pairing: PairingDirective(method: PairMethod.dynamicPairingCode, format: PairingCodeFormat.digits.rawValue) + ) + ) + let data = try JSONEncoder().encode(activation) + try await server.sendJSON(#require(String(data: data, encoding: .utf8))) +} + +private func waitForClientJSON(_ server: MockNoiseServer, type: String) async throws -> Data { + #expect(await waitUntil(timeout: .seconds(3)) { + await server.clientJSONMessages(ofType: type).count >= 1 + }) + guard let message = await server.clientJSONMessages(ofType: type).last else { + throw ConcurrentPairingTestError.missingMessage(type) + } + return message +} + +private func metadataStateJSON(title: String) throws -> String { + let message = ServerStateMessage(payload: ServerStatePayload( + metadata: ServerMetadataState(title: .value(title)) + )) + return try #require(String(data: JSONEncoder().encode(message), encoding: .utf8)) +} + +private func streamStartPCMJSON() throws -> String { + let message = StreamStartMessage(payload: StreamStartPayload( + player: StreamStartPlayer(codec: AudioCodec.pcm.rawValue, sampleRate: 8_000, channels: 1, bitDepth: 16, codecHeader: nil), + artwork: nil, + visualizer: nil + )) + return try #require(String(data: JSONEncoder().encode(message), encoding: .utf8)) +} + +private func audioChunkTimestamp(index: Int, baseTimestamp: Int64 = 1_000_000) -> Int64 { + baseTimestamp + Int64(index) * 25_000 +} + +private func audioChunkFrame(index: Int, baseTimestamp: Int64 = 1_000_000) -> Data { + var frame = Data([BinaryMessageType.audioChunk.rawValue]) + var timestamp = audioChunkTimestamp(index: index, baseTimestamp: baseTimestamp).bigEndian + frame.append(Data(bytes: ×tamp, count: MemoryLayout.size)) + frame.append(contentsOf: [0, 0, 0, 0]) + frame.append(Data(repeating: 0x7F, count: 400)) + return frame +} + +private func sentGoodbyeReasons(from server: MockNoiseServer) async -> [GoodbyeReason] { + await server.clientJSONMessages(ofType: ClientGoodbyeMessage.typeString).compactMap { + try? JSONDecoder().decode(ClientGoodbyeMessage.self, from: $0).payload.reason + } +} + +private actor ConcurrentCollectedValues { + private var values: [Element] = [] + + var count: Int { + values.count + } + + var all: [Element] { + values + } + + func append(_ value: Element) { + values.append(value) + } +} + +private enum ConcurrentPairingTestError: Error { + case missingMessage(String) +} diff --git a/Tests/SendspinKitTests/Client/DigitAudioPairingTests.swift b/Tests/SendspinKitTests/Client/DigitAudioPairingTests.swift index 7e89d72..009cee9 100644 --- a/Tests/SendspinKitTests/Client/DigitAudioPairingTests.swift +++ b/Tests/SendspinKitTests/Client/DigitAudioPairingTests.swift @@ -134,8 +134,8 @@ struct DigitAudioPairingTests { @Test("clip before client/pair-init closes silently") func clipBeforePairInitCloses() async throws { let store = InMemoryPairingRecordStore() - for _ in 0 ..< dynamicPairingFailureEscalationThreshold { - _ = await store.incrementDynamicPairingFailureCount() + for _ in 0 ..< dynamicPairingRoundLimit { + _ = await store.incrementDynamicPairingRoundCount() } let session = try await makeDigitAudioSession(store: store) try await activateDigits(session.server) @@ -319,7 +319,7 @@ struct SpeakerDigitAudioTranscriptTests { #expect(emission.digitAudioPack?.clips.count == DigitAudioPackConstants.clipCount) let handshakeHash = try #require(await session.server.establishedHandshakeHash) - let sid = CPaceSessionIdentifier.make(handshakeHash: handshakeHash, counter: pairInit.payload.pairingIndex) + let sid = CPaceSessionIdentifier.make(handshakeHash: handshakeHash, counter: pairInit.payload.pairingIndex, round: 1) let cpace = try CPace(role: .initiator, prs: Data(emission.payload.utf8), sid: sid) let auth = ServerPairAuthMessage( payload: ServerPairAuthPayload(pakeMsg1: Base64URL.encode(cpace.publicShare)) diff --git a/Tests/SendspinKitTests/Client/DynamicPairingTranscriptTests.swift b/Tests/SendspinKitTests/Client/DynamicPairingTranscriptTests.swift index 59943d4..bf564f4 100644 --- a/Tests/SendspinKitTests/Client/DynamicPairingTranscriptTests.swift +++ b/Tests/SendspinKitTests/Client/DynamicPairingTranscriptTests.swift @@ -200,7 +200,8 @@ private func dynamicServerTranscript( } let sid = CPaceSessionIdentifier.make( handshakeHash: handshakeHash, - counter: fixture.counter + counter: initMessage.payload.pairingIndex, + round: 1 ) let prs = emission.format == .digits ? Data(emission.payload.utf8) : try qrPayload(emission.payload) let cpace = try CPace( @@ -325,31 +326,78 @@ struct DynamicPairingTranscriptTests { await session.client.disconnect() } - @Test("binding mismatch aborts with pairing_code_mismatch and keeps the socket open") + @Test("binding mismatch retries with pairing_code_mismatch still available to a later round") func bindingMismatch() async throws { let session = try await makeDynamicTestSession() _ = try await dynamicServerTranscript(session, badServerConfirmation: true) - let abortData = try await waitForClientMessage(session.server, type: PairAbortMessage.typeString) - let abort = try JSONDecoder().decode(PairAbortMessage.self, from: abortData) - #expect(abort.payload.reason.rawValue == "pairing_code_mismatch") + _ = try await waitForClientMessage(session.server, type: ClientPairRetryMessage.typeString) #expect(await session.store.listRecords().allSatisfy { $0.serverId == nil }) - #expect(await endedEvent(session.events, reason: .pairingCodeMismatch) != nil) - #expect(await collectClientEvent(from: session.events) { $0 == .pairingCodeChanged(nil) } != nil) #expect(await MainActor.run { session.client.connectionState == .connected }) + #expect(await collectClientEvent(from: session.events, timeout: .milliseconds(100)) { + if case .pairingAttemptEnded = $0 { + return true + } + return false + } == nil) + #expect(await collectClientEvent(from: session.events, timeout: .milliseconds(100)) { $0 == .pairingCodeChanged(nil) } == nil) try await session.server.sendJSON(#"{"type":"server/state","payload":{}}"#) #expect(await MainActor.run { session.client.connectionState == .connected }) await session.client.disconnect() } - @Test("invalid server confirmation increments once, clears code, and keeps connection open") + @Test("invalid server confirmation retries with a fresh round and can then succeed") func serverConfirmationFailure() async throws { - let session = try await makeDynamicTestSession() - _ = try await dynamicServerTranscript(session, badServerConfirmation: true) - _ = try await waitForClientMessage(session.server, type: PairAbortMessage.typeString) + let fixture = try dynamicFixture() + let session = try await makeDynamicTestSession( + nonceBOverride: dataFromHex(fixture.nonceB), + pairingHandshakeHashOverride: dataFromHex(fixture.handshakeHash) + ) + let (emission, _) = try await dynamicServerTranscript(session, badServerConfirmation: true) + _ = try await waitForClientMessage(session.server, type: ClientPairRetryMessage.typeString) #expect(await session.store.dynamicPairingFailureCount() == 1) - #expect(await endedEvent(session.events, reason: .pairingCodeMismatch) != nil) - #expect(await collectClientEvent(from: session.events) { $0 == .pairingCodeChanged(nil) } != nil) - #expect(await MainActor.run { session.client.connectionState == .connected }) + #expect(await session.store.dynamicPairingRoundCount() == 1) + #expect(emission.payload.count == 6) + + let retryEventTask = Task { await codeEvent(session.events) } + try await session.server.sendJSON(#"{"type":"server/pair-init","payload":{}}"#) + let retryEmissionEvent = try #require(await retryEventTask.value) + let retryEmission = try retryEmissionEvent.unwrapEmission() + #expect(retryEmission.payload == emission.payload) + let roundTwoSID = CPaceSessionIdentifier.make( + handshakeHash: dataFromHex(fixture.handshakeHash), + counter: fixture.counter, + round: 2 + ) + let retryCPace = try CPace( + role: .initiator, + prs: Data(retryEmission.payload.utf8), + sid: roundTwoSID, + scalarOverride: dataFromHex(fixture.scalarA) + ) + try await session.server.sendJSON(#require(String(data: JSONEncoder().encode(ServerPairAuthMessage( + payload: ServerPairAuthPayload(pakeMsg1: Base64URL.encode(retryCPace.publicShare)) + )), encoding: .utf8))) + let retryAuth = try await JSONDecoder().decode( + ClientPairAuthMessage.self, + from: waitForClientMessage(session.server, type: ClientPairAuthMessage.typeString, count: 2) + ) + let retryShare = try #require(Base64URL.decode(retryAuth.payload.pakeMsg2, count: 32)) + let retrySecrets = try retryCPace.derive(remoteShare: retryShare) + let retryTag = CPaceX25519.mcfTag( + isk: retrySecrets.isk, + sid: roundTwoSID, + share: retryCPace.publicShare, + associatedData: CPaceX25519.defaultInitiatorAD + ) + try await session.server.sendJSON(#require(String(data: JSONEncoder().encode(ServerPairConfirmMessage( + payload: ServerPairConfirmPayload(serverKc: Base64URL.encode(retryTag)) + )), encoding: .utf8))) + _ = try await waitForClientMessage(session.server, type: ClientPairConfirmMessage.typeString, count: 1) + _ = try await waitForClientMessage(session.server, type: ClientPairFinalizeMessage.typeString, count: 1) + #expect(await session.store.dynamicPairingFailureCount() == 0) + #expect(await session.store.dynamicPairingRoundCount() == 0) + try await session.server.sendJSON(#"{"type":"server/pair-finalize","payload":{}}"#) + #expect(await waitUntil { await session.store.listRecords().contains { $0.serverId != nil } }) await session.client.disconnect() } @@ -426,14 +474,12 @@ struct DynamicPairingTranscriptTests { } } -@Suite("Dynamic pairing failure counter", .timeLimit(.minutes(1))) +@Suite("Dynamic pairing budget", .timeLimit(.minutes(1))) struct DynamicPairingFailureCounterTests { - @Test("one failure below the escalation threshold starts immediately") - func oneBelowEscalationThresholdStartsImmediately() async throws { + @Test("failure count does not gate a fresh dynamic attempt") + func failureCountDoesNotGate() async throws { let store = InMemoryPairingRecordStore() - for _ in 0 ..< dynamicPairingFailureEscalationThreshold - 1 { - _ = await store.incrementDynamicPairingFailureCount() - } + _ = await store.incrementDynamicPairingFailureCount() let session = try await makeDynamicTestSession(store: store) try await activateDynamic(session.server) _ = try await waitForClientMessage(session.server, type: ClientPairInitMessage.typeString) @@ -442,13 +488,14 @@ struct DynamicPairingFailureCounterTests { await session.client.disconnect() } - @Test("counter increments only for server confirmation failures and resets after success") + @Test("failure counter resets after a verified confirmation") func counterSemantics() async throws { let store = InMemoryPairingRecordStore() let session = try await makeDynamicTestSession(store: store) _ = try await dynamicServerTranscript(session, badServerConfirmation: true) - _ = try await waitForClientMessage(session.server, type: PairAbortMessage.typeString) + _ = try await waitForClientMessage(session.server, type: ClientPairRetryMessage.typeString) #expect(await store.dynamicPairingFailureCount() == 1) + #expect(await store.dynamicPairingRoundCount() == 1) #expect(await MainActor.run { session.client.connectionState == .connected }) await session.client.disconnect() @@ -459,28 +506,17 @@ struct DynamicPairingFailureCounterTests { #expect(await store.dynamicPairingFailureCount() == 0) await success.client.disconnect() - let escalatedStore = InMemoryPairingRecordStore() - for _ in 0 ..< dynamicPairingFailureEscalationThreshold { - _ = await escalatedStore.incrementDynamicPairingFailureCount() - } - let escalated = try await makeDynamicTestSession(store: escalatedStore) - try await activateDynamic(escalated.server) - _ = try await waitForClientMessage(escalated.server, type: ClientPairPendingMessage.typeString) - #expect(await escalated.server.clientJSONMessages(ofType: ClientPairInitMessage.typeString).isEmpty) - try await escalated.client.openPairingWindow() - _ = try await waitForClientMessage(escalated.server, type: ClientPairInitMessage.typeString) - try await escalated.client.cancelPairingAttempt() - await escalated.client.disconnect() + await session.client.disconnect() } } @Suite("Pairing window", .timeLimit(.minutes(1))) struct PairingWindowTests { - @Test("escalated dynamic attempt waits for one open window and does not time out while pending") - func dynamicEscalation() async throws { + @Test("round-limit attempts wait for an operator window and do not start the timeout") + func roundLimitWaitsForWindow() async throws { let store = InMemoryPairingRecordStore() - for _ in 0 ..< dynamicPairingFailureEscalationThreshold { - _ = await store.incrementDynamicPairingFailureCount() + for _ in 0 ..< dynamicPairingRoundLimit { + _ = await store.incrementDynamicPairingRoundCount() } let session = try await makeDynamicTestSession(store: store, attemptTimeout: .milliseconds(100)) try await activateDynamic(session.server) diff --git a/Tests/SendspinKitTests/Client/RehandshakeTests.swift b/Tests/SendspinKitTests/Client/RehandshakeTests.swift index db84543..b1983a4 100644 --- a/Tests/SendspinKitTests/Client/RehandshakeTests.swift +++ b/Tests/SendspinKitTests/Client/RehandshakeTests.swift @@ -602,6 +602,16 @@ private actor ThrowingPairingRecordStore: PairingRecordStore { func remove(pskId _: String) async {} func markUsed(pskId _: String) async {} + + func dynamicPairingRoundCount() async -> UInt32 { + 0 + } + + func incrementDynamicPairingRoundCount() async -> UInt32 { + 0 + } + + func resetDynamicPairingRoundCount() async {} } /// A bounded store whose free space cannot fit a stored-pubkey record. The @@ -609,6 +619,7 @@ private actor ThrowingPairingRecordStore: PairingRecordStore { /// path) so pairing must go through the record-mode fallback. private actor ExhaustedPairingRecordStore: PairingRecordStore { private(set) var records: [PairingRecord] = [] + private var rounds: UInt32 = 0 private let retainsPreProvisionedRecord: Bool init(retainsPreProvisionedRecord: Bool) { @@ -640,4 +651,17 @@ private actor ExhaustedPairingRecordStore: PairingRecordStore { func storageAccounting() async -> PairingStorageAccounting? { PairingStorageAccounting(free: 0, capacity: 10, costIndividual: 1, costShared: 1) } + + func dynamicPairingRoundCount() async -> UInt32 { + rounds + } + + func incrementDynamicPairingRoundCount() async -> UInt32 { + rounds += 1 + return rounds + } + + func resetDynamicPairingRoundCount() async { + rounds = 0 + } } diff --git a/Tests/SendspinKitTests/Client/SendspinClientTests.swift b/Tests/SendspinKitTests/Client/SendspinClientTests.swift index e9f8ff5..b7d14fb 100644 --- a/Tests/SendspinKitTests/Client/SendspinClientTests.swift +++ b/Tests/SendspinKitTests/Client/SendspinClientTests.swift @@ -1115,12 +1115,13 @@ struct SendspinClientTests { .compactMap { (try? decoder.decode(ClientGoodbyeMessage.self, from: $0))?.payload.reason } } - private func streamFinishes(_ stream: AsyncStream) async -> Bool { + private func streamFinishes(_ stream: S) async -> Bool + where S.Element: Sendable { let result = await outcomeOfUnstructuredOperation( timeout: .seconds(1), operation: { var iterator = stream.makeAsyncIterator() - while await iterator.next() != nil {} + while try await iterator.next() != nil {} return true } ) diff --git a/Tests/SendspinKitTests/Client/VisualizerDataDeliveryTests.swift b/Tests/SendspinKitTests/Client/VisualizerDataDeliveryTests.swift new file mode 100644 index 0000000..9d8446e --- /dev/null +++ b/Tests/SendspinKitTests/Client/VisualizerDataDeliveryTests.swift @@ -0,0 +1,425 @@ +import Foundation +@testable import SendspinKit +import Testing + +struct VisualizerDataDeliveryTests { + @Test("cancellation before parking lets a new iterator reclaim ownership") + func cancellationBeforeParkDoesNotStealFrame() async { + let mailbox = VisualizerDataMailbox(capacityBytes: 64) + let cancelled = Task { () -> VisualizerData? in + await Task.yield() + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + return await iterator.next() + } + cancelled.cancel() + #expect(await cancelled.value == nil) + + let value = VisualizerData(type: .peak, data: Data([1]), localDisplayTime: .max) + mailbox.offer(value, now: 0) + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + #expect(await iterator.next() == value) + mailbox.finish() + } + + @Test("cancellation while parked lets a new iterator reclaim ownership") + func cancellationWhileParkedDoesNotStealFrame() async { + let clock = MailboxTestClock() + let mailbox = VisualizerDataMailbox(capacityBytes: 64, now: { clock.read() }) + let waiting = Task { () -> VisualizerData? in + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + return await iterator.next() + } + #expect(await clock.waitUntilReadCount(2)) + waiting.cancel() + #expect(await waiting.value == nil) + + let value = VisualizerData(type: .peak, data: Data([2]), localDisplayTime: .max) + mailbox.offer(value, now: 0) + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + #expect(await iterator.next() == value) + mailbox.finish() + } + + @Test("a second live iterator returns nil without replacing the owner") + func iteratorOwnershipIsStable() async { + let clock = MailboxTestClock() + let mailbox = VisualizerDataMailbox(capacityBytes: 64, now: { clock.read() }) + let parked = Task { () -> VisualizerData? in + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + return await iterator.next() + } + #expect(await clock.waitUntilReadCount(2)) + + var second = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + #expect(await second.next() == nil) + let value = VisualizerData(type: .peak, data: Data([3]), localDisplayTime: .max) + mailbox.offer(value, now: 0) + #expect(await parked.value == value) + mailbox.finish() + } + + @Test("mailbox admission remains safe at Int.max capacity") + func intMaxCapacityDoesNotOverflow() async { + let mailbox = VisualizerDataMailbox(capacityBytes: .max) + let value = VisualizerData(type: .loudness, data: Data([4, 5]), localDisplayTime: .max) + mailbox.offer(value, now: 0) + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + #expect(await iterator.next() == value) + mailbox.finish() + } + + @Test("mailbox rechecks a frame deadline after a waiter resumes") + func resumedExpiredFrameIsDropped() async { + let clock = MailboxTestClock(value: 0) + let mailbox = VisualizerDataMailbox(capacityBytes: 64, now: { clock.read() }) + let pending = Task { () -> VisualizerData? in + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + return await iterator.next() + } + #expect(await clock.waitUntilReadCount(2)) + clock.setValue(10) + mailbox.offer( + VisualizerData(type: .beat, data: Data([6]), localDisplayTime: 5), + now: 0 + ) + mailbox.finish() + #expect(await pending.value == nil) + } + + @Test("a canceled read that was woken does not transfer its frame to a newer waiter") + func cancellationAfterWakeDropsFrameWithoutStealing() async { + let clock = MailboxTestClock() + let mailbox = VisualizerDataMailbox(capacityBytes: 64, now: { clock.read() }) + let oldRead = Task { () -> VisualizerData? in + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + return await iterator.next() + } + #expect(await clock.waitUntilReadCount(2)) + + let first = VisualizerData(type: .peak, data: Data([7]), localDisplayTime: .max) + mailbox.offer(first, now: 0) + oldRead.cancel() + + let newer = Task { () -> VisualizerData? in + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + return await iterator.next() + } + #expect(await clock.waitUntilReadCount(4)) + #expect(await oldRead.value == nil) + + let second = VisualizerData(type: .peak, data: Data([8]), localDisplayTime: .max) + mailbox.offer(second, now: 0) + #expect(await newer.value == second) + mailbox.finish() + } + + @Test("invalidation after a wake never delivers an invalid frame") + func invalidationAfterWakeDropsFrame() async { + let validity = VisualizerFrameValidity() + let clock = MailboxTestClock(blockedRead: 3) + let mailbox = VisualizerDataMailbox(capacityBytes: 64, now: { clock.read() }) + let pending = Task { () -> VisualizerData? in + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + return await iterator.next() + } + #expect(await clock.waitUntilReadCount(2)) + + let value = VisualizerData( + type: .beat, + data: Data([9]), + localDisplayTime: .max, + validity: validity + ) + mailbox.offer(value, now: 0) + #expect(await clock.waitUntilReadCount(3)) + validity.invalidate() + clock.releaseBlockedRead() + mailbox.finish() + #expect(await pending.value == nil) + } + + @Test("an abandoned iterator token releases ownership") + func abandonedIteratorReclaimsOwnership() async { + let mailbox = VisualizerDataMailbox(capacityBytes: 64) + let first = VisualizerData(type: .peak, data: Data([10]), localDisplayTime: .max) + mailbox.offer(first, now: 0) + + do { + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + #expect(await iterator.next() == first) + } + await Task.yield() + + let second = VisualizerData(type: .peak, data: Data([11]), localDisplayTime: .max) + mailbox.offer(second, now: 0) + var replacement = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + #expect(await replacement.next() == second) + mailbox.finish() + } + + @Test("consuming a frame releases its byte storage and linked-queue budget") + func consumedFrameReleasesLinkedQueueBytes() async { + let frameBytes = BinaryMessage.headerSize + 1 + let mailbox = VisualizerDataMailbox(capacityBytes: frameBytes * 2) + let firstReleased = ByteReleaseProbe() + offerTrackedFrame(firstReleased, to: mailbox, byte: 12) + let second = VisualizerData(type: .loudness, data: Data([13]), localDisplayTime: .max) + let third = VisualizerData(type: .loudness, data: Data([14]), localDisplayTime: .max) + + do { + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + #expect(await (iterator.next())?.data == Data([12])) + mailbox.offer(second, now: 0) + mailbox.offer(third, now: 0) + #expect(await iterator.next() == second) + #expect(await iterator.next() == third) + } + #expect(firstReleased.wasReleased) + mailbox.finish() + } + + @Test("queued frames retain the configuration that validated them") + func configurationSnapshotSurvivesUpdate() async { + let old = VisualizerStreamConfiguration( + types: [.spectrum], + rateMax: 30, + spectrum: SpectrumConfiguration(nDispBins: 2, scale: .lin, fMin: 20, fMax: 20_000) + ) + let updated = VisualizerStreamConfiguration(types: [.loudness], rateMax: 60) + let mailbox = VisualizerDataMailbox(capacityBytes: 128) + let oldFrame = VisualizerData( + type: .spectrum, + data: Data([0, 1, 0, 2]), + localDisplayTime: .max, + streamConfiguration: old + ) + let newFrame = VisualizerData( + type: .loudness, + data: Data([0, 3]), + localDisplayTime: .max, + streamConfiguration: updated + ) + mailbox.offer(oldFrame, now: 0) + mailbox.offer(newFrame, now: 0) + + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + #expect(await iterator.next()?.streamConfiguration == old) + #expect(await iterator.next()?.streamConfiguration == updated) + mailbox.finish() + } + + @Test("mailbox keeps retained visualizer bytes within the exact wire budget") + func byteBudgetDropsOldestFrames() async { + let mailbox = VisualizerDataMailbox(capacityBytes: 22) + let validity = VisualizerFrameValidity() + let first = VisualizerData( + type: .loudness, + data: Data([1, 2]), + localDisplayTime: .max, + validity: validity + ) + let second = VisualizerData( + type: .loudness, + data: Data([3, 4]), + localDisplayTime: .max, + validity: validity + ) + let third = VisualizerData( + type: .loudness, + data: Data([5, 6]), + localDisplayTime: .max, + validity: validity + ) + + mailbox.offer(first, now: 0) + mailbox.offer(second, now: 0) + mailbox.offer(third, now: 0) + + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + #expect(await iterator.next() == second) + #expect(await iterator.next() == third) + mailbox.finish() + #expect(await iterator.next() == nil) + } + + @Test("an oversized visualizer frame never bypasses the byte cap") + func oversizedFrameIsDropped() async { + let mailbox = VisualizerDataMailbox(capacityBytes: BinaryMessage.headerSize + 1) + let value = VisualizerData( + type: .spectrum, + data: Data(repeating: 0, count: 2), + localDisplayTime: .max + ) + mailbox.offer(value, now: 0) + + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + mailbox.finish() + #expect(await iterator.next() == nil) + } + + @Test("mailbox drops expired frames when a slow consumer resumes") + func expiredFramesAreDroppedOnConsumption() async { + let mailbox = VisualizerDataMailbox(capacityBytes: 64) + let value = VisualizerData(type: .beat, data: Data([1]), localDisplayTime: 1) + mailbox.offer(value, now: 0) + + let pending = Task { () -> VisualizerData? in + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + return await iterator.next() + } + let observation = await observeTask( + pending, + timeout: .milliseconds(50), + onTimeout: { mailbox.finish() } + ) + switch observation { + case .timedOut: + break + case let .completed(value): + Issue.record("dropping an expired frame must keep a live mailbox open; got \(String(describing: value))") + } + mailbox.finish() + } + + @Test("clear releases all retained visualizer frames") + func clearDropsQueuedFrames() async { + let mailbox = VisualizerDataMailbox(capacityBytes: 64) + mailbox.offer( + VisualizerData(type: .peak, data: Data([1]), localDisplayTime: .max), + now: 0 + ) + mailbox.clear() + + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + mailbox.finish() + #expect(await iterator.next() == nil) + } + + @Test("parked clear, end, and teardown preserve the primary mailbox") + func parkedDeliveryDoesNotClearPrimaryMailbox() { + let mailbox = VisualizerDataMailbox(capacityBytes: 64) + let primary = makeConnectionDataDelivery(mailbox: mailbox) + let parked = makeConnectionDataDelivery(mailbox: mailbox) + primary.promoteToPrimary() + let frame = VisualizerData(type: .peak, data: Data([15, 16]), localDisplayTime: .max) + primary.offerVisualizerIfValid(frame, validity: SessionValidityToken()) + #expect(mailbox.retainedByteCount == frame.frameByteCount) + + for _ in 0 ..< 3 { + parked.clearVisualizer() + #expect(mailbox.retainedByteCount == frame.frameByteCount) + } + + primary.clearVisualizer() + #expect(mailbox.retainedByteCount == 0) + mailbox.finish() + } + + @Test("primary delivery clear releases retained bytes immediately") + func primaryDeliveryClearsMailboxImmediately() { + let mailbox = VisualizerDataMailbox(capacityBytes: 64) + let primary = makeConnectionDataDelivery(mailbox: mailbox) + primary.promoteToPrimary() + let frame = VisualizerData(type: .peak, data: Data([17, 18]), localDisplayTime: .max) + primary.offerVisualizerIfValid(frame, validity: SessionValidityToken()) + #expect(mailbox.retainedByteCount == frame.frameByteCount) + + primary.clearVisualizer() + + #expect(mailbox.retainedByteCount == 0) + mailbox.finish() + } + + @Test("mailbox preserves FIFO order among retained frames") + func retainedFramesRemainFifo() async { + let mailbox = VisualizerDataMailbox(capacityBytes: 64) + let values = (0 ..< 4).map { index in + VisualizerData(type: .loudness, data: Data([UInt8(index)]), localDisplayTime: .max) + } + for value in values { + mailbox.offer(value, now: 0) + } + + var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + for value in values { + #expect(await iterator.next() == value) + } + mailbox.finish() + } +} + +private func makeConnectionDataDelivery(mailbox: VisualizerDataMailbox) -> ConnectionDataDelivery { + let (_, audio) = AsyncStream.makeStream() + let (_, artwork) = AsyncStream.makeStream() + return ConnectionDataDelivery( + audio: audio, + artwork: artwork, + visualizer: mailbox, + artworkObserver: nil + ) +} + +private final class ByteReleaseProbe: @unchecked Sendable { + private let lock = NSLock() + private var released = false + + var wasReleased: Bool { + lock.withLock { released } + } + + func markReleased() { + lock.withLock { released = true } + } +} + +private func offerTrackedFrame(_ probe: ByteReleaseProbe, to mailbox: VisualizerDataMailbox, byte: UInt8) { + let pointer = UnsafeMutableRawPointer.allocate(byteCount: 1, alignment: 1) + pointer.initializeMemory(as: UInt8.self, repeating: byte, count: 1) + let data = Data(bytesNoCopy: pointer, count: 1, deallocator: .custom { pointer, _ in + pointer.deallocate() + probe.markReleased() + }) + mailbox.offer(VisualizerData(type: .loudness, data: data, localDisplayTime: .max), now: 0) +} + +private final class MailboxTestClock: @unchecked Sendable { + private let lock = NSLock() + private var value: Int64 + private var readCount = 0 + private let blockedRead: Int? + private let release = DispatchSemaphore(value: 0) + + init(value: Int64 = 0, blockedRead: Int? = nil) { + self.value = value + self.blockedRead = blockedRead + } + + func read() -> Int64 { + let shouldBlock = lock.withLock { + readCount += 1 + return readCount == blockedRead + } + if shouldBlock { + release.wait() + } + return lock.withLock { value } + } + + func setValue(_ value: Int64) { + lock.withLock { self.value = value } + } + + func waitUntilReadCount(_ target: Int) async -> Bool { + for _ in 0 ..< 10_000 { + if lock.withLock({ readCount >= target }) { + return true + } + await Task.yield() + } + return false + } + + func releaseBlockedRead() { + release.signal() + } +} diff --git a/Tests/SendspinKitTests/Crypto/CPaceTests.swift b/Tests/SendspinKitTests/Crypto/CPaceTests.swift index 7b199e2..a14d96c 100644 --- a/Tests/SendspinKitTests/Crypto/CPaceTests.swift +++ b/Tests/SendspinKitTests/Crypto/CPaceTests.swift @@ -276,8 +276,11 @@ struct DynamicPairingCodeDerivationTests { let nonceA = dataFromHex(fixture.nonceA) let nonceB = dataFromHex(fixture.nonceB) let sid = dataFromHex(fixture.sid) - let expectedSID = CPaceSessionIdentifier.make(handshakeHash: handshakeHash, counter: fixture.counter) - #expect(sid == expectedSID) + #expect( + sid == dataFromHex( + "73656e647370696e2d706169722d70616b652d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0f0000000100000001" + ) + ) let commit = Data(SHA256.hash(data: Data("sendspin-pair-commit-v1".utf8) + nonceB)) #expect(commit == dataFromHex(fixture.commitB)) @@ -351,8 +354,8 @@ struct PairingSessionIdentifierTests { @Test("uses raw hash and big-endian counter") func identifierEncoding() { let hash = Data(repeating: 0xAB, count: CPaceSessionIdentifier.handshakeHashLength) - let sid = CPaceSessionIdentifier.make(handshakeHash: hash, counter: 0x0102_0304) - #expect(sid == Data("sendspin-pair-pake-v1".utf8) + hash + dataFromHex("01020304")) + let sid = CPaceSessionIdentifier.make(handshakeHash: hash, counter: 0x0102_0304, round: 0x0506_0708) + #expect(sid == Data("sendspin-pair-pake-v1".utf8) + hash + dataFromHex("0102030405060708")) } } diff --git a/Tests/SendspinKitTests/Integration/LeaveGroupTests.swift b/Tests/SendspinKitTests/Integration/LeaveGroupTests.swift new file mode 100644 index 0000000..f3f6871 --- /dev/null +++ b/Tests/SendspinKitTests/Integration/LeaveGroupTests.swift @@ -0,0 +1,170 @@ +import Foundation +@testable import SendspinKit +import Testing + +@Suite("Client leave") +@MainActor +struct LeaveGroupTests { + @Test("leave sends encrypted client/leave with an empty payload without requiring controller") + func leaveSendsExactEncryptedMessageWithoutStateFlap() async throws { + let client = try makeTestClient(roles: [.metadataV1]) + let server = try await connectClient(client, activeRoles: [.metadataV1]) + try await establishClockSync(client, via: server) + + let stateCountBefore = await server.clientJSONMessages(ofType: ClientStateMessage.typeString).count + try await client.leaveGroup() + + #expect(await waitUntil(timeout: .seconds(3)) { + await server.clientJSONMessages(ofType: ClientLeaveMessage.typeString).count == 1 + }) + let leaveData = try #require(await server.clientJSONMessages(ofType: ClientLeaveMessage.typeString).first) + let leaveObject = try #require(JSONSerialization.jsonObject(with: leaveData) as? [String: Any]) + let leavePayload = try #require(leaveObject["payload"] as? [String: Any]) + #expect(leaveObject["type"] as? String == ClientLeaveMessage.typeString) + #expect(leavePayload.isEmpty) + #expect( + await server.clientJSONMessages(ofType: ClientStateMessage.typeString).count == stateCountBefore, + "leave must not publish an availability or client/state flap" + ) + + await client.disconnect() + } + + @Test("leave does not invent or clear local group state while unavailable") + func leavePreservesGroupStateWhileExternalSourceIsActive() async throws { + let client = try makeTestClient(roles: [.metadataV1]) + let server = try await connectClient(client, activeRoles: [.metadataV1]) + try await establishClockSync(client, via: server) + try await server.sendJSON(#"{"type":"group/update","payload":{"playback_state":"playing","group_id":"group-1","group_name":"Living Room"}}"#) + #expect(await waitUntil { + await MainActor.run { client.currentGroup?.groupId == "group-1" } + }) + + try await client.enterExternalSource() + #expect(client.clientOperationalState == .externalSource) + let groupBefore = client.currentGroup + let stateCountBeforeLeave = await server.clientJSONMessages(ofType: ClientStateMessage.typeString).count + + try await client.leaveGroup() + + #expect(await waitUntil(timeout: .seconds(3)) { + await server.clientJSONMessages(ofType: ClientLeaveMessage.typeString).count == 1 + }) + #expect(client.currentGroup == groupBefore) + #expect(client.clientOperationalState == .externalSource) + #expect( + await server.clientJSONMessages(ofType: ClientStateMessage.typeString).count == stateCountBeforeLeave, + "leave must not toggle unavailable client/state" + ) + + await client.disconnect() + } + + @Test("leave is unavailable while disconnected") + func leaveRequiresConnection() async throws { + let client = try makeTestClient(roles: [.metadataV1]) + + await #expect(throws: SendspinClientError.notConnected) { + try await client.leaveGroup() + } + } + + @Test("leave is unavailable after disconnect") + func leaveRejectsStoppedConnection() async throws { + let client = try makeTestClient(roles: [.metadataV1]) + _ = try await connectClient(client, activeRoles: [.metadataV1]) + await client.disconnect() + + await #expect(throws: SendspinClientError.notConnected) { + try await client.leaveGroup() + } + } + + @Test("leave is gated during re-handshake") + func leaveRequiresCompletedRehandshake() async throws { + let client = try makeTestClient(roles: [.metadataV1]) + let server = try await connectClient(client, activeRoles: [.metadataV1]) + let connection = try #require(client.connection) + + try await server.beginRehandshake(to: .sentinel) + #expect(await waitUntil { await connection.isRehandshakeInProgress }) + await #expect(throws: SendspinClientError.handshakeIncomplete) { + try await client.leaveGroup() + } + #expect(await server.clientJSONMessages(ofType: ClientLeaveMessage.typeString).isEmpty) + + await client.disconnect() + } + + @Test("queued leave is rejected after graceful shutdown begins") + func queuedLeaveDoesNotFollowGoodbye() async throws { + let client = try makeTestClient(roles: [.metadataV1]) + let server = try await connectClient(client, activeRoles: [.metadataV1]) + let connection = try #require(client.connection) + + await connection.clockSyncTask?.cancel() + await connection.clockSyncTask?.value + #expect(await waitUntil { await connection.outboundInFlight == false }) + + await server.enableGoodbyeGate() + let firstSend = Task { () -> Result in + do { + try await connection.send( + clientMessage: ClientTimeMessage( + payload: ClientTimePayload(clientTransmitted: MonotonicClock.nowMicroseconds()) + ) + ) + return .success(()) + } catch { + return .failure(error) + } + } + #expect(await waitUntil { await server.isGoodbyeGateWaiting }) + + let leave = Task { () -> Result in + do { + try await client.leaveGroup() + return .success(()) + } catch { + return .failure(error) + } + } + #expect(await waitUntil { await connection.outboundWaiters.count == 1 }) + + let disconnect = Task { await client.disconnect(reason: .userRequest) } + #expect(await waitUntil { await connection.lifecycle == .shuttingDown }) + + await server.releaseGoodbyeGate() + let firstResult = await firstSend.value + #expect((try? firstResult.get()) != nil) + + let leaveResult = await leave.value + guard case let .failure(error) = leaveResult else { + Issue.record("leave must be rejected once graceful shutdown begins") + await disconnect.value + return + } + guard case SendspinClientError.notConnected = error else { + Issue.record("queued leave failed with an unexpected error: \(error)") + await disconnect.value + return + } + + await disconnect.value + #expect(await server.clientJSONMessages(ofType: ClientLeaveMessage.typeString).isEmpty) + #expect(await server.clientJSONMessages(ofType: ClientGoodbyeMessage.typeString).count == 1) + } + + @Test("leave surfaces encrypted transport failure") + func leaveSurfacesSendFailure() async throws { + let client = try makeTestClient(roles: [.metadataV1]) + let server = try await connectClient(client, activeRoles: [.metadataV1]) + await server.transport.setShouldFailOnSend(true) + + await #expect(throws: SendspinClientError.self) { + try await client.leaveGroup() + } + #expect(await server.clientJSONMessages(ofType: ClientLeaveMessage.typeString).isEmpty) + #expect(await waitUntil { await server.transport.disconnectCalled }) + } +} diff --git a/Tests/SendspinKitTests/Integration/ProtocolBoundaryTests.swift b/Tests/SendspinKitTests/Integration/ProtocolBoundaryTests.swift new file mode 100644 index 0000000..05df8d5 --- /dev/null +++ b/Tests/SendspinKitTests/Integration/ProtocolBoundaryTests.swift @@ -0,0 +1,337 @@ +import Foundation +@testable import SendspinKit +import Testing + +/// Protocol-boundary coverage against a real encrypted ``MockNoiseServer``. +@Suite("Protocol boundaries", .serialized, .timeLimit(.minutes(1))) +@MainActor +struct ProtocolBoundaryTests { + @Test("a fragmented send completes under the old key before re-handshake reply, while a queued send is rejected") + func fragmentedSendIsFencedByRehandshake() async throws { + let transport = MockTransport() + let fixture = try await makeEstablishedConnection( + transport: transport, + activities: [.playback], + activeRoles: [], + roles: [] + ) + let connection = fixture.connection + let server = fixture.server + + // Remove the sampler so it cannot compete with the ordered send sequence. + #expect(await waitUntil { await connection.clockSyncTask != nil }, "clock-sync task handle must appear before cancel") + await connection.clockSyncTask?.cancel() + await connection.clockSyncTask?.value + #expect(await waitUntil { await !connection.outboundInFlight }, "initial clock samples must drain") + + await transport.enableGoodbyeGate() + let fragmented = Task { () -> Result in + do { + try await connection.send(clientMessage: ProtocolBoundaryOutboundMessage( + kind: .fragmented, + note: String(repeating: "f", count: NoiseChannel.maxSinglePayload + 2_000) + )) + return .success(()) + } catch { + return .failure(error) + } + } + #expect(await waitUntil { await transport.isGoodbyeGateWaiting }) + + // This sender is queued before message 1 and checks the gate after the fence. + let queued = Task { () -> Result in + do { + try await connection.send(clientMessage: ProtocolBoundaryOutboundMessage( + kind: .queued, + note: "must-not-cross-rehandshake" + )) + return .success(()) + } catch { + return .failure(error) + } + } + #expect(await waitUntil { await connection.outboundWaiters.count == 1 }) + + try await server.beginRehandshake(to: .sentinel) + #expect(await waitUntil { await connection.isRehandshakeInProgress }) + await transport.releaseGoodbyeGate() + + let fragmentedResult = await fragmented.value + #expect((try? fragmentedResult.get()) != nil) + #expect( + await waitUntil { + await server.decryptedMessages.contains { message in + protocolBoundaryOutboundKind(in: message) == .fragmented + } + }, + "the complete fragmented message must arrive before the key swap" + ) + + let queuedResult = await queued.value + #expect((try? queuedResult.get()) == nil, "a sender queued before message 1 must not cross the re-handshake gate") + #expect(await waitUntil { await server.rehandshakeComplete }) + + let observedMessages = await server.decryptedMessages + let fragmentedIndex = try #require(observedMessages.firstIndex { + protocolBoundaryOutboundKind(in: $0) == .fragmented + }) + let rehandshakeReplyIndex = try #require(observedMessages.firstIndex { + guard $0.first == NoiseFrameType.json else { return false } + return SendspinEncoding.messageType(of: Data($0.dropFirst())) == NoiseHandshakeMessage.typeString + }) + #expect(fragmentedIndex < rehandshakeReplyIndex, "the complete old-key message must precede the noise reply") + let observedKinds = observedMessages.compactMap(protocolBoundaryOutboundKind) + #expect(observedKinds == [.fragmented], "only the complete old-key message may reach the peer") + await connection.shutdown() + } + + @Test("an encrypted null state immediately clears a pending future metadata snapshot") + func nullClearsPendingFutureMetadataImmediately() async throws { + let schedule = ProtocolBoundaryManualTime(now: 0) + let clock = ProtocolBoundaryIdentityClock() + let fixture = try await makeEstablishedConnection( + clock: clock, + activities: [.playback], + activeRoles: [], + roles: [.metadataV1], + scheduleNow: { schedule.now }, + scheduleSleep: { duration in try await Task.sleep(for: duration) } + ) + let server = fixture.server + let connection = fixture.connection + + try await server.sendActivation(activities: [.playback], activeRoles: [.metadataV1]) + #expect(await waitUntil { await connection.activeRoles == [.metadataV1] }) + + try await server.sendJSON(#"{"type":"server/state","payload":{"metadata":null}}"#) + #expect(await waitUntil { await connection.currentMetadata == nil }) + + try await server.sendActivation(activities: [.playback], activeRoles: []) + #expect(await waitUntil { await connection.activeRoles.isEmpty }) + try await server.sendActivation(activities: [.playback], activeRoles: [.metadataV1]) + #expect(await waitUntil { await connection.activeRoles == [.metadataV1] }) + + try await server.sendJSON(#"{"type":"server/state","payload":{"metadata":{"timestamp":1000,"title":"pending"}}}"#) + #expect(await waitUntil { await connection.metadataPending != nil }) + + // Null is a clear, not omission, even for the first state after reactivation. + try await server.sendJSON(#"{"type":"server/state","payload":{"metadata":null}}"#) + #expect(await waitUntil { await connection.metadataPending == nil }) + #expect(await connection.currentMetadata == nil) + #expect(await connection.metadataScheduleTask == nil) + await connection.shutdown() + } + + @Test("metadata, color, and controller roles do not add objects to client/state") + func serverStateRolesRemainAbsentFromClientState() async throws { + let fixture = try await makeEstablishedConnection( + activities: [.playback], + activeRoles: [], + roles: [.metadataV1, .colorV1, .controllerV1] + ) + let server = fixture.server + let connection = fixture.connection + await connection.clockSyncTask?.cancel() + await connection.clockSyncTask?.value + + try await server.sendActivation( + activities: [.playback], + activeRoles: [.metadataV1, .colorV1, .controllerV1] + ) + #expect( + await waitUntil { + await connection.activeRoles == [.metadataV1, .colorV1, .controllerV1] + } + ) + #expect( + await waitUntil { + await clientStateSnapshots(server).contains(where: { + $0.payload.player == nil && $0.payload.artwork == nil && $0.payload.visualizer == nil + }) + }, + "the initial non-player client/state must reach the peer before role removal" + ) + let initialState = try #require( + await clientStateSnapshots(server).reversed().first(where: { + $0.payload.player == nil && $0.payload.artwork == nil && $0.payload.visualizer == nil + }) + ) + let beforeRemoval = await server.clientJSONMessages(ofType: ClientStateMessage.typeString).count + + try await server.sendActivation(activities: [.playback], activeRoles: []) + #expect(await waitUntil { await connection.activeRoles.isEmpty }) + #expect( + await waitUntil { + let states = await clientStateSnapshots(server) + return states.count > beforeRemoval && states.dropFirst(beforeRemoval).contains(where: { + $0.payload.player == nil && $0.payload.artwork == nil && $0.payload.visualizer == nil + }) + }, + "the removal client/state must reach the peer before recording the reactivation baseline" + ) + let baseline = await server.clientJSONMessages(ofType: ClientStateMessage.typeString).count + + try await server.sendActivation( + activities: [.playback], + activeRoles: [.metadataV1, .colorV1, .controllerV1] + ) + #expect(await waitUntil { + await connection.activeRoles == [.metadataV1, .colorV1, .controllerV1] + }) + + #expect( + await waitUntil { + let states = await clientStateSnapshots(server) + return states.count > baseline && states.dropFirst(baseline).contains(where: { $0 == initialState }) + }, + "reactivation must deliver a fresh exact non-player client/state snapshot" + ) + await connection.shutdown() + } + + @Test("player reactivation publishes a player state-bearing client/state snapshot") + func playerRoleReactivationPublishesPlayerState() async throws { + let fixture = try await makeEstablishedConnection( + activities: [.playback], + activeRoles: [], + roles: [.playerV1] + ) + let server = fixture.server + let connection = fixture.connection + #expect(await waitUntil { await connection.clockSyncTask != nil }, "clock-sync task handle must appear before cancel") + await connection.clockSyncTask?.cancel() + await connection.clockSyncTask?.value + #expect(await waitUntil { await !connection.outboundInFlight }, "initial clock samples must drain") + + try await server.sendActivation(activities: [.playback], activeRoles: [.playerV1]) + #expect(await waitUntil { await connection.activeRoles == [.playerV1] }) + #expect( + await waitUntil { + await clientStateSnapshots(server).contains(where: { $0.payload.player != nil }) + }, + "the initial player client/state must reach the peer before role removal" + ) + let initialState = try #require( + await clientStateSnapshots(server).reversed().first(where: { $0.payload.player != nil }) + ) + let beforeRemoval = await server.clientJSONMessages(ofType: ClientStateMessage.typeString).count + + try await server.sendActivation(activities: [.playback], activeRoles: []) + #expect(await waitUntil { await connection.activeRoles.isEmpty }) + #expect( + await waitUntil { + let states = await clientStateSnapshots(server) + return states.count > beforeRemoval && states.dropFirst(beforeRemoval).contains(where: { + $0.payload.player == nil + }) + }, + "the removal client/state must reach the peer before recording the reactivation baseline" + ) + let baseline = await server.clientJSONMessages(ofType: ClientStateMessage.typeString).count + + try await server.sendActivation(activities: [.playback], activeRoles: [.playerV1]) + #expect(await waitUntil { await connection.activeRoles == [.playerV1] }) + + #expect( + await waitUntil { + let states = await clientStateSnapshots(server) + return states.count > baseline && states.dropFirst(baseline).contains(where: { $0 == initialState }) + }, + "reactivation must deliver a fresh exact player client/state snapshot" + ) + #expect(await connection.playerStateSent) + await connection.shutdown() + } + + @Test("controller volume round-trips the server's integer group value without client-side averaging") + func controllerVolumeIsServerAuthoritativeInteger() async throws { + let client = try makeTestClient(roles: [.controllerV1]) + let server = try await connectClient( + client, + activeRoles: [.controllerV1], + activities: [.playback] + ) + + await server.injectText(#"{"type":"server/state","payload":{"controller":{"supported_commands":["volume"],"volume":37,"muted":false}}}"#) + #expect(await waitUntil { await MainActor.run { client.currentControllerState?.volume == 37 } }) + + let baseline = await server.clientJSONMessages(ofType: ClientCommandMessage.typeString).count + try await client.setGroupVolume(42) + #expect(await waitUntil { + await server.clientJSONMessages(ofType: ClientCommandMessage.typeString).count > baseline + }) + let commandData = try #require(await server.clientJSONMessages(ofType: ClientCommandMessage.typeString).last) + let commandObject = try #require(JSONSerialization.jsonObject(with: commandData) as? [String: Any]) + let payload = try #require(commandObject["payload"] as? [String: Any]) + let controller = try #require(payload["controller"] as? [String: Any]) + #expect(controller["command"] as? String == "volume") + #expect(controller["volume"] as? Int == 42) + + // A later server/state value remains authoritative for the group. + await server.injectText(#"{"type":"server/state","payload":{"controller":{"supported_commands":["volume"],"volume":37,"muted":false}}}"#) + #expect(await waitUntil { await MainActor.run { client.currentControllerState?.volume == 37 } }) + await client.disconnect() + } +} + +private func clientStateSnapshots(_ server: MockNoiseServer) async -> [ClientStateMessage] { + await server.clientJSONMessages(ofType: ClientStateMessage.typeString).compactMap { + try? JSONDecoder().decode(ClientStateMessage.self, from: $0) + } +} + +private func protocolBoundaryOutboundKind(in message: Data) -> ProtocolBoundaryOutboundKind? { + guard message.first == NoiseFrameType.json else { return nil } + guard let decoded = try? JSONDecoder().decode( + ProtocolBoundaryOutboundMessage.self, + from: Data(message.dropFirst()) + ) else { return nil } + return decoded.kind +} + +private enum ProtocolBoundaryOutboundKind: String, Codable, Sendable { + case fragmented + case queued +} + +private struct ProtocolBoundaryOutboundMessage: Codable, Sendable { + let kind: ProtocolBoundaryOutboundKind + let note: String +} + +private final class ProtocolBoundaryManualTime: @unchecked Sendable { + var now: Int64 + + init(now: Int64) { + self.now = now + } +} + +private actor ProtocolBoundaryIdentityClock: ClockSyncProtocol { + var hasSynced: Bool { + false + } + + func processServerTime( + clientTransmitted _: Int64, + serverReceived _: Int64, + serverTransmitted _: Int64, + clientReceived _: Int64 + ) {} + + func serverTimeToLocal(_ serverTime: Int64) -> Int64 { + serverTime + } + + func localTimeToServer(_ localTime: Int64) -> Int64 { + localTime + } + + func snapshot() -> TimeFilterSnapshot? { + nil + } + + func diagnosticSnapshot() -> ClockSynchronizer.DiagnosticSnapshot? { + nil + } +} diff --git a/Tests/SendspinKitTests/Models/MessageEncodingTests.swift b/Tests/SendspinKitTests/Models/MessageEncodingTests.swift index dccedf2..fdb9704 100644 --- a/Tests/SendspinKitTests/Models/MessageEncodingTests.swift +++ b/Tests/SendspinKitTests/Models/MessageEncodingTests.swift @@ -186,6 +186,17 @@ struct MessageEncodingTests { #expect(state?.supportedCommands.count == 3) } + // MARK: - client/leave + + @Test("client/leave encodes an exact empty payload") + func clientLeave_encodesEmptyPayload() throws { + let data = try SendspinEncoding.makeEncoder().encode(ClientLeaveMessage()) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let payload = try #require(object["payload"] as? [String: Any]) + #expect(object["type"] as? String == ClientLeaveMessage.typeString) + #expect(payload.isEmpty) + } + // MARK: - client/goodbye @Test diff --git a/Tests/SendspinKitTests/Resources/cpace-mcf-known-answer.json b/Tests/SendspinKitTests/Resources/cpace-mcf-known-answer.json index 18452c9..f2fb77e 100644 --- a/Tests/SendspinKitTests/Resources/cpace-mcf-known-answer.json +++ b/Tests/SendspinKitTests/Resources/cpace-mcf-known-answer.json @@ -1,5 +1,5 @@ { - "provenance": "generated by the cpace-py reference", + "provenance": "generated independently with cpace v0.1.0 (arturpragacz/cpace-py) and cryptography; regeneration uses CPace-X25519-SHA512 with explicit scalars, round-aware SID, and zero-nonce ChaCha20Poly1305", "draft": "draft-irtf-cfrg-cpace-21 Appendix B.1 inputs and Section 10.4 MCF", "mac_key": "29258cb342b2d939adf80ab06e7970741080d8f9655ed5adc75813e5f7803a454c9058b8f0f479cf6e7506c73818c7b626fcdbb703e74688879b0b2d39cfe4c8", "tag_a": "214b05fed53d47d1ac815b42eae64cc68f93f2013db81db04cc9a4f12a1a5ca513cb2458c9071bbecf720556872de984260fdc2b576c8f5331c455de81bd22dd", @@ -8,7 +8,7 @@ "provenance": "Generated independently by cpace-py CPaceRole.INITIATOR plus Python cryptography X25519/ChaCha20Poly1305; fixed inputs below.", "handshake_hash": "00112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0f", "counter": 1, - "sid": "73656e647370696e2d706169722d70616b652d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0f00000001", + "sid": "73656e647370696e2d706169722d70616b652d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0f0000000100000001", "nonce_A": "101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f", "nonce_B": "303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f", "commit_B": "0258642708cba446dfc2dc727f3dfab589d6ea13d6d8fb4bc2831ffbb7a398b7", @@ -18,29 +18,29 @@ "qr_token": "SP:1HYXJG6UC5JAU69DKMFK3GYUGIDXDBU75VBO4SMI", "scalar_A": "505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f", "scalar_B": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", - "generator": "cfb9576d74fc1670589c0d143815f49c4ad0cd6a61403fc4c27dde73de0d1e50", - "pake_msg_1": "06142a7200a82639728ca6773a1b34b09e0d2513234f707ecc8365334c38955c", - "pake_msg_2": "e121e5a32ea8cd901c13d6434da1edd9583fa3c3caaa58a03a5d1f410e00b44c", - "isk": "7f7da78395ffde81ec34a4cdc59a3f615980b9bd2b1010bbaed908f5a908c41ff17b71c2e6f50ef184b6afebd2ed6a6028caf5fb112e07f7015e6619361242da", - "server_kc": "aae41c83c36c72fe4366be8c29ab20cf2cd659ebba1a33651d4902f9a221b6b8972caca674bc7595db5c3282214d96831c191797ffb2155599e159865d802b33", - "client_kc": "e98d2d7f7167acba7945b0f749188257790206d35f12706f9d3f8d2549aed9dca26e7e17c5293ffb9c589781a1487aa3f89f7b09cd30bf9d0211ec2d6d0fd6be", - "wrapped_nonce_B": "1af4aa620239f553f43315336549f764e369367481883eeba026292cfd6339dc6377353b4ca4bfa5d3616a5f898970bf", - "wrapped_psk": "6fdb5833f47356dd93798fc09402b2dacd64c487b484e68e0ce75de2427949eb19990896ce9bb07048d636df40147152" + "generator": "11e95c349d5e7c3c2aa9b4f0e4c4f4bbba5e0855a7b8ff3ea0f6fc24f26efa3a", + "pake_msg_1": "83fc9b46c92839be66b02bb86e6d7c289d582ef4cdaa406523e3b219fc6b3723", + "pake_msg_2": "f0224a6c0c550890ff0b762a34b177858baddf8f5edf0178eb3bff1b1e42a172", + "isk": "62d2731ca7cb5714e1f1d5941399eef30dc4ad78fa7abeafe11fb7d8efea6b6c4dba3476ee18e26d371f8b293b6bbf767390650eb9baf516be6c9f6efbc45dc5", + "server_kc": "2c305b5d94cc7b8a05529a9d6acce19762b4eedcbf1d2c9817da0bad2482cf20e0afa02ce52c4defdf5810e3811289b587c76dc83055f2655f5fd29e81ca72e5", + "client_kc": "6e1862e9c321509a32dbe99d065807a44236564c2263d92786604297032142060fc3a491846aefc1c0bb1c3cc740b144098745b9d4eff0b72018e519db1a8bc1", + "wrapped_nonce_B": "318e3dc89425ee045ae5d34c4d2e3807719473df8437f9a8109263801f36318414b86c3ad824337c6cb71bf4d6973efa", + "wrapped_psk": "6cbf5fcb134473ac8db5cd9f31ca63aaf8fdc4452aab1fba07c2442058c744858f0228e4092e7d147dbc7ca1b6748ac7" }, "static_transcript": { "provenance": "Generated independently by cpace-py CPace (CPaceRole.INITIATOR server A) plus Python cryptography ChaCha20Poly1305; fixed static inputs below.", "handshake_hash": "00112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0f", "counter": 1, - "sid": "73656e647370696e2d706169722d70616b652d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0f00000001", + "sid": "73656e647370696e2d706169722d70616b652d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0f0000000100000001", "code": "12345678", "scalar_A": "505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f", "scalar_B": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", - "generator": "6c9f68a140d61b3edef6b17ea27d668da7c8aa086f3acea79d0fde811334b052", - "pake_msg_1": "c66b105816e3b0d8ae5151c09b6b6369826a418cc621e2ecc2d18984be8cc523", - "pake_msg_2": "e10b04993348f05bb4819738cddb873ed9a5844d99cfb19cc6067bf5c2a65d57", - "isk": "9e8a1f9fe8586554a0f91a692267b85a8b756985bf8627bf26c2a06711e70967534c5aefda3c64c3c12ef8afc116e7549aa8622ffc5c3726d791d586877a4908", - "server_kc": "043c16ee6581df6ecd3aa8cc5b0ca55c294b5a3fa3a012382418ce725b814d390f0c88c6f6b01e95c363130fb76a2f23d26032dc0633098d4b0d521de6f01ba8", - "client_kc": "e3a26c8b4827a9643b9819502e8f888d75298984fe5f0ec8e521ea9cbbca799148c64f485a3cd39a0f3265bde12e8550b76a8dc048d582aafba2a8092b827fd4", - "wrapped_psk": "9f82a7f94f0515f502396b762b38a94dcca5578ee51333adcfcefe6a4b891e5a24c6a8c60371956d156860a4382fbc95" + "generator": "acc3ae4c7e42f4765c8dc7b36b8bc45fc08ead5e9a371069a6770b68fe927a48", + "pake_msg_1": "a7e7bcb095b069a8250e2a8b70098a8b7a56575ae27bfc99564cda2aa08d2954", + "pake_msg_2": "0713bb022d18187bfb9fb0bd61e31004bd3104decd112af9f659b31ebc395a76", + "isk": "38402c55841b8d367add23588451874f8a534c0f6e5c26f8d25fe09542c4866c7ac8d9d3899458883eaa27338f024eee31091d4109f93c4fe894592f95a35c92", + "server_kc": "df3e148fb02da38de512b4f9b626915dadcd19858196464f1888367b28bc35997f5e639073aa24e975621f180964e6024d0ae44e0eefd89ef009af23705f0f88", + "client_kc": "f2dda91943be2bb6fea013102458dcb07edda75a90b040139d22c2dc6529090dc133d7405f902a72c2d61eaf2d390e49cd69ef31bdc91b820531f9ff466c2b45", + "wrapped_psk": "75a19513b4de2d1f72d2c1c5e29f972abcffefc8af03b06b767f81e2bc185beb2c9dd5257779db24e413dc62cfc8d3ac" } } diff --git a/Tests/SendspinKitTests/Synchronization/TimeFilterSnapshotTests.swift b/Tests/SendspinKitTests/Synchronization/TimeFilterSnapshotTests.swift index 9ba41ba..490b7ca 100644 --- a/Tests/SendspinKitTests/Synchronization/TimeFilterSnapshotTests.swift +++ b/Tests/SendspinKitTests/Synchronization/TimeFilterSnapshotTests.swift @@ -57,6 +57,18 @@ struct TimeFilterSnapshotTests { // MARK: - Negative offset + @Test + func localDelayDoesNotChangeServerClockProgressInverse() { + let serverTimestamp: Int64 = 750_000 + let localDue = Self.driftSnapshot.serverTimeToLocal(serverTimestamp) + let delayedLocalDue = localDue - 237_000 + + #expect(Self.driftSnapshot.localTimeToServer(localDue) == serverTimestamp) + // A local scheduling correction is not metadata progress; convert it back only + // when explicitly measuring the earlier handoff instant. + #expect(Self.driftSnapshot.localTimeToServer(delayedLocalDue) != serverTimestamp) + } + @Test func serverTimeToLocal_worksWithNegativeOffset() { // offset = -3_000 means client is ahead of server diff --git a/docs/VISUALIZER_DELIVERY.md b/docs/VISUALIZER_DELIVERY.md new file mode 100644 index 0000000..a352402 --- /dev/null +++ b/docs/VISUALIZER_DELIVERY.md @@ -0,0 +1,23 @@ +# Visualizer delivery + +`SendspinClient.visualizerData` is a bounded `VisualizerDataStream`, not an unbounded +`AsyncStream`. Its mailbox counts each retained frame as the visualizer wire size: +9 bytes for the type and timestamp plus the payload bytes. A frame larger than the +configured `VisualizerConfiguration.bufferCapacity` is dropped. When a frame would +exceed the remaining budget, the oldest retained frames are dropped first; this keeps +a slow consumer close to the live display while preserving FIFO order among frames +that remain. + +Frames whose local display time has passed are dropped both when they arrive and when +a consumer resumes. `stream/clear` and `stream/end` invalidate queued frames and immediately release their +mailbox storage. Session retirement and client close also release all retained bytes. A valid new stream +resets the timestamp floor. An in-place `stream/start` preserves the floor, so frames +cannot rewind unless the protocol supplies an explicit clear or new stream. + +The mailbox has one in-flight read per iterator and never creates a producer task. +A second iterator returns `nil`; concurrent reads on one iterator are unsupported. +The message loop offers frames non-blockingly; it never waits for the public consumer. + +The negotiated `rateMax` remains one scalar for all periodic types (`loudness`, +`f_peak`, and `spectrum`). `beat` and `peak` remain event-driven and are not throttled +by that scalar. diff --git a/docs/audio-timing-model.md b/docs/audio-timing-model.md index 8899663..1374329 100644 --- a/docs/audio-timing-model.md +++ b/docs/audio-timing-model.md @@ -46,10 +46,9 @@ is deliberately not combined with drift correction. ## What the implementation now does -- The correction formula reads `(expected + L) − cursor`. Three sites shared the old, inverted - equilibrium — `updateCorrectionSchedule`, `graceExpiryRebaselineCursor` and the reanchor - target — and are now one definition. -- `L` includes the device path, read from the HAL at `prepare()` (`OutputDeviceLatency`). +- The correction target is one shared mapping: `snapshot.localTimeToServer(localNow + physicalPipeline + localOutputDelay)`, with saturating local arithmetic. The callback error, grace-expiry rebaseline, and reanchor target all use that exact helper, including nonzero clock drift. +- `L` includes the device path, read from the HAL at `prepare()` (`OutputDeviceLatency`), while the commanded output delay remains a separate local-domain term. +- A runtime output-delay change shifts every pending scheduler/startup/deferred local play instant by `oldDelay - newDelay` exactly once. Wire timestamps and decoded cadence remain unchanged; chunks already yielded to the output are immutable and are corrected by render pacing rather than rewriting PCM. - The queue starts on silence at `prepare()`, so the device pays its spin-up during the window already being spent buffering. - The first real frame is placed to the sample: once the queue is running the device consumes at From 590a9154dd59ca1ff9019db8885bbf128b21310d Mon Sep 17 00:00:00 2001 From: David Bishop Date: Thu, 10 Sep 2026 12:10:33 -0700 Subject: [PATCH 2/3] Refine pairing and visualizer APIs for app integration Expose identified pairing snapshots and attempt-scoped actions, use fallible atomic round reservations, and fence stale pairing sends and window lifecycle transitions. Add typed visualizer presentation time, self-contained frames, explicitly acquired subscriptions with terminal cancellation and serialized reads, and a bounded presentation scheduler example. Update examples and documentation and add regression coverage for storage failures, pairing windows, cancellation races, and co-timestamped visualization frames. --- .../Sources/CLIPlayer/CLIPlayer.swift | 32 +- .../Sources/ClockSyncDiagnostics/main.swift | 25 +- .../ControllerClient/ControllerClient.swift | 23 +- .../Sources/ErrorRecovery/main.swift | 52 +- .../Sources/MetadataClient/main.swift | 37 +- .../MultiCodecPlayer/MultiCodecPlayer.swift | 20 +- Examples/README.md | 12 +- Examples/VisualizerClient/Package.resolved | 33 + Examples/VisualizerClient/Package.swift | 32 + Examples/VisualizerClient/README.md | 44 ++ .../Sources/VisualizerClient/main.swift | 515 +++++++++++++ .../VisualizerFrameIngestor.swift | 19 + .../VisualizerPresentationScheduler.swift | 152 ++++ ...VisualizerPresentationSchedulerTests.swift | 164 ++++ README.md | 73 +- .../Articles/AudioPipeline.md | 2 +- Sources/SendspinKit.docc/Articles/Events.md | 18 + .../Articles/GettingStarted.md | 68 +- Sources/SendspinKit/Client/ClientTypes.swift | 213 +++++- .../Client/ConnectionDataDelivery.swift | 6 +- .../SendspinKit/Client/ConnectionEvent.swift | 7 +- .../Client/SendspinClient+Commands.swift | 32 +- .../Client/SendspinClient+Handshake.swift | 15 +- .../SendspinClient+PairingCoordinator.swift | 35 +- .../SendspinKit/Client/SendspinClient.swift | 81 +- .../Client/SendspinConnection+Lifecycle.swift | 13 +- .../SendspinConnection+MessageHandling.swift | 455 +++++++++--- .../Client/SendspinConnection+Outbound.swift | 26 +- .../SendspinConnection+PairingQuery.swift | 12 + .../Client/SendspinConnection.swift | 27 +- .../Client/SendspinPersistenceProvider.swift | 78 +- .../Client/SessionValidityToken.swift | 2 +- .../Client/VisualizerDataDelivery.swift | 189 +++-- .../Client/ConcurrentPairingTests.swift | 8 +- .../Client/DigitAudioPairingTests.swift | 8 +- .../DynamicPairingTranscriptTests.swift | 355 +++++++-- .../Client/LivePairingArbitrationTests.swift | 3 +- .../Client/PairingAppLayerTests.swift | 303 ++++++++ .../Client/RehandshakeTests.swift | 17 +- .../Client/SendspinClientTests.swift | 4 +- .../Client/SendspinConnectionTests.swift | 80 ++ .../Client/StaticPairingTranscriptTests.swift | 129 +++- .../Client/VisualizerDataDeliveryTests.swift | 699 +++++++++++------- .../Crypto/CryptoPrimitivesTests.swift | 74 ++ .../EstablishedConnectionFactory.swift | 2 +- .../BinaryGateIntegrationTests.swift | 35 +- .../Integration/FrameOrderingTests.swift | 7 +- .../Integration/ProtocolBoundaryTests.swift | 2 + .../SendspinKitTests/PublicSurfaceTests.swift | 4 +- docs/VISUALIZER_DELIVERY.md | 21 +- 50 files changed, 3485 insertions(+), 778 deletions(-) create mode 100644 Examples/VisualizerClient/Package.resolved create mode 100644 Examples/VisualizerClient/Package.swift create mode 100644 Examples/VisualizerClient/README.md create mode 100644 Examples/VisualizerClient/Sources/VisualizerClient/main.swift create mode 100644 Examples/VisualizerClient/Sources/VisualizerClientCore/VisualizerFrameIngestor.swift create mode 100644 Examples/VisualizerClient/Sources/VisualizerClientCore/VisualizerPresentationScheduler.swift create mode 100644 Examples/VisualizerClient/Tests/VisualizerClientCoreTests/VisualizerPresentationSchedulerTests.swift create mode 100644 Tests/SendspinKitTests/Client/PairingAppLayerTests.swift diff --git a/Examples/CLIPlayer/Sources/CLIPlayer/CLIPlayer.swift b/Examples/CLIPlayer/Sources/CLIPlayer/CLIPlayer.swift index 2018949..47f7959 100644 --- a/Examples/CLIPlayer/Sources/CLIPlayer/CLIPlayer.swift +++ b/Examples/CLIPlayer/Sources/CLIPlayer/CLIPlayer.swift @@ -195,16 +195,12 @@ final class CLIPlayer { artworkUrl: metadata.artworkURL ) - // Ignored in TUI mode — these are either handled by log mode only, or - // have no corresponding on-screen element yet. Keep the list explicit - // so adding a new case is a compiler error, not a silent drop. - case .paired: - break - - case .pairingCodeChanged, .pairingAttemptEnded: - break - - case .audioOutputChanged, + // Explicit cases keep this example current as events evolve; apps may use `default: break` to ignore other events. + case .paired, + .pairingCodeChanged, + .pairingAttemptEnded, + .pairingWindowChanged, + .audioOutputChanged, .outputFormatStatusChanged, .streamingFailed, .groupUpdated, @@ -229,18 +225,22 @@ final class CLIPlayer { case let .serverConnected(info): print("[EVENT] Server connected: \(info.name) (\(info.serverId)) trust=\(info.trustLevel)") - case let .paired(serverId): - print("[EVENT] Paired with server: \(serverId) trust=user") + case let .paired(snapshot): + print("[EVENT] Paired with server: \(snapshot.peer.id) trust=user") - case let .pairingCodeChanged(emission): - if let emission { + case let .pairingCodeChanged(snapshot): + if let emission = snapshot.code { print("[PAIRING] Code \(emission.format.rawValue): \(emission.payload)") } else { print("[PAIRING] Code cleared") } - case let .pairingAttemptEnded(reason): - print("[PAIRING] Attempt ended: \(reason.rawValue)") + case let .pairingAttemptEnded(snapshot): + print("[PAIRING] Attempt ended: \(snapshot.id.rawValue) \(snapshot.phase)") + + case let .pairingWindowChanged(window): + let status = window == nil ? "closed" : "opened" + print("[PAIRING] Window \(status)") case let .audioOutputChanged(output): print("[AUDIO OUTPUT] \(output.diagnosticDescription ?? "unknown") rate=\(output.sampleRate.map(String.init) ?? "unknown")") diff --git a/Examples/ClockSyncDiagnostics/Sources/ClockSyncDiagnostics/main.swift b/Examples/ClockSyncDiagnostics/Sources/ClockSyncDiagnostics/main.swift index 9c3d90e..9997695 100644 --- a/Examples/ClockSyncDiagnostics/Sources/ClockSyncDiagnostics/main.swift +++ b/Examples/ClockSyncDiagnostics/Sources/ClockSyncDiagnostics/main.swift @@ -201,10 +201,27 @@ struct ClockSyncDiagnostics: AsyncParsableCommand { } state.shouldQuit = true break eventLoop - case .paired, .pairingCodeChanged, .pairingAttemptEnded, .audioOutputChanged, .outputFormatStatusChanged, .streamingFailed, - .streamStarted, .streamFormatChanged, .streamEnded, .streamCleared, - .groupUpdated, .metadataReceived, .controllerStateUpdated, .controllerStateCleared, .colorStateUpdated, - .colorStateCleared, .artworkStreamStarted, .visualizerStreamStarted, .outputDelayChanged, + // Explicit cases keep this example current as events evolve; apps may use `default: break` to ignore other events. + case .paired, + .pairingCodeChanged, + .pairingAttemptEnded, + .pairingWindowChanged, + .audioOutputChanged, + .outputFormatStatusChanged, + .streamingFailed, + .streamStarted, + .streamFormatChanged, + .streamEnded, + .streamCleared, + .groupUpdated, + .metadataReceived, + .controllerStateUpdated, + .controllerStateCleared, + .colorStateUpdated, + .colorStateCleared, + .artworkStreamStarted, + .visualizerStreamStarted, + .outputDelayChanged, .lastPlayedServerChanged: break } diff --git a/Examples/ControllerClient/Sources/ControllerClient/ControllerClient.swift b/Examples/ControllerClient/Sources/ControllerClient/ControllerClient.swift index ce52c6a..65b8efb 100644 --- a/Examples/ControllerClient/Sources/ControllerClient/ControllerClient.swift +++ b/Examples/ControllerClient/Sources/ControllerClient/ControllerClient.swift @@ -178,10 +178,25 @@ struct ControllerClient: AsyncParsableCommand { print("\n[disconnected] \(reason)") return - case .paired, .pairingCodeChanged, .pairingAttemptEnded, .audioOutputChanged, .outputFormatStatusChanged, .streamingFailed, - .streamStarted, .streamFormatChanged, .streamEnded, .streamCleared, - .controllerStateCleared, .colorStateUpdated, .colorStateCleared, .artworkStreamStarted, .visualizerStreamStarted, - .outputDelayChanged, .lastPlayedServerChanged: + // Explicit cases keep this example current as events evolve; apps may use `default: break` to ignore other events. + case .paired, + .pairingCodeChanged, + .pairingAttemptEnded, + .pairingWindowChanged, + .audioOutputChanged, + .outputFormatStatusChanged, + .streamingFailed, + .streamStarted, + .streamFormatChanged, + .streamEnded, + .streamCleared, + .controllerStateCleared, + .colorStateUpdated, + .colorStateCleared, + .artworkStreamStarted, + .visualizerStreamStarted, + .outputDelayChanged, + .lastPlayedServerChanged: break } } diff --git a/Examples/ErrorRecovery/Sources/ErrorRecovery/main.swift b/Examples/ErrorRecovery/Sources/ErrorRecovery/main.swift index 0997091..b4157a8 100644 --- a/Examples/ErrorRecovery/Sources/ErrorRecovery/main.swift +++ b/Examples/ErrorRecovery/Sources/ErrorRecovery/main.swift @@ -84,7 +84,8 @@ private func isRetryableError(_ error: any Error) -> Bool { // move. `notConnected` and `handshakeIncomplete` are likewise fine // to retry — connect() rebuilds from scratch. return true - case .roleNotActive, .streamNotActive, .invalidServerURL, .noDiscoveredServers, .serverURLRequired: + case .stalePairingAttempt, .roleNotActive, .streamNotActive, + .invalidServerURL, .noDiscoveredServers, .serverURLRequired: // Logic/configuration errors are not transient connection failures — // retrying the connection won't help. return false @@ -148,16 +149,8 @@ struct ErrorRecovery: AsyncParsableCommand { var retryDelay: Double = 1.0 @MainActor - func run() async throws { - let url = try await resolveServerURL(server: server, discover: discover, timeout: timeout) - - // Shared quit flag: SIGINT handler and the event loop both set this; - // the retry loop reads it. All accesses happen on MainActor. - let state = RetryState() - - // Build client once. disconnect() resets state to .disconnected, so - // we can call connect() again on the same instance without rebuilding. - let client = try SendspinClient( + private func makeClient() throws -> SendspinClient { + try SendspinClient( identity: .generate(), name: "Error Recovery", roles: [.playerV1], @@ -168,6 +161,19 @@ struct ErrorRecovery: AsyncParsableCommand { ] ) ) + } + + @MainActor + func run() async throws { + let url = try await resolveServerURL(server: server, discover: discover, timeout: timeout) + + // Shared quit flag: SIGINT handler and the event loop both set this; + // the retry loop reads it. All accesses happen on MainActor. + let state = RetryState() + + // Build client once. disconnect() resets state to .disconnected, so + // we can call connect() again on the same instance without rebuilding. + let client = try makeClient() // SIGINT: graceful shutdown. Set flag first so the retry loop exits, // then disconnect to send client/goodbye. The dispatch handler runs on @@ -252,10 +258,26 @@ struct ErrorRecovery: AsyncParsableCommand { } break eventLoop - case .paired, .pairingCodeChanged, .pairingAttemptEnded, .audioOutputChanged, .outputFormatStatusChanged, .streamingFailed, - .streamFormatChanged, .streamCleared, .groupUpdated, .metadataReceived, - .controllerStateUpdated, .controllerStateCleared, .colorStateUpdated, .colorStateCleared, - .artworkStreamStarted, .visualizerStreamStarted, .outputDelayChanged, .lastPlayedServerChanged: + // Explicit cases keep this example current as events evolve; apps may use `default: break` to ignore other events. + case .paired, + .pairingCodeChanged, + .pairingAttemptEnded, + .pairingWindowChanged, + .audioOutputChanged, + .outputFormatStatusChanged, + .streamingFailed, + .streamFormatChanged, + .streamCleared, + .groupUpdated, + .metadataReceived, + .controllerStateUpdated, + .controllerStateCleared, + .colorStateUpdated, + .colorStateCleared, + .artworkStreamStarted, + .visualizerStreamStarted, + .outputDelayChanged, + .lastPlayedServerChanged: break } } diff --git a/Examples/MetadataClient/Sources/MetadataClient/main.swift b/Examples/MetadataClient/Sources/MetadataClient/main.swift index 33fbabc..89fc72a 100644 --- a/Examples/MetadataClient/Sources/MetadataClient/main.swift +++ b/Examples/MetadataClient/Sources/MetadataClient/main.swift @@ -37,6 +37,15 @@ struct MetadataClient: AsyncParsableCommand { @Option(name: .long, help: "mDNS discovery timeout in seconds (used with --discover).") var timeout: Double = 5.0 + @MainActor + private func makeClient() throws -> SendspinClient { + try SendspinClient( + identity: .generate(), + name: "Metadata Client", + roles: [.metadataV1] + ) + } + @MainActor func run() async throws { let url = try await resolveServerURL( @@ -48,11 +57,7 @@ struct MetadataClient: AsyncParsableCommand { // Build the client. We only request the metadata role — no playerConfig // needed because we are not playing audio. The server will send us // track metadata, group updates, and stream lifecycle events. - let client = try SendspinClient( - identity: .generate(), - name: "Metadata Client", - roles: [.metadataV1] - ) + let client = try makeClient() // MARK: SIGINT handling // Ignore the default handler so Ctrl-C doesn't kill us mid-async-loop. @@ -169,10 +174,24 @@ struct MetadataClient: AsyncParsableCommand { } return - case .paired, .pairingCodeChanged, .pairingAttemptEnded, .audioOutputChanged, .outputFormatStatusChanged, .streamingFailed, - .streamFormatChanged, .streamCleared, .controllerStateUpdated, .controllerStateCleared, - .colorStateUpdated, .colorStateCleared, .artworkStreamStarted, .visualizerStreamStarted, - .outputDelayChanged, .lastPlayedServerChanged: + // Explicit cases keep this example current as events evolve; apps may use `default: break` to ignore other events. + case .paired, + .pairingCodeChanged, + .pairingAttemptEnded, + .pairingWindowChanged, + .audioOutputChanged, + .outputFormatStatusChanged, + .streamingFailed, + .streamFormatChanged, + .streamCleared, + .controllerStateUpdated, + .controllerStateCleared, + .colorStateUpdated, + .colorStateCleared, + .artworkStreamStarted, + .visualizerStreamStarted, + .outputDelayChanged, + .lastPlayedServerChanged: break } } diff --git a/Examples/MultiCodecPlayer/Sources/MultiCodecPlayer/MultiCodecPlayer.swift b/Examples/MultiCodecPlayer/Sources/MultiCodecPlayer/MultiCodecPlayer.swift index 7db0470..98a86c2 100644 --- a/Examples/MultiCodecPlayer/Sources/MultiCodecPlayer/MultiCodecPlayer.swift +++ b/Examples/MultiCodecPlayer/Sources/MultiCodecPlayer/MultiCodecPlayer.swift @@ -166,9 +166,23 @@ struct MultiCodecPlayer: AsyncParsableCommand { print("[disconnected] \(reason)") return - case .paired, .pairingCodeChanged, .pairingAttemptEnded, .audioOutputChanged, .outputFormatStatusChanged, .streamingFailed, - .streamCleared, .controllerStateUpdated, .controllerStateCleared, .colorStateUpdated, .colorStateCleared, - .artworkStreamStarted, .visualizerStreamStarted, .outputDelayChanged, .lastPlayedServerChanged: + // Explicit cases keep this example current as events evolve; apps may use `default: break` to ignore other events. + case .paired, + .pairingCodeChanged, + .pairingAttemptEnded, + .pairingWindowChanged, + .audioOutputChanged, + .outputFormatStatusChanged, + .streamingFailed, + .streamCleared, + .controllerStateUpdated, + .controllerStateCleared, + .colorStateUpdated, + .colorStateCleared, + .artworkStreamStarted, + .visualizerStreamStarted, + .outputDelayChanged, + .lastPlayedServerChanged: break } } diff --git a/Examples/README.md b/Examples/README.md index 6c39a62..6dcfae3 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -13,6 +13,7 @@ Standalone example apps demonstrating SendspinKit features. Each is a self-conta | **ErrorRecovery** | Reconnection with exponential backoff and error classification | | **ClockSyncDiagnostics** | Real-time Kalman filter clock sync diagnostics dashboard | | **CLIPlayer** | Full-featured player with status display | +| **VisualizerClient** | macOS SwiftUI visualizer with bounded deadline-aware frame delivery | ## Learning Path @@ -46,7 +47,16 @@ swift run MetadataClient --discover swift run MetadataClient --discover --timeout 10 ``` -Use `--help` on any example for its full option list. +Use `--help` on any example for its full option list. VisualizerClient is a macOS SwiftUI app and accepts an explicit URL or discovery: + +```bash +cd Examples/VisualizerClient +swift run VisualizerClient --server ws://192.168.1.100:8927/sendspin +swift run VisualizerClient --discover --timeout 5 +``` + +VisualizerClient uses a generated process-local identity. Add `--pairing` for explicit paired-only +access; persist the displayed pairing token in a real application. ## Requirements diff --git a/Examples/VisualizerClient/Package.resolved b/Examples/VisualizerClient/Package.resolved new file mode 100644 index 0000000..52ef8e1 --- /dev/null +++ b/Examples/VisualizerClient/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "68a544acce9044f5be3364c58bc236268803b1aa8006a875c864aec13d2f8a78", + "pins" : [ + { + "identity" : "flac-binary-xcframework", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sbooth/flac-binary-xcframework.git", + "state" : { + "revision" : "9005dc2cd455765fb6824eb215c9703429bbe8ff", + "version" : "0.2.0" + } + }, + { + "identity" : "ogg-binary-xcframework", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sbooth/ogg-binary-xcframework.git", + "state" : { + "revision" : "48cbf24e7fb5d329b1f5e24cd2e5b048585ff770", + "version" : "0.1.3" + } + }, + { + "identity" : "swift-sodium", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jedisct1/swift-sodium.git", + "state" : { + "revision" : "4f9164a0a2c9a6a7ff53a2833d54a5c79c957342", + "version" : "0.9.1" + } + } + ], + "version" : 3 +} diff --git a/Examples/VisualizerClient/Package.swift b/Examples/VisualizerClient/Package.swift new file mode 100644 index 0000000..a1416ac --- /dev/null +++ b/Examples/VisualizerClient/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "VisualizerClient", + platforms: [.macOS(.v14)], + dependencies: [ + .package(path: "../..") + ], + targets: [ + .target( + name: "VisualizerClientCore", + dependencies: [ + .product(name: "SendspinKit", package: "SendspinKit") + ] + ), + .executableTarget( + name: "VisualizerClient", + dependencies: [ + "VisualizerClientCore", + .product(name: "SendspinKit", package: "SendspinKit") + ] + ), + .testTarget( + name: "VisualizerClientCoreTests", + dependencies: [ + "VisualizerClientCore", + .product(name: "SendspinKit", package: "SendspinKit") + ] + ) + ] +) diff --git a/Examples/VisualizerClient/README.md b/Examples/VisualizerClient/README.md new file mode 100644 index 0000000..9014ad8 --- /dev/null +++ b/Examples/VisualizerClient/README.md @@ -0,0 +1,44 @@ +# VisualizerClient + +A minimal macOS SwiftUI visualizer consumer. It requests loudness and spectrum frames, +waits for each frame's typed presentation deadline, checks generation validity, and renders +only the latest due frame on an AppKit CoreVideo display-link tick. + +The library mailbox is bounded by `VisualizerConfiguration.bufferCapacity`. The app adds a +second bounded presentation queue with the same byte budget. Its consumer ingests frames +immediately (it never sleeps on a frame deadline), evicts the oldest queued bytes when full, +and keeps the newest due frame for each rendered type. Slow display ticks therefore preserve +all due types without allowing stale data or an unbounded per-frame task queue. + +## Run + +From the repository root: + +```bash +cd Examples/VisualizerClient +swift run VisualizerClient --server ws://127.0.0.1:8927/sendspin +``` + +Use mDNS discovery instead: + +```bash +swift run VisualizerClient --discover --timeout 5 +``` + +The app accepts `--name ` to set its client name. Add `--pairing` to disable unpaired +access and enable dynamic pairing setup. The generated identity and pairing PSK are process-local +for this example; the printed pairing token must be persisted by a real host application. +The pairing panel displays the current immutable attempt snapshot, treats the peer ID as unverified +until trust succeeds, opens the authorization window with the captured `PairingAttemptID`, and +cancels with that exact ID. A stale button action is reported instead of retargeting a newer attempt. + +Close the window with the **Close client** button or the window close action. The app cancels the +visualizer subscription before awaiting `SendspinClient.close()`. + +## Timing boundary + +`PresentationClock` and `PresentationInstant` stay in SendspinKit's monotonic presentation domain; +the app never converts them through `Date` or wall time. A successful visualizer submission at a +CoreVideo display-link tick means the app submitted the latest due value to the view model. It does +not guarantee the next screen refresh or screen-photon time, and this example does not claim exact +refresh synchronization or a display-link-to-presentation-clock mapping. diff --git a/Examples/VisualizerClient/Sources/VisualizerClient/main.swift b/Examples/VisualizerClient/Sources/VisualizerClient/main.swift new file mode 100644 index 0000000..f4593c9 --- /dev/null +++ b/Examples/VisualizerClient/Sources/VisualizerClient/main.swift @@ -0,0 +1,515 @@ +import CoreVideo +import Foundation +import Observation +import SendspinKit +import SwiftUI +import VisualizerClientCore + +private enum LaunchParseError: Error { + case message(String) +} + +private struct LaunchOptions: Sendable { + let server: String? + let discover: Bool + let timeout: Double + let name: String + let pairing: Bool + + static func parse(_ arguments: ArraySlice) -> Result { + var server: String? + var discover = false + var timeout = 5.0 + var name = "Visualizer Client" + var pairing = false + var iterator = arguments.makeIterator() + + while let argument = iterator.next() { + switch argument { + case "--server": + guard let value = iterator.next() else { return .failure(.message("--server needs a URL")) } + server = value + case "--discover": + discover = true + case "--timeout": + guard let value = iterator.next(), let parsed = Double(value), parsed > 0 else { + return .failure(.message("--timeout needs a positive number of seconds")) + } + timeout = parsed + case "--name": + guard let value = iterator.next(), !value.isEmpty else { return .failure(.message("--name needs a value")) } + name = value + case "--pairing": + pairing = true + case "--help", "-h": + return .failure(.message(Self.usage)) + default: + return .failure(.message("Unknown argument: \(argument)\n\n\(Self.usage)")) + } + } + + guard discover || server != nil else { + return .failure(.message("Provide --server or --discover\n\n\(Self.usage)")) + } + guard !(discover && server != nil) else { + return .failure(.message("Choose either --server or --discover\n\n\(Self.usage)")) + } + return .success(Self(server: server, discover: discover, timeout: timeout, name: name, pairing: pairing)) + } + + static let usage = """ + Usage: VisualizerClient --server [--name ] [--pairing] + VisualizerClient --discover [--timeout ] [--name ] [--pairing] + """ +} + +private final class DisplayLinkCallbackToken: @unchecked Sendable { + private let lock = NSLock() + private let callback: @MainActor () -> Void + private var stopped = false + private var callbackQueued = false + private var releaseWhenIdle: (() -> Void)? + + init(callback: @escaping @MainActor () -> Void) { + self.callback = callback + } + + func setReleaseWhenIdle(_ release: @escaping () -> Void) { + lock.withLock { + releaseWhenIdle = release + releaseIfIdleLocked() + } + } + + func invoke() { + let callbackToDeliver = lock.withLock { () -> (@MainActor () -> Void)? in + guard !stopped, !callbackQueued else { return nil } + callbackQueued = true + return callback + } + guard let callbackToDeliver else { return } + + DispatchQueue.main.async { [self] in + let shouldDeliver = lock.withLock { !stopped } + if shouldDeliver { callbackToDeliver() } + didDeliver() + } + } + + func stop() { + lock.withLock { + stopped = true + releaseIfIdleLocked() + } + } + + private func didDeliver() { + lock.withLock { + callbackQueued = false + releaseIfIdleLocked() + } + } + + private func releaseIfIdleLocked() { + guard stopped, !callbackQueued, let releaseWhenIdle else { return } + self.releaseWhenIdle = nil + releaseWhenIdle() + } +} + +@MainActor +private final class DisplayLinkDriver { + private nonisolated(unsafe) var link: CVDisplayLink? + private var callbackContext: Unmanaged? + private var isStopped = false + + init(callback: @escaping @MainActor () -> Void) { + var created: CVDisplayLink? + CVDisplayLinkCreateWithActiveCGDisplays(&created) + link = created + guard let link else { return } + + let token = DisplayLinkCallbackToken(callback: callback) + let retained = Unmanaged.passRetained(token) + callbackContext = retained + token.setReleaseWhenIdle { retained.release() } + CVDisplayLinkSetOutputCallback(link, { _, _, _, _, _, context in + guard let context else { return kCVReturnSuccess } + Unmanaged.fromOpaque(context).takeUnretainedValue().invoke() + return kCVReturnSuccess + }, retained.toOpaque()) + } + + func start() { + guard let link, !isStopped else { return } + CVDisplayLinkStart(link) + } + + func stop() { + guard !isStopped else { return } + isStopped = true + if let link { + CVDisplayLinkStop(link) + CVDisplayLinkSetOutputCallback(link, nil, nil) + } + callbackContext?.takeUnretainedValue().stop() + callbackContext = nil + link = nil + } + + deinit { + if let link { + CVDisplayLinkStop(link) + CVDisplayLinkSetOutputCallback(link, nil, nil) + } + callbackContext?.takeUnretainedValue().stop() + } +} + +@MainActor +@Observable +private final class VisualizerAppModel { + enum Phase: Equatable { + case idle + case connecting + case connected + case failed(String) + case closed + } + + private(set) var phase: Phase = .idle + private(set) var serverName = "" + private(set) var pairingSnapshot: PairingAttemptSnapshot? + private(set) var pairingWindow: PairingWindowSnapshot? + private(set) var pairingMessage = "" + private(set) var loudness: Double = 0 + private(set) var spectrum = [Double]() + private(set) var lastPresentedType: VisualizerType? + private(set) var lastPresentationTime: PresentationInstant? + private(set) var displayTickCount = 0 + + private let options: LaunchOptions + private let presentationClock = PresentationClock() + private var client: SendspinClient? + private var subscription: VisualizerFrameSubscription? + private var consumerTask: Task? + private var eventTask: Task? + private var displayLink: DisplayLinkDriver? + private var scheduler = VisualizerPresentationScheduler(capacityBytes: 65_536) + private var hasStarted = false + + init(options: LaunchOptions) { + self.options = options + } + + var statusText: String { + switch phase { + case .idle: return "Ready" + case .connecting: return "Connecting…" + case .connected: return serverName.isEmpty ? "Connected" : "Connected to \(serverName)" + case let .failed(message): return "Error: \(message)" + case .closed: return "Closed" + } + } + + func start() async { + guard !hasStarted else { return } + hasStarted = true + phase = .connecting + + do { + let url = try await SendspinClient.resolveServerURL( + server: options.server, + discover: options.discover, + timeout: .milliseconds(Int(options.timeout * 1_000)) + ) + let identity = SendspinIdentity.generate() + let pairing = options.pairing ? PairingConfiguration(dynamicPairingCodeEnabled: true) : nil + if let pairing { + let token = PairingToken(clientKey: identity.publicKeyBytes, pairingPsk: pairing.pairingPsk) + print("Pairing token (persist this in a real app): \(token.string)") + } + + let visualizer = try VisualizerConfiguration( + types: [.loudness, .spectrum], + rateMax: 30, + spectrum: SpectrumConfiguration(nDispBins: 32, scale: .log, fMin: 60, fMax: 16_000), + bufferCapacity: 65_536 + ) + scheduler = VisualizerPresentationScheduler(capacityBytes: visualizer.bufferCapacity) + let client = try SendspinClient( + identity: identity, + name: options.name, + roles: [.visualizerV1, .metadataV1], + visualizerConfig: visualizer, + unpairedAccessEnabled: !options.pairing, + pairing: pairing + ) + let subscription = try client.acquireVisualizerFrames() + self.client = client + self.subscription = subscription + startEventTask(client: client) + startFrameTask(subscription: subscription) + displayLink = DisplayLinkDriver { [weak self] in + self?.displayTick() + } + displayLink?.start() + try await client.connect(to: url) + } catch { + let message = error.localizedDescription + await close() + phase = .failed(message) + } + } + + private func startEventTask(client: SendspinClient) { + eventTask = Task { @MainActor [weak self] in + for await event in client.events() { + guard let self else { return } + switch event { + case let .serverConnected(info): + serverName = info.name + phase = .connected + case let .pairingCodeChanged(snapshot): + pairingSnapshot = snapshot + pairingMessage = snapshot.code.map { "\($0.format.rawValue): \($0.payload)" } ?? "Code cleared" + case let .pairingAttemptEnded(snapshot): + pairingSnapshot = snapshot + pairingWindow = nil + pairingMessage = "Attempt ended: \(snapshot.phase)" + case let .streamEnded(roles): + if roles == nil || roles?.contains(StreamRole.visualizer.rawValue) == true { + clearPresentedVisualizer() + } + case let .streamCleared(roles): + if roles == nil || roles?.contains(StreamRole.visualizer.rawValue) == true { + clearPresentedVisualizer() + } + case let .paired(snapshot): + pairingSnapshot = snapshot + pairingMessage = "Paired: \(snapshot.peer.name) (\(snapshot.peer.trustLevel))" + case let .pairingWindowChanged(window): + pairingWindow = window + case let .disconnected(reason): + clearPresentedVisualizer() + pairingWindow = nil + pairingMessage = "Disconnected: \(reason)" + if phase != .closed { phase = .failed("Disconnected: \(reason)") } + return + case .audioOutputChanged, .outputFormatStatusChanged, .streamingFailed, + .streamStarted, .streamFormatChanged, + .groupUpdated, .metadataReceived, .controllerStateUpdated, + .controllerStateCleared, .colorStateUpdated, .colorStateCleared, + .artworkStreamStarted, .visualizerStreamStarted, .outputDelayChanged, + .lastPlayedServerChanged: + break + } + } + } + } + + private func startFrameTask(subscription: VisualizerFrameSubscription) { + frameTask(subscription: subscription) + } + + private func frameTask(subscription: VisualizerFrameSubscription) { + consumerTask = Task { @MainActor [weak self] in + guard let self else { return } + await VisualizerFrameIngestor.consume(from: subscription) { frame in + await MainActor.run { + _ = self.scheduler.ingest(frame) + } + } + } + } + + private func clearPresentedVisualizer() { + scheduler.reset() + loudness = 0 + spectrum = [] + lastPresentedType = nil + lastPresentationTime = nil + } + + private func displayTick() { + guard phase != .closed else { return } + displayTickCount += 1 + let batch = scheduler.tick(at: presentationClock.now) + for type in batch.clearedTypes { + clearPresentedFrame(of: type) + } + for frame in batch.frames { + switch frame.type { + case .loudness: + loudness = decodeLoudness(frame.data) + case .spectrum: + spectrum = decodeSpectrum(frame.data) + default: + break + } + lastPresentedType = frame.type + lastPresentationTime = frame.presentationTime + } + } + + private func clearPresentedFrame(of type: VisualizerType) { + switch type { + case .loudness: + loudness = 0 + case .spectrum: + spectrum = [] + default: + break + } + if lastPresentedType == type { + lastPresentedType = nil + lastPresentationTime = nil + } + } + + func openPairingWindow(for attemptID: PairingAttemptID?) async { + guard let attemptID, let client else { + pairingMessage = "No admitted pairing attempt" + return + } + do { + try await client.openPairingWindow(for: attemptID) + pairingMessage = "Authorization window requested" + } catch { + pairingMessage = error.localizedDescription + } + } + + func cancelPairing(attemptID: PairingAttemptID?) async { + guard let attemptID, let client else { + pairingMessage = "No admitted pairing attempt" + return + } + do { + try await client.cancelPairing(attemptID: attemptID) + pairingMessage = "Cancellation requested" + } catch { + pairingMessage = error.localizedDescription + } + } + + func close() async { + guard phase != .closed else { return } + phase = .closed + consumerTask?.cancel() + eventTask?.cancel() + subscription?.cancel() + displayLink?.stop() + displayLink = nil + clearPresentedVisualizer() + if let client { await client.close() } + subscription = nil + self.client = nil + } + + func pairingWindowText(for window: PairingWindowSnapshot) -> String { + let duration = PresentationClock().duration(from: .now, to: window.expiresAt) + let remaining = duration > .zero ? duration : .zero + return "Window expires in \(remaining.formatted(.units(allowed: [.minutes, .seconds])))" + } + + private func decodeLoudness(_ data: Data) -> Double { + guard data.count == 2 else { return 0 } + let value = (UInt16(data[data.startIndex]) << 8) | UInt16(data[data.index(data.startIndex, offsetBy: 1)]) + return Double(value) / Double(UInt16.max) + } + + private func decodeSpectrum(_ data: Data) -> [Double] { + guard data.count.isMultiple(of: 2) else { return [] } + return stride(from: 0, to: data.count, by: 2).map { offset in + let high = UInt16(data[data.index(data.startIndex, offsetBy: offset)]) + let low = UInt16(data[data.index(data.startIndex, offsetBy: offset + 1)]) + return Double((high << 8) | low) / Double(UInt16.max) + } + } +} + +private struct VisualizerView: View { + @Bindable var model: VisualizerAppModel + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Sendspin Visualizer").font(.title) + Text(model.statusText).foregroundStyle(.secondary) + HStack(alignment: .bottom, spacing: 4) { + RoundedRectangle(cornerRadius: 4) + .fill(.blue) + .frame(width: 36, height: max(2, 180 * model.loudness)) + ForEach(Array(model.spectrum.enumerated()), id: \.offset) { _, value in + RoundedRectangle(cornerRadius: 2) + .fill(.purple) + .frame(width: 5, height: max(2, 180 * value)) + } + } + .frame(maxWidth: .infinity, minHeight: 190, alignment: .bottomLeading) + Text("Latest due frame: \(model.lastPresentedType.map(\.rawValue) ?? "none")") + .font(.caption) + if let renderedPairing = model.pairingSnapshot { + Divider() + Text("Pairing").font(.headline) + Text(model.pairingMessage).font(.caption) + let peerDescription = [ + "Peer: \(renderedPairing.peer.name)", + "— \(renderedPairing.peer.id)", + "(\(String(describing: renderedPairing.peer.trustLevel)))" + ].joined(separator: " ") + Text(verbatim: peerDescription) + .font(.caption) + if let window = model.pairingWindow, + window.attemptID == renderedPairing.id { + Text(model.pairingWindowText(for: window)).font(.caption) + } + let terminal = if case .ended = renderedPairing.phase { true } else { false } + HStack { + Button("Authorize") { + let attemptID = renderedPairing.id + Task { await model.openPairingWindow(for: attemptID) } + } + .disabled(terminal || model.phase != .connected) + Button("Cancel attempt") { + let attemptID = renderedPairing.id + Task { await model.cancelPairing(attemptID: attemptID) } + } + .disabled(terminal || model.phase != .connected) + } + } + HStack { + Text("Display ticks: \(model.displayTickCount)").font(.caption) + Spacer() + Button("Close client") { Task { await model.close() } } + } + } + .padding(24) + .frame(minWidth: 620, minHeight: 360) + } +} + +@main +struct VisualizerClientApp: App { + @State private var model: VisualizerAppModel + + init() { + switch LaunchOptions.parse(CommandLine.arguments.dropFirst()) { + case let .success(options): + _model = State(initialValue: VisualizerAppModel(options: options)) + case let .failure(.message(message)): + print(message) + _model = State(initialValue: VisualizerAppModel(options: LaunchOptions( + server: nil, discover: false, timeout: 5, name: "Visualizer Client", pairing: false + ))) + } + } + + var body: some Scene { + WindowGroup("VisualizerClient") { + VisualizerView(model: model) + .task { await model.start() } + .onDisappear { Task { await model.close() } } + } + } +} diff --git a/Examples/VisualizerClient/Sources/VisualizerClientCore/VisualizerFrameIngestor.swift b/Examples/VisualizerClient/Sources/VisualizerClientCore/VisualizerFrameIngestor.swift new file mode 100644 index 0000000..6ee62dd --- /dev/null +++ b/Examples/VisualizerClient/Sources/VisualizerClientCore/VisualizerFrameIngestor.swift @@ -0,0 +1,19 @@ +import SendspinKit + +public enum VisualizerFrameIngestor { + public static func consumeNext( + from iterator: inout VisualizerFrameSubscription.Iterator + ) async -> VisualizerFrame? { + await iterator.next() + } + + public static func consume( + from subscription: VisualizerFrameSubscription, + ingest: @escaping @Sendable (VisualizerFrame) async -> Void + ) async { + var iterator = subscription.makeAsyncIterator() + while !Task.isCancelled, let frame = await consumeNext(from: &iterator) { + await ingest(frame) + } + } +} diff --git a/Examples/VisualizerClient/Sources/VisualizerClientCore/VisualizerPresentationScheduler.swift b/Examples/VisualizerClient/Sources/VisualizerClientCore/VisualizerPresentationScheduler.swift new file mode 100644 index 0000000..310288c --- /dev/null +++ b/Examples/VisualizerClient/Sources/VisualizerClientCore/VisualizerPresentationScheduler.swift @@ -0,0 +1,152 @@ +import Foundation +import SendspinKit + +public struct VisualizerPresentationBatch: Sendable { + public let frames: [VisualizerFrame] + public let clearedTypes: [VisualizerType] + + public init(frames: [VisualizerFrame], clearedTypes: [VisualizerType]) { + self.frames = frames + self.clearedTypes = clearedTypes + } +} + +public struct VisualizerPresentationScheduler: Sendable { + public static let visualizerHeaderByteCount = 9 + + private struct Entry: Sendable { + let sequence: UInt64 + let frame: VisualizerFrame + let byteCount: Int + } + + private let capacityBytes: Int + private var entries: [Entry] = [] + private var queuedBytes = 0 + private var presentedBytes = 0 + private var nextSequence: UInt64 = 0 + private var presented: [(VisualizerType, VisualizerFrame, Int)] = [] + + public init(capacityBytes: Int) { + precondition(capacityBytes > 0) + self.capacityBytes = capacityBytes + } + + public var retainedByteCount: Int { + queuedBytes + presentedBytes + } + + public var capacity: Int { + capacityBytes + } + + @discardableResult + public mutating func ingest(_ frame: VisualizerFrame) -> Bool { + let (byteCount, overflow) = Self.visualizerHeaderByteCount.addingReportingOverflow(frame.data.count) + guard !overflow, byteCount <= capacityBytes, frame.isValid else { return false } + + while byteCount > availableCapacity, !entries.isEmpty { + let evicted = entries.removeFirst() + queuedBytes -= evicted.byteCount + } + guard byteCount <= availableCapacity else { return false } + + entries.append(Entry(sequence: nextSequence, frame: frame, byteCount: byteCount)) + nextSequence &+= 1 + queuedBytes += byteCount + return true + } + + public mutating func tick(at now: PresentationInstant) -> VisualizerPresentationBatch { + var selected: [(VisualizerType, Entry)] = [] + var retained: [Entry] = [] + var newRetainedBytes = 0 + + for entry in entries { + guard entry.frame.isValid else { continue } + guard entry.frame.presentationTime <= now else { + retained.append(entry) + newRetainedBytes += entry.byteCount + continue + } + if let index = selected.firstIndex(where: { $0.0 == entry.frame.type }) { + if isLater(entry, than: selected[index].1) { + selected[index].1 = entry + } + } else { + selected.append((entry.frame.type, entry)) + } + } + entries = retained + queuedBytes = newRetainedBytes + + var frames = selected.map { $0.1 } + .sorted { lhs, rhs in + if lhs.frame.presentationTime != rhs.frame.presentationTime { + return lhs.frame.presentationTime < rhs.frame.presentationTime + } + return lhs.sequence < rhs.sequence + } + .compactMap { entry -> VisualizerFrame? in + guard entry.frame.isValid else { return nil } + setPresented(entry.frame) + return entry.frame + } + + var clearedTypes: [VisualizerType] = [] + let invalidPresentedTypes = presented.compactMap { type, frame, _ in + frame.isValid ? nil : type + } + for type in invalidPresentedTypes { + if let index = presented.firstIndex(where: { $0.0 == type }) { + presentedBytes -= presented[index].2 + presented.remove(at: index) + } + appendCleared(type, to: &clearedTypes) + } + for frame in frames where !frame.isValid { + if let index = presented.firstIndex(where: { $0.0 == frame.type }) { + presentedBytes -= presented[index].2 + presented.remove(at: index) + } + appendCleared(frame.type, to: &clearedTypes) + } + frames.removeAll { !$0.isValid } + return VisualizerPresentationBatch(frames: frames, clearedTypes: clearedTypes) + } + + public mutating func reset() { + entries.removeAll(keepingCapacity: true) + queuedBytes = 0 + presentedBytes = 0 + presented.removeAll(keepingCapacity: true) + } + + private var availableCapacity: Int { + capacityBytes - presentedBytes - queuedBytes + } + + private mutating func setPresented(_ frame: VisualizerFrame) { + let byteCount = Self.visualizerHeaderByteCount + frame.data.count + if let index = presented.firstIndex(where: { $0.0 == frame.type }) { + presentedBytes -= presented[index].2 + presented[index].1 = frame + presented[index].2 = byteCount + } else { + presented.append((frame.type, frame, byteCount)) + } + presentedBytes += byteCount + } + + private func appendCleared(_ type: VisualizerType, to clearedTypes: inout [VisualizerType]) { + guard !clearedTypes.contains(type) else { return } + clearedTypes.append(type) + } + + private func isLater(_ lhs: Entry, than rhs: Entry) -> Bool { + if lhs.frame.presentationTime != rhs.frame.presentationTime { + return lhs.frame.presentationTime > rhs.frame.presentationTime + } + return lhs.sequence > rhs.sequence + } +} diff --git a/Examples/VisualizerClient/Tests/VisualizerClientCoreTests/VisualizerPresentationSchedulerTests.swift b/Examples/VisualizerClient/Tests/VisualizerClientCoreTests/VisualizerPresentationSchedulerTests.swift new file mode 100644 index 0000000..7d024ac --- /dev/null +++ b/Examples/VisualizerClient/Tests/VisualizerClientCoreTests/VisualizerPresentationSchedulerTests.swift @@ -0,0 +1,164 @@ +import Foundation +import Testing +import VisualizerClientCore +@testable import SendspinKit + +struct VisualizerPresentationSchedulerTests { + private static let configuration = VisualizerStreamConfiguration( + types: [.loudness, .spectrum], + rateMax: 30, + spectrum: SpectrumConfiguration(nDispBins: 2, scale: .log, fMin: 60, fMax: 16_000) + ) + + @Test + func ingestDoesNotLetFutureFrameStarveAnotherType() { + var scheduler = VisualizerPresentationScheduler(capacityBytes: 128) + let now = PresentationInstant(rawMicroseconds: 1_000) + let futureLoudness = frame(.loudness, byte: 1, at: 2_000) + let dueSpectrum = frame(.spectrum, byte: 2, at: 1_000) + + let acceptedFuture = scheduler.ingest(futureLoudness) + let acceptedDue = scheduler.ingest(dueSpectrum) + #expect(acceptedFuture) + #expect(acceptedDue) + let batch = scheduler.tick(at: now) + + #expect(batch.frames.map(\.type) == [.spectrum]) + #expect(batch.frames.first?.data == Data([2])) + #expect(scheduler.retainedByteCount == 2 * (futureLoudness.data.count + VisualizerPresentationScheduler.visualizerHeaderByteCount)) + } + + @Test + func tickSelectsLatestDueValuePerTypeAndOrdersEqualTimestampsByArrival() { + var scheduler = VisualizerPresentationScheduler(capacityBytes: 256) + let timestamp = PresentationInstant(rawMicroseconds: 1_000) + let firstLoudness = frame(.loudness, byte: 1, at: timestamp.rawMicroseconds) + let latestLoudness = frame(.loudness, byte: 2, at: timestamp.rawMicroseconds) + let spectrum = frame(.spectrum, byte: 3, at: timestamp.rawMicroseconds) + + let acceptedFirst = scheduler.ingest(firstLoudness) + let acceptedLatest = scheduler.ingest(latestLoudness) + let acceptedSpectrum = scheduler.ingest(spectrum) + #expect(acceptedFirst) + #expect(acceptedLatest) + #expect(acceptedSpectrum) + let batch = scheduler.tick(at: timestamp) + + #expect(batch.frames.map(\.type) == [.loudness, .spectrum]) + #expect(batch.frames.map(\.data) == [Data([2]), Data([3])]) + #expect(scheduler.retainedByteCount == 2 * VisualizerPresentationScheduler.visualizerHeaderByteCount + 2) + } + + @Test + func futureFramesRemainQueuedUntilPresentationClockReachesDeadline() { + var scheduler = VisualizerPresentationScheduler(capacityBytes: 128) + let future = frame(.loudness, byte: 1, at: 2_000) + let accepted = scheduler.ingest(future) + #expect(accepted) + + #expect(scheduler.tick(at: PresentationInstant(rawMicroseconds: 1_999)).frames.isEmpty) + #expect(scheduler.retainedByteCount == 10) + #expect(scheduler.tick(at: PresentationInstant(rawMicroseconds: 2_000)).frames == [future]) + } + + @Test + func realMailboxFramesReachSchedulerWithoutDeadlineBlocking() async throws { + let mailbox = VisualizerFrameMailbox(capacityBytes: 128, now: { PresentationInstant(rawMicroseconds: 0) }) + let subscription = try VisualizerFrameSubscription(acquiring: mailbox) + let first = frame(.loudness, byte: 1, at: 1_000) + let second = frame(.spectrum, byte: 2, at: 1_000) + mailbox.offer(first, now: PresentationInstant(rawMicroseconds: 0)) + mailbox.offer(second, now: PresentationInstant(rawMicroseconds: 0)) + + var iterator = subscription.makeAsyncIterator() + var scheduler = VisualizerPresentationScheduler(capacityBytes: 128) + let firstFromMailbox = try #require(await VisualizerFrameIngestor.consumeNext(from: &iterator)) + let secondFromMailbox = try #require(await VisualizerFrameIngestor.consumeNext(from: &iterator)) + let acceptedFirst = scheduler.ingest(firstFromMailbox) + let acceptedSecond = scheduler.ingest(secondFromMailbox) + #expect(acceptedFirst) + #expect(acceptedSecond) + + let batch = scheduler.tick(at: PresentationInstant(rawMicroseconds: 1_000)) + #expect(batch.frames.map(\.type) == [.loudness, .spectrum]) + subscription.cancel() + } + + @Test + func byteBudgetEvictsOldestFramesForFreshness() { + var scheduler = VisualizerPresentationScheduler(capacityBytes: 19) + let first = frame(.loudness, byte: 1, at: 1_000) + let second = frame(.spectrum, byte: 2, at: 1_001) + + let acceptedFirst = scheduler.ingest(first) + let acceptedSecond = scheduler.ingest(second) + #expect(acceptedFirst) + #expect(acceptedSecond) + #expect(scheduler.retainedByteCount == VisualizerPresentationScheduler.visualizerHeaderByteCount + second.data.count) + #expect(scheduler.tick(at: PresentationInstant(rawMicroseconds: 1_001)).frames == [second]) + } + + @Test + func shownFramesConsumeTheSameByteBudgetAsQueuedFrames() { + var scheduler = VisualizerPresentationScheduler(capacityBytes: 19) + let shown = frame(.loudness, byte: 1, at: 1_000) + let queued = frame(.spectrum, byte: 2, at: 2_000) + + let acceptedShown = scheduler.ingest(shown) + #expect(acceptedShown) + _ = scheduler.tick(at: PresentationInstant(rawMicroseconds: 1_000)) + let acceptedQueued = scheduler.ingest(queued) + + #expect(!acceptedQueued) + #expect(scheduler.retainedByteCount == VisualizerPresentationScheduler.visualizerHeaderByteCount + shown.data.count) + } + + @Test + func invalidGenerationClearsPreviouslyPresentedValue() { + let validity = VisualizerFrameValidity() + let frame = VisualizerFrame( + type: .loudness, + data: Data([1]), + presentationTime: PresentationInstant(rawMicroseconds: 1_000), + configuration: Self.configuration, + validity: validity + ) + var scheduler = VisualizerPresentationScheduler(capacityBytes: 128) + let accepted = scheduler.ingest(frame) + #expect(accepted) + _ = scheduler.tick(at: PresentationInstant(rawMicroseconds: 1_000)) + + validity.invalidate() + let batch = scheduler.tick(at: PresentationInstant(rawMicroseconds: 2_000)) + + #expect(batch.frames.isEmpty) + #expect(batch.clearedTypes == [.loudness]) + } + + @Test + func displayedValueIsRetainedAcrossTicksUntilReplacementOrReset() { + var scheduler = VisualizerPresentationScheduler(capacityBytes: 128) + let first = frame(.loudness, byte: 1, at: 1_000) + let replacement = frame(.loudness, byte: 2, at: 3_000) + + let acceptedFirst = scheduler.ingest(first) + #expect(acceptedFirst) + _ = scheduler.tick(at: PresentationInstant(rawMicroseconds: 1_000)) + let acceptedReplacement = scheduler.ingest(replacement) + #expect(acceptedReplacement) + #expect(scheduler.tick(at: PresentationInstant(rawMicroseconds: 2_000)).frames.isEmpty) + #expect(scheduler.tick(at: PresentationInstant(rawMicroseconds: 3_000)).frames == [replacement]) + scheduler.reset() + #expect(scheduler.retainedByteCount == 0) + #expect(scheduler.tick(at: PresentationInstant(rawMicroseconds: 4_000)).frames.isEmpty) + } + + private func frame(_ type: VisualizerType, byte: UInt8, at timestamp: Int64) -> VisualizerFrame { + VisualizerFrame( + type: type, + data: Data([byte]), + presentationTime: PresentationInstant(rawMicroseconds: timestamp), + configuration: Self.configuration + ) + } +} diff --git a/README.md b/README.md index b88a98f..916eb98 100644 --- a/README.md +++ b/README.md @@ -125,21 +125,40 @@ makes the client available again but does not automatically rejoin its previous ## Pairing codes Pairing-code flows are app-facing setup hooks. Enable a method in `PairingConfiguration`, then -listen to the existing `SendspinClient.events()` stream for -`ClientEvent.pairingCodeChanged(_:)`: +listen to `SendspinClient.events()` for `ClientEvent.pairingCodeChanged(_:)`, +`ClientEvent.pairingAttemptEnded(_:)`, and `ClientEvent.paired(_:)`. Each event carries a +`PairingAttemptSnapshot`; keep its `id` with the UI state that displayed its code. - Dynamic pairing emits a `PairingCodeEmission` with `format == .digits` and a contiguous six-digit `payload`, or with `format == .qrCode` and a complete version-one `SP:1` `payload`. Display or speak the value from the app; presentation grouping and QR image generation remain app - responsibilities. When the server advertises the optional speaker capability, a digits emission - also carries a validated `digitAudioPack`; the host app is responsible for decoding and playing - those ten clips. A `nil` emission clears any displayed code. -- Call `try await client.openPairingWindow()` from the app's physical-gesture or equivalent - operator-confirmation hook. The call records or consumes the connection-owned window intent and - returns without waiting for pairing to finish. Call `try await client.cancelPairingAttempt()` to - cancel an in-progress attempt or close the local window. -- Listen for `ClientEvent.pairingAttemptEnded(_:)` to distinguish reasons such as - `.pairingCodeMismatch`, `.userCancelled`, `.attemptTimeout`, and `.methodNotSupported`. + responsibilities. A `nil` `code` clears any displayed code. +- Call `try await client.openPairingWindow(for: snapshot.id)` from the app's physical-gesture or + equivalent operator-confirmation hook. It records or consumes the connection-owned window and + returns without waiting for pairing to finish. +- Cancel only the attempt represented by the ID captured with the rendered snapshot: + + ```swift + // `renderedPairing` is the immutable snapshot captured by the UI row/button. + let displayedAttemptID = renderedPairing?.id + if let displayedAttemptID { + do { + try await client.cancelPairing(attemptID: displayedAttemptID) + } catch SendspinClientError.stalePairingAttempt { + // The displayed attempt ended; do not retarget a newer attempt. + } + } + ``` + + `PairingAttemptID` is opaque. A retry retains its ID; a later activation receives a new one. + `client.currentPairing` retains the latest terminal snapshot until another attempt starts. + `client.pairingWindow` is the observable authorization window for that attempt and becomes `nil` + when it expires or closes; its `expiresAt` is UI state, not a trust assertion. + `snapshot.peer.id` is unverified while `snapshot.peer.trustLevel == .none`; only successful + pairing establishes `.user` trust. The authorization window is operator consent, not server trust. + Handle terminal `snapshot.phase` values such as + `.ended(.pairingCodeMismatch)`, `.ended(.userCancelled)`, `.ended(.attemptTimeout)`, and + `.ended(.methodNotSupported)` as outcomes rather than assuming cancellation succeeded. Static pairing uses `PairingConfiguration(staticPairingCode:staticPairingCodeEnabled:)`. The host must provision and persist a device-unique eight-digit ASCII decimal code; never ship a fixed @@ -213,14 +232,19 @@ schedule color changes alongside audio, artwork, or visualizer updates. Configure the visualizer role when creating the client. The requested types, maximum update rate, and optional spectrum parameters are published in `client/state`; the server's negotiated types, rate, conditional `tracks_downbeats`, and spectrum parameters are exposed by the -`.visualizerStreamStarted` event and `currentVisualizerStreamConfiguration`. Visualizer frames are -delivered through `client.visualizerData`; each `VisualizerData` includes its `type`, raw payload, -local display deadline, and a stream-generation validity token. Consumers must check -`frame.isRenderable` immediately before drawing: this rejects frames invalidated by -`stream/clear`, `stream/end`, or session replacement, and also rejects frames whose display -deadline has become stale while queued. Each frame retains the negotiated configuration that -validated it, including across an in-place configuration update. Frames whose translated -deadline has already passed at arrival are discarded. +`.visualizerStreamStarted` event and `currentVisualizerStreamConfiguration`. Acquire the single +bounded data-plane consumer with `try client.acquireVisualizerFrames()`. Each `VisualizerFrame` +contains its type, raw payload, typed `presentationTime`, and the negotiated configuration that +validated it. The subscription drops stale frames and invalidates queued frames when a stream or +session ends; it never creates an unbounded producer queue. + +Use `PresentationClock` and `PresentationInstant` for scheduling. A frame is eligible only while +`frame.eligibilityForScheduling(at: clock.now)` is true; after sleeping until its presentation +instant, capture a fresh instant and call `frame.isValid` immediately before submitting it to the +view model or display tick. `isValid` checks stream generation only, so a due frame can remain valid; +its deadline is a scheduling decision, not a lifetime check. Do not convert presentation instants +through wall clock time or draw a frame early. See `Examples/VisualizerClient` for a bounded SwiftUI +consumer; a display-link submission is not a guarantee about screen-photon timing. ```swift let visualizer = try SendspinClient( @@ -264,6 +288,17 @@ SendspinKit uses a Kalman filter for clock synchronization and timestamp-based a - **Playback Window** — Configurable tolerance for network jitter (default +/-50ms) - **Sync Correction** — Frame-level drop/insert to maintain alignment without audible glitches +A successful command API call means that SendspinKit accepted and sent the encrypted command. It is +not a server acknowledgement and is not evidence that application audio has started, completed, or +become audible. Treat the subsequent state/event stream and audio output telemetry as separate +signals. + +`outputDelayMs` models physical downstream delay after the client submits audio to its output path. +When the value changes, pending audio is retimed for the new delay and already submitted audio cannot +be rewritten. The command/event transition therefore does not create instantaneous acoustic +convergence: the new timing takes effect as the retimed pipeline reaches the downstream device. +Keep this distinction when measuring synchronization or presenting completion UI. + ## Documentation API documentation is available via DocC. Build it locally with: diff --git a/Sources/SendspinKit.docc/Articles/AudioPipeline.md b/Sources/SendspinKit.docc/Articles/AudioPipeline.md index d9c0fea..1ec6e45 100644 --- a/Sources/SendspinKit.docc/Articles/AudioPipeline.md +++ b/Sources/SendspinKit.docc/Articles/AudioPipeline.md @@ -39,4 +39,4 @@ The sync offset is used by the scheduler to convert server-domain timestamps to ## Custom audio processing -To access raw audio data for visualization or effects, enable ``PlayerConfiguration/emitRawAudioEvents`` and consume ``SendspinClient/audioChunks``. You can also provide a process callback via ``PlayerConfiguration/processCallback`` that runs inline in the audio pipeline before scheduling. Visualizer-role payloads are delivered separately through ``SendspinClient/visualizerData``. +To access raw audio data for visualization or effects, enable ``PlayerConfiguration/emitRawAudioEvents`` and consume ``SendspinClient/audioChunks``. You can also provide a process callback via ``PlayerConfiguration/processCallback`` that runs inline in the audio pipeline before scheduling. Visualizer-role payloads are delivered separately through ``SendspinClient/acquireVisualizerFrames()`` as bounded ``VisualizerFrame`` values with typed presentation deadlines. diff --git a/Sources/SendspinKit.docc/Articles/Events.md b/Sources/SendspinKit.docc/Articles/Events.md index 73b4c50..cd3d5a9 100644 --- a/Sources/SendspinKit.docc/Articles/Events.md +++ b/Sources/SendspinKit.docc/Articles/Events.md @@ -54,3 +54,21 @@ These properties update on the main actor and trigger SwiftUI view updates autom ``ColorState`` includes both its raw server timestamp and a local absolute display time when clock sync is ready. Most UI consumers can apply colors immediately; synchronized consumers can schedule the update using ``ColorState/localDisplayTime``. + +## Completion and physical timing + +A command method returning successfully means that SendspinKit accepted and sent the encrypted +command. It does not mean that the server acknowledged the command, that application audio has +started or ended, or that a sample is audible. Use the resulting control events and output telemetry +when the UI needs an observed state transition. + +``SendspinClient/outputDelayMs`` describes physical downstream delay after submission to the output +path. Changing it retimes pending audio; samples already submitted cannot be rewritten. Consequently +an output-delay change is not instantaneous acoustic convergence. The adjusted timing becomes +observable as the retimed pipeline reaches the downstream device. + +For visualizers, ``PresentationClock`` and ``PresentationInstant`` are a monotonic scheduling domain, +not a display-photon clock. ``PresentationClock/sleep(until:)`` prevents early submission and +``VisualizerFrame/isValid`` checks stream generation at the due instant, but a CoreVideo display-link +submission does not guarantee the next screen refresh or exact photon timing. Consumers must not +claim refresh synchronization without an independently measured clock mapping. diff --git a/Sources/SendspinKit.docc/Articles/GettingStarted.md b/Sources/SendspinKit.docc/Articles/GettingStarted.md index f250657..81db5e6 100644 --- a/Sources/SendspinKit.docc/Articles/GettingStarted.md +++ b/Sources/SendspinKit.docc/Articles/GettingStarted.md @@ -47,6 +47,34 @@ include ``SpectrumConfiguration`` whenever the requested types contain ``Visuali These role configurations seed the initial `client/state` snapshot; dynamic preference changes use the corresponding state-preference APIs. +A visualizer consumer owns one bounded subscription. Consume frames FIFO, use the monotonic +``PresentationClock`` to await each future ``VisualizerFrame/presentationTime``, then check +``VisualizerFrame/isValid`` before handing the due value to the UI. `isValid` checks stream generation +only, so a due frame can remain valid; `eligibilityForScheduling(at:)` is the pre-deadline gate. +Never convert these instants through wall-clock time or retain an unbounded app queue. + +```swift +let frames = try client.acquireVisualizerFrames() +let consumer = Task { + var iterator = frames.makeAsyncIterator() + let clock = PresentationClock() + while let frame = await iterator.next() { + if frame.eligibilityForScheduling(at: clock.now) { + try await clock.sleep(until: frame.presentationTime) + } + guard frame.isValid else { continue } + // Replace the latest due value for this type in a bounded UI mailbox. + submitDueFrame(frame) + } +} + +// On shutdown: consumer.cancel(); frames.cancel(); await client.close() +``` + +A display-link submission is not a guarantee of the next screen refresh or screen-photon time; an +app must not claim exact refresh synchronization without an independently measured clock mapping. +See the runnable ``VisualizerClient`` example for a SwiftUI/AppKit implementation. + ## Leave a group Any client role can leave its current server group: @@ -122,35 +150,39 @@ for await event in client.events() { ## Pair with a code Code-based pairing is coordinated by the host app. Pass a ``PairingConfiguration`` with the -method enabled, start consuming ``SendspinClient/events``, and use the pairing events to drive the -operator UI: +method enabled, start consuming ``SendspinClient/events``, and retain the complete +``PairingAttemptSnapshot`` that drives the operator UI: ```swift for await event in client.events() { switch event { - case let .pairingCodeChanged(emission?): - print("Pairing \(emission.format.rawValue): \(emission.payload)") - // Display or speak digits; render the complete SP:1 payload as a QR code. - if let pack = emission.digitAudioPack { - playDigitAudio(pack) // The host app decodes and plays the ten clips. + case let .pairingCodeChanged(snapshot): + if let code = snapshot.code { + print("Pairing \(code.format.rawValue): \(code.payload)") } - case .pairingCodeChanged(nil): - print("Pairing code cleared") - case let .pairingAttemptEnded(reason): - print("Pairing attempt ended: \(reason.rawValue)") + case let .pairingAttemptEnded(snapshot): + print("Pairing attempt \(snapshot.id.rawValue) ended: \(snapshot.phase)") + case let .paired(snapshot): + print("Paired with \(snapshot.peer.name); trust: \(snapshot.peer.trustLevel)") default: break } } ``` -Call ``SendspinClient/openPairingWindow()`` when the app receives its physical-gesture or other -operator-confirmation signal. It returns after recording or consuming the connection-owned window; -it does not wait for the attempt. ``SendspinClient/cancelPairingAttempt()`` cancels an attempt or -closes the local window. Dynamic codes are six contiguous digits or a complete version-one `SP:1` -token. If the dynamic method includes a speaker output capability, the digits emission also includes -a validated ``DigitAudioPack``; the host app decodes and plays its clips. Static pairing instead -requires the host to provision and persist a device-unique eight-digit ASCII decimal code with +Call ``SendspinClient/openPairingWindow(for:)`` with the ID captured by the rendered snapshot when +the app receives its physical-gesture or other operator-confirmation signal. It returns after +recording or consuming the connection-owned window; it does not wait for the attempt. To cancel, +call ``SendspinClient/cancelPairing(attemptID:)`` with that same captured ID. A stale ID throws +``SendspinClientError/stalePairingAttempt(_:)`` and never retargets a newer attempt. The observable +``SendspinClient/currentPairing`` retains the latest terminal snapshot until a new attempt starts; +``SendspinClient/pairingWindow`` becomes `nil` when its authorization window expires or closes, and +its `expiresAt` is not a trust assertion. The peer ID is unverified while +``PairingPeer/trustLevel`` is `.none`; the authorization window is not proof of server trust. Dynamic +codes are six contiguous digits or a complete version-one `SP:1` token. If +the dynamic method includes a speaker output capability, the code emission also includes a validated +``DigitAudioPack``; the host app decodes and plays its clips. Static pairing instead requires the +host to provision and persist a device-unique eight-digit ASCII decimal code with ``PairingConfiguration/init(pairingPsk:store:enabled:dynamicPairingCodeEnabled:staticPairingCode:staticPairingCodeEnabled:digitAudio:)``; the library never supplies a fixed default or emits that secret. Choose at most one code method in ``PairingConfiguration``. Dynamic pairing binds device presence, while a leaked static code is diff --git a/Sources/SendspinKit/Client/ClientTypes.swift b/Sources/SendspinKit/Client/ClientTypes.swift index 23a6655..3e3eff7 100644 --- a/Sources/SendspinKit/Client/ClientTypes.swift +++ b/Sources/SendspinKit/Client/ClientTypes.swift @@ -144,17 +144,74 @@ public struct VisualizerStreamConfiguration: Sendable, Equatable { } } +/// Monotonic presentation-time instant used by SendspinKit. +/// `rawMicroseconds` shares the synchronized server-timestamp domain after conversion; +/// compare only with another ``PresentationInstant`` (it is not wall-clock time). +public struct PresentationInstant: Sendable, Hashable, Comparable { + public let rawMicroseconds: Int64 + + public init(rawMicroseconds: Int64) { + self.rawMicroseconds = rawMicroseconds + } + + /// The current instant in the presentation clock's monotonic domain. + public static var now: Self { + Self(rawMicroseconds: MonotonicClock.absoluteMicroseconds()) + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawMicroseconds < rhs.rawMicroseconds + } + + /// Adds a duration, saturating if the resulting microsecond value overflows. + public func adding(_ duration: Duration) -> Self { + let components = duration.components + let seconds = components.seconds.multipliedReportingOverflow(by: 1_000_000) + let micros = components.attoseconds / 1_000_000_000_000 + let (whole, overflow) = seconds.partialValue.addingReportingOverflow(micros) + let delta: Int64 = if seconds.overflow || overflow { + components.seconds >= 0 ? .max : .min + } else { + whole + } + let (result, resultOverflow) = rawMicroseconds.addingReportingOverflow(delta) + return Self(rawMicroseconds: resultOverflow ? (delta >= 0 ? .max : .min) : result) + } + + public func duration(to other: Self) -> Duration { + let (delta, overflow) = other.rawMicroseconds.subtractingReportingOverflow(rawMicroseconds) + return .microseconds(overflow ? (other > self ? .max : .min) : delta) + } +} + +/// Access to the same monotonic time domain used for visualizer deadlines. +public struct PresentationClock: Sendable { + public init() {} + + public var now: PresentationInstant { + .now + } + + public func duration(from start: PresentationInstant, to end: PresentationInstant) -> Duration { + start.duration(to: end) + } + + /// Sleeps until an instant in the presentation domain without converting through wall time. + public func sleep(until instant: PresentationInstant) async throws { + let remaining = now.duration(to: instant) + guard remaining > .zero else { return } + try await Task.sleep(for: remaining) + } +} + /// Validity shared by frames in one visualizer stream generation. -/// Queued frames can outlive stream boundaries or session replacement; check -/// ``VisualizerData/isRenderable`` before drawing. -public final class VisualizerFrameValidity: @unchecked Sendable { +/// The token is intentionally private to the library; stream boundaries invalidate +/// queued frames without exposing mutable lifetime state to app code. +final class VisualizerFrameValidity: @unchecked Sendable { private let lock = NSLock() private var valid = true - public init() {} - - /// Whether this frame still belongs to the active visualizer stream. - public var isValid: Bool { + var isValid: Bool { lock.withLock { valid } } @@ -164,47 +221,70 @@ public final class VisualizerFrameValidity: @unchecked Sendable { } /// Visualizer bytes received from the visualizer stream. -public struct VisualizerData: Sendable, Equatable { +/// +/// Stream validity can change independently of a frame's bytes. +/// Use ``matchesPayload(_:)`` for explicit payload equality rather than comparing +/// stream-lifetime state. +public struct VisualizerFrame: Sendable, Equatable { /// The visualization type encoded by the binary message type byte. public let type: VisualizerType /// Raw visualizer payload bytes after the Sendspin binary header. public let data: Data - /// Local absolute display time in microseconds. - public let localDisplayTime: Int64 - /// The negotiated configuration used to validate this frame. Consumers should - /// use this snapshot when rendering queued frames after a configuration update. - public let streamConfiguration: VisualizerStreamConfiguration? - /// Stream-generation validity. Check this immediately before rendering. - public let validity: VisualizerFrameValidity + /// The local monotonic instant at which this frame should be presented. + public let presentationTime: PresentationInstant + /// The negotiated configuration used to validate this frame. + public let configuration: VisualizerStreamConfiguration + + private let validity: VisualizerFrameValidity + + /// Whether this frame belongs to the active visualizer stream. + /// Check immediately before presenting; a late frame can remain `isValid`. + /// Lateness is scheduling eligibility, not stream lifetime. + public var isValid: Bool { + validity.isValid + } - /// True only while this frame belongs to the active stream and its display deadline is fresh. - public var isRenderable: Bool { - isRenderable(at: MonotonicClock.absoluteMicroseconds()) + /// Whether this frame may be submitted to a future display schedule at `instant`. + /// The deadline check is intentionally separate from ``isValid`` at presentation. + public func eligibilityForScheduling(at instant: PresentationInstant) -> Bool { + validity.isValid && presentationTime > instant } - /// True only while valid and not already late at the supplied local instant. - public func isRenderable(at localNow: Int64) -> Bool { - validity.isValid && localDisplayTime > localNow + /// Explicit payload comparison that excludes stream-generation lifetime. + public func matchesPayload(_ other: VisualizerFrame) -> Bool { + type == other.type && data == other.data && presentationTime == other.presentationTime + && configuration == other.configuration + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.matchesPayload(rhs) } public init( type: VisualizerType, data: Data, - localDisplayTime: Int64, - streamConfiguration: VisualizerStreamConfiguration? = nil, - validity: VisualizerFrameValidity = VisualizerFrameValidity() + presentationTime: PresentationInstant, + configuration: VisualizerStreamConfiguration ) { self.type = type self.data = data - self.localDisplayTime = localDisplayTime - self.streamConfiguration = streamConfiguration - self.validity = validity + self.presentationTime = presentationTime + self.configuration = configuration + validity = VisualizerFrameValidity() } - public static func == (lhs: VisualizerData, rhs: VisualizerData) -> Bool { - lhs.type == rhs.type && lhs.data == rhs.data && lhs.localDisplayTime == rhs.localDisplayTime - && lhs.streamConfiguration == rhs.streamConfiguration - && lhs.validity === rhs.validity + init( + type: VisualizerType, + data: Data, + presentationTime: PresentationInstant, + configuration: VisualizerStreamConfiguration, + validity: VisualizerFrameValidity + ) { + self.type = type + self.data = data + self.presentationTime = presentationTime + self.configuration = configuration + self.validity = validity } } @@ -225,11 +305,72 @@ public struct PairingCodeEmission: Sendable, Equatable { } } +/// Opaque identity for one admitted pairing attempt. A retry keeps this identity; +/// a later activation receives a new identity. +public struct PairingAttemptID: Hashable, Sendable { + public let rawValue: UUID + + init() { + rawValue = UUID() + } +} + +/// The server descriptor is available before authentication and therefore reports +/// ``TrustLevel/none`` until the pairing succeeds. +public struct PairingPeer: Sendable, Equatable { + public let id: String + public let name: String + public let trustLevel: TrustLevel + + public init(id: String, name: String, trustLevel: TrustLevel = .none) { + self.id = id + self.name = name + self.trustLevel = trustLevel + } +} + +public enum PairingAttemptPhase: Sendable, Equatable { + /// The server has admitted pairing but setup is waiting for authorization. + case pending + case codeReady + case authenticating + case succeeded + case ended(PairAbortReason) +} + +/// Immutable UI projection of a connection-owned pairing attempt. +public struct PairingAttemptSnapshot: Sendable, Equatable { + public let id: PairingAttemptID + public let peer: PairingPeer + public let phase: PairingAttemptPhase + public let code: PairingCodeEmission? + + public init(id: PairingAttemptID, peer: PairingPeer, phase: PairingAttemptPhase, code: PairingCodeEmission? = nil) { + self.id = id + self.peer = peer + self.phase = phase + self.code = code + } +} + +/// The operator-authorized window for one pairing attempt. The owning attempt +/// identity is explicit because pairing peers are untrusted until pairing succeeds. +public struct PairingWindowSnapshot: Sendable, Equatable { + public let attemptID: PairingAttemptID + public let expiresAt: PresentationInstant + + public init(attemptID: PairingAttemptID, expiresAt: PresentationInstant) { + self.attemptID = attemptID + self.expiresAt = expiresAt + } +} + public enum ClientEvent: Sendable, Equatable { case serverConnected(ServerInfo) - case pairingCodeChanged(PairingCodeEmission?) - case pairingAttemptEnded(PairAbortReason) - case paired(serverId: String) + case pairingCodeChanged(PairingAttemptSnapshot) + case pairingAttemptEnded(PairingAttemptSnapshot) + case pairingWindowChanged(PairingWindowSnapshot?) + case paired(PairingAttemptSnapshot) /// The client observed a new advisory audio-output capability snapshot. case audioOutputChanged(AudioOutputSnapshot) /// The current session's output-format negotiation status changed. @@ -502,6 +643,8 @@ public enum StreamRole: String, Sendable, Hashable { public enum SendspinClientError: SendspinError, Equatable, LocalizedError { /// A method that requires an active connection was called while disconnected. case notConnected + /// A pairing command referred to an attempt that is no longer active. + case stalePairingAttempt(PairingAttemptID) /// ``SendspinClient/connect(to:)`` or ``SendspinClient/acceptConnection(_:)`` /// was called while a connection is already in progress or established. case alreadyConnected @@ -526,6 +669,8 @@ public enum SendspinClientError: SendspinError, Equatable, LocalizedError { switch self { case .notConnected: "Not connected to a Sendspin server" + case let .stalePairingAttempt(id): + "Pairing attempt \(id.rawValue) is no longer active" case .alreadyConnected: "Already connected or connecting to a Sendspin server" case let .sendFailed(reason): diff --git a/Sources/SendspinKit/Client/ConnectionDataDelivery.swift b/Sources/SendspinKit/Client/ConnectionDataDelivery.swift index fca13bf..f61a49b 100644 --- a/Sources/SendspinKit/Client/ConnectionDataDelivery.swift +++ b/Sources/SendspinKit/Client/ConnectionDataDelivery.swift @@ -14,13 +14,13 @@ final class ConnectionDataDelivery: @unchecked Sendable { private let audio: AsyncStream.Continuation private let artwork: AsyncStream.Continuation - private let visualizer: VisualizerDataMailbox + private let visualizer: VisualizerFrameMailbox private let artworkObserver: (@Sendable (ArtworkData) -> Void)? init( audio: AsyncStream.Continuation, artwork: AsyncStream.Continuation, - visualizer: VisualizerDataMailbox, + visualizer: VisualizerFrameMailbox, artworkObserver: (@Sendable (ArtworkData) -> Void)? ) { self.audio = audio @@ -52,7 +52,7 @@ final class ConnectionDataDelivery: @unchecked Sendable { } } - func offerVisualizerIfValid(_ value: VisualizerData, validity: SessionValidityToken) { + func offerVisualizerIfValid(_ value: VisualizerFrame, validity: SessionValidityToken) { lock.withLock { guard mode == .primary else { return } validity.offerIfValid(value, to: visualizer) diff --git a/Sources/SendspinKit/Client/ConnectionEvent.swift b/Sources/SendspinKit/Client/ConnectionEvent.swift index 39818c8..a36d316 100644 --- a/Sources/SendspinKit/Client/ConnectionEvent.swift +++ b/Sources/SendspinKit/Client/ConnectionEvent.swift @@ -75,10 +75,11 @@ enum ConnectionEvent: Equatable { case playerMutedChanged(Bool) /// Pairing persisted a new long-term record. - case paired(serverId: String) + case paired(PairingAttemptSnapshot) - case pairingCodeChanged(PairingCodeEmission?) - case pairingAttemptEnded(PairAbortReason) + case pairingCodeChanged(PairingAttemptSnapshot) + case pairingAttemptEnded(PairingAttemptSnapshot) + case pairingWindowChanged(PairingWindowSnapshot?) /// Server changed admitted activities or active roles. case serverActivated(activities: Set, activeRoles: Set) diff --git a/Sources/SendspinKit/Client/SendspinClient+Commands.swift b/Sources/SendspinKit/Client/SendspinClient+Commands.swift index f97b7d9..db859cf 100644 --- a/Sources/SendspinKit/Client/SendspinClient+Commands.swift +++ b/Sources/SendspinKit/Client/SendspinClient+Commands.swift @@ -169,20 +169,36 @@ extension SendspinClient { } public extension SendspinClient { - /// Open the connection-owned pairing window for one code-based attempt. + /// Open the attempt-scoped pairing window for `attemptID`. + /// Dynamic pairing resets the global budget and performs a fallible round reservation; + /// static pairing does neither. The window controls eligibility, not peer trust. @MainActor - func openPairingWindow() async throws { + func openPairingWindow(for attemptID: PairingAttemptID) async throws { try requireOpen() - guard let connection = pairingTargetConnection() else { throw SendspinClientError.notConnected } - await connection.openPairingWindow() + let candidates = [connection, pairingConnection].compactMap(\.self) + guard !candidates.isEmpty else { throw SendspinClientError.notConnected } + for candidate in candidates { + guard let snapshot = await candidate.pairingAttemptSnapshot(), snapshot.id == attemptID else { continue } + try await candidate.openPairingWindow(attemptID: attemptID) + return + } + throw SendspinClientError.stalePairingAttempt(attemptID) } - /// Cancel the current code-based pairing attempt, if any. + /// Cancel exactly the attempt represented by `attemptID`. The ID is matched + /// against both the primary and parked pairing connection; it never retargets + /// another connection after the original attempt ends. @MainActor - func cancelPairingAttempt() async throws { + func cancelPairing(attemptID: PairingAttemptID) async throws { try requireOpen() - guard let connection = pairingTargetConnection() else { throw SendspinClientError.notConnected } - await connection.cancelPairingAttempt() + let candidates = [connection, pairingConnection].compactMap(\.self) + guard !candidates.isEmpty else { throw SendspinClientError.notConnected } + for candidate in candidates { + guard let snapshot = await candidate.pairingAttemptSnapshot(), snapshot.id == attemptID else { continue } + try await candidate.cancelPairing(attemptID: attemptID) + return + } + throw SendspinClientError.stalePairingAttempt(attemptID) } /// Start playback. diff --git a/Sources/SendspinKit/Client/SendspinClient+Handshake.swift b/Sources/SendspinKit/Client/SendspinClient+Handshake.swift index 1d4ae92..c7d8034 100644 --- a/Sources/SendspinKit/Client/SendspinClient+Handshake.swift +++ b/Sources/SendspinKit/Client/SendspinClient+Handshake.swift @@ -75,7 +75,20 @@ extension SendspinClient { digitAudio: nil ) } - return await runtime.snapshot() + let configuration = await runtime.snapshot() + // The facade owns the app-facing policy. PairingConfiguration's runtime + // supplies pairing methods and storage state, while this value keeps the + // explicit SendspinClient setting authoritative for each handshake. + return PairingManagementConfiguration( + pairingPsk: configuration.pairingPsk, + pairingPskEnabled: configuration.pairingPskEnabled, + recordModePskId: configuration.recordModePskId, + unpairedAccessEnabled: unpairedAccessEnabled, + dynamicPairingCodeEnabled: configuration.dynamicPairingCodeEnabled, + staticPairingCodeEnabled: configuration.staticPairingCodeEnabled, + staticPairingCode: configuration.staticPairingCode, + digitAudio: configuration.digitAudio + ) } /// Build the client/hello payload from the catalog fixed for this session. diff --git a/Sources/SendspinKit/Client/SendspinClient+PairingCoordinator.swift b/Sources/SendspinKit/Client/SendspinClient+PairingCoordinator.swift index ea3e3e8..6c5e771 100644 --- a/Sources/SendspinKit/Client/SendspinClient+PairingCoordinator.swift +++ b/Sources/SendspinKit/Client/SendspinClient+PairingCoordinator.swift @@ -1,21 +1,34 @@ import Foundation extension SendspinClient { - @MainActor - func pairingTargetConnection() -> SendspinConnection? { - pairingConnection ?? connection - } - @MainActor func applyPairingConnectionEvent(_ event: ConnectionEvent) { guard pairingConnection != nil else { return } switch event { - case let .paired(serverId): - emitEvent(.paired(serverId: serverId)) - case let .pairingCodeChanged(emission): - emitEvent(.pairingCodeChanged(emission)) - case let .pairingAttemptEnded(reason): - emitEvent(.pairingAttemptEnded(reason)) + case let .paired(snapshot): + updateCurrentPairing(snapshot) + // A late success from an older attempt must not close a newer window. + if pairingWindow?.attemptID == snapshot.id { + clearPairingWindow() + } + emitEvent(.paired(snapshot)) + case let .pairingCodeChanged(snapshot): + // The terminal event is followed by a nil-code projection so observers + // can see code removal without losing the terminal lifecycle state. + if case .ended = currentPairing?.phase, snapshot.code == nil, + snapshot.id == currentPairing?.id { + emitEvent(.pairingCodeChanged(snapshot)) + } else { + updateCurrentPairing(snapshot) + emitEvent(.pairingCodeChanged(snapshot)) + } + case let .pairingAttemptEnded(snapshot): + updateCurrentPairing(snapshot) + clearPairingWindow() + emitEvent(.pairingAttemptEnded(snapshot)) + case let .pairingWindowChanged(window): + updatePairingWindow(window) + emitEvent(.pairingWindowChanged(window)) case .disconnected: if let side = pairingConnection { dropPairingConnection(side) diff --git a/Sources/SendspinKit/Client/SendspinClient.swift b/Sources/SendspinKit/Client/SendspinClient.swift index d6def18..005322a 100644 --- a/Sources/SendspinKit/Client/SendspinClient.swift +++ b/Sources/SendspinKit/Client/SendspinClient.swift @@ -40,6 +40,11 @@ public final class SendspinClient { public private(set) var connectionState: ConnectionState = .disconnected /// Trust level established by the currently admitted Noise PSK. public private(set) var trustLevel: TrustLevel = .none + /// Latest immutable pairing projection, including the terminal snapshot until a new attempt starts. + public private(set) var currentPairing: PairingAttemptSnapshot? + /// Operator authorization for one pairing attempt, or nil when no window is open. + /// A window does not change the peer's ``TrustLevel``; that is established only after pairing. + public private(set) var pairingWindow: PairingWindowSnapshot? /// The audio format currently being streamed by the server, or nil if no stream is active. public private(set) var currentStreamFormat: AudioFormatSpec? /// Written both here and by the control drain's `.operationalState` case, so @@ -198,14 +203,15 @@ public final class SendspinClient { /// Most recent artwork payload received from the artwork data stream. public private(set) var currentArtwork: ArtworkData? - let visualizerDataMailbox: VisualizerDataMailbox - /// Visualizer bytes from the visualizer data stream. + let visualizerFrameMailbox: VisualizerFrameMailbox + /// Acquire the single app-facing visualizer frame subscription. /// - /// Delivery is FIFO among retained frames and bounded by the configured - /// visualizer `bufferCapacity` using the wire frame size (9 + payload bytes). - /// Periodic types are requested with the shared `rateMax` scalar; beat and - /// peak remain event-driven as defined by the visualizer role. - public let visualizerData: VisualizerDataStream + /// A subscription owns the bounded mailbox consumer until it is cancelled, + /// its pending read is cancelled, or it is deallocated. A second live + /// subscription fails instead of silently returning an empty iterator. + public func acquireVisualizerFrames() throws(VisualizerFrameAcquisitionError) -> VisualizerFrameSubscription { + try VisualizerFrameSubscription(acquiring: visualizerFrameMailbox) + } public convenience init( identity: SendspinIdentity, @@ -307,8 +313,7 @@ public final class SendspinClient { (audioChunks, audioChunksContinuation) = AsyncStream.makeStream() (artwork, artworkContinuation) = AsyncStream.makeStream() - visualizerDataMailbox = VisualizerDataMailbox(capacityBytes: visualizerConfig?.bufferCapacity ?? 1) - visualizerData = VisualizerDataStream(mailbox: visualizerDataMailbox) + visualizerFrameMailbox = VisualizerFrameMailbox(capacityBytes: visualizerConfig?.bufferCapacity ?? 1) if roleSet.contains(.playerV1) { startAudioOutputCapabilityMonitoring() @@ -335,7 +340,7 @@ public final class SendspinClient { eventSubscribers.removeAll() audioChunksContinuation.finish() artworkContinuation.finish() - visualizerDataMailbox.finish() + visualizerFrameMailbox.finish() // Safety net: dropping a connected client must not leak a live, playing // connection graph. Capture the connection into a local — do NOT capture // self. (`isolated deinit` runs on the MainActor, so reading the isolated @@ -354,7 +359,7 @@ public final class SendspinClient { /// /// Each call returns an independent stream that receives future control events. /// Binary role payloads are not emitted here; use ``audioChunks``, ``artwork``, - /// and ``visualizerData`` for data-plane bytes. + /// and ``acquireVisualizerFrames()`` for data-plane bytes. public func events() -> AsyncStream { let id = UUID() let (stream, continuation) = AsyncStream.makeStream() @@ -432,6 +437,18 @@ public final class SendspinClient { currentControllerState = state } + func updateCurrentPairing(_ snapshot: PairingAttemptSnapshot?) { + currentPairing = snapshot + } + + func updatePairingWindow(_ window: PairingWindowSnapshot?) { + pairingWindow = window + } + + func clearPairingWindow() { + pairingWindow = nil + } + private func updateCodecHeader(_ header: Data?) { currentCodecHeader = header } @@ -631,7 +648,7 @@ public final class SendspinClient { drainConnectionEventsTask?.cancel() drainConnectionEventsTask = nil sessionValidity?.invalidate() - visualizerDataMailbox.clear() + visualizerFrameMailbox.clear() let retired = connection connection = nil return retired @@ -700,7 +717,7 @@ public final class SendspinClient { let dataDelivery = ConnectionDataDelivery( audio: audioChunksContinuation, artwork: artworkContinuation, - visualizer: visualizerDataMailbox, + visualizer: visualizerFrameMailbox, artworkObserver: deliveryArtworkObserver ) if !installAsPairingSide { @@ -846,6 +863,9 @@ public final class SendspinClient { await newConnection.prepareInitialPairingActivation(outcomePairing) } await newConnection.start() + if !installAsPairingSide, let snapshot = await newConnection.pairingAttemptSnapshot() { + updateCurrentPairing(snapshot) + } // Drain control events without retaining the client: upgrade weak `self` per event. // Otherwise a parked task prevents deinit and its cleanup safety net. @@ -1005,7 +1025,7 @@ public final class SendspinClient { eventSubscribers.removeAll() audioChunksContinuation.finish() artworkContinuation.finish() - visualizerDataMailbox.finish() + visualizerFrameMailbox.finish() } /// Record the host application's audio-session activation state. @@ -1085,14 +1105,33 @@ public final class SendspinClient { func applyConnectionEvent(_ event: ConnectionEvent) { // swiftlint:disable:this function_body_length guard !isTerminated else { return } switch event { - case let .paired(serverId): - emitEvent(.paired(serverId: serverId)) + case let .paired(snapshot): + currentPairing = snapshot + // A late success from an older attempt must not close a newer window. + if pairingWindow?.attemptID == snapshot.id { + pairingWindow = nil + } + emitEvent(.paired(snapshot)) + + case let .pairingCodeChanged(snapshot): + // A terminal nil-code projection follows the ended event so a + // consumer can observe both lifecycle and code removal in order. + if case .ended = currentPairing?.phase, snapshot.code == nil, + snapshot.id == currentPairing?.id { + emitEvent(.pairingCodeChanged(snapshot)) + } else { + currentPairing = snapshot + emitEvent(.pairingCodeChanged(snapshot)) + } - case let .pairingCodeChanged(emission): - emitEvent(.pairingCodeChanged(emission)) + case let .pairingAttemptEnded(snapshot): + currentPairing = snapshot + pairingWindow = nil + emitEvent(.pairingAttemptEnded(snapshot)) - case let .pairingAttemptEnded(reason): - emitEvent(.pairingAttemptEnded(reason)) + case let .pairingWindowChanged(window): + pairingWindow = window + emitEvent(.pairingWindowChanged(window)) case let .serverConnected(info): currentServerId = info.serverId @@ -1296,7 +1335,7 @@ public final class SendspinClient { playerStreamActive = false artworkStreamActive = false currentVisualizerStreamConfiguration = nil - visualizerDataMailbox.clear() + visualizerFrameMailbox.clear() } /// Clear server-reported state that is scoped to a single connection. A diff --git a/Sources/SendspinKit/Client/SendspinConnection+Lifecycle.swift b/Sources/SendspinKit/Client/SendspinConnection+Lifecycle.swift index 1c576a2..8d5d976 100644 --- a/Sources/SendspinKit/Client/SendspinConnection+Lifecycle.swift +++ b/Sources/SendspinKit/Client/SendspinConnection+Lifecycle.swift @@ -187,15 +187,18 @@ extension SendspinConnection { } else { visualizerDelivery?.clear() } + if pairingAttemptID != nil { + enqueuePairingCode(nil) + } + pairingAttemptTask?.cancel() + closePairingWindow() pairingAttemptActive = false pendingPairingPsk = nil - if dynamicPairingAttempt != nil { - controlSink.enqueue(.pairingCodeChanged(nil)) - dynamicPairingAttempt = nil - } + dynamicPairingAttempt = nil staticPairingAttempt = nil + pairingAttemptID = nil + pairingAttemptPeer = nil pairingAttemptTask?.cancel() - pairingWindowTask?.cancel() // Stop the engine (async cleanup: close output, finish channels) await audioEngine.shutdown() diff --git a/Sources/SendspinKit/Client/SendspinConnection+MessageHandling.swift b/Sources/SendspinKit/Client/SendspinConnection+MessageHandling.swift index 7081300..0b03ad5 100644 --- a/Sources/SendspinKit/Client/SendspinConnection+MessageHandling.swift +++ b/Sources/SendspinKit/Client/SendspinConnection+MessageHandling.swift @@ -260,7 +260,12 @@ extension SendspinConnection { /// The live message loop starts after setup, so pairing must be initialized here /// rather than waiting for another server/activate frame. func applyInitialPairingActivation(_ pairing: PairingDirective) async { - guard activities == [.pairing], pairingAttemptActive == false else { return } + guard activities == [.pairing] else { return } + if pairingAttemptID == nil { + admitPairingAttempt() + } else { + enqueuePairingSnapshot(phase: .pending) + } pairingAttemptActive = true pairingActivateCounter = pairingActivateCounter == .max ? 0 : pairingActivateCounter + 1 switch pairing.method { @@ -276,6 +281,9 @@ extension SendspinConnection { } func handleServerActivate(_ message: ServerActivateMessage) async { + if Set(message.payload.activities) == [.pairing], pairingAttemptID == nil { + admitPairingAttempt() + } let advertisement = await livePairingAdvertisement() sessionContext = ActivationAdmissibility.SessionContext( category: sessionContext.category, @@ -353,7 +361,7 @@ extension SendspinConnection { await beginStaticPairingAttempt(format: message.payload.pairing?.format) } else if pairingAttemptActive || pendingPairingPsk != nil || dynamicPairingAttempt != nil || staticPairingAttempt != nil { if dynamicPairingAttempt != nil { - controlSink.enqueue(.pairingCodeChanged(nil)) + enqueuePairingCode(nil) } clearPairingAttempt() } @@ -376,29 +384,61 @@ extension SendspinConnection { } } + func admitPairingAttempt() { + guard pairingAttemptID == nil else { return } + pairingAbortAuthorization = nil + pairingAttemptID = PairingAttemptID() + pairingAttemptPeer = PairingPeer(id: currentServerId ?? "", name: serverName) + enqueuePairingSnapshot(phase: .pending) + } + + func enqueuePairingSnapshot(phase: PairingAttemptPhase, code: PairingCodeEmission? = nil) { + guard let id = pairingAttemptID, let peer = pairingAttemptPeer else { return } + let snapshot = PairingAttemptSnapshot(id: id, peer: peer, phase: phase, code: code) + switch phase { + case .ended: + controlSink.enqueue(.pairingAttemptEnded(snapshot)) + case .succeeded: + controlSink.enqueue(.paired(snapshot)) + default: + controlSink.enqueue(.pairingCodeChanged(snapshot)) + } + } + + func enqueuePairingCode(_ emission: PairingCodeEmission?) { + enqueuePairingSnapshot(phase: emission == nil ? .authenticating : .codeReady, code: emission) + } + func beginPairingAttempt() async { + guard pairingAttemptID != nil else { return } guard pskCategory == .pairing, pendingPairingPsk == nil, dynamicPairingAttempt == nil, staticPairingAttempt == nil else { - if pskCategory != .pairing { - try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .methodNotSupported))) + if pskCategory != .pairing, let attemptID = pairingAttemptID { + try? await sendPairingWrapped( + PairAbortMessage(payload: PairAbortPayload(reason: .methodNotSupported)), + attemptID: attemptID + ) } return } + guard let authorizedAttemptID = pairingAttemptID else { return } let generated = await selectPairingLongTermPsk() - guard pairingAttemptActive else { return } + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } pendingPairingPsk = generated pairingAttemptTask?.cancel() + let attemptID = pairingAttemptID pairingAttemptTask = Task { [weak self] in try? await Task.sleep(for: self?.pairingAttemptTimeout ?? .seconds(120)) guard !Task.isCancelled else { return } - await self?.pairingAttemptTimedOut() + await self?.pairingAttemptTimedOut(attemptID: attemptID) } - try? await sendWrapped(ClientPairFinalizeMessage( - payload: ClientPairFinalizePayload(longTermPsk: generated.base64URL) - )) + try? await sendPairingWrapped( + ClientPairFinalizeMessage(payload: ClientPairFinalizePayload(longTermPsk: generated.base64URL)), + attemptID: authorizedAttemptID + ) } /// The long-term PSK offered in `client/pair-finalize`. Normally freshly @@ -427,17 +467,22 @@ extension SendspinConnection { } func beginDynamicPairingAttempt(format: String?) async { + guard pairingAttemptID != nil else { return } guard pskCategory == .sentinel, let rawFormat = format, - let selectedFormat = PairingCodeFormat(rawValue: rawFormat), - await dynamicPairingCodeIsOffered(format: selectedFormat) + let selectedFormat = PairingCodeFormat(rawValue: rawFormat) else { - clearPairingAttempt(reason: .methodNotSupported) - try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .methodNotSupported))) + guard let attemptID = pairingAttemptID else { return } + await abortPairingAttempt(reason: .methodNotSupported, attemptID: attemptID) return } + guard let authorizedAttemptID = pairingAttemptID else { return } + guard await dynamicPairingCodeIsOffered(format: selectedFormat), pairingAttemptID == authorizedAttemptID else { return } guard dynamicPairingAttempt == nil, staticPairingAttempt == nil, pendingPairingPsk == nil else { - try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .concurrentAttempt))) + try? await sendPairingWrapped( + PairAbortMessage(payload: PairAbortPayload(reason: .concurrentAttempt)), + attemptID: authorizedAttemptID + ) await transport.disconnect() return } @@ -455,6 +500,7 @@ extension SendspinConnection { let pairingHandshakeHash = channel.handshakeHash #endif let advertisement = await livePairingAdvertisement() + guard pairingAttemptID == authorizedAttemptID else { return } let dynamicDescriptor = advertisement.supportedPairMethods[PairMethod.dynamicPairingCode] let digitAudioDescriptor = selectedFormat == .digits && dynamicDescriptor?.outChannels?.contains("speaker") == true ? dynamicDescriptor?.digitAudio @@ -476,13 +522,35 @@ extension SendspinConnection { secrets: nil, clientConfirmationSent: false ) - let roundCount = await pairingStore?.dynamicPairingRoundCount() ?? 0 - if roundCount >= dynamicPairingRoundLimit, !pairingWindowOpen { - try? await sendWrapped(ClientPairPendingMessage( - payload: ClientPairPendingPayload(pairingIndex: pairingActivateCounter) - )) - } else { - await sendDynamicPairInit() + guard let pairingStore else { + Log.client.error("Dynamic pairing requires a durable pairing store") + clearPairingAttempt() + disconnectReason = .connectionLost(nil) + await transport.disconnect() + return + } + do { + let reservation = try await pairingStore.reserveDynamicPairingRound(limit: dynamicPairingRoundLimit) + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } + guard var reservedAttempt = dynamicPairingAttempt else { return } + switch reservation { + case let .reserved(round, _): + reservedAttempt.round = round + dynamicPairingAttempt = reservedAttempt + await sendDynamicPairInit(attemptID: authorizedAttemptID) + case .exhausted: + // Before the first pair-init, budget exhaustion keeps the attempt pending. + try? await sendPairingWrapped( + ClientPairPendingMessage(payload: ClientPairPendingPayload(pairingIndex: pairingActivateCounter)), + attemptID: authorizedAttemptID + ) + } + } catch { + Log.client.error("Dynamic pairing budget reservation failed: \(error.localizedDescription)") + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } + clearPairingAttempt() + disconnectReason = .connectionLost(nil) + await transport.disconnect() } } @@ -492,24 +560,30 @@ extension SendspinConnection { } func beginStaticPairingAttempt(format: String?) async { + guard pairingAttemptID != nil else { return } guard pskCategory == .sentinel, format == nil, let runtime = pairingConfigurationRuntime else { - clearPairingAttempt(reason: .methodNotSupported) - try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .methodNotSupported))) + guard let attemptID = pairingAttemptID else { return } + await abortPairingAttempt(reason: .methodNotSupported, attemptID: attemptID) return } + guard let authorizedAttemptID = pairingAttemptID else { return } let configuration = await runtime.snapshot() + guard pairingAttemptID == authorizedAttemptID else { return } guard configuration.staticPairingCodeIsAdvertised, let code = configuration.staticPairingCode, PairingManagementConfiguration.isValidStaticPairingCode(code) else { - clearPairingAttempt(reason: .methodNotSupported) - try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .methodNotSupported))) + guard let attemptID = pairingAttemptID else { return } + await abortPairingAttempt(reason: .methodNotSupported, attemptID: attemptID) return } guard dynamicPairingAttempt == nil, staticPairingAttempt == nil, pendingPairingPsk == nil else { - try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .concurrentAttempt))) + try? await sendPairingWrapped( + PairAbortMessage(payload: PairAbortPayload(reason: .concurrentAttempt)), + attemptID: authorizedAttemptID + ) await transport.disconnect() return } @@ -531,22 +605,26 @@ extension SendspinConnection { if pairingWindowOpen { await sendStaticPairInit() } else { - try? await sendWrapped(ClientPairPendingMessage( - payload: ClientPairPendingPayload(pairingIndex: pairingActivateCounter) - )) + guard let attemptID = pairingAttemptID else { return } + try? await sendPairingWrapped( + ClientPairPendingMessage(payload: ClientPairPendingPayload(pairingIndex: pairingActivateCounter)), + attemptID: attemptID + ) } } - func sendDynamicPairInit() async { - guard var attempt = dynamicPairingAttempt else { return } - pairingWindowOpen = false - pairingWindowTask?.cancel() - pairingWindowTask = nil - if attempt.round == 0 { + func sendDynamicPairInit(attemptID: PairingAttemptID? = nil) async { + guard let authorizedAttemptID = attemptID ?? pairingAttemptID, + pairingAttemptID == authorizedAttemptID, + var attempt = dynamicPairingAttempt, + attempt.round > 0 + else { return } + closePairingWindow(for: authorizedAttemptID) + if pairingAttemptTask == nil { pairingAttemptTask = Task { [weak self] in try? await Task.sleep(for: self?.pairingAttemptTimeout ?? .seconds(120)) guard !Task.isCancelled else { return } - await self?.pairingAttemptTimedOut() + await self?.pairingAttemptTimedOut(attemptID: authorizedAttemptID) } } attempt.pairInitSent = false @@ -556,45 +634,98 @@ extension SendspinConnection { attempt.clientConfirmationSent = false dynamicPairingAttempt = attempt do { - try await sendWrapped(ClientPairInitMessage(payload: ClientPairInitPayload( + try await sendPairingWrapped(ClientPairInitMessage(payload: ClientPairInitPayload( pairingIndex: attempt.pairingIndex, commitB: Base64URL.encode(attempt.commitB) - ))) + )), attemptID: authorizedAttemptID) + guard pairingAttemptID == authorizedAttemptID else { return } attempt.pairInitSent = true dynamicPairingAttempt = attempt } catch { + guard pairingAttemptID == authorizedAttemptID else { return } clearPairingAttempt() } } - func openPairingWindow() async { - guard !pairingWindowOpen else { return } - await pairingStore?.resetDynamicPairingFailureCount() - await pairingStore?.resetDynamicPairingRoundCount() + func openPairingWindow(attemptID: PairingAttemptID) async throws { + // The identity is reserved at admission, before the server chooses a + // pairing method. Allow the operator to authorize that reserved attempt + // before the activation arrives; method setup consumes the window later. + guard pairingAttemptID == attemptID else { + throw SendspinClientError.stalePairingAttempt(attemptID) + } + pairingAttemptActive = true + let authorizedAttemptID = attemptID + // Only dynamic pairing consumes the shared round budget. Static-code + // approval is scoped to this attempt and does not reset that budget. + if var dynamicAttempt = dynamicPairingAttempt, dynamicAttempt.round == 0 { + guard let pairingStore else { + await failPairingStorage(for: authorizedAttemptID) + throw PairingRecordStoreError.storageExhausted + } + do { + try await pairingStore.resetDynamicPairingBudget() + guard pairingAttemptID == authorizedAttemptID else { + throw SendspinClientError.stalePairingAttempt(authorizedAttemptID) + } + let reservation = try await pairingStore.reserveDynamicPairingRound(limit: dynamicPairingRoundLimit) + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { + throw SendspinClientError.stalePairingAttempt(authorizedAttemptID) + } + guard case let .reserved(round, _) = reservation else { + clearPairingAttempt(reason: .pairingCodeMismatch) + pairingAbortAuthorization = authorizedAttemptID + try? await sendPairingWrapped( + PairAbortMessage(payload: PairAbortPayload(reason: .pairingCodeMismatch)), + attemptID: authorizedAttemptID, + allowClearedAbort: true + ) + throw PairingRecordStoreError.storageExhausted + } + dynamicAttempt.round = round + dynamicPairingAttempt = dynamicAttempt + } catch let error as SendspinClientError { + throw error + } catch { + Log.client.error("Dynamic pairing budget reservation failed: \(error.localizedDescription)") + await failPairingStorage(for: authorizedAttemptID) + throw error + } + } + guard !pairingWindowOpen, pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { + throw SendspinClientError.stalePairingAttempt(authorizedAttemptID) + } pairingWindowOpen = true + pairingWindowAttemptID = authorizedAttemptID + let expiresAt = PresentationInstant.now.adding(pairingWindowLifetime) + pairingWindowExpiresAt = expiresAt + controlSink.enqueue(.pairingWindowChanged(PairingWindowSnapshot( + attemptID: authorizedAttemptID, + expiresAt: expiresAt + ))) pairingWindowTask?.cancel() pairingWindowTask = Task { [weak self] in try? await Task.sleep(for: self?.pairingWindowLifetime ?? .seconds(300)) guard !Task.isCancelled else { return } - await self?.closePairingWindow() + await self?.expirePairingWindow(for: authorizedAttemptID) } if dynamicPairingAttempt != nil { - await sendDynamicPairInit() + await sendDynamicPairInit(attemptID: authorizedAttemptID) } else if staticPairingAttempt != nil { - await sendStaticPairInit() + await sendStaticPairInit(attemptID: authorizedAttemptID) } } - func sendStaticPairInit() async { - guard var attempt = staticPairingAttempt, attempt.cpace == nil else { return } - pairingWindowOpen = false - pairingWindowTask?.cancel() - pairingWindowTask = nil + func sendStaticPairInit(attemptID: PairingAttemptID? = nil) async { + guard attemptID == nil || pairingAttemptID == attemptID, + var attempt = staticPairingAttempt, attempt.cpace == nil else { return } + closePairingWindow(for: attemptID) pairingAttemptTask?.cancel() + let attemptID = pairingAttemptID pairingAttemptTask = Task { [weak self] in try? await Task.sleep(for: self?.pairingAttemptTimeout ?? .seconds(120)) guard !Task.isCancelled else { return } - await self?.pairingAttemptTimedOut() + await self?.pairingAttemptTimedOut(attemptID: attemptID) } attempt.cpace = try? CPace( role: .responder, @@ -607,24 +738,52 @@ extension SendspinConnection { return } staticPairingAttempt = attempt - try? await sendWrapped(ClientPairInitMessage( - payload: ClientPairInitPayload(pairingIndex: attempt.pairingIndex, commitB: nil) - )) + guard let attemptID = pairingAttemptID else { return } + try? await sendPairingWrapped( + ClientPairInitMessage(payload: ClientPairInitPayload(pairingIndex: attempt.pairingIndex, commitB: nil)), + attemptID: attemptID + ) } - func closePairingWindow() { + /// Close or consume the one-attempt authorization window. The snapshot's + /// lifetime is the public authorization state, so clearing it emits exactly + /// one nil event even if a close races expiry or finalization. + func closePairingWindow(for attemptID: PairingAttemptID? = nil, cancelTask: Bool = true) { + guard attemptID == nil || pairingWindowAttemptID == attemptID else { return } + let hadPublicWindow = pairingWindowExpiresAt != nil pairingWindowOpen = false - pairingWindowTask?.cancel() + pairingWindowAttemptID = nil + pairingWindowExpiresAt = nil + if cancelTask { + pairingWindowTask?.cancel() + } pairingWindowTask = nil + if hadPublicWindow { + controlSink.enqueue(.pairingWindowChanged(nil)) + } } - func cancelPairingAttempt() async { - guard dynamicPairingAttempt != nil || staticPairingAttempt != nil || pendingPairingPsk != nil else { + /// Expiry runs in the window task itself, so it must clear the state without + /// cancelling that currently executing task. + func expirePairingWindow(for attemptID: PairingAttemptID) { + guard pairingWindowAttemptID == attemptID, pairingWindowExpiresAt != nil else { return } + closePairingWindow(for: attemptID, cancelTask: false) + } + + func cancelPairing(attemptID: PairingAttemptID) async throws { + guard pairingAttemptID == attemptID else { throw SendspinClientError.stalePairingAttempt(attemptID) } + guard dynamicPairingAttempt != nil || staticPairingAttempt != nil || pendingPairingPsk != nil || pairingAttemptActive else { closePairingWindow() - return + throw SendspinClientError.stalePairingAttempt(attemptID) } clearPairingAttempt(reason: .userCancelled) - try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .userCancelled))) + guard pairingAttemptID == nil else { return } + pairingAbortAuthorization = attemptID + try? await sendPairingWrapped( + PairAbortMessage(payload: PairAbortPayload(reason: .userCancelled)), + attemptID: attemptID, + allowClearedAbort: true + ) } func handleDigitAudioClip(_ message: BinaryMessage) throws { @@ -646,24 +805,11 @@ extension SendspinConnection { guard var attempt = dynamicPairingAttempt, attempt.pairInitSent, attempt.cpace == nil, attempt.serverShare == nil else { throw PairingProtocolError.invalidSequence } - let nextRound = attempt.round == 0 ? 1 : attempt.round + 1 - if let pairingStore { - guard await pairingStore.dynamicPairingRoundCount() < dynamicPairingRoundLimit else { - clearPairingAttempt(reason: .pairingCodeMismatch) - try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .pairingCodeMismatch))) - return - } - guard await pairingStore.incrementDynamicPairingRoundCount() <= dynamicPairingRoundLimit else { - clearPairingAttempt(reason: .pairingCodeMismatch) - try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .pairingCodeMismatch))) - return - } - } else if nextRound > dynamicPairingRoundLimit { - clearPairingAttempt(reason: .pairingCodeMismatch) - try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .pairingCodeMismatch))) + guard attempt.round > 0 else { + // The first round is reserved when the attempt is authorized. A pending + // attempt cannot receive server/pair-init until a reset authorizes it. return } - attempt.round = nextRound if attempt.prs == nil { guard let encodedNonceA = message.payload.nonceA, let nonceA = Base64URL.decode(encodedNonceA, count: 32) @@ -702,13 +848,13 @@ extension SendspinConnection { } attempt.prs = prs attempt.emission = emission - controlSink.enqueue(.pairingCodeChanged(emission)) + enqueuePairingCode(emission) } else { guard message.payload.nonceA == nil, let prs = attempt.prs else { throw PairingProtocolError.invalidSequence } if let emission = attempt.emission { - controlSink.enqueue(.pairingCodeChanged(emission)) + enqueuePairingCode(emission) } attempt.clientConfirmationSent = false attempt.serverShare = nil @@ -746,7 +892,11 @@ extension SendspinConnection { attempt.serverShare = share attempt.secrets = try cpace.derive(remoteShare: share) dynamicPairingAttempt = attempt - try await sendWrapped(ClientPairAuthMessage(payload: ClientPairAuthPayload(pakeMsg2: Base64URL.encode(cpace.publicShare)))) + guard let attemptID = pairingAttemptID else { return } + try await sendPairingWrapped( + ClientPairAuthMessage(payload: ClientPairAuthPayload(pakeMsg2: Base64URL.encode(cpace.publicShare))), + attemptID: attemptID + ) return } guard var attempt = staticPairingAttempt, attempt.serverShare == nil, @@ -756,7 +906,11 @@ extension SendspinConnection { attempt.serverShare = share attempt.secrets = try cpace.derive(remoteShare: share) staticPairingAttempt = attempt - try await sendWrapped(ClientPairAuthMessage(payload: ClientPairAuthPayload(pakeMsg2: Base64URL.encode(cpace.publicShare)))) + guard let attemptID = pairingAttemptID else { return } + try await sendPairingWrapped( + ClientPairAuthMessage(payload: ClientPairAuthPayload(pakeMsg2: Base64URL.encode(cpace.publicShare))), + attemptID: attemptID + ) } func handleServerPairConfirm(_ message: ServerPairConfirmMessage) async throws { @@ -780,11 +934,13 @@ extension SendspinConnection { tag, CPaceX25519.mcfTag(isk: secrets.isk, sid: attempt.sid, share: serverShare, associatedData: CPaceX25519.defaultInitiatorAD) ) else { - clearPairingAttempt(reason: .pairingCodeMismatch) - try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .pairingCodeMismatch))) + guard let attemptID = pairingAttemptID else { return } + await abortPairingAttempt(reason: .pairingCodeMismatch, attemptID: attemptID) return } + guard let authorizedAttemptID = pairingAttemptID else { return } let generated = await selectPairingLongTermPsk() + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } pendingPairingPsk = generated attempt.clientConfirmationSent = true staticPairingAttempt = attempt @@ -794,12 +950,12 @@ extension SendspinConnection { share: cpace.publicShare, associatedData: CPaceX25519.defaultResponderAD ) - try await sendWrapped(ClientPairConfirmMessage( + try await sendPairingWrapped(ClientPairConfirmMessage( payload: ClientPairConfirmPayload( clientKc: Base64URL.encode(clientTag), wrappedNonceB: nil ) - )) + ), attemptID: authorizedAttemptID) let wrappedPsk = try PairingWrap.wrap( plaintext: generated.bytes, label: Data("sendspin-pair-psk-wrap-v1".utf8), @@ -807,12 +963,13 @@ extension SendspinConnection { isk: secrets.isk, suite: suite ) - try await sendWrapped(ClientPairFinalizeMessage( + try await sendPairingWrapped(ClientPairFinalizeMessage( payload: ClientPairFinalizePayload(wrappedPsk: Base64URL.encode(wrappedPsk)) - )) + ), attemptID: authorizedAttemptID) } private func handleDynamicServerPairConfirm(_ message: ServerPairConfirmMessage) async throws { + guard let authorizedAttemptID = pairingAttemptID else { return } guard var attempt = dynamicPairingAttempt, !attempt.clientConfirmationSent, let sid = attempt.sid, @@ -823,23 +980,56 @@ extension SendspinConnection { else { throw PairingProtocolError.invalidSequence } let expected = CPaceX25519.mcfTag(isk: secrets.isk, sid: sid, share: serverShare, associatedData: CPaceX25519.defaultInitiatorAD) guard CPaceX25519.constantTimeEqual(tag, expected) else { - _ = await pairingStore?.incrementDynamicPairingFailureCount() - let globalRounds = await pairingStore?.dynamicPairingRoundCount() ?? attempt.round - if attempt.round < dynamicPairingRoundLimit, globalRounds < dynamicPairingRoundLimit { + if attempt.round < dynamicPairingRoundLimit { attempt.serverShare = nil attempt.cpace = nil attempt.secrets = nil attempt.sid = nil dynamicPairingAttempt = attempt - try? await sendWrapped(ClientPairRetryMessage(payload: ClientPairRetryPayload())) + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } + guard let pairingStore else { + await failPairingStorage(for: authorizedAttemptID) + return + } + let reservation: DynamicPairingRoundReservation + do { + reservation = try await pairingStore.reserveDynamicPairingRound(limit: dynamicPairingRoundLimit) + } catch { + Log.client.error("Dynamic pairing budget reservation failed: \(error.localizedDescription)") + await failPairingStorage(for: authorizedAttemptID) + return + } + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } + guard case let .reserved(round, _) = reservation else { + await abortPairingAttempt(reason: .pairingCodeMismatch, attemptID: authorizedAttemptID) + return + } + attempt.round = round + dynamicPairingAttempt = attempt + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } + try? await sendPairingWrapped( + ClientPairRetryMessage(payload: ClientPairRetryPayload()), + attemptID: authorizedAttemptID + ) + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } + await sendDynamicPairInit(attemptID: authorizedAttemptID) } else { - clearPairingAttempt(reason: .pairingCodeMismatch) - try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .pairingCodeMismatch))) + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } + await abortPairingAttempt(reason: .pairingCodeMismatch, attemptID: authorizedAttemptID) } return } - await pairingStore?.resetDynamicPairingFailureCount() - await pairingStore?.resetDynamicPairingRoundCount() + do { + try await pairingStore?.resetDynamicPairingBudget() + } catch { + Log.client.error("Dynamic pairing budget reset failed: \(error.localizedDescription)") + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } + clearPairingAttempt() + disconnectReason = .connectionLost(nil) + await transport.disconnect() + return + } + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } let clientTag = CPaceX25519.mcfTag( isk: secrets.isk, sid: sid, @@ -854,15 +1044,16 @@ extension SendspinConnection { suite: suite ) let generated = await selectPairingLongTermPsk() + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } pendingPairingPsk = generated attempt.clientConfirmationSent = true dynamicPairingAttempt = attempt - try await sendWrapped(ClientPairConfirmMessage( + try await sendPairingWrapped(ClientPairConfirmMessage( payload: ClientPairConfirmPayload( clientKc: Base64URL.encode(clientTag), wrappedNonceB: Base64URL.encode(wrappedNonce) ) - )) + ), attemptID: authorizedAttemptID) let wrappedPsk = try PairingWrap.wrap( plaintext: generated.bytes, label: Data("sendspin-pair-psk-wrap-v1".utf8), @@ -870,12 +1061,13 @@ extension SendspinConnection { isk: secrets.isk, suite: suite ) - try await sendWrapped(ClientPairFinalizeMessage( + try await sendPairingWrapped(ClientPairFinalizeMessage( payload: ClientPairFinalizePayload(wrappedPsk: Base64URL.encode(wrappedPsk)) - )) + ), attemptID: authorizedAttemptID) } - func pairingAttemptTimedOut() async { + func pairingAttemptTimedOut(attemptID: PairingAttemptID?) async { + guard let attemptID, pairingAttemptID == attemptID else { return } // A stale wake (its handle was cancelled and replaced by a newer attempt // or teardown) must not detach the newer handle or abort the fresh attempt. guard !Task.isCancelled else { return } @@ -883,11 +1075,36 @@ extension SendspinConnection { // Detach this task's handle before clear: clearPairingAttempt cancels the // owned task, which would self-cancel the abort send below. pairingAttemptTask = nil - clearPairingAttempt(reason: .attemptTimeout) - try? await sendWrapped(PairAbortMessage(payload: PairAbortPayload(reason: .attemptTimeout))) + await abortPairingAttempt(reason: .attemptTimeout, attemptID: attemptID) + } + + private func failPairingStorage(for attemptID: PairingAttemptID) async { + guard pairingAttemptID == attemptID else { return } + clearPairingAttempt() + disconnectReason = .connectionLost(nil) + await transport.disconnect() + } + + private func abortPairingAttempt(reason: PairAbortReason, attemptID: PairingAttemptID) async { + guard pairingAttemptID == attemptID else { return } + pairingAbortAuthorization = attemptID + clearPairingAttempt(reason: reason) + try? await sendPairingWrapped( + PairAbortMessage(payload: PairAbortPayload(reason: reason)), + attemptID: attemptID, + allowClearedAbort: true + ) } func clearPairingAttempt(reason: PairAbortReason? = nil) { + if let reason, let id = pairingAttemptID, let peer = pairingAttemptPeer { + controlSink.enqueue(.pairingAttemptEnded(PairingAttemptSnapshot( + id: id, peer: peer, phase: .ended(reason), code: nil + ))) + } + if pairingAttemptID != nil { + enqueuePairingCode(nil) + } pairingAttemptActive = false pendingPairingPsk = nil dynamicPairingAttempt = nil @@ -895,30 +1112,40 @@ extension SendspinConnection { pairingAttemptTask?.cancel() pairingAttemptTask = nil closePairingWindow() - if let reason { - controlSink.enqueue(.pairingAttemptEnded(reason)) - controlSink.enqueue(.pairingCodeChanged(nil)) - } + pairingAttemptID = nil + pairingAttemptPeer = nil } func handleServerPairFinalize(_: ServerPairFinalizeMessage) async { guard let generated = pendingPairingPsk, let pairingStore else { return } - let hadCodeAttempt = dynamicPairingAttempt != nil || staticPairingAttempt != nil - if hadCodeAttempt { - controlSink.enqueue(.pairingCodeChanged(nil)) + let authorizedAttemptID = pairingAttemptID + let successSnapshot: PairingAttemptSnapshot? = if let id = pairingAttemptID, let peer = pairingAttemptPeer { + PairingAttemptSnapshot(id: id, peer: peer, phase: .succeeded, code: nil) + } else { + nil } - clearPairingAttempt() let records = await pairingStore.listRecords() + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } if records.contains(where: { $0.pskId == generated.pskId }) { await pairingStore.markUsed(pskId: generated.pskId) - controlSink.enqueue(.paired(serverId: currentServerId ?? "")) + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } + clearPairingAttempt() + if let successSnapshot { + controlSink.enqueue(.paired(successSnapshot)) + } return } do { try await pairingStore.insert(PairingRecord(psk: generated, serverId: currentServerId)) - controlSink.enqueue(.paired(serverId: currentServerId ?? "")) + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } + clearPairingAttempt() + if let successSnapshot { + controlSink.enqueue(.paired(successSnapshot)) + } } catch { + guard pairingAttemptID == authorizedAttemptID, pairingAttemptActive else { return } Log.client.error("Pairing record persistence failed: \(error.localizedDescription)") + clearPairingAttempt() disconnectReason = .connectionLost(nil) await transport.disconnect() } @@ -1559,11 +1786,11 @@ extension SendspinConnection { return } visualizerTimestampFloor = localDisplayTime - let visualizerData = VisualizerData( + let visualizerData = VisualizerFrame( type: type, data: message.data, - localDisplayTime: localDisplayTime, - streamConfiguration: configuration, + presentationTime: PresentationInstant(rawMicroseconds: localDisplayTime), + configuration: configuration, validity: visualizerFrameValidity ) if let dataDelivery { diff --git a/Sources/SendspinKit/Client/SendspinConnection+Outbound.swift b/Sources/SendspinKit/Client/SendspinConnection+Outbound.swift index 26088f5..5da16e7 100644 --- a/Sources/SendspinKit/Client/SendspinConnection+Outbound.swift +++ b/Sources/SendspinKit/Client/SendspinConnection+Outbound.swift @@ -38,7 +38,9 @@ extension SendspinConnection { func sendWrapped( _ message: some Codable & Sendable, bypassRehandshakeGate: Bool = false, - requireRunningLifecycle: Bool = false + requireRunningLifecycle: Bool = false, + expectedPairingAttemptID: PairingAttemptID? = nil, + allowClearedPairingAbort: Bool = false ) async throws { await acquireOutboundSlot() defer { releaseOutboundSlot() } @@ -46,6 +48,16 @@ extension SendspinConnection { guard !outboundFailed else { throw SendspinClientError.sendFailed("outbound channel is dead") } + if let expectedPairingAttemptID { + let currentAuthorized = pairingAttemptID == expectedPairingAttemptID + let abortAuthorized = allowClearedPairingAbort && pairingAbortAuthorization == expectedPairingAttemptID + guard currentAuthorized || abortAuthorized else { + throw SendspinClientError.stalePairingAttempt(expectedPairingAttemptID) + } + if abortAuthorized, !currentAuthorized { + pairingAbortAuthorization = nil + } + } // Gate checks come after acquisition: a sender parked during an exchange // or shutdown must not proceed under stale keys or a closing session. guard bypassRehandshakeGate || !rehandshakeInProgress else { @@ -79,6 +91,18 @@ extension SendspinConnection { } } + func sendPairingWrapped( + _ message: some Codable & Sendable, + attemptID: PairingAttemptID, + allowClearedAbort: Bool = false + ) async throws { + try await sendWrapped( + message, + expectedPairingAttemptID: attemptID, + allowClearedPairingAbort: allowClearedAbort + ) + } + // MARK: - Facade-initiated sends /// Send a facade-initiated protocol message, wrapping transport errors in diff --git a/Sources/SendspinKit/Client/SendspinConnection+PairingQuery.swift b/Sources/SendspinKit/Client/SendspinConnection+PairingQuery.swift index 8a8b72d..9676ea7 100644 --- a/Sources/SendspinKit/Client/SendspinConnection+PairingQuery.swift +++ b/Sources/SendspinKit/Client/SendspinConnection+PairingQuery.swift @@ -55,6 +55,18 @@ extension SendspinConnection { ) } + func pairingAttemptSnapshot() -> PairingAttemptSnapshot? { + guard let id = pairingAttemptID, let peer = pairingAttemptPeer else { return nil } + let phase: PairingAttemptPhase = if dynamicPairingAttempt?.emission != nil || staticPairingAttempt != nil { + .codeReady + } else if pendingPairingPsk != nil { + .authenticating + } else { + .pending + } + return PairingAttemptSnapshot(id: id, peer: peer, phase: phase, code: dynamicPairingAttempt?.emission) + } + func projectionSnapshot() -> ProjectionSnapshot { ProjectionSnapshot( serverId: currentServerId ?? "", diff --git a/Sources/SendspinKit/Client/SendspinConnection.swift b/Sources/SendspinKit/Client/SendspinConnection.swift index 34a634c..658ed5f 100644 --- a/Sources/SendspinKit/Client/SendspinConnection.swift +++ b/Sources/SendspinKit/Client/SendspinConnection.swift @@ -23,8 +23,8 @@ actor SendspinConnection { let audioEngine: AudioEngine let audioSink: AsyncStream.Continuation let artworkSink: AsyncStream.Continuation - let visualizerSink: AsyncStream.Continuation - let visualizerDelivery: VisualizerDataMailbox? + let visualizerSink: AsyncStream.Continuation + let visualizerDelivery: VisualizerFrameMailbox? let dataDelivery: ConnectionDataDelivery? let activationGate: ConnectionActivationGate? let emitRawAudio: Bool @@ -122,6 +122,8 @@ actor SendspinConnection { /// Set when an outbound send fails: a burned nonce makes the channel /// crypto-dead, so queued and later senders must fail without encrypting. var outboundFailed = false + /// Authorization retained only for the terminal abort of the attempt that was just cleared. + var pairingAbortAuthorization: PairingAttemptID? /// Server info var currentServerId: String? @@ -153,6 +155,9 @@ actor SendspinConnection { /// Set synchronously when an admitted pairing activation starts setup. It /// covers the suspension before a concrete PSK/code attempt is installed. var pairingAttemptActive = false + /// Identity is created at pairing admission, before any awaited setup work. + var pairingAttemptID: PairingAttemptID? + var pairingAttemptPeer: PairingPeer? var initialPairingActivation: PairingDirective? var pendingPairingPsk: Psk? var pairingAttemptTask: Task? @@ -167,6 +172,8 @@ actor SendspinConnection { var nonceA: Data? var prs: Data? var emission: PairingCodeEmission? + /// The atomically reserved global round. Zero means the attempt is held pending + /// until an explicit dynamic-budget reset action makes a reservation possible. var round: UInt32 var sid: Data? var pairInitSent: Bool @@ -190,6 +197,8 @@ actor SendspinConnection { var staticPairingAttempt: StaticPairingAttempt? var pairingActivateCounter: UInt32 = 0 var pairingWindowOpen = false + var pairingWindowAttemptID: PairingAttemptID? + var pairingWindowExpiresAt: PresentationInstant? var pairingWindowTask: Task? let pairingWindowLifetime: Duration @@ -282,8 +291,8 @@ actor SendspinConnection { }, audioSink: AsyncStream.Continuation = AsyncStream.makeStream().1, artworkSink: AsyncStream.Continuation = AsyncStream.makeStream().1, - visualizerSink: AsyncStream.Continuation = AsyncStream.makeStream().1, - visualizerDelivery: VisualizerDataMailbox? = nil, + visualizerSink: AsyncStream.Continuation = AsyncStream.makeStream().1, + visualizerDelivery: VisualizerFrameMailbox? = nil, dataDelivery: ConnectionDataDelivery? = nil, activationGate: ConnectionActivationGate? = nil, emitRawAudio: Bool = true, @@ -308,7 +317,13 @@ actor SendspinConnection { self.serverName = serverName self.activities = activities self.activeRoles = activeRoles - pairingAttemptActive = false + let preallocatedPairing = pairingConfigurationRuntime != nil + && (activities.isEmpty || activities == [.pairing]) + pairingAttemptActive = activities == [.pairing] + pairingAttemptID = preallocatedPairing ? PairingAttemptID() : nil + pairingAttemptPeer = preallocatedPairing + ? PairingPeer(id: serverId, name: serverName) + : nil initialPairingActivation = nil pendingPairingPsk = nil pairingAttemptTask = nil @@ -326,6 +341,8 @@ actor SendspinConnection { dynamicPairingAttempt = nil staticPairingAttempt = nil pairingWindowOpen = false + pairingWindowAttemptID = nil + pairingWindowExpiresAt = nil pairingWindowTask = nil self.identityPrivateKey = identityPrivateKey self.serverStaticPublicKey = serverStaticPublicKey diff --git a/Sources/SendspinKit/Client/SendspinPersistenceProvider.swift b/Sources/SendspinKit/Client/SendspinPersistenceProvider.swift index 2e0f863..9ab9923 100644 --- a/Sources/SendspinKit/Client/SendspinPersistenceProvider.swift +++ b/Sources/SendspinKit/Client/SendspinPersistenceProvider.swift @@ -48,11 +48,7 @@ public struct PairingRecord: Sendable, Equatable, Hashable { } } -/// Persistence for long-term pairing records. -/// -/// SendspinKit never persists pairing records implicitly. Applications provide a -/// Keychain, file, or database implementation when records must survive process -/// restarts. Methods are async so storage I/O stays outside the main actor. +/// Storage accounting for long-term pairing records. public struct PairingStorageAccounting: Sendable, Equatable { public let free: Int public let capacity: Int? @@ -131,6 +127,16 @@ public actor PairingConfigurationRuntime { } } +/// The result of an atomic dynamic pairing round reservation. +public enum DynamicPairingRoundReservation: Sendable, Equatable { + /// The round is durably reserved and may proceed. `round` starts at one. + case reserved(round: UInt32, remaining: UInt32) + /// The persisted budget has no rounds remaining. + case exhausted +} + +/// Persistence for long-term pairing records. Applications provide a Keychain, +/// file, or database implementation when records must survive process restarts. /// The store is the host app's durability boundary: mutating operations must be /// serialized with one another and complete durably before they return. This prevents /// a successful pairing from being lost while a new handshake is already using the @@ -156,24 +162,16 @@ public protocol PairingRecordStore: Sendable { /// Return storage accounting, or nil when the store is unbounded or unknown. func storageAccounting() async -> PairingStorageAccounting? - /// Return the persisted dynamic pairing failure count. - func dynamicPairingFailureCount() async -> Int - - /// Increment and return the dynamic pairing failure count as one atomic operation. - func incrementDynamicPairingFailureCount() async -> Int - - /// Reset the dynamic pairing failure count atomically. - func resetDynamicPairingFailureCount() async - /// Return the global number of dynamic pairing rounds since the last verified key confirmation. - /// Implementations must persist this counter globally, not partition it by server or address. - func dynamicPairingRoundCount() async -> UInt32 + /// This is diagnostic only; admission must use ``reserveDynamicPairingRound(limit:)``. + func dynamicPairingRoundCount() async throws -> UInt32 - /// Record an emitted dynamic pairing round and return the new global count. - func incrementDynamicPairingRoundCount() async -> UInt32 + /// Atomically reserve the next dynamic pairing round, persisting the reservation before returning. + /// The limit is global across servers and addresses. A reservation is never returned above it. + func reserveDynamicPairingRound(limit: UInt32) async throws -> DynamicPairingRoundReservation - /// Reset the global dynamic pairing round count after successful confirmation or operator action. - func resetDynamicPairingRoundCount() async + /// Reset the global dynamic pairing budget after verified confirmation or operator action. + func resetDynamicPairingBudget() async throws } public extension PairingRecordStore { @@ -182,16 +180,6 @@ public extension PairingRecordStore { func storageAccounting() async -> PairingStorageAccounting? { nil } - - func dynamicPairingFailureCount() async -> Int { - 0 - } - - func incrementDynamicPairingFailureCount() async -> Int { - 1 - } - - func resetDynamicPairingFailureCount() async {} } /// Errors raised while configuring pairing records. @@ -206,8 +194,7 @@ public enum PairingRecordStoreError: Error, Sendable, Equatable { /// provide an application persistence implementation. public actor InMemoryPairingRecordStore: PairingRecordStore { private var records: [PairingRecord] - private var dynamicPairingFailureCount = 0 - private var dynamicPairingRoundCount = 0 + private var dynamicPairingRoundCount: UInt32 = 0 private let reservedPskIds: Set public init(pairingPsk: Psk? = nil, preProvisionedRecord: PairingRecord? = nil) { @@ -257,29 +244,17 @@ public actor InMemoryPairingRecordStore: PairingRecordStore { records.append(record) } - public func dynamicPairingFailureCount() async -> Int { - dynamicPairingFailureCount - } - - public func incrementDynamicPairingFailureCount() async -> Int { - dynamicPairingFailureCount += 1 - return dynamicPairingFailureCount - } - - public func resetDynamicPairingFailureCount() async { - dynamicPairingFailureCount = 0 - } - - public func dynamicPairingRoundCount() async -> UInt32 { - UInt32(dynamicPairingRoundCount) + public func dynamicPairingRoundCount() async throws -> UInt32 { + dynamicPairingRoundCount } - public func incrementDynamicPairingRoundCount() async -> UInt32 { + public func reserveDynamicPairingRound(limit: UInt32) async throws -> DynamicPairingRoundReservation { + guard dynamicPairingRoundCount < limit else { return .exhausted } dynamicPairingRoundCount += 1 - return UInt32(dynamicPairingRoundCount) + return .reserved(round: dynamicPairingRoundCount, remaining: limit - dynamicPairingRoundCount) } - public func resetDynamicPairingRoundCount() async { + public func resetDynamicPairingBudget() async throws { dynamicPairingRoundCount = 0 } } @@ -340,6 +315,9 @@ public struct PairingConfiguration: Sendable { pairingPsk: resolved, pairingPskEnabled: enabled, recordModePskId: fallback.pskId, + // The facade applies its explicit unpaired-access policy before the + // first handshake; this default keeps standalone configuration + // snapshots conservative until that owner supplies the policy. unpairedAccessEnabled: true, dynamicPairingCodeEnabled: dynamicPairingCodeEnabled, staticPairingCodeEnabled: staticPairingCodeEnabled, diff --git a/Sources/SendspinKit/Client/SessionValidityToken.swift b/Sources/SendspinKit/Client/SessionValidityToken.swift index 50674dd..e054ef3 100644 --- a/Sources/SendspinKit/Client/SessionValidityToken.swift +++ b/Sources/SendspinKit/Client/SessionValidityToken.swift @@ -77,7 +77,7 @@ final class SessionValidityToken: Sendable { } /// Atomically check validity before offering a frame to bounded delivery. - func offerIfValid(_ element: VisualizerData, to mailbox: VisualizerDataMailbox) { + func offerIfValid(_ element: VisualizerFrame, to mailbox: VisualizerFrameMailbox) { lock.withLock { isValidNow in guard isValidNow else { return } mailbox.offer(element) diff --git a/Sources/SendspinKit/Client/VisualizerDataDelivery.swift b/Sources/SendspinKit/Client/VisualizerDataDelivery.swift index 9263d6f..40034cf 100644 --- a/Sources/SendspinKit/Client/VisualizerDataDelivery.swift +++ b/Sources/SendspinKit/Client/VisualizerDataDelivery.swift @@ -1,52 +1,106 @@ import Foundation -/// A bounded async sequence for visualizer frames. -/// The mailbox has one pending consumer and a configured wire-byte budget. -/// Oldest retained frames are discarded when a new frame does not fit. -/// -/// A stream has a single-consumer contract: only one iterator may consume it at -/// a time. A second live iterator returns `nil` rather than replacing the -/// current consumer. If the owning iterator is abandoned or cancelled, a later -/// iterator may take ownership. -public struct VisualizerDataStream: AsyncSequence, Sendable { - public typealias Element = VisualizerData - - private let mailbox: VisualizerDataMailbox - - init(mailbox: VisualizerDataMailbox) { +/// The error thrown when an app attempts to acquire a second visualizer consumer. +public enum VisualizerFrameAcquisitionError: Error, Sendable, Equatable { + case consumerAlreadyActive +} + +/// Bounded async sequence with one mailbox consumer. +/// Acquire it with ``SendspinClient/acquireVisualizerFrames()``; ownership lasts until +/// `cancel()` or deallocation, and cancelling a pending `next()` releases the consumer. +/// Copied iterators share one read; a concurrent copy ends without canceling that read. +public final class VisualizerFrameSubscription: AsyncSequence, @unchecked Sendable { + public typealias Element = VisualizerFrame + + private let mailbox: VisualizerFrameMailbox + private let token: VisualizerIteratorToken + private let iteratorLock = NSLock() + private var iteratorIssued = false + + init(acquiring mailbox: VisualizerFrameMailbox) throws(VisualizerFrameAcquisitionError) { + self.mailbox = mailbox + let candidate = VisualizerIteratorToken(mailbox: mailbox) + guard mailbox.claim(lease: candidate.lease) else { + throw .consumerAlreadyActive + } + token = candidate + } + + fileprivate init(mailbox: VisualizerFrameMailbox, token: VisualizerIteratorToken) { self.mailbox = mailbox + self.token = token } public struct Iterator: AsyncIteratorProtocol, Sendable { - private let mailbox: VisualizerDataMailbox - private let token: VisualizerIteratorToken + private let mailbox: VisualizerFrameMailbox? + private let token: VisualizerIteratorToken? - fileprivate init(mailbox: VisualizerDataMailbox) { + fileprivate init( + mailbox: VisualizerFrameMailbox?, + token: VisualizerIteratorToken? + ) { self.mailbox = mailbox - token = VisualizerIteratorToken(mailbox: mailbox) + self.token = token } - public mutating func next() async -> VisualizerData? { - await mailbox.next(owner: token) + public mutating func next() async -> VisualizerFrame? { + guard let mailbox, let token, token.beginRead() else { return nil } + defer { token.endRead() } + return await mailbox.next(owner: token) } } + /// Release this subscription's mailbox ownership immediately. + public func cancel() { + mailbox.cancel(lease: token.lease) + } + + deinit { + cancel() + } + + /// A subscription has one consumer; repeated iterator requests end immediately. public func makeAsyncIterator() -> Iterator { - Iterator(mailbox: mailbox) + let isFirst = iteratorLock.withLock { + guard !iteratorIssued else { return false } + iteratorIssued = true + return true + } + guard isFirst else { return Iterator(mailbox: nil, token: nil) } + return Iterator(mailbox: mailbox, token: token) } } /// Identity for the one iterator allowed to consume a mailbox. -private final class VisualizerIteratorLease: @unchecked Sendable {} +private final class VisualizerIteratorLease: @unchecked Sendable { + /// Access is serialized by the mailbox lock and remains true for the lease lifetime. + var revoked = false +} private final class VisualizerIteratorToken: @unchecked Sendable { - weak var mailbox: VisualizerDataMailbox? + weak var mailbox: VisualizerFrameMailbox? let lease = VisualizerIteratorLease() + private let readLock = NSLock() + private var readInFlight = false - init(mailbox: VisualizerDataMailbox) { + init(mailbox: VisualizerFrameMailbox) { self.mailbox = mailbox } + /// Copied iterator structs share this single-flight boundary. A losing read ends + /// immediately and must not cancel the read that owns the mailbox waiter. + func beginRead() -> Bool { + readLock.withLock { + guard !readInFlight else { return false } + readInFlight = true + return true + } + } + + func endRead() { + readLock.withLock { readInFlight = false } + } + deinit { mailbox?.cancel(lease: lease) } @@ -54,18 +108,18 @@ private final class VisualizerIteratorToken: @unchecked Sendable { /// Lock-based delivery storage keeps the message loop non-blocking and avoids /// an unbounded task or `AsyncStream` buffer when the host does not consume. -final class VisualizerDataMailbox: @unchecked Sendable { +final class VisualizerFrameMailbox: @unchecked Sendable { private enum ReadResult { - case value(VisualizerData) + case value(VisualizerFrame) case end case retry } private final class QueueNode { - let value: VisualizerData + let value: VisualizerFrame var next: QueueNode? - init(_ value: VisualizerData) { + init(_ value: VisualizerFrame) { self.value = value } } @@ -82,7 +136,9 @@ final class VisualizerDataMailbox: @unchecked Sendable { private let lock = NSLock() private let capacityBytes: Int - private let now: @Sendable () -> Int64 + private let now: @Sendable () -> PresentationInstant + private let beforePark: (@Sendable () -> Void)? + private let beforePostHandoffCheck: (@Sendable () -> Void)? private var queueHead: QueueNode? private var queueTail: QueueNode? private var queuedBytes = 0 @@ -94,16 +150,38 @@ final class VisualizerDataMailbox: @unchecked Sendable { lock.withLock { queuedBytes } } + var claimable: Bool { + lock.withLock { !finished && ownerLease == nil } + } + + fileprivate func claim(lease: VisualizerIteratorLease) -> Bool { + lock.withLock { + guard !finished, !lease.revoked, ownerLease == nil else { return false } + ownerLease = lease + return true + } + } + + private func leaseIsActive(_ lease: VisualizerIteratorLease) -> Bool { + lock.withLock { + !finished && !lease.revoked && ownerLease === lease + } + } + init( capacityBytes: Int, - now: @escaping @Sendable () -> Int64 = { MonotonicClock.absoluteMicroseconds() } + now: @escaping @Sendable () -> PresentationInstant = { .now }, + beforePark: (@Sendable () -> Void)? = nil, + beforePostHandoffCheck: (@Sendable () -> Void)? = nil ) { precondition(capacityBytes > 0) self.capacityBytes = capacityBytes self.now = now + self.beforePark = beforePark + self.beforePostHandoffCheck = beforePostHandoffCheck } - func offer(_ value: VisualizerData, now arrivalNow: Int64? = nil) { + func offer(_ value: VisualizerFrame, now arrivalNow: PresentationInstant? = nil) { lock.lock() guard !finished else { lock.unlock() @@ -112,7 +190,7 @@ final class VisualizerDataMailbox: @unchecked Sendable { let currentNow = arrivalNow ?? now() discardExpiredLocked(now: currentNow) - guard value.localDisplayTime > currentNow else { + guard value.eligibilityForScheduling(at: currentNow) else { lock.unlock() return } @@ -137,7 +215,7 @@ final class VisualizerDataMailbox: @unchecked Sendable { lock.unlock() } - fileprivate func next(owner iterator: VisualizerIteratorToken) async -> VisualizerData? { + fileprivate func next(owner iterator: VisualizerIteratorToken) async -> VisualizerFrame? { while true { guard !Task.isCancelled else { cancel(lease: iterator.lease) @@ -145,13 +223,14 @@ final class VisualizerDataMailbox: @unchecked Sendable { } enum Immediate { - case value(VisualizerData) + case value(VisualizerFrame) case empty case finished case notOwner } let immediate: Immediate = lock.withLock { + guard !finished, !iterator.lease.revoked else { return .finished } if let ownerLease, ownerLease !== iterator.lease { return .notOwner } @@ -160,19 +239,19 @@ final class VisualizerDataMailbox: @unchecked Sendable { } discardExpiredLocked(now: now()) if let value = dequeueHeadLocked() { + guard !iterator.lease.revoked, self.ownerLease === iterator.lease else { + return .finished + } return .value(value) } - if finished { - return .finished - } return .empty } switch immediate { case let .value(value): - // A frame can be invalidated or expire after it was dequeued. - // Never deliver it merely because it was fresh at dequeue time. - if value.isRenderable(at: now()) { + // Recheck both stream generation and deadline after dequeue. A + // consumer must never receive a frame that became stale while waking. + if value.isValid, value.eligibilityForScheduling(at: now()), leaseIsActive(iterator.lease) { return value } continue @@ -182,6 +261,7 @@ final class VisualizerDataMailbox: @unchecked Sendable { break } + beforePark?() let result = await withTaskCancellationHandler { await withCheckedContinuation { (continuation: CheckedContinuation) in park(owner: iterator, continuation: continuation) @@ -189,20 +269,17 @@ final class VisualizerDataMailbox: @unchecked Sendable { } onCancel: { cancel(lease: iterator.lease) } + beforePostHandoffCheck?() switch result { case let .value(value): - // Cancellation owns the handoff decision. A frame already - // resumed to a canceled read is dropped; requeueing it would - // transfer ownership across iterator lifetimes and can strand - // a new waiter's continuation. - guard !Task.isCancelled else { - cancel(lease: iterator.lease) + // An explicitly canceled lease drops a frame already handed to its continuation. + guard !Task.isCancelled, leaseIsActive(iterator.lease) else { return nil } - if value.isRenderable(at: now()) { + if value.isValid, value.eligibilityForScheduling(at: now()) { return value } - // The directly delivered frame expired or was invalidated + // The directly delivered frame was invalidated or became stale // while this task was waking. Wait for a fresh frame. // Loop to wait for the next frame rather than returning stale data. case .retry: @@ -237,7 +314,8 @@ final class VisualizerDataMailbox: @unchecked Sendable { continuation: CheckedContinuation ) { let result: ReadResult? = lock.withLock { - guard !finished, ownerLease == nil || ownerLease === iterator.lease else { return .end } + guard !finished, !iterator.lease.revoked, + ownerLease == nil || ownerLease === iterator.lease else { return .end } ownerLease = iterator.lease discardExpiredLocked(now: now()) // A frame can arrive between the immediate check and installing the @@ -262,20 +340,19 @@ final class VisualizerDataMailbox: @unchecked Sendable { fileprivate func cancel(lease: VisualizerIteratorLease) { let pending: CheckedContinuation? = lock.withLock { + lease.revoked = true guard ownerLease === lease else { return nil } let pending = waiter?.lease === lease ? waiter?.continuation : nil if waiter?.lease === lease { waiter = nil } - // Do not requeue a value that raced with cancellation. The value - // was handed to this read and is intentionally dropped. ownerLease = nil return pending } pending?.resume(returning: .end) } - private func appendLocked(_ value: VisualizerData) { + private func appendLocked(_ value: VisualizerFrame) { let node = QueueNode(value) if let queueTail { queueTail.next = node @@ -287,7 +364,7 @@ final class VisualizerDataMailbox: @unchecked Sendable { } @discardableResult - private func dequeueHeadLocked() -> VisualizerData? { + private func dequeueHeadLocked() -> VisualizerFrame? { guard let node = queueHead else { return nil } queueHead = node.next node.next = nil @@ -304,16 +381,16 @@ final class VisualizerDataMailbox: @unchecked Sendable { queuedBytes = 0 } - private func discardExpiredLocked(now: Int64) { + private func discardExpiredLocked(now: PresentationInstant) { // Server visualizer timestamps are non-decreasing, so expired frames form // a prefix. Each dequeued node releases its Data immediately. - while let value = queueHead?.value, value.localDisplayTime <= now { + while let value = queueHead?.value, !value.eligibilityForScheduling(at: now) { _ = dequeueHeadLocked() } } } -extension VisualizerData { +extension VisualizerFrame { /// The capacity accounting size required by the visualizer wire contract. var frameByteCount: Int { let (bytes, overflow) = BinaryMessage.headerSize.addingReportingOverflow(data.count) diff --git a/Tests/SendspinKitTests/Client/ConcurrentPairingTests.swift b/Tests/SendspinKitTests/Client/ConcurrentPairingTests.swift index c7555f0..d7f5291 100644 --- a/Tests/SendspinKitTests/Client/ConcurrentPairingTests.swift +++ b/Tests/SendspinKitTests/Client/ConcurrentPairingTests.swift @@ -17,7 +17,7 @@ struct ConcurrentPairingTests { let codeTask = Task { await collectClientEvent(from: session.events) { - if case .pairingCodeChanged(.some) = $0 { + if case let .pairingCodeChanged(snapshot) = $0, snapshot.code != nil { return true } return false @@ -161,18 +161,18 @@ struct ConcurrentPairingTests { await session.client.disconnect() } - @Test("cancelPairingAttempt targets the parked side and leaves playback connected") + @Test("cancelPairing targets the parked side and leaves playback connected") func cancelTargetsPairingSide() async throws { let session = try await makeSession() let side = try await admitPairingSide(to: session.client) _ = await collectClientEvent(from: session.events) { - if case .pairingCodeChanged(.some) = $0 { + if case let .pairingCodeChanged(snapshot) = $0, snapshot.code != nil { return true } return false } - try await session.client.cancelPairingAttempt() + try await session.client.cancelPairing(attemptID: #require(await MainActor.run { session.client.currentPairing?.id })) let abort = try await waitForClientJSON(side, type: PairAbortMessage.typeString) let decoded = try JSONDecoder().decode(PairAbortMessage.self, from: abort) diff --git a/Tests/SendspinKitTests/Client/DigitAudioPairingTests.swift b/Tests/SendspinKitTests/Client/DigitAudioPairingTests.swift index 009cee9..5aec8d4 100644 --- a/Tests/SendspinKitTests/Client/DigitAudioPairingTests.swift +++ b/Tests/SendspinKitTests/Client/DigitAudioPairingTests.swift @@ -91,15 +91,15 @@ private func assertSilentProtocolClose(_ session: DigitAudioSession) async throw private func nextCode(_ events: AsyncStream) async -> PairingCodeEmission? { await collectClientEvent(from: events, timeout: .seconds(3)) { - if case .pairingCodeChanged(.some) = $0 { + if case let .pairingCodeChanged(snapshot) = $0, snapshot.code != nil { return true } return false }.flatMap { event in - guard case let .pairingCodeChanged(emission?) = event else { + guard case let .pairingCodeChanged(snapshot) = event else { return nil } - return emission + return snapshot.code } } @@ -135,7 +135,7 @@ struct DigitAudioPairingTests { func clipBeforePairInitCloses() async throws { let store = InMemoryPairingRecordStore() for _ in 0 ..< dynamicPairingRoundLimit { - _ = await store.incrementDynamicPairingRoundCount() + _ = try await store.reserveDynamicPairingRound(limit: dynamicPairingRoundLimit) } let session = try await makeDigitAudioSession(store: store) try await activateDigits(session.server) diff --git a/Tests/SendspinKitTests/Client/DynamicPairingTranscriptTests.swift b/Tests/SendspinKitTests/Client/DynamicPairingTranscriptTests.swift index bf564f4..34c3fd9 100644 --- a/Tests/SendspinKitTests/Client/DynamicPairingTranscriptTests.swift +++ b/Tests/SendspinKitTests/Client/DynamicPairingTranscriptTests.swift @@ -61,11 +61,13 @@ private struct DynamicTestSession { let events: AsyncStream let pairingHandshakeHashOverride: Data? let deterministic: Bool + let hasPrimary: Bool } @MainActor private func makeDynamicTestSession( store: (any PairingRecordStore)? = nil, + primary: Bool = false, attemptTimeout: Duration = .seconds(120), windowLifetime: Duration = .seconds(300), nonceBOverride: Data? = nil, @@ -77,7 +79,13 @@ private func makeDynamicTestSession( let client = try SendspinClient( identity: .generate(), name: "Dynamic Pairing Test Client", - roles: [], + roles: primary ? [.playerV1, .controllerV1] : [], + playerConfig: primary ? PlayerConfiguration( + bufferCapacity: 65_536, + supportedFormats: [AudioFormatSpec(codec: .pcm, channels: 1, sampleRate: 8_000, bitDepth: 16)], + volumeMode: .none, + emitRawAudioEvents: true + ) : nil, pairing: PairingConfiguration( pairingPsk: pairingPsk, store: resolvedStore, @@ -95,16 +103,41 @@ private func makeDynamicTestSession( let server = MockNoiseServer(transport: transport, psk: .sentinel) let events = client.events() async let accepted: Void = client.acceptConnection(transport) - try await server.establishSession(activities: [], activeRoles: []) + try await server.establishSession( + activities: primary ? [.playback] : [], + activeRoles: primary ? [.playerV1, .controllerV1] : [] + ) try await accepted #expect(await waitUntil { await MainActor.run { client.connectionState == .connected } }) + if primary { + let sideTransport = MockTransport() + let side = MockNoiseServer(transport: sideTransport, psk: .sentinel) + async let sideAccepted: Void = client.acceptConnection(sideTransport) + try await side.beginAdmission(name: "Dynamic Pairing Side") + for _ in 0 ..< dynamicPairingRoundLimit { + _ = try await resolvedStore.reserveDynamicPairingRound(limit: dynamicPairingRoundLimit) + } + try await activateDynamic(side) + try await sideAccepted + #expect(await waitUntil { await MainActor.run { client.pairingConnection != nil } }) + return DynamicTestSession( + client: client, + server: side, + store: resolvedStore, + events: events, + pairingHandshakeHashOverride: pairingHandshakeHashOverride, + deterministic: nonceBOverride != nil && pairingHandshakeHashOverride != nil && pairingScalarBOverride != nil, + hasPrimary: true + ) + } return DynamicTestSession( client: client, server: server, store: resolvedStore, events: events, pairingHandshakeHashOverride: pairingHandshakeHashOverride, - deterministic: nonceBOverride != nil && pairingHandshakeHashOverride != nil && pairingScalarBOverride != nil + deterministic: nonceBOverride != nil && pairingHandshakeHashOverride != nil && pairingScalarBOverride != nil, + hasPrimary: false ) } @@ -132,7 +165,7 @@ private func waitForClientMessage( private func endedEvent(_ stream: AsyncStream, reason: PairAbortReason) async -> ClientEvent? { await collectClientEvent(from: stream, timeout: .seconds(3)) { - if case .pairingAttemptEnded(reason) = $0 { + if case let .pairingAttemptEnded(snapshot) = $0, snapshot.phase == .ended(reason) { return true } return false @@ -141,14 +174,14 @@ private func endedEvent(_ stream: AsyncStream, reason: PairAbortRea private extension ClientEvent { func unwrapEmission() throws -> PairingCodeEmission { - guard case let .pairingCodeChanged(value?) = self else { throw DynamicTestError.missingEvent } + guard case let .pairingCodeChanged(snapshot) = self, let value = snapshot.code else { throw DynamicTestError.missingEvent } return value } } private func codeEvent(_ stream: AsyncStream) async -> ClientEvent? { await collectClientEvent(from: stream, timeout: .seconds(3)) { - if case .pairingCodeChanged(.some) = $0 { + if case let .pairingCodeChanged(snapshot) = $0, snapshot.code != nil { return true } return false @@ -167,11 +200,27 @@ private func pairingMessageTypes(_ server: MockNoiseServer) async -> [String] { private func dynamicServerTranscript( _ session: DynamicTestSession, format: PairingCodeFormat = .digits, - badServerConfirmation: Bool = false + badServerConfirmation: Bool = false, + operatorOpen: Bool = false ) async throws -> (PairingCodeEmission, [String]) { let fixture = try dynamicFixture() - try await activateDynamic(session.server, format: format) - let initData = try await waitForClientMessage(session.server, type: ClientPairInitMessage.typeString) + if operatorOpen, !session.hasPrimary { + for _ in 0 ..< dynamicPairingRoundLimit { + _ = try await session.store.reserveDynamicPairingRound(limit: dynamicPairingRoundLimit) + } + } + if !session.hasPrimary { + try await activateDynamic(session.server, format: format) + } + let initData: Data + if operatorOpen { + _ = try await waitForClientMessage(session.server, type: ClientPairPendingMessage.typeString) + let attemptID = try #require(await MainActor.run { session.client.currentPairing?.id }) + try await session.client.openPairingWindow(for: attemptID) + initData = try await waitForClientMessage(session.server, type: ClientPairInitMessage.typeString) + } else { + initData = try await waitForClientMessage(session.server, type: ClientPairInitMessage.typeString) + } let initMessage = try JSONDecoder().decode(ClientPairInitMessage.self, from: initData) #expect(initMessage.payload.pairingIndex == fixture.counter) if session.pairingHandshakeHashOverride != nil { @@ -188,11 +237,7 @@ private func dynamicServerTranscript( )), encoding: .utf8)! ) let event = try #require(await eventTask.value) - let emission: PairingCodeEmission = if case let .pairingCodeChanged(value?) = event { - value - } else { - throw DynamicTestError.missingEvent - } + let emission = try event.unwrapEmission() let handshakeHash: Data = if let override = session.pairingHandshakeHashOverride { override } else { @@ -258,6 +303,7 @@ private func qrPayload(_ token: String) throws -> Data { return Data(bytes.prefix(24)) } +@MainActor @Suite("Dynamic pairing transcripts", .timeLimit(.minutes(1))) struct DynamicPairingTranscriptTests { @Test("happy path uses fixture-exact code and persists only after acknowledgement") @@ -268,8 +314,9 @@ struct DynamicPairingTranscriptTests { pairingHandshakeHashOverride: dataFromHex(fixture.handshakeHash), pairingScalarBOverride: dataFromHex(fixture.scalarB) ) - let (emission, _) = try await dynamicServerTranscript(session) + let (emission, _) = try await dynamicServerTranscript(session, operatorOpen: true) #expect(emission.format == .digits) + #expect(session.client.pairingWindow == nil) #expect(emission.payload == fixture.digitsCode) #expect(emission.payload == "268386") let confirms = try await waitForClientMessage(session.server, type: ClientPairConfirmMessage.typeString) @@ -307,7 +354,57 @@ struct DynamicPairingTranscriptTests { try await session.server.sendJSON(#"{"type":"server/pair-finalize","payload":{}}"#) #expect(await waitUntil { await session.store.listRecords().filter { $0.serverId != nil }.count == 1 }) #expect(await session.store.listRecords().filter { $0.serverId != nil }.count == 1) - #expect(await collectClientEvent(from: session.events) { $0 == .pairingCodeChanged(nil) } != nil) + #expect(await collectClientEvent(from: session.events) { + if case let .pairingCodeChanged(snapshot) = $0 { + return snapshot.code == nil + } + return false + } != nil) + await session.client.disconnect() + } + + @Test("parked dynamic pairing succeeds after operator authorization") + func parkedSideHappyPath() async throws { + let fixture = try dynamicFixture() + let session = try await makeDynamicTestSession( + primary: true, + nonceBOverride: dataFromHex(fixture.nonceB), + pairingHandshakeHashOverride: dataFromHex(fixture.handshakeHash), + pairingScalarBOverride: dataFromHex(fixture.scalarB) + ) + let windowEventsTask = Task { () -> [ClientEvent] in + var events = [ClientEvent]() + for await event in session.events { + if case .pairingWindowChanged = event { + events.append(event) + if events.count == 2 { + return events + } + } + } + return events + } + let (emission, _) = try await dynamicServerTranscript(session, operatorOpen: true) + #expect(emission.payload == fixture.digitsCode) + let finalize = try await waitForClientMessage(session.server, type: ClientPairFinalizeMessage.typeString) + #expect(finalize.isEmpty == false) + try await session.server.sendJSON(#"{"type":"server/pair-finalize","payload":{}}"#) + let serverID = await session.server.serverId + #expect(await waitUntil { await session.store.listRecords().contains { $0.serverId == serverID } }) + let windowEvents = await observeTask(windowEventsTask, timeout: .seconds(2)) + guard case let .completed(events) = windowEvents else { + Issue.record("parked dynamic pairing window did not emit open then nil") + await session.client.disconnect() + return + } + #expect(events.count == 2) + guard case .pairingWindowChanged(.some) = events[0], + case .pairingWindowChanged(nil) = events[1] else { + Issue.record("parked dynamic pairing window did not emit open then nil") + await session.client.disconnect() + return + } + #expect(session.client.pairingWindow == nil) await session.client.disconnect() } @@ -339,7 +436,12 @@ struct DynamicPairingTranscriptTests { } return false } == nil) - #expect(await collectClientEvent(from: session.events, timeout: .milliseconds(100)) { $0 == .pairingCodeChanged(nil) } == nil) + #expect(await collectClientEvent(from: session.events, timeout: .milliseconds(100)) { + if case let .pairingCodeChanged(snapshot) = $0 { + return snapshot.code == nil + } + return false + } == nil) try await session.server.sendJSON(#"{"type":"server/state","payload":{}}"#) #expect(await MainActor.run { session.client.connectionState == .connected }) await session.client.disconnect() @@ -354,8 +456,7 @@ struct DynamicPairingTranscriptTests { ) let (emission, _) = try await dynamicServerTranscript(session, badServerConfirmation: true) _ = try await waitForClientMessage(session.server, type: ClientPairRetryMessage.typeString) - #expect(await session.store.dynamicPairingFailureCount() == 1) - #expect(await session.store.dynamicPairingRoundCount() == 1) + #expect(try await session.store.dynamicPairingRoundCount() == 2) #expect(emission.payload.count == 6) let retryEventTask = Task { await codeEvent(session.events) } @@ -394,8 +495,7 @@ struct DynamicPairingTranscriptTests { )), encoding: .utf8))) _ = try await waitForClientMessage(session.server, type: ClientPairConfirmMessage.typeString, count: 1) _ = try await waitForClientMessage(session.server, type: ClientPairFinalizeMessage.typeString, count: 1) - #expect(await session.store.dynamicPairingFailureCount() == 0) - #expect(await session.store.dynamicPairingRoundCount() == 0) + #expect(try await session.store.dynamicPairingRoundCount() == 0) try await session.server.sendJSON(#"{"type":"server/pair-finalize","payload":{}}"#) #expect(await waitUntil { await session.store.listRecords().contains { $0.serverId != nil } }) await session.client.disconnect() @@ -474,49 +574,39 @@ struct DynamicPairingTranscriptTests { } } +@MainActor @Suite("Dynamic pairing budget", .timeLimit(.minutes(1))) struct DynamicPairingFailureCounterTests { - @Test("failure count does not gate a fresh dynamic attempt") - func failureCountDoesNotGate() async throws { - let store = InMemoryPairingRecordStore() - _ = await store.incrementDynamicPairingFailureCount() - let session = try await makeDynamicTestSession(store: store) - try await activateDynamic(session.server) - _ = try await waitForClientMessage(session.server, type: ClientPairInitMessage.typeString) - #expect(await session.server.clientJSONMessages(ofType: ClientPairPendingMessage.typeString).isEmpty) - try await session.client.cancelPairingAttempt() - await session.client.disconnect() - } - - @Test("failure counter resets after a verified confirmation") + @Test("verified confirmation resets the dynamic budget") func counterSemantics() async throws { let store = InMemoryPairingRecordStore() let session = try await makeDynamicTestSession(store: store) _ = try await dynamicServerTranscript(session, badServerConfirmation: true) _ = try await waitForClientMessage(session.server, type: ClientPairRetryMessage.typeString) - #expect(await store.dynamicPairingFailureCount() == 1) - #expect(await store.dynamicPairingRoundCount() == 1) + #expect(try await store.dynamicPairingRoundCount() == 2) #expect(await MainActor.run { session.client.connectionState == .connected }) await session.client.disconnect() - let success = try await makeDynamicTestSession(store: store) + let successStore = InMemoryPairingRecordStore() + let success = try await makeDynamicTestSession(store: successStore) _ = try await dynamicServerTranscript(success) _ = try await waitForClientMessage(success.server, type: ClientPairConfirmMessage.typeString) _ = try await waitForClientMessage(success.server, type: ClientPairFinalizeMessage.typeString) - #expect(await store.dynamicPairingFailureCount() == 0) + #expect(try await successStore.dynamicPairingRoundCount() == 0) await success.client.disconnect() await session.client.disconnect() } } +@MainActor @Suite("Pairing window", .timeLimit(.minutes(1))) struct PairingWindowTests { @Test("round-limit attempts wait for an operator window and do not start the timeout") func roundLimitWaitsForWindow() async throws { let store = InMemoryPairingRecordStore() for _ in 0 ..< dynamicPairingRoundLimit { - _ = await store.incrementDynamicPairingRoundCount() + _ = try await store.reserveDynamicPairingRound(limit: dynamicPairingRoundLimit) } let session = try await makeDynamicTestSession(store: store, attemptTimeout: .milliseconds(100)) try await activateDynamic(session.server) @@ -524,13 +614,179 @@ struct PairingWindowTests { #expect(await session.server.clientJSONMessages(ofType: ClientPairInitMessage.typeString).isEmpty) try await Task.sleep(for: .milliseconds(150)) #expect(await session.server.clientJSONMessages(ofType: PairAbortMessage.typeString).isEmpty) - try await session.client.openPairingWindow() + let attemptID = try #require(await MainActor.run { session.client.currentPairing?.id }) + try await session.client.openPairingWindow(for: attemptID) _ = try await waitForClientMessage(session.server, type: ClientPairInitMessage.typeString) - try await session.client.cancelPairingAttempt() + let refreshedAttemptID = try #require(await MainActor.run { session.client.currentPairing?.id }) + try await session.client.cancelPairing(attemptID: refreshedAttemptID) + await session.client.disconnect() + } +} + +@MainActor +@Suite("Pairing final fences", .timeLimit(.minutes(1))) +struct PairingFinalFenceTests { + @Test("reset failure publishes no authorization window or code and disconnects") + func resetFailureIsTerminalBeforeWindowPublication() async throws { + let store = FinalFenceStore(initialRounds: dynamicPairingRoundLimit, reset: .throws) + let session = try await makeDynamicTestSession(store: store) + try await activateDynamic(session.server) + _ = try await waitForClientMessage(session.server, type: ClientPairPendingMessage.typeString) + let attemptID = try #require(session.client.currentPairing?.id) + + await #expect(throws: PairingRecordStoreError.storageExhausted) { + try await session.client.openPairingWindow(for: attemptID) + } + #expect(await waitUntil { await session.server.disconnectCalled }) + #expect(session.client.pairingWindow == nil) + #expect(session.client.currentPairing?.code == nil) + #expect(await session.server.clientJSONMessages(ofType: ClientPairInitMessage.typeString).isEmpty) await session.client.disconnect() } + + @Test("reserve failure publishes no authorization window or code and disconnects") + func reserveFailureIsTerminalBeforeWindowPublication() async throws { + let store = FinalFenceStore(initialRounds: dynamicPairingRoundLimit, reset: .succeeds, reserve: .throwsAfterReset) + let session = try await makeDynamicTestSession(store: store) + try await activateDynamic(session.server) + _ = try await waitForClientMessage(session.server, type: ClientPairPendingMessage.typeString) + let attemptID = try #require(session.client.currentPairing?.id) + + await #expect(throws: PairingRecordStoreError.storageExhausted) { + try await session.client.openPairingWindow(for: attemptID) + } + #expect(await waitUntil { await session.server.disconnectCalled }) + #expect(session.client.pairingWindow == nil) + #expect(session.client.currentPairing?.code == nil) + #expect(await session.server.clientJSONMessages(ofType: ClientPairInitMessage.typeString).isEmpty) + await session.client.disconnect() + } + + @Test("cancellation while reset is blocked makes the public open stale") + func cancellationDuringResetCannotPublishWindow() async throws { + let store = FinalFenceStore(initialRounds: dynamicPairingRoundLimit, reset: .blocks) + let session = try await makeDynamicTestSession(store: store) + try await activateDynamic(session.server) + _ = try await waitForClientMessage(session.server, type: ClientPairPendingMessage.typeString) + let attemptID = try #require(session.client.currentPairing?.id) + let open = Task { () -> Result in + do { + try await session.client.openPairingWindow(for: attemptID) + return .success(()) + } catch { return .failure(error) } + } + #expect(await waitUntil { await store.resetStarted }) + + try await session.client.cancelPairing(attemptID: attemptID) + _ = try await waitForClientMessage(session.server, type: PairAbortMessage.typeString) + await store.releaseReset() + let result = await open.value + guard case let .failure(error) = result else { + Issue.record("opening a cancelled attempt must not report success") + await session.client.disconnect() + return + } + #expect(error as? SendspinClientError == .stalePairingAttempt(attemptID)) + #expect(session.client.pairingWindow == nil) + #expect(await session.server.clientJSONMessages(ofType: ClientPairInitMessage.typeString).isEmpty) + #expect(await MainActor.run { session.client.connectionState == .connected }) + await session.client.disconnect() + } + + @Test("retry exhaustion sends a terminal abort and leaves no live attempt") + func retryExhaustionAbortsTerminally() async throws { + let store = InMemoryPairingRecordStore() + for _ in 0 ..< dynamicPairingRoundLimit - 1 { + _ = try await store.reserveDynamicPairingRound(limit: dynamicPairingRoundLimit) + } + let session = try await makeDynamicTestSession(store: store) + _ = try await dynamicServerTranscript(session, badServerConfirmation: true) + + let abort = try await waitForClientMessage(session.server, type: PairAbortMessage.typeString) + #expect(try JSONDecoder().decode(PairAbortMessage.self, from: abort).payload.reason == .pairingCodeMismatch) + #expect(await endedEvent(session.events, reason: .pairingCodeMismatch) != nil) + #expect(await collectClientEvent(from: session.events, timeout: .milliseconds(100)) { + if case let .pairingCodeChanged(snapshot) = $0 { + return snapshot.code == nil + } + return false + } != nil) + #expect(await MainActor.run { session.client.connectionState == .connected }) + await session.client.disconnect() + } +} + +private enum FinalFenceReset: Sendable { + case succeeds + case `throws` + case blocks } +private enum FinalFenceReserve: Sendable { + case succeeds + case throwsAfterReset +} + +private actor FinalFenceStore: PairingRecordStore { + private var rounds: UInt32 + private let resetMode: FinalFenceReset + private let reserveMode: FinalFenceReserve + private var resetContinuation: CheckedContinuation? + private(set) var resetStarted = false + + init( + initialRounds: UInt32 = 0, + reset: FinalFenceReset = .succeeds, + reserve: FinalFenceReserve = .succeeds + ) { + rounds = initialRounds + resetMode = reset + reserveMode = reserve + } + + func listRecords() async -> [PairingRecord] { + [] + } + + func insert(_: PairingRecord) async throws { + throw PairingRecordStoreError.storageExhausted + } + + func remove(pskId _: String) async {} + func markUsed(pskId _: String) async {} + func dynamicPairingRoundCount() async throws -> UInt32 { + rounds + } + + func reserveDynamicPairingRound(limit: UInt32) async throws -> DynamicPairingRoundReservation { + if reserveMode == .throwsAfterReset, rounds == 0 { + throw PairingRecordStoreError.storageExhausted + } + guard rounds < limit else { return .exhausted } + rounds += 1 + return .reserved(round: rounds, remaining: limit - rounds) + } + + func resetDynamicPairingBudget() async throws { + switch resetMode { + case .succeeds: + rounds = 0 + case .throws: + throw PairingRecordStoreError.storageExhausted + case .blocks: + resetStarted = true + await withCheckedContinuation { resetContinuation = $0 } + rounds = 0 + } + } + + func releaseReset() { + resetContinuation?.resume() + resetContinuation = nil + } +} + +@MainActor @Suite("Dynamic pairing protocol errors", .timeLimit(.minutes(1))) struct DynamicPairingProtocolErrorTests { @Test("wrong nonce length silently closes without abort or persistence") @@ -559,11 +815,12 @@ struct DynamicPairingProtocolErrorTests { try await session.server.sendJSON(#"{"type":"server/pair-auth","payload":{"pake_msg_1":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}"#) #expect(await waitUntil { await session.server.disconnectCalled }) #expect(await session.server.clientJSONMessages(ofType: PairAbortMessage.typeString).isEmpty) - #expect(await session.store.dynamicPairingFailureCount() == 0) + #expect(try await session.store.dynamicPairingRoundCount() == 1) await session.client.disconnect() } } +@MainActor @Suite("Dynamic pairing timeout and admissibility", .timeLimit(.minutes(1))) struct DynamicPairingTimeoutTests { @Test("attempt timeout uses the exact attempt_timeout reason") @@ -576,7 +833,12 @@ struct DynamicPairingTimeoutTests { let abort = try JSONDecoder().decode(PairAbortMessage.self, from: abortData) #expect(abort.payload.reason.rawValue == "attempt_timeout") #expect(await endedEvent(session.events, reason: .attemptTimeout) != nil) - #expect(await collectClientEvent(from: session.events) { $0 == .pairingCodeChanged(nil) } != nil) + #expect(await collectClientEvent(from: session.events) { + if case let .pairingCodeChanged(snapshot) = $0 { + return snapshot.code == nil + } + return false + } != nil) #expect(await session.store.listRecords().allSatisfy { $0.serverId == nil }) await session.client.disconnect() } @@ -595,7 +857,12 @@ struct DynamicPairingTimeoutTests { // The events stream is single-consumer and the connection enqueues the // ended reason before the code clear, so sequential reads are ordered. #expect(await endedEvent(session.events, reason: .userCancelled) != nil) - #expect(await collectClientEvent(from: session.events) { $0 == .pairingCodeChanged(nil) } != nil) + #expect(await collectClientEvent(from: session.events) { + if case let .pairingCodeChanged(snapshot) = $0 { + return snapshot.code == nil + } + return false + } != nil) await session.client.disconnect() } diff --git a/Tests/SendspinKitTests/Client/LivePairingArbitrationTests.swift b/Tests/SendspinKitTests/Client/LivePairingArbitrationTests.swift index 8aff9d4..6506151 100644 --- a/Tests/SendspinKitTests/Client/LivePairingArbitrationTests.swift +++ b/Tests/SendspinKitTests/Client/LivePairingArbitrationTests.swift @@ -122,7 +122,8 @@ struct LivePairingArbitrationTests { : ClientPairInitMessage.typeString _ = try await waitForLivePairingMessage(incumbent, type: firstMessageType) if kind == .staticCode { - try await client.openPairingWindow() + let attemptID = try #require(await MainActor.run { client.currentPairing?.id }) + try await client.openPairingWindow(for: attemptID) _ = try await waitForLivePairingMessage(incumbent, type: ClientPairInitMessage.typeString) } diff --git a/Tests/SendspinKitTests/Client/PairingAppLayerTests.swift b/Tests/SendspinKitTests/Client/PairingAppLayerTests.swift new file mode 100644 index 0000000..014a4d0 --- /dev/null +++ b/Tests/SendspinKitTests/Client/PairingAppLayerTests.swift @@ -0,0 +1,303 @@ +import Foundation +@testable import SendspinKit +import Testing + +/// App-facing pairing lifecycle tests. These deliberately exercise the facade +/// rather than reaching into connection-owned attempt state. +@MainActor +@Suite("Pairing app layer", .timeLimit(.minutes(1))) +struct PairingAppLayerTests { + @Test("primary pairing can be cancelled by its snapshot identity") + func cancelPrimaryPairing() async throws { + let session = try await makeSession() + let attempt = try #require(session.client.currentPairing) + + try await session.client.cancelPairing(attemptID: attempt.id) + + let abort = try await waitForMessage(session.server, type: PairAbortMessage.typeString) + #expect(try JSONDecoder().decode(PairAbortMessage.self, from: abort).payload.reason == .userCancelled) + #expect(session.client.currentPairing?.id == attempt.id) + #expect(session.client.currentPairing?.phase == .ended(.userCancelled)) + #expect(session.client.connection != nil) + await session.client.disconnect() + } + + @Test("parked pairing side can be cancelled without retargeting playback") + func cancelPairingSide() async throws { + let session = try await makePlaybackSession() + let side = try await admitPairingSide(to: session.client) + let attempt = try #require(session.client.currentPairing) + let primary = session.client.connection + + try await session.client.cancelPairing(attemptID: attempt.id) + + let abort = try await waitForMessage(side, type: PairAbortMessage.typeString) + #expect(try JSONDecoder().decode(PairAbortMessage.self, from: abort).payload.reason == .userCancelled) + #expect(session.client.connection === primary) + #expect(session.client.currentPairing?.id == attempt.id) + #expect(session.client.currentPairing?.phase == .ended(.userCancelled)) + await session.client.disconnect() + } + + @Test("stale cancellation after A ends and B starts has no effect") + func staleCancelCannotRetargetNewAttempt() async throws { + let session = try await makeSession() + let first = try #require(session.client.currentPairing) + try await session.client.cancelPairing(attemptID: first.id) + _ = try await waitForMessage(session.server, type: PairAbortMessage.typeString) + + try await activatePairing(session.server) + #expect(await waitUntil { await MainActor.run { + guard let current = session.client.currentPairing else { return false } + return current.id != first.id && current.phase == .pending + } }) + let second = try #require(session.client.currentPairing) + let abortCount = await session.server.clientJSONMessages(ofType: PairAbortMessage.typeString).count + + await #expect(throws: SendspinClientError.stalePairingAttempt(first.id)) { + try await session.client.cancelPairing(attemptID: first.id) + } + #expect(session.client.currentPairing == second) + #expect(await session.server.clientJSONMessages(ofType: PairAbortMessage.typeString).count == abortCount) + // A late observer reads the terminal/current projection rather than an actor handle. + #expect(session.client.currentPairing?.id == second.id) + await session.client.disconnect() + } + + @Test("retry retains the attempt identity and terminal snapshot is observable") + func retryRetainsIdentity() async throws { + let session = try await makeSession() + let first = try #require(session.client.currentPairing) + let nonceA = Base64URL.encode(Data(repeating: 0, count: 32)) + let pairInit = ServerPairInitMessage(payload: ServerPairInitPayload(nonceA: nonceA)) + try await session.server.sendJSON(#require(String(data: JSONEncoder().encode(pairInit), encoding: .utf8))) + #expect(await waitUntil { await MainActor.run { session.client.currentPairing?.phase == .codeReady } }) + + let initMessage = try await JSONDecoder().decode( + ClientPairInitMessage.self, + from: #require(session.server.clientJSONMessages(ofType: ClientPairInitMessage.typeString).last) + ) + let code = try #require(session.client.currentPairing?.code?.payload) + let handshakeHash = try #require(await session.server.establishedHandshakeHash) + let sid = CPaceSessionIdentifier.make( + handshakeHash: handshakeHash, + counter: initMessage.payload.pairingIndex, + round: 1 + ) + let serverCPace = try CPace( + role: .initiator, + prs: Data(code.utf8), + sid: sid + ) + let auth = ServerPairAuthMessage(payload: ServerPairAuthPayload( + pakeMsg1: Base64URL.encode(serverCPace.publicShare) + )) + try await session.server.sendJSON(#require(String(data: JSONEncoder().encode(auth), encoding: .utf8))) + _ = try await waitForMessage(session.server, type: ClientPairAuthMessage.typeString) + let invalidTag = Base64URL.encode(Data(repeating: 0, count: 64)) + let confirm = ServerPairConfirmMessage(payload: ServerPairConfirmPayload(serverKc: invalidTag)) + try await session.server.sendJSON(#require(String(data: JSONEncoder().encode(confirm), encoding: .utf8))) + _ = try await waitForMessage(session.server, type: ClientPairRetryMessage.typeString) + #expect(session.client.currentPairing?.id == first.id) + + try await session.client.cancelPairing(attemptID: first.id) + #expect(await waitUntil { await MainActor.run { + session.client.currentPairing?.phase == .ended(.userCancelled) + } }) + #expect(session.client.currentPairing?.id == first.id) + await session.client.disconnect() + } + + @Test("a stale primary success does not close a newer authorization window") + func stalePrimarySuccessPreservesNewerWindow() async throws { + let session = try await makeSession() + let oldAttempt = try #require(session.client.currentPairing) + let newerAttemptID = PairingAttemptID() + let newerWindow = PairingWindowSnapshot(attemptID: newerAttemptID, expiresAt: .now) + + session.client.applyConnectionEvent(.pairingWindowChanged(newerWindow)) + session.client.applyConnectionEvent(.paired(PairingAttemptSnapshot( + id: oldAttempt.id, + peer: oldAttempt.peer, + phase: .succeeded + ))) + + #expect(session.client.pairingWindow == newerWindow) + session.client.applyConnectionEvent(.paired(PairingAttemptSnapshot( + id: newerAttemptID, + peer: oldAttempt.peer, + phase: .succeeded + ))) + #expect(session.client.pairingWindow == nil) + await session.client.disconnect() + } + + @Test("a stale parked-side success does not close a newer authorization window") + func stalePairingSideSuccessPreservesNewerWindow() async throws { + let session = try await makePlaybackSession() + let side = try await admitPairingSide(to: session.client) + _ = side + let oldAttempt = try #require(session.client.currentPairing) + let newerAttemptID = PairingAttemptID() + let newerWindow = PairingWindowSnapshot(attemptID: newerAttemptID, expiresAt: .now) + + session.client.applyPairingConnectionEvent(.pairingWindowChanged(newerWindow)) + session.client.applyPairingConnectionEvent(.paired(PairingAttemptSnapshot( + id: oldAttempt.id, + peer: oldAttempt.peer, + phase: .succeeded + ))) + + #expect(session.client.pairingWindow == newerWindow) + session.client.applyPairingConnectionEvent(.paired(PairingAttemptSnapshot( + id: newerAttemptID, + peer: oldAttempt.peer, + phase: .succeeded + ))) + #expect(session.client.pairingWindow == nil) + await session.client.disconnect() + } + + @Test("consuming an authorization window clears the public snapshot exactly once") + func pairingWindowIsConsumedAtPairInit() async throws { + let session = try await makeSession() + let attempt = try #require(session.client.currentPairing) + #expect(attempt.peer.trustLevel == .none) + + let windowEventsTask = Task { () -> [ClientEvent] in + var windowEvents = [ClientEvent]() + for await event in session.events { + guard case .pairingWindowChanged = event else { continue } + windowEvents.append(event) + if windowEvents.count == 2 { + return windowEvents + } + } + return windowEvents + } + try await session.client.openPairingWindow(for: attempt.id) + try await activatePairing(session.server) + _ = try await waitForMessage(session.server, type: ClientPairInitMessage.typeString) + + let windowEvents = await observeTask(windowEventsTask, timeout: .seconds(2)) + guard case let .completed(events) = windowEvents else { + Issue.record("pairing window open and consume events were not both observed") + await session.client.disconnect() + return + } + guard case let .pairingWindowChanged(window?) = events.first, + case .pairingWindowChanged(nil) = events.last else { + Issue.record("pairing window did not emit open then nil") + await session.client.disconnect() + return + } + #expect(events.count == 2) + #expect(window.attemptID == attempt.id) + #expect(session.client.pairingWindow == nil) + await session.client.disconnect() + } + + private struct Session { + let client: SendspinClient + let server: MockNoiseServer + let events: AsyncStream + } + + private func makeSession(windowLifetime: Duration = .seconds(30)) async throws -> Session { + let pairingPsk = Psk.generate() + let store = InMemoryPairingRecordStore(pairingPsk: pairingPsk) + let client = try SendspinClient( + identity: .generate(), + name: "Pairing App Layer Client", + roles: [], + pairing: PairingConfiguration( + pairingPsk: pairingPsk, + store: store, + enabled: false, + dynamicPairingCodeEnabled: true + ), + audioOutputCapabilityProvider: AudioOutputCapabilityService(), + handshakeTimeout: .seconds(3), + pairingAttemptTimeout: .seconds(30), + pairingWindowLifetime: windowLifetime + ) + let events = client.events() + let transport = MockTransport() + let server = MockNoiseServer(transport: transport, psk: .sentinel) + async let accepted: Void = client.acceptConnection(transport) + try await server.establishSession(activities: [], activeRoles: []) + try await accepted + try await activatePairing(server) + #expect(await waitUntil { await MainActor.run { client.currentPairing != nil } }) + return Session(client: client, server: server, events: events) + } + + private func makePlaybackSession() async throws -> Session { + let pairingPsk = Psk.generate() + let store = InMemoryPairingRecordStore(pairingPsk: pairingPsk) + let client = try SendspinClient( + identity: .generate(), + name: "Pairing App Layer Playback Client", + roles: [.playerV1, .controllerV1], + playerConfig: PlayerConfiguration( + bufferCapacity: 65_536, + supportedFormats: [AudioFormatSpec(codec: .pcm, channels: 1, sampleRate: 8_000, bitDepth: 16)], + volumeMode: .none, + emitRawAudioEvents: true + ), + pairing: PairingConfiguration( + pairingPsk: pairingPsk, + store: store, + enabled: false, + dynamicPairingCodeEnabled: true + ), + audioOutputCapabilityProvider: makeInertAudioOutputCapabilityProvider(), + handshakeTimeout: .seconds(3), + pairingAttemptTimeout: .seconds(30), + pairingWindowLifetime: .seconds(30) + ) + let events = client.events() + let transport = MockTransport() + let server = MockNoiseServer(transport: transport, psk: .sentinel) + async let accepted: Void = client.acceptConnection(transport) + try await server.establishSession(activities: [.playback], activeRoles: [.playerV1, .controllerV1]) + try await accepted + #expect(await waitUntil { await MainActor.run { client.connectionState == .connected } }) + return Session(client: client, server: server, events: events) + } + + private func admitPairingSide(to client: SendspinClient) async throws -> MockNoiseServer { + let transport = MockTransport() + let side = MockNoiseServer(transport: transport, psk: .sentinel) + async let accepted: Void = client.acceptConnection(transport) + try await side.beginAdmission(name: "Pairing Side") + try await activatePairing(side) + try await accepted + #expect(await waitUntil { await MainActor.run { client.pairingConnection != nil } }) + #expect(await waitUntil { await MainActor.run { client.currentPairing != nil } }) + return side + } + + private func activatePairing(_ server: MockNoiseServer) async throws { + let message = ServerActivateMessage(payload: ServerActivatePayload( + activities: [.pairing], + activeRoles: [], + pairing: PairingDirective(method: PairMethod.dynamicPairingCode, format: PairingCodeFormat.digits.rawValue) + )) + try await server.sendJSON(#require(String(data: JSONEncoder().encode(message), encoding: .utf8))) + } + + private func waitForMessage(_ server: MockNoiseServer, type: String) async throws -> Data { + #expect(await waitUntil(timeout: .seconds(3)) { + await server.clientJSONMessages(ofType: type).count >= 1 + }) + guard let message = await server.clientJSONMessages(ofType: type).last else { + throw PairingAppLayerTestError.missingMessage(type) + } + return message + } +} + +private enum PairingAppLayerTestError: Error { + case missingMessage(String) +} diff --git a/Tests/SendspinKitTests/Client/RehandshakeTests.swift b/Tests/SendspinKitTests/Client/RehandshakeTests.swift index b1983a4..57714fc 100644 --- a/Tests/SendspinKitTests/Client/RehandshakeTests.swift +++ b/Tests/SendspinKitTests/Client/RehandshakeTests.swift @@ -603,15 +603,15 @@ private actor ThrowingPairingRecordStore: PairingRecordStore { func markUsed(pskId _: String) async {} - func dynamicPairingRoundCount() async -> UInt32 { + func dynamicPairingRoundCount() async throws -> UInt32 { 0 } - func incrementDynamicPairingRoundCount() async -> UInt32 { - 0 + func reserveDynamicPairingRound(limit: UInt32) async throws -> DynamicPairingRoundReservation { + .reserved(round: 1, remaining: limit > 0 ? limit - 1 : 0) } - func resetDynamicPairingRoundCount() async {} + func resetDynamicPairingBudget() async throws {} } /// A bounded store whose free space cannot fit a stored-pubkey record. The @@ -652,16 +652,17 @@ private actor ExhaustedPairingRecordStore: PairingRecordStore { PairingStorageAccounting(free: 0, capacity: 10, costIndividual: 1, costShared: 1) } - func dynamicPairingRoundCount() async -> UInt32 { + func dynamicPairingRoundCount() async throws -> UInt32 { rounds } - func incrementDynamicPairingRoundCount() async -> UInt32 { + func reserveDynamicPairingRound(limit: UInt32) async throws -> DynamicPairingRoundReservation { + guard rounds < limit else { return .exhausted } rounds += 1 - return rounds + return .reserved(round: rounds, remaining: limit - rounds) } - func resetDynamicPairingRoundCount() async { + func resetDynamicPairingBudget() async throws { rounds = 0 } } diff --git a/Tests/SendspinKitTests/Client/SendspinClientTests.swift b/Tests/SendspinKitTests/Client/SendspinClientTests.swift index b7d14fb..f1f064c 100644 --- a/Tests/SendspinKitTests/Client/SendspinClientTests.swift +++ b/Tests/SendspinKitTests/Client/SendspinClientTests.swift @@ -317,7 +317,7 @@ struct SendspinClientTests { let events = client.events() let audio = client.audioChunks let artwork = client.artwork - let visualizer = client.visualizerData + let visualizer = try client.acquireVisualizerFrames() async let firstClose: Void = client.close() async let secondClose: Void = client.close() @@ -351,7 +351,7 @@ struct SendspinClientTests { let events = client.events() let audio = client.audioChunks let artwork = client.artwork - let visualizer = client.visualizerData + let visualizer = try client.acquireVisualizerFrames() let collectedEvents = Task { @MainActor in var values: [ClientEvent] = [] for await event in events { diff --git a/Tests/SendspinKitTests/Client/SendspinConnectionTests.swift b/Tests/SendspinKitTests/Client/SendspinConnectionTests.swift index 03f2d6e..e59b667 100644 --- a/Tests/SendspinKitTests/Client/SendspinConnectionTests.swift +++ b/Tests/SendspinKitTests/Client/SendspinConnectionTests.swift @@ -1441,6 +1441,86 @@ struct SendspinConnectionSessionTests { // MARK: - Outbound whole-message serialization + @Test("a queued pairing send rejects a cancelled attempt after replacement without reaching the wire") + func queuedPairingSendRejectsStaleAttemptAfterReplacement() async throws { + let transport = MockTransport() + let connection = try await makeConnectionWithTransport(transport) + #expect(await waitUntil { await connection.clockSyncTask != nil }) + await connection.clockSyncTask?.cancel() + await connection.clockSyncTask?.value + #expect(await waitUntil { await !connection.outboundInFlight }) + + await connection.admitPairingAttempt() + let oldAttemptID = try #require(await connection.pairingAttemptID) + await transport.enableGoodbyeGate() + let blocker = Task { () -> Result in + do { + try await connection.send(clientMessage: OutboundTestMessage( + type: .padded, + note: String(repeating: "f", count: NoiseChannel.maxSinglePayload + 2_000) + )) + return .success(()) + } catch { return .failure(error) } + } + #expect(await waitUntil { await transport.isGoodbyeGateWaiting }) + + let stale = Task { () -> Result in + do { + try await connection.sendPairingWrapped( + PairAbortMessage(payload: PairAbortPayload(reason: .userCancelled)), + attemptID: oldAttemptID + ) + return .success(()) + } catch { return .failure(error) } + } + #expect(await waitUntil { await connection.outboundWaiters.count == 1 }) + await connection.clearPairingAttempt() + await connection.admitPairingAttempt() + let replacementID = try #require(await connection.pairingAttemptID) + #expect(replacementID != oldAttemptID) + await transport.releaseGoodbyeGate() + + #expect(await (try? blocker.value.get()) != nil) + guard case let .failure(error) = await stale.value else { + Issue.record("a queued send for a retired attempt must fail") + await connection.shutdown() + return + } + #expect(error is SendspinClientError) + let server = try #require(await connectionReadbacks.server(for: transport)) + #expect(await waitUntil(timeout: .seconds(3)) { + await server.decryptedMessages.contains { typeOfDecryptedJSON($0) == .padded } + }) + #expect(await server.decryptedMessages + .contains { SendspinEncoding.messageType(of: Data($0.dropFirst())) == PairAbortMessage.typeString } == false) + await connection.shutdown() + } + + @Test("new pairing admission clears one-shot terminal abort authorization") + func newAdmissionClearsTerminalAbortAuthorization() async throws { + let transport = MockTransport() + let connection = try await makeConnectionWithTransport(transport) + await connection.admitPairingAttempt() + let oldAttemptID = try #require(await connection.pairingAttemptID) + try await connection.openPairingWindow(attemptID: oldAttemptID) + try await connection.cancelPairing(attemptID: oldAttemptID) + let server = try #require(await connectionReadbacks.server(for: transport)) + #expect(await waitUntil { await server.clientJSONMessages(ofType: PairAbortMessage.typeString).count == 1 }) + + await connection.admitPairingAttempt() + let replacementID = try #require(await connection.pairingAttemptID) + #expect(replacementID != oldAttemptID) + await #expect(throws: SendspinClientError.stalePairingAttempt(oldAttemptID)) { + try await connection.sendPairingWrapped( + PairAbortMessage(payload: PairAbortPayload(reason: .userCancelled)), + attemptID: oldAttemptID, + allowClearedAbort: true + ) + } + #expect(await server.clientJSONMessages(ofType: PairAbortMessage.typeString).count == 1) + await connection.shutdown() + } + /// Core regression: a fragmented message parks mid-send with all fragment /// nonces already consumed; the peer must still decrypt both messages in send /// order with no AEAD gap. diff --git a/Tests/SendspinKitTests/Client/StaticPairingTranscriptTests.swift b/Tests/SendspinKitTests/Client/StaticPairingTranscriptTests.swift index 282f410..fa6985f 100644 --- a/Tests/SendspinKitTests/Client/StaticPairingTranscriptTests.swift +++ b/Tests/SendspinKitTests/Client/StaticPairingTranscriptTests.swift @@ -64,6 +64,7 @@ private struct StaticTestSession { @MainActor private func makeStaticTestSession( store: (any PairingRecordStore)? = nil, + primary: Bool = false, attemptTimeout: Duration = .seconds(120), windowLifetime: Duration = .seconds(300), pairingHandshakeHashOverride: Data? = nil, @@ -74,7 +75,13 @@ private func makeStaticTestSession( let client = try SendspinClient( identity: .generate(), name: "Static Pairing Test Client", - roles: [], + roles: primary ? [.playerV1, .controllerV1] : [], + playerConfig: primary ? PlayerConfiguration( + bufferCapacity: 65_536, + supportedFormats: [AudioFormatSpec(codec: .pcm, channels: 1, sampleRate: 8_000, bitDepth: 16)], + volumeMode: .none, + emitRawAudioEvents: true + ) : nil, pairing: PairingConfiguration( pairingPsk: pairingPsk, store: resolvedStore, @@ -92,9 +99,21 @@ private func makeStaticTestSession( let server = MockNoiseServer(transport: transport, psk: .sentinel) let events = client.events() async let accepted: Void = client.acceptConnection(transport) - try await server.establishSession(activities: [], activeRoles: []) + try await server.establishSession( + activities: primary ? [.playback] : [], + activeRoles: primary ? [.playerV1, .controllerV1] : [] + ) try await accepted #expect(await waitUntil { await MainActor.run { client.connectionState == .connected } }) + if primary { + let sideTransport = MockTransport() + let side = MockNoiseServer(transport: sideTransport, psk: .sentinel) + async let sideAccepted: Void = client.acceptConnection(sideTransport) + try await side.beginAdmission(name: "Static Pairing Side") + try await activateStatic(side) + try await sideAccepted + return StaticTestSession(client: client, server: side, store: resolvedStore, events: events) + } return StaticTestSession(client: client, server: server, store: resolvedStore, events: events) } @@ -130,9 +149,16 @@ private func pairingTypes(_ server: MockNoiseServer) async -> [String] { } } -private func staticServerTranscript(_ session: StaticTestSession) async throws -> (Data, Data) { +private func staticServerTranscript(_ session: StaticTestSession, operatorOpen: Bool = false) async throws -> (Data, Data) { let fixture = try staticFixture() - let initData = try await waitForStaticClientMessage(session.server, type: ClientPairInitMessage.typeString) + let initData: Data + if operatorOpen { + let attemptID = try #require(await MainActor.run { session.client.currentPairing?.id }) + try await session.client.openPairingWindow(for: attemptID) + initData = try await waitForStaticClientMessage(session.server, type: ClientPairInitMessage.typeString) + } else { + initData = try await waitForStaticClientMessage(session.server, type: ClientPairInitMessage.typeString) + } let initMessage = try JSONDecoder().decode(ClientPairInitMessage.self, from: initData) #expect(initMessage.payload.pairingIndex == fixture.counter) #expect(initMessage.payload.commitB == nil) @@ -169,6 +195,7 @@ private func staticServerTranscript(_ session: StaticTestSession) async throws - return (confirmData, finalizeData) } +@MainActor @Suite("Static pairing windows", .timeLimit(.minutes(1))) struct StaticPairingWindowTests { @Test("static attempt is pending until the window opens, without starting its timeout") @@ -179,7 +206,8 @@ struct StaticPairingWindowTests { try await Task.sleep(for: .milliseconds(150)) #expect(await session.server.clientJSONMessages(ofType: PairAbortMessage.typeString).isEmpty) #expect(await session.server.clientJSONMessages(ofType: ClientPairInitMessage.typeString).isEmpty) - try await session.client.openPairingWindow() + let attemptID = try #require(session.client.currentPairing?.id) + try await session.client.openPairingWindow(for: attemptID) let initData = try await waitForStaticClientMessage(session.server, type: ClientPairInitMessage.typeString) let pairInit = try JSONDecoder().decode(ClientPairInitMessage.self, from: initData) #expect(pairInit.payload.commitB == nil) @@ -189,7 +217,8 @@ struct StaticPairingWindowTests { @Test("a pre-opened window admits static activation directly") func preOpenedWindowSendsInitDirectly() async throws { let session = try await makeStaticTestSession() - try await session.client.openPairingWindow() + let attemptID = try #require(session.client.currentPairing?.id) + try await session.client.openPairingWindow(for: attemptID) try await activateStatic(session.server) _ = try await waitForStaticClientMessage(session.server, type: ClientPairInitMessage.typeString) #expect(await session.server.clientJSONMessages(ofType: ClientPairPendingMessage.typeString).isEmpty) @@ -199,7 +228,8 @@ struct StaticPairingWindowTests { @Test("an expired window makes a later static activation pending") func expiredWindowGatesStaticActivation() async throws { let session = try await makeStaticTestSession(windowLifetime: .milliseconds(100)) - try await session.client.openPairingWindow() + let attemptID = try #require(session.client.currentPairing?.id) + try await session.client.openPairingWindow(for: attemptID) try await Task.sleep(for: .milliseconds(150)) try await activateStatic(session.server) _ = try await waitForStaticClientMessage(session.server, type: ClientPairPendingMessage.typeString) @@ -210,7 +240,8 @@ struct StaticPairingWindowTests { @Test("a rejected static activation cancels its attempt and allows a fresh activation") func rejectedStaticActivationCleansUpAttempt() async throws { let session = try await makeStaticTestSession() - try await session.client.openPairingWindow() + let attemptID = try #require(session.client.currentPairing?.id) + try await session.client.openPairingWindow(for: attemptID) try await activateStatic(session.server) _ = try await waitForStaticClientMessage(session.server, type: ClientPairInitMessage.typeString) let connection = try #require(await MainActor.run { session.client.connection }) @@ -247,8 +278,13 @@ struct StaticPairingWindowTests { staticPairingCodeEnabled: true, staticPairingCode: "12345678" )) - try await session.client.openPairingWindow() try await activateStatic(session.server) + #expect(await waitUntil { await MainActor.run { + guard let current = session.client.currentPairing else { return false } + return current.id != attemptID && current.phase == .pending + } }) + let refreshedAttemptID = try #require(session.client.currentPairing?.id) + try await session.client.openPairingWindow(for: refreshedAttemptID) let refreshedInit = try await waitForStaticClientMessage( session.server, type: ClientPairInitMessage.typeString, @@ -263,6 +299,7 @@ struct StaticPairingWindowTests { } } +@MainActor @Suite("Static pairing transcripts", .timeLimit(.minutes(1))) struct StaticPairingTranscriptTests { @Test("static code validation uses exactly eight configured ASCII digits") @@ -283,6 +320,47 @@ struct StaticPairingTranscriptTests { await session.client.disconnect() } + @Test("parked static pairing succeeds after operator authorization") + func parkedSideHappyPath() async throws { + let fixture = try staticFixture() + let session = try await makeStaticTestSession( + primary: true, + pairingHandshakeHashOverride: dataFromHex(fixture.handshakeHash), + pairingScalarBOverride: dataFromHex(fixture.scalarB) + ) + let windowEventsTask = Task { () -> [ClientEvent] in + var events = [ClientEvent]() + for await event in session.events { + if case .pairingWindowChanged = event { + events.append(event) + if events.count == 2 { + return events + } + } + } + return events + } + _ = try await staticServerTranscript(session, operatorOpen: true) + try await session.server.sendJSON(#"{"type":"server/pair-finalize","payload":{}}"#) + let serverID = await session.server.serverId + #expect(await waitUntil { await session.store.listRecords().contains { $0.serverId == serverID } }) + let windowEvents = await observeTask(windowEventsTask, timeout: .seconds(2)) + guard case let .completed(events) = windowEvents else { + Issue.record("parked static pairing window did not emit open then nil") + await session.client.disconnect() + return + } + #expect(events.count == 2) + guard case .pairingWindowChanged(.some) = events[0], + case .pairingWindowChanged(nil) = events[1] else { + Issue.record("parked static pairing window did not emit open then nil") + await session.client.disconnect() + return + } + #expect(session.client.pairingWindow == nil) + await session.client.disconnect() + } + @Test("static transcript sends fixture-exact CPace bytes and wrapped finalize shape") func messageShape() async throws { let fixture = try staticFixture() @@ -291,8 +369,8 @@ struct StaticPairingTranscriptTests { pairingScalarBOverride: dataFromHex(fixture.scalarB) ) try await activateStatic(session.server) - try await session.client.openPairingWindow() - let (confirmData, finalizeData) = try await staticServerTranscript(session) + let (confirmData, finalizeData) = try await staticServerTranscript(session, operatorOpen: true) + #expect(session.client.pairingWindow == nil) let confirm = try JSONDecoder().decode(ClientPairConfirmMessage.self, from: confirmData) let finalize = try JSONDecoder().decode(ClientPairFinalizeMessage.self, from: finalizeData) #expect(confirm.payload.clientKc == Base64URL.encode(dataFromHex(fixture.clientKc))) @@ -333,7 +411,8 @@ struct StaticPairingTranscriptTests { pairingScalarBOverride: scalarB ) try await activateStatic(session.server) - try await session.client.openPairingWindow() + let attemptID = try #require(session.client.currentPairing?.id) + try await session.client.openPairingWindow(for: attemptID) _ = try await waitForStaticClientMessage(session.server, type: ClientPairInitMessage.typeString) let cpace = try CPace( role: .initiator, @@ -366,13 +445,14 @@ struct StaticPairingTranscriptTests { let abortData = try await waitForStaticClientMessage(session.server, type: PairAbortMessage.typeString) let abort = try JSONDecoder().decode(PairAbortMessage.self, from: abortData) #expect(abort.payload.reason == .pairingCodeMismatch) - #expect(await store.dynamicPairingFailureCount() == 0) + #expect(try await store.dynamicPairingRoundCount() == 0) #expect(await store.listRecords().allSatisfy { $0.serverId == nil }) #expect(await MainActor.run { session.client.connectionState == .connected }) await session.client.disconnect() } } +@MainActor @Suite("Static pairing protocol errors", .timeLimit(.minutes(1))) struct StaticPairingProtocolErrorTests { @Test("static activation with a format aborts as unsupported and keeps connection open") @@ -398,13 +478,14 @@ struct StaticPairingProtocolErrorTests { let session = try await makeStaticTestSession() try await activateStatic(session.server) _ = try await waitForStaticClientMessage(session.server, type: ClientPairPendingMessage.typeString) - try await session.client.openPairingWindow() + let attemptID = try #require(session.client.currentPairing?.id) + try await session.client.openPairingWindow(for: attemptID) _ = try await waitForStaticClientMessage(session.server, type: ClientPairInitMessage.typeString) try await session.server.sendJSON(#"{"type":"server/pair-init","payload":{"nonce_A":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}"#) #expect(await waitUntil { await session.server.disconnectCalled }) #expect(await session.server.clientJSONMessages(ofType: PairAbortMessage.typeString).isEmpty) #expect(await session.store.listRecords().allSatisfy { $0.serverId == nil }) - #expect(await session.store.dynamicPairingFailureCount() == 0) + #expect(try await session.store.dynamicPairingRoundCount() == 0) await session.client.disconnect() } @@ -413,18 +494,20 @@ struct StaticPairingProtocolErrorTests { for share in ["AA", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"] { let session = try await makeStaticTestSession() try await activateStatic(session.server) - try await session.client.openPairingWindow() + let attemptID = try #require(session.client.currentPairing?.id) + try await session.client.openPairingWindow(for: attemptID) _ = try await waitForStaticClientMessage(session.server, type: ClientPairInitMessage.typeString) try await session.server.sendJSON(#"{"type":"server/pair-auth","payload":{"pake_msg_1":"\#(share)"}}"#) #expect(await waitUntil { await session.server.disconnectCalled }) #expect(await session.server.clientJSONMessages(ofType: PairAbortMessage.typeString).isEmpty) #expect(await session.store.listRecords().allSatisfy { $0.serverId == nil }) - #expect(await session.store.dynamicPairingFailureCount() == 0) + #expect(try await session.store.dynamicPairingRoundCount() == 0) await session.client.disconnect() } } } +@MainActor @Suite("Static pairing cancellation", .timeLimit(.minutes(1))) struct PairingCancellationTests { @Test("cancelling static activate discards state without changing counter") @@ -433,7 +516,7 @@ struct PairingCancellationTests { try await activateStatic(session.server) _ = try await waitForStaticClientMessage(session.server, type: ClientPairPendingMessage.typeString) try await session.server.sendJSON(#"{"type":"server/activate","payload":{"activities":[],"active_roles":[]}}"#) - #expect(await session.store.dynamicPairingFailureCount() == 0) + #expect(try await session.store.dynamicPairingRoundCount() == 0) #expect(await session.store.listRecords().allSatisfy { $0.serverId == nil }) await session.client.disconnect() } @@ -445,12 +528,12 @@ struct PairingCancellationTests { _ = try await waitForStaticClientMessage(session.server, type: ClientPairPendingMessage.typeString) try await session.server.sendJSON(#"{"type":"pair/abort","payload":{"reason":"user_cancelled"}}"#) #expect(await collectClientEvent(from: session.events) { - if case .pairingAttemptEnded(.userCancelled) = $0 { + if case let .pairingAttemptEnded(snapshot) = $0, snapshot.phase == .ended(.userCancelled) { return true } return false } != nil) - #expect(await session.store.dynamicPairingFailureCount() == 0) + #expect(try await session.store.dynamicPairingRoundCount() == 0) await session.client.disconnect() } @@ -459,17 +542,19 @@ struct PairingCancellationTests { let session = try await makeStaticTestSession(attemptTimeout: .milliseconds(100)) await session.server.transport.setHonorCancellationSends(true) try await activateStatic(session.server) - try await session.client.openPairingWindow() + let attemptID = try #require(session.client.currentPairing?.id) + try await session.client.openPairingWindow(for: attemptID) _ = try await waitForStaticClientMessage(session.server, type: ClientPairInitMessage.typeString) let abortData = try await waitForStaticClientMessage(session.server, type: PairAbortMessage.typeString) let abort = try JSONDecoder().decode(PairAbortMessage.self, from: abortData) #expect(abort.payload.reason == .attemptTimeout) - #expect(await session.store.dynamicPairingFailureCount() == 0) + #expect(try await session.store.dynamicPairingRoundCount() == 0) #expect(await session.store.listRecords().allSatisfy { $0.serverId == nil }) await session.client.disconnect() } } +@MainActor @Suite("Pairing index sequence", .timeLimit(.minutes(1))) struct PairingIndexSequenceTests { @Test("successive static activations carry increasing pairing indexes") diff --git a/Tests/SendspinKitTests/Client/VisualizerDataDeliveryTests.swift b/Tests/SendspinKitTests/Client/VisualizerDataDeliveryTests.swift index 9d8446e..991ac6f 100644 --- a/Tests/SendspinKitTests/Client/VisualizerDataDeliveryTests.swift +++ b/Tests/SendspinKitTests/Client/VisualizerDataDeliveryTests.swift @@ -2,312 +2,476 @@ import Foundation @testable import SendspinKit import Testing -struct VisualizerDataDeliveryTests { - @Test("cancellation before parking lets a new iterator reclaim ownership") - func cancellationBeforeParkDoesNotStealFrame() async { - let mailbox = VisualizerDataMailbox(capacityBytes: 64) - let cancelled = Task { () -> VisualizerData? in - await Task.yield() - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - return await iterator.next() - } - cancelled.cancel() - #expect(await cancelled.value == nil) - - let value = VisualizerData(type: .peak, data: Data([1]), localDisplayTime: .max) - mailbox.offer(value, now: 0) - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - #expect(await iterator.next() == value) +struct VisualizerFrameDeliveryTests { + private let configuration = VisualizerStreamConfiguration(types: [.peak, .loudness, .beat, .spectrum], rateMax: 60) + + @Test("a canceled subscription releases mailbox ownership") + func cancellationReleasesOwnership() async throws { + let mailbox = VisualizerFrameMailbox(capacityBytes: 64) + let first = try VisualizerFrameSubscription(acquiring: mailbox) + first.cancel() + let second = try VisualizerFrameSubscription(acquiring: mailbox) + mailbox.offer(frame(type: .peak, byte: 1, at: .max), now: PresentationInstant(rawMicroseconds: 0)) + var iterator = second.makeAsyncIterator() + #expect(await iterator.next()?.data == Data([1])) mailbox.finish() } - @Test("cancellation while parked lets a new iterator reclaim ownership") - func cancellationWhileParkedDoesNotStealFrame() async { + @Test("a pending read cancellation releases ownership") + func pendingCancellationReleasesOwnership() async throws { let clock = MailboxTestClock() - let mailbox = VisualizerDataMailbox(capacityBytes: 64, now: { clock.read() }) - let waiting = Task { () -> VisualizerData? in - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - return await iterator.next() + let mailbox = VisualizerFrameMailbox(capacityBytes: 64, now: { clock.now }) + let first = try VisualizerFrameSubscription(acquiring: mailbox) + let pending = Task { var iterator = first.makeAsyncIterator(); return await iterator.next() } + #expect(await clock.waitUntilReadCount(1)) + pending.cancel() + let observation = await observeTask( + pending, + timeout: .seconds(1), + onTimeout: { mailbox.finish() } + ) + guard case let .completed(value) = observation else { + Issue.record("cancelled mailbox read did not finish") + mailbox.finish() + return } - #expect(await clock.waitUntilReadCount(2)) - waiting.cancel() - #expect(await waiting.value == nil) - - let value = VisualizerData(type: .peak, data: Data([2]), localDisplayTime: .max) - mailbox.offer(value, now: 0) - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - #expect(await iterator.next() == value) + #expect(value == nil) + let second = try VisualizerFrameSubscription(acquiring: mailbox) + mailbox.offer(frame(type: .peak, byte: 2, at: .max), now: PresentationInstant(rawMicroseconds: 0)) + var iterator = second.makeAsyncIterator() + #expect(await iterator.next()?.data == Data([2])) mailbox.finish() } - @Test("a second live iterator returns nil without replacing the owner") - func iteratorOwnershipIsStable() async { - let clock = MailboxTestClock() - let mailbox = VisualizerDataMailbox(capacityBytes: 64, now: { clock.read() }) - let parked = Task { () -> VisualizerData? in - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - return await iterator.next() - } - #expect(await clock.waitUntilReadCount(2)) + @Test("an old iterator cannot consume a replacement after cancellation") + func oldIteratorAfterCancellationDoesNotConsumeReplacement() async throws { + let mailbox = VisualizerFrameMailbox(capacityBytes: 64) + let first = try VisualizerFrameSubscription(acquiring: mailbox) + var oldIterator = first.makeAsyncIterator() + first.cancel() - var second = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - #expect(await second.next() == nil) - let value = VisualizerData(type: .peak, data: Data([3]), localDisplayTime: .max) - mailbox.offer(value, now: 0) - #expect(await parked.value == value) + let replacement = try VisualizerFrameSubscription(acquiring: mailbox) + let replacementFrame = frame(type: .peak, byte: 19, at: .max) + mailbox.offer(replacementFrame, now: PresentationInstant(rawMicroseconds: 0)) + + #expect(await oldIterator.next() == nil) + var replacementIterator = replacement.makeAsyncIterator() + #expect(await replacementIterator.next() == replacementFrame) mailbox.finish() } - @Test("mailbox admission remains safe at Int.max capacity") - func intMaxCapacityDoesNotOverflow() async { - let mailbox = VisualizerDataMailbox(capacityBytes: .max) - let value = VisualizerData(type: .loudness, data: Data([4, 5]), localDisplayTime: .max) - mailbox.offer(value, now: 0) - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - #expect(await iterator.next() == value) + @Test("subscription deinit revokes an old iterator lease") + func subscriptionDeinitRevokesOldIteratorLease() async throws { + let mailbox = VisualizerFrameMailbox(capacityBytes: 64) + var oldIterator: VisualizerFrameSubscription.Iterator? + do { + let first = try VisualizerFrameSubscription(acquiring: mailbox) + oldIterator = first.makeAsyncIterator() + } + + let replacement = try VisualizerFrameSubscription(acquiring: mailbox) + let replacementFrame = frame(type: .peak, byte: 24, at: .max) + mailbox.offer(replacementFrame, now: PresentationInstant(rawMicroseconds: 0)) + + var staleIterator = try #require(oldIterator) + #expect(await staleIterator.next() == nil) + var replacementIterator = replacement.makeAsyncIterator() + #expect(await replacementIterator.next() == replacementFrame) mailbox.finish() } - @Test("mailbox rechecks a frame deadline after a waiter resumes") - func resumedExpiredFrameIsDropped() async { - let clock = MailboxTestClock(value: 0) - let mailbox = VisualizerDataMailbox(capacityBytes: 64, now: { clock.read() }) - let pending = Task { () -> VisualizerData? in - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + @Test("explicit cancellation between the immediate check and park is a barrier") + func explicitCancellationImmediateCheckToParkBarrier() async throws { + let reachedParkBarrier = DispatchSemaphore(value: 0) + let releaseParkBarrier = DispatchSemaphore(value: 0) + let mailbox = VisualizerFrameMailbox( + capacityBytes: 64, + beforePark: { + reachedParkBarrier.signal() + _ = releaseParkBarrier.wait(timeout: .now() + 1) + } + ) + let first = try VisualizerFrameSubscription(acquiring: mailbox) + let pending = Task { + var iterator = first.makeAsyncIterator() return await iterator.next() } - #expect(await clock.waitUntilReadCount(2)) - clock.setValue(10) - mailbox.offer( - VisualizerData(type: .beat, data: Data([6]), localDisplayTime: 5), - now: 0 + + defer { + first.cancel() + releaseParkBarrier.signal() + mailbox.finish() + } + try #require(await waitForSemaphore(reachedParkBarrier)) + first.cancel() + releaseParkBarrier.signal() + let observation = await observeTask( + pending, + timeout: .seconds(1), + onTimeout: { + mailbox.finish() + releaseParkBarrier.signal() + } ) + guard case let .completed(value) = observation else { + Issue.record("cancelled parked read did not finish") + return + } + #expect(value == nil) + + let replacement = try VisualizerFrameSubscription(acquiring: mailbox) + let replacementFrame = frame(type: .peak, byte: 20, at: .max) + mailbox.offer(replacementFrame, now: PresentationInstant(rawMicroseconds: 0)) + var replacementIterator = replacement.makeAsyncIterator() + #expect(await replacementIterator.next() == replacementFrame) mailbox.finish() - #expect(await pending.value == nil) } - @Test("a canceled read that was woken does not transfer its frame to a newer waiter") - func cancellationAfterWakeDropsFrameWithoutStealing() async { - let clock = MailboxTestClock() - let mailbox = VisualizerDataMailbox(capacityBytes: 64, now: { clock.read() }) - let oldRead = Task { () -> VisualizerData? in - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + @Test("explicit cancellation after handoff drops the old frame") + func explicitCancellationAfterHandoff() async throws { + let reachedParkBarrier = DispatchSemaphore(value: 0) + let releaseParkBarrier = DispatchSemaphore(value: 0) + let reachedHandoffBarrier = DispatchSemaphore(value: 0) + let releaseHandoffBarrier = DispatchSemaphore(value: 0) + let mailbox = VisualizerFrameMailbox( + capacityBytes: 64, + beforePark: { + reachedParkBarrier.signal() + _ = releaseParkBarrier.wait(timeout: .now() + 1) + }, + beforePostHandoffCheck: { + reachedHandoffBarrier.signal() + _ = releaseHandoffBarrier.wait(timeout: .now() + 1) + } + ) + let first = try VisualizerFrameSubscription(acquiring: mailbox) + let pending = Task { + var iterator = first.makeAsyncIterator() return await iterator.next() } - #expect(await clock.waitUntilReadCount(2)) - - let first = VisualizerData(type: .peak, data: Data([7]), localDisplayTime: .max) - mailbox.offer(first, now: 0) - oldRead.cancel() - - let newer = Task { () -> VisualizerData? in - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - return await iterator.next() + defer { + first.cancel() + releaseParkBarrier.signal() + releaseHandoffBarrier.signal() + mailbox.finish() } - #expect(await clock.waitUntilReadCount(4)) - #expect(await oldRead.value == nil) - - let second = VisualizerData(type: .peak, data: Data([8]), localDisplayTime: .max) - mailbox.offer(second, now: 0) - #expect(await newer.value == second) + try #require(await waitForSemaphore(reachedParkBarrier)) + releaseParkBarrier.signal() + mailbox.offer(frame(type: .peak, byte: 21, at: .max), now: PresentationInstant(rawMicroseconds: 0)) + try #require(await waitForSemaphore(reachedHandoffBarrier)) + first.cancel() + releaseHandoffBarrier.signal() + let observation = await observeTask( + pending, + timeout: .seconds(1), + onTimeout: { + mailbox.finish() + releaseHandoffBarrier.signal() + } + ) + guard case let .completed(value) = observation else { + Issue.record("cancelled handed-off read did not finish") + return + } + #expect(value == nil) + releaseHandoffBarrier.signal() mailbox.finish() } - @Test("invalidation after a wake never delivers an invalid frame") - func invalidationAfterWakeDropsFrame() async { - let validity = VisualizerFrameValidity() - let clock = MailboxTestClock(blockedRead: 3) - let mailbox = VisualizerDataMailbox(capacityBytes: 64, now: { clock.read() }) - let pending = Task { () -> VisualizerData? in - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + @Test("task cancellation after handoff drops the old frame") + func taskCancellationAfterHandoff() async throws { + let reachedParkBarrier = DispatchSemaphore(value: 0) + let releaseParkBarrier = DispatchSemaphore(value: 0) + let reachedHandoffBarrier = DispatchSemaphore(value: 0) + let releaseHandoffBarrier = DispatchSemaphore(value: 0) + let mailbox = VisualizerFrameMailbox( + capacityBytes: 64, + beforePark: { + reachedParkBarrier.signal() + _ = releaseParkBarrier.wait(timeout: .now() + 1) + }, + beforePostHandoffCheck: { + reachedHandoffBarrier.signal() + _ = releaseHandoffBarrier.wait(timeout: .now() + 1) + } + ) + let subscription = try VisualizerFrameSubscription(acquiring: mailbox) + let pending = Task { + var iterator = subscription.makeAsyncIterator() return await iterator.next() } - #expect(await clock.waitUntilReadCount(2)) + defer { + pending.cancel() + releaseParkBarrier.signal() + releaseHandoffBarrier.signal() + mailbox.finish() + } + try #require(await waitForSemaphore(reachedParkBarrier)) + releaseParkBarrier.signal() + mailbox.offer(frame(type: .peak, byte: 25, at: .max), now: PresentationInstant(rawMicroseconds: 0)) - let value = VisualizerData( - type: .beat, - data: Data([9]), - localDisplayTime: .max, - validity: validity + try #require(await waitForSemaphore(reachedHandoffBarrier)) + pending.cancel() + releaseHandoffBarrier.signal() + let observation = await observeTask( + pending, + timeout: .seconds(1), + onTimeout: { + mailbox.finish() + releaseHandoffBarrier.signal() + } ) - mailbox.offer(value, now: 0) - #expect(await clock.waitUntilReadCount(3)) - validity.invalidate() - clock.releaseBlockedRead() + guard case let .completed(value) = observation else { + Issue.record("task-cancelled handed-off read did not finish") + return + } + #expect(value == nil) + subscription.cancel() + + releaseHandoffBarrier.signal() mailbox.finish() - #expect(await pending.value == nil) } - @Test("an abandoned iterator token releases ownership") - func abandonedIteratorReclaimsOwnership() async { - let mailbox = VisualizerDataMailbox(capacityBytes: 64) - let first = VisualizerData(type: .peak, data: Data([10]), localDisplayTime: .max) - mailbox.offer(first, now: 0) + @Test("copied iterators share one parked read and preserve the original") + func copiedIteratorsShareOneInFlightRead() async throws { + let clock = MailboxTestClock() + let reachedPark = DispatchSemaphore(value: 0) + let releasePark = DispatchSemaphore(value: 0) + let mailbox = VisualizerFrameMailbox( + capacityBytes: 64, + now: { clock.now }, + beforePark: { + reachedPark.signal() + _ = releasePark.wait(timeout: .now() + 1) + } + ) + let subscription = try VisualizerFrameSubscription(acquiring: mailbox) + let first = subscription.makeAsyncIterator() + var copy = first + let pending = Task { + var iterator = first + return await iterator.next() + } - do { - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - #expect(await iterator.next() == first) + try #require(await waitForSemaphore(reachedPark)) + defer { + pending.cancel() + releasePark.signal() + mailbox.finish() } - await Task.yield() + let parkedReadCount = clock.readCountSnapshot + #expect(await copy.next() == nil) + #expect(clock.readCountSnapshot == parkedReadCount) + releasePark.signal() - let second = VisualizerData(type: .peak, data: Data([11]), localDisplayTime: .max) - mailbox.offer(second, now: 0) - var replacement = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - #expect(await replacement.next() == second) - mailbox.finish() + let value = frame(type: .peak, byte: 23, at: .max) + mailbox.offer(value, now: PresentationInstant(rawMicroseconds: 0)) + let observation = await observeTask( + pending, + timeout: .seconds(1), + onTimeout: { + mailbox.finish() + releasePark.signal() + } + ) + guard case let .completed(result) = observation else { + Issue.record("copied iterator's parked read did not finish") + return + } + #expect(result == value) } - @Test("consuming a frame releases its byte storage and linked-queue budget") - func consumedFrameReleasesLinkedQueueBytes() async { - let frameBytes = BinaryMessage.headerSize + 1 - let mailbox = VisualizerDataMailbox(capacityBytes: frameBytes * 2) - let firstReleased = ByteReleaseProbe() - offerTrackedFrame(firstReleased, to: mailbox, byte: 12) - let second = VisualizerData(type: .loudness, data: Data([13]), localDisplayTime: .max) - let third = VisualizerData(type: .loudness, data: Data([14]), localDisplayTime: .max) - - do { - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - #expect(await (iterator.next())?.data == Data([12])) - mailbox.offer(second, now: 0) - mailbox.offer(third, now: 0) - #expect(await iterator.next() == second) - #expect(await iterator.next() == third) + @Test("a second active subscription throws without replacing the owner") + func secondSubscriptionIsRejected() throws { + let mailbox = VisualizerFrameMailbox(capacityBytes: 64) + let first = try VisualizerFrameSubscription(acquiring: mailbox) + #expect(throws: VisualizerFrameAcquisitionError.consumerAlreadyActive) { + _ = try VisualizerFrameSubscription(acquiring: mailbox) } - #expect(firstReleased.wasReleased) + first.cancel() mailbox.finish() } - @Test("queued frames retain the configuration that validated them") - func configurationSnapshotSurvivesUpdate() async { - let old = VisualizerStreamConfiguration( - types: [.spectrum], - rateMax: 30, - spectrum: SpectrumConfiguration(nDispBins: 2, scale: .lin, fMin: 20, fMax: 20_000) - ) - let updated = VisualizerStreamConfiguration(types: [.loudness], rateMax: 60) - let mailbox = VisualizerDataMailbox(capacityBytes: 128) - let oldFrame = VisualizerData( - type: .spectrum, - data: Data([0, 1, 0, 2]), - localDisplayTime: .max, - streamConfiguration: old - ) - let newFrame = VisualizerData( - type: .loudness, - data: Data([0, 3]), - localDisplayTime: .max, - streamConfiguration: updated - ) - mailbox.offer(oldFrame, now: 0) - mailbox.offer(newFrame, now: 0) + @Test("a subscription ends duplicate iterators without entering mailbox reads") + func duplicateIteratorEndsImmediately() async throws { + let mailbox = VisualizerFrameMailbox(capacityBytes: 64) + let subscription = try VisualizerFrameSubscription(acquiring: mailbox) + var primary = subscription.makeAsyncIterator() + var duplicate = subscription.makeAsyncIterator() - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - #expect(await iterator.next()?.streamConfiguration == old) - #expect(await iterator.next()?.streamConfiguration == updated) + #expect(await duplicate.next() == nil) + + mailbox.offer(frame(type: .peak, byte: 10, at: .max), now: PresentationInstant(rawMicroseconds: 0)) + #expect(await primary.next()?.data == Data([10])) mailbox.finish() } - @Test("mailbox keeps retained visualizer bytes within the exact wire budget") - func byteBudgetDropsOldestFrames() async { - let mailbox = VisualizerDataMailbox(capacityBytes: 22) + @Test("scheduling eligibility is separate from validity at presentation") + func dueFrameCanRemainValid() { let validity = VisualizerFrameValidity() - let first = VisualizerData( - type: .loudness, - data: Data([1, 2]), - localDisplayTime: .max, - validity: validity - ) - let second = VisualizerData( - type: .loudness, - data: Data([3, 4]), - localDisplayTime: .max, - validity: validity + let frame = frame(type: .peak, byte: 3, at: 10, validity: validity) + #expect(frame.eligibilityForScheduling(at: PresentationInstant(rawMicroseconds: 9))) + #expect(frame.eligibilityForScheduling(at: PresentationInstant(rawMicroseconds: 10)) == false) + #expect(frame.isValid) + validity.invalidate() + #expect(frame.isValid == false) + #expect(frame.eligibilityForScheduling(at: PresentationInstant(rawMicroseconds: 9)) == false) + } + + @Test("invalidated frames are not delivered after a waiter wakes") + func invalidationAfterWakeDropsFrame() async throws { + let validity = VisualizerFrameValidity() + let clock = MailboxTestClock() + let reachedParkBarrier = DispatchSemaphore(value: 0) + let releaseParkBarrier = DispatchSemaphore(value: 0) + let reachedHandoffBarrier = DispatchSemaphore(value: 0) + let releaseHandoffBarrier = DispatchSemaphore(value: 0) + let mailbox = VisualizerFrameMailbox( + capacityBytes: 64, + now: { clock.now }, + beforePark: { + reachedParkBarrier.signal() + _ = releaseParkBarrier.wait(timeout: .now() + 1) + }, + beforePostHandoffCheck: { + reachedHandoffBarrier.signal() + _ = releaseHandoffBarrier.wait(timeout: .now() + 1) + } ) - let third = VisualizerData( - type: .loudness, - data: Data([5, 6]), - localDisplayTime: .max, - validity: validity + let subscription = try VisualizerFrameSubscription(acquiring: mailbox) + let pending = Task { var iterator = subscription.makeAsyncIterator(); return await iterator.next() } + defer { + releaseParkBarrier.signal() + releaseHandoffBarrier.signal() + mailbox.finish() + } + try #require(await waitForSemaphore(reachedParkBarrier)) + releaseParkBarrier.signal() + mailbox.offer(frame(type: .beat, byte: 4, at: .max, validity: validity), now: PresentationInstant(rawMicroseconds: 0)) + try #require(await waitForSemaphore(reachedHandoffBarrier)) + validity.invalidate() + releaseHandoffBarrier.signal() + mailbox.finish() + let observation = await observeTask( + pending, + timeout: .seconds(1), + onTimeout: { + mailbox.finish() + releaseHandoffBarrier.signal() + } ) + guard case let .completed(value) = observation else { + Issue.record("invalidated handed-off read did not finish") + return + } + #expect(value == nil) + } - mailbox.offer(first, now: 0) - mailbox.offer(second, now: 0) - mailbox.offer(third, now: 0) + @Test("mailbox drops expired frames but keeps future frames FIFO") + func expirationAndFIFO() async throws { + let clock = MailboxTestClock(value: 0) + let mailbox = VisualizerFrameMailbox(capacityBytes: 64, now: { clock.now }) + let subscription = try VisualizerFrameSubscription(acquiring: mailbox) + mailbox.offer(frame(type: .peak, byte: 5, at: 1), now: PresentationInstant(rawMicroseconds: 0)) + mailbox.offer(frame(type: .peak, byte: 6, at: 20), now: PresentationInstant(rawMicroseconds: 0)) + clock.setValue(10) + var iterator = subscription.makeAsyncIterator() + #expect(await iterator.next()?.data == Data([6])) + mailbox.finish() + } - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - #expect(await iterator.next() == second) - #expect(await iterator.next() == third) + @Test("mailbox enforces the exact wire byte budget") + func byteBudgetDropsOldestFrames() async throws { + let frameBytes = BinaryMessage.headerSize + Data([UInt8(7)]).count + let mailbox = VisualizerFrameMailbox(capacityBytes: frameBytes * 2) + let subscription = try VisualizerFrameSubscription(acquiring: mailbox) + mailbox.offer(frame(type: .peak, byte: 7, at: .max), now: PresentationInstant(rawMicroseconds: 0)) + mailbox.offer(frame(type: .peak, byte: 8, at: .max), now: PresentationInstant(rawMicroseconds: 0)) + mailbox.offer(frame(type: .peak, byte: 9, at: .max), now: PresentationInstant(rawMicroseconds: 0)) + var iterator = subscription.makeAsyncIterator() + try #require(mailbox.retainedByteCount == frameBytes * 2) + #expect(await iterator.next()?.data == Data([8])) + #expect(await iterator.next()?.data == Data([9])) + mailbox.finish() + } + + @Test("mailbox admission remains safe at Int.max capacity") + func intMaxCapacityDoesNotOverflow() async throws { + let mailbox = VisualizerFrameMailbox(capacityBytes: .max) + let subscription = try VisualizerFrameSubscription(acquiring: mailbox) + let value = frame(type: .loudness, byte: 4, at: .max) + mailbox.offer(value, now: PresentationInstant(rawMicroseconds: 0)) + var iterator = subscription.makeAsyncIterator() + #expect(await iterator.next() == value) mailbox.finish() - #expect(await iterator.next() == nil) } @Test("an oversized visualizer frame never bypasses the byte cap") - func oversizedFrameIsDropped() async { - let mailbox = VisualizerDataMailbox(capacityBytes: BinaryMessage.headerSize + 1) - let value = VisualizerData( - type: .spectrum, - data: Data(repeating: 0, count: 2), - localDisplayTime: .max + func oversizedFrameIsDropped() throws { + let mailbox = VisualizerFrameMailbox(capacityBytes: BinaryMessage.headerSize + 1) + _ = try VisualizerFrameSubscription(acquiring: mailbox) + let oversized = VisualizerFrame( + type: .loudness, + data: Data([1, 2]), + presentationTime: PresentationInstant(rawMicroseconds: .max), + configuration: configuration ) - mailbox.offer(value, now: 0) - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + mailbox.offer(oversized, now: PresentationInstant(rawMicroseconds: 0)) + + #expect(mailbox.retainedByteCount == 0) mailbox.finish() - #expect(await iterator.next() == nil) } - @Test("mailbox drops expired frames when a slow consumer resumes") - func expiredFramesAreDroppedOnConsumption() async { - let mailbox = VisualizerDataMailbox(capacityBytes: 64) - let value = VisualizerData(type: .beat, data: Data([1]), localDisplayTime: 1) - mailbox.offer(value, now: 0) + @Test("consuming a frame releases its byte storage and linked-queue budget") + func consumedFrameReleasesLinkedQueueBytes() async throws { + let frameBytes = BinaryMessage.headerSize + 1 + let mailbox = VisualizerFrameMailbox(capacityBytes: frameBytes * 2) + let firstReleased = ByteReleaseProbe() + offerTrackedFrame(firstReleased, to: mailbox, byte: 12) + let second = frame(type: .loudness, byte: 13, at: .max) + let third = frame(type: .loudness, byte: 14, at: .max) - let pending = Task { () -> VisualizerData? in - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - return await iterator.next() - } - let observation = await observeTask( - pending, - timeout: .milliseconds(50), - onTimeout: { mailbox.finish() } - ) - switch observation { - case .timedOut: - break - case let .completed(value): - Issue.record("dropping an expired frame must keep a live mailbox open; got \(String(describing: value))") + do { + let subscription = try VisualizerFrameSubscription(acquiring: mailbox) + var iterator = subscription.makeAsyncIterator() + #expect(await iterator.next()?.data == Data([12])) + mailbox.offer(second, now: PresentationInstant(rawMicroseconds: 0)) + mailbox.offer(third, now: PresentationInstant(rawMicroseconds: 0)) + #expect(await iterator.next() == second) + #expect(await iterator.next() == third) } + #expect(firstReleased.wasReleased) mailbox.finish() } - @Test("clear releases all retained visualizer frames") - func clearDropsQueuedFrames() async { - let mailbox = VisualizerDataMailbox(capacityBytes: 64) - mailbox.offer( - VisualizerData(type: .peak, data: Data([1]), localDisplayTime: .max), - now: 0 - ) + @Test("clear releases all retained visualizer frames immediately") + func clearDropsQueuedFrames() throws { + let mailbox = VisualizerFrameMailbox(capacityBytes: 64) + _ = try VisualizerFrameSubscription(acquiring: mailbox) + mailbox.offer(frame(type: .peak, byte: 15, at: .max), now: PresentationInstant(rawMicroseconds: 0)) + mailbox.offer(frame(type: .loudness, byte: 16, at: .max), now: PresentationInstant(rawMicroseconds: 0)) + #expect(mailbox.retainedByteCount > 0) + mailbox.clear() - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() + #expect(mailbox.retainedByteCount == 0) mailbox.finish() - #expect(await iterator.next() == nil) } - @Test("parked clear, end, and teardown preserve the primary mailbox") + @Test("parked delivery cannot clear the primary mailbox") func parkedDeliveryDoesNotClearPrimaryMailbox() { - let mailbox = VisualizerDataMailbox(capacityBytes: 64) + let mailbox = VisualizerFrameMailbox(capacityBytes: 64) let primary = makeConnectionDataDelivery(mailbox: mailbox) let parked = makeConnectionDataDelivery(mailbox: mailbox) primary.promoteToPrimary() - let frame = VisualizerData(type: .peak, data: Data([15, 16]), localDisplayTime: .max) - primary.offerVisualizerIfValid(frame, validity: SessionValidityToken()) - #expect(mailbox.retainedByteCount == frame.frameByteCount) + let value = frame(type: .peak, byte: 17, at: .max) + primary.offerVisualizerIfValid(value, validity: SessionValidityToken()) + #expect(mailbox.retainedByteCount == value.frameByteCount) for _ in 0 ..< 3 { parked.clearVisualizer() - #expect(mailbox.retainedByteCount == frame.frameByteCount) + #expect(mailbox.retainedByteCount == value.frameByteCount) } primary.clearVisualizer() @@ -317,12 +481,12 @@ struct VisualizerDataDeliveryTests { @Test("primary delivery clear releases retained bytes immediately") func primaryDeliveryClearsMailboxImmediately() { - let mailbox = VisualizerDataMailbox(capacityBytes: 64) + let mailbox = VisualizerFrameMailbox(capacityBytes: 64) let primary = makeConnectionDataDelivery(mailbox: mailbox) primary.promoteToPrimary() - let frame = VisualizerData(type: .peak, data: Data([17, 18]), localDisplayTime: .max) - primary.offerVisualizerIfValid(frame, validity: SessionValidityToken()) - #expect(mailbox.retainedByteCount == frame.frameByteCount) + let value = frame(type: .peak, byte: 18, at: .max) + primary.offerVisualizerIfValid(value, validity: SessionValidityToken()) + #expect(mailbox.retainedByteCount == value.frameByteCount) primary.clearVisualizer() @@ -330,25 +494,23 @@ struct VisualizerDataDeliveryTests { mailbox.finish() } - @Test("mailbox preserves FIFO order among retained frames") - func retainedFramesRemainFifo() async { - let mailbox = VisualizerDataMailbox(capacityBytes: 64) - let values = (0 ..< 4).map { index in - VisualizerData(type: .loudness, data: Data([UInt8(index)]), localDisplayTime: .max) - } - for value in values { - mailbox.offer(value, now: 0) - } - - var iterator = VisualizerDataStream(mailbox: mailbox).makeAsyncIterator() - for value in values { - #expect(await iterator.next() == value) - } - mailbox.finish() + private func frame( + type: VisualizerType, + byte: UInt8, + at microseconds: Int64, + validity: VisualizerFrameValidity = VisualizerFrameValidity() + ) -> VisualizerFrame { + VisualizerFrame( + type: type, + data: Data([byte]), + presentationTime: PresentationInstant(rawMicroseconds: microseconds), + configuration: configuration, + validity: validity + ) } } -private func makeConnectionDataDelivery(mailbox: VisualizerDataMailbox) -> ConnectionDataDelivery { +private func makeConnectionDataDelivery(mailbox: VisualizerFrameMailbox) -> ConnectionDataDelivery { let (_, audio) = AsyncStream.makeStream() let (_, artwork) = AsyncStream.makeStream() return ConnectionDataDelivery( @@ -359,6 +521,14 @@ private func makeConnectionDataDelivery(mailbox: VisualizerDataMailbox) -> Conne ) } +private func waitForSemaphore(_ semaphore: DispatchSemaphore) async -> Bool { + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + continuation.resume(returning: semaphore.wait(timeout: .now() + 1) == .success) + } + } +} + private final class ByteReleaseProbe: @unchecked Sendable { private let lock = NSLock() private var released = false @@ -372,37 +542,40 @@ private final class ByteReleaseProbe: @unchecked Sendable { } } -private func offerTrackedFrame(_ probe: ByteReleaseProbe, to mailbox: VisualizerDataMailbox, byte: UInt8) { +private func offerTrackedFrame(_ probe: ByteReleaseProbe, to mailbox: VisualizerFrameMailbox, byte: UInt8) { let pointer = UnsafeMutableRawPointer.allocate(byteCount: 1, alignment: 1) pointer.initializeMemory(as: UInt8.self, repeating: byte, count: 1) let data = Data(bytesNoCopy: pointer, count: 1, deallocator: .custom { pointer, _ in pointer.deallocate() probe.markReleased() }) - mailbox.offer(VisualizerData(type: .loudness, data: data, localDisplayTime: .max), now: 0) + let value = VisualizerFrame( + type: .loudness, + data: data, + presentationTime: PresentationInstant(rawMicroseconds: .max), + configuration: VisualizerStreamConfiguration(types: [.loudness], rateMax: 60) + ) + mailbox.offer(value, now: PresentationInstant(rawMicroseconds: 0)) } private final class MailboxTestClock: @unchecked Sendable { private let lock = NSLock() private var value: Int64 private var readCount = 0 - private let blockedRead: Int? - private let release = DispatchSemaphore(value: 0) - init(value: Int64 = 0, blockedRead: Int? = nil) { + init(value: Int64 = 0) { self.value = value - self.blockedRead = blockedRead } - func read() -> Int64 { - let shouldBlock = lock.withLock { + var now: PresentationInstant { + lock.withLock { readCount += 1 - return readCount == blockedRead - } - if shouldBlock { - release.wait() + return PresentationInstant(rawMicroseconds: value) } - return lock.withLock { value } + } + + var readCountSnapshot: Int { + lock.withLock { readCount } } func setValue(_ value: Int64) { @@ -418,8 +591,4 @@ private final class MailboxTestClock: @unchecked Sendable { } return false } - - func releaseBlockedRead() { - release.signal() - } } diff --git a/Tests/SendspinKitTests/Crypto/CryptoPrimitivesTests.swift b/Tests/SendspinKitTests/Crypto/CryptoPrimitivesTests.swift index 1923a0e..abfa326 100644 --- a/Tests/SendspinKitTests/Crypto/CryptoPrimitivesTests.swift +++ b/Tests/SendspinKitTests/Crypto/CryptoPrimitivesTests.swift @@ -157,6 +157,45 @@ struct PskCandidateTests { } } + @Test("Dynamic pairing reservations are atomic under concurrency and bounded") + func dynamicPairingReservationsAreAtomic() async throws { + let store = InMemoryPairingRecordStore() + let limit: UInt32 = 20 + let reservations = await withTaskGroup(of: DynamicPairingRoundReservation.self, returning: [DynamicPairingRoundReservation].self) { group in + for _ in 0 ..< 100 { + group.addTask { + await reserveRound(store, limit: limit) + } + } + var results: [DynamicPairingRoundReservation] = [] + for await result in group { + results.append(result) + } + return results + } + let rounds = reservations.compactMap { reservation -> UInt32? in + guard case let .reserved(round, _) = reservation else { return nil } + return round + } + #expect(rounds.count == Int(limit)) + #expect(Set(rounds).count == Int(limit)) + #expect(try await store.dynamicPairingRoundCount() == limit) + #expect(try await store.reserveDynamicPairingRound(limit: limit) == .exhausted) + try await store.resetDynamicPairingBudget() + #expect(try await store.reserveDynamicPairingRound(limit: limit) == .reserved(round: 1, remaining: limit - 1)) + } + + @Test("Dynamic pairing budget storage failures are thrown") + func dynamicPairingBudgetFailureIsNotSilenced() async throws { + let store = FailingDynamicBudgetStore() + await #expect(throws: PairingRecordStoreError.storageExhausted) { + try await store.reserveDynamicPairingRound(limit: dynamicPairingRoundLimit) + } + await #expect(throws: PairingRecordStoreError.storageExhausted) { + try await store.resetDynamicPairingBudget() + } + } + @Test("A matching PSK in the wrong declared category is a lookup miss") func wrongCategoryIsLookupMiss() { let record = Psk.generate() @@ -188,6 +227,41 @@ struct PskCandidateTests { } } +private func reserveRound( + _ store: InMemoryPairingRecordStore, + limit: UInt32 +) async -> DynamicPairingRoundReservation { + do { + return try await store.reserveDynamicPairingRound(limit: limit) + } catch { + return .exhausted + } +} + +private actor FailingDynamicBudgetStore: PairingRecordStore { + func listRecords() async -> [PairingRecord] { + [] + } + + func insert(_: PairingRecord) async throws {} + + func remove(pskId _: String) async {} + + func markUsed(pskId _: String) async {} + + func dynamicPairingRoundCount() async throws -> UInt32 { + throw PairingRecordStoreError.storageExhausted + } + + func reserveDynamicPairingRound(limit _: UInt32) async throws -> DynamicPairingRoundReservation { + throw PairingRecordStoreError.storageExhausted + } + + func resetDynamicPairingBudget() async throws { + throw PairingRecordStoreError.storageExhausted + } +} + @Suite("SendspinIdentity") struct SendspinIdentityTests { @Test("client_id is 43 base64url characters and stable across restore") diff --git a/Tests/SendspinKitTests/Helpers/EstablishedConnectionFactory.swift b/Tests/SendspinKitTests/Helpers/EstablishedConnectionFactory.swift index 3f866a0..9a2f219 100644 --- a/Tests/SendspinKitTests/Helpers/EstablishedConnectionFactory.swift +++ b/Tests/SendspinKitTests/Helpers/EstablishedConnectionFactory.swift @@ -31,7 +31,7 @@ func makeEstablishedConnection( engine: AudioEngine? = nil, audioSink: AsyncStream.Continuation = AsyncStream.makeStream().1, artworkSink: AsyncStream.Continuation = AsyncStream.makeStream().1, - visualizerSink: AsyncStream.Continuation = AsyncStream.makeStream().1, + visualizerSink: AsyncStream.Continuation = AsyncStream.makeStream().1, emitRawAudio: Bool = true, validity: SessionValidityToken = SessionValidityToken(), advertisedCommands: Set = [.setOutputDelay], diff --git a/Tests/SendspinKitTests/Integration/BinaryGateIntegrationTests.swift b/Tests/SendspinKitTests/Integration/BinaryGateIntegrationTests.swift index 341103d..e28de74 100644 --- a/Tests/SendspinKitTests/Integration/BinaryGateIntegrationTests.swift +++ b/Tests/SendspinKitTests/Integration/BinaryGateIntegrationTests.swift @@ -53,7 +53,7 @@ struct BinaryGateIntegrationTests { @Test("invalid visualizer configuration does not reject a valid player stream") func invalidVisualizerConfigurationContinuesPlayerHandling() async throws { let audio = AsyncStream.makeStream() - let visualizer = AsyncStream.makeStream() + let visualizer = AsyncStream.makeStream() let visualizerState = try VisualizerStateObject(types: [.loudness], rateMax: 30) let fixture = try await makeEstablishedConnection( activeRoles: [.playerV1, .visualizerV1], @@ -64,7 +64,7 @@ struct BinaryGateIntegrationTests { ) let audioEngine = fixture.connection.audioEngineForTesting let audioValues = BinaryGateValues() - let visualizerValues = BinaryGateValues() + let visualizerValues = BinaryGateValues() let audioConsumer = Task { for await value in audio.0 { await audioValues.append(value) @@ -111,14 +111,14 @@ struct BinaryGateIntegrationTests { @Test("visualizer binary requires the visualizer state send") func visualizerBinaryIsDroppedBeforeStateAndDeliveredAfter() async throws { - let visualizer = AsyncStream.makeStream() + let visualizer = AsyncStream.makeStream() let clock = StubClock() let visualizerState = try VisualizerStateObject(types: [.loudness], rateMax: 30) let fixture = try await makeEstablishedConnection( clock: clock, activeRoles: [.visualizerV1], visualizerSink: visualizer.1, roles: [.visualizerV1], initialVisualizerState: visualizerState ) - let values = BinaryGateValues() + let values = BinaryGateValues() let consumer = Task { for await value in visualizer.0 { await values.append(value) @@ -149,7 +149,7 @@ struct BinaryGateIntegrationTests { @Test("valid visualizer binary shapes reach the visualizer stream") func validVisualizerTypesDeliverTheirDocumentedPayloadShapes() async throws { - let visualizer = AsyncStream.makeStream() + let visualizer = AsyncStream.makeStream() let spectrum = SpectrumConfiguration(nDispBins: 2, scale: .lin, fMin: 20, fMax: 20_000) let visualizerState = try VisualizerStateObject( types: [.loudness, .beat, .fPeak, .spectrum, .peak], @@ -163,7 +163,7 @@ struct BinaryGateIntegrationTests { roles: [.visualizerV1], initialVisualizerState: visualizerState ) - let values = BinaryGateValues() + let values = BinaryGateValues() let consumer = Task { for await value in visualizer.0 { await values.append(value) @@ -208,7 +208,7 @@ struct BinaryGateIntegrationTests { @Test("malformed visualizer payloads are dropped before the public stream") func malformedVisualizerPayloadDoesNotCrossTheEmissionBarrier() async throws { - let visualizer = AsyncStream.makeStream() + let visualizer = AsyncStream.makeStream() let state = try VisualizerStateObject(types: [.loudness], rateMax: 30) let fixture = try await makeEstablishedConnection( clock: StubClock(), @@ -245,14 +245,14 @@ struct BinaryGateIntegrationTests { @Test("stale visualizer frames are dropped using their arrival instant") func staleVisualizerFrameIsDroppedAtArrival() async throws { - let visualizer = AsyncStream.makeStream() + let visualizer = AsyncStream.makeStream() let clock = StubClock() let visualizerState = try VisualizerStateObject(types: [.loudness], rateMax: 30) let fixture = try await makeEstablishedConnection( clock: clock, activeRoles: [.visualizerV1], visualizerSink: visualizer.1, roles: [.visualizerV1], initialVisualizerState: visualizerState ) - let values = BinaryGateValues() + let values = BinaryGateValues() let consumer = Task { for await value in visualizer.0 { await values.append(value) @@ -283,7 +283,7 @@ struct BinaryGateIntegrationTests { @Test("visualizer frame validity changes at clear and end boundaries") func visualizerFramesAreInvalidatedByClearAndEnd() async throws { - let visualizer = AsyncStream.makeStream() + let visualizer = AsyncStream.makeStream() let clock = StubClock() let state = try VisualizerStateObject(types: [.loudness], rateMax: 30) let fixture = try await makeEstablishedConnection( @@ -304,18 +304,19 @@ struct BinaryGateIntegrationTests { await fixture.connection.handleVisualizerBinary(frame, arrival: 1_000_000) let consumer = Task { await visualizer.0.first(where: { _ in true }) } let beforeClear = try #require(await consumer.value) - #expect(beforeClear.validity.isValid) - #expect(beforeClear.isRenderable(at: 1_000_000)) - #expect(beforeClear.isRenderable(at: 2_000_000) == false) + #expect(beforeClear.isValid) + #expect(beforeClear.isValid) + #expect(beforeClear.eligibilityForScheduling(at: PresentationInstant(rawMicroseconds: 1_000_000))) + #expect(beforeClear.eligibilityForScheduling(at: PresentationInstant(rawMicroseconds: 2_000_000)) == false) await fixture.connection.handleStreamClear(StreamClearMessage(payload: StreamClearPayload(roles: ["visualizer"]))) - #expect(beforeClear.validity.isValid == false) + #expect(beforeClear.isValid == false) await fixture.connection.handleVisualizerBinary(frame, arrival: 1_000_000) let afterClear = try #require(await visualizer.0.first(where: { _ in true })) - #expect(afterClear.validity.isValid) + #expect(afterClear.isValid) await fixture.connection.handleStreamEnd(StreamEndMessage(payload: StreamEndPayload(roles: ["visualizer"]))) - #expect(afterClear.validity.isValid == false) + #expect(afterClear.isValid == false) await fixture.connection.handleStreamStart(StreamStartMessage(payload: StreamStartPayload( player: nil, artwork: nil, visualizer: StreamStartVisualizer() @@ -324,7 +325,7 @@ struct BinaryGateIntegrationTests { await fixture.connection.handleVisualizerBinary(frame, arrival: 1_000_000) let afterEnd = try #require(await visualizer.0.first(where: { _ in true })) await fixture.connection.shutdown() - #expect(afterEnd.validity.isValid == false) + #expect(afterEnd.isValid == false) } @Test("role-changing activation resets the player binary gate") diff --git a/Tests/SendspinKitTests/Integration/FrameOrderingTests.swift b/Tests/SendspinKitTests/Integration/FrameOrderingTests.swift index 513a65a..09d8ce7 100644 --- a/Tests/SendspinKitTests/Integration/FrameOrderingTests.swift +++ b/Tests/SendspinKitTests/Integration/FrameOrderingTests.swift @@ -413,10 +413,11 @@ struct FrameOrderingTests { let client = try makePlayerClient(roles: [.playerV1, .visualizerV1], visualizerConfig: visualizerConfig) let mock = try await connectClient(client, activeRoles: [.playerV1, .visualizerV1]) - let visualizerData = CollectedValues() + let visualizerData = CollectedValues() + let visualizerSubscription = try client.acquireVisualizerFrames() let collectTask = Task { - for await payload in client.visualizerData { + for await payload in visualizerSubscription { await visualizerData.append(payload) } } @@ -445,7 +446,7 @@ struct FrameOrderingTests { collectTask.cancel() let payload = try #require(await visualizerData.all.first) - #expect(payload.localDisplayTime > 0, "Synced visualizer payload should carry a local display deadline") + #expect(payload.presentationTime.rawMicroseconds > 0, "Synced visualizer payload should carry a local display deadline") await client.disconnect() } diff --git a/Tests/SendspinKitTests/Integration/ProtocolBoundaryTests.swift b/Tests/SendspinKitTests/Integration/ProtocolBoundaryTests.swift index 05df8d5..421d8d2 100644 --- a/Tests/SendspinKitTests/Integration/ProtocolBoundaryTests.swift +++ b/Tests/SendspinKitTests/Integration/ProtocolBoundaryTests.swift @@ -131,8 +131,10 @@ struct ProtocolBoundaryTests { ) let server = fixture.server let connection = fixture.connection + #expect(await waitUntil { await connection.clockSyncTask != nil }, "clock-sync task handle must appear before cancel") await connection.clockSyncTask?.cancel() await connection.clockSyncTask?.value + #expect(await waitUntil { await !connection.outboundInFlight }, "initial clock samples must drain") try await server.sendActivation( activities: [.playback], diff --git a/Tests/SendspinKitTests/PublicSurfaceTests.swift b/Tests/SendspinKitTests/PublicSurfaceTests.swift index 6e3561e..7b34908 100644 --- a/Tests/SendspinKitTests/PublicSurfaceTests.swift +++ b/Tests/SendspinKitTests/PublicSurfaceTests.swift @@ -1,4 +1,4 @@ -import SendspinKit +@testable import SendspinKit import Testing @Suite("Public pairing surface") @@ -24,7 +24,7 @@ struct PublicSurfaceTests { pairing: PairingConfiguration(pairingPsk: pairingPsk) ) do { - try await client.openPairingWindow() + try await client.openPairingWindow(for: PairingAttemptID()) } catch SendspinClientError.notConnected { // The API remains callable before a transport is connected. } diff --git a/docs/VISUALIZER_DELIVERY.md b/docs/VISUALIZER_DELIVERY.md index a352402..d23f653 100644 --- a/docs/VISUALIZER_DELIVERY.md +++ b/docs/VISUALIZER_DELIVERY.md @@ -1,7 +1,8 @@ # Visualizer delivery -`SendspinClient.visualizerData` is a bounded `VisualizerDataStream`, not an unbounded -`AsyncStream`. Its mailbox counts each retained frame as the visualizer wire size: +`SendspinClient.acquireVisualizerFrames()` returns the single bounded +`VisualizerFrameSubscription`; it is not an unbounded `AsyncStream`. Its mailbox counts each retained +frame as the visualizer wire size: 9 bytes for the type and timestamp plus the payload bytes. A frame larger than the configured `VisualizerConfiguration.bufferCapacity` is dropped. When a frame would exceed the remaining budget, the oldest retained frames are dropped first; this keeps @@ -21,3 +22,19 @@ The message loop offers frames non-blockingly; it never waits for the public con The negotiated `rateMax` remains one scalar for all periodic types (`loudness`, `f_peak`, and `spectrum`). `beat` and `peak` remain event-driven and are not throttled by that scalar. + +## Display scheduling + +`VisualizerFrame.presentationTime` is a `PresentationInstant` in the monotonic domain shared by +`PresentationClock`. Never translate it through wall-clock `Date` or schedule a draw from arrival +order. A consumer may use `eligibilityForScheduling(at:)` while a frame is still in the future and +`PresentationClock.sleep(until:)` to avoid drawing early. At the display callback, it should capture +one fresh clock instant and perform the final `isValid` generation check immediately before +submitting pixels. `isValid` is intentionally independent of the presentation deadline: a due frame +can remain valid, while `eligibilityForScheduling(at:)` is only the pre-deadline scheduling gate. + +`Examples/VisualizerClient` demonstrates a bounded consumer: one FIFO task awaits each frame's +presentation deadline, then a per-type latest-due mailbox feeds an AppKit CoreVideo display-link +tick. A display-link submission is not a guarantee of screen-photon timing or exact refresh +synchronization, because this example does not claim a display-link-to-presentation-clock mapping. +Do not convert instants through wall time or retain an unbounded app-side queue. From 3faaec2358c2187a728e5e54d58fcd3acd621925 Mon Sep 17 00:00:00 2001 From: David Bishop Date: Thu, 10 Sep 2026 14:51:42 -0700 Subject: [PATCH 3/3] Fix flaky tests --- Sources/SendspinKit/Audio/AudioEngine.swift | 9 +- .../Client/VisualizerDataDelivery.swift | 12 +- .../Audio/AudioEngineTests.swift | 19 +- .../Audio/AudioStartupReleaseTests.swift | 22 ++- .../DynamicPairingTranscriptTests.swift | 2 +- .../Client/VisualizerDataDeliveryTests.swift | 167 +++++++++++------- 6 files changed, 144 insertions(+), 87 deletions(-) diff --git a/Sources/SendspinKit/Audio/AudioEngine.swift b/Sources/SendspinKit/Audio/AudioEngine.swift index cfd9f40..da2e351 100644 --- a/Sources/SendspinKit/Audio/AudioEngine.swift +++ b/Sources/SendspinKit/Audio/AudioEngine.swift @@ -199,6 +199,8 @@ actor AudioEngine { private var startupReleaseInvocation: UInt64 = 0 private var startupReleaseInProgress = false private var outputHasStarted = false + /// Absolute time source for startup selection; injectable only through the internal test init. + private let startupNow: @Sendable () -> Int64 private let engineID = UUID().uuidString private struct StartupBuffer { @@ -355,11 +357,13 @@ actor AudioEngine { scheduler: AudioScheduler, clock: any ClockSyncProtocol, enableStartupBuffering: Bool = false, - startupMinBufferMs: Int = 0 + startupMinBufferMs: Int = 0, + startupNow: @escaping @Sendable () -> Int64 = { MonotonicClock.absoluteMicroseconds() } ) { self.output = output audioScheduler = scheduler self.clock = clock + self.startupNow = startupNow let sink = DataPlaneSink() _commandsSink = sink _commandStream = sink.commands @@ -394,6 +398,7 @@ actor AudioEngine { output = audioPlayer self.audioScheduler = audioScheduler self.clock = clock + startupNow = { MonotonicClock.absoluteMicroseconds() } let sink = DataPlaneSink() _commandsSink = sink _commandStream = sink.commands @@ -970,7 +975,7 @@ actor AudioEngine { return } - let nowUs = MonotonicClock.absoluteMicroseconds() + let nowUs = startupNow() let playTimes = buffer.chunks.map(\.playTimeMicroseconds) let candidate = Self.releaseSelection( playTimes: playTimes, diff --git a/Sources/SendspinKit/Client/VisualizerDataDelivery.swift b/Sources/SendspinKit/Client/VisualizerDataDelivery.swift index 40034cf..5b4b5eb 100644 --- a/Sources/SendspinKit/Client/VisualizerDataDelivery.swift +++ b/Sources/SendspinKit/Client/VisualizerDataDelivery.swift @@ -137,8 +137,8 @@ final class VisualizerFrameMailbox: @unchecked Sendable { private let lock = NSLock() private let capacityBytes: Int private let now: @Sendable () -> PresentationInstant - private let beforePark: (@Sendable () -> Void)? - private let beforePostHandoffCheck: (@Sendable () -> Void)? + private let beforePark: (@Sendable () async -> Void)? + private let beforePostHandoffCheck: (@Sendable () async -> Void)? private var queueHead: QueueNode? private var queueTail: QueueNode? private var queuedBytes = 0 @@ -171,8 +171,8 @@ final class VisualizerFrameMailbox: @unchecked Sendable { init( capacityBytes: Int, now: @escaping @Sendable () -> PresentationInstant = { .now }, - beforePark: (@Sendable () -> Void)? = nil, - beforePostHandoffCheck: (@Sendable () -> Void)? = nil + beforePark: (@Sendable () async -> Void)? = nil, + beforePostHandoffCheck: (@Sendable () async -> Void)? = nil ) { precondition(capacityBytes > 0) self.capacityBytes = capacityBytes @@ -261,7 +261,7 @@ final class VisualizerFrameMailbox: @unchecked Sendable { break } - beforePark?() + await beforePark?() let result = await withTaskCancellationHandler { await withCheckedContinuation { (continuation: CheckedContinuation) in park(owner: iterator, continuation: continuation) @@ -269,7 +269,7 @@ final class VisualizerFrameMailbox: @unchecked Sendable { } onCancel: { cancel(lease: iterator.lease) } - beforePostHandoffCheck?() + await beforePostHandoffCheck?() switch result { case let .value(value): // An explicitly canceled lease drops a frame already handed to its continuation. diff --git a/Tests/SendspinKitTests/Audio/AudioEngineTests.swift b/Tests/SendspinKitTests/Audio/AudioEngineTests.swift index 6c91bdb..9e5a3b4 100644 --- a/Tests/SendspinKitTests/Audio/AudioEngineTests.swift +++ b/Tests/SendspinKitTests/Audio/AudioEngineTests.swift @@ -9,15 +9,20 @@ actor StubClock: ClockSyncProtocol { private var synchronized = true private let offset: Int64 // offset = server - client private let anchorToNow: Bool + private let absoluteAnchorMicroseconds: Int64? /// - Parameter anchorToNow: when true, `serverTimeToLocal` maps a (small) server - /// timestamp to `MonotonicClock.absoluteMicroseconds() + serverTime`, so a chunk - /// scheduled with a near-zero/near-future `ts` lands inside the scheduler's - /// playback window and is actually emitted to `scheduledChunks` (rather than - /// dropped-late). Required to drive `runSchedulerOutput`'s rebuild path. - init(offsetMicroseconds: Int64 = 0, anchorToNow: Bool = false) { + /// timestamp to the current monotonic instant plus `serverTime`. + /// - Parameter absoluteAnchorMicroseconds: optional fixed absolute instant for tests that + /// need server-to-local conversion without elapsed wall-clock time. + init( + offsetMicroseconds: Int64 = 0, + anchorToNow: Bool = false, + absoluteAnchorMicroseconds: Int64? = nil + ) { offset = offsetMicroseconds self.anchorToNow = anchorToNow + self.absoluteAnchorMicroseconds = absoluteAnchorMicroseconds } var hasSynced: Bool { @@ -33,7 +38,7 @@ actor StubClock: ClockSyncProtocol { func serverTimeToLocal(_ serverTime: Int64) -> Int64 { if anchorToNow { - return MonotonicClock.absoluteMicroseconds() + serverTime + return (absoluteAnchorMicroseconds ?? MonotonicClock.absoluteMicroseconds()) + serverTime } // Stub: local = server - offset return serverTime - offset @@ -41,7 +46,7 @@ actor StubClock: ClockSyncProtocol { func localTimeToServer(_ localTime: Int64) -> Int64 { if anchorToNow { - return localTime - MonotonicClock.absoluteMicroseconds() + return localTime - (absoluteAnchorMicroseconds ?? MonotonicClock.absoluteMicroseconds()) } // Stub: server = local + offset (inverse of serverTimeToLocal) return localTime + offset diff --git a/Tests/SendspinKitTests/Audio/AudioStartupReleaseTests.swift b/Tests/SendspinKitTests/Audio/AudioStartupReleaseTests.swift index 481a956..852083c 100644 --- a/Tests/SendspinKitTests/Audio/AudioStartupReleaseTests.swift +++ b/Tests/SendspinKitTests/Audio/AudioStartupReleaseTests.swift @@ -443,7 +443,8 @@ struct AudioStartupReleaseTests { @Test("chunks arriving during PCM priming are scheduled after the startup commit") func chunksDuringPCMPrimingAreDeferredUntilAfterCommit() async throws { - let clock = StubClock(anchorToNow: true) + let startupNow = MonotonicClock.absoluteMicroseconds() + let clock = StubClock(anchorToNow: true, absoluteAnchorMicroseconds: startupNow) let output = SpyAudioOutput() let scheduler = AudioScheduler(clockSync: clock) let engine = AudioEngine( @@ -451,20 +452,33 @@ struct AudioStartupReleaseTests { scheduler: scheduler, clock: clock, enableStartupBuffering: true, - startupMinBufferMs: 200 + startupMinBufferMs: 200, + startupNow: { startupNow } ) let format = try AudioFormatSpec(codec: .pcm, channels: 2, sampleRate: 48_000, bitDepth: 16) - let firstTimestamp: Int64 = 1_000_000 - let deferredTimestamp: Int64 = 1_100_000 + let firstTimestamp: Int64 = 0 + let deferredTimestamp: Int64 = 300_000 await engine.start() await engine.commands.enqueue(.streamStart(format, codecHeader: nil)) await output.blockNextPCM() + defer { + // Release the non-cancellable spy continuation before fallback shutdown, even when + // an expectation fails while the startup coordinator is parked in PCM priming. + Task { + await output.releaseBlockedPCM() + await engine.shutdown() + } + } await engine.commands.enqueue(.chunk(Data(repeating: 0x01, count: 100), ts: firstTimestamp)) #expect(await waitUntil { await output.playedPCMTimestamps.contains(firstTimestamp) }) await engine.commands.enqueue(.chunk(Data(repeating: 0x02, count: 100), ts: deferredTimestamp)) #expect(await waitUntil { await engine.appliedCommandKinds().count(where: { $0 == .chunk }) == 2 }) #expect(await !output.recordedCalls.contains("startPrepared()")) + #expect( + await scheduler.stats.received == 0, + "the deferred chunk must not reach the scheduler before startup commit" + ) await output.releaseBlockedPCM() #expect(await waitUntil(timeout: .seconds(3)) { await output.recordedCalls.contains("startPrepared()") }) diff --git a/Tests/SendspinKitTests/Client/DynamicPairingTranscriptTests.swift b/Tests/SendspinKitTests/Client/DynamicPairingTranscriptTests.swift index 34c3fd9..53ec04f 100644 --- a/Tests/SendspinKitTests/Client/DynamicPairingTranscriptTests.swift +++ b/Tests/SendspinKitTests/Client/DynamicPairingTranscriptTests.swift @@ -374,7 +374,7 @@ struct DynamicPairingTranscriptTests { ) let windowEventsTask = Task { () -> [ClientEvent] in var events = [ClientEvent]() - for await event in session.events { + for await event in session.client.events() { if case .pairingWindowChanged = event { events.append(event) if events.count == 2 { diff --git a/Tests/SendspinKitTests/Client/VisualizerDataDeliveryTests.swift b/Tests/SendspinKitTests/Client/VisualizerDataDeliveryTests.swift index 991ac6f..13d5f0f 100644 --- a/Tests/SendspinKitTests/Client/VisualizerDataDeliveryTests.swift +++ b/Tests/SendspinKitTests/Client/VisualizerDataDeliveryTests.swift @@ -82,13 +82,13 @@ struct VisualizerFrameDeliveryTests { @Test("explicit cancellation between the immediate check and park is a barrier") func explicitCancellationImmediateCheckToParkBarrier() async throws { - let reachedParkBarrier = DispatchSemaphore(value: 0) - let releaseParkBarrier = DispatchSemaphore(value: 0) + let reachedParkBarrier = AsyncTestBarrier() + let releaseParkBarrier = AsyncTestBarrier() let mailbox = VisualizerFrameMailbox( capacityBytes: 64, beforePark: { - reachedParkBarrier.signal() - _ = releaseParkBarrier.wait(timeout: .now() + 1) + await reachedParkBarrier.signalReached() + await releaseParkBarrier.waitUntilReleased() } ) let first = try VisualizerFrameSubscription(acquiring: mailbox) @@ -99,18 +99,18 @@ struct VisualizerFrameDeliveryTests { defer { first.cancel() - releaseParkBarrier.signal() + Task { await releaseParkBarrier.release() } mailbox.finish() } - try #require(await waitForSemaphore(reachedParkBarrier)) + try #require(await reachedParkBarrier.waitUntilReached()) first.cancel() - releaseParkBarrier.signal() + await releaseParkBarrier.release() let observation = await observeTask( pending, timeout: .seconds(1), onTimeout: { mailbox.finish() - releaseParkBarrier.signal() + await releaseParkBarrier.release() } ) guard case let .completed(value) = observation else { @@ -129,19 +129,19 @@ struct VisualizerFrameDeliveryTests { @Test("explicit cancellation after handoff drops the old frame") func explicitCancellationAfterHandoff() async throws { - let reachedParkBarrier = DispatchSemaphore(value: 0) - let releaseParkBarrier = DispatchSemaphore(value: 0) - let reachedHandoffBarrier = DispatchSemaphore(value: 0) - let releaseHandoffBarrier = DispatchSemaphore(value: 0) + let reachedParkBarrier = AsyncTestBarrier() + let releaseParkBarrier = AsyncTestBarrier() + let reachedHandoffBarrier = AsyncTestBarrier() + let releaseHandoffBarrier = AsyncTestBarrier() let mailbox = VisualizerFrameMailbox( capacityBytes: 64, beforePark: { - reachedParkBarrier.signal() - _ = releaseParkBarrier.wait(timeout: .now() + 1) + await reachedParkBarrier.signalReached() + await releaseParkBarrier.waitUntilReleased() }, beforePostHandoffCheck: { - reachedHandoffBarrier.signal() - _ = releaseHandoffBarrier.wait(timeout: .now() + 1) + await reachedHandoffBarrier.signalReached() + await releaseHandoffBarrier.waitUntilReleased() } ) let first = try VisualizerFrameSubscription(acquiring: mailbox) @@ -151,22 +151,24 @@ struct VisualizerFrameDeliveryTests { } defer { first.cancel() - releaseParkBarrier.signal() - releaseHandoffBarrier.signal() + Task { + await releaseParkBarrier.release() + await releaseHandoffBarrier.release() + } mailbox.finish() } - try #require(await waitForSemaphore(reachedParkBarrier)) - releaseParkBarrier.signal() + try #require(await reachedParkBarrier.waitUntilReached()) + await releaseParkBarrier.release() mailbox.offer(frame(type: .peak, byte: 21, at: .max), now: PresentationInstant(rawMicroseconds: 0)) - try #require(await waitForSemaphore(reachedHandoffBarrier)) + try #require(await reachedHandoffBarrier.waitUntilReached()) first.cancel() - releaseHandoffBarrier.signal() + await releaseHandoffBarrier.release() let observation = await observeTask( pending, timeout: .seconds(1), onTimeout: { mailbox.finish() - releaseHandoffBarrier.signal() + await releaseHandoffBarrier.release() } ) guard case let .completed(value) = observation else { @@ -174,25 +176,25 @@ struct VisualizerFrameDeliveryTests { return } #expect(value == nil) - releaseHandoffBarrier.signal() + await releaseHandoffBarrier.release() mailbox.finish() } @Test("task cancellation after handoff drops the old frame") func taskCancellationAfterHandoff() async throws { - let reachedParkBarrier = DispatchSemaphore(value: 0) - let releaseParkBarrier = DispatchSemaphore(value: 0) - let reachedHandoffBarrier = DispatchSemaphore(value: 0) - let releaseHandoffBarrier = DispatchSemaphore(value: 0) + let reachedParkBarrier = AsyncTestBarrier() + let releaseParkBarrier = AsyncTestBarrier() + let reachedHandoffBarrier = AsyncTestBarrier() + let releaseHandoffBarrier = AsyncTestBarrier() let mailbox = VisualizerFrameMailbox( capacityBytes: 64, beforePark: { - reachedParkBarrier.signal() - _ = releaseParkBarrier.wait(timeout: .now() + 1) + await reachedParkBarrier.signalReached() + await releaseParkBarrier.waitUntilReleased() }, beforePostHandoffCheck: { - reachedHandoffBarrier.signal() - _ = releaseHandoffBarrier.wait(timeout: .now() + 1) + await reachedHandoffBarrier.signalReached() + await releaseHandoffBarrier.waitUntilReleased() } ) let subscription = try VisualizerFrameSubscription(acquiring: mailbox) @@ -202,23 +204,25 @@ struct VisualizerFrameDeliveryTests { } defer { pending.cancel() - releaseParkBarrier.signal() - releaseHandoffBarrier.signal() + Task { + await releaseParkBarrier.release() + await releaseHandoffBarrier.release() + } mailbox.finish() } - try #require(await waitForSemaphore(reachedParkBarrier)) - releaseParkBarrier.signal() + try #require(await reachedParkBarrier.waitUntilReached()) + await releaseParkBarrier.release() mailbox.offer(frame(type: .peak, byte: 25, at: .max), now: PresentationInstant(rawMicroseconds: 0)) - try #require(await waitForSemaphore(reachedHandoffBarrier)) + try #require(await reachedHandoffBarrier.waitUntilReached()) pending.cancel() - releaseHandoffBarrier.signal() + await releaseHandoffBarrier.release() let observation = await observeTask( pending, timeout: .seconds(1), onTimeout: { mailbox.finish() - releaseHandoffBarrier.signal() + await releaseHandoffBarrier.release() } ) guard case let .completed(value) = observation else { @@ -228,21 +232,21 @@ struct VisualizerFrameDeliveryTests { #expect(value == nil) subscription.cancel() - releaseHandoffBarrier.signal() + await releaseHandoffBarrier.release() mailbox.finish() } @Test("copied iterators share one parked read and preserve the original") func copiedIteratorsShareOneInFlightRead() async throws { let clock = MailboxTestClock() - let reachedPark = DispatchSemaphore(value: 0) - let releasePark = DispatchSemaphore(value: 0) + let reachedPark = AsyncTestBarrier() + let releasePark = AsyncTestBarrier() let mailbox = VisualizerFrameMailbox( capacityBytes: 64, now: { clock.now }, beforePark: { - reachedPark.signal() - _ = releasePark.wait(timeout: .now() + 1) + await reachedPark.signalReached() + await releasePark.waitUntilReleased() } ) let subscription = try VisualizerFrameSubscription(acquiring: mailbox) @@ -253,16 +257,16 @@ struct VisualizerFrameDeliveryTests { return await iterator.next() } - try #require(await waitForSemaphore(reachedPark)) + try #require(await reachedPark.waitUntilReached()) defer { pending.cancel() - releasePark.signal() + Task { await releasePark.release() } mailbox.finish() } let parkedReadCount = clock.readCountSnapshot #expect(await copy.next() == nil) #expect(clock.readCountSnapshot == parkedReadCount) - releasePark.signal() + await releasePark.release() let value = frame(type: .peak, byte: 23, at: .max) mailbox.offer(value, now: PresentationInstant(rawMicroseconds: 0)) @@ -271,7 +275,7 @@ struct VisualizerFrameDeliveryTests { timeout: .seconds(1), onTimeout: { mailbox.finish() - releasePark.signal() + await releasePark.release() } ) guard case let .completed(result) = observation else { @@ -322,42 +326,44 @@ struct VisualizerFrameDeliveryTests { func invalidationAfterWakeDropsFrame() async throws { let validity = VisualizerFrameValidity() let clock = MailboxTestClock() - let reachedParkBarrier = DispatchSemaphore(value: 0) - let releaseParkBarrier = DispatchSemaphore(value: 0) - let reachedHandoffBarrier = DispatchSemaphore(value: 0) - let releaseHandoffBarrier = DispatchSemaphore(value: 0) + let reachedParkBarrier = AsyncTestBarrier() + let releaseParkBarrier = AsyncTestBarrier() + let reachedHandoffBarrier = AsyncTestBarrier() + let releaseHandoffBarrier = AsyncTestBarrier() let mailbox = VisualizerFrameMailbox( capacityBytes: 64, now: { clock.now }, beforePark: { - reachedParkBarrier.signal() - _ = releaseParkBarrier.wait(timeout: .now() + 1) + await reachedParkBarrier.signalReached() + await releaseParkBarrier.waitUntilReleased() }, beforePostHandoffCheck: { - reachedHandoffBarrier.signal() - _ = releaseHandoffBarrier.wait(timeout: .now() + 1) + await reachedHandoffBarrier.signalReached() + await releaseHandoffBarrier.waitUntilReleased() } ) let subscription = try VisualizerFrameSubscription(acquiring: mailbox) let pending = Task { var iterator = subscription.makeAsyncIterator(); return await iterator.next() } defer { - releaseParkBarrier.signal() - releaseHandoffBarrier.signal() + Task { + await releaseParkBarrier.release() + await releaseHandoffBarrier.release() + } mailbox.finish() } - try #require(await waitForSemaphore(reachedParkBarrier)) - releaseParkBarrier.signal() + try #require(await reachedParkBarrier.waitUntilReached()) + await releaseParkBarrier.release() mailbox.offer(frame(type: .beat, byte: 4, at: .max, validity: validity), now: PresentationInstant(rawMicroseconds: 0)) - try #require(await waitForSemaphore(reachedHandoffBarrier)) + try #require(await reachedHandoffBarrier.waitUntilReached()) validity.invalidate() - releaseHandoffBarrier.signal() + await releaseHandoffBarrier.release() mailbox.finish() let observation = await observeTask( pending, timeout: .seconds(1), onTimeout: { mailbox.finish() - releaseHandoffBarrier.signal() + await releaseHandoffBarrier.release() } ) guard case let .completed(value) = observation else { @@ -521,10 +527,37 @@ private func makeConnectionDataDelivery(mailbox: VisualizerFrameMailbox) -> Conn ) } -private func waitForSemaphore(_ semaphore: DispatchSemaphore) async -> Bool { - await withCheckedContinuation { continuation in - DispatchQueue.global().async { - continuation.resume(returning: semaphore.wait(timeout: .now() + 1) == .success) +private actor AsyncTestBarrier { + private var reached = false + private var released = false + private var releaseWaiters: [CheckedContinuation] = [] + + func signalReached() { + reached = true + } + + func waitUntilReached(timeout: Duration = .seconds(2)) async -> Bool { + await waitUntil(timeout: timeout) { await self.isReached } + } + + private var isReached: Bool { + reached + } + + func release() { + guard !released else { return } + released = true + let waiters = releaseWaiters + releaseWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume() + } + } + + func waitUntilReleased() async { + guard !released else { return } + await withCheckedContinuation { continuation in + releaseWaiters.append(continuation) } } }