Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2c76486
feat(video): introduce NANORTC_VIDEO_PKT_RING_SIZE macro + pkt_ring_t…
claude Apr 22, 2026
d972066
feat(video): wire pkt_ring_tail cursor + honor PKT_RING_SIZE in write…
claude Apr 22, 2026
e7c3dcd
test(video): regression for pkt_ring decoupling + document PKT_RING_S…
claude Apr 22, 2026
91d175b
docs(video): fix inaccurate ~19 KB claim in PKT_RING_SIZE guidance
claude Apr 23, 2026
c1cb699
feat(video): detect pkt_ring overrun + bump stats counter
0x1abin Apr 23, 2026
984e46e
test(video): aliasing regression for pkt_ring overrun detection
0x1abin Apr 23, 2026
b08b9cf
docs(video): tighten PKT_RING_SIZE sizing guidance to per-frame fragm…
0x1abin Apr 23, 2026
d597999
docs(phase8): mark PR-3 completed, record review-driven design change
0x1abin Apr 23, 2026
144daba
Merge pull request #54 from 0x1abin/claude/plan-next-steps-kFsdg
0x1abin Apr 23, 2026
0ed7e64
chore: merge origin/main into develop
0x1abin Apr 25, 2026
ba32abf
docs(video): clarify pkt_ring sizing rule + overrun counter wording
0x1abin Apr 25, 2026
75dc6ba
refactor(video): extract pkt_ring_alloc_slot + commit_slot helpers
0x1abin Apr 25, 2026
67466ba
docs(phase8): mark PR-4 completed and refresh PR-3 record
0x1abin Apr 25, 2026
814bcab
fix(sctp): emit DISCONNECTED on retransmit exhaustion
0x1abin Apr 25, 2026
c0c928d
docs(phase8): mark PR-2 completed
0x1abin Apr 25, 2026
8448f72
Merge branch 'main' into develop
0x1abin Apr 25, 2026
55ee8f5
feat(ci): expand fuzz job to fuzz_h265 + fuzz_turn
0x1abin Apr 26, 2026
dbb9cd4
ci(interop): add nanortc-as-TURN-client relay job
0x1abin Apr 26, 2026
d59a528
fix(rtc): defer DTLS_HANDSHAKING state until dtls_start succeeds
0x1abin Apr 26, 2026
8fea1e8
fix(rtc): tear down DTLS context on ice_restart
0x1abin Apr 26, 2026
183eda3
test(crc): add RFC 1952 / 3309 / 3720 vector tests
0x1abin Apr 26, 2026
3b2d026
fix(tests): gate test_crc32 on DATACHANNEL
0x1abin Apr 26, 2026
ea52028
ci(esp-idf): add compile-only build workflow for examples
0x1abin Apr 26, 2026
0a87d1f
fix(esp32_video): pin default flash size to 4MB in sdkconfig.defaults
0x1abin Apr 26, 2026
19b273f
ci(esp-idf): fetch submodules in checkout for esp32_video sample data
0x1abin Apr 26, 2026
9a75412
fix(rtc): clear cached fingerprints on ice_restart
0x1abin Apr 26, 2026
af8f566
docs(rtc): correct rtc_begin_dtls_handshake state-failure comment
0x1abin Apr 26, 2026
9e8fda2
Merge remote-tracking branch 'origin/main' into develop
0x1abin Apr 26, 2026
cc5a149
test(rtc): add e2e regression tests for ice_restart hardening
0x1abin Apr 27, 2026
ecacebb
docs(phase8): record post-PR-5 ice_restart hardening as TD-022
0x1abin Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/esp-idf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: ESP-IDF Build

# Build nanortc ESP-IDF examples to catch component / Kconfig / lwIP regressions
# without requiring real hardware. Compile-only — no flashing, no on-device tests.
#
# The Python E2E test (tests/esp32/test_esp32_dc.py) requires hardware or QEMU and
# is intentionally not part of this workflow; it runs as workflow_dispatch / manual.

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
esp-idf-build:
name: ESP-IDF ${{ matrix.example.target }} / ${{ matrix.example.path }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
example:
# esp32_datachannel: minimal config — exercise nanortc on every supported target
- { path: esp32_datachannel, target: esp32 }
- { path: esp32_datachannel, target: esp32s3 }
- { path: esp32_datachannel, target: esp32p4 }
# esp32_audio / esp32_video: representative target only
# (the codec stacks are target-agnostic; if datachannel builds on all
# three, the additional codec components only need one verification)
- { path: esp32_audio, target: esp32s3 }
- { path: esp32_video, target: esp32s3 }
# esp32_camera (P4 only) is intentionally excluded — its
# board_manager.defaults autogen + camera-sensor selection is
# board-specific and will be added in a follow-up workflow.
steps:
- uses: actions/checkout@v4
with:
# esp32_video bakes sample_data submodule frames into video.blob /
# audio.blob at build time; without this, those targets fail with
# "sample_data/h264SampleFrames is not a directory".
submodules: recursive

- name: ESP-IDF build (${{ matrix.example.target }})
uses: espressif/esp-idf-ci-action@v1
with:
esp_idf_version: v5.5
target: ${{ matrix.example.target }}
path: examples/${{ matrix.example.path }}
15 changes: 15 additions & 0 deletions docs/exec-plans/active/phase8-continued-optimization.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,21 @@ This bug has existed since the initial ICE implementation; Phase 4's interop sui

**Dependency.** None — independent of PR-1..PR-4. Can land in parallel with any of them. Recommend landing early in Phase 8 because it unblocks real browser-as-answerer deployments.

### Post-merge hardening

Landed 2026-04-26 → 2026-04-27. A Copilot review on PR #59 surfaced three latent correctness gaps in the restart path that the original PR-5 work didn't cover. All three landed on `develop` as small follow-up commits, then were pinned with regression tests:

| Commit | Fix | Reverting it now fails |
|---|---|---|
| `d59a528` | Defer `state = NANORTC_STATE_DTLS_HANDSHAKING` until `dtls_start()` returns OK so a failure leaves the FSM at `ICE_CONNECTED` and is retryable. | `tests/test_trickle_ice.c::test_api_ice_restart_clears_dtls` (FSM-level). |
| `8fea1e8` | Call `dtls_destroy(&rtc->dtls)` first thing in `nanortc_ice_restart()` so the next handshake re-runs `dtls_init()` instead of skipping it via the `if (!crypto_ctx)` guard. | `tests/test_e2e.c::test_e2e_ice_restart_dtls_rehandshake` — without the destroy, the post-restart fingerprint matches the pre-restart fingerprint (cert reused). |
| `9a75412` | `memset` both `sdp.local_fingerprint` and `dtls.local_fingerprint` after the destroy so `rtc_cache_fingerprint()` (a write-once cache) re-populates from the freshly generated cert. | `tests/test_e2e.c::test_e2e_ice_restart_sdp_fingerprint_refresh` — without the clear, the SDP cache holds the stale hash while DTLS holds the new one, so any peer that enforces `a=fingerprint` rejects the new handshake. |
| `af8f566` | Doc-only: corrected the rationale comment so the FSM-vs-receive-path distinction is explicit. | n/a (doc). |

The two new E2E tests bracket the unit-level T20/T21 coverage with a full ICE → DTLS round trip on both sides, restart, re-sync, reconnect, and assert new keying material — closing the gap that the unit tests probe state in isolation but cannot show that two peers actually re-handshake. Validation: each new e2e test was confirmed to fail when its target commit is surgically reverted in the working tree (and to pass on baseline `develop`).

See [tech-debt-tracker.md TD-022](../tech-debt-tracker.md#resolved-debt) for the consolidated entry.

---

## P2 Series (on-demand)
Expand Down
1 change: 1 addition & 0 deletions docs/exec-plans/tech-debt-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Track known debt, prioritize by impact, pay down continuously.
| TD-018 | 2026-04-13 | Phase 8 / PR-5: Replaced the single `last_txid` / `last_{local,remote}_idx` scratch in `nano_ice_t` with a fixed per-pair pending-transaction table (`nano_ice_pending_t pending[NANORTC_ICE_MAX_PENDING_CHECKS=4]`). `ice_generate_check` allocates a slot (free → stale → reap-oldest, RFC 8445 §6.1.4.2); `ice_handle_stun` Binding Response branch scans the table for a txid match, uses the captured pair indices to populate `selected_*`, and frees the slot after MESSAGE-INTEGRITY verifies. Added `selected_local_idx` for `ice_generate_consent` (previously read a stale `last_local_idx`). Secondary fix: ICE FAILED path in `rtc_process_timers` now emits `NANORTC_EV_DISCONNECTED` symmetric to the consent-expiry path so applications see a consistent signal across both failure modes. New config macros `NANORTC_ICE_MAX_PENDING_CHECKS` / `NANORTC_ICE_CHECK_TIMEOUT_MS` with compile-time validation. New regression tests: `test_ice_controlling_multi_pair_response_out_of_order` (feeds the 2nd of 3 responses — pre-fix rejected, post-fix selects the correct pair), `test_ice_controlling_pending_table_full` (asserts reap-oldest makes forward progress), and augmented `test_e2e_ice_connection_timeout` to assert both `EV_ICE_STATE_CHANGE(FAILED)` and `EV_DISCONNECTED`. `sizeof(nano_ice_t)` stays under the 600 B ceiling. All 6 feature combos × 2 crypto backends + ASan + interop green. |
| TD-021 | 2026-04-17 | ICE hardening pass per RFC 8445 / 8489 / 7675: (a) `ice_handle_stun()` now rejects incoming Binding Request / Response without MESSAGE-INTEGRITY or FINGERPRINT (§7.1.2.1, §7.1.3, §8.1). (b) `nanortc_init()` fills `ice.tie_breaker` via `cfg->crypto->random_bytes()` (§5.2). (c) `src/nano_sdp.c` emits srflx/relay priorities via `ICE_SRFLX_PRIORITY(idx)` / `ICE_RELAY_PRIORITY(idx)` with the runtime-matching slot index so SDP-advertised priority equals STUN PRIORITY (§5.1.2.1). (d) New `STUN_BINDING_ERROR` (0x0111) handler matches txid, frees the pending slot, and surfaces the error to the caller (§7.3.1.1 / RFC 8489 §6.3.4); full 487 auto-swap is a follow-up. (e) `ice_generate_check()` short-circuits in DISCONNECTED (consent lost — recovery requires `ice_restart`, RFC 7675 §5.2). (f) `ice_consent_expired()` now surfaces an unarmed `consent_expiry_ms` as expired instead of silently returning false, so a forgotten-arm bug fails loud rather than disabling the liveness timeout. Six new unit tests (`test_ice_request_without_fingerprint_rejected`, `test_ice_response_without_integrity_rejected`, `test_ice_binding_error_frees_pending_slot`, `test_ice_generate_check_noop_in_disconnected`, `test_e2e_tie_breaker_is_randomised`, updated `test_consent_expired_when_unarmed`) pin each behavior. Full module audit at [docs/engineering/ice-rfc-compliance.md](../engineering/ice-rfc-compliance.md). 20/20 ctest + 15/15 fast CI green across feature combos. |
| TD-020 | 2026-04-17 | ICE pair formation enforces RFC 8445 §6.1.2.2: `ice_advance_to_same_family_pair()` in `src/nano_ice.c` advances past cross-family (local, remote) combinations before allocating a pending slot, so check budget is only spent on pairs a UDP socket can actually carry. Linux run-loop (`examples/common/run_loop_linux.c`) and `browser_interop` (`examples/browser_interop/main.c`) now enumerate `AF_INET6` interfaces alongside `AF_INET`, skipping link-local / multicast / unspecified; global IPv6 addresses are registered as additional local candidates via `nanortc_add_local_candidate()`. End-to-end covered by `test_e2e_ipv6_loopback_connects` (two `nanortc_t` reach DTLS_CONNECTED over `[::1]` with `selected_family == 6`) and three unit tests `test_ice_pair_family_filter_*` (skip cross-family, pick both same-family pairs, emit no packet when no same-family pair exists). All 20 ctest suites + fast CI (clang-format, ASan, `ICE_SRFLX=OFF`, `IPV6=OFF`) green. |
| TD-022 | 2026-04-27 | Post-PR-5 ICE-restart hardening (Copilot review on PR #59). Three correctness fixes plus a doc rewording, all in `nanortc_ice_restart()` / `rtc_begin_dtls_handshake()` (`src/nano_rtc.c:2320–2393`, `:949–:980`): (a) `d59a528` defers `state = NANORTC_STATE_DTLS_HANDSHAKING` until `dtls_start()` succeeds — on failure the FSM stays at `ICE_CONNECTED` so the caller can retry; (b) `8fea1e8` calls `dtls_destroy(&rtc->dtls)` first thing in restart so the next handshake re-runs `dtls_init()` instead of skipping it via the `if (!crypto_ctx)` guard, preventing key-material reuse and orphan handshake timers across the new ICE 5-tuple; (c) `9a75412` `memset`s both `sdp.local_fingerprint` and `dtls.local_fingerprint` after the destroy — `rtc_cache_fingerprint()` is a write-once cache that bails on non-empty input, so without the clear the next `create_offer` / `accept_offer` would advertise the *previous* cert's hash while `dtls_init()` generates a new cert (DTLS verify then fails on any peer that enforces `a=fingerprint`); (d) `af8f566` corrects the now-misleading rationale comment to make the FSM-vs-receive-path distinction explicit. Unit coverage: `test_api_ice_restart_clears_dtls` + `test_api_ice_restart_no_dtls_safe` in `tests/test_trickle_ice.c`. E2E regression coverage added 2026-04-27: `test_e2e_ice_restart_dtls_rehandshake` (full ICE → DTLS → restart → reconnect roundtrip; asserts new cert hash differs from pre-restart) and `test_e2e_ice_restart_sdp_fingerprint_refresh` (asserts `sdp.local_fingerprint` re-syncs to the freshly generated `dtls.local_fingerprint` across the restart) in `tests/test_e2e.c`. Validation matrix in [docs/exec-plans/active/phase8-continued-optimization.md](active/phase8-continued-optimization.md#post-merge-hardening) confirms each new e2e test fails when its target commit is surgically reverted. `scripts/ci-check.sh --fast` 15/15 green; full host suite 22/22. |
| TD-019 | 2026-04-15 | Phase 5.2: nanortc-as-TURN-client outbound data path. **F6** added a `bool via_turn` parameter to `ice_handle_stun()` and `rtc_process_receive()`; recursive calls from `turn_unwrap_data` / `turn_unwrap_channel_data` set it true so USE-CANDIDATE checks select `selected_type=NANORTC_ICE_CAND_RELAY` on relay paths. **F7** deferred the TURN wrap from `rtc_enqueue_transmit()` to `nanortc_poll_output()` via a per-slot `out_wrap_meta[]` side-table + dedicated `turn_buf` (sized via `NANORTC_TURN_BUF_SIZE`), preventing eager-wrap collisions when bursts of media share `stun_buf` scratch. **F8** routed RFC 7675 consent freshness checks through `rtc_enqueue_transmit()` so they get wrapped on RELAY pairs (previously bypassed via `rtc_enqueue_output` direct, dropping ~30 s after handshake). **F9** moved `CreatePermission` fan-out into `rtc_process_timers()`: each tick walks `remote_candidates[]` and emits one `CreatePermission` for the first peer without an active permission (previously only `remote_candidates[0]` got one, missing trickle-late relay/srflx candidates). **F10** added `stats_enqueue_via_turn` / `stats_enqueue_direct` / `stats_wrap_dropped` / `stats_tx_queue_full` counters on `nanortc_t` for runtime observability of the wrap path (16 B overhead, reserved for a future public stats API). Hand-verified end-to-end on a downstream macOS camera SDK example with a real cellular phone viewer (`type=relay tx_via_turn=8 tx_direct=0 wrap_drop=0`). All 22 nanortc unit tests + 5 SDK unit tests pass. **Coverage gap:** `tests/interop/test_interop_turn_relay.c` only exercises the libdc-as-relay direction; nanortc-as-relay direction has no automated test yet. See [docs/engineering/turn-rfc-compliance.md](../engineering/turn-rfc-compliance.md) Phase 5.2 section for the full F6–F10 finding table. |

## Principles
Expand Down
7 changes: 6 additions & 1 deletion examples/esp32_video/sdkconfig.defaults
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# nanortc ESP32 A/V example — default SDK configuration
#
# Custom partition table (factory = ~12 MB for firmware + embedded media blobs)
# Custom partition table (factory ~3.6 MB) — needs >= 4 MB flash.
# Default IDF flash size is 2 MB, so partition-table generation fails
# without an explicit FLASHSIZE here. Boards with larger flash can
# override via menuconfig.
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"

Expand Down
17 changes: 14 additions & 3 deletions src/nano_rtc.c
Original file line number Diff line number Diff line change
Expand Up @@ -947,9 +947,11 @@ int nanortc_poll_output(nanortc_t *rtc, nanortc_output_t *out)
}

/* Init DTLS (if needed) and begin handshake after ICE connects.
* State is only advanced to DTLS_HANDSHAKING after dtls_start() succeeds,
* so a failure leaves state at ICE_CONNECTED and the caller can retry
* without a half-initialised DTLS context being driven by inbound bytes. */
* State is only advanced to DTLS_HANDSHAKING after dtls_start() succeeds.
* On failure, state remains at ICE_CONNECTED so the caller can retry
* DTLS startup from the ICE-connected state. Note: this only governs
* the FSM; the inbound DTLS gate is handle_input's `state >= ICE_CONNECTED`
* check, so DTLS records remain accepted on the failure-retry path. */
static int rtc_begin_dtls_handshake(nanortc_t *rtc, const nanortc_addr_t *src)
{
int is_server = (rtc->sdp.local_setup == NANORTC_SDP_SETUP_PASSIVE);
Expand Down Expand Up @@ -2331,6 +2333,15 @@ int nanortc_ice_restart(nanortc_t *rtc)
* timers. dtls_destroy is a no-op when crypto_ctx is NULL. */
dtls_destroy(&rtc->dtls);

/* Invalidate cached fingerprints tied to the destroyed DTLS identity.
* rtc_cache_fingerprint() is a write-once cache that bails when
* sdp.local_fingerprint is non-empty; without this clear, the next
* create_offer/accept_offer would advertise the *previous* cert's
* fingerprint while dtls_init() generates a new one — DTLS verify
* would then fail on the peer side. */
memset(rtc->sdp.local_fingerprint, 0, sizeof(rtc->sdp.local_fingerprint));
memset(rtc->dtls.local_fingerprint, 0, sizeof(rtc->dtls.local_fingerprint));

/* Reset ICE state (preserves role + tie_breaker, bumps generation) */
int rc = ice_restart(&rtc->ice);
if (rc != NANORTC_OK) {
Expand Down
Loading
Loading