Files
amethyst/quic/plans/2026-05-08-lock-split-design.md
T
Claude ef4bb99988 refactor(quic): split conn.lock into streamsLock + per-level lock + lifecycleLock
The single connection-wide `QuicConnection.lock` mutex serialised every
critical path: the read loop's `feedDatagram`, the send loop's
`drainOutbound`, and every public mutator (`openBidiStream`,
`streamById`, `flowControlSnapshot`, ...). The multiplexing testcase
opens hundreds of bidi streams in parallel and was capped at ~25
streams/sec by lock contention against the I/O loops.

Phase 1 of the lock split (see
`quic/plans/2026-05-08-lock-split-design.md`) introduces three
domain-specific mutexes:

  - `streamsLock` — streams registry, datagram queues, stream-id
    counters, connection-level flow-control bookkeeping, pending-
    retransmit maps for control frames
  - `LevelState.levelLock` (one per encryption level) — per-level
    pnSpace / sentPackets / ackTracker / CRYPTO buffers
  - `lifecycleLock` — status transitions, close reason/error code

Acquisition order: `lifecycleLock < streamsLock < levelLock`.
Per-stream `synchronized(this)` blocks inside SendBuffer/ReceiveBuffer
remain at the leaf — never acquire any QuicConnection mutex while
holding a per-stream lock.

The legacy `lock: Mutex` field is preserved as a deprecated alias of
`lifecycleLock` for source-compatibility with external test harnesses;
new code MUST use the appropriate domain lock.

Highlights:

  - `feedDatagram` / `drainOutbound` now require the caller to hold
    `streamsLock`; the driver wraps each call. Phase 1 keeps the whole
    feed/drain inside `streamsLock` for safety; phase 2 (deferred) will
    split frame-collection from encrypt + sentPackets-record so app
    coroutines can intersperse during the encrypt window.
  - `pendingPing`, `peerTransportParameters`, `status`,
    `handshakeComplete` are now @Volatile so observers read them
    without a lock.
  - `markClosedExternally` no longer needs any lock (status is
    @Volatile, signals are channel-thread-safe).
  - Driver's PTO bookkeeping uses the volatile fields directly — no
    lock needed.
  - Tests that manually acquired `conn.lock` to call
    `getOrCreatePeerStreamLocked` / `onTokensAcked` / `onTokensLost`
    now acquire `streamsLock` (the domain those routines mutate).
  - New `MultiplexingThroughputTest` locks in the contract: 1000
    parallel `openBidiStream` calls must complete in <2 s.

Test plan:

  - `:quic:jvmTest` — 294 tests pass (293 prior + 1 new throughput).
  - `MultiplexingThroughputTest`: 1000 bidi streams in 52 ms
    (~19,000 streams/sec on the in-memory pipe), well above the
    250+/sec target.
  - `:nestsClient:compileKotlinJvm` — clean, no API breaks.
  - `./gradlew :quic:spotlessApply` — clean.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 02:24:51 +00:00

9.6 KiB

QuicConnection Lock Split — Design Note

Date: 2026-05-08

Problem

QuicConnection.lock: Mutex serialises every meaningful operation:

  • drainOutbound (send loop, holds lock during a full datagram build — iterates every stream, allocates packet numbers, encrypts).
  • feedDatagram (read loop, holds lock during decrypt + frame dispatch
    • per-stream insert).
  • openBidiStream / openUniStream (app code, holds lock for stream allocation + map insert).
  • getOrCreatePeerStreamLocked (parser path on the read loop's critical section, but app code can also call it from tests).

Multiplexing test against aioquic measures ~25 streams/sec — every coroutine fights this single mutex.

Goal

Split the mutex into per-domain mutexes so the read loop, send loop, and app code can mostly progress concurrently. Per-stream synchronized(this) inside SendBuffer/ReceiveBuffer already handles per-stream serialisation; we don't touch those.

Domain Map

Domain A — streamsLock: Mutex (the streams registry)

Fields:

  • streams: MutableMap<Long, QuicStream>
  • streamsList: MutableList<QuicStream> (insertion-ordered list parallel to streams, used by writer round-robin)
  • nextLocalBidiIndex, nextLocalUniIndex
  • streamRoundRobinStart — read+written by writer; used in the same critical section it holds streamsLock for the iteration
  • peerInitiatedUniCount, peerInitiatedBidiCount
  • advertisedMaxStreamsUni, advertisedMaxStreamsBidi, advertisedMaxData
  • pendingMaxStreamsUni, pendingMaxStreamsBidi, pendingMaxData
  • pendingMaxStreamData: MutableMap<Long, Long>
  • pendingNewConnectionId: MutableMap<Long, …>
  • newPeerStreams: ArrayDeque<QuicStream>
  • pendingDatagrams: ArrayDeque<ByteArray> — outbound DATAGRAMs
  • incomingDatagrams: ArrayDeque<ByteArray> — inbound DATAGRAMs
  • sendConnectionFlowCredit, sendConnectionFlowConsumed
  • receiveConnectionFlowLimit

Rationale: the writer needs an atomic snapshot of "all streams + all pending control-frame retransmits + datagram queues + flow-control counters" in one critical section to assemble a packet. The parser needs the same coverage when delivering a STREAM frame (look up or create the stream + queue receive bytes + bump pending* fields). Splitting these into multiple sub-locks would force the writer/parser to acquire several locks per pass — same contention, more deadlock risk.

peerMaxStreamsBidi, peerMaxStreamsUni stay @Volatile (already are): the writer reads them once at the top of a stream open; the parser writes once on inbound MAX_STREAMS. Atomic long write is sufficient on all supported platforms.

Domain B — LevelState.levelLock: Mutex (one per level: initial / handshake / application)

Fields per LevelState:

  • pnSpace: PacketNumberSpaceState
  • sentPackets: MutableMap<Long, SentPacket>
  • ackTracker
  • cryptoSend: SendBuffer, cryptoReceive: ReceiveBuffer
  • sendProtection: PacketProtection?, receiveProtection: PacketProtection?
  • keysDiscarded
  • largestAckedPn, largestAckedSentTimeMs

The writer iterates through levels in order (initial → handshake → application) when building a coalesced datagram. Each level's critical section is independent, so the lock is held only for the duration of build at that level (which doesn't touch the streams registry except to read streamsListLocked() for stream frames inside the application build — that read transitions through streamsLock).

Domain C — lifecycleLock: Mutex (status + handshake metadata)

Fields:

  • status: Status
  • closeReason: String?, closeErrorCode: Long
  • peerTransportParameters: TransportParameters? — read-mostly after handshake; using @Volatile reference + write-once-after-handshake is sufficient here. Promoted to @Volatile so writer/parser can snapshot without a lock.
  • handshakeComplete: Boolean
  • closeAllSignals (the channels are themselves thread-safe; lock is only required to serialise the status transition)

Domain D — Atomic / @Volatile (no lock)

Fields:

  • pendingPing — toggled by driver under PTO; observed by writer. Promote to @Volatile.
  • consecutivePtoCount — already @Volatile. Driver writes it under its own logic; no further protection needed because it's only read inside the same loop iteration that wrote it.
  • destinationConnectionId — already has volatile semantics (internal set on a @Volatile var). Stays as is.
  • udpStatsSupplier — already @Volatile.
  • peerMaxStreamsBidi, peerMaxStreamsUni — already @Volatile.
  • handshakeDoneSignal: CompletableDeferred<Unit> — coroutines primitive, thread-safe.

Domain E — Per-stream (UNCHANGED)

QuicStream already protects its SendBuffer / ReceiveBuffer with internal synchronized(this) blocks. Nothing changes here.

Lock Acquisition Order

To prevent deadlock, document and enforce:

lifecycleLock < streamsLock < (any LevelState.levelLock)

Per-stream synchronized(...) blocks inside SendBuffer/ReceiveBuffer remain at the leaf — never acquire any QuicConnection mutex while holding a per-stream lock.

In practice the only nesting that happens is:

  • drainOutbound acquires streamsLock (for the streams loop + stream-frame creation) but the per-level builds happen outside that block — each level acquires its own levelLock separately. No nested streamsLocklevelLock chain.
  • Actually re-checking the design: the writer needs to allocate a PN at the chosen level while it has decided which streams to flush. Two options: (1) acquire streamsLock, snapshot streams + frames, release; acquire each levelLock to encode + record. (2) hold streamsLock during level encode for the application packet (because stream-frame retransmit tokens get recorded into level.sentPackets in the same operation). We take option (2) — encode under both locks, with strict order streamsLocklevelLock. The other levels (initial/handshake) don't touch streamsLock at all, so they only acquire levelLock.

Public API Compatibility

QuicConnection.lock: Mutex is val-public. External callers exist (tests + InMemoryQuicPipe-driven harnesses). To avoid breaking those:

  • Keep the lock: Mutex field as a deprecated forwarder. It now also exists, but it is an alias for lifecycleLock. New code must NOT use it. Existing tests that lock it before mutating state used to cover all domains; we update them in place to use the appropriate lock(s).

Actually simpler: keep lock: Mutex as a no-op lock (still a Mutex so external code compiles), document that it no longer guards anything, update the tests that lock it.

After review: tests use conn.lock to serialise their direct calls to onTokensAcked/onTokensLost/getOrCreatePeerStreamLocked. We update those tests to acquire streamsLock instead (since those routines mutate stream-domain state). The lock field is kept as deprecated for source compatibility but is functionally a leaf no-op.

Migration Plan

  1. Add streamsLock, lifecycleLock fields. Keep lock as alias of lifecycleLock.
  2. Add levelLock to LevelState.
  3. Convert getOrCreatePeerStreamLockedgetOrCreatePeerStream doing its own streamsLock acquisition. Keep the old name as a forwarder for backwards compat.
  4. Update openBidiStream, openUniStream, streamById, pollIncomingPeerStream, awaitIncomingPeerStream, pollIncomingDatagram, awaitIncomingDatagram, queueDatagram, flowControlSnapshot to acquire streamsLock.
  5. Update close, markClosedExternally to use lifecycleLock.
  6. Update driver's readLoop/sendLoop:
    • feedDatagram no longer wraps in conn-wide lock. Instead the parser acquires streamsLock around stream-touching code, and levelLock around level-touching code.
    • drainOutbound is restructured similarly.
  7. Update tests that hold conn.lock to use the relevant new lock.

Risk + Mitigation

  • Deadlock: enforce order via code review + (where practical) inline comments at each acquisition site. Keep nesting shallow.
  • Missed coverage: enumerate every field in this doc; if a field can be mutated from two domains we either move it to a single domain or annotate it as @Volatile.
  • Performance regression: more mutex acquisitions overall; but the critical path (multiplexing test) sees parallel execution instead of serial, which more than compensates.

Implementation Phases

This commit implements phase 1 — separate domain locks but drainOutbound and feedDatagram still hold streamsLock for the entire pass. The wins from phase 1 alone:

  • App code (openBidiStream, streamById, flowControlSnapshot) no longer contends with lifecycleLock-only operations.
  • The PTO timer path stops touching any mutex (volatile fields).
  • markClosedExternally no longer needs a lock.
  • close() only takes lifecycleLock — opens the path for in-progress drain to finish without status-write contention.

Phase 2 (deferred follow-up): split buildApplicationPacket into a "collect frames under streamsLock" stage and an "encrypt + record under levelLock" stage so app coroutines can intersperse during the encrypt window. That requires more invasive surgery on the writer's internals; phase 1 ships first to lock in the safer subset.

Verification

  • :quic:jvmTest — full suite must pass.
  • MultiplexingThroughputTest (new): 1000 streams in <500 ms on InMemoryQuicPipe.