mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Audited all 143 plan files across the 10 plans/ folders. Each plan now carries a Status header (shipped | in-progress | queued | abandoned) backed by codebase evidence, and every folder has a README.md index grouping plans by status. Shipped plans were moved into a per-folder plans/archive/ (via git mv, history preserved) so each plans/ folder surfaces only live work: shipped (archived): 122 in-progress: 8 queued: 7 abandoned: 4 docs/plans/ is the frozen legacy folder; its plans were stamped and indexed in place (48 of 52 archived) but it remains closed to new plans. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
341 lines
16 KiB
Markdown
341 lines
16 KiB
Markdown
# Congestion control for `:quic` — implementation plan
|
||
|
||
> **Status:** abandoned — parked indefinitely; no `CongestionController` exists in code, and the one real concern was instead solved by the smaller `SendBuffer.bestEffort` drop-lost-ranges fix that shipped.
|
||
> _Audited 2026-06-30._
|
||
|
||
**Status:** **parked indefinitely 2026-05-05.** After drafting this
|
||
plan we concluded the audio-rooms workload doesn't actually need CC —
|
||
see [Why](#why-and-why-this-is-honestly-low-priority) below. The one
|
||
real concern that surfaced (STREAM retransmit wasting bandwidth on
|
||
stale Opus frames) is addressed by a much smaller fix — a
|
||
`bestEffort` flag on `SendBuffer` that drops lost ranges instead of
|
||
retransmitting them, set by moq-lite for group streams. That
|
||
follow-up landed on the same branch.
|
||
|
||
This plan is preserved as a reference if a future workload (large
|
||
file transfer over `:quic`, multiple concurrent media streams,
|
||
running on heavily-shared mobile uplinks with hostile routers) ever
|
||
makes CC necessary. The architecture below is sound; we just don't
|
||
have a problem big enough to justify the implementation cost today.
|
||
|
||
## Why (and why this is honestly low-priority)
|
||
|
||
`:quic` has loss detection (RFC 9002 §6) and per-frame retransmit
|
||
(shipped 2026-05-05; see [`2026-05-04-control-frame-retransmit.md`](2026-05-04-control-frame-retransmit.md))
|
||
but no rate limiter — we send as fast as the application provides
|
||
bytes. For our actual production workload, that's fine:
|
||
|
||
- Audio rooms push ~8 KB/sec (Opus at 64 kbps). Way under any modern
|
||
link capacity. Not a single audio Nest has wedged on rate so far.
|
||
- `nostrnests.com` (moq-rs relay) is forwarding, not absorbing — the
|
||
bottleneck is per-subscriber stream-id cap, not bandwidth. The
|
||
retransmit subsystem already addresses that.
|
||
- The "stream cliff" investigation
|
||
([`nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`](../../nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md))
|
||
found nothing CC-shaped — the issues were stream-id exhaustion +
|
||
per-subscriber forward queue overflow, both fixed by other means.
|
||
|
||
Where it actually matters:
|
||
|
||
1. **Bufferbloat on slow links.** A user on a degraded mobile connection
|
||
(slow Wi-Fi, train tunnel, congested LTE cell) — without CC we keep
|
||
sending into a full router queue, RTT inflates, and the listener
|
||
experiences stale audio rather than back-pressured cleaner audio.
|
||
2. **Multi-flow fairness.** A single audio Nest sharing a slow uplink
|
||
with a download — we'd grab unfair share and the download stalls
|
||
(or vice versa).
|
||
3. **Burst handling after a stall.** When a paused listener reconnects,
|
||
the relay forwards a burst of cached frames. Without CC, the burst
|
||
either floods the path or causes immediate loss.
|
||
4. **Being a good citizen.** Every other QUIC stack in the wild
|
||
implements CC. Without it we look like a misbehaving client; some
|
||
relays/middleboxes might rate-limit us heuristically.
|
||
|
||
None of these are urgent, none are user-visible bugs today, and the
|
||
retransmit subsystem we just shipped is what the audio cliff actually
|
||
needed. **This plan stays open as the natural next item but should not
|
||
be prioritised over field-validation of the retransmit work.**
|
||
|
||
## Reference
|
||
|
||
We follow [RFC 9002 §7 NewReno](https://www.rfc-editor.org/rfc/rfc9002#section-7)
|
||
— the QUIC-flavored NewReno reference algorithm. Three reasons:
|
||
|
||
- It's the RFC's reference algorithm. Other stacks treat it as the
|
||
baseline; if/when we ever interop-test against quiche/neqo with CC
|
||
enabled, this is the variant they all support and verify.
|
||
- It's ~200 lines of Kotlin without pacing — the smallest credible CC
|
||
implementation. Cubic is +200 lines for a modest win; BBR is +1500
|
||
for a much bigger win (and a much bigger maintenance surface).
|
||
- Loss-based AIMD pairs naturally with the loss-detection +
|
||
retransmit machinery already in place. Nothing new is needed in
|
||
loss detection itself; CC just consumes the loss signal.
|
||
|
||
We mirror neqo's `cc/classic_cc.rs` shape because it's the cleanest
|
||
NewReno implementation we've found. If we later want CUBIC, neqo's
|
||
`cc/cubic.rs` is the obvious follow-up.
|
||
|
||
## Scope
|
||
|
||
**In scope (this plan):**
|
||
|
||
- NewReno per RFC 9002 §7 (slow start + congestion avoidance + recovery).
|
||
- Bytes-in-flight tracker.
|
||
- Send-side gating: writer checks `bytesInFlight + packetSize <= cwnd`
|
||
before emitting ack-eliciting packets.
|
||
- Persistent-congestion handling (RFC 9002 §7.6): N consecutive PTOs
|
||
collapse cwnd to the minimum.
|
||
|
||
**Out of scope (separate follow-ups):**
|
||
|
||
- **Pacing** (RFC 9002 §7.7). Audio at 50 packets/sec is already
|
||
paced by the Opus encoder cadence; bursts are ≤ a few packets.
|
||
Add when a real burst-loss problem is observed.
|
||
- **CUBIC** ([RFC 9438](https://www.rfc-editor.org/rfc/rfc9438)). Modest
|
||
throughput win on long-RTT paths; not worth the complexity until we
|
||
have a measured throughput regression.
|
||
- **BBR** (Google draft). Big throughput / fairness win on
|
||
bottleneck-buffered paths; way out of scope.
|
||
- **ECN** (RFC 9000 §13.4 + RFC 9002 §B.4). Requires both the OS
|
||
network stack to set ECN bits and the peer to reflect ECN counts in
|
||
ACK frames. Defer until we hear from a peer that supports ECN.
|
||
- **0-RTT cwnd preservation**. We don't speak 0-RTT.
|
||
- **HyStart++** (RFC 9406). Slow-start exit heuristic; minor refinement.
|
||
|
||
## Architecture
|
||
|
||
### `CongestionController`
|
||
|
||
New class at `quic/.../connection/recovery/CongestionController.kt`.
|
||
Mirrors neqo's `cc/classic_cc.rs`.
|
||
|
||
```kotlin
|
||
class CongestionController(
|
||
private val maxDatagramSize: Int = 1200,
|
||
) {
|
||
/** RFC 9002 §B.2 — initial cwnd is min(10 * MSS, max(2 * MSS, 14720)). */
|
||
var congestionWindow: Long = minOf(10L * maxDatagramSize, maxOf(2L * maxDatagramSize, 14720L))
|
||
private set
|
||
|
||
/** Sum of sizes of all ack-eliciting in-flight packets. */
|
||
var bytesInFlight: Long = 0L
|
||
private set
|
||
|
||
/** ssthresh — initially infinity (= Long.MAX_VALUE). */
|
||
var slowStartThreshold: Long = Long.MAX_VALUE
|
||
private set
|
||
|
||
/** RFC 9002 §B.2 — once cwnd >= ssthresh we're in CA. */
|
||
val inSlowStart: Boolean get() = congestionWindow < slowStartThreshold
|
||
|
||
/**
|
||
* RFC 9002 §7.3.2 — start of the most recent congestion-recovery
|
||
* period. Loss events with `sentAt <= recoveryStart` don't trigger
|
||
* a fresh halving (we already responded to that loss epoch).
|
||
*/
|
||
private var recoveryStartTimeMs: Long? = null
|
||
|
||
/** Caller checks before emitting ack-eliciting bytes. */
|
||
fun canSend(packetSize: Int): Boolean = bytesInFlight + packetSize <= congestionWindow
|
||
|
||
fun onPacketSent(sentPacket: SentPacket) { /* +bytesInFlight */ }
|
||
fun onPacketAcked(sentPacket: SentPacket) { /* -bytesInFlight + grow cwnd */ }
|
||
fun onPacketLost(sentPacket: SentPacket, nowMs: Long) { /* -bytesInFlight + maybe halve */ }
|
||
fun onPersistentCongestion() { /* cwnd = MIN_CWND, slow-start again */ }
|
||
fun onPacketDiscarded(sentPacket: SentPacket) { /* key-discard path: -bytesInFlight, no cwnd change */ }
|
||
}
|
||
```
|
||
|
||
### Send-side gating
|
||
|
||
`QuicConnectionWriter.drainOutbound` consults the controller before
|
||
emitting an ack-eliciting packet.
|
||
|
||
```kotlin
|
||
val packet = buildApplicationPacket(...)
|
||
if (packet != null && packet.ackEliciting && !cc.canSend(packet.size)) {
|
||
// Park the packet — return null so the driver waits for an ACK
|
||
// (which will free cwnd) or for the PTO timer to fire.
|
||
return null
|
||
}
|
||
```
|
||
|
||
ACK-only packets bypass the gate per RFC 9002 §7.2 ("ACK frames…
|
||
do not count toward bytes_in_flight").
|
||
|
||
PTO probes also bypass — RFC 9002 §6.2 says PTO probes MUST be sent
|
||
regardless of cwnd ("an endpoint can send packets in excess of the
|
||
congestion window when sending probe packets"). Existing
|
||
`pendingPing` flag is the integration point.
|
||
|
||
### Hook integration
|
||
|
||
| Existing hook | New CC call |
|
||
|---|---|
|
||
| `QuicConnectionWriter.buildApplicationPacket` records SentPacket | `cc.onPacketSent(sp)` after recording (only if ackEliciting) |
|
||
| `QuicConnectionParser` ACK handler iterates drained packets | `cc.onPacketAcked(sp)` per drained ack-eliciting packet |
|
||
| `QuicConnection.onTokensLost` (RFC 9002 §6) iterates lost set | `cc.onPacketLost(sp, nowMs)` per lost ack-eliciting packet |
|
||
| `LevelState.discardKeys()` clears Initial/Handshake sentPackets | `cc.onPacketDiscarded(sp)` per cleared packet (decrement only) |
|
||
| PTO fires `pendingPing = true` | `cc.consecutivePtoCount` tracking — at N=3, `cc.onPersistentCongestion()` |
|
||
|
||
### NewReno math
|
||
|
||
Slow start (`cwnd < ssthresh`):
|
||
- On each ACK: `cwnd += acked_bytes`. (Exponential growth — doubles per RTT.)
|
||
|
||
Congestion avoidance (`cwnd >= ssthresh`):
|
||
- On each ACK: `cwnd += MSS * acked_bytes / cwnd`. (Linear growth — +MSS per RTT.)
|
||
|
||
Loss event (RFC 9002 §7.3.2):
|
||
- If `sentPacket.sentAtMs > recoveryStartTimeMs ?: -1`:
|
||
- `recoveryStartTimeMs = nowMs`
|
||
- `ssthresh = cwnd / 2`
|
||
- `cwnd = max(ssthresh, MIN_CWND)` // MIN_CWND = 2 * MSS
|
||
- Else: in same recovery period, don't double-halve.
|
||
|
||
Persistent congestion (RFC 9002 §7.6):
|
||
- `cwnd = MIN_CWND`
|
||
- `recoveryStartTimeMs = null`
|
||
- Slow-start kicks in again.
|
||
|
||
## File-by-file plan
|
||
|
||
Each step compiles + passes existing tests before moving on.
|
||
|
||
### Step 1: type stub
|
||
|
||
- New `CongestionController.kt` with the public API + `canSend` (always
|
||
true) + no-op hooks.
|
||
- Wired as `QuicConnection.cc: CongestionController`.
|
||
- Existing tests still pass; nothing observable changes.
|
||
|
||
### Step 2: bytesInFlight tracking
|
||
|
||
- Implement `onPacketSent` + `onPacketAcked` + `onPacketLost` +
|
||
`onPacketDiscarded` to manage `bytesInFlight` only — leave cwnd
|
||
fixed at the initial value.
|
||
- Wire from the writer (after SentPacket recording), parser (ACK
|
||
drain loop), `onTokensLost` dispatch, and `LevelState.discardKeys()`.
|
||
- Test: `BytesInFlightTrackingTest` — invariants under send / ack /
|
||
loss / discard / mixed.
|
||
|
||
### Step 3: slow-start growth
|
||
|
||
- Implement the slow-start branch of `onPacketAcked` (cwnd += acked).
|
||
- Test: `SlowStartGrowthTest` — cwnd doubles per RTT in steady-state
|
||
ACK arrival.
|
||
|
||
### Step 4: congestion-avoidance growth
|
||
|
||
- Implement the CA branch (cwnd += MSS * acked / cwnd).
|
||
- Wire ssthresh — initial Long.MAX_VALUE means we never enter CA
|
||
unless a loss has happened. We'll exercise this in step 5.
|
||
- Test: `CongestionAvoidanceGrowthTest` — cwnd grows ≈+MSS per RTT
|
||
once `cwnd >= ssthresh`.
|
||
|
||
### Step 5: loss reaction
|
||
|
||
- Implement `onPacketLost` cwnd-halve + recovery-period gating.
|
||
- Test: `LossReactionTest` — single loss halves; second loss in same
|
||
period doesn't re-halve; loss after recovery period elapses
|
||
re-halves.
|
||
|
||
### Step 6: send-side gating
|
||
|
||
- Wire `cc.canSend(packetSize)` into the writer.
|
||
- ACK-only path bypasses (writer already distinguishes; we just need
|
||
to compute the projected packet size).
|
||
- PTO probe path bypasses (writer's `pendingPing` branch).
|
||
- Test: `SendGatingTest` — driver returns null when bytesInFlight ≥
|
||
cwnd; resumes after ACK frees cwnd.
|
||
|
||
### Step 7: persistent congestion
|
||
|
||
- Track consecutive-PTO count (already on QuicConnection as
|
||
`consecutivePtoCount`).
|
||
- At N=3 (RFC 9002 §B.6 — `kPersistentCongestionThreshold` = 3 PTOs
|
||
worth of time, simplified as 3 consecutive PTO fires here),
|
||
call `cc.onPersistentCongestion()`.
|
||
- Test: `PersistentCongestionTest` — 3 consecutive PTOs collapse cwnd
|
||
to min.
|
||
|
||
### Step 8: integration test
|
||
|
||
- New `nestsClient/.../moq/lite/MoqLiteSessionCongestionTest.kt`:
|
||
simulate a 100ms-RTT path with 1% loss; observe cwnd settling
|
||
around the bandwidth-delay product; audio continues without
|
||
unbounded queue growth on either side.
|
||
- Maybe also an in-process-pipe variant under `quic/...` that injects
|
||
loss at a rate above what the receiver's ACK can keep up with;
|
||
observe the gate engaging.
|
||
|
||
### Step 9: documentation
|
||
|
||
- Update [`2026-04-26-quic-stack-status.md`](2026-04-26-quic-stack-status.md):
|
||
Phase F flips from "done (no CC)" to "done"; "no congestion control"
|
||
removed from deferred-work list.
|
||
|
||
## Test inventory mirrored from neqo
|
||
|
||
Total: ~12 unit tests + 2 integration tests = 14 new tests.
|
||
|
||
| neqo test (cc/classic_cc.rs) | Asserts | Our equivalent |
|
||
|---|---|---|
|
||
| `init` | initial cwnd matches RFC 9002 §B.2 | `CongestionControllerTest.initialCwnd` |
|
||
| `slow_start` | each ACK doubles cwnd in slow-start | `SlowStartGrowthTest.basicGrowth` |
|
||
| `slow_start_to_ca` | crossing ssthresh transitions to CA | `SlowStartGrowthTest.crossesSsthreshIntoCa` |
|
||
| `ca_growth` | linear growth in CA | `CongestionAvoidanceGrowthTest.linearGrowth` |
|
||
| `loss_event_halves` | first loss halves cwnd, sets ssthresh | `LossReactionTest.firstLossHalves` |
|
||
| `recovery_period_suppresses_re_halving` | second loss in same period no-op | `LossReactionTest.secondLossInPeriodSuppressed` |
|
||
| `loss_after_recovery_re_halves` | loss after recovery period re-halves | `LossReactionTest.lossAfterRecoveryRehalves` |
|
||
| `cwnd_floor_min_window` | cwnd never drops below 2 * MSS | `LossReactionTest.cwndFloor` |
|
||
| `app_limited_no_growth` | app-limited path freezes cwnd | `AppLimitedTest.noGrowthWhenAppLimited` |
|
||
| `bytes_in_flight_tracking` | invariants under send / ack / loss | `BytesInFlightTrackingTest.allHooks` |
|
||
| `persistent_congestion_resets_to_min` | 3 consecutive PTOs → min cwnd | `PersistentCongestionTest.threeConsecutivePtos` |
|
||
| `key_discard_decrements_in_flight` | key-discard path correctly drops in-flight bytes | `BytesInFlightTrackingTest.keyDiscardDecrements` |
|
||
| `gating_blocks_send_at_cap` | writer returns null when at cwnd cap | `SendGatingTest.blocksAtCap` |
|
||
| `gating_resumes_after_ack` | ACK frees cwnd, send resumes | `SendGatingTest.resumesAfterAck` |
|
||
|
||
Plus 2 integration tests as listed in step 8.
|
||
|
||
## Risks and mitigations
|
||
|
||
| Risk | Mitigation |
|
||
|---|---|
|
||
| Throughput regression on good networks (cwnd starts at 14 KB and grows) | Initial 10 * MSS = ~12 KB. RTT 50 ms steady-state means cwnd doubles every 50 ms; saturation at link bandwidth in seconds. For audio at 8 KB/sec we never approach the cap. |
|
||
| Send-gate stalls audio when cwnd shrinks | PTO probe path bypasses cwnd. Worst case: a cwnd-blocked send waits for ACK + RTT. Audio room jitter buffer hides this. |
|
||
| Off-by-one in loss-event recovery-period gating | Mirror neqo's `time > recovery_start` strict comparison + tests. |
|
||
| Persistent-congestion false trigger | We use the conservative "3 consecutive PTOs" simplification; the strict RFC 9002 §B.6 definition needs a duration check we can add later. Risk is collapsing cwnd unnecessarily — surfaces as audio stall the user can fix by reconnecting. |
|
||
| Interaction with existing per-frame retransmit | None expected — retransmit operates on lost-token dispatch; CC operates on packet sizes. They're orthogonal. |
|
||
| `SendBuffer.takeChunk` doesn't know cwnd | The gate is at `drainOutbound` granularity — packet-level, not chunk-level. SendBuffer remains unchanged. |
|
||
|
||
## Effort
|
||
|
||
Realistic: **3–5 days** of focused work. Steps 1–4 ~1 day; steps 5–7
|
||
~2 days; integration test + doc ~1 day; bug-fixing slack 1 day.
|
||
|
||
## Acceptance criteria
|
||
|
||
- All ~14 ported tests pass.
|
||
- Existing `:quic` test suite unchanged (`QuicLossDetectionTest`,
|
||
`RetransmitIntegrationTest`, `KeyDiscardTest`, etc.).
|
||
- `MoqLiteSessionCongestionTest` lossy-path scenario shows cwnd
|
||
settling to a steady-state value rather than growing unboundedly.
|
||
- `NestsListenerProdTest` (existing JVM interop test against
|
||
nostrnests.com) continues to pass with the gate wired in.
|
||
- No regression in handshake latency or audio start-up time.
|
||
|
||
## Open questions
|
||
|
||
- **What's the right `kPersistentCongestionThreshold`?** Simplified
|
||
here as "3 consecutive PTOs without any ACK". Strict RFC version
|
||
uses a duration formula that requires more PTO bookkeeping. Keep it
|
||
simple unless field data suggests we're collapsing too aggressively.
|
||
- **MTU.** We use `maxDatagramSize = 1200` everywhere (no PMTUD).
|
||
CC sizes off this constant. If we ever add PMTUD this needs to
|
||
reflow. Track as a note for the PMTUD plan.
|
||
- **Should the gate be per-space?** RFC 9002 §B.4 ties cwnd to a
|
||
single congestion controller, not per-pn-space. We follow the RFC.
|
||
Initial / Handshake bytes count toward cwnd until the level is
|
||
discarded; that's correct per the spec.
|