Split SendspinKit control and data planes - #35
Merged
Conversation
Split the protocol client into a thin @mainactor facade and an off-MainActor connection actor, so all heavy audio and control work runs off the main actor while @observable state still updates on the main actor for SwiftUI. Architecture (one-way dependency: facade -> connection -> engine): - SendspinClient (@mainactor @observable): thin facade holding public API, observable state, and the public events stream. Owns nothing audio-related. - SendspinConnection (actor): owns the transport, ordered message loop, protocol-intent gates, clock sync, and the AudioEngine. Holds no facade reference and imports nothing @MainActor-isolated (proven at compile time). Single writer of session state. - AudioEngine (actor): owns decode/schedule/output/sync-telemetry and the seamless-format state machine. No @mainactor anywhere. Control plane: connection emits ConnectionEvent on a control AsyncStream; the facade drains it on MainActor, applies @observable state, re-emits public ClientEvent (single public emission point). Data plane: audio/artwork/visualizer bytes bypass the facade and are yielded directly to the public continuation off-main via SessionValidityToken, an atomic check-and-yield guard for stale binary events from retired connections. Lifetime is managed by owned objects, not generation counters: a supervisor task, run-once teardown, an identity guard, and SessionValidityToken. Reconnect builds a new connection+engine+token; shutdown() invalidates the old token so its in-flight binary events are silently dropped. Transport hardening: - NWWebSocketTransport gains outbound client-initiated dial (ws:// / wss://) in addition to inbound-accepted connections. - Per-send deadline via nonisolated timedSend racing NWConnection.send against a timeout; first contender wins, losers are no-ops. sendTimeout is init-injectable (5s default) for testability. - New SendCompletion type coordinates the race under OSAllocatedUnfairLock, resuming the continuation outside the critical section (reentrancy-safe). - New TransportError.sendTimedOut distinguishes a wedged connection. - FrameInbox for ordered inbound frame handling, made lifecycle-finished. - WatermarkedSink onWarning now captured under lock and invoked after release (reentrancy-safe). Models: - Splits the monolithic SendspinMessage into per-domain files (Hello, ClientState, ServerState, Stream, Time, Command, Goodbye). - Adds static typeString to every concrete message with let type derivation, preserving synthesized Codable. Connection-owned audio state: - Tightens audio state ownership to the connection/engine; seamless-format classification is synchronous via announcedPlayerFormat. EngineReport carries full target operational state (bidirectional into/out of .error/.synchronized). Test infrastructure and coverage: - New TestBox (generic isolation-safe single-value box) and AsyncTestDeadline helpers (runUnstructuredWithDeadline, observeTask, waitUntil) replacing ad-hoc timeout scaffolding. - New SendCompletionTests, FrameInboxTests, DataPlaneSinkTests, SessionRetireTests, ControlEventSinkTests. - Expanded integration coverage for stream ordering, transport failure paths, connection teardown/retirement, data-plane sequencing, optimistic controller state reconciliation, and hardware-volume startup safety. - LoopbackWebSocketServer bounded-start helper; MockTransport and ServerMessageHelpers conveniences.
teancom
force-pushed
the
control-data-plane-split
branch
from
June 14, 2026 17:24
37ac35b to
b4e7e0d
Compare
Test Coverage ReportLine Coverage: 91.01% · Function Coverage: 88.37% Detailed Coverage Report |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Split the protocol client into a thin @mainactor facade and an off-MainActor connection actor, so all heavy audio and control work runs off the main actor while @observable state still updates on the main actor for SwiftUI.
Architecture (one-way dependency: facade -> connection -> engine):
Control plane: connection emits ConnectionEvent on a control AsyncStream; the facade drains it on MainActor, applies @observable state, re-emits public ClientEvent (single public emission point).
Data plane: audio/artwork/visualizer bytes bypass the facade and are yielded directly to the public continuation off-main via SessionValidityToken, an atomic check-and-yield guard for stale binary events from retired connections.
Lifetime is managed by owned objects, not generation counters: a supervisor task, run-once teardown, an identity guard, and SessionValidityToken. Reconnect builds a new connection+engine+token; shutdown() invalidates the old token so its in-flight binary events are silently dropped.
Transport hardening:
Models:
Connection-owned audio state:
Test infrastructure and coverage: