Room dependency stages - #1097
Conversation
…yped plane Room's per-connection and per-join subsystems were spread across three conventions: the optional State.transport field, the _dataTracks side-slot, and setup/teardown extensions ordered by comments and an isFullReconnect flag. They now live in stage payloads inside Room.State: idle -> connecting(ConnectionDependencies) -> connected(JoinDependencies) - ConnectionDependencies (data tracks) is created by connect() and carried across a full reconnect; JoinDependencies (transports, their channels) is built from a JOIN response by one factory and retired on teardown. A join is constructible only from its connection, so cross-tier construction order is compiler-checked, and stage-gated storage replaces the optional fields: transports exist iff the stage is .connected. - Payloads are staged and retired only through the stage transitions, which return the retired payload for teardown. Because the stage lives beside the data fields in the one StateSync, a transition and its state reset are a single atomic mutation. - configureTransports' silent "transports are already configured" no-op becomes a failed transition: a duplicate JOIN now throws instead of being swallowed. Read sites are unchanged (State.transport and Room.dataTracks forward into the stage). Verified with RoomTests, RoomStateTests, DataTrackLifecycleTests, and the new DependencyStageTests against a local server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The data channel pairs' E2EE wiring had three connection-boundary leaks: - connect()'s legacy e2eeOptions branch never installed the manager on the pairs, so a frame-only encrypted room could not decrypt incoming packets from participants publishing with data-channel encryption enabled — and, worse, the pairs silently kept whatever manager a previous connection had installed. The manager is now installed uniformly in all three options branches; sending still consults isDataChannelEncryptionEnabled, which stays false for legacy options. - Nothing cleared the pairs' manager reference on disconnect; a reused Room inherited the released manager of the previous connection. cleanUp now detaches it at connection end (kept across a full reconnect, where the manager survives by design). - The negotiated max-message-size survived reset() into the next session, applying a stale ceiling until the new SDP answer arrived. reset() restores the default. Regression-tested by EncryptedDataChannelTests/encryptionSurvivesFullReconnect (encrypted send after a full reconnect) and DataChannelPairTests/resetRestoresDefaultMaxMessageSize. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ConnectionDependencies now derives the manager from the room options and owns it for the connection's lifetime; the public Room.e2eeManager facade (declaration unchanged) reads and writes through the stage, so it is nil before connect and after disconnect, and the setter takes effect only while a connection exists. - Retirement is structural: stage.end() dropping the connection payload replaces the engine-delegate branch that nil'd the manager on .disconnected — a branch that also mutated a second lock from inside Room's state callback. - Connection teardown lives on the retired payload (ConnectionDependencies.tearDown), called from cleanUp at the same point the manager cleanup ran before, after participant cleanup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EncryptedDataChannelTests exceeded SwiftLint's type_body_length after gaining encryptionSurvivesFullReconnect; the test moves verbatim into an extension, matching the file's existing RoomDelegate extension. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Planned follow-ups, in rough dependency order (each is its own PR; 1–2 are independent of each other):
|
Resolves the DataChannelPair.reset conflict with #1097: the negotiated max-message-size now lives in the drains, so the per-session re-default is ported as set(maxMessageSize: defaultMaxMessageSize) after the drains reset — same event stream, so it is ordered after the .fail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Part of CLT-3228 (ad-hoc DI in
Room) — the smallest slice that buys static safety. No public API change.Room's per-connection and per-join subsystems lived in an optionalState.transport, nullable side-slots (_e2eeManager,_dataTracks), and reset lists ordered by comments andisFullReconnect:flags. They are now one dependency stage insideRoom.State:stateDiagram-v2 idle --> connecting: connect() stages ConnectionDependencies (data tracks, E2EE manager) connecting --> connected: JOIN builds JoinDependencies (transports + channels) connected --> connecting: full reconnect retires the join, keeps the connection connecting --> idle: disconnect retires the connectionJoinDependenciesis only constructible from itsConnectionDependencies, by one factory (the oldconfigureTransportsbody): cross-tier init order is compiler-checked, and transports exist iff the stage is.connected— the optionals are gone.State.transport,Room.dataTracks, andRoom.e2eeManagerforward into the stage — read sites and the public API are unchanged.e2eeManageris now genuinely connection-scoped (nil before connect and after disconnect).invalidStateinstead of silently no-oping.Fixes riding along (changeset included), all instances of the forgotten-reset class:
e2eeOptionsbranch never installed the crypto manager on the data-channel pairs — such rooms couldn't decrypt incoming encrypted packets, and a reusedRoomkept the previous connection's released manager. Install is now uniform across the options branches (sending still gates onisDataChannelEncryptionEnabled), and connection teardown detaches it.DataChannelPair.reset()restores the defaultmaxMessageSizeinstead of carrying the old session's negotiated value into the next one.Tested with the Room, DataChannel, DataStream, DataTrack, and RPC suites against a local server — including the new
DependencyStageTests,encryptionSurvivesFullReconnect, andresetRestoresDefaultMaxMessageSize.