Skip to content

Room dependency stages - #1097

Merged
pblazej merged 4 commits into
mainfrom
blaze/room-dependencies
Aug 25, 2026
Merged

Room dependency stages#1097
pblazej merged 4 commits into
mainfrom
blaze/room-dependencies

Conversation

@pblazej

@pblazej pblazej commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

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 optional State.transport, nullable side-slots (_e2eeManager, _dataTracks), and reset lists ordered by comments and isFullReconnect: flags. They are now one dependency stage inside Room.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 connection
Loading
  • JoinDependencies is only constructible from its ConnectionDependencies, by one factory (the old configureTransports body): cross-tier init order is compiler-checked, and transports exist iff the stage is .connected — the optionals are gone.
  • Rebuild = new payload, so nothing leaks across a reconnect by being missing from a reset list; a stage transition and its state reset are one atomic mutation under the existing lock.
  • State.transport, Room.dataTracks, and Room.e2eeManager forward into the stage — read sites and the public API are unchanged. e2eeManager is now genuinely connection-scoped (nil before connect and after disconnect).
  • A duplicate JOIN throws invalidState instead of silently no-oping.

Fixes riding along (changeset included), all instances of the forgotten-reset class:

  • The legacy e2eeOptions branch never installed the crypto manager on the data-channel pairs — such rooms couldn't decrypt incoming encrypted packets, and a reused Room kept the previous connection's released manager. Install is now uniform across the options branches (sending still gates on isDataChannelEncryptionEnabled), and connection teardown detaches it.
  • DataChannelPair.reset() restores the default maxMessageSize instead 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, and resetRestoresDefaultMaxMessageSize.

pblazej and others added 3 commits August 24, 2026 15:56
…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>
@pblazej pblazej changed the title Stage Room's connection- and join-scoped dependencies in a typed plane Room dependency stages Aug 24, 2026
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>
@pblazej
pblazej marked this pull request as ready for review August 25, 2026 07:32

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@pblazej

pblazej commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Planned follow-ups, in rough dependency order (each is its own PR; 1–2 are independent of each other):

  1. State data tiers — split Room.State's plain data fields into ConnectionJoin sub-structs with forwarders, so cleanUp's hand-maintained keep-list disappears the same way the subsystem reset lists did here. Mostly cherry-pickable from checkpoint/room-di-tiers-v1; rider fixes: connectSpan surviving region failover, publish-codecs/track-permissions resetting with their tier.

  2. Composition root — replace the six lazy vars with self-less let construction wired right after super.init(), before self escapes. Supersedes the Room half of Fix data races on Room's lazy member initialization #1088 (the non-Room fixes in Fix data races on lazily initialized members outside Room #1089 are needed regardless); requires swapping the MockDataChannelPair assignment seam for a send interceptor on the real pair. A validated version exists on the checkpoint branch and applies with minor adaptation.

  3. runConnection single-owner task — one long-lived task owns the connection; full reconnect becomes a loop iteration and teardown gets exactly two call sites. This makes "gates re-arm before the transport == nil window opens" structural (currently sequenced by hand in cleanUpRTC), and replaces isFullReconnect: flags with a cancellation-vs-graceful-shutdown split. Largest behavioral risk; wants a TSan soak.

  4. Finish the join factoryTransport takes its offer handler as an init parameter (the one remaining wire-later gap inside JoinDependencies.make), and channel/cryptor composition becomes pure initializer parameters.

  5. Small refinements — per-join generation IDs so late events from a dead session are dropped by one comparison; a continuation-based intent queue (gRPC-style) replacing _queuedBlocks; a serial state-effects stream so onDidMutate becomes a pure yield.

@pblazej
pblazej merged commit c3671b7 into main Aug 25, 2026
91 of 101 checks passed
@pblazej
pblazej deleted the blaze/room-dependencies branch August 25, 2026 11:49
pblazej added a commit that referenced this pull request Aug 26, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants