srt-runtime 0.4.0

SRT (Secure Reliable Transport, draft-sharabayko-srt-01) packet codecs + sans-IO HSv5 Caller-Listener and Rendezvous handshake state machines, an ARQ reliability engine, a TSBPD delivery scheduler, LiveCC packet pacing, FileCC window-based congestion control, optional (crypto feature) payload encryption, and an optional (tokio feature) async UDP socket adapter; no_std core.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
# Changelog

All notable changes to `srt-runtime` are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.4.0] - 2026-08-11

### Fixed
- `io::SrtSocket::connect_from` (the tokio Caller adapter) no longer defaults
  the peer's `initial_seq_number` to `0` when it can't be extracted from the
  handshake bytes that just drove the connection to `Connected`. That
  fallback made a genuine peer ISN of 0 indistinguishable from an extraction
  failure, silently mis-seeding ARQ/TSBPD sequence tracking for the whole
  connection — a defect that would surface much later as inexplicable
  loss/reordering, far from its cause. The extraction is now
  `require_peer_isn`, which returns `Error::InvalidField` instead of `0` on
  any parse failure; `SrtSocket::connect`/`connect_from` propagate that error
  instead of silently continuing. No public API previously guaranteed the old
  default, so this is not expected to be observable as a behaviour change in
  practice — see the module's `isn_tests` for why the path is effectively
  unreachable today, and why the guard still matters for future refactors.

### Changed
- MSRV raised to **1.95.0** (issue #949). This removes the workspace's MSRV
  split: `webrtc-runtime`'s optional `media` feature needed rustc 1.88 (via
  `rcgen`), which had grown a dedicated CI job, six `--exclude` lanes and a
  guard script to contain. Adopting let-chains and `is_multiple_of` where the
  1.95 lints require them; no functional or API change.
### Changed (Breaking)
- `KeyParity` (`km_refresh`), `LossListEntry` (`packet::nak`) now carry
  `#[non_exhaustive]` (issue #806's non_exhaustive drift-guard audit). A
  downstream `match` on either of these now needs a wildcard arm.

### Added
- `tests/non_exhaustive_coverage.rs` drift guard (issue #806).

### Fixed
- Doc accuracy (#941 row 6): README install snippet corrected from `"0.2"`
  to `"0.3"` (the crate is 0.3.0).

## [0.3.0] - 2026-07-29

### Changed (BREAKING)
- **Requires `broadcast-common` 9.** No functional or API change of this
  crate's own; the bump exists solely to carry the new requirement.
  `broadcast-common` 9.0.0 changed `Encrypt::encrypt` to take `&mut self` (so a
  stateful implementor can own a running per-key IV counter — it fixes a
  duplicate-IV/two-time-pad defect), and its `Parse`/`Serialize` traits appear
  in this crate's public API, so a consumer cannot mix a `broadcast-common` 8
  build with this one. That makes it a breaking release even though no line of
  logic here moved.

## [0.2.0] - 2026-07-06

### Added

- **`filecc` — SRT File Transfer Congestion Control (FileCC), §5.2** (issue
  #620). [`filecc::FileCc`] is the file/bulk-transfer-mode sibling of
  [`livecc::LiveCC`] §5.1: a two-phase hybrid AIMD window + pacing
  controller. Slow Start (§5.2.1.1) grows `CWND_SIZE` by the ACK
  sequence-number delta each full ACK, holding `PKT_SND_PERIOD` fixed at 1
  microsecond, until the first loss/timeout or `CWND_SIZE` exceeding
  `MAX_CWND_SIZE` ends it. Congestion Avoidance (§5.2.1.2) recomputes
  `CWND_SIZE` directly from `RECEIVING_RATE`/`RTT` each ACK, and runs the
  full NAK-driven rate-decrease state machine: the 2%-loss-ratio tolerance,
  the `LastDecSeq`-bounded congestion-period detection, the `1.03x`
  repeat-decrease backoff (bounded by `DecCount<=5`), the `AvgNAKNum` EWMA,
  and the `MAX_BW`-derived `MIN_PERIOD` clamp. Sans-IO, `no_std` (including
  `thumbv7em-none-eabi`): driven by `on_ack`/`on_loss`/`on_timeout`, no
  wall-clock reads. Curated at `specs/rules/srt-congestion.md`, every
  constant/formula cited to a source line. The draft's two flagged gaps
  (the `RECEIVING_RATE`/`EST_LINK_CAPACITY` EWMA smoothing weight; the
  packet-pairs probing mechanics that would produce those inputs) are
  resolved with a documented choice in the module doc, not silently
  invented. `DecRandom` (Step 4's repeat-decrease staggering factor) is
  rounded to the nearest whole number — found by a pre-tag audit: a raw
  fractional draw made Step 4's `NAKCount == DecCount * DecRandom` gate a
  measure-zero float comparison, since both counters are integers. Also
  documented (not "fixed", since it's a property of the draft's own literal
  Step 4 pseudocode, verified against `libsrt`'s differing reference
  formulation): once the immediate post-reset check fails (`DecRandom != 1`),
  neither counter is touched again for the rest of the congestion period, so
  Step 4 fires at most once per period unless the drawn `DecRandom` rounds
  to exactly `1`.
- **Payload encryption wired into the handshake, plus a KM Refresh driver**
  (issue #621, `crypto` feature). `draft-sharabayko-srt-01` §6.1.5 Key
  Material Exchange is now piggybacked on the existing Caller-Listener
  CONCLUSION extension flow instead of a new wire message: opt-in
  `handshake_sm::CryptoConfig` (a pre-shared passphrase plus a
  caller-supplied fresh Salt/SEK — this crate's sans-IO core still never
  reads OS randomness, matching `derive_cookie`'s existing precedent) on
  `HandshakeConfig::crypto`. When set, `CallerHandshake` derives a KEK
  (`crypto::derive_kek`), wraps its SEK (`crypto::wrap_sek`), and sends it as
  a `packet::KeyMaterial` `SRT_CMD_KMREQ` extension on its CONCLUSION;
  `ListenerHandshake` unwraps it (`crypto::unwrap_sek`) and echoes the same
  Key Material back as `SRT_CMD_KMRSP` to confirm (§6.1.5, "the responder
  echoes the same KM message back to prove it derived the same SEK"); the
  Caller verifies the echo before trusting it. The negotiated SEK/Salt are
  exposed via new `handshake_sm::NegotiatedParams::sek`/`salt` fields. A
  mismatched passphrase fails the RFC 3394 wrap-integrity check and rejects
  the connection (`RejectionReason::BadSecret`); one side configured for
  encryption and the other not rejects as `RejectionReason::Unsecure`.
  - New `km_refresh` module (`crypto` feature): a sans-IO §6.1.6 KM Refresh
    (SEK-rotation) driver, `km_refresh::KmRefreshDriver`. Driven by
    `on_packet_sent(n)`/`tick()` (no wall-clock reads), it fires
    `KmRefreshEvent::PreAnnounce` at `refresh_period - pre_announcement_period`
    packets, `Switchover` at `refresh_period`, and `Decommission` at
    `refresh_period + pre_announcement_period`, alternating `KeyParity`
    (`Even`/`Odd`) and keeping both keys valid through the transition window
    — the spec-recommended thresholds (`2^25` / `4000`) are
    `KmRefreshThresholds::RECOMMENDED`. The driver tracks state only; actual
    SEK generation/wrap/send in response to `PreAnnounce` is the caller's
    job, mirroring `CryptoConfig`'s caller-supplied-randomness design.
  - Wiring the negotiated SEK / `KmRefreshDriver` events into `io.rs`'s
    tokio adapter to actually encrypt/decrypt data-packet payloads
    end-to-end is the one remaining tracked follow-up (see README) — this
    release wires the handshake negotiation and ships the rotation state
    machine, both sans-IO and fully tested.
- **Tokio UDP socket adapter** (feature `tokio`, issue #611): real-socket async
  SRT connection over UDP that drives the existing sans-IO engines end-to-end:
  [`io::SrtSocket`] caller `connect` / listener `accept` and
  [`io::SrtListener`] UDP bind + accept loop. Handshake (caller/listener) →
  ARQ data transfer with retransmit/ACK/NAK → TSBPD-ordered delivery with
  LiveCC pacing, all behind a single `send`/`recv` async interface. Behind a
  new non-default `tokio` feature (implies `std`); the sans-IO core stays
  `no_std` without it.
  - `io::SrtSocket::connect` — bind + HSv5 caller handshake to remote peer.
  - `io::SrtListener::bind` / `accept` — listen for incoming SRT Callers.
  - `SrtSocket::send` / `recv` — async application payload transfer.
  - `tests/io_loopback.rs` — full loopback integration test: listener binds
    ephemeral 127.0.0.1 port, caller connects, sends N≥20 distinct payloads,
    receiver gets ALL N in order (byte-identical), wrapped in
    `tokio::time::timeout` for fail-fast on deadlock.
- **Sans-IO TSBPD delivery scheduler + too-late packet drop** (§4.5/§4.6/§4.7
  of `draft-sharabayko-srt-01`; curated at `specs/rules/srt-tsbpd.md`, issue
  #607):
  - `tsbpd::TsbpdScheduler` — receiver-side delivery scheduler: `feed_data`
    accepts a packet's sequence number and 32-bit timestamp, computes its
    `PktTsbpdTime` per the rule-9 formula; `tick` releases packets in
    sequence order when their play time has arrived.
  - `PktTsbpdTime = TsbpdTimeBase + PKT_TIMESTAMP + TsbpdDelay + Drift` with
    spec-cited constants: minimum `TsbpdDelay` 120 ms (rule 10),
    `TLPKTDROP_THRESHOLD` default `1.25 × TsbpdDelay` (rule 19).
  - Too-late drop on arrival: a packet whose `PktTsbpdTime` is already past
    `(now - TLPKTDROP_THRESHOLD)` is dropped immediately.
  - Too-late drop via release loop: buffered packets past the drop threshold
    are dropped when the gap ahead of them is filled (rule 21 pseudocode).
  - 32-bit timestamp wrapping handled via lossless u64 arithmetic — no
    wrapping-period TsbpdTimeBase adjustment (rule 16 is separate, driven by
    the handshake layer, not implemented here).
  - Sans-IO (`core::time::Duration`) throughout — no wall-clock read in the
    crate.
  - `tests/tsbpd_delivery.rs` — 12 integration tests: ordered/out-of-order
    delivery, withholding until play time, too-late drop on arrival, drop
    chain after gap fill, timestamp wrap, disabled drop, gap blocking,
    custom threshold, drift inclusion, gradual tick, duplicate suppression.
  - Explicit non-goals: drift estimation (§4.7), fake-ACK on receiver skip
    (rule 22), sender-side TLPKTDROP (rule 18-20), wrapping-period
    TsbpdTimeBase adjustment (rule 16).
- **Sans-IO LiveCC packet pacing controller** (§5.1 SRT Packet Pacing and Live
  Congestion Control; issue #610, curated at `specs/rules/srt-livecc.md`):
  - `livecc::LiveCC` — sender-side pacing state: computes the inter-packet send
    period (`PKT_SND_PERIOD`) from the running EWMA average payload size
    (`AvgPayloadSize`) and the configured maximum bandwidth (`MAX_BW`), per the
    `§5.1.2` formulas.
  - `livecc::MaxBwConfig` — three bandwidth-configuration modes (`MAXBW_SET`,
    `INPUTBW_SET`, `INPUTBW_ESTIMATED`) plus `Infinite` (unbounded), per
    `§5.1.1`.
  - `on_data_packet` — updates the `AvgPayloadSize` EWMA (`7/8 * old + 1/8 *
    packet`, L3219).
  - `on_ack_received` — computes `PKT_SND_PERIOD = PktSize * 1000000 / MAX_BW`
    (L3234), returning `Duration::ZERO` for infinite bandwidth.
  - Initial `AvgPayloadSize` capped at 1456 bytes (L3222-3223); default
    `MAXBW_SET` at 1 Gbps (L3122-3123).
  - `tests/livecc_pacing.rs` — integration tests that assert hand-computed
    `PKT_SND_PERIOD` constants for known payload and bandwidth values; EWMA
    step-by-step convergence; all three bandwidth modes; runtime mode switching.
- **Sans-IO ARQ (Automatic Repeat reQuest) reliability engine** (§4.8
  Acknowledgement and Lost Packet Handling, §4.8.1 ACKs/ACKACKs, §4.8.2 NAKs,
  §4.10 Round-Trip Time Estimation; issue #606), driving the existing
  ACK/NAK/ACKACK/Data packet codecs — no wire format is re-encoded:
  - `arq::Sender` — buffers every sent data packet (rule 1); `on_nak`
    records a NAK's loss-list entries for prioritized retransmission (rules
    5, 15, 16, 18); `tick` drains the pending retransmit queue, setting the
    `R` flag and incrementing the resend counter; `on_ack` frees every
    packet acknowledged by the ACK's `n + 1` cumulative semantics (rule 8)
    and, for a Full ACK only, updates RTT/RTTVar from the ACK's carried
    value (rule 33) and returns the ACKACK reply (rules 3, 9).
  - `arq::Receiver` — tracks arrivals and a cumulative ack point;
    `feed_data` detects newly-opened sequence gaps and returns an immediate
    NAK (rules 4, 14) plus the sequence numbers that became
    in-order-deliverable as a result (`FeedOutcome::delivered`); `tick`
    emits a Full ACK every 10 ms (rule 11), a Light ACK once 64 packets have
    arrived since the last ACK (rule 12), and a periodic NAK once
    `NAKInterval = max((RTT + 4*RTTVar)/2, 20ms)` has elapsed and the loss
    list is non-empty (rules 21-22) — never a NAK when nothing is lost;
    `on_ackack` matches an ACKACK against its outstanding Full ACK and
    updates RTT/RTTVar from the measured round trip (rules 26-30).
  - `arq::rtt::RttEstimator` — the rule 29-31 RTT/RTTVar EWMA (`RTT = 7/8 *
    RTT + 1/8 * rtt`, `RTTVar = 3/4 * RTTVar + 1/4 * abs(RTT - rtt)`,
    microseconds, initial 100 ms / 50 ms), shared by both roles.
  - `arq::seq` — wrap-safe 31-bit sequence-number arithmetic (circular
    comparison/increment, comparable to RFC 1982 serial number arithmetic;
    the draft does not itself specify a comparison algorithm, so this is
    implementation-defined, documented as such).
  - Timing is entirely caller-driven (`now: core::time::Duration` passed to
    every `tick`/`feed_data`/`on_data`/`on_ack`/`on_ackack` call) — no
    wall-clock read anywhere in the crate.
  - `tests/arq_recovery.rs` — an in-memory `Sender`<->`Receiver` wiring (no
    sockets) that drops two packets in transit, asserts the receiver's NAK
    triggers sender retransmission, all packets are ultimately delivered in
    order, the ACK/ACKACK exchange advances the sender's acknowledged
    sequence and frees its send buffer, RTT converges toward an injected
    30 ms round trip on both sides, and a zero-loss run never emits a
    spurious NAK (from either the immediate or periodic path).
  - Explicit non-goals, unchanged from prior releases: TLPKTDROP fake-ACK
    skip handling, RTO-based/congestion-control retransmission, send-queue
    overflow sizing, TSBPD delivery timing.
- **§6 SRT payload encryption primitives** (issue #608), behind a new
  non-default `crypto` feature (zero new dependencies for the default/no_std
  packet-codec core):
  - `crypto::aes_ctr_apply` — AES-CTR payload encrypt/decrypt (self-inverse).
    The per-packet counter (`crypto::packet_counter`) is derived from the Key
    Material `Salt` and the data packet's Packet Sequence Number per the
    §6.2.2/§6.3.2 formula `IV = (MSB(112, Salt) << 2) XOR PktSeqNo` — the
    draft gives a second, textually different formula in §6.1.2 that this
    crate deliberately does *not* implement; both are transcribed and the
    conflict documented in `specs/rules/srt-crypto.md` and the `crypto`
    module doc.
  - `crypto::wrap_sek` / `crypto::unwrap_sek` — RFC 3394 AES key wrap/unwrap
    of the SEK under the KEK (§6.1.5/§6.2.1/§6.3.1), split as
    `(icv, wrapped)` to match `packet::KeyMaterial`'s `icv`/`x_sek`/`o_sek`
    fields; supports wrapping one or two concatenated SEKs (`KK` = even/odd
    vs. both).
  - `crypto::derive_kek` — KEK derivation from a pre-shared passphrase via
    PBKDF2 (HMAC-SHA1, 2048 iterations) per §6.1.4/§6.2.1/§6.3.1, salted with
    the Key Material `Salt`'s low 64 bits (`LSB(64,Salt)`).
  - `crypto::select_sek` — picks the even/odd SEK for a data packet from its
    `KK` field (`packet::EncryptionKeyField`).
  - AES-128/192/256 (`KLen` 16/24/32 bytes) supported throughout, selected by
    key length at runtime.
  - Uses the `aes`/`ctr`/`aes-kw`/`pbkdf2`/`hmac`/`sha1` RustCrypto crates
    (all `no_std`) — no hand-rolled crypto.
  - `tests/crypto_vectors.rs` — external ground truth, not spec-vector-free
    self-checks: the RFC 3394 §4.1 worked key-wrap vector (byte-exact wrap
    *and* unwrap), the NIST SP 800-38A Appendix F.5.1 CTR-AES128 vector
    (byte-exact both directions), and an SRT-specific SEK+Salt+PktSeqNo
    payload round-trip including a wrong-SEK-does-not-recover negative case.
    `draft-sharabayko-srt-01` §6 has no test vectors of its own
    (`specs/rules/srt-crypto.md`).
- **Sans-IO Rendezvous handshake state machine** (§4.3.2, issue #609, curated
  at `specs/rules/srt-rendezvous.md`), reusing the same shared
  `handshake_sm` types and packet codecs as the Caller-Listener flow:
  - `rendezvous::RendezvousHandshake` — a single, symmetric engine: both
    peers run the same code. `start()` sends WAVEAHAND (Version 5, this
    side's own cookie); the **cookie contest** (greater cookie wins) resolves
    each side's `rendezvous::RendezvousRole` (`Initiator`/`Responder`) at
    runtime from the first inbound message's cookie. Drives the
    `Waving -> Attention -> Initiated -> Connected` states (Parallel
    Handshake Flow, §4.3.2.2) with the full Initiator/Responder transition
    tables, including the idempotent missing-packet recovery rules
    (§4.3.2.2: a Responder stuck in `Initiated` always re-sends HSRSP on a
    repeated HSREQ; may promote to `Connected` on non-handshake traffic via
    `on_recovery_trigger()`, modeling "as if it had received AGREEMENT").
    The Serial flow (§4.3.2.1) is handled by the same engine, not a separate
    state — see the module docs' "Serial vs Parallel flow" note for why.
  - Identical cookies are surfaced as `RejectionReason::RdvCookie` (Table 7
    code `1009`, "rendezvous cookie collision") rather than an internal
    retry loop.
  - `tests/rendezvous_round_trip.rs` — two `RendezvousHandshake` peers wired
    together in memory (no sockets), both reaching `Connected` with
    cross-matching negotiated socket ids and the greater-of-both latency;
    a deterministic cookie tie-break test; and a malformed-extension-mid-flow
    test asserting a structured rejection, never a panic.
  - `tests/no_panic.rs` extended to fuzz the Rendezvous engine at Waving,
    Attention, and Initiated.
  - Explicit non-goals, unchanged: TSBPD delivery, congestion control, a
    `tokio` socket adapter, and the Version-4 legacy Rendezvous path.
- **Sans-IO HSv5 Caller-Listener handshake state machine** (§4.3.1, issue
  #598), driving the existing packet codecs from #565 — no raw handshake
  bytes are hand-encoded:
  - `caller::CallerHandshake``start()` builds the INDUCTION handshake
    (Version 4, `Extension Field` `2` per §4.3.1.1's legacy UDT socket-type
    quirk); `feed()` consumes the Listener's INDUCTION response (validating
    Version 5 + the `0x4A17` SRT magic code), builds the CONCLUSION handshake
    (captured SYN Cookie, HSREQ + optional Stream ID / Group Membership
    extensions), then consumes the Listener's CONCLUSION response to reach
    `CallerHandshakeState::Connected` with a `handshake_sm::NegotiatedParams`.
  - `listener::ListenerHandshake` — the mirror: replies to INDUCTION with a
    cookie (`handshake_sm::derive_cookie` is a ready-made, non-standardized
    derivation helper — the draft specifies only the semantic inputs, not a
    wire algorithm); validates the Caller's CONCLUSION (`Handshake Type`,
    `Version`, the echoed SYN Cookie, and every extension block), replying
    with HSRSP + optional Group on success or a Table 7 rejection packet
    (`Handshake Type` = `1000 + code`) on failure.
  - `handshake_sm::RejectionReason` — the full §4.3 Table 7 Handshake
    Rejection Reason set, with `name()` + `Display` (issue #204 convention).
  - `handshake_sm::HandshakeConfig` / `NegotiatedParams` / `HandshakeOutput`    the negotiation input/output and driven-engine event type. Latency is
    negotiated as the greater of both parties' TSBPD delay (§4.3.1.2); flags
    as the bitwise AND of both parties' advertised `SRT Flags`.
  - Timeouts/retransmits are modeled as caller-driven `tick()` calls — no
    wall-clock read anywhere in the crate.
  - `tests/handshake_round_trip.rs` — a full in-memory Caller<->Listener
    handshake (no sockets, no bytes touching a network) reaching `Connected`
    on both sides with cross-matching negotiated version/latency/socket
    ids/Stream ID/Group; plus a forged-cookie rejection path asserting the
    Table 7 wire encoding on the rejection packet actually sent.
  - `tests/no_panic.rs` extended to feed arbitrary parsed handshake packets
    into both engines at every state that accepts inbound packets.
  - Explicit non-goals, unchanged from `0.1.0`: ARQ/loss, TSBPD delivery,
    congestion control, AES key-wrap/unwrap crypto, and a `tokio` socket
    adapter. (The Rendezvous handshake, §4.3.2, is no longer a non-goal —
    see above.)

### Fixed

- **Tokio UDP adapter (`io.rs`) — loss recovery now genuinely works end-to-end**
  (release-audit findings S1–S4). The adapter previously could not recover lost
  packets over a real socket; the driver loop, packet pacing, dest-socket-id,
  and error mapping were all wrong.
  - **Background driver task per connection (S2 root cause).** `SrtSocket` is now
    a handle over a background task that runs a `tokio::select!` loop
    (socket RX / application-send / periodic `tokio::time::interval` tick). The
    old pull-based `send`/`recv` only advanced the protocol while the app was
    inside a call, so a fire-and-forget sender went dormant and never drained
    inbound NAKs or emitted retransmissions — loss recovery deadlocked. The
    periodic tick arm keeps retransmit/ACK/NAK progressing regardless of
    application call timing. Retransmits (drained from the NAK loss list by
    `arq::Sender::tick`) are queued ahead of new first-time data each cycle,
    preserving the spec's loss-list-before-first-transmission priority
    (`draft-sharabayko-srt-01` §4.8.2, rules 5/15/16).
  - **Single in-order delivery cursor.** Delivery is now driven solely by the
    TSBPD scheduler; the ARQ receiver drives reliability (loss detection / NAK /
    ACK point) only. Previously both cursors delivered from one staging map and
    raced, reordering retransmitted packets. TLPKTDROP is disabled in the
    adapter so a NAK-recovered gap is waited for, not skipped — `recv` delivers
    every payload in order.
  - **LiveCC pacing applied to DATA packets only (S1).** `PKT_SND_PERIOD` (§5.1)
    now paces original/retransmitted DATA packets and never throttles
    ACK/NAK/ACKACK/Keep-Alive control feedback (which loss recovery rides on).
    `LiveCC` is fed payload sizes at each data send and the send period is read
    where a data packet is actually emitted, instead of being misapplied once
    per flush to every datagram.
  - **Correct peer socket id + real SYN cookie (S3).** Outgoing packets now use
    the peer's *negotiated SRT Socket ID* (`NegotiatedParams::peer_socket_id`)
    as `dest_socket_id`, not its initial sequence number. The listener's SYN
    cookie is derived via `handshake_sm::derive_cookie` from the peer address, a
    1-minute time bucket, and a per-listener random secret (§4.3.1.1), replacing
    the hard-coded `0xC0FFEE42` constant.
  - **I/O errors preserve context (S4).** A new `Error::Io { kind, context }`
    variant carries the `std::io::ErrorKind` and the failing call site
    (`bind`/`connect`/`recv`/`send`/…), so e.g. a bind failure is
    distinguishable from a mid-connection reset — replacing the previous
    flatten-everything-to-`InvalidField{reason:"io error"}`.
  - New `tests/io_loss_recovery.rs`: a loss-injecting UDP relay drops a
    deterministic subset of first-time DATA packets between caller and listener;
    the test sends 40 payloads and asserts all arrive in order, byte-identical,
    proving NAK→retransmit recovery *through `io.rs`* (wrapped in a 15 s
    `tokio::time::timeout`).
- **`#[non_exhaustive]` on forward-evolving public types** (release-audit
  dimension F): `livecc::MaxBwConfig`, `arq::FeedOutcome`, `tsbpd::TickOutcome`,
  and `rendezvous::RendezvousRole`.

## [0.1.0] - 2026-07-04

Initial scaffold — SRT ([`draft-sharabayko-srt-01`](https://datatracker.ietf.org/doc/html/draft-sharabayko-srt-01))
packet codecs (issue #565).

### Added

- **Packet dispatch** (`SrtPacket`) — parses the 16-byte SRT header's `F` bit
  to route to a data or control packet (§3, Figure 2).
- **Data packet** (`DataPacket`, §3.1) — sequence number, `PacketPosition`
  (First/Middle/Last/Solo), order flag, `EncryptionKeyField`, retransmitted
  flag, message number, and the opaque payload.
- **Control packets** (`ControlPacket`, §3.2), one struct per Table 1 type:
  - `HandshakePacket` (§3.2.1) — `EncryptionField`, `HandshakeType`, and a
    lazily-walked `HandshakeExtensions` loop (mirroring `dvb-si`'s descriptor
    loop convention) with typed decoders for the Handshake Extension Message
    (`HsExtMessage`, §3.2.1.1), Key Material (§3.2.1.2), Stream ID
    (`as_stream_id`, §3.2.1.3 — including the 32-bit-little-endian-word
    storage quirk), and Group Membership (`GroupMembershipExtension`,
    §3.2.1.4).
  - `KeyMaterial` (§3.2.2) — the full KEKI/Cipher/Auth/SE/Salt/ICV/xSEK/oSEK
    layout, with the `S`/`V`/`PT`/`Sign`/reserved fixed-value fields
    validated (not stored). Carries wrapped-key bytes opaquely — no AES
    key-wrap/unwrap.
  - `KeepAlivePacket`, `CongestionWarningPacket`, `ShutdownPacket` (§3.2.3,
    §3.2.6, §3.2.7).
  - `AckPacket` with `AckCif::{Full,Small,Light}` (§3.2.4), selected by CIF
    length.
  - `NakPacket` (§3.2.5) with lazy `LossListEntry` (Single/Range) decoding
    per Appendix A's sequence-number coding.
  - `AckAckPacket`, `DropReqPacket`, `PeerErrorPacket` (§3.2.8-§3.2.10).
  - `UserDefinedPacket` for Control Type `0x7FFF` / undefined types, with
    `as_key_material()` for the Key Material-over-control-packet delivery
    form.
- Every public spec/field enum (`PacketPosition`, `EncryptionKeyField`,
  `ControlType`, `EncryptionField`, `HandshakeType`, `ExtensionType`,
  `GroupType`, `KmKeyFlag`, `Cipher`, `KmAuth`, `StreamEncapsulation`) has a
  `name()` + `Display` (issue #204 convention), enforced by
  `tests/label_coverage.rs`.
- Reserved/fixed-value fields (`Subtype`, the header `Type-specific
  Information` word where unused, the Key Material fixed fields) are
  validated on parse and not stored — see the crate root's reserved-bit
  policy.
- `tests/no_panic.rs` — a deterministic-PRNG fuzz-smoke test feeding
  truncated/random bytes to every parser and lazy-loop iterator.
- `no_std` + `alloc` core (default `std` feature togglable); no `unsafe`
  (`#![forbid(unsafe_code)]`).

### Explicit non-goals for this release

- Handshake state machine (caller/listener/rendezvous, §4.3).
- ARQ/loss handling, TSBPD, congestion control (§4-§5).
- AES key-wrap/unwrap crypto (§6).
- `tokio` socket adapter.

[Unreleased]: https://github.com/fishloa/rust-broadcast/compare/main...HEAD