From b071c22b716228268776b8a70641507c84aa0006 Mon Sep 17 00:00:00 2001 From: Lee Yunjin Date: Sun, 4 Oct 2026 22:16:56 +0900 Subject: [PATCH 1/7] feat(webrtc): DataChannel support (SDP + ICE-lite + DTLS + SCTP over DTLS) Adds a DataChannel-only WebRTC stack behind CWIST_WEBRTC=1 (default on), per the v3.9 scope tracked in #308: - SDP offer/answer for m=application datachannel (RFC 4566 subset): ice-ufrag/ice-pwd, fingerprint SHA-256, setup roles, mid, host candidate, sctp-port, ice-lite. - ICE-lite agent (RFC 5389/8445): STUN binding request validation with MESSAGE-INTEGRITY (ice-pwd keyed HMAC-SHA1), XOR-MAPPED-ADDRESS responses with FINGERPRINT, USE-CANDIDATE nomination. The browser stays controlling. - DTLS over the nominated UDP path using the vendored BoringSSL (DTLS_server_method / DTLS_client_method, memory BIOs, plain DTLS, no SRTP). Ephemeral self-signed P-256 certificate per ctx. - SCTP DataChannels tunneled over DTLS (RFC 8831) via the new lib/usrsctp submodule in AF_CONN raw mode, plus DCEP open/ack (RFC 8832) and ordered binary/string message delivery. - Public API in include/cwist/net/webrtc.h: ctx bound to a UDP port, cwist_webrtc_handle_offer(), message/channel callbacks, send, close. tests/test_webrtc.c drives the full stack over UDP loopback between two in-process ctxs (answering ICE-lite endpoint + offering peer) and exchanges DataChannel messages in both directions; it joins make test. example/webrtc contains a browser-interop demo (RTCPeerConnection + POST /offer signaling). Known limitations (MVP): ICE-lite only (no STUN/TURN server, no trickle candidate parsing from offers), no peer fingerprint verification, ordered delivery only, IPv4 host candidates, one bundled m-line. --- .gitmodules | 3 + Makefile | 65 ++++ example/webrtc/Makefile | 25 ++ example/webrtc/README.md | 40 ++ example/webrtc/index.html | 48 +++ example/webrtc/main.c | 85 +++++ include/cwist/net/webrtc.h | 93 +++++ lib/usrsctp | 1 + src/net/webrtc/dtls.c | 93 +++++ src/net/webrtc/ice.c | 405 ++++++++++++++++++++ src/net/webrtc/sctp.c | 310 +++++++++++++++ src/net/webrtc/sdp.c | 111 ++++++ src/net/webrtc/webrtc.c | 629 +++++++++++++++++++++++++++++++ src/net/webrtc/webrtc_internal.h | 164 ++++++++ tests/test_webrtc.c | 179 +++++++++ 15 files changed, 2251 insertions(+) create mode 100644 example/webrtc/Makefile create mode 100644 example/webrtc/README.md create mode 100644 example/webrtc/index.html create mode 100644 example/webrtc/main.c create mode 100644 include/cwist/net/webrtc.h create mode 160000 lib/usrsctp create mode 100644 src/net/webrtc/dtls.c create mode 100644 src/net/webrtc/ice.c create mode 100644 src/net/webrtc/sctp.c create mode 100644 src/net/webrtc/sdp.c create mode 100644 src/net/webrtc/webrtc.c create mode 100644 src/net/webrtc/webrtc_internal.h create mode 100644 tests/test_webrtc.c diff --git a/.gitmodules b/.gitmodules index 50ca346a5..9c39b5eb2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -26,3 +26,6 @@ path = lib/multipart-parser-c url = https://github.com/iafonov/multipart-parser-c.git branch = main +[submodule "lib/usrsctp"] + path = lib/usrsctp + url = https://github.com/sctplab/usrsctp diff --git a/Makefile b/Makefile index 8a3f62e30..8f7ea0c52 100644 --- a/Makefile +++ b/Makefile @@ -101,6 +101,10 @@ LSQUIC_DIR = lib/lsquic LSQUIC_BUILD_DIR = $(LSQUIC_DIR)/build LSQUIC_LIB = $(LSQUIC_BUILD_DIR)/src/liblsquic/liblsquic.a +USRSCTP_DIR = lib/usrsctp +USRSCTP_BUILD_DIR = $(USRSCTP_DIR)/build +USRSCTP_LIB = $(USRSCTP_BUILD_DIR)/usrsctplib/libusrsctp.a + CURL_LIBS := $(shell pkg-config --libs libcurl 2>/dev/null) NGHTTP2_LIBS := $(shell pkg-config --libs libnghttp2 2>/dev/null) BROTLI_LIBS := $(shell pkg-config --libs libbrotlienc libbrotlicommon libbrotlidec 2>/dev/null) @@ -140,6 +144,15 @@ ifeq ($(CWIST_WEBTRANSPORT),1) CFLAGS += -DCWIST_WEBTRANSPORT endif +# WebRTC DataChannel (src/net/webrtc/). DataChannel-only: SDP offer/answer, +# ICE-lite, DTLS via the vendored BoringSSL, and SCTP DataChannels (RFC 8831) +# through the lib/usrsctp submodule running in AF_CONN raw mode tunneled over +# DTLS. Set CWIST_WEBRTC=0 to leave it out. +CWIST_WEBRTC ?= 1 +ifeq ($(CWIST_WEBRTC),1) + CFLAGS += -DCWIST_WEBRTC -I$(USRSCTP_DIR)/usrsctplib +endif + # Detect OS IO_SRC = src/sys/io/io_select.c # Default fallback @@ -699,6 +712,16 @@ EXTERNAL_LIBS = $(URIPARSER_LIB) \ $(BORINGSSL_SSL_LIB) \ $(BORINGSSL_CRYPTO_LIB) +ifeq ($(CWIST_WEBRTC),1) + SRCS += src/net/webrtc/webrtc.c \ + src/net/webrtc/ice.c \ + src/net/webrtc/sdp.c \ + src/net/webrtc/dtls.c \ + src/net/webrtc/sctp.c + EXTERNAL_LIBS += $(USRSCTP_LIB) + LIBS += $(USRSCTP_LIB) +endif + # --- Build Targets --- all: $(LIBTTAK_LIB) $(CJSON_LIB) $(URIPARSER_LIB) $(SQLITE_DIR)/sqlite3.c $(LSQUIC_LIB) $(LIB_NAME) @@ -716,6 +739,34 @@ $(SQLITE_DIR)/sqlite3.c: # Ensure lsquic submodule is checked out before compiling objects that need its headers $(OBJS): | lib/lsquic/include/lsquic.h +ifeq ($(CWIST_WEBRTC),1) +$(OBJS): | $(USRSCTP_DIR)/usrsctplib/usrsctp.h + +$(USRSCTP_DIR)/usrsctplib/usrsctp.h: + @if [ ! -f "$@" ]; then \ + echo "Initializing usrsctp submodule..."; \ + git submodule update --init --recursive $(USRSCTP_DIR); \ + fi + +USRSCTP_REV := $(if $(wildcard $(USRSCTP_DIR)/.git),$(shell git -C $(USRSCTP_DIR) rev-parse HEAD 2>/dev/null)) +USRSCTP_STAMP = $(USRSCTP_BUILD_DIR)/.usrsctp_built_$(or $(USRSCTP_REV),unversioned) + +$(USRSCTP_LIB): $(USRSCTP_STAMP) + +$(USRSCTP_STAMP): + @echo "Building usrsctp..." + @mkdir -p $(USRSCTP_BUILD_DIR) + cmake -S $(USRSCTP_DIR) -B $(USRSCTP_BUILD_DIR) \ + -DCMAKE_C_COMPILER=$(CC) \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -Dsctp_build_programs=OFF \ + -Dsctp_build_fuzzer=OFF + cmake --build $(USRSCTP_BUILD_DIR) --target usrsctp + @rm -f $(USRSCTP_BUILD_DIR)/.usrsctp_built_* + @touch $@ +endif + lib/lsquic/include/lsquic.h: @if [ ! -f "$@" ]; then \ echo "Initializing lsquic submodule..."; \ @@ -926,6 +977,12 @@ bench_security_pool: $(LIB_NAME) tests/bench_security_pool.c $(CC) $(CFLAGS) -o bench_security_pool tests/bench_security_pool.c $(LIB_NAME) $(LIBS) ./bench_security_pool +# WebRTC DataChannel end-to-end: ICE + DTLS + SCTP over UDP loopback with an +# in-process peer (client role) built on the same stack. +ifeq ($(CWIST_WEBRTC),1) +TEST_TARGETS += test_webrtc +endif + test: $(TEST_TARGETS) src/sys/app/app.o: src/sys/app/worker_affinity.h @@ -1458,6 +1515,14 @@ test_multipart: $(LIB_NAME) tests/test_multipart.c $(CC) $(CFLAGS) -o test_multipart tests/test_multipart.c $(LIB_NAME) $(LIBS) ./test_multipart +ifeq ($(CWIST_WEBRTC),1) +test_webrtc: $(LIB_NAME) $(USRSCTP_LIB) tests/test_webrtc.c + $(CC) $(CFLAGS) -o test_webrtc tests/test_webrtc.c $(LIB_NAME) $(LIBS) + ./test_webrtc + +.PHONY: test_webrtc +endif + test_waf: $(LIB_NAME) tests/test_waf.c $(CC) $(CFLAGS) -o test_waf tests/test_waf.c $(LIB_NAME) $(LIBS) ./test_waf diff --git a/example/webrtc/Makefile b/example/webrtc/Makefile new file mode 100644 index 000000000..2bf0b61ea --- /dev/null +++ b/example/webrtc/Makefile @@ -0,0 +1,25 @@ +CC ?= gcc +CFLAGS = -Wall -Wextra -g -I../../include + +ROOT = ../.. +LIBS = $(ROOT)/libcwist.a \ + $(ROOT)/lib/cnats/build/lib/libnats_static.a \ + $(ROOT)/lib/libttak/lib/libttak.a \ + $(ROOT)/lib/cjson/libcjson.a \ + $(ROOT)/lib/uriparser/build/liburiparser.a \ + $(ROOT)/lib/lsquic/build/src/liblsquic/liblsquic.a \ + $(ROOT)/lib/usrsctp/build/usrsctplib/libusrsctp.a \ + $(ROOT)/lib/boringssl/build/libssl.a \ + $(ROOT)/lib/boringssl/build/libcrypto.a \ + -pthread -ldl -lm -lstdc++ -lz -lcurl -lnghttp2 + +SRCS = main.c +TARGET = webrtc-server + +all: $(TARGET) + +$(TARGET): $(SRCS) $(ROOT)/libcwist.a + $(CC) $(CFLAGS) -o $(TARGET) $(SRCS) $(LIBS) + +clean: + rm -f $(TARGET) diff --git a/example/webrtc/README.md b/example/webrtc/README.md new file mode 100644 index 000000000..22ffbc0e6 --- /dev/null +++ b/example/webrtc/README.md @@ -0,0 +1,40 @@ +# CWIST WebRTC DataChannel example + +DataChannel-only WebRTC echo server: the browser sends an SDP offer over +HTTP POST, the CWIST server (acting as an ICE-lite endpoint) answers, and +both ends run ICE + DTLS + SCTP (RFC 8831) so messages on a DataChannel are +echoed back. + +## Build + +```sh +make -C ../.. libcwist.a # builds lib/usrsctp too (CWIST_WEBRTC=1 default) +make +``` + +## Run + +```sh +./webrtc-server +# signaling: http://localhost:8080/ (webrtc UDP port ) +``` + +Open http://localhost:8080/ in a browser, click **Connect**, type a message +and press **Send**. Every DataChannel message is echoed back and printed on +the server console. + +## Layout + +- `main.c` — cwist app serving `index.html` (GET /) and SDP answers + (POST /application/sdp), plus the echo logic on the cwist webrtc ctx. +- `index.html` — minimal RTCPeerConnection client using a DataChannel. + +## Notes / limitations (MVP) + +- The server is ICE-lite: it answers STUN binding requests and treats a + valid `USE-CANDIDATE` request as the nominated pair. The browser must be + the controlling agent (the default). +- The browser connects to the host candidate advertised in the answer + (`a=candidate:` with the server's best-guess local IPv4). Set the env + var or edit the code behind a NAT; there is no STUN/TURN server support. +- Ordered delivery only; the answer pins `max-message-size:262144`. diff --git a/example/webrtc/index.html b/example/webrtc/index.html new file mode 100644 index 000000000..3018d84cd --- /dev/null +++ b/example/webrtc/index.html @@ -0,0 +1,48 @@ + + + + + CWIST WebRTC DataChannel echo + + +

CWIST WebRTC DataChannel echo

+ + + +

+  
+
+
diff --git a/example/webrtc/main.c b/example/webrtc/main.c
new file mode 100644
index 000000000..2d8979c71
--- /dev/null
+++ b/example/webrtc/main.c
@@ -0,0 +1,85 @@
+/**
+ * @file main.c
+ * @brief WebRTC DataChannel echo server: SDP signaling over HTTP POST,
+ * ICE-lite + DTLS + SCTP DataChannel echo over UDP.
+ *
+ * Open http://localhost:8080/ in a browser, click Connect, and type
+ * messages: the server echoes every DataChannel message back.
+ */
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+static cwist_webrtc_ctx *g_webrtc;
+
+static void on_message(cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                       size_t len, void *user) {
+    (void)user;
+    (void)channel;
+    printf("[webrtc] message (%zu bytes): %.*s\n", len, (int)len, (const char *)data);
+    cwist_webrtc_conn_send(conn, channel, data, len, CWIST_WEBRTC_DATA_BINARY);
+}
+
+static void on_channel(cwist_webrtc_conn *conn, uint16_t channel, const char *label,
+                       void *user) {
+    (void)conn;
+    (void)user;
+    printf("[webrtc] channel %u opened by peer (label=%s)\n", channel, label);
+}
+
+static void index_handler(cwist_http_request *req, cwist_http_response *res) {
+    (void)req;
+    FILE *f = fopen("index.html", "rb");
+    if (!f) {
+        /* Fall back to the source directory layout. */
+        f = fopen("example/webrtc/index.html", "rb");
+    }
+    if (!f) {
+        cwist_http_header_add(&res->headers, "Content-Type", "text/plain");
+        cwist_sstring_assign(res->body, "index.html not found");
+        return;
+    }
+    char html[16384];
+    size_t n = fread(html, 1, sizeof(html) - 1, f);
+    fclose(f);
+    html[n] = '\0';
+    cwist_http_header_add(&res->headers, "Content-Type", "text/html");
+    cwist_sstring_assign(res->body, html);
+}
+
+static void offer_handler(cwist_http_request *req, cwist_http_response *res) {
+    static char answer[4096];
+    if (cwist_webrtc_handle_offer(g_webrtc, req->body->data, answer, sizeof(answer)) < 0) {
+        cwist_http_header_add(&res->headers, "Content-Type", "text/plain");
+        cwist_sstring_assign(res->body, "invalid SDP offer");
+        res->status_code = CWIST_HTTP_BAD_REQUEST;
+        return;
+    }
+    printf("[webrtc] answered offer; UDP port %u, fingerprint %s\n",
+           cwist_webrtc_ctx_port(g_webrtc), cwist_webrtc_ctx_fingerprint(g_webrtc));
+    cwist_http_header_add(&res->headers, "Content-Type", "application/sdp");
+    cwist_sstring_assign(res->body, answer);
+}
+
+int main(void) {
+    g_webrtc = cwist_webrtc_ctx_new(0);
+    if (!g_webrtc) {
+        fprintf(stderr, "failed to create webrtc ctx\n");
+        return 1;
+    }
+    cwist_webrtc_ctx_set_message_handler(g_webrtc, &on_message, NULL);
+    cwist_webrtc_ctx_set_channel_handler(g_webrtc, &on_channel, NULL);
+
+    cwist_app *app = cwist_app_create();
+    cwist_app_get(app, "/", index_handler);
+    cwist_app_post(app, "/offer", offer_handler);
+    printf("signaling: http://localhost:8080/ (webrtc UDP port %u)\n",
+           cwist_webrtc_ctx_port(g_webrtc));
+    cwist_app_listen(app, 8080);
+    cwist_app_destroy(app);
+    cwist_webrtc_ctx_free(g_webrtc);
+    return 0;
+}
diff --git a/include/cwist/net/webrtc.h b/include/cwist/net/webrtc.h
new file mode 100644
index 000000000..5361e38fe
--- /dev/null
+++ b/include/cwist/net/webrtc.h
@@ -0,0 +1,93 @@
+/** @file webrtc.h
+ * @brief WebRTC DataChannel server (SDP + ICE-lite + DTLS + SCTP) interface.
+ *
+ * DataChannel-only WebRTC per RFC 8831/8832: the server acts as an ICE-lite
+ * endpoint, completes a DTLS handshake (BoringSSL, plain DTLS, no SRTP) over
+ * the nominated UDP path, and runs SCTP with DataChannel establishment over
+ * the DTLS connection via usrsctp.
+ */
+#ifndef __CWIST_NET_WEBRTC_H__
+#define __CWIST_NET_WEBRTC_H__
+
+#include 
+#include 
+
+typedef struct cwist_webrtc_ctx cwist_webrtc_ctx;
+typedef struct cwist_webrtc_conn cwist_webrtc_conn;
+
+typedef enum {
+    CWIST_WEBRTC_DATA_BINARY = 0,  /**< PPID 51 */
+    CWIST_WEBRTC_DATA_STRING = 1   /**< PPID 50 */
+} cwist_webrtc_data_type;
+
+/**
+ * @brief DataChannel message callback, invoked from the ctx event-loop thread.
+ * @param channel_id SCTP stream id (DataChannel id).
+ */
+typedef void (*cwist_webrtc_message_cb)(cwist_webrtc_conn *conn, uint16_t channel_id,
+                                        const uint8_t *data, size_t len, void *user);
+
+/**
+ * @brief Optional DataChannel open notification (remote peer opened a channel).
+ */
+typedef void (*cwist_webrtc_channel_cb)(cwist_webrtc_conn *conn, uint16_t channel_id,
+                                        const char *label, void *user);
+
+/**
+ * @brief Create a WebRTC context bound to a UDP port (0 picks an ephemeral port).
+ * @return NULL on failure.
+ */
+cwist_webrtc_ctx *cwist_webrtc_ctx_new(uint16_t port);
+
+/**
+ * @brief UDP port the context is bound to.
+ */
+uint16_t cwist_webrtc_ctx_port(const cwist_webrtc_ctx *ctx);
+
+/**
+ * @brief Set the callback for incoming DataChannel messages.
+ */
+void cwist_webrtc_ctx_set_message_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_message_cb cb,
+                                          void *user);
+
+/**
+ * @brief Set the callback for incoming DataChannel open (DCEP) events.
+ */
+void cwist_webrtc_ctx_set_channel_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_channel_cb cb,
+                                          void *user);
+
+/**
+ * @brief Answer a browser SDP offer (ICE-lite, setup:passive).
+ * @param offer The remote SDP offer.
+ * @param answer_out Buffer receiving the generated SDP answer.
+ * @param out_len Size of answer_out.
+ * @return 0 on success, -1 on error.
+ *
+ * The connection completes asynchronously (ICE nomination, DTLS, SCTP);
+ * cwist_webrtc_ctx_poll() must be driven, or the ctx background thread
+ * handles it when created with cwist_webrtc_ctx_new().
+ */
+int cwist_webrtc_handle_offer(cwist_webrtc_ctx *ctx, const char *offer, char *answer_out,
+                              size_t out_len);
+
+/**
+ * @brief Number of fully established (SCTP/DTLS up) connections.
+ */
+int cwist_webrtc_ctx_connection_count(const cwist_webrtc_ctx *ctx);
+
+/**
+ * @brief Send a message on a DataChannel.
+ * @return 0 on success, -1 on error.
+ */
+int cwist_webrtc_conn_send(cwist_webrtc_conn *conn, uint16_t channel_id, const uint8_t *data,
+                           size_t len, cwist_webrtc_data_type type);
+
+/**
+ * @brief Local certificate fingerprint (SHA-256, colon-separated hex).
+ */
+const char *cwist_webrtc_ctx_fingerprint(const cwist_webrtc_ctx *ctx);
+
+void cwist_webrtc_conn_close(cwist_webrtc_conn *conn);
+void cwist_webrtc_ctx_free(cwist_webrtc_ctx *ctx);
+
+#endif
diff --git a/lib/usrsctp b/lib/usrsctp
new file mode 160000
index 000000000..fd070e05a
--- /dev/null
+++ b/lib/usrsctp
@@ -0,0 +1 @@
+Subproject commit fd070e05a7474f38c7fecdf4d4b6005d2547ee00
diff --git a/src/net/webrtc/dtls.c b/src/net/webrtc/dtls.c
new file mode 100644
index 000000000..0bbe09308
--- /dev/null
+++ b/src/net/webrtc/dtls.c
@@ -0,0 +1,93 @@
+/** @file dtls.c
+ * @brief DTLS helpers: ephemeral certificate generation, fingerprint, SSL_CTX.
+ */
+#include "webrtc_internal.h"
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#define CWIST_DTLS_MTU 1200
+
+int cwist_dtls_generate_cert(X509 **cert_out, EVP_PKEY **pkey_out) {
+    *cert_out = NULL;
+    *pkey_out = NULL;
+
+    EVP_PKEY_CTX *kctx = EVP_PKEY_CTX_new_id(EVP_PKEY_EC, NULL);
+    if (!kctx)
+        return -1;
+    if (EVP_PKEY_keygen_init(kctx) <= 0 || EVP_PKEY_CTX_set_ec_paramgen_curve_nid(kctx, NID_X9_62_prime256v1) <= 0) {
+        EVP_PKEY_CTX_free(kctx);
+        return -1;
+    }
+    EVP_PKEY *pkey = NULL;
+    if (EVP_PKEY_keygen(kctx, &pkey) <= 0) {
+        EVP_PKEY_CTX_free(kctx);
+        return -1;
+    }
+    EVP_PKEY_CTX_free(kctx);
+
+    X509 *cert = X509_new();
+    if (!cert) {
+        EVP_PKEY_free(pkey);
+        return -1;
+    }
+    X509_set_version(cert, 2);
+    ASN1_INTEGER_set(X509_get_serialNumber(cert), 1);
+    X509_gmtime_adj(X509_getm_notBefore(cert), 0);
+    X509_gmtime_adj(X509_getm_notAfter(cert), 60L * 60L * 24L * 30);
+    X509_set_pubkey(cert, pkey);
+    X509_NAME *name = X509_get_subject_name(cert);
+    X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, (const uint8_t *)"cwist-webrtc", -1, -1, 0);
+    X509_set_issuer_name(cert, name);
+    if (X509_sign(cert, pkey, EVP_sha256()) == 0) {
+        X509_free(cert);
+        EVP_PKEY_free(pkey);
+        return -1;
+    }
+    *cert_out = cert;
+    *pkey_out = pkey;
+    return 0;
+}
+
+int cwist_dtls_fingerprint(X509 *cert, char *out, size_t cap) {
+    uint8_t der[2048];
+    int der_len = i2d_X509(cert, NULL);
+    if (der_len <= 0 || (size_t)der_len > sizeof(der))
+        return -1;
+    uint8_t *p = der;
+    i2d_X509(cert, &p);
+    uint8_t digest[EVP_MAX_MD_SIZE];
+    unsigned int dlen = 0;
+    if (!EVP_Digest(der, (size_t)der_len, digest, &dlen, EVP_sha256(), NULL))
+        return -1;
+    size_t off = 0;
+    for (unsigned int i = 0; i < dlen && off + 3 < cap; i++)
+        off += (size_t)snprintf(out + off, cap - off, "%s%02X", i ? ":" : "", digest[i]);
+    return off > 0 && off < cap ? 0 : -1;
+}
+
+SSL_CTX *cwist_dtls_ctx_new(int is_server, X509 *cert, EVP_PKEY *pkey) {
+    SSL_CTX *ctx = SSL_CTX_new(is_server ? DTLS_server_method() : DTLS_client_method());
+    if (!ctx)
+        return NULL;
+    SSL_CTX_set_min_proto_version(ctx, DTLS1_2_VERSION);
+    if (is_server) {
+        if (SSL_CTX_use_certificate(ctx, cert) != 1 || SSL_CTX_use_PrivateKey(ctx, pkey) != 1 ||
+            SSL_CTX_check_private_key(ctx) != 1) {
+            SSL_CTX_free(ctx);
+            return NULL;
+        }
+    } else {
+        /* Peer fingerprint verification is optional at MVP; the test pins the
+         * expected fingerprint via the SDP exchange. */
+        SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, NULL);
+    }
+    return ctx;
+}
+
+unsigned int cwist_dtls_link_mtu(void) {
+    return CWIST_DTLS_MTU;
+}
diff --git a/src/net/webrtc/ice.c b/src/net/webrtc/ice.c
new file mode 100644
index 000000000..241f45582
--- /dev/null
+++ b/src/net/webrtc/ice.c
@@ -0,0 +1,405 @@
+/** @file ice.c
+ * @brief Minimal STUN/ICE (RFC 5389 / RFC 8445) for the ICE-lite agent.
+ */
+#include "webrtc_internal.h"
+
+#include 
+#include 
+#include 
+#include 
+
+#define STUN_MAGIC 0x2112A442u
+#define STUN_HDR_LEN 20
+#define ATTR_MAPPED_ADDRESS 0x0001
+#define ATTR_USERNAME 0x0006
+#define ATTR_MESSAGE_INTEGRITY 0x0008
+#define ATTR_PRIORITY 0x0024
+#define ATTR_USE_CANDIDATE 0x0025
+#define ATTR_XOR_MAPPED_ADDRESS 0x0020
+#define ATTR_FINGERPRINT 0x8028
+#define ATTR_ICE_CONTROLLED 0x8029
+#define ATTR_ICE_CONTROLLING 0x802A
+
+static uint32_t crc32_table[256];
+static bool crc32_ready = false;
+
+static void crc32_init(void) {
+    if (crc32_ready)
+        return;
+    for (uint32_t i = 0; i < 256; i++) {
+        uint32_t c = i;
+        for (int k = 0; k < 8; k++)
+            c = (c & 1) ? 0xEDB88320u ^ (c >> 1) : c >> 1;
+        crc32_table[i] = c;
+    }
+    crc32_ready = true;
+}
+
+/* Incremental CRC-32 (IEEE) over possibly several consecutive calls. */
+static uint32_t crc32_update(uint32_t c, const uint8_t *data, size_t len) {
+    crc32_init();
+    for (size_t i = 0; i < len; i++)
+        c = crc32_table[(c ^ data[i]) & 0xFF] ^ (c >> 8);
+    return c;
+}
+
+static uint32_t rd32(const uint8_t *p) {
+    return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3];
+}
+
+static uint16_t rd16(const uint8_t *p) {
+    return (uint16_t)((uint16_t)p[0] << 8 | p[1]);
+}
+
+static void wr16(uint8_t *p, uint16_t v) {
+    p[0] = (uint8_t)(v >> 8);
+    p[1] = (uint8_t)v;
+}
+
+static void wr32(uint8_t *p, uint32_t v) {
+    p[0] = (uint8_t)(v >> 24);
+    p[1] = (uint8_t)(v >> 16);
+    p[2] = (uint8_t)(v >> 8);
+    p[3] = (uint8_t)v;
+}
+
+int cwist_ice_stun_is_message(const uint8_t *buf, size_t len) {
+    return len >= STUN_HDR_LEN && rd32(buf + 4) == STUN_MAGIC;
+}
+
+bool cwist_ice_stun_is_binding_request(const uint8_t *buf, size_t len) {
+    return cwist_ice_stun_is_message(buf, len) && rd16(buf) == CWIST_STUN_BINDING_REQUEST;
+}
+
+/* Walk attributes. Calls cb(type, value, value_len) for each; stop if cb returns false. */
+typedef bool (*attr_cb)(uint16_t type, const uint8_t *val, uint16_t val_len, void *arg);
+
+static bool walk_attrs(const uint8_t *msg, size_t len, attr_cb cb, void *arg) {
+    uint16_t msg_len = rd16(msg + 2);
+    if ((size_t)msg_len + STUN_HDR_LEN > len || msg_len % 4 != 0)
+        return false;
+    size_t off = STUN_HDR_LEN;
+    size_t end = STUN_HDR_LEN + msg_len;
+    while (off + 4 <= end) {
+        uint16_t type = rd16(msg + off);
+        uint16_t alen = rd16(msg + off + 2);
+        off += 4;
+        if (off + alen > end)
+            return false;
+        if (!cb(type, msg + off, alen, arg))
+            return false;
+        off += (alen + 3) & ~3u;
+    }
+    return off == end;
+}
+
+typedef struct {
+    bool found;
+    uint16_t val_len;
+    uint16_t type_wanted;
+} attr_find_arg;
+
+static bool attr_find_cb(uint16_t type, const uint8_t *val, uint16_t val_len, void *arg) {
+    (void)val;
+    attr_find_arg *a = arg;
+    if (type == a->type_wanted) {
+        a->found = true;
+        a->val_len = val_len;
+        return false;
+    }
+    return true;
+}
+
+static bool attr_present(const uint8_t *msg, size_t len, uint16_t type) {
+    attr_find_arg a = { .type_wanted = type };
+    walk_attrs(msg, len, attr_find_cb, &a);
+    return a.found;
+}
+
+bool cwist_ice_stun_has_use_candidate(const uint8_t *buf, size_t len) {
+    return attr_present(buf, len, ATTR_USE_CANDIDATE);
+}
+
+/* Locate the MESSAGE-INTEGRITY attribute; *mi_off gets its header offset. */
+static bool find_mi(const uint8_t *msg, size_t len, size_t *mi_off) {
+    if (!cwist_ice_stun_is_message(msg, len))
+        return false;
+    uint16_t msg_len = rd16(msg + 2);
+    size_t end = STUN_HDR_LEN + msg_len;
+    size_t off = STUN_HDR_LEN;
+    while (off + 4 <= end) {
+        uint16_t type = rd16(msg + off);
+        uint16_t alen = rd16(msg + off + 2);
+        if (type == ATTR_MESSAGE_INTEGRITY) {
+            *mi_off = off;
+            return alen == 20;
+        }
+        off += 4 + ((alen + 3) & ~3u);
+    }
+    return false;
+}
+
+int cwist_ice_stun_validate_request(const uint8_t *buf, size_t len, const char *pwd) {
+    size_t pwd_len = strlen(pwd);
+    uint8_t hmac[EVP_MAX_MD_SIZE];
+    unsigned int hmac_len = 0;
+    size_t mi_off;
+    if (!cwist_ice_stun_is_message(buf, len))
+        return 0;
+    if (!find_mi(buf, len, &mi_off))
+        return 1; /* no MI: accept (RFC 8445 requires it, but stay permissive at MVP) */
+    /* MI covers the message up to and including the MI attribute header, with
+     * the message length field set to end at the MI attribute. */
+    uint8_t hdr[STUN_HDR_LEN];
+    memcpy(hdr, buf, STUN_HDR_LEN);
+    wr16(hdr + 2, (uint16_t)(mi_off - STUN_HDR_LEN + 4 + 20));
+    HMAC_CTX *ctx = HMAC_CTX_new();
+    if (!ctx)
+        return 0;
+    HMAC_Init_ex(ctx, pwd, (int)pwd_len, EVP_sha1(), NULL);
+    HMAC_Update(ctx, hdr, STUN_HDR_LEN);
+    HMAC_Update(ctx, buf + STUN_HDR_LEN, mi_off - STUN_HDR_LEN);
+    HMAC_Final(ctx, hmac, &hmac_len);
+    HMAC_CTX_free(ctx);
+    return hmac_len == 20 && memcmp(hmac, buf + mi_off + 4, 20) == 0;
+}
+
+int cwist_ice_stun_build_response(uint8_t *out, size_t cap, const uint8_t *req, size_t req_len,
+                                  const struct sockaddr_in *mapped, const char *pwd) {
+    (void)req_len;
+    uint8_t body[64];
+    size_t blen = 0;
+
+    /* XOR-MAPPED-ADDRESS, IPv4 */
+    wr16(body + blen, ATTR_XOR_MAPPED_ADDRESS);
+    wr16(body + blen + 2, 8);
+    blen += 4;
+    body[blen++] = 0x00;
+    body[blen++] = 0x01; /* family IPv4 */
+    wr16(body + blen, (uint16_t)(ntohs(mapped->sin_port) ^ 0xFFFF));
+    blen += 2;
+    uint32_t addr = ntohl(mapped->sin_addr.s_addr) ^ STUN_MAGIC;
+    wr32(body + blen, addr);
+    blen += 4;
+    while (blen % 4)
+        body[blen++] = 0;
+
+    /* MESSAGE-INTEGRITY over header+body+MI attr header with adjusted length */
+    uint16_t len_for_mi = (uint16_t)(blen + 4 + 20);
+    uint8_t hdr[STUN_HDR_LEN];
+    memset(hdr, 0, STUN_HDR_LEN);
+    wr16(hdr, CWIST_STUN_BINDING_RESPONSE);
+    wr16(hdr + 2, len_for_mi);
+    wr32(hdr + 4, STUN_MAGIC);
+    memcpy(hdr + 8, req + 8, 12);
+    uint8_t mi[20];
+    unsigned int mi_len = 0;
+    HMAC_CTX *hctx = HMAC_CTX_new();
+    if (!hctx)
+        return -1;
+    HMAC_Init_ex(hctx, pwd, (int)strlen(pwd), EVP_sha1(), NULL);
+    HMAC_Update(hctx, hdr, STUN_HDR_LEN);
+    HMAC_Update(hctx, body, blen);
+    HMAC_Final(hctx, mi, &mi_len);
+    HMAC_CTX_free(hctx);
+
+    wr16(body + blen, ATTR_MESSAGE_INTEGRITY);
+    wr16(body + blen + 2, 20);
+    blen += 4;
+    memcpy(body + blen, mi, 20);
+    blen += 20;
+
+    /* FINGERPRINT */
+    uint16_t len_for_fp = (uint16_t)(blen + 8);
+    uint8_t hdr2[STUN_HDR_LEN];
+    memcpy(hdr2, hdr, STUN_HDR_LEN);
+    wr16(hdr2 + 2, len_for_fp);
+    uint32_t c = crc32_update(0xFFFFFFFFu, hdr2, STUN_HDR_LEN);
+    c = crc32_update(c, body, blen);
+    c ^= 0xFFFFFFFFu;
+    wr16(body + blen, ATTR_FINGERPRINT);
+    wr16(body + blen + 2, 4);
+    wr32(body + blen + 4, c ^ 0x5354554Eu);
+    blen += 8;
+
+    if (cap < STUN_HDR_LEN + blen)
+        return -1;
+    memcpy(out, hdr2, STUN_HDR_LEN);
+    memcpy(out + STUN_HDR_LEN, body, blen);
+    return (int)(STUN_HDR_LEN + blen);
+}
+
+int cwist_ice_stun_build_request(uint8_t *out, size_t cap, const uint8_t txid[12],
+                                 const char *username) {
+    size_t ulen = strlen(username);
+    if (cap < STUN_HDR_LEN)
+        return -1;
+    memset(out, 0, STUN_HDR_LEN);
+    wr16(out, CWIST_STUN_BINDING_REQUEST);
+    wr32(out + 4, STUN_MAGIC);
+    memcpy(out + 8, txid, 12);
+
+    size_t blen = 0;
+    uint8_t body[256];
+    /* SOFTWARE-ish placeholder skipped; USERNAME */
+    if (ulen > 0) {
+        wr16(body + blen, ATTR_USERNAME);
+        wr16(body + blen + 2, (uint16_t)ulen);
+        blen += 4;
+        memcpy(body + blen, username, ulen);
+        blen += ulen;
+        while (blen % 4)
+            body[blen++] = 0;
+    }
+    /* ICE-CONTROLLING (8 bytes) */
+    wr16(body + blen, ATTR_ICE_CONTROLLING);
+    wr16(body + blen + 2, 8);
+    memset(body + blen + 4, 0, 8);
+    blen += 12;
+    /* USE-CANDIDATE */
+    wr16(body + blen, ATTR_USE_CANDIDATE);
+    wr16(body + blen + 2, 0);
+    blen += 4;
+    /* PRIORITY */
+    wr16(body + blen, ATTR_PRIORITY);
+    wr16(body + blen + 2, 4);
+    wr32(body + blen + 4, 0x7EFFFFFFu);
+    blen += 8;
+
+    /* MESSAGE-INTEGRITY + FINGERPRINT added by the caller via _finalize; here we
+     * keep the request unsigned because the MI key (peer pwd) is supplied at
+     * send time. cwist_ice_stun_sign_request() handles it. */
+    wr16(out + 2, (uint16_t)blen);
+    if (cap < STUN_HDR_LEN + blen)
+        return -1;
+    memcpy(out + STUN_HDR_LEN, body, blen);
+    return (int)(STUN_HDR_LEN + blen);
+}
+
+/* Append MI + FINGERPRINT to a freshly built request. */
+static int stun_sign(uint8_t *msg, size_t cap, size_t msg_len, const char *pwd) {
+    uint8_t body[64];
+    size_t blen = 0;
+    uint16_t len_for_mi = (uint16_t)(msg_len - STUN_HDR_LEN + 4 + 20);
+    uint8_t hdr[STUN_HDR_LEN];
+    memcpy(hdr, msg, STUN_HDR_LEN);
+    wr16(hdr + 2, len_for_mi);
+    uint8_t mi[20];
+    unsigned int mi_len = 0;
+    HMAC_CTX *hctx = HMAC_CTX_new();
+    if (!hctx)
+        return -1;
+    HMAC_Init_ex(hctx, pwd, (int)strlen(pwd), EVP_sha1(), NULL);
+    HMAC_Update(hctx, hdr, STUN_HDR_LEN);
+    HMAC_Update(hctx, msg + STUN_HDR_LEN, msg_len - STUN_HDR_LEN);
+    HMAC_Final(hctx, mi, &mi_len);
+    HMAC_CTX_free(hctx);
+
+    wr16(body + blen, ATTR_MESSAGE_INTEGRITY);
+    wr16(body + blen + 2, 20);
+    blen += 4;
+    memcpy(body + blen, mi, 20);
+    blen += 20;
+
+    uint16_t len_for_fp = (uint16_t)(msg_len - STUN_HDR_LEN + blen + 8);
+    uint8_t hdr2[STUN_HDR_LEN];
+    memcpy(hdr2, hdr, STUN_HDR_LEN);
+    wr16(hdr2 + 2, len_for_fp);
+    uint32_t c = crc32_update(0xFFFFFFFFu, hdr2, STUN_HDR_LEN);
+    c = crc32_update(c, msg + STUN_HDR_LEN, msg_len - STUN_HDR_LEN);
+    c = crc32_update(c, body, blen);
+    c ^= 0xFFFFFFFFu;
+    wr16(body + blen, ATTR_FINGERPRINT);
+    wr16(body + blen + 2, 4);
+    wr32(body + blen + 4, c ^ 0x5354554Eu);
+    blen += 8;
+
+    if (cap < msg_len + blen)
+        return -1;
+    memcpy(msg + msg_len, body, blen);
+    memcpy(msg, hdr2, STUN_HDR_LEN);
+    return (int)(msg_len + blen);
+}
+
+int cwist_ice_stun_sign_request(uint8_t *msg, size_t cap, size_t msg_len, const char *pwd) {
+    return stun_sign(msg, cap, msg_len, pwd);
+}
+
+typedef struct {
+    const uint8_t *mi_val;
+    struct sockaddr_in mapped;
+    bool has_mapped;
+} parse_resp_arg;
+
+static bool parse_resp_cb(uint16_t type, const uint8_t *val, uint16_t val_len, void *arg) {
+    parse_resp_arg *a = arg;
+    if (type == ATTR_MESSAGE_INTEGRITY) {
+        a->mi_val = val;
+        (void)val_len;
+    } else if (type == ATTR_XOR_MAPPED_ADDRESS && val_len >= 8) {
+        a->mapped.sin_family = AF_INET;
+        a->mapped.sin_port = htons((uint16_t)(rd16(val + 2) ^ 0xFFFF));
+        a->mapped.sin_addr.s_addr = htonl(rd32(val + 4) ^ STUN_MAGIC);
+        a->has_mapped = true;
+    }
+    return true;
+}
+
+int cwist_ice_stun_parse_response(const uint8_t *buf, size_t len, const uint8_t txid[12],
+                                  const char *pwd, struct sockaddr_in *mapped) {
+    if (!cwist_ice_stun_is_message(buf, len) || rd16(buf) != CWIST_STUN_BINDING_RESPONSE)
+        return 0;
+    if (memcmp(buf + 8, txid, 12) != 0)
+        return 0;
+    if (!cwist_ice_stun_validate_request(buf, len, pwd))
+        return 0;
+    parse_resp_arg a = { 0 };
+    if (!walk_attrs(buf, len, parse_resp_cb, &a))
+        return 0;
+    if (!a.mi_val)
+        return 0;
+    if (mapped) {
+        if (!a.has_mapped)
+            return 0;
+        *mapped = a.mapped;
+    }
+    return 1;
+}
+
+static const char b64url_chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+
+int cwist_ice_stun_get_remote_ufrag(const uint8_t *buf, size_t len, char *out, size_t cap) {
+    if (!cwist_ice_stun_is_message(buf, len))
+        return -1;
+    uint16_t msg_len = rd16(buf + 2);
+    size_t end = STUN_HDR_LEN + msg_len;
+    size_t off = STUN_HDR_LEN;
+    while (off + 4 <= end) {
+        uint16_t type = rd16(buf + off);
+        uint16_t alen = rd16(buf + off + 2);
+        if (type == ATTR_USERNAME) {
+            size_t i = 0;
+            while (i < alen && i + 1 < cap && buf[off + 4 + i] != ':') {
+                out[i] = (char)buf[off + 4 + i];
+                i++;
+            }
+            out[i] = '\0';
+            return 0;
+        }
+        off += 4 + ((alen + 3) & ~3u);
+    }
+    return -1;
+}
+
+void cwist_ice_random_creds(char *ufrag, size_t ufrag_cap, char *pwd, size_t pwd_cap) {
+    uint8_t raw[24];
+    RAND_bytes(raw, sizeof(raw));
+    size_t n = ufrag_cap - 1 < 8 ? ufrag_cap - 1 : 8;
+    for (size_t i = 0; i < n; i++)
+        ufrag[i] = b64url_chars[raw[i] & 63];
+    ufrag[n] = '\0';
+    size_t m = pwd_cap - 1 < 32 ? pwd_cap - 1 : 32;
+    for (size_t i = 0; i < m; i++)
+        pwd[i] = b64url_chars[raw[8 + (i % 16)] & 63];
+    pwd[m] = '\0';
+}
diff --git a/src/net/webrtc/sctp.c b/src/net/webrtc/sctp.c
new file mode 100644
index 000000000..f5a4ddf09
--- /dev/null
+++ b/src/net/webrtc/sctp.c
@@ -0,0 +1,310 @@
+/** @file sctp.c
+ * @brief SCTP DataChannels over DTLS (RFC 8831) and DCEP (RFC 8832).
+ *
+ * usrsctp runs in AF_CONN raw mode tunneled over the DTLS connection
+ * (RFC 8831): the module pumps usrsctp_handle_timers() from the ctx event
+ * loop and the global conn_output callback (registered with
+ * usrsctp_init_nothreads) hands packets back to the owning connection.
+ *
+ * The address handle convention follows usrsctp's ekr_loop example: every
+ * endpoint registers its connection pointer as its handle, binds with it,
+ * and the offering side "connects" to its own handle. Packets are injected
+ * with usrsctp_conninput(conn) and replies come back through the same
+ * handle, which keeps routing unambiguous when several associations share
+ * one usrsctp instance. The answering side listens and accepts; the
+ * accepted socket carries no receive callback, so inbound data is drained
+ * with usrsctp_recvmsg() from the event loop.
+ */
+#include "webrtc_internal.h"
+
+#include 
+#include 
+#include 
+#include 
+
+#define DCEP_ACK 0x02
+#define DCEP_OPEN 0x03
+
+#define PPID_STRING 50
+#define PPID_BINARY 51
+#define PPID_DCEP 53
+
+#define CWIST_SCTP_PORT 5000
+#define CHANNEL_BITS(n) ((n) * 2)
+#define CH_STATE_BYTES (65536 / 4) /* 2 bits per channel */
+
+static int sctp_global_output(void *addr, void *buffer, size_t length, uint8_t tos,
+                              uint8_t set_df) {
+    (void)tos;
+    (void)set_df;
+    struct cwist_webrtc_conn *conn = addr;
+    if (!conn || conn->state == CWIST_CONN_DEAD)
+        return -1;
+    cwist_webrtc_conn_sctp_out(conn, buffer, length);
+    return 0;
+}
+
+int cwist_sctp_global_init(void) {
+    static int initialized = 0;
+    if (initialized)
+        return 0;
+    usrsctp_init_nothreads(0, &sctp_global_output, NULL);
+    /* RFC 8831: SCTP runs directly over DTLS, never over UDP tunneling. */
+    usrsctp_sysctl_set_sctp_udp_tunneling_port(0);
+    initialized = 1;
+    return 0;
+}
+
+static bool ch_get(struct cwist_webrtc_conn *conn, uint16_t ch, int bit) {
+    if (!conn->ch_state)
+        return false;
+    size_t off = CHANNEL_BITS(ch) + (size_t)bit;
+    return (conn->ch_state[off / 8] >> (off % 8)) & 1u;
+}
+
+static void ch_set(struct cwist_webrtc_conn *conn, uint16_t ch, int bit) {
+    if (!conn->ch_state)
+        return;
+    size_t off = CHANNEL_BITS(ch) + (size_t)bit;
+    conn->ch_state[off / 8] |= (uint8_t)(1u << (off % 8));
+}
+
+/* Effective data socket: the accepted socket on the answering side. */
+static struct socket *cwist_sctp_data_sock(struct cwist_webrtc_conn *conn) {
+    return conn->sctp_acc ? conn->sctp_acc : conn->sctp_sock;
+}
+
+static void dispatch_message(struct cwist_webrtc_conn *conn, uint16_t sid, uint32_t ppid,
+                             const uint8_t *msg, size_t datalen) {
+    if (ppid == PPID_DCEP && datalen >= 1) {
+        if (msg[0] == DCEP_OPEN && datalen >= 12) {
+            uint16_t label_len = (uint16_t)((uint16_t)msg[8] << 8 | msg[9]);
+            char label[201];
+            size_t n = label_len;
+            if (n > sizeof(label) - 1)
+                n = sizeof(label) - 1;
+            if (12 + n > datalen)
+                n = datalen > 12 ? datalen - 12 : 0;
+            memcpy(label, msg + 12, n);
+            label[n] = '\0';
+            ch_set(conn, sid, 0);
+            dcep_send(conn, sid, DCEP_ACK, NULL);
+            cwist_webrtc_conn_on_channel_open(conn, sid, label);
+        }
+        /* ACK (0x02): channel ready both ways. */
+    } else if (ppid == PPID_BINARY) {
+        cwist_webrtc_conn_on_message(conn, sid, msg, datalen, 0);
+    } else if (ppid == PPID_STRING) {
+        cwist_webrtc_conn_on_message(conn, sid, msg, datalen, 1);
+    }
+}
+
+/* receive_cb for the offering (connected) socket. */
+static int on_incoming(struct socket *sock, union sctp_sockstore addr, void *data, size_t datalen,
+                       struct sctp_rcvinfo rcv, int flags, void *ulp_info) {
+    (void)sock;
+    (void)addr;
+    (void)flags;
+    struct cwist_webrtc_conn *conn = ulp_info;
+    if (!conn || !data)
+        return 1;
+    dispatch_message(conn, rcv.rcv_sid, ntohl(rcv.rcv_ppid), (const uint8_t *)data, datalen);
+    free(data);
+    return 1;
+}
+
+int dcep_send(struct cwist_webrtc_conn *conn, uint16_t channel, uint8_t type, const char *label) {
+    uint8_t msg[256];
+    size_t len = 0;
+    msg[len++] = type;
+    if (type == DCEP_OPEN) {
+        size_t label_len = label ? strlen(label) : 0;
+        if (label_len > 200)
+            label_len = 200;
+        msg[len++] = 0x00; /* DATA_CHANNEL_RELIABLE */
+        msg[len++] = 0x00;
+        msg[len++] = 0x00; /* priority */
+        msg[len++] = 0x00;
+        msg[len++] = 0x00;
+        msg[len++] = 0x00; /* reliability parameter */
+        msg[len++] = 0x00;
+        msg[len++] = (uint8_t)(label_len >> 8);
+        msg[len++] = (uint8_t)label_len;
+        msg[len++] = 0x00;
+        msg[len++] = 0x00; /* protocol length 0 */
+        memcpy(msg + len, label, label_len);
+        len += label_len;
+    } else {
+        memset(msg + len, 0, 11); /* ACK is a 12-byte header */
+        len += 11;
+    }
+
+    struct sctp_sndinfo info;
+    memset(&info, 0, sizeof(info));
+    info.snd_sid = channel;
+    info.snd_ppid = htonl(PPID_DCEP);
+    int rc = usrsctp_sendv(cwist_sctp_data_sock(conn), msg, len, NULL, 0, &info, sizeof(info),
+                           SCTP_SENDV_SNDINFO, 0);
+    return rc >= 0 ? 0 : -1;
+}
+
+int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn) {
+    conn->sctp_sock = usrsctp_socket(AF_CONN, SOCK_STREAM, IPPROTO_SCTP,
+                                     conn->is_server_role ? NULL : &on_incoming, NULL, 0,
+                                     conn->is_server_role ? NULL : conn);
+    if (!conn->sctp_sock) {
+        return -1;
+    }
+    usrsctp_set_non_blocking(conn->sctp_sock, 1);
+
+    int on = 1;
+    usrsctp_setsockopt(conn->sctp_sock, IPPROTO_SCTP, SCTP_RECVRCVINFO, &on, sizeof(on));
+    usrsctp_setsockopt(conn->sctp_sock, IPPROTO_SCTP, SCTP_NODELAY, &on, sizeof(on));
+
+    conn->ch_state = calloc(1, CH_STATE_BYTES);
+    if (!conn->ch_state) {
+        usrsctp_close(conn->sctp_sock);
+        conn->sctp_sock = NULL;
+        return -1;
+    }
+
+    usrsctp_register_address(conn);
+
+    struct sockaddr_conn local;
+    memset(&local, 0, sizeof(local));
+    local.sconn_family = AF_CONN;
+    local.sconn_port = htons(CWIST_SCTP_PORT);
+    local.sconn_addr = conn;
+    if (usrsctp_bind(conn->sctp_sock, (struct sockaddr *)&local, sizeof(local)) < 0) {
+        usrsctp_deregister_address(conn);
+        free(conn->ch_state);
+        conn->ch_state = NULL;
+        usrsctp_close(conn->sctp_sock);
+        conn->sctp_sock = NULL;
+        return -1;
+    }
+
+    if (conn->is_server_role) {
+        /* Passive side must accept the incoming SCTP association. */
+        if (usrsctp_listen(conn->sctp_sock, 1) < 0) {
+            usrsctp_deregister_address(conn);
+            free(conn->ch_state);
+            conn->ch_state = NULL;
+            usrsctp_close(conn->sctp_sock);
+            conn->sctp_sock = NULL;
+            return -1;
+        }
+    } else {
+        /* Active side initiates the SCTP association (sends the INIT).
+         * The remote handle is our own: the answering endpoint's replies
+         * are routed back through the handle recorded from its conninput(),
+         * i.e. its own pointer (see ekr_loop in the usrsctp tree). */
+        struct sockaddr_conn remote;
+        memset(&remote, 0, sizeof(remote));
+        remote.sconn_family = AF_CONN;
+        remote.sconn_port = htons(CWIST_SCTP_PORT);
+        remote.sconn_addr = conn;
+        if (usrsctp_connect(conn->sctp_sock, (struct sockaddr *)&remote, sizeof(remote)) < 0 &&
+            errno != EINPROGRESS) {
+            usrsctp_deregister_address(conn);
+            free(conn->ch_state);
+            conn->ch_state = NULL;
+            usrsctp_close(conn->sctp_sock);
+            conn->sctp_sock = NULL;
+            return -1;
+        }
+    }
+    return 0;
+}
+
+void cwist_sctp_conn_close(struct cwist_webrtc_conn *conn) {
+    if (conn->sctp_acc) {
+        usrsctp_close(conn->sctp_acc);
+        conn->sctp_acc = NULL;
+    }
+    if (conn->sctp_sock) {
+        usrsctp_close(conn->sctp_sock);
+        conn->sctp_sock = NULL;
+    }
+    if (conn->ch_state) {
+        usrsctp_deregister_address(conn);
+        free(conn->ch_state);
+        conn->ch_state = NULL;
+    }
+}
+
+void cwist_sctp_conn_input(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len) {
+    if (conn->sctp_sock)
+        usrsctp_conninput(conn, data, len, 0);
+}
+
+/* Drain inbound data; accept the association on the passive side. */
+void cwist_sctp_conn_drain(struct cwist_webrtc_conn *conn) {
+    if (!conn->sctp_sock)
+        return;
+    if (conn->is_server_role && !conn->sctp_acc) {
+        conn->sctp_acc = usrsctp_accept(conn->sctp_sock, NULL, NULL);
+        if (conn->sctp_acc) {
+            int on = 1;
+            usrsctp_set_non_blocking(conn->sctp_acc, 1);
+            usrsctp_setsockopt(conn->sctp_acc, IPPROTO_SCTP, SCTP_RECVRCVINFO, &on,
+                               sizeof(on));
+        }
+    }
+    struct socket *sock = cwist_sctp_data_sock(conn);
+    if (!sock)
+        return;
+    static uint8_t buf[1 << 20];
+    for (;;) {
+        struct sctp_rcvinfo rcv;
+        socklen_t infolen = sizeof(rcv);
+        unsigned int infotype = SCTP_RECVV_RCVINFO;
+        int msg_flags = 0;
+        struct sockaddr_conn from;
+        socklen_t fromlen = sizeof(from);
+        ssize_t n = usrsctp_recvv(sock, buf, sizeof(buf), (struct sockaddr *)&from, &fromlen,
+                                  &rcv, &infolen, &infotype, &msg_flags);
+        (void)infotype;
+        if (n <= 0)
+            break;
+        if (msg_flags & MSG_NOTIFICATION)
+            continue;
+        if ((int)infolen < (int)sizeof(rcv))
+            continue;
+        dispatch_message(conn, rcv.rcv_sid, ntohl(rcv.rcv_ppid), buf, (size_t)n);
+    }
+}
+
+bool cwist_sctp_assoc_established(struct cwist_webrtc_conn *conn) {
+    if (!conn->sctp_sock)
+        return false;
+    if (conn->is_server_role)
+        return conn->sctp_acc != NULL;
+    struct sctp_status status;
+    socklen_t slen = sizeof(status);
+    memset(&status, 0, sizeof(status));
+    if (usrsctp_getsockopt(conn->sctp_sock, IPPROTO_SCTP, SCTP_STATUS, &status, &slen) < 0)
+        return false;
+    return status.sstat_state == SCTP_ESTABLISHED;
+}
+
+int cwist_sctp_send(struct cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                    size_t len, int is_string) {
+    if (!cwist_sctp_data_sock(conn) || !conn->sctp_ready)
+        return -1;
+    if (!ch_get(conn, channel, 0) && !ch_get(conn, channel, 1)) {
+        /* Brand-new channel opened by us: announce it with DCEP OPEN. */
+        char label[32];
+        snprintf(label, sizeof(label), "dc%u", channel);
+        if (dcep_send(conn, channel, DCEP_OPEN, label) < 0)
+            return -1;
+        ch_set(conn, channel, 1);
+    }
+    struct sctp_sndinfo info;
+    memset(&info, 0, sizeof(info));
+    info.snd_sid = channel;
+    info.snd_ppid = htonl(is_string ? PPID_STRING : PPID_BINARY);
+    int rc = usrsctp_sendv(cwist_sctp_data_sock(conn), data, len, NULL, 0, &info, sizeof(info),
+                           SCTP_SENDV_SNDINFO, 0);
+    return rc >= 0 ? 0 : -1;
+}
diff --git a/src/net/webrtc/sdp.c b/src/net/webrtc/sdp.c
new file mode 100644
index 000000000..38813e644
--- /dev/null
+++ b/src/net/webrtc/sdp.c
@@ -0,0 +1,111 @@
+/** @file sdp.c
+ * @brief Minimal SDP (RFC 4566) parse/generate for DataChannel offers/answers.
+ */
+#include "webrtc_internal.h"
+
+#include 
+#include 
+
+static void copy_attr_value(const char *line, const char *prefix, char *dst, size_t cap) {
+    size_t plen = strlen(prefix);
+    if (strncmp(line, prefix, plen) != 0)
+        return;
+    const char *v = line + plen;
+    size_t n = strcspn(v, "\r\n");
+    if (n >= cap)
+        n = cap - 1;
+    memcpy(dst, v, n);
+    dst[n] = '\0';
+}
+
+int cwist_sdp_parse(const char *sdp, size_t len, cwist_sdp_info *info) {
+    memset(info, 0, sizeof(*info));
+    char line[512];
+    size_t pos = 0;
+    while (pos < len) {
+        size_t n = strcspn(sdp + pos, "\r\n");
+        if (n >= sizeof(line))
+            n = sizeof(line) - 1;
+        memcpy(line, sdp + pos, n);
+        line[n] = '\0';
+        pos += n;
+        while (pos < len && (sdp[pos] == '\r' || sdp[pos] == '\n'))
+            pos++;
+
+        if (strncmp(line, "a=ice-ufrag:", 12) == 0)
+            copy_attr_value(line, "a=ice-ufrag:", info->ice_ufrag, sizeof(info->ice_ufrag));
+        else if (strncmp(line, "a=ice-pwd:", 10) == 0)
+            copy_attr_value(line, "a=ice-pwd:", info->ice_pwd, sizeof(info->ice_pwd));
+        else if (strncmp(line, "a=fingerprint:", 14) == 0)
+            copy_attr_value(line, "a=fingerprint:sha-256 ", info->fingerprint,
+                            sizeof(info->fingerprint));
+        else if (strncmp(line, "a=setup:", 8) == 0)
+            copy_attr_value(line, "a=setup:", info->setup, sizeof(info->setup));
+        else if (strncmp(line, "a=mid:", 6) == 0)
+            copy_attr_value(line, "a=mid:", info->mid, sizeof(info->mid));
+        else if (strcmp(line, "a=ice-lite") == 0)
+            info->has_lite = 1;
+    }
+    if (info->ice_ufrag[0] == '\0' || info->ice_pwd[0] == '\0')
+        return -1;
+    return 0;
+}
+
+static int write_session(char *out, size_t cap, const char *fingerprint, const char *ufrag,
+                         const char *pwd, const char *mid, const char *setup, int with_lite,
+                         long long sess_id) {
+    int n = snprintf(out, cap,
+                     "v=0\r\n"
+                     "o=- %lld 2 IN IP4 127.0.0.1\r\n"
+                     "s=-\r\n"
+                     "t=0 0\r\n"
+                     "a=group:BUNDLE %s\r\n"
+                     "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n"
+                     "c=IN IP4 0.0.0.0\r\n"
+                     "a=mid:%s\r\n"
+                     "a=ice-ufrag:%s\r\n"
+                     "a=ice-pwd:%s\r\n"
+                     "%s"
+                     "a=fingerprint:sha-256 %s\r\n"
+                     "a=setup:%s\r\n"
+                     "a=sctp-port:5000\r\n"
+                     "a=max-message-size:262144\r\n",
+                     sess_id, mid, mid, ufrag, pwd, with_lite ? "a=ice-lite\r\n" : "", fingerprint,
+                     setup);
+    if (n < 0 || (size_t)n >= cap)
+        return -1;
+    return 0;
+}
+
+int cwist_sdp_write_answer(char *out, size_t cap, const char *fingerprint, const char *ufrag,
+                           const char *pwd, const char *mid, const char *host, uint16_t port) {
+    /* ICE-lite answering endpoint takes the passive DTLS role. */
+    int n = snprintf(out, cap,
+                     "v=0\r\n"
+                     "o=- 872978578 2 IN IP4 127.0.0.1\r\n"
+                     "s=-\r\n"
+                     "t=0 0\r\n"
+                     "a=group:BUNDLE %s\r\n"
+                     "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n"
+                     "c=IN IP4 0.0.0.0\r\n"
+                     "a=mid:%s\r\n"
+                     "a=ice-ufrag:%s\r\n"
+                     "a=ice-pwd:%s\r\n"
+                     "a=ice-lite\r\n"
+                     "a=fingerprint:sha-256 %s\r\n"
+                     "a=setup:passive\r\n"
+                     "a=candidate:1 1 UDP 2122260223 %s %u typ host\r\n"
+                     "a=end-of-candidates\r\n"
+                     "a=sctp-port:5000\r\n"
+                     "a=max-message-size:262144\r\n",
+                     mid, mid, ufrag, pwd, fingerprint, host, (unsigned)port);
+    if (n < 0 || (size_t)n >= cap)
+        return -1;
+    return 0;
+}
+
+int cwist_sdp_write_offer(char *out, size_t cap, const char *fingerprint, const char *ufrag,
+                          const char *pwd, const char *mid) {
+    /* Full agent offering endpoint takes the active DTLS role. */
+    return write_session(out, cap, fingerprint, ufrag, pwd, mid, "active", 0, 337018573);
+}
diff --git a/src/net/webrtc/webrtc.c b/src/net/webrtc/webrtc.c
new file mode 100644
index 000000000..2836f0e9e
--- /dev/null
+++ b/src/net/webrtc/webrtc.c
@@ -0,0 +1,629 @@
+/** @file webrtc.c
+ * @brief WebRTC DataChannel server: ctx, connection state machine, UDP loop.
+ *
+ * One background thread per ctx drives ICE-lite, the DTLS handshake
+ * (BoringSSL over memory BIOs), and SCTP-over-DTLS via usrsctp. Connections
+ * are keyed by the nominated remote UDP address.
+ */
+#include "webrtc_internal.h"
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#define STUN_RETRANS_MS 300
+#define STUN_MAX_ATTEMPTS 7
+#define SCTP_TIMER_MS 10
+
+typedef struct pending_send {
+    struct pending_send *next;
+    uint16_t channel;
+    int is_string;
+    size_t len;
+    uint8_t data[];
+} pending_send;
+
+/* ---- cross-TU helper implementations used by sctp.c / ice.c ---- */
+
+void cwist_webrtc_conn_on_message(cwist_webrtc_conn *conn, uint16_t channel,
+                                  const uint8_t *data, size_t len, int is_string) {
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    if (ctx->msg_cb)
+        ctx->msg_cb(conn, channel, data, len, ctx->msg_user);
+    (void)is_string;
+}
+
+void cwist_webrtc_conn_on_channel_open(cwist_webrtc_conn *conn, uint16_t channel,
+                                       const char *label) {
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    if (ctx->ch_cb)
+        ctx->ch_cb(conn, channel, label, ctx->ch_user);
+}
+
+void cwist_webrtc_conn_sctp_out(struct cwist_webrtc_conn *conn, const void *buffer, size_t len) {
+    if (!conn->ssl)
+        return;
+    SSL_write(conn->ssl, buffer, (int)len);
+}
+
+int cwist_webrtc_conn_send_raw(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len) {
+    return sendto(conn->ctx->udp_fd, data, len, 0, (struct sockaddr *)&conn->remote,
+                  conn->remote_len);
+}
+
+struct cwist_webrtc_conn *cwist_webrtc_ctx_find_conn(cwist_webrtc_ctx *ctx,
+                                                     const struct sockaddr_in *remote) {
+    for (struct cwist_webrtc_conn *c = ctx->conns; c; c = c->next) {
+        if (c->remote.sin_addr.s_addr == remote->sin_addr.s_addr &&
+            c->remote.sin_port == remote->sin_port)
+            return c;
+    }
+    return NULL;
+}
+
+void cwist_webrtc_ctx_add_conn(cwist_webrtc_ctx *ctx, struct cwist_webrtc_conn *conn) {
+    pthread_mutex_lock(&ctx->lock);
+    conn->next = ctx->conns;
+    ctx->conns = conn;
+    pthread_mutex_unlock(&ctx->lock);
+}
+
+void cwist_webrtc_conn_wake(cwist_webrtc_ctx *ctx) {
+    uint8_t b = 1;
+    ssize_t rc = write(ctx->wake_pipe[1], &b, 1);
+    (void)rc;
+}
+
+/* Best-effort local IPv4 for host candidates: first non-loopback address. */
+static void cwist_webrtc_detect_host_ip(char *out, size_t cap) {
+    snprintf(out, cap, "127.0.0.1");
+    struct ifaddrs *ifas = NULL;
+    if (getifaddrs(&ifas) < 0)
+        return;
+    for (struct ifaddrs *ifa = ifas; ifa; ifa = ifa->ifa_next) {
+        if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET)
+            continue;
+        struct sockaddr_in *sin = (struct sockaddr_in *)ifa->ifa_addr;
+        uint32_t a = ntohl(sin->sin_addr.s_addr);
+        if (((a >> 24) & 0xFF) == 127)
+            continue;
+        snprintf(out, cap, "%u.%u.%u.%u", (a >> 24) & 0xFF, (a >> 16) & 0xFF, (a >> 8) & 0xFF,
+                 a & 0xFF);
+        break;
+    }
+    freeifaddrs(ifas);
+}
+
+/* ---- time helpers ---- */
+
+static void now_ts(struct timespec *ts) {
+    clock_gettime(CLOCK_MONOTONIC, ts);
+}
+
+static long ms_until(const struct timespec *ts) {
+    struct timespec now;
+    now_ts(&now);
+    long ms = (ts->tv_sec - now.tv_sec) * 1000 + (ts->tv_nsec - now.tv_nsec) / 1000000;
+    return ms;
+}
+
+static void in_ms(long ms, struct timespec *ts) {
+    now_ts(ts);
+    ts->tv_sec += ms / 1000;
+    ts->tv_nsec += (ms % 1000) * 1000000;
+    if (ts->tv_nsec >= 1000000000) {
+        ts->tv_sec += 1;
+        ts->tv_nsec -= 1000000000;
+    }
+}
+
+/* ---- conn lifecycle ---- */
+
+static void conn_free(struct cwist_webrtc_conn *conn) {
+    cwist_sctp_conn_close(conn);
+    if (conn->ssl)
+        SSL_free(conn->ssl);
+    while (conn->pending_head) {
+        pending_send *p = conn->pending_head;
+        conn->pending_head = p->next;
+        free(p);
+    }
+    free(conn);
+}
+
+static struct cwist_webrtc_conn *conn_new(cwist_webrtc_ctx *ctx,
+                                          const struct sockaddr_in *remote, int is_server_role) {
+    struct cwist_webrtc_conn *conn = calloc(1, sizeof(*conn));
+    if (!conn)
+        return NULL;
+    conn->ctx = ctx;
+    conn->is_server_role = is_server_role;
+    conn->state = CWIST_CONN_STUN;
+    conn->remote = *remote;
+    conn->remote_len = sizeof(*remote);
+    return conn;
+}
+
+static void conn_arm_stun_timer(struct cwist_webrtc_conn *conn) {
+    in_ms(STUN_RETRANS_MS, &conn->stun_next);
+}
+
+static void conn_send_stun_request(struct cwist_webrtc_conn *conn) {
+    uint8_t msg[512];
+    char username[128];
+    snprintf(username, sizeof(username), "%s:%s", conn->peer_ufrag, conn->ctx->ice_ufrag);
+    int len = cwist_ice_stun_build_request(msg, sizeof(msg), conn->stun_txid, username);
+    if (len < 0)
+        return;
+    len = cwist_ice_stun_sign_request(msg, sizeof(msg), (size_t)len, conn->peer_pwd);
+    if (len < 0)
+        return;
+    cwist_webrtc_conn_send_raw(conn, msg, (size_t)len);
+    conn_arm_stun_timer(conn);
+    conn->stun_attempts++;
+}
+
+static void conn_start_dtls(struct cwist_webrtc_conn *conn) {
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    SSL_CTX *ssl_ctx = conn->is_server_role ? ctx->server_ssl_ctx : ctx->client_ssl_ctx;
+    conn->ssl = SSL_new(ssl_ctx);
+    if (!conn->ssl) {
+        conn->state = CWIST_CONN_DEAD;
+        return;
+    }
+    BIO *rbio = BIO_new(BIO_s_mem());
+    BIO *wbio = BIO_new(BIO_s_mem());
+    BIO_set_mem_eof_return(rbio, -1);
+    SSL_set_bio(conn->ssl, rbio, wbio);
+    SSL_set_options(conn->ssl, SSL_OP_NO_QUERY_MTU);
+    SSL_set_mtu(conn->ssl, 1200);
+    if (conn->is_server_role)
+        SSL_set_accept_state(conn->ssl);
+    else
+        SSL_set_connect_state(conn->ssl);
+    conn->state = CWIST_CONN_DTLS;
+    SSL_do_handshake(conn->ssl);
+}
+
+static void conn_flush_dtls_out(struct cwist_webrtc_conn *conn) {
+    uint8_t buf[2048];
+    for (;;) {
+        int n = BIO_read(SSL_get_wbio(conn->ssl), buf, sizeof(buf));
+        if (n <= 0)
+            break;
+        cwist_webrtc_conn_send_raw(conn, buf, (size_t)n);
+        if (n < (int)sizeof(buf))
+            break;
+    }
+}
+
+static void conn_mark_dead(struct cwist_webrtc_conn *conn) {
+    conn->state = CWIST_CONN_DEAD;
+}
+
+/* Drive handshake/data for a conn whose rbio has input. */
+static void conn_service(struct cwist_webrtc_conn *conn) {
+    if (conn->state == CWIST_CONN_DTLS) {
+        int rc = SSL_do_handshake(conn->ssl);
+        if (rc == 1) {
+            if (cwist_sctp_conn_open(conn) < 0) {
+                conn_mark_dead(conn);
+                return;
+            }
+            conn->state = CWIST_CONN_ESTABLISHED;
+        } else {
+            int err = SSL_get_error(conn->ssl, rc);
+            if (err == SSL_ERROR_SSL)
+                conn_mark_dead(conn);
+        }
+    }
+    if (conn->state == CWIST_CONN_ESTABLISHED) {
+        uint8_t buf[65536];
+        for (;;) {
+            int n = SSL_read(conn->ssl, buf, sizeof(buf));
+            if (n > 0) {
+                cwist_sctp_conn_input(conn, buf, (size_t)n);
+                continue;
+            }
+            int err = SSL_get_error(conn->ssl, n);
+            if (err == SSL_ERROR_ZERO_RETURN || (err == SSL_ERROR_SYSCALL && n == 0)) {
+                conn_mark_dead(conn);
+            }
+            break;
+        }
+        if (conn->state == CWIST_CONN_ESTABLISHED && !conn->sctp_ready &&
+            cwist_sctp_assoc_established(conn)) {
+            conn->sctp_ready = true;
+        }
+        cwist_sctp_conn_drain(conn);
+        if (conn->sctp_ready) {
+            while (conn->pending_head) {
+                pending_send *p = conn->pending_head;
+                if (cwist_sctp_send(conn, p->channel, p->data, p->len, p->is_string) < 0)
+                    break;
+                conn->pending_head = p->next;
+                if (!conn->pending_head)
+                    conn->pending_tail = NULL;
+                free(p);
+            }
+        }
+    }
+    if (conn->ssl)
+        conn_flush_dtls_out(conn);
+}
+
+/* ---- packet handling ---- */
+
+static void handle_stun(cwist_webrtc_ctx *ctx, const uint8_t *buf, size_t len,
+                        const struct sockaddr_in *remote) {
+    if (cwist_ice_stun_is_binding_request(buf, len)) {
+        if (!ctx->ice_lite_server)
+            return; /* this ctx has not answered any offer: not an ICE-lite endpoint */
+        struct cwist_webrtc_conn *conn = cwist_webrtc_ctx_find_conn(ctx, remote);
+        if (!cwist_ice_stun_validate_request(buf, len, ctx->ice_pwd))
+            return; /* bad MESSAGE-INTEGRITY: drop (RFC 8445 would send a 400) */
+        uint8_t resp[256];
+        int rlen = cwist_ice_stun_build_response(resp, sizeof(resp), buf, len, remote,
+                                                 ctx->ice_pwd);
+        if (rlen < 0)
+            return;
+        sendto(ctx->udp_fd, resp, (size_t)rlen, 0, (const struct sockaddr *)remote,
+               sizeof(*remote));
+        if (!conn && cwist_ice_stun_has_use_candidate(buf, len)) {
+            /* Adopt a connection parked by cwist_webrtc_handle_offer() when the
+             * request USERNAME carries its ufrag, else start a fresh one. */
+            char rfrag[64] = "";
+            cwist_ice_stun_get_remote_ufrag(buf, len, rfrag, sizeof(rfrag));
+            for (struct cwist_webrtc_conn *c = ctx->conns; c; c = c->next) {
+                if (c->is_server_role && c->state == CWIST_CONN_STUN && c->remote.sin_port == 0 &&
+                    (rfrag[0] == '\0' || strcmp(c->peer_ufrag, rfrag) == 0)) {
+                    conn = c;
+                    break;
+                }
+            }
+            int fresh = 0;
+            if (!conn) {
+                conn = conn_new(ctx, remote, 1);
+                fresh = 1;
+            }
+            if (conn) {
+                conn->remote = *remote;
+                conn->remote_len = sizeof(*remote);
+                if (fresh)
+                    cwist_webrtc_ctx_add_conn(ctx, conn);
+            }
+        }
+        if (conn && conn->state == CWIST_CONN_STUN &&
+            cwist_ice_stun_has_use_candidate(buf, len)) {
+            conn_start_dtls(conn);
+            conn_service(conn);
+        }
+        return;
+    }
+    if (!cwist_ice_stun_is_message(buf, len))
+        return;
+    /* STUN response: only our client-role connections expect these. */
+    struct cwist_webrtc_conn *conn = cwist_webrtc_ctx_find_conn(ctx, remote);
+    if (!conn || conn->is_server_role || conn->state != CWIST_CONN_STUN)
+        return;
+    struct sockaddr_in mapped;
+    if (cwist_ice_stun_parse_response(buf, len, conn->stun_txid, conn->peer_pwd, &mapped)) {
+        conn_start_dtls(conn);
+        conn_service(conn);
+    }
+}
+
+static void handle_packet(cwist_webrtc_ctx *ctx, const uint8_t *buf, size_t len,
+                          const struct sockaddr_in *remote) {
+    if (cwist_ice_stun_is_message(buf, len)) {
+        handle_stun(ctx, buf, len, remote);
+        return;
+    }
+    /* DTLS record (content type 20-64). */
+    struct cwist_webrtc_conn *conn = cwist_webrtc_ctx_find_conn(ctx, remote);
+    if (!conn || !conn->ssl || conn->state == CWIST_CONN_STUN)
+        return;
+    if (len > INT32_MAX)
+        return;
+    BIO_write(SSL_get_rbio(conn->ssl), buf, (int)len);
+    conn_service(conn);
+}
+
+/* ---- event loop ---- */
+
+static void loop_run_timers(cwist_webrtc_ctx *ctx) {
+    struct timespec now;
+    now_ts(&now);
+    struct cwist_webrtc_conn *dead = NULL;
+
+    pthread_mutex_lock(&ctx->lock);
+    struct cwist_webrtc_conn **pp = &ctx->conns;
+    while (*pp) {
+        struct cwist_webrtc_conn *conn = *pp;
+        int reap = conn->state == CWIST_CONN_DEAD || conn->closed;
+        if (reap) {
+            *pp = conn->next;
+            conn->next = dead;
+            dead = conn;
+            continue;
+        }
+        pp = &conn->next;
+    }
+    pthread_mutex_unlock(&ctx->lock);
+
+    while (dead) {
+        struct cwist_webrtc_conn *next = dead->next;
+        conn_free(dead);
+        dead = next;
+    }
+
+    for (struct cwist_webrtc_conn *conn = ctx->conns; conn; conn = conn->next) {
+        if (conn->state == CWIST_CONN_STUN && !conn->is_server_role &&
+            conn->stun_attempts > 0 && ms_until(&conn->stun_next) <= 0) {
+            if (conn->stun_attempts >= STUN_MAX_ATTEMPTS) {
+                conn_mark_dead(conn);
+                continue;
+            }
+            conn_send_stun_request(conn);
+        }
+        if (conn->state == CWIST_CONN_DTLS) {
+            struct timeval tv;
+            if (DTLSv1_get_timeout(conn->ssl, &tv) == 1 && tv.tv_sec == 0 && tv.tv_usec == 0) {
+                DTLSv1_handle_timeout(conn->ssl);
+                conn_service(conn);
+            }
+        }
+        if (conn->state == CWIST_CONN_ESTABLISHED) {
+            conn_service(conn);
+        }
+    }
+}
+
+static void *ctx_thread_main(void *arg) {
+    cwist_webrtc_ctx *ctx = arg;
+    uint8_t buf[65536];
+    struct timespec last_tick;
+    now_ts(&last_tick);
+
+    while (!ctx->stop) {
+        struct pollfd fds[2] = {
+            { .fd = ctx->udp_fd, .events = POLLIN },
+            { .fd = ctx->wake_pipe[0], .events = POLLIN },
+        };
+        int prc = poll(fds, 2, SCTP_TIMER_MS);
+        if (prc < 0) {
+            if (errno == EINTR)
+                continue;
+            break;
+        }
+        if (fds[1].revents & POLLIN) {
+            uint8_t drain[64];
+            while (read(ctx->wake_pipe[0], drain, sizeof(drain)) > 0)
+                ;
+        }
+        if (fds[0].revents & POLLIN) {
+            for (;;) {
+                struct sockaddr_in remote;
+                socklen_t rlen = sizeof(remote);
+                ssize_t n = recvfrom(ctx->udp_fd, buf, sizeof(buf), MSG_DONTWAIT,
+                                     (struct sockaddr *)&remote, &rlen);
+                if (n <= 0)
+                    break;
+                handle_packet(ctx, buf, (size_t)n, &remote);
+            }
+        }
+
+        struct timespec now;
+        now_ts(&now);
+        long elapsed_ms = (now.tv_sec - last_tick.tv_sec) * 1000 +
+                          (now.tv_nsec - last_tick.tv_nsec) / 1000000;
+        if (elapsed_ms > SCTP_TIMER_MS)
+            elapsed_ms = SCTP_TIMER_MS;
+        if (elapsed_ms < 0)
+            elapsed_ms = 0;
+        if (elapsed_ms > 0) {
+            usrsctp_handle_timers((uint32_t)elapsed_ms);
+            last_tick = now;
+        }
+        loop_run_timers(ctx);
+    }
+    return NULL;
+}
+
+/* ---- public API ---- */
+
+cwist_webrtc_ctx *cwist_webrtc_ctx_new(uint16_t port) {
+    if (cwist_sctp_global_init() < 0)
+        return NULL;
+
+    cwist_webrtc_ctx *ctx = calloc(1, sizeof(*ctx));
+    if (!ctx)
+        return NULL;
+    ctx->udp_fd = -1;
+    ctx->wake_pipe[0] = -1;
+    ctx->wake_pipe[1] = -1;
+    pthread_mutex_init(&ctx->lock, NULL);
+
+    ctx->udp_fd = socket(AF_INET, SOCK_DGRAM, 0);
+    if (ctx->udp_fd < 0)
+        goto fail;
+    int flags = fcntl(ctx->udp_fd, F_GETFL, 0);
+    fcntl(ctx->udp_fd, F_SETFL, flags | O_NONBLOCK);
+    struct sockaddr_in addr;
+    memset(&addr, 0, sizeof(addr));
+    addr.sin_family = AF_INET;
+    addr.sin_addr.s_addr = htonl(INADDR_ANY);
+    addr.sin_port = htons(port);
+    if (bind(ctx->udp_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0)
+        goto fail;
+    socklen_t alen = sizeof(addr);
+    if (getsockname(ctx->udp_fd, (struct sockaddr *)&addr, &alen) < 0)
+        goto fail;
+    ctx->port = ntohs(addr.sin_port);
+
+    if (pipe(ctx->wake_pipe) < 0)
+        goto fail;
+    for (int i = 0; i < 2; i++) {
+        int fl = fcntl(ctx->wake_pipe[i], F_GETFL, 0);
+        fcntl(ctx->wake_pipe[i], F_SETFL, fl | O_NONBLOCK);
+    }
+
+    if (cwist_dtls_generate_cert(&ctx->cert, &ctx->pkey) < 0)
+        goto fail;
+    if (cwist_dtls_fingerprint(ctx->cert, ctx->fingerprint, sizeof(ctx->fingerprint)) < 0)
+        goto fail;
+    ctx->server_ssl_ctx = cwist_dtls_ctx_new(1, ctx->cert, ctx->pkey);
+    ctx->client_ssl_ctx = cwist_dtls_ctx_new(0, NULL, NULL);
+    if (!ctx->server_ssl_ctx || !ctx->client_ssl_ctx)
+        goto fail;
+
+    cwist_ice_random_creds(ctx->ice_ufrag, sizeof(ctx->ice_ufrag), ctx->ice_pwd,
+                           sizeof(ctx->ice_pwd));
+    cwist_webrtc_detect_host_ip(ctx->host_ip, sizeof(ctx->host_ip));
+
+    ctx->thread_running = pthread_create(&ctx->thread, NULL, &ctx_thread_main, ctx) == 0;
+    if (!ctx->thread_running)
+        goto fail;
+    return ctx;
+
+fail:
+    cwist_webrtc_ctx_free(ctx);
+    return NULL;
+}
+
+uint16_t cwist_webrtc_ctx_port(const cwist_webrtc_ctx *ctx) {
+    return ctx->port;
+}
+
+const char *cwist_webrtc_ctx_fingerprint(const cwist_webrtc_ctx *ctx) {
+    return ctx->fingerprint;
+}
+
+void cwist_webrtc_ctx_set_message_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_message_cb cb,
+                                          void *user) {
+    ctx->msg_cb = cb;
+    ctx->msg_user = user;
+}
+
+void cwist_webrtc_ctx_set_channel_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_channel_cb cb,
+                                          void *user) {
+    ctx->ch_cb = cb;
+    ctx->ch_user = user;
+}
+
+int cwist_webrtc_handle_offer(cwist_webrtc_ctx *ctx, const char *offer, char *answer_out,
+                              size_t out_len) {
+    cwist_sdp_info info;
+    if (cwist_sdp_parse(offer, strlen(offer), &info) < 0)
+        return -1;
+    struct sockaddr_in wildcard;
+    memset(&wildcard, 0, sizeof(wildcard));
+    wildcard.sin_family = AF_INET;
+    wildcard.sin_addr.s_addr = htonl(INADDR_ANY);
+    wildcard.sin_port = 0;
+    struct cwist_webrtc_conn *conn = conn_new(ctx, &wildcard, 1);
+    if (!conn)
+        return -1;
+    snprintf(conn->peer_ufrag, sizeof(conn->peer_ufrag), "%s", info.ice_ufrag);
+    snprintf(conn->peer_pwd, sizeof(conn->peer_pwd), "%s", info.ice_pwd);
+    cwist_webrtc_ctx_add_conn(ctx, conn);
+    ctx->ice_lite_server = 1;
+
+    const char *mid = info.mid[0] ? info.mid : "0";
+    return cwist_sdp_write_answer(answer_out, out_len, ctx->fingerprint, ctx->ice_ufrag,
+                                  ctx->ice_pwd, mid, ctx->host_ip, ctx->port);
+}
+
+int cwist_webrtc_ctx_connection_count(const cwist_webrtc_ctx *ctx) {
+    int n = 0;
+    for (struct cwist_webrtc_conn *c = ctx->conns; c; c = c->next) {
+        if (c->sctp_ready)
+            n++;
+    }
+    return n;
+}
+
+int cwist_webrtc_conn_send(cwist_webrtc_conn *conn, uint16_t channel_id, const uint8_t *data,
+                           size_t len, cwist_webrtc_data_type type) {
+    if (len > 1 << 20)
+        return -1;
+    pending_send *p = malloc(sizeof(*p) + len);
+    if (!p)
+        return -1;
+    p->next = NULL;
+    p->channel = channel_id;
+    p->is_string = type == CWIST_WEBRTC_DATA_STRING;
+    p->len = len;
+    memcpy(p->data, data, len);
+
+    pthread_mutex_lock(&conn->ctx->lock);
+    if (conn->pending_tail)
+        conn->pending_tail->next = p;
+    else
+        conn->pending_head = p;
+    conn->pending_tail = p;
+    pthread_mutex_unlock(&conn->ctx->lock);
+    cwist_webrtc_conn_wake(conn->ctx);
+    return 0;
+}
+
+void cwist_webrtc_conn_close(cwist_webrtc_conn *conn) {
+    conn->closed = true;
+    cwist_webrtc_conn_wake(conn->ctx);
+}
+
+void cwist_webrtc_ctx_free(cwist_webrtc_ctx *ctx) {
+    if (!ctx)
+        return;
+    ctx->stop = true;
+    cwist_webrtc_conn_wake(ctx);
+    if (ctx->thread_running)
+        pthread_join(ctx->thread, NULL);
+
+    struct cwist_webrtc_conn *conn = ctx->conns;
+    while (conn) {
+        struct cwist_webrtc_conn *next = conn->next;
+        conn_free(conn);
+        conn = next;
+    }
+    if (ctx->udp_fd >= 0)
+        close(ctx->udp_fd);
+    if (ctx->wake_pipe[0] >= 0)
+        close(ctx->wake_pipe[0]);
+    if (ctx->wake_pipe[1] >= 0)
+        close(ctx->wake_pipe[1]);
+    if (ctx->server_ssl_ctx)
+        SSL_CTX_free(ctx->server_ssl_ctx);
+    if (ctx->client_ssl_ctx)
+        SSL_CTX_free(ctx->client_ssl_ctx);
+    if (ctx->cert)
+        X509_free(ctx->cert);
+    if (ctx->pkey)
+        EVP_PKEY_free(ctx->pkey);
+    pthread_mutex_destroy(&ctx->lock);
+    free(ctx);
+}
+
+/* ---- internal client-role dialer (loopback tests) ---- */
+
+cwist_webrtc_conn *cwist_webrtc_connect(cwist_webrtc_ctx *ctx, const struct sockaddr_in *remote,
+                                        const char *peer_ufrag, const char *peer_pwd) {
+    struct cwist_webrtc_conn *conn = conn_new(ctx, remote, 0);
+    if (!conn)
+        return NULL;
+    snprintf(conn->peer_ufrag, sizeof(conn->peer_ufrag), "%s", peer_ufrag);
+    snprintf(conn->peer_pwd, sizeof(conn->peer_pwd), "%s", peer_pwd);
+    RAND_bytes(conn->stun_txid, sizeof(conn->stun_txid));
+    cwist_webrtc_ctx_add_conn(ctx, conn);
+    conn_send_stun_request(conn);
+    cwist_webrtc_conn_wake(ctx);
+    return conn;
+}
diff --git a/src/net/webrtc/webrtc_internal.h b/src/net/webrtc/webrtc_internal.h
new file mode 100644
index 000000000..626ca5e71
--- /dev/null
+++ b/src/net/webrtc/webrtc_internal.h
@@ -0,0 +1,164 @@
+/** @file webrtc_internal.h
+ * @brief Shared internals between the webrtc module translation units.
+ */
+#ifndef __CWIST_WEBRTC_INTERNAL_H__
+#define __CWIST_WEBRTC_INTERNAL_H__
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+struct socket; /* usrsctp */
+struct pending_send;
+
+typedef enum {
+    CWIST_CONN_STUN = 0, /* waiting for ICE nomination (or response, client) */
+    CWIST_CONN_DTLS = 1, /* DTLS handshake in flight */
+    CWIST_CONN_ESTABLISHED = 2, /* DTLS up, SCTP pending or up */
+    CWIST_CONN_DEAD = 3
+} cwist_conn_state;
+
+struct cwist_webrtc_conn {
+    cwist_webrtc_ctx *ctx;
+    int is_server_role; /* 1: passive DTLS role (answering), 0: active (offering) */
+    cwist_conn_state state;
+    bool sctp_ready;
+    struct sockaddr_in remote;
+    socklen_t remote_len;
+
+    SSL *ssl;
+
+    struct socket *sctp_sock;
+    struct socket *sctp_acc; /* accepted socket on the answering side */
+    uint8_t *ch_state; /* 2 bits per channel: 0=opened_by_peer, 1=open_sent */
+
+    /* ICE */
+    char peer_ufrag[64];
+    char peer_pwd[128];
+    uint8_t stun_txid[12];
+    struct timespec stun_next;
+    int stun_attempts;
+
+    bool closed;
+    struct pending_send *pending_head;
+    struct pending_send *pending_tail;
+    struct cwist_webrtc_conn *next;
+};
+
+struct cwist_webrtc_ctx {
+    int udp_fd;
+    uint16_t port;
+    pthread_t thread;
+    bool thread_running;
+    int wake_pipe[2];
+    bool stop;
+    int ice_lite_server; /* set once an offer has been answered */
+
+    X509 *cert;
+    EVP_PKEY *pkey;
+    SSL_CTX *server_ssl_ctx;
+    SSL_CTX *client_ssl_ctx;
+    char fingerprint[128];
+    char ice_ufrag[16];
+    char ice_pwd[64];
+    char host_ip[64]; /* best-guess local IPv4 for SDP host candidates */
+
+    cwist_webrtc_message_cb msg_cb;
+    void *msg_user;
+    cwist_webrtc_channel_cb ch_cb;
+    void *ch_user;
+
+    cwist_webrtc_conn *conns;
+    pthread_mutex_t lock;
+};
+
+/* ice.c */
+#define CWIST_STUN_BINDING_REQUEST 0x0001
+#define CWIST_STUN_BINDING_RESPONSE 0x0101
+
+int cwist_ice_stun_is_message(const uint8_t *buf, size_t len);
+bool cwist_ice_stun_is_binding_request(const uint8_t *buf, size_t len);
+bool cwist_ice_stun_has_use_candidate(const uint8_t *buf, size_t len);
+/* Extract the remote ufrag from the USERNAME attribute ("remoteufrag:localufrag"). */
+int cwist_ice_stun_get_remote_ufrag(const uint8_t *buf, size_t len, char *out, size_t cap);
+/* Validate MESSAGE-INTEGRITY of a request against pwd. 1 = valid, 0 = invalid. */
+int cwist_ice_stun_validate_request(const uint8_t *buf, size_t len, const char *pwd);
+/* Build a binding response (XOR-MAPPED-ADDRESS + MI + fingerprint). Returns length or -1. */
+int cwist_ice_stun_build_response(uint8_t *out, size_t cap, const uint8_t *req, size_t req_len,
+                                  const struct sockaddr_in *mapped, const char *pwd);
+/* Build a controlling binding request with USE-CANDIDATE. Returns length or -1. */
+int cwist_ice_stun_build_request(uint8_t *out, size_t cap, const uint8_t txid[12],
+                                 const char *username);
+/* Sign a built request (append MI + FINGERPRINT). Returns new length or -1. */
+int cwist_ice_stun_sign_request(uint8_t *msg, size_t cap, size_t msg_len, const char *pwd);
+/* Validate a response: cookie, txid match, MI with pwd, return mapped address. */
+int cwist_ice_stun_parse_response(const uint8_t *buf, size_t len, const uint8_t txid[12],
+                                  const char *pwd, struct sockaddr_in *mapped);
+void cwist_ice_random_creds(char *ufrag, size_t ufrag_cap, char *pwd, size_t pwd_cap);
+
+/* sdp.c */
+typedef struct {
+    char ice_ufrag[64];
+    char ice_pwd[128];
+    char fingerprint[128];
+    char setup[16];
+    char mid[16];
+    int has_lite;
+} cwist_sdp_info;
+
+/* Parse the fields the ICE-lite agent needs. Missing fields stay zeroed. Returns 0 on success. */
+int cwist_sdp_parse(const char *sdp, size_t len, cwist_sdp_info *info);
+int cwist_sdp_write_answer(char *out, size_t cap, const char *fingerprint, const char *ufrag,
+                           const char *pwd, const char *mid, const char *host, uint16_t port);
+int cwist_sdp_write_offer(char *out, size_t cap, const char *fingerprint, const char *ufrag,
+                          const char *pwd, const char *mid);
+
+/* dtls.c */
+int cwist_dtls_generate_cert(X509 **cert, EVP_PKEY **pkey);
+int cwist_dtls_fingerprint(X509 *cert, char *out, size_t cap);
+SSL_CTX *cwist_dtls_ctx_new(int is_server, X509 *cert, EVP_PKEY *pkey);
+
+/* sctp.c */
+/* Global usrsctp init (nothreads; caller pumps usrsctp_handle_timers). Idempotent. */
+int cwist_sctp_global_init(void);
+/* Create the per-conn SCTP socket. If active_role, also connect() to start the association. */
+int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn);
+void cwist_sctp_conn_close(struct cwist_webrtc_conn *conn);
+/* Feed a DTLS-decrypted SCTP packet into usrsctp. */
+void cwist_sctp_conn_input(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len);
+/* Accept the association (passive side) and drain queued inbound messages. */
+void cwist_sctp_conn_drain(struct cwist_webrtc_conn *conn);
+int dcep_send(struct cwist_webrtc_conn *conn, uint16_t channel, uint8_t type,
+              const char *label);
+/* Send a DataChannel message; sends DCEP OPEN first when the channel is new. */
+int cwist_sctp_send(struct cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                    size_t len, int is_string);
+bool cwist_sctp_assoc_established(struct cwist_webrtc_conn *conn);
+
+/* webrtc.c helpers used across the module */
+int cwist_webrtc_conn_send_raw(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len);
+void cwist_webrtc_conn_sctp_out(struct cwist_webrtc_conn *conn, const void *buffer, size_t len);
+void cwist_webrtc_conn_on_channel_open(struct cwist_webrtc_conn *conn, uint16_t channel,
+                                       const char *label);
+void cwist_webrtc_conn_on_message(struct cwist_webrtc_conn *conn, uint16_t channel,
+                                  const uint8_t *data, size_t len, int is_string);
+struct cwist_webrtc_conn *cwist_webrtc_ctx_find_conn(cwist_webrtc_ctx *ctx,
+                                                     const struct sockaddr_in *remote);
+void cwist_webrtc_ctx_add_conn(cwist_webrtc_ctx *ctx, struct cwist_webrtc_conn *conn);
+void cwist_webrtc_conn_start_dtls(struct cwist_webrtc_conn *conn);
+void cwist_webrtc_conn_wake(cwist_webrtc_ctx *ctx);
+
+/* Internal client-role dialer (used by loopback tests): starts ICE against a
+ * remote ICE-lite endpoint described by peer_ufrag/peer_pwd from its answer. */
+cwist_webrtc_conn *cwist_webrtc_connect(cwist_webrtc_ctx *ctx, const struct sockaddr_in *remote,
+                                        const char *peer_ufrag, const char *peer_pwd);
+
+#endif
diff --git a/tests/test_webrtc.c b/tests/test_webrtc.c
new file mode 100644
index 000000000..c8faabc67
--- /dev/null
+++ b/tests/test_webrtc.c
@@ -0,0 +1,179 @@
+/** @file test_webrtc.c
+ * @brief End-to-end WebRTC DataChannel test over UDP loopback.
+ *
+ * Drives a full ICE + DTLS + SCTP exchange between two in-process cwist
+ * webrtc contexts (one answering ICE-lite endpoint, one offering peer built
+ * on the internal client role) and exchanges DataChannel messages in both
+ * directions.
+ */
+#include 
+
+#include "../src/net/webrtc/webrtc_internal.h"
+
+#include 
+#include 
+#include 
+#include 
+
+typedef struct test_peer {
+    int got_ping;
+    int got_pong;
+    int got_channel;
+    char label[64];
+    cwist_webrtc_conn *conn;
+} test_peer;
+
+static void server_on_message(cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                              size_t len, void *user) {
+    test_peer *p = user;
+    if (len == 4 && memcmp(data, "ping", 4) == 0) {
+        p->got_ping = 1;
+        /* Reply on the same channel: server -> client direction. */
+        assert(cwist_webrtc_conn_send(conn, channel, (const uint8_t *)"pong", 4,
+                                      CWIST_WEBRTC_DATA_BINARY) == 0);
+    }
+}
+
+static void client_on_message(cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                              size_t len, void *user) {
+    test_peer *p = user;
+    p->conn = conn;
+    if (len == 4 && memcmp(data, "pong", 4) == 0)
+        p->got_pong = 1;
+    (void)channel;
+}
+
+static void peer_on_channel(cwist_webrtc_conn *conn, uint16_t channel, const char *label,
+                            void *user) {
+    test_peer *p = user;
+    p->conn = conn;
+    p->got_channel = 1;
+    snprintf(p->label, sizeof(p->label), "%s", label);
+    (void)channel;
+}
+
+int main(void) {
+    /* ICE/STUN unit checks: build, sign, validate, respond. */
+    {
+        uint8_t req[512];
+        uint8_t txid[12];
+        memset(txid, 0xAB, sizeof(txid));
+        int len = cwist_ice_stun_build_request(req, sizeof(req), txid, "rfrag:lfrag");
+        assert(len > 0);
+        len = cwist_ice_stun_sign_request(req, sizeof(req), (size_t)len, "testpassword0123456789");
+        assert(len > 0);
+        assert(cwist_ice_stun_is_message(req, (size_t)len));
+        assert(cwist_ice_stun_is_binding_request(req, (size_t)len));
+        assert(cwist_ice_stun_has_use_candidate(req, (size_t)len));
+        assert(cwist_ice_stun_validate_request(req, (size_t)len, "testpassword0123456789") == 1);
+        assert(cwist_ice_stun_validate_request(req, (size_t)len, "wrongpassword000000000") == 0);
+        char rfrag[64];
+        assert(cwist_ice_stun_get_remote_ufrag(req, (size_t)len, rfrag, sizeof(rfrag)) == 0);
+        assert(strcmp(rfrag, "rfrag") == 0);
+
+        struct sockaddr_in mapped;
+        memset(&mapped, 0, sizeof(mapped));
+        mapped.sin_family = AF_INET;
+        mapped.sin_port = htons(54321);
+        mapped.sin_addr.s_addr = htonl(0x7F000001);
+        uint8_t resp[512];
+        int rlen = cwist_ice_stun_build_response(resp, sizeof(resp), req, (size_t)len, &mapped,
+                                                 "testpassword0123456789");
+        assert(rlen > 0);
+        struct sockaddr_in parsed;
+        assert(cwist_ice_stun_parse_response(resp, (size_t)rlen, txid, "testpassword0123456789",
+                                             &parsed) == 1);
+        assert(parsed.sin_port == htons(54321));
+        assert(parsed.sin_addr.s_addr == htonl(0x7F000001));
+    }
+
+    /* SDP unit checks. */
+    {
+        char offer[1024];
+        assert(cwist_sdp_write_offer(offer, sizeof(offer),
+                                     "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:"
+                                     "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99",
+                                     "offrfrag", "offpassword0123456789012", "0") == 0);
+        cwist_sdp_info info;
+        assert(cwist_sdp_parse(offer, strlen(offer), &info) == 0);
+        assert(strcmp(info.ice_ufrag, "offrfrag") == 0);
+        assert(strcmp(info.ice_pwd, "offpassword0123456789012") == 0);
+        assert(strcmp(info.setup, "active") == 0);
+        assert(info.has_lite == 0);
+    }
+
+    /* End-to-end: offer/answer + ICE + DTLS + SCTP + DataChannel ping/pong. */
+    cwist_webrtc_ctx *server = cwist_webrtc_ctx_new(0);
+    assert(server != NULL);
+    assert(cwist_webrtc_ctx_port(server) > 0);
+    assert(strchr(cwist_webrtc_ctx_fingerprint(server), ':') != NULL);
+
+    test_peer server_peer = { 0 };
+    cwist_webrtc_ctx_set_message_handler(server, &server_on_message, &server_peer);
+    cwist_webrtc_ctx_set_channel_handler(server, &peer_on_channel, &server_peer);
+
+    char offer[1024];
+    assert(cwist_sdp_write_offer(offer, sizeof(offer), "00:11:22:33:44:55:66:77:88:99:"
+                                                       "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55",
+                                 "browserfrag", "browserpassword0123456789012", "0") == 0);
+    char answer[2048];
+    assert(cwist_webrtc_handle_offer(server, offer, answer, sizeof(answer)) == 0);
+    assert(strstr(answer, "a=ice-lite") != NULL);
+    assert(strstr(answer, "a=setup:passive") != NULL);
+    assert(strstr(answer, "webrtc-datachannel") != NULL);
+    assert(strstr(answer, "a=candidate:") != NULL);
+
+    cwist_sdp_info ans;
+    assert(cwist_sdp_parse(answer, strlen(answer), &ans) == 0);
+
+    test_peer client_peer = { 0 };
+    cwist_webrtc_ctx *client = cwist_webrtc_ctx_new(0);
+    assert(client != NULL);
+    cwist_webrtc_ctx_set_message_handler(client, &client_on_message, &client_peer);
+
+    struct sockaddr_in remote;
+    memset(&remote, 0, sizeof(remote));
+    remote.sin_family = AF_INET;
+    remote.sin_port = htons(cwist_webrtc_ctx_port(server));
+    remote.sin_addr.s_addr = htonl(0x7F000001);
+
+    cwist_webrtc_conn *conn = cwist_webrtc_connect(client, &remote, ans.ice_ufrag, ans.ice_pwd);
+    assert(conn != NULL);
+
+    /* Open channel 1 from the offering side and send "ping". */
+    assert(cwist_webrtc_conn_send(conn, 1, (const uint8_t *)"ping", 4,
+                                  CWIST_WEBRTC_DATA_BINARY) == 0);
+
+    int waited_ms = 0;
+    while (waited_ms < 10000 &&
+           !(server_peer.got_channel && client_peer.got_pong && server_peer.got_ping)) {
+        usleep(10000);
+        waited_ms += 10;
+    }
+
+    assert(server_peer.got_ping);
+    assert(server_peer.got_channel);
+    assert(strcmp(server_peer.label, "dc1") == 0);
+    assert(client_peer.got_pong);
+
+    /* Second message on the now-established channel, both directions again. */
+    server_peer.got_ping = 0;
+    client_peer.got_pong = 0;
+    assert(cwist_webrtc_conn_send(conn, 1, (const uint8_t *)"ping", 4,
+                                  CWIST_WEBRTC_DATA_BINARY) == 0);
+    waited_ms = 0;
+    while (waited_ms < 5000 && !(client_peer.got_pong && server_peer.got_ping)) {
+        usleep(10000);
+        waited_ms += 10;
+    }
+    assert(server_peer.got_ping);
+    assert(client_peer.got_pong);
+
+    assert(cwist_webrtc_ctx_connection_count(server) >= 1);
+
+    cwist_webrtc_ctx_free(client);
+    cwist_webrtc_ctx_free(server);
+
+    printf("test_webrtc: all assertions passed\n");
+    return 0;
+}

From b395a9cd8e626961f5364ffdc8099df25db4829b Mon Sep 17 00:00:00 2001
From: Lee Yunjin 
Date: Sun, 4 Oct 2026 23:51:40 +0900
Subject: [PATCH 2/7] test(webrtc): wire test_webrtc into TEST_TARGETS

check_test_wiring.py only scans the TEST_TARGETS = ... assignment block,
so the conditional TEST_TARGETS += from the feature commit was invisible
to the gate and CI flagged tests/test_webrtc.c as unwired. List
test_webrtc in the main block instead, keep the link rule next to the
other test rules, and add a CWIST_WEBRTC=0 skip stub so the target
resolves when the module is compiled out.
---
 Makefile | 14 ++++++++------
 1 file changed, 8 insertions(+), 6 deletions(-)

diff --git a/Makefile b/Makefile
index 8f7ea0c52..7be220d96 100644
--- a/Makefile
+++ b/Makefile
@@ -895,6 +895,7 @@ TEST_TARGETS = test_worker_affinity \
                test_cors \
                test_websocket \
                test_websocket_async \
+               test_webrtc \
                test_jwt \
                test_migrate \
                test_json_heal \
@@ -977,12 +978,7 @@ bench_security_pool: $(LIB_NAME) tests/bench_security_pool.c
 	$(CC) $(CFLAGS) -o bench_security_pool tests/bench_security_pool.c $(LIB_NAME) $(LIBS)
 	./bench_security_pool
 
-# WebRTC DataChannel end-to-end: ICE + DTLS + SCTP over UDP loopback with an
-# in-process peer (client role) built on the same stack.
-ifeq ($(CWIST_WEBRTC),1)
-TEST_TARGETS += test_webrtc
-endif
-
+# WebRTC DataChannel end-to-end test (see the test_webrtc rule below).
 test: $(TEST_TARGETS)
 
 src/sys/app/app.o: src/sys/app/worker_affinity.h
@@ -1520,6 +1516,12 @@ test_webrtc: $(LIB_NAME) $(USRSCTP_LIB) tests/test_webrtc.c
 	$(CC) $(CFLAGS) -o test_webrtc tests/test_webrtc.c $(LIB_NAME) $(LIBS)
 	./test_webrtc
 
+.PHONY: test_webrtc
+else
+# CWIST_WEBRTC=0: the module and its test are compiled out.
+test_webrtc:
+	@echo "CWIST_WEBRTC=0: skipping test_webrtc"
+
 .PHONY: test_webrtc
 endif
 

From 86007eff5162d7341b801b8ef4838d7569ebf124 Mon Sep 17 00:00:00 2001
From: Lee Yunjin 
Date: Mon, 5 Oct 2026 17:41:03 +0900
Subject: [PATCH 3/7] fix(alloc): Replace raw alloc/free into
 cwist_alloc/cwist_free

---
 src/net/webrtc/sctp.c   | 14 ++++++++------
 src/net/webrtc/webrtc.c | 14 +++++++-------
 2 files changed, 15 insertions(+), 13 deletions(-)

diff --git a/src/net/webrtc/sctp.c b/src/net/webrtc/sctp.c
index f5a4ddf09..4e7c2e417 100644
--- a/src/net/webrtc/sctp.c
+++ b/src/net/webrtc/sctp.c
@@ -22,6 +22,8 @@
 #include 
 #include 
 
+#include 
+
 #define DCEP_ACK 0x02
 #define DCEP_OPEN 0x03
 
@@ -109,7 +111,7 @@ static int on_incoming(struct socket *sock, union sctp_sockstore addr, void *dat
     if (!conn || !data)
         return 1;
     dispatch_message(conn, rcv.rcv_sid, ntohl(rcv.rcv_ppid), (const uint8_t *)data, datalen);
-    free(data);
+    cwist_free(data);
     return 1;
 }
 
@@ -161,7 +163,7 @@ int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn) {
     usrsctp_setsockopt(conn->sctp_sock, IPPROTO_SCTP, SCTP_RECVRCVINFO, &on, sizeof(on));
     usrsctp_setsockopt(conn->sctp_sock, IPPROTO_SCTP, SCTP_NODELAY, &on, sizeof(on));
 
-    conn->ch_state = calloc(1, CH_STATE_BYTES);
+    conn->ch_state = cwist_malloc(1, CH_STATE_BYTES);
     if (!conn->ch_state) {
         usrsctp_close(conn->sctp_sock);
         conn->sctp_sock = NULL;
@@ -177,7 +179,7 @@ int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn) {
     local.sconn_addr = conn;
     if (usrsctp_bind(conn->sctp_sock, (struct sockaddr *)&local, sizeof(local)) < 0) {
         usrsctp_deregister_address(conn);
-        free(conn->ch_state);
+        cwist_free(conn->ch_state);
         conn->ch_state = NULL;
         usrsctp_close(conn->sctp_sock);
         conn->sctp_sock = NULL;
@@ -188,7 +190,7 @@ int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn) {
         /* Passive side must accept the incoming SCTP association. */
         if (usrsctp_listen(conn->sctp_sock, 1) < 0) {
             usrsctp_deregister_address(conn);
-            free(conn->ch_state);
+            cwist_free(conn->ch_state);
             conn->ch_state = NULL;
             usrsctp_close(conn->sctp_sock);
             conn->sctp_sock = NULL;
@@ -207,7 +209,7 @@ int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn) {
         if (usrsctp_connect(conn->sctp_sock, (struct sockaddr *)&remote, sizeof(remote)) < 0 &&
             errno != EINPROGRESS) {
             usrsctp_deregister_address(conn);
-            free(conn->ch_state);
+            cwist_free(conn->ch_state);
             conn->ch_state = NULL;
             usrsctp_close(conn->sctp_sock);
             conn->sctp_sock = NULL;
@@ -228,7 +230,7 @@ void cwist_sctp_conn_close(struct cwist_webrtc_conn *conn) {
     }
     if (conn->ch_state) {
         usrsctp_deregister_address(conn);
-        free(conn->ch_state);
+        cwist_free(conn->ch_state);
         conn->ch_state = NULL;
     }
 }
diff --git a/src/net/webrtc/webrtc.c b/src/net/webrtc/webrtc.c
index 2836f0e9e..22e48a594 100644
--- a/src/net/webrtc/webrtc.c
+++ b/src/net/webrtc/webrtc.c
@@ -134,14 +134,14 @@ static void conn_free(struct cwist_webrtc_conn *conn) {
     while (conn->pending_head) {
         pending_send *p = conn->pending_head;
         conn->pending_head = p->next;
-        free(p);
+        cwist_free(p);
     }
-    free(conn);
+    cwist_free(conn);
 }
 
 static struct cwist_webrtc_conn *conn_new(cwist_webrtc_ctx *ctx,
                                           const struct sockaddr_in *remote, int is_server_role) {
-    struct cwist_webrtc_conn *conn = calloc(1, sizeof(*conn));
+    struct cwist_webrtc_conn *conn = cwist_malloc(1, sizeof(*conn));
     if (!conn)
         return NULL;
     conn->ctx = ctx;
@@ -252,7 +252,7 @@ static void conn_service(struct cwist_webrtc_conn *conn) {
                 conn->pending_head = p->next;
                 if (!conn->pending_head)
                     conn->pending_tail = NULL;
-                free(p);
+                cwist_free(p);
             }
         }
     }
@@ -444,7 +444,7 @@ cwist_webrtc_ctx *cwist_webrtc_ctx_new(uint16_t port) {
     if (cwist_sctp_global_init() < 0)
         return NULL;
 
-    cwist_webrtc_ctx *ctx = calloc(1, sizeof(*ctx));
+    cwist_webrtc_ctx *ctx = cwist_malloc(1, sizeof(*ctx));
     if (!ctx)
         return NULL;
     ctx->udp_fd = -1;
@@ -555,7 +555,7 @@ int cwist_webrtc_conn_send(cwist_webrtc_conn *conn, uint16_t channel_id, const u
                            size_t len, cwist_webrtc_data_type type) {
     if (len > 1 << 20)
         return -1;
-    pending_send *p = malloc(sizeof(*p) + len);
+    pending_send *p = cwist_alloc(sizeof(*p) + len);
     if (!p)
         return -1;
     p->next = NULL;
@@ -609,7 +609,7 @@ void cwist_webrtc_ctx_free(cwist_webrtc_ctx *ctx) {
     if (ctx->pkey)
         EVP_PKEY_free(ctx->pkey);
     pthread_mutex_destroy(&ctx->lock);
-    free(ctx);
+    cwist_free(ctx);
 }
 
 /* ---- internal client-role dialer (loopback tests) ---- */

From 9e72e33d9c2dcd83568246d279a6e1f6911d1fa5 Mon Sep 17 00:00:00 2001
From: Lee Yunjin 
Date: Mon, 5 Oct 2026 18:39:35 +0900
Subject: [PATCH 4/7] fix(webrtc): use current cwist allocator API

---
 src/net/webrtc/sctp.c   | 2 +-
 src/net/webrtc/webrtc.c | 6 ++++--
 2 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/src/net/webrtc/sctp.c b/src/net/webrtc/sctp.c
index 4e7c2e417..2115b87f1 100644
--- a/src/net/webrtc/sctp.c
+++ b/src/net/webrtc/sctp.c
@@ -163,7 +163,7 @@ int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn) {
     usrsctp_setsockopt(conn->sctp_sock, IPPROTO_SCTP, SCTP_RECVRCVINFO, &on, sizeof(on));
     usrsctp_setsockopt(conn->sctp_sock, IPPROTO_SCTP, SCTP_NODELAY, &on, sizeof(on));
 
-    conn->ch_state = cwist_malloc(1, CH_STATE_BYTES);
+    conn->ch_state = cwist_malloc(CH_STATE_BYTES);
     if (!conn->ch_state) {
         usrsctp_close(conn->sctp_sock);
         conn->sctp_sock = NULL;
diff --git a/src/net/webrtc/webrtc.c b/src/net/webrtc/webrtc.c
index 22e48a594..551d8642d 100644
--- a/src/net/webrtc/webrtc.c
+++ b/src/net/webrtc/webrtc.c
@@ -7,6 +7,8 @@
  */
 #include "webrtc_internal.h"
 
+#include 
+
 #include 
 #include 
 #include 
@@ -141,7 +143,7 @@ static void conn_free(struct cwist_webrtc_conn *conn) {
 
 static struct cwist_webrtc_conn *conn_new(cwist_webrtc_ctx *ctx,
                                           const struct sockaddr_in *remote, int is_server_role) {
-    struct cwist_webrtc_conn *conn = cwist_malloc(1, sizeof(*conn));
+    struct cwist_webrtc_conn *conn = cwist_malloc(sizeof(*conn));
     if (!conn)
         return NULL;
     conn->ctx = ctx;
@@ -444,7 +446,7 @@ cwist_webrtc_ctx *cwist_webrtc_ctx_new(uint16_t port) {
     if (cwist_sctp_global_init() < 0)
         return NULL;
 
-    cwist_webrtc_ctx *ctx = cwist_malloc(1, sizeof(*ctx));
+    cwist_webrtc_ctx *ctx = cwist_malloc(sizeof(*ctx));
     if (!ctx)
         return NULL;
     ctx->udp_fd = -1;

From 57f5227e941d23d11579ab133ab5cf5a29f033cf Mon Sep 17 00:00:00 2001
From: Lee Yunjin 
Date: Mon, 5 Oct 2026 18:52:20 +0900
Subject: [PATCH 5/7] docs(webrtc): add Doxygen comments to src/net/webrtc

---
 src/net/webrtc/dtls.c            |  50 ++++++
 src/net/webrtc/ice.c             | 238 ++++++++++++++++++++++---
 src/net/webrtc/sctp.c            | 202 ++++++++++++++++++++-
 src/net/webrtc/sdp.c             |  61 +++++++
 src/net/webrtc/webrtc.c          | 297 ++++++++++++++++++++++++++++++-
 src/net/webrtc/webrtc_internal.h | 236 +++++++++++++++++++++---
 6 files changed, 1024 insertions(+), 60 deletions(-)

diff --git a/src/net/webrtc/dtls.c b/src/net/webrtc/dtls.c
index 0bbe09308..5f50a02bd 100644
--- a/src/net/webrtc/dtls.c
+++ b/src/net/webrtc/dtls.c
@@ -1,5 +1,10 @@
 /** @file dtls.c
  * @brief DTLS helpers: ephemeral certificate generation, fingerprint, SSL_CTX.
+ *
+ * Provides the minimal DTLS layer for WebRTC: an ephemeral self-signed
+ * ECDSA certificate, the SHA-256 certificate fingerprint used in SDP
+ * a=fingerprint attributes, and construction of client/server SSL_CTX
+ * objects pinned to DTLS 1.2.
  */
 #include "webrtc_internal.h"
 
@@ -9,8 +14,24 @@
 #include 
 #include 
 
+/** @brief DTLS record/link MTU assumed for the media path (bytes).
+ * Used to size outgoing DTLS records so they fit typical UDP/ICE paths
+ * without IP fragmentation.
+ */
 #define CWIST_DTLS_MTU 1200
 
+/** @brief Generate an ephemeral self-signed ECDSA certificate for DTLS.
+ * @param cert_out Receives a new X509 certificate; set to NULL on entry
+ *                 and on failure.
+ * @param pkey_out Receives the matching EVP_PKEY; set to NULL on entry
+ *                 and on failure.
+ * @return 0 on success, -1 on any OpenSSL error.
+ * @note The key is P-256 (NID_X9_62_prime256v1). The certificate has
+ *       version 3 (v2 encoding), serial 1, a 30-day validity starting now,
+ *       and CN "cwist-webrtc". On failure all intermediate objects are
+ *       freed and both outputs remain NULL; on success ownership moves to
+ *       the caller, who must free them with X509_free()/EVP_PKEY_free().
+ */
 int cwist_dtls_generate_cert(X509 **cert_out, EVP_PKEY **pkey_out) {
     *cert_out = NULL;
     *pkey_out = NULL;
@@ -52,6 +73,19 @@ int cwist_dtls_generate_cert(X509 **cert_out, EVP_PKEY **pkey_out) {
     return 0;
 }
 
+/** @brief Compute the SHA-256 fingerprint of a certificate in SDP format.
+ * @param cert Certificate to hash; must be non-NULL.
+ * @param out  Output buffer receiving the fingerprint text.
+ * @param cap  Capacity of @p out in bytes (including the NUL terminator).
+ * @return 0 on success, -1 if the DER encoding fails, the certificate
+ *         exceeds the internal 2048-byte DER buffer, or the digest does
+ *         not fit in @p out.
+ * @retval 0 Fingerprint written, e.g. "AB:12:...:EF" in uppercase hex.
+ * @retval -1 Encoding, size, or digest error.
+ * @note The result is the colon-separated uppercase hex SHA-256 digest as
+ *       used in the SDP a=fingerprint attribute. No terminator is written
+ *       beyond what snprintf() already emits.
+ */
 int cwist_dtls_fingerprint(X509 *cert, char *out, size_t cap) {
     uint8_t der[2048];
     int der_len = i2d_X509(cert, NULL);
@@ -69,6 +103,19 @@ int cwist_dtls_fingerprint(X509 *cert, char *out, size_t cap) {
     return off > 0 && off < cap ? 0 : -1;
 }
 
+/** @brief Create a DTLS SSL_CTX for the client or server role.
+ * @param is_server Non-zero to build a DTLS server context, zero for a
+ *                  DTLS client context.
+ * @param cert      Server certificate; used (with @p pkey) only when
+ *                  @p is_server is non-zero. May be NULL for a client.
+ * @param pkey      Private key matching @p cert; only used for a server.
+ * @return New SSL_CTX on success, NULL on allocation failure or when
+ *         certificate/key loading or validation fails on the server side.
+ * @note The context is pinned to DTLS 1.2 as the minimum protocol version.
+ *       Client contexts use SSL_VERIFY_NONE: peer fingerprint verification
+ *       is deferred to the SDP exchange (the test pins the expected
+ *       fingerprint out of band).
+ */
 SSL_CTX *cwist_dtls_ctx_new(int is_server, X509 *cert, EVP_PKEY *pkey) {
     SSL_CTX *ctx = SSL_CTX_new(is_server ? DTLS_server_method() : DTLS_client_method());
     if (!ctx)
@@ -88,6 +135,9 @@ SSL_CTX *cwist_dtls_ctx_new(int is_server, X509 *cert, EVP_PKEY *pkey) {
     return ctx;
 }
 
+/** @brief Return the DTLS link MTU in bytes.
+ * @return The value of CWIST_DTLS_MTU (1200).
+ */
 unsigned int cwist_dtls_link_mtu(void) {
     return CWIST_DTLS_MTU;
 }
diff --git a/src/net/webrtc/ice.c b/src/net/webrtc/ice.c
index 241f45582..d6ae69741 100644
--- a/src/net/webrtc/ice.c
+++ b/src/net/webrtc/ice.c
@@ -1,5 +1,12 @@
 /** @file ice.c
  * @brief Minimal STUN/ICE (RFC 5389 / RFC 8445) for the ICE-lite agent.
+ *
+ * Implements just enough of STUN message parsing, validation, and construction
+ * to run an ICE-lite endpoint: binding request/response handling,
+ * MESSAGE-INTEGRITY (HMAC-SHA1) and FINGERPRINT (CRC-32) generation and
+ * checking, XOR-MAPPED-ADDRESS extraction, and random ICE credential
+ * generation. No retransmission or state machine logic lives here; callers in
+ * webrtc.c drive timing and nomination.
  */
 #include "webrtc_internal.h"
 
@@ -8,21 +15,30 @@
 #include 
 #include 
 
-#define STUN_MAGIC 0x2112A442u
-#define STUN_HDR_LEN 20
-#define ATTR_MAPPED_ADDRESS 0x0001
-#define ATTR_USERNAME 0x0006
-#define ATTR_MESSAGE_INTEGRITY 0x0008
-#define ATTR_PRIORITY 0x0024
-#define ATTR_USE_CANDIDATE 0x0025
-#define ATTR_XOR_MAPPED_ADDRESS 0x0020
-#define ATTR_FINGERPRINT 0x8028
-#define ATTR_ICE_CONTROLLED 0x8029
-#define ATTR_ICE_CONTROLLING 0x802A
-
+/** @name STUN wire-format constants (RFC 5389)
+ * @{ */
+#define STUN_MAGIC 0x2112A442u /**< Magic cookie; also the XOR mask for addresses/ports. */
+#define STUN_HDR_LEN 20        /**< Fixed STUN message header size in bytes. */
+#define ATTR_MAPPED_ADDRESS 0x0001     /**< MAPPED-ADDRESS attribute type. Unused (we send XOR-MAPPED-ADDRESS). */
+#define ATTR_USERNAME 0x0006           /**< USERNAME attribute type ("remoteufrag:localufrag"). */
+#define ATTR_MESSAGE_INTEGRITY 0x0008  /**< MESSAGE-INTEGRITY attribute type (HMAC-SHA1, 20 bytes). */
+#define ATTR_PRIORITY 0x0024           /**< PRIORITY attribute type (ICE, RFC 8445). */
+#define ATTR_USE_CANDIDATE 0x0025      /**< USE-CANDIDATE attribute type (nomination hint). */
+#define ATTR_XOR_MAPPED_ADDRESS 0x0020 /**< XOR-MAPPED-ADDRESS attribute type. */
+#define ATTR_FINGERPRINT 0x8028        /**< FINGERPRINT attribute type (CRC-32 xor 0x5354554E). */
+#define ATTR_ICE_CONTROLLED 0x8029     /**< ICE-CONTROLLED attribute type. Unused by this agent. */
+#define ATTR_ICE_CONTROLLING 0x802A    /**< ICE-CONTROLLING attribute type (we always send it). */
+/** @} */
+
+/** Byte-at-a-time CRC-32 (IEEE, poly 0xEDB88320) lookup table, built lazily by crc32_init(). */
 static uint32_t crc32_table[256];
+/** Set once crc32_table has been initialized. */
 static bool crc32_ready = false;
 
+/** @brief Build the CRC-32 lookup table on first use.
+ *
+ * Idempotent; subsequent calls return immediately.
+ */
 static void crc32_init(void) {
     if (crc32_ready)
         return;
@@ -35,7 +51,13 @@ static void crc32_init(void) {
     crc32_ready = true;
 }
 
-/* Incremental CRC-32 (IEEE) over possibly several consecutive calls. */
+/** @brief Incremental CRC-32 (IEEE) over possibly several consecutive calls.
+ * @param c    Running CRC value; start a new checksum with 0xFFFFFFFF and xor
+ *             the final result with 0xFFFFFFFF (the STUN FINGERPRINT framing).
+ * @param data Bytes to fold into the checksum.
+ * @param len  Number of bytes at @p data.
+ * @return Updated running CRC.
+ */
 static uint32_t crc32_update(uint32_t c, const uint8_t *data, size_t len) {
     crc32_init();
     for (size_t i = 0; i < len; i++)
@@ -43,19 +65,34 @@ static uint32_t crc32_update(uint32_t c, const uint8_t *data, size_t len) {
     return c;
 }
 
+/** @brief Read a 32-bit big-endian value.
+ * @param p Pointer to at least 4 readable bytes.
+ * @return The value in host byte order semantics (as an integer composed from big-endian bytes).
+ */
 static uint32_t rd32(const uint8_t *p) {
     return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3];
 }
 
+/** @brief Read a 16-bit big-endian value.
+ * @param p Pointer to at least 2 readable bytes.
+ */
 static uint16_t rd16(const uint8_t *p) {
     return (uint16_t)((uint16_t)p[0] << 8 | p[1]);
 }
 
+/** @brief Write a 16-bit value in big-endian order.
+ * @param p Destination buffer (2 bytes).
+ * @param v Value to encode.
+ */
 static void wr16(uint8_t *p, uint16_t v) {
     p[0] = (uint8_t)(v >> 8);
     p[1] = (uint8_t)v;
 }
 
+/** @brief Write a 32-bit value in big-endian order.
+ * @param p Destination buffer (4 bytes).
+ * @param v Value to encode.
+ */
 static void wr32(uint8_t *p, uint32_t v) {
     p[0] = (uint8_t)(v >> 24);
     p[1] = (uint8_t)(v >> 16);
@@ -63,17 +100,44 @@ static void wr32(uint8_t *p, uint32_t v) {
     p[3] = (uint8_t)v;
 }
 
+/** @brief Check whether a datagram looks like a STUN message.
+ * @param buf Datagram bytes.
+ * @param len Number of bytes at @p buf.
+ * @return 1 if @p buf holds at least a STUN header and the magic cookie matches,
+ *         0 otherwise.
+ * @note This is a shape check only; no attribute parsing or auth is performed.
+ */
 int cwist_ice_stun_is_message(const uint8_t *buf, size_t len) {
     return len >= STUN_HDR_LEN && rd32(buf + 4) == STUN_MAGIC;
 }
 
+/** @brief Check whether a datagram is a STUN binding request.
+ * @param buf Datagram bytes.
+ * @param len Number of bytes at @p buf.
+ * @return true if @p buf is a STUN message with type CWIST_STUN_BINDING_REQUEST.
+ */
 bool cwist_ice_stun_is_binding_request(const uint8_t *buf, size_t len) {
     return cwist_ice_stun_is_message(buf, len) && rd16(buf) == CWIST_STUN_BINDING_REQUEST;
 }
 
-/* Walk attributes. Calls cb(type, value, value_len) for each; stop if cb returns false. */
+/** Callback invoked once per STUN attribute during a walk_attrs() scan.
+ * @param type    Attribute type field.
+ * @param val     Pointer to the attribute value inside the message buffer.
+ * @param val_len Value length in bytes (unpadded).
+ * @param arg     Opaque pointer supplied to walk_attrs().
+ * @retval true   Continue walking.
+ * @retval false  Stop walking (walk_attrs() then reports failure to its caller).
+ */
 typedef bool (*attr_cb)(uint16_t type, const uint8_t *val, uint16_t val_len, void *arg);
 
+/** @brief Walk the attribute list of a STUN message.
+ * @param msg STUN message buffer (header + attributes).
+ * @param len Total bytes available at @p msg.
+ * @param cb  Callback invoked for each attribute, in message order.
+ * @param arg Passed through to @p cb.
+ * @return true if the attribute area is well-formed and fully consumed,
+ *         false if the message is truncated, malformed, or @p cb stopped the walk.
+ */
 static bool walk_attrs(const uint8_t *msg, size_t len, attr_cb cb, void *arg) {
     uint16_t msg_len = rd16(msg + 2);
     if ((size_t)msg_len + STUN_HDR_LEN > len || msg_len % 4 != 0)
@@ -93,12 +157,16 @@ static bool walk_attrs(const uint8_t *msg, size_t len, attr_cb cb, void *arg) {
     return off == end;
 }
 
+/** State for attr_find_cb(): which attribute type to look for and the result. */
 typedef struct {
-    bool found;
-    uint16_t val_len;
-    uint16_t type_wanted;
+    bool found;           /**< Set true once the wanted attribute has been seen. */
+    uint16_t val_len;     /**< Value length of the found attribute (valid only if found). */
+    uint16_t type_wanted; /**< Attribute type to search for. */
 } attr_find_arg;
 
+/** @brief attr_cb that records the first occurrence of a wanted attribute.
+ * Stops the walk (returns false) after the first match so later duplicates are ignored.
+ */
 static bool attr_find_cb(uint16_t type, const uint8_t *val, uint16_t val_len, void *arg) {
     (void)val;
     attr_find_arg *a = arg;
@@ -110,17 +178,35 @@ static bool attr_find_cb(uint16_t type, const uint8_t *val, uint16_t val_len, vo
     return true;
 }
 
+/** @brief Check whether a STUN message contains an attribute of the given type.
+ * @param msg  STUN message buffer.
+ * @param len  Bytes available at @p msg.
+ * @param type Attribute type to look for.
+ * @return true if present. Malformed messages simply report "not found".
+ */
 static bool attr_present(const uint8_t *msg, size_t len, uint16_t type) {
     attr_find_arg a = { .type_wanted = type };
     walk_attrs(msg, len, attr_find_cb, &a);
     return a.found;
 }
 
+/** @brief Check whether a STUN message carries the USE-CANDIDATE attribute.
+ * @param buf Datagram bytes.
+ * @param len Number of bytes at @p buf.
+ */
 bool cwist_ice_stun_has_use_candidate(const uint8_t *buf, size_t len) {
     return attr_present(buf, len, ATTR_USE_CANDIDATE);
 }
 
-/* Locate the MESSAGE-INTEGRITY attribute; *mi_off gets its header offset. */
+/** @brief Locate the MESSAGE-INTEGRITY attribute in a STUN message.
+ * @param msg    STUN message buffer.
+ * @param len    Bytes available at @p msg.
+ * @param mi_off Receives the offset of the MI attribute header within @p msg.
+ * @return true if a MESSAGE-INTEGRITY attribute was found, false otherwise.
+ * @retval true  Only if the attribute length is exactly 20 (HMAC-SHA1 size).
+ * @warning A short/long MI attribute is reported as "not found" here; callers
+ *          treat that as an unsigned message rather than as a hard error.
+ */
 static bool find_mi(const uint8_t *msg, size_t len, size_t *mi_off) {
     if (!cwist_ice_stun_is_message(msg, len))
         return false;
@@ -139,6 +225,19 @@ static bool find_mi(const uint8_t *msg, size_t len, size_t *mi_off) {
     return false;
 }
 
+/** @brief Validate the MESSAGE-INTEGRITY of a STUN message against a password.
+ * @param buf Datagram bytes (any STUN message type; also used for responses).
+ * @param len Number of bytes at @p buf.
+ * @param pwd Short-term credential password used as the HMAC-SHA1 key.
+ * @return 1 if the HMAC matches, 0 if the message is malformed, the HMAC does
+ *         not match, or OpenSSL allocation fails.
+ * @retval 1 Also when no MESSAGE-INTEGRITY attribute is present: accepted for
+ *            MVP interoperability (RFC 8445 requires MI, but the agent stays
+ *            permissive here).
+ * @note The HMAC covers the message up to and including the MI attribute
+ *       header, with the header's length field adjusted to end at the MI
+ *       attribute, per RFC 5389.
+ */
 int cwist_ice_stun_validate_request(const uint8_t *buf, size_t len, const char *pwd) {
     size_t pwd_len = strlen(pwd);
     uint8_t hmac[EVP_MAX_MD_SIZE];
@@ -164,6 +263,21 @@ int cwist_ice_stun_validate_request(const uint8_t *buf, size_t len, const char *
     return hmac_len == 20 && memcmp(hmac, buf + mi_off + 4, 20) == 0;
 }
 
+/** @brief Build a STUN binding response for a received binding request.
+ *
+ * Emits a message containing XOR-MAPPED-ADDRESS (IPv4 only), MESSAGE-INTEGRITY
+ * (HMAC-SHA1 keyed with @p pwd), and FINGERPRINT attributes, reusing the
+ * request's transaction ID.
+ *
+ * @param out    Destination buffer.
+ * @param cap    Capacity of @p out in bytes.
+ * @param req    The received binding request (used for its transaction ID).
+ * @param req_len Bytes at @p req; currently unused.
+ * @param mapped Address/port to report back (XOR-encoded with the magic cookie).
+ * @param pwd    Short-term credential password for the MI HMAC.
+ * @return Total response length in bytes, or -1 if @p cap is too small or
+ *         OpenSSL allocation fails.
+ */
 int cwist_ice_stun_build_response(uint8_t *out, size_t cap, const uint8_t *req, size_t req_len,
                                   const struct sockaddr_in *mapped, const char *pwd) {
     (void)req_len;
@@ -229,6 +343,21 @@ int cwist_ice_stun_build_response(uint8_t *out, size_t cap, const uint8_t *req,
     return (int)(STUN_HDR_LEN + blen);
 }
 
+/** @brief Build an unsigned ICE-controlling STUN binding request.
+ *
+ * The request carries USERNAME (only if @p username is non-empty),
+ * ICE-CONTROLLING, USE-CANDIDATE, and PRIORITY attributes. MESSAGE-INTEGRITY
+ * and FINGERPRINT are intentionally not added here; the MI key (the peer's
+ * password) is only known at send time, so the caller signs the built request
+ * with cwist_ice_stun_sign_request().
+ *
+ * @param out      Destination buffer.
+ * @param cap      Capacity of @p out in bytes.
+ * @param txid     12-byte transaction ID to copy into the header.
+ * @param username "remoteufrag:localufrag" string for the USERNAME attribute;
+ *                 skipped when empty.
+ * @return Total request length in bytes, or -1 if @p cap is too small.
+ */
 int cwist_ice_stun_build_request(uint8_t *out, size_t cap, const uint8_t txid[12],
                                  const char *username) {
     size_t ulen = strlen(username);
@@ -276,7 +405,21 @@ int cwist_ice_stun_build_request(uint8_t *out, size_t cap, const uint8_t txid[12
     return (int)(STUN_HDR_LEN + blen);
 }
 
-/* Append MI + FINGERPRINT to a freshly built request. */
+/** @brief Append MESSAGE-INTEGRITY and FINGERPRINT attributes to a built request.
+ *
+ * Rewrites the message header with the final length after appending. The MI
+ * HMAC covers header+body up to and including the MI attribute header, with the
+ * length field adjusted accordingly, per RFC 5389; the FINGERPRINT CRC covers
+ * the whole message including MI.
+ *
+ * @param msg     Buffer holding a message previously built by
+ *                cwist_ice_stun_build_request(); extended in place.
+ * @param cap     Total capacity of @p msg.
+ * @param msg_len Current length of the message in @p msg.
+ * @param pwd     Short-term credential password for the MI HMAC.
+ * @return New total message length, or -1 if @p cap is too small or OpenSSL
+ *         allocation fails.
+ */
 static int stun_sign(uint8_t *msg, size_t cap, size_t msg_len, const char *pwd) {
     uint8_t body[64];
     size_t blen = 0;
@@ -321,16 +464,27 @@ static int stun_sign(uint8_t *msg, size_t cap, size_t msg_len, const char *pwd)
     return (int)(msg_len + blen);
 }
 
+/** @brief Public wrapper around stun_sign() for signing a built binding request.
+ * @param msg     Buffer holding the request; extended in place.
+ * @param cap     Total capacity of @p msg.
+ * @param msg_len Current request length.
+ * @param pwd     Peer password used as the MI HMAC key.
+ * @return New total length, or -1 on failure (see stun_sign()).
+ */
 int cwist_ice_stun_sign_request(uint8_t *msg, size_t cap, size_t msg_len, const char *pwd) {
     return stun_sign(msg, cap, msg_len, pwd);
 }
 
+/** State for parse_resp_cb(): collected MESSAGE-INTEGRITY value and mapped address. */
 typedef struct {
-    const uint8_t *mi_val;
-    struct sockaddr_in mapped;
-    bool has_mapped;
+    const uint8_t *mi_val;   /**< Pointer to the MI attribute value (into the message buffer), or NULL. */
+    struct sockaddr_in mapped; /**< Decoded XOR-MAPPED-ADDRESS, valid only if has_mapped. */
+    bool has_mapped;         /**< True once an IPv4 XOR-MAPPED-ADDRESS has been decoded. */
 } parse_resp_arg;
 
+/** @brief attr_cb collecting the MI value and XOR-MAPPED-ADDRESS from a response.
+ * Never stops the walk; duplicates overwrite earlier values (last one wins).
+ */
 static bool parse_resp_cb(uint16_t type, const uint8_t *val, uint16_t val_len, void *arg) {
     parse_resp_arg *a = arg;
     if (type == ATTR_MESSAGE_INTEGRITY) {
@@ -345,6 +499,22 @@ static bool parse_resp_cb(uint16_t type, const uint8_t *val, uint16_t val_len, v
     return true;
 }
 
+/** @brief Validate a received STUN binding response.
+ *
+ * Checks, in order: well-formed STUN message of type BINDING-RESPONSE,
+ * transaction ID match against @p txid, MESSAGE-INTEGRITY HMAC with @p pwd
+ * (via cwist_ice_stun_validate_request()), and presence of a MESSAGE-INTEGRITY
+ * attribute. If @p mapped is non-NULL, an IPv4 XOR-MAPPED-ADDRESS must also be
+ * present and is decoded into it.
+ *
+ * @param buf    Datagram bytes.
+ * @param len    Number of bytes at @p buf.
+ * @param txid   Transaction ID the response must carry (the one we sent).
+ * @param pwd    Local password used as the MI HMAC key.
+ * @param mapped Optional output for the decoded mapped address.
+ * @retval 1 Valid response.
+ * @retval 0 Any check failed or the message is malformed.
+ */
 int cwist_ice_stun_parse_response(const uint8_t *buf, size_t len, const uint8_t txid[12],
                                   const char *pwd, struct sockaddr_in *mapped) {
     if (!cwist_ice_stun_is_message(buf, len) || rd16(buf) != CWIST_STUN_BINDING_RESPONSE)
@@ -366,8 +536,21 @@ int cwist_ice_stun_parse_response(const uint8_t *buf, size_t len, const uint8_t
     return 1;
 }
 
+/** Base64url alphabet (no padding) used to encode random ICE credentials. */
 static const char b64url_chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
 
+/** @brief Extract the remote ufrag from the USERNAME attribute of a STUN message.
+ *
+ * The USERNAME attribute holds "remoteufrag:localufrag" (RFC 8445); this
+ * returns the part before the first ':'.
+ *
+ * @param buf Datagram bytes.
+ * @param len Number of bytes at @p buf.
+ * @param out Output buffer for the NUL-terminated ufrag.
+ * @param cap Capacity of @p out; output is truncated to cap - 1 characters.
+ * @retval 0 Ufrag extracted (possibly truncated).
+ * @retval -1 Not a STUN message, or no USERNAME attribute present.
+ */
 int cwist_ice_stun_get_remote_ufrag(const uint8_t *buf, size_t len, char *out, size_t cap) {
     if (!cwist_ice_stun_is_message(buf, len))
         return -1;
@@ -391,6 +574,17 @@ int cwist_ice_stun_get_remote_ufrag(const uint8_t *buf, size_t len, char *out, s
     return -1;
 }
 
+/** @brief Generate random ICE ufrag/pwd credentials.
+ *
+ * Both strings are drawn from 24 bytes of CSPRNG output encoded with the
+ * base64url alphabet (6 bits per character).
+ *
+ * @param ufrag     Output buffer for the ufrag.
+ * @param ufrag_cap Capacity of @p ufrag; up to 8 characters plus NUL are written.
+ * @param pwd       Output buffer for the password.
+ * @param pwd_cap   Capacity of @p pwd; up to 32 characters plus NUL are written.
+ * @warning Behavior is undefined if either capacity is 0 (cap - 1 underflows).
+ */
 void cwist_ice_random_creds(char *ufrag, size_t ufrag_cap, char *pwd, size_t pwd_cap) {
     uint8_t raw[24];
     RAND_bytes(raw, sizeof(raw));
diff --git a/src/net/webrtc/sctp.c b/src/net/webrtc/sctp.c
index 2115b87f1..6ed81a0d5 100644
--- a/src/net/webrtc/sctp.c
+++ b/src/net/webrtc/sctp.c
@@ -24,17 +24,49 @@
 
 #include 
 
-#define DCEP_ACK 0x02
-#define DCEP_OPEN 0x03
+/** @name DCEP message types (RFC 8832) */
+/**@{*/
+#define DCEP_ACK 0x02  /**< DCEP ACK: acknowledges a received OPEN. */
+#define DCEP_OPEN 0x03 /**< DCEP OPEN: announces a new DataChannel. */
+/**@}*/
 
-#define PPID_STRING 50
-#define PPID_BINARY 51
-#define PPID_DCEP 53
+/** @name SCTP payload protocol identifiers (RFC 8831) */
+/**@{*/
+#define PPID_STRING 50 /**< DataChannel message in UTF-8 string form. */
+#define PPID_BINARY 51 /**< DataChannel message in binary form. */
+#define PPID_DCEP 53   /**< DCEP control message (OPEN/ACK). */
+/**@}*/
 
+/** WebRTC DataChannel well-known SCTP port (RFC 8831, both endpoints use it). */
 #define CWIST_SCTP_PORT 5000
-#define CHANNEL_BITS(n) ((n) * 2)
+
+/** @name Per-connection channel state bitmap
+ *
+ * conn->ch_state tracks two flags per DataChannel id in a packed bit array:
+ * bit 0 = channel announced by the peer (DCEP OPEN received), bit 1 = we
+ * sent DCEP OPEN for the channel. Any bit set means the channel is usable.
+ */
+/**@{*/
+#define CHANNEL_BITS(n) ((n) * 2)                 /**< Bit offset of channel (n)'s flags. */
 #define CH_STATE_BYTES (65536 / 4) /* 2 bits per channel */
+/**@}*/
 
+/**
+ * @brief Global usrsctp conn_output callback (AF_CONN raw mode).
+ *
+ * Hands a usrsctp-generated SCTP packet to the owning connection for
+ * transmission over its DTLS link. Registered once via
+ * usrsctp_init_nothreads() in cwist_sctp_global_init(). The @p addr handle is
+ * the cwist_webrtc_conn pointer registered with usrsctp_register_address().
+ *
+ * @param addr     Connection handle (cwist_webrtc_conn *); may be NULL.
+ * @param buffer   SCTP packet to transmit; ownership stays with usrsctp.
+ * @param length   Length of @p buffer in bytes.
+ * @param tos      Ignored (AF_CONN).
+ * @param set_df   Ignored (AF_CONN).
+ * @return 0 on success, -1 if the connection is gone (packet is dropped).
+ * @warning Called from usrsctp context; must not block or free @p buffer.
+ */
 static int sctp_global_output(void *addr, void *buffer, size_t length, uint8_t tos,
                               uint8_t set_df) {
     (void)tos;
@@ -46,6 +78,15 @@ static int sctp_global_output(void *addr, void *buffer, size_t length, uint8_t t
     return 0;
 }
 
+/**
+ * @brief Initialize the process-wide usrsctp instance. Idempotent.
+ *
+ * Registers the global conn_output callback (sctp_global_output) and disables
+ * UDP tunneling as required by RFC 8831. usrsctp must be pumped with
+ * usrsctp_handle_timers() from the caller's event loop afterwards.
+ *
+ * @return Always 0; later calls are no-ops.
+ */
 int cwist_sctp_global_init(void) {
     static int initialized = 0;
     if (initialized)
@@ -57,6 +98,14 @@ int cwist_sctp_global_init(void) {
     return 0;
 }
 
+/**
+ * @brief Read a per-channel flag bit from conn->ch_state.
+ *
+ * @param conn  Connection whose channel bitmap is read.
+ * @param ch    DataChannel id (0..65535).
+ * @param bit   Flag index within the channel's two bits (0 or 1).
+ * @return The flag value, or false if no bitmap is allocated.
+ */
 static bool ch_get(struct cwist_webrtc_conn *conn, uint16_t ch, int bit) {
     if (!conn->ch_state)
         return false;
@@ -64,6 +113,13 @@ static bool ch_get(struct cwist_webrtc_conn *conn, uint16_t ch, int bit) {
     return (conn->ch_state[off / 8] >> (off % 8)) & 1u;
 }
 
+/**
+ * @brief Set a per-channel flag bit in conn->ch_state.
+ *
+ * @param conn  Connection whose channel bitmap is updated.
+ * @param ch    DataChannel id (0..65535).
+ * @param bit   Flag index within the channel's two bits (0 or 1).
+ */
 static void ch_set(struct cwist_webrtc_conn *conn, uint16_t ch, int bit) {
     if (!conn->ch_state)
         return;
@@ -71,11 +127,32 @@ static void ch_set(struct cwist_webrtc_conn *conn, uint16_t ch, int bit) {
     conn->ch_state[off / 8] |= (uint8_t)(1u << (off % 8));
 }
 
-/* Effective data socket: the accepted socket on the answering side. */
+/**
+ * @brief Resolve the socket that carries DataChannel traffic.
+ *
+ * @param conn  Connection to query.
+ * @return The accepted socket on the answering side, otherwise the listening
+ *         (offering) socket; may be NULL before cwist_sctp_conn_open().
+ */
 static struct socket *cwist_sctp_data_sock(struct cwist_webrtc_conn *conn) {
     return conn->sctp_acc ? conn->sctp_acc : conn->sctp_sock;
 }
 
+/**
+ * @brief Route one received SCTP message to the right handler.
+ *
+ * DCEP OPEN messages are parsed for their label (truncated to 200 bytes),
+ * the channel is marked as peer-opened, a DCEP ACK is sent back, and
+ * cwist_webrtc_conn_on_channel_open() is fired. A received DCEP ACK only
+ * confirms the channel is ready both ways and is otherwise ignored. Binary
+ * and string messages are passed to cwist_webrtc_conn_on_message().
+ *
+ * @param conn    Owning connection; used for callbacks and channel flags.
+ * @param sid     SCTP stream id == DataChannel id.
+ * @param ppid    Payload protocol identifier in host byte order (PPID_*).
+ * @param msg     Message payload; not NUL-terminated.
+ * @param datalen Length of @p msg in bytes.
+ */
 static void dispatch_message(struct cwist_webrtc_conn *conn, uint16_t sid, uint32_t ppid,
                              const uint8_t *msg, size_t datalen) {
     if (ppid == PPID_DCEP && datalen >= 1) {
@@ -101,7 +178,23 @@ static void dispatch_message(struct cwist_webrtc_conn *conn, uint16_t sid, uint3
     }
 }
 
-/* receive_cb for the offering (connected) socket. */
+/**
+ * @brief usrsctp receive callback for the offering (connected) socket.
+ *
+ * Fires inside usrsctp whenever data arrives on the active side's socket;
+ * hands the message to dispatch_message() and frees the usrsctp-allocated
+ * buffer. The answering side instead drains with usrsctp_recvv() in
+ * cwist_sctp_conn_drain(), so this callback is not installed there.
+ *
+ * @param sock     Ignored.
+ * @param addr     Ignored.
+ * @param data     usrsctp-allocated message buffer; freed here.
+ * @param datalen  Length of @p data.
+ * @param rcv      Receive info carrying the stream id and PPID.
+ * @param flags    Ignored.
+ * @param ulp_info The cwist_webrtc_conn registered at socket creation.
+ * @return Always 1, telling usrsctp the data was consumed.
+ */
 static int on_incoming(struct socket *sock, union sctp_sockstore addr, void *data, size_t datalen,
                        struct sctp_rcvinfo rcv, int flags, void *ulp_info) {
     (void)sock;
@@ -115,6 +208,20 @@ static int on_incoming(struct socket *sock, union sctp_sockstore addr, void *dat
     return 1;
 }
 
+/**
+ * @brief Send a DCEP control message (OPEN or ACK) on a DataChannel.
+ *
+ * Builds the message in a stack buffer (max 12-byte header + 200-byte label)
+ * and sends it with PPID_DCEP on stream @p channel via the effective data
+ * socket. OPEN carries a RELIABLE channel type and the given label;
+ * ACK is a bare 12-byte header with no label.
+ *
+ * @param conn    Owning connection.
+ * @param channel DataChannel id == SCTP stream id to send on.
+ * @param type    DCEP message type (DCEP_OPEN or DCEP_ACK).
+ * @param label   Channel label for OPEN; ignored (may be NULL) for ACK.
+ * @return 0 if the message was handed to usrsctp, -1 on send failure.
+ */
 int dcep_send(struct cwist_webrtc_conn *conn, uint16_t channel, uint8_t type, const char *label) {
     uint8_t msg[256];
     size_t len = 0;
@@ -150,6 +257,24 @@ int dcep_send(struct cwist_webrtc_conn *conn, uint16_t channel, uint8_t type, co
     return rc >= 0 ? 0 : -1;
 }
 
+/**
+ * @brief Create the per-connection SCTP socket and start (or listen for) the
+ * association.
+ *
+ * Creates a non-blocking AF_CONN socket with SCTP_RECVRCVINFO and SCTP_NODELAY
+ * enabled, allocates the channel state bitmap, registers the connection as
+ * the AF_CONN address handle, and binds to CWIST_SCTP_PORT. The offering
+ * side additionally connect()s to its own handle to send the INIT (EINPROGRESS
+ * is treated as in-flight); the answering side listen()s for the association.
+ * On any failure all partially created state is torn down.
+ *
+ * @param conn  Connection to set up; sctp_sock/ch_state are filled in.
+ * @return 0 on success, -1 if the socket, bitmap, bind, or listen/connect
+ *         step fails.
+ * @warning The DTLS link must already be up; the active side's connect only
+ *          starts the SCTP handshake, whose packets travel via the
+ *          conn_output callback into cwist_webrtc_conn_sctp_out().
+ */
 int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn) {
     conn->sctp_sock = usrsctp_socket(AF_CONN, SOCK_STREAM, IPPROTO_SCTP,
                                      conn->is_server_role ? NULL : &on_incoming, NULL, 0,
@@ -219,6 +344,15 @@ int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn) {
     return 0;
 }
 
+/**
+ * @brief Tear down all per-connection SCTP state.
+ *
+ * Closes the accepted socket (if any) and the main socket, then deregisters
+ * the AF_CONN handle and frees the channel state bitmap. Safe to call on a
+ * connection that was never opened or already closed.
+ *
+ * @param conn  Connection whose SCTP state is destroyed.
+ */
 void cwist_sctp_conn_close(struct cwist_webrtc_conn *conn) {
     if (conn->sctp_acc) {
         usrsctp_close(conn->sctp_acc);
@@ -235,12 +369,35 @@ void cwist_sctp_conn_close(struct cwist_webrtc_conn *conn) {
     }
 }
 
+/**
+ * @brief Feed a DTLS-decrypted SCTP packet into usrsctp.
+ *
+ * Injects the packet into the connection's association; any immediate
+ * replies are routed back through sctp_global_output(). A no-op if the
+ * socket is not open yet.
+ *
+ * @param conn  Owning connection (registered AF_CONN handle).
+ * @param data  SCTP packet bytes as received over DTLS.
+ * @param len   Length of @p data.
+ */
 void cwist_sctp_conn_input(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len) {
     if (conn->sctp_sock)
         usrsctp_conninput(conn, data, len, 0);
 }
 
-/* Drain inbound data; accept the association on the passive side. */
+/**
+ * @brief Accept the association (answering side) and drain inbound messages.
+ *
+ * On the passive side, accepts the pending association once and enables
+ * non-blocking mode plus SCTP_RECVRCVINFO on the accepted socket. Then reads
+ * all queued messages from the effective data socket into a static 1 MiB
+ * buffer, skipping notifications and datagrams without complete rcvinfo, and
+ * dispatches each message. Runs from the event loop; never blocks.
+ *
+ * @param conn  Connection to service.
+ * @note The receive buffer is a shared static, so this must not be called
+ *       concurrently from multiple threads.
+ */
 void cwist_sctp_conn_drain(struct cwist_webrtc_conn *conn) {
     if (!conn->sctp_sock)
         return;
@@ -277,6 +434,16 @@ void cwist_sctp_conn_drain(struct cwist_webrtc_conn *conn) {
     }
 }
 
+/**
+ * @brief Check whether the SCTP association is fully established.
+ *
+ * On the answering side the association is up once the accepted socket
+ * exists; on the offering side the SCTP_STATUS socket option must report
+ * SCTP_ESTABLISHED.
+ *
+ * @param conn  Connection to query.
+ * @return true if DataChannel traffic can flow, false otherwise.
+ */
 bool cwist_sctp_assoc_established(struct cwist_webrtc_conn *conn) {
     if (!conn->sctp_sock)
         return false;
@@ -290,6 +457,23 @@ bool cwist_sctp_assoc_established(struct cwist_webrtc_conn *conn) {
     return status.sstat_state == SCTP_ESTABLISHED;
 }
 
+/**
+ * @brief Send a DataChannel message, opening the channel first if needed.
+ *
+ * Fails unless the association's data socket exists and conn->sctp_ready is
+ * set. If neither channel flag is set (channel never announced in either
+ * direction), a DCEP OPEN with an auto-generated "dc" label is sent first
+ * and the open_sent bit is recorded so the OPEN is sent only once. The
+ * payload goes out with PPID_STRING or PPID_BINARY on the channel's stream.
+ *
+ * @param conn      Owning connection.
+ * @param channel   DataChannel id == SCTP stream id.
+ * @param data      Message payload; may be NULL when @p len is 0.
+ * @param len       Payload length in bytes.
+ * @param is_string Non-zero to send as a UTF-8 string message.
+ * @return 0 if the message was handed to usrsctp, -1 if the link is not
+ *         ready or sending failed.
+ */
 int cwist_sctp_send(struct cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
                     size_t len, int is_string) {
     if (!cwist_sctp_data_sock(conn) || !conn->sctp_ready)
diff --git a/src/net/webrtc/sdp.c b/src/net/webrtc/sdp.c
index 38813e644..017b04d8d 100644
--- a/src/net/webrtc/sdp.c
+++ b/src/net/webrtc/sdp.c
@@ -1,11 +1,24 @@
 /** @file sdp.c
  * @brief Minimal SDP (RFC 4566) parse/generate for DataChannel offers/answers.
+ *
+ * Supports only the subset of SDP needed for a single SCTP DataChannel
+ * m-section: ICE credentials, DTLS fingerprint/setup, mid, and ice-lite.
+ * Parsing extracts fields into a cwist_sdp_info struct; generation produces
+ * offer or answer strings with CRLF line endings.
  */
 #include "webrtc_internal.h"
 
 #include 
 #include 
 
+/** @brief Copy the value part of an attribute line after a fixed prefix.
+ * @param line   NUL-terminated SDP line beginning with @p prefix.
+ * @param prefix Attribute prefix (e.g. "a=ice-ufrag:") to strip.
+ * @param dst    Destination buffer, always NUL-terminated on success.
+ * @param cap    Capacity of @p dst in bytes.
+ * @note Does nothing if @p line does not start with @p prefix.
+ *       Silently truncates values longer than cap - 1.
+ */
 static void copy_attr_value(const char *line, const char *prefix, char *dst, size_t cap) {
     size_t plen = strlen(prefix);
     if (strncmp(line, prefix, plen) != 0)
@@ -18,6 +31,15 @@ static void copy_attr_value(const char *line, const char *prefix, char *dst, siz
     dst[n] = '\0';
 }
 
+/** @brief Parse an SDP blob into a cwist_sdp_info structure.
+ * @param sdp   SDP text (not required to be NUL-terminated).
+ * @param len   Length of @p sdp in bytes.
+ * @param info  Output structure; zeroed first, then filled from recognized lines.
+ * @return 0 on success, -1 if ice-ufrag or ice-pwd is missing or empty.
+ * @note Lines longer than 511 bytes are truncated. If an attribute appears
+ *       more than once, the last occurrence wins. Sets info->has_lite when
+ *       "a=ice-lite" appears.
+ */
 int cwist_sdp_parse(const char *sdp, size_t len, cwist_sdp_info *info) {
     memset(info, 0, sizeof(*info));
     char line[512];
@@ -51,6 +73,19 @@ int cwist_sdp_parse(const char *sdp, size_t len, cwist_sdp_info *info) {
     return 0;
 }
 
+/** @brief Emit the shared session/m-section portion of an SDP offer.
+ * @param out         Output buffer, receives CRLF-terminated SDP text.
+ * @param cap         Capacity of @p out in bytes.
+ * @param fingerprint DTLS certificate fingerprint (hex, colon-separated).
+ * @param ufrag       ICE username fragment.
+ * @param pwd         ICE password.
+ * @param mid         Media section ID for the BUNDLE group and m-line.
+ * @param setup       DTLS setup attribute value ("active"/"passive").
+ * @param with_lite   Nonzero to include an "a=ice-lite" attribute line.
+ * @param sess_id     o= line session ID.
+ * @return 0 on success, -1 if the output was truncated or encoding failed.
+ * @warning Not NUL-guaranteed on truncation; failure must be checked before use.
+ */
 static int write_session(char *out, size_t cap, const char *fingerprint, const char *ufrag,
                          const char *pwd, const char *mid, const char *setup, int with_lite,
                          long long sess_id) {
@@ -77,6 +112,20 @@ static int write_session(char *out, size_t cap, const char *fingerprint, const c
     return 0;
 }
 
+/** @brief Generate an SDP answer for an ICE-lite DataChannel endpoint.
+ * @param out         Output buffer, receives CRLF-terminated SDP answer.
+ * @param cap         Capacity of @p out in bytes.
+ * @param fingerprint DTLS certificate fingerprint (hex, colon-separated).
+ * @param ufrag       ICE username fragment.
+ * @param pwd         ICE password.
+ * @param mid         Media section ID (also used in the BUNDLE group).
+ * @param host        Local host address for the a=candidate line.
+ * @param port        Local UDP port for the a=candidate line.
+ * @return 0 on success, -1 if the output was truncated or encoding failed.
+ * @note Declares ice-lite and hardcodes a=setup:passive, since the
+ *       ICE-lite answering endpoint takes the passive DTLS role, and emits
+ *       a single host candidate followed by end-of-candidates.
+ */
 int cwist_sdp_write_answer(char *out, size_t cap, const char *fingerprint, const char *ufrag,
                            const char *pwd, const char *mid, const char *host, uint16_t port) {
     /* ICE-lite answering endpoint takes the passive DTLS role. */
@@ -104,6 +153,18 @@ int cwist_sdp_write_answer(char *out, size_t cap, const char *fingerprint, const
     return 0;
 }
 
+/** @brief Generate an SDP offer for a full (non-lite) DataChannel endpoint.
+ * @param out         Output buffer, receives CRLF-terminated SDP offer.
+ * @param cap         Capacity of @p out in bytes.
+ * @param fingerprint DTLS certificate fingerprint (hex, colon-separated).
+ * @param ufrag       ICE username fragment.
+ * @param pwd         ICE password.
+ * @param mid         Media section ID (also used in the BUNDLE group).
+ * @return 0 on success, -1 if the output was truncated or encoding failed.
+ * @note Thin wrapper over write_session() with setup "active", no ice-lite,
+ *       and a fixed session ID; the full agent offering endpoint takes the
+ *       active DTLS role.
+ */
 int cwist_sdp_write_offer(char *out, size_t cap, const char *fingerprint, const char *ufrag,
                           const char *pwd, const char *mid) {
     /* Full agent offering endpoint takes the active DTLS role. */
diff --git a/src/net/webrtc/webrtc.c b/src/net/webrtc/webrtc.c
index 551d8642d..9bc1a22b5 100644
--- a/src/net/webrtc/webrtc.c
+++ b/src/net/webrtc/webrtc.c
@@ -21,10 +21,32 @@
 #include 
 #include 
 
+/** @def STUN_RETRANS_MS
+ * @brief STUN binding request retransmission interval in milliseconds. */
 #define STUN_RETRANS_MS 300
+/** @def STUN_MAX_ATTEMPTS
+ * @brief Maximum STUN binding request transmissions before giving up. */
 #define STUN_MAX_ATTEMPTS 7
+/** @def SCTP_TIMER_MS
+ * @brief Event-loop poll timeout / SCTP timer tick in milliseconds. */
 #define SCTP_TIMER_MS 10
 
+/** @struct pending_send
+ * @brief Outbound message queued while the SCTP association is not yet ready.
+ *
+ * Singly linked queue node holding a copy of the payload so the caller of
+ * cwist_webrtc_conn_send() can reuse or free its buffer immediately.
+ */
+/** @var pending_send::next
+ * @brief Next node in the queue, or NULL if this is the tail. */
+/** @var pending_send::channel
+ * @brief SCTP stream id (DataChannel id) to send on. */
+/** @var pending_send::is_string
+ * @brief Non-zero if the payload is a DataChannel string message, zero for binary. */
+/** @var pending_send::len
+ * @brief Payload length in bytes. */
+/** @var pending_send::data
+ * @brief Inline payload bytes; allocated with room for @c len bytes after the header. */
 typedef struct pending_send {
     struct pending_send *next;
     uint16_t channel;
@@ -35,6 +57,16 @@ typedef struct pending_send {
 
 /* ---- cross-TU helper implementations used by sctp.c / ice.c ---- */
 
+/** @fn cwist_webrtc_conn_on_message
+ * @brief Dispatch an inbound DataChannel message to the ctx message callback.
+ * @param conn  Connection the message arrived on.
+ * @param channel  SCTP stream id carrying the message.
+ * @param data  Message payload (not NUL-terminated).
+ * @param len  Payload length in bytes.
+ * @param is_string  Whether the payload is a string message (currently unused).
+ *
+ * Runs on the ctx event-loop thread.
+ */
 void cwist_webrtc_conn_on_message(cwist_webrtc_conn *conn, uint16_t channel,
                                   const uint8_t *data, size_t len, int is_string) {
     cwist_webrtc_ctx *ctx = conn->ctx;
@@ -43,6 +75,14 @@ void cwist_webrtc_conn_on_message(cwist_webrtc_conn *conn, uint16_t channel,
     (void)is_string;
 }
 
+/** @fn cwist_webrtc_conn_on_channel_open
+ * @brief Notify the application that a DataChannel was opened.
+ * @param conn  Connection owning the channel.
+ * @param channel  Id of the opened SCTP stream / DataChannel.
+ * @param label  DataChannel label string.
+ *
+ * Runs on the ctx event-loop thread.
+ */
 void cwist_webrtc_conn_on_channel_open(cwist_webrtc_conn *conn, uint16_t channel,
                                        const char *label) {
     cwist_webrtc_ctx *ctx = conn->ctx;
@@ -50,17 +90,42 @@ void cwist_webrtc_conn_on_channel_open(cwist_webrtc_conn *conn, uint16_t channel
         ctx->ch_cb(conn, channel, label, ctx->ch_user);
 }
 
+/** @fn cwist_webrtc_conn_sctp_out
+ * @brief Feed SCTP output bytes into DTLS.
+ * @param conn  Connection whose SCTP stack produced output.
+ * @param buffer  SCTP packet bytes.
+ * @param len  Number of bytes in @p buffer.
+ *
+ * Writes the record into the SSL object's write BIO; the sender is
+ * responsible for flushing the wbio afterwards. No-op until the SSL object
+ * exists.
+ */
 void cwist_webrtc_conn_sctp_out(struct cwist_webrtc_conn *conn, const void *buffer, size_t len) {
     if (!conn->ssl)
         return;
     SSL_write(conn->ssl, buffer, (int)len);
 }
 
+/** @fn cwist_webrtc_conn_send_raw
+ * @brief Send a raw UDP datagram to the connection's remote address.
+ * @param conn  Destination connection.
+ * @param data  Datagram bytes.
+ * @param len  Datagram length.
+ * @return  Bytes sent, or -1 on error (see @c errno), as from sendto(2).
+ */
 int cwist_webrtc_conn_send_raw(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len) {
     return sendto(conn->ctx->udp_fd, data, len, 0, (struct sockaddr *)&conn->remote,
                   conn->remote_len);
 }
 
+/** @fn cwist_webrtc_ctx_find_conn
+ * @brief Look up a connection by its nominated remote IPv4 address.
+ * @param ctx  Context to search.
+ * @param remote  Remote address (address and port must match exactly).
+ * @return  Matching connection, or NULL if none.
+ * @note  Iterates without holding ctx->lock; safe only from the event-loop
+ *        thread or other lock-free call sites in this file.
+ */
 struct cwist_webrtc_conn *cwist_webrtc_ctx_find_conn(cwist_webrtc_ctx *ctx,
                                                      const struct sockaddr_in *remote) {
     for (struct cwist_webrtc_conn *c = ctx->conns; c; c = c->next) {
@@ -71,6 +136,13 @@ struct cwist_webrtc_conn *cwist_webrtc_ctx_find_conn(cwist_webrtc_ctx *ctx,
     return NULL;
 }
 
+/** @fn cwist_webrtc_ctx_add_conn
+ * @brief Insert a connection at the head of the ctx connection list.
+ * @param ctx  Context owning the list.
+ * @param conn  Connection to insert; takes ownership.
+ *
+ * Takes ctx->lock around the list mutation.
+ */
 void cwist_webrtc_ctx_add_conn(cwist_webrtc_ctx *ctx, struct cwist_webrtc_conn *conn) {
     pthread_mutex_lock(&ctx->lock);
     conn->next = ctx->conns;
@@ -78,13 +150,27 @@ void cwist_webrtc_ctx_add_conn(cwist_webrtc_ctx *ctx, struct cwist_webrtc_conn *
     pthread_mutex_unlock(&ctx->lock);
 }
 
+/** @fn cwist_webrtc_conn_wake
+ * @brief Write one byte to the ctx wake pipe to interrupt poll().
+ * @param ctx  Context whose event loop should wake.
+ *
+ * Best-effort and async-signal-style safe: the pipe is O_NONBLOCK, so a
+ * full pipe is silently ignored.
+ */
 void cwist_webrtc_conn_wake(cwist_webrtc_ctx *ctx) {
     uint8_t b = 1;
     ssize_t rc = write(ctx->wake_pipe[1], &b, 1);
     (void)rc;
 }
 
-/* Best-effort local IPv4 for host candidates: first non-loopback address. */
+/** @fn cwist_webrtc_detect_host_ip
+ * @brief Pick a best-effort local IPv4 address for host candidates.
+ * @param out  Output buffer for a dotted-quad string.
+ * @param cap  Capacity of @p out.
+ *
+ * Writes "127.0.0.1" first, then replaces it with the first non-loopback
+ * IPv4 interface address found, if any.
+ */
 static void cwist_webrtc_detect_host_ip(char *out, size_t cap) {
     snprintf(out, cap, "127.0.0.1");
     struct ifaddrs *ifas = NULL;
@@ -106,10 +192,19 @@ static void cwist_webrtc_detect_host_ip(char *out, size_t cap) {
 
 /* ---- time helpers ---- */
 
+/** @fn now_ts
+ * @brief Fill @p ts with the current monotonic clock.
+ * @param ts  Output timespec.
+ */
 static void now_ts(struct timespec *ts) {
     clock_gettime(CLOCK_MONOTONIC, ts);
 }
 
+/** @fn ms_until
+ * @brief Milliseconds from now until @p ts.
+ * @param ts  Target timestamp (monotonic clock).
+ * @return  Signed millisecond delta; negative if @p ts is in the past.
+ */
 static long ms_until(const struct timespec *ts) {
     struct timespec now;
     now_ts(&now);
@@ -117,6 +212,11 @@ static long ms_until(const struct timespec *ts) {
     return ms;
 }
 
+/** @fn in_ms
+ * @brief Compute the absolute monotonic timestamp @p ms milliseconds from now.
+ * @param ms  Offset in milliseconds (may exceed one second).
+ * @param ts  Output timespec.
+ */
 static void in_ms(long ms, struct timespec *ts) {
     now_ts(ts);
     ts->tv_sec += ms / 1000;
@@ -129,6 +229,13 @@ static void in_ms(long ms, struct timespec *ts) {
 
 /* ---- conn lifecycle ---- */
 
+/** @fn conn_free
+ * @brief Tear down a connection and release all of its resources.
+ * @param conn  Connection to destroy; must already be unlinked from ctx->conns.
+ *
+ * Closes the SCTP association, frees the SSL object, drains the pending-send
+ * queue, and frees the connection itself.
+ */
 static void conn_free(struct cwist_webrtc_conn *conn) {
     cwist_sctp_conn_close(conn);
     if (conn->ssl)
@@ -141,6 +248,13 @@ static void conn_free(struct cwist_webrtc_conn *conn) {
     cwist_free(conn);
 }
 
+/** @fn conn_new
+ * @brief Allocate and minimally initialize a new connection.
+ * @param ctx  Owning context.
+ * @param remote  Remote address; copied into the connection.
+ * @param is_server_role  Non-zero for ICE-lite server role (passive DTLS), zero for client role.
+ * @return  New connection in state CWIST_CONN_STUN, or NULL on allocation failure.
+ */
 static struct cwist_webrtc_conn *conn_new(cwist_webrtc_ctx *ctx,
                                           const struct sockaddr_in *remote, int is_server_role) {
     struct cwist_webrtc_conn *conn = cwist_malloc(sizeof(*conn));
@@ -154,10 +268,22 @@ static struct cwist_webrtc_conn *conn_new(cwist_webrtc_ctx *ctx,
     return conn;
 }
 
+/** @fn conn_arm_stun_timer
+ * @brief Set the STUN retransmission deadline to now + STUN_RETRANS_MS.
+ * @param conn  Connection whose stun_next field is updated.
+ */
 static void conn_arm_stun_timer(struct cwist_webrtc_conn *conn) {
     in_ms(STUN_RETRANS_MS, &conn->stun_next);
 }
 
+/** @fn conn_send_stun_request
+ * @brief Build, sign, and transmit a STUN binding request to the peer.
+ * @param conn  Client-role connection in state CWIST_CONN_STUN.
+ *
+ * Uses USERNAME peer_ufrag:ice_ufrag and short-term credentials with
+ * peer_pwd. Re-arms the retransmission timer and bumps stun_attempts; on
+ * STUN_MAX_ATTEMPTS the timer loop marks the connection dead.
+ */
 static void conn_send_stun_request(struct cwist_webrtc_conn *conn) {
     uint8_t msg[512];
     char username[128];
@@ -173,6 +299,15 @@ static void conn_send_stun_request(struct cwist_webrtc_conn *conn) {
     conn->stun_attempts++;
 }
 
+/** @fn conn_start_dtls
+ * @brief Create the SSL object for a connection and kick off the DTLS handshake.
+ * @param conn  Connection about to enter state CWIST_CONN_DTLS.
+ *
+ * Selects the server or client SSL_CTX by role, attaches memory BIOs, sets
+ * MTU 1200, and calls SSL_do_handshake() once so the first flight (ServerHello
+ * or ClientHello) is queued in the wbio for the caller to flush.
+ * On SSL_new failure the connection is marked CWIST_CONN_DEAD.
+ */
 static void conn_start_dtls(struct cwist_webrtc_conn *conn) {
     cwist_webrtc_ctx *ctx = conn->ctx;
     SSL_CTX *ssl_ctx = conn->is_server_role ? ctx->server_ssl_ctx : ctx->client_ssl_ctx;
@@ -195,6 +330,13 @@ static void conn_start_dtls(struct cwist_webrtc_conn *conn) {
     SSL_do_handshake(conn->ssl);
 }
 
+/** @fn conn_flush_dtls_out
+ * @brief Drain the SSL write BIO and send every pending DTLS datagram.
+ * @param conn  Connection whose wbio holds outbound records.
+ *
+ * Sends via cwist_webrtc_conn_send_raw(); stops when the BIO is empty or a
+ * read returns a short buffer.
+ */
 static void conn_flush_dtls_out(struct cwist_webrtc_conn *conn) {
     uint8_t buf[2048];
     for (;;) {
@@ -207,11 +349,26 @@ static void conn_flush_dtls_out(struct cwist_webrtc_conn *conn) {
     }
 }
 
+/** @fn conn_mark_dead
+ * @brief Mark a connection as dead so the timer loop reaps it.
+ * @param conn  Connection to mark; actual freeing happens later, off the list.
+ */
 static void conn_mark_dead(struct cwist_webrtc_conn *conn) {
     conn->state = CWIST_CONN_DEAD;
 }
 
-/* Drive handshake/data for a conn whose rbio has input. */
+/** @fn conn_service
+ * @brief Drive the DTLS handshake and data path for a connection with input pending.
+ * @param conn  Connection whose rbio has received data or that needs servicing.
+ *
+ * In CWIST_CONN_DTLS: advances the handshake; on success opens the SCTP
+ * association and moves to CWIST_CONN_ESTABLISHED, on SSL_ERROR_SSL marks
+ * the connection dead.
+ * In CWIST_CONN_ESTABLISHED: SSL_read() loop feeds decrypted SCTP packets to
+ * cwist_sctp_conn_input(), detects peer close (ZERO_RETURN/SYSCALL), tracks
+ * sctp_ready via cwist_sctp_assoc_established(), drains outbound SCTP, and
+ * flushes the pending-send queue. Finally flushes any DTLS output.
+ */
 static void conn_service(struct cwist_webrtc_conn *conn) {
     if (conn->state == CWIST_CONN_DTLS) {
         int rc = SSL_do_handshake(conn->ssl);
@@ -264,6 +421,20 @@ static void conn_service(struct cwist_webrtc_conn *conn) {
 
 /* ---- packet handling ---- */
 
+/** @fn handle_stun
+ * @brief Process a STUN binding request or response.
+ * @param ctx  Receiving context.
+ * @param buf  Raw datagram.
+ * @param len  Datagram length.
+ * @param remote  Source address of the datagram.
+ *
+ * Requests are answered only on ICE-lite server contexts: a valid request
+ * gets a binding response, and one with USE-CANDIDATE adopts a parked
+ * offer connection (matched by ufrag) or creates a fresh server-role
+ * connection, then starts DTLS. Responses are accepted only by client-role
+ * connections in state CWIST_CONN_STUN whose transaction id and credentials
+ * match; a valid response starts DTLS.
+ */
 static void handle_stun(cwist_webrtc_ctx *ctx, const uint8_t *buf, size_t len,
                         const struct sockaddr_in *remote) {
     if (cwist_ice_stun_is_binding_request(buf, len)) {
@@ -323,6 +494,16 @@ static void handle_stun(cwist_webrtc_ctx *ctx, const uint8_t *buf, size_t len,
     }
 }
 
+/** @fn handle_packet
+ * @brief Demux an inbound UDP datagram and dispatch it.
+ * @param ctx  Receiving context.
+ * @param buf  Raw datagram.
+ * @param len  Datagram length.
+ * @param remote  Source address of the datagram.
+ *
+ * STUN messages go to handle_stun(); anything else is treated as a DTLS
+ * record for the matching connection and is written into the SSL rbio.
+ */
 static void handle_packet(cwist_webrtc_ctx *ctx, const uint8_t *buf, size_t len,
                           const struct sockaddr_in *remote) {
     if (cwist_ice_stun_is_message(buf, len)) {
@@ -341,6 +522,16 @@ static void handle_packet(cwist_webrtc_ctx *ctx, const uint8_t *buf, size_t len,
 
 /* ---- event loop ---- */
 
+/** @fn loop_run_timers
+ * @brief Run per-iteration timer and reaping work for the event loop.
+ * @param ctx  Context being serviced.
+ *
+ * Under ctx->lock, unlinks dead or closed connections from the list, then
+ * frees them outside the lock. For live connections: retransmits STUN
+ * binding requests on client-role connections (giving up after
+ * STUN_MAX_ATTEMPTS), handles expired DTLS retransmission timeouts, and
+ * services established connections.
+ */
 static void loop_run_timers(cwist_webrtc_ctx *ctx) {
     struct timespec now;
     now_ts(&now);
@@ -389,6 +580,15 @@ static void loop_run_timers(cwist_webrtc_ctx *ctx) {
     }
 }
 
+/** @fn ctx_thread_main
+ * @brief Event-loop thread entry point for a context.
+ * @param arg  cwist_webrtc_ctx pointer.
+ * @return  Always NULL.
+ *
+ * Loops until ctx->stop: polls the UDP socket and wake pipe (SCTP_TIMER_MS
+ * timeout), drains wake bytes, handles all pending inbound datagrams, feeds
+ * elapsed time to usrsctp_handle_timers(), and runs loop_run_timers().
+ */
 static void *ctx_thread_main(void *arg) {
     cwist_webrtc_ctx *ctx = arg;
     uint8_t buf[65536];
@@ -442,6 +642,16 @@ static void *ctx_thread_main(void *arg) {
 
 /* ---- public API ---- */
 
+/** @fn cwist_webrtc_ctx_new
+ * @brief Create a WebRTC server context on a UDP port.
+ * @param port  UDP port to bind; port 0 requests an ephemeral port.
+ * @return  New context, or NULL on any initialization failure.
+ *
+ * Initializes usrsctp, binds a non-blocking UDP socket, creates the wake
+ * pipe, generates the DTLS certificate and both SSL_CTXs (server and
+ * client), rolls ICE credentials, detects the host IP, and starts the
+ * background event-loop thread. ctx->port holds the bound port.
+ */
 cwist_webrtc_ctx *cwist_webrtc_ctx_new(uint16_t port) {
     if (cwist_sctp_global_init() < 0)
         return NULL;
@@ -501,26 +711,65 @@ cwist_webrtc_ctx *cwist_webrtc_ctx_new(uint16_t port) {
     return NULL;
 }
 
+/** @fn cwist_webrtc_ctx_port
+ * @brief Get the UDP port a context is bound to.
+ * @param ctx  Context created by cwist_webrtc_ctx_new().
+ * @return  Bound port number.
+ */
 uint16_t cwist_webrtc_ctx_port(const cwist_webrtc_ctx *ctx) {
     return ctx->port;
 }
 
+/** @fn cwist_webrtc_ctx_fingerprint
+ * @brief Get the DTLS certificate fingerprint for SDP signaling.
+ * @param ctx  Context created by cwist_webrtc_ctx_new().
+ * @return  Colon-separated SHA-256 fingerprint string; valid for the ctx lifetime.
+ */
 const char *cwist_webrtc_ctx_fingerprint(const cwist_webrtc_ctx *ctx) {
     return ctx->fingerprint;
 }
 
+/** @fn cwist_webrtc_ctx_set_message_handler
+ * @brief Register the DataChannel message callback.
+ * @param ctx  Context to configure.
+ * @param cb  Callback invoked per inbound message, or NULL to disable.
+ * @param user  Opaque pointer passed back to @p cb.
+ *
+ * The callback runs on the ctx event-loop thread.
+ */
 void cwist_webrtc_ctx_set_message_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_message_cb cb,
                                           void *user) {
     ctx->msg_cb = cb;
     ctx->msg_user = user;
 }
 
+/** @fn cwist_webrtc_ctx_set_channel_handler
+ * @brief Register the DataChannel-opened callback.
+ * @param ctx  Context to configure.
+ * @param cb  Callback invoked when a DataChannel opens, or NULL to disable.
+ * @param user  Opaque pointer passed back to @p cb.
+ *
+ * The callback runs on the ctx event-loop thread.
+ */
 void cwist_webrtc_ctx_set_channel_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_channel_cb cb,
                                           void *user) {
     ctx->ch_cb = cb;
     ctx->ch_user = user;
 }
 
+/** @fn cwist_webrtc_handle_offer
+ * @brief Parse an SDP offer and build the corresponding ICE-lite answer.
+ * @param ctx  Context that will own the new connection.
+ * @param offer  Remote SDP offer text.
+ * @param answer_out  Buffer receiving the generated answer SDP.
+ * @param out_len  Capacity of @p answer_out.
+ * @return  0 on success, -1 on SDP parse failure or allocation failure.
+ *
+ * Parks a server-role connection with a wildcard remote address and the
+ * offer's ICE credentials; handle_stun() later adopts it when the peer's
+ * STUN request nominates a candidate. Also flips the ctx into ICE-lite
+ * server mode.
+ */
 int cwist_webrtc_handle_offer(cwist_webrtc_ctx *ctx, const char *offer, char *answer_out,
                               size_t out_len) {
     cwist_sdp_info info;
@@ -544,6 +793,11 @@ int cwist_webrtc_handle_offer(cwist_webrtc_ctx *ctx, const char *offer, char *an
                                   ctx->ice_pwd, mid, ctx->host_ip, ctx->port);
 }
 
+/** @fn cwist_webrtc_ctx_connection_count
+ * @brief Count connections whose SCTP association is ready.
+ * @param ctx  Context to inspect.
+ * @return  Number of connections with sctp_ready set.
+ */
 int cwist_webrtc_ctx_connection_count(const cwist_webrtc_ctx *ctx) {
     int n = 0;
     for (struct cwist_webrtc_conn *c = ctx->conns; c; c = c->next) {
@@ -553,6 +807,19 @@ int cwist_webrtc_ctx_connection_count(const cwist_webrtc_ctx *ctx) {
     return n;
 }
 
+/** @fn cwist_webrtc_conn_send
+ * @brief Queue a message for delivery on a DataChannel.
+ * @param conn  Target connection.
+ * @param channel_id  SCTP stream id / DataChannel id.
+ * @param data  Payload bytes.
+ * @param len  Payload length; must be at most 1 MiB.
+ * @param type  CWIST_WEBRTC_DATA_STRING or CWIST_WEBRTC_DATA_BINARY.
+ * @return  0 if queued, -1 if the message is too large or allocation failed.
+ *
+ * The payload is copied; if the SCTP association is not ready yet the
+ * message waits in the connection's pending-send queue and is flushed by
+ * conn_service(). Thread-safe; wakes the event loop.
+ */
 int cwist_webrtc_conn_send(cwist_webrtc_conn *conn, uint16_t channel_id, const uint8_t *data,
                            size_t len, cwist_webrtc_data_type type) {
     if (len > 1 << 20)
@@ -577,11 +844,26 @@ int cwist_webrtc_conn_send(cwist_webrtc_conn *conn, uint16_t channel_id, const u
     return 0;
 }
 
+/** @fn cwist_webrtc_conn_close
+ * @brief Request asynchronous teardown of a connection.
+ * @param conn  Connection to close.
+ *
+ * Only flags the connection; the event loop unlinks and frees it on its
+ * next timer pass. Wakes the event loop.
+ */
 void cwist_webrtc_conn_close(cwist_webrtc_conn *conn) {
     conn->closed = true;
     cwist_webrtc_conn_wake(conn->ctx);
 }
 
+/** @fn cwist_webrtc_ctx_free
+ * @brief Stop a context's event loop and release all of its resources.
+ * @param ctx  Context to destroy; NULL is accepted as a no-op.
+ *
+ * Signals the thread to stop and joins it, frees every connection, then
+ * closes the UDP socket and wake pipe and frees the SSL_CTXs, certificate,
+ * and private key.
+ */
 void cwist_webrtc_ctx_free(cwist_webrtc_ctx *ctx) {
     if (!ctx)
         return;
@@ -616,6 +898,17 @@ void cwist_webrtc_ctx_free(cwist_webrtc_ctx *ctx) {
 
 /* ---- internal client-role dialer (loopback tests) ---- */
 
+/** @fn cwist_webrtc_connect
+ * @brief Open a client-role connection to a remote ICE-lite endpoint.
+ * @param ctx  Context to dial from.
+ * @param remote  Remote UDP address.
+ * @param peer_ufrag  Remote ICE username fragment for STUN credentials.
+ * @param peer_pwd  Remote ICE password for STUN MESSAGE-INTEGRITY.
+ * @return  New connection in state CWIST_CONN_STUN, or NULL on failure.
+ *
+ * Intended for loopback tests. Starts STUN binding requests immediately and
+ * performs DTLS in client (active) role once a binding response arrives.
+ */
 cwist_webrtc_conn *cwist_webrtc_connect(cwist_webrtc_ctx *ctx, const struct sockaddr_in *remote,
                                         const char *peer_ufrag, const char *peer_pwd) {
     struct cwist_webrtc_conn *conn = conn_new(ctx, remote, 0);
diff --git a/src/net/webrtc/webrtc_internal.h b/src/net/webrtc/webrtc_internal.h
index 626ca5e71..46599074c 100644
--- a/src/net/webrtc/webrtc_internal.h
+++ b/src/net/webrtc/webrtc_internal.h
@@ -1,5 +1,9 @@
 /** @file webrtc_internal.h
  * @brief Shared internals between the webrtc module translation units.
+ *
+ * Declares the private connection/context types plus the internal API split
+ * across ice.c, sdp.c, dtls.c, sctp.c, and webrtc.c. Not part of the public
+ *  interface.
  */
 #ifndef __CWIST_WEBRTC_INTERNAL_H__
 #define __CWIST_WEBRTC_INTERNAL_H__
@@ -19,6 +23,9 @@
 struct socket; /* usrsctp */
 struct pending_send;
 
+/**
+ * @brief High-level ICE/DTLS/SCTP connection state of a single peer.
+ */
 typedef enum {
     CWIST_CONN_STUN = 0, /* waiting for ICE nomination (or response, client) */
     CWIST_CONN_DTLS = 1, /* DTLS handshake in flight */
@@ -26,6 +33,12 @@ typedef enum {
     CWIST_CONN_DEAD = 3
 } cwist_conn_state;
 
+/**
+ * @brief Internal state for one WebRTC peer connection (single remote address).
+ *
+ * Owned by a cwist_webrtc_ctx; linked into ctx->conns. Guarded by ctx->lock
+ * for list membership and callback-relevant fields.
+ */
 struct cwist_webrtc_conn {
     cwist_webrtc_ctx *ctx;
     int is_server_role; /* 1: passive DTLS role (answering), 0: active (offering) */
@@ -53,6 +66,13 @@ struct cwist_webrtc_conn {
     struct cwist_webrtc_conn *next;
 };
 
+/**
+ * @brief Shared context: UDP socket, certificate, callbacks, and connection list.
+ *
+ * One context owns one UDP socket and serves all peer connections on it.
+ * A background thread pumps ICE retransmits and DTLS/SCTP I/O until
+ * thread_running goes false.
+ */
 struct cwist_webrtc_ctx {
     int udp_fd;
     uint16_t port;
@@ -80,85 +100,247 @@ struct cwist_webrtc_ctx {
     pthread_mutex_t lock;
 };
 
-/* ice.c */
-#define CWIST_STUN_BINDING_REQUEST 0x0001
-#define CWIST_STUN_BINDING_RESPONSE 0x0101
+/** @{ @name STUN message types (ice.c) */
+#define CWIST_STUN_BINDING_REQUEST 0x0001  /**< STUN Binding request. */
+#define CWIST_STUN_BINDING_RESPONSE 0x0101 /**< STUN Binding success response. */
+/** @} */
 
+/**
+ * @brief Check whether a datagram looks like a STUN message.
+ * @param buf Datagram bytes.
+ * @param len Datagram length.
+ * @return 1 if @p len covers a STUN header and the magic cookie matches, else 0.
+ */
 int cwist_ice_stun_is_message(const uint8_t *buf, size_t len);
+/**
+ * @brief Check whether a datagram is a STUN Binding request.
+ * @return true if it is a STUN message with type CWIST_STUN_BINDING_REQUEST.
+ */
 bool cwist_ice_stun_is_binding_request(const uint8_t *buf, size_t len);
+/**
+ * @brief Check for the USE-CANDIDATE attribute in a Binding request.
+ * @return true if the request carries USE-CANDIDATE (nominated pair).
+ */
 bool cwist_ice_stun_has_use_candidate(const uint8_t *buf, size_t len);
-/* Extract the remote ufrag from the USERNAME attribute ("remoteufrag:localufrag"). */
+/**
+ * @brief Extract the remote ufrag from the USERNAME attribute ("remoteufrag:localufrag").
+ * @param out Output buffer.
+ * @param cap Capacity of @p out.
+ * @return 0 on success, -1 if the attribute is missing/malformed.
+ */
 int cwist_ice_stun_get_remote_ufrag(const uint8_t *buf, size_t len, char *out, size_t cap);
-/* Validate MESSAGE-INTEGRITY of a request against pwd. 1 = valid, 0 = invalid. */
+/**
+ * @brief Validate MESSAGE-INTEGRITY of a request against pwd.
+ * @retval 1 valid
+ * @retval 0 invalid
+ */
 int cwist_ice_stun_validate_request(const uint8_t *buf, size_t len, const char *pwd);
-/* Build a binding response (XOR-MAPPED-ADDRESS + MI + fingerprint). Returns length or -1. */
+/**
+ * @brief Build a binding response (XOR-MAPPED-ADDRESS + MI + fingerprint).
+ * @return Message length, or -1 on failure/overflow.
+ */
 int cwist_ice_stun_build_response(uint8_t *out, size_t cap, const uint8_t *req, size_t req_len,
                                   const struct sockaddr_in *mapped, const char *pwd);
-/* Build a controlling binding request with USE-CANDIDATE. Returns length or -1. */
+/**
+ * @brief Build a controlling binding request with USE-CANDIDATE.
+ * @param txid 12-byte transaction ID to embed (must match the stored one).
+ * @param username "localufrag:remoteufrag".
+ * @return Message length, or -1 on failure.
+ */
 int cwist_ice_stun_build_request(uint8_t *out, size_t cap, const uint8_t txid[12],
                                  const char *username);
-/* Sign a built request (append MI + FINGERPRINT). Returns new length or -1. */
+/**
+ * @brief Sign a built request (append MI + FINGERPRINT).
+ * @return New message length, or -1 on failure.
+ */
 int cwist_ice_stun_sign_request(uint8_t *msg, size_t cap, size_t msg_len, const char *pwd);
-/* Validate a response: cookie, txid match, MI with pwd, return mapped address. */
+/**
+ * @brief Validate a response: cookie, txid match, MI with pwd, return mapped address.
+ * @param mapped Filled with the XOR-MAPPED-ADDRESS on success.
+ * @return 1 on success, 0 on validation failure.
+ */
 int cwist_ice_stun_parse_response(const uint8_t *buf, size_t len, const uint8_t txid[12],
                                   const char *pwd, struct sockaddr_in *mapped);
+/**
+ * @brief Generate random ICE ufrag/pwd credentials.
+ * @param ufrag Output buffer for the ufrag.
+ * @param ufrag_cap Capacity of @p ufrag.
+ * @param pwd Output buffer for the password.
+ * @param pwd_cap Capacity of @p pwd.
+ */
 void cwist_ice_random_creds(char *ufrag, size_t ufrag_cap, char *pwd, size_t pwd_cap);
 
-/* sdp.c */
+/** @{ @name SDP parsing and serialization (sdp.c) */
+
+/**
+ * @brief Parsed subset of a remote SDP description needed by the ICE-lite agent.
+ *
+ * Fields the parser does not find stay zeroed (memset at entry).
+ */
 typedef struct {
-    char ice_ufrag[64];
-    char ice_pwd[128];
-    char fingerprint[128];
-    char setup[16];
-    char mid[16];
-    int has_lite;
+    char ice_ufrag[64];     /**< Remote ICE ufrag. */
+    char ice_pwd[128];      /**< Remote ICE password. */
+    char fingerprint[128];  /**< Remote DTLS certificate fingerprint. */
+    char setup[16];         /**< a=setup value (e.g. "active", "passive"). */
+    char mid[16];           /**< a=mid value. */
+    int has_lite;           /**< Non-zero if a=ice-lite was present. */
 } cwist_sdp_info;
 
-/* Parse the fields the ICE-lite agent needs. Missing fields stay zeroed. Returns 0 on success. */
+/**
+ * @brief Parse the fields the ICE-lite agent needs from an SDP description.
+ * @param info Filled on return; missing fields stay zeroed.
+ * @return 0 on success, -1 if ice-ufrag or ice-pwd is missing or empty.
+ */
 int cwist_sdp_parse(const char *sdp, size_t len, cwist_sdp_info *info);
+/**
+ * @brief Serialize an SDP answer for the ICE-lite answering endpoint.
+ *
+ * The answer takes the passive DTLS role and advertises a host candidate for
+ * @p host:@p port.
+ * @return 0 on success, -1 if the output was truncated or encoding failed.
+ */
 int cwist_sdp_write_answer(char *out, size_t cap, const char *fingerprint, const char *ufrag,
                            const char *pwd, const char *mid, const char *host, uint16_t port);
+/**
+ * @brief Serialize an SDP offer for the full-agent offering endpoint.
+ *
+ * The offer takes the active DTLS role; candidates are signalled separately.
+ * @return 0 on success, -1 if the output was truncated or encoding failed.
+ */
 int cwist_sdp_write_offer(char *out, size_t cap, const char *fingerprint, const char *ufrag,
                           const char *pwd, const char *mid);
+/** @} */
 
-/* dtls.c */
+/** @{ @name DTLS certificate helpers (dtls.c) */
+
+/**
+ * @brief Generate a self-signed EC (P-256) certificate/key pair for DTLS.
+ * @param cert Receives the certificate (NULL on failure).
+ * @param pkey Receives the private key (NULL on failure).
+ * @return 0 on success, -1 on failure.
+ */
 int cwist_dtls_generate_cert(X509 **cert, EVP_PKEY **pkey);
+/**
+ * @brief Compute the SHA-256 fingerprint of a certificate in "AA:BB:..." form.
+ * @param out Output buffer.
+ * @param cap Capacity of @p out.
+ * @return 0 on success, -1 on failure.
+ */
 int cwist_dtls_fingerprint(X509 *cert, char *out, size_t cap);
+/**
+ * @brief Create a DTLS 1.2 SSL_CTX for one endpoint role.
+ * @param is_server Non-zero for the passive (server) context; certificate and
+ *                  key are only loaded in this mode.
+ * @param cert Certificate used by the server context.
+ * @param pkey Private key matching @p cert.
+ * @return New SSL_CTX, or NULL on failure. Caller frees with SSL_CTX_free.
+ * @note Peer fingerprint verification is not enforced; callers must match
+ *       the SDP fingerprint out of band.
+ */
 SSL_CTX *cwist_dtls_ctx_new(int is_server, X509 *cert, EVP_PKEY *pkey);
+/** @} */
+
+/** @{ @name SCTP / DataChannel layer (sctp.c) */
 
-/* sctp.c */
-/* Global usrsctp init (nothreads; caller pumps usrsctp_handle_timers). Idempotent. */
+/**
+ * @brief Global usrsctp init (nothreads; caller pumps usrsctp_handle_timers).
+ * @return 0 on success. Idempotent.
+ */
 int cwist_sctp_global_init(void);
-/* Create the per-conn SCTP socket. If active_role, also connect() to start the association. */
+/**
+ * @brief Create the per-conn SCTP socket.
+ * @param conn Connection whose sctp_sock/sctp_acc are set up.
+ * @return 0 on success, -1 on failure.
+ * @note If conn->is_server_role is false (active role), also connect() to
+ *       start the association.
+ */
 int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn);
+/**
+ * @brief Tear down the connection's SCTP socket(s) and free related state.
+ */
 void cwist_sctp_conn_close(struct cwist_webrtc_conn *conn);
-/* Feed a DTLS-decrypted SCTP packet into usrsctp. */
+/**
+ * @brief Feed a DTLS-decrypted SCTP packet into usrsctp.
+ */
 void cwist_sctp_conn_input(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len);
-/* Accept the association (passive side) and drain queued inbound messages. */
+/**
+ * @brief Accept the association (passive side) and drain queued inbound messages.
+ */
 void cwist_sctp_conn_drain(struct cwist_webrtc_conn *conn);
+/**
+ * @brief Send a DCEP control message on a channel.
+ * @param type DCEP message type (e.g. OPEN, ACK).
+ * @param label Channel label; only used for OPEN, truncated to 200 bytes.
+ * @return 0 on success, -1 on failure.
+ */
 int dcep_send(struct cwist_webrtc_conn *conn, uint16_t channel, uint8_t type,
               const char *label);
-/* Send a DataChannel message; sends DCEP OPEN first when the channel is new. */
+/**
+ * @brief Send a DataChannel message; sends DCEP OPEN first when the channel is new.
+ * @param is_string Non-zero for string messages, zero for binary.
+ * @return 0 on success, -1 on failure.
+ */
 int cwist_sctp_send(struct cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
                     size_t len, int is_string);
+/**
+ * @brief Check whether the SCTP association is established.
+ * @return true once the association handshake completed.
+ */
 bool cwist_sctp_assoc_established(struct cwist_webrtc_conn *conn);
+/** @} */
 
-/* webrtc.c helpers used across the module */
+/** @{ @name Cross-module helpers implemented in webrtc.c */
+
+/**
+ * @brief Send a DTLS-encrypted SCTP packet to the peer over the ctx UDP socket.
+ * @return Bytes sent, or -1 on error.
+ */
 int cwist_webrtc_conn_send_raw(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len);
+/**
+ * @brief usrsctp send callback entry point: encrypt via DTLS and transmit.
+ * @param buffer SCTP packet produced by usrsctp.
+ */
 void cwist_webrtc_conn_sctp_out(struct cwist_webrtc_conn *conn, const void *buffer, size_t len);
+/**
+ * @brief Dispatch a channel-open notification to the user ch_cb.
+ */
 void cwist_webrtc_conn_on_channel_open(struct cwist_webrtc_conn *conn, uint16_t channel,
                                        const char *label);
+/**
+ * @brief Dispatch an inbound DataChannel message to the user msg_cb.
+ */
 void cwist_webrtc_conn_on_message(struct cwist_webrtc_conn *conn, uint16_t channel,
                                   const uint8_t *data, size_t len, int is_string);
+/**
+ * @brief Find a connection by remote address in the ctx list.
+ * @return Matching connection, or NULL if none.
+ */
 struct cwist_webrtc_conn *cwist_webrtc_ctx_find_conn(cwist_webrtc_ctx *ctx,
                                                      const struct sockaddr_in *remote);
+/**
+ * @brief Link a connection into the ctx connection list.
+ * @note Takes ctx->lock internally.
+ */
 void cwist_webrtc_ctx_add_conn(cwist_webrtc_ctx *ctx, struct cwist_webrtc_conn *conn);
+/**
+ * @brief Kick off the DTLS handshake for a connection that nominated its pair.
+ * @note Performs SSL_do_handshake work on the current state.
+ */
 void cwist_webrtc_conn_start_dtls(struct cwist_webrtc_conn *conn);
+/**
+ * @brief Write a byte to the ctx wake pipe to interrupt the pump thread's poll.
+ */
 void cwist_webrtc_conn_wake(cwist_webrtc_ctx *ctx);
 
-/* Internal client-role dialer (used by loopback tests): starts ICE against a
- * remote ICE-lite endpoint described by peer_ufrag/peer_pwd from its answer. */
+/**
+ * @brief Internal client-role dialer (used by loopback tests).
+ *
+ * Starts ICE against a remote ICE-lite endpoint described by peer_ufrag/peer_pwd
+ * from its answer.
+ * @return New connection, or NULL on failure.
+ */
 cwist_webrtc_conn *cwist_webrtc_connect(cwist_webrtc_ctx *ctx, const struct sockaddr_in *remote,
                                         const char *peer_ufrag, const char *peer_pwd);
+/** @} */
 
 #endif

From 727c0ba3b9fbcff79251bbbfa10812bfda2409fe Mon Sep 17 00:00:00 2001
From: Lee Yunjin 
Date: Mon, 5 Oct 2026 19:34:02 +0900
Subject: [PATCH 6/7] feat(reactor): one-shot timers on the run thread

Add cwist_reactor_timer_{init,arm,cancel,armed}: caller-owned timers kept
in a min-heap per reactor and fired on the run thread.  Each backend's
poll wait (io_uring, epoll, kqueue) is shortened to the earliest
deadline, still capped at the existing 100 ms idle wait, so a reactor
with no armed timer wakes no more often than before.  Arm, cancel and the
callbacks run on the run thread only; foreign threads go through
cwist_reactor_post().

Until now anything that needed a deadline ran its own thread
(websocket_async's sweep watchdog) or polled on a fixed tick.  The WebRTC
event loop is the first user.

Also make the run flag atomic: cwist_reactor_stop() is called from
foreign threads and was writing a plain bool the run loop reads (found by
TSan in the new WebRTC tests).

WASI stubs cover the new functions.  tests/test_reactor_timer checks
deadline ordering under out-of-order arming, cancel, re-arm, self re-arm
from the callback, and never-early firing, on the default backend and on
forced epoll.
---
 Makefile                       |   9 ++
 include/cwist/sys/io/reactor.h |  22 +++++
 src/sys/io/reactor.c           | 157 +++++++++++++++++++++++++++++++--
 src/sys/wasi/compat.c          |  26 ++++++
 tests/test_reactor_timer.c     | 132 +++++++++++++++++++++++++++
 5 files changed, 337 insertions(+), 9 deletions(-)
 create mode 100644 tests/test_reactor_timer.c

diff --git a/Makefile b/Makefile
index 08a37dcc9..1880e4473 100644
--- a/Makefile
+++ b/Makefile
@@ -853,6 +853,7 @@ TEST_TARGETS = test_worker_affinity \
                test_app_resource_limits \
                test_classic_pool_scaling \
                test_reactor_wake \
+               test_reactor_timer \
                test_reactor_drain_chunk \
                test_latency_probe \
                test_sstring \
@@ -1005,6 +1006,14 @@ test_reactor_wake: tests/test_reactor_wake.c src/sys/io/reactor.c
 	$(CC) $(CFLAGS) -o $@ tests/test_reactor_wake.c -pthread
 	./$@
 
+# Reactor one-shot timers, on the default backend and on forced epoll.
+test_reactor_timer: tests/test_reactor_timer.c src/sys/io/reactor.c
+	$(CC) $(CFLAGS) -o $@ tests/test_reactor_timer.c -pthread
+	./$@
+ifeq ($(UNAME_S),Linux)
+	CWIST_REACTOR_BACKEND=epoll ./$@
+endif
+
 # Cooperative-queuing correctness test (issue #25): CWIST_REACTOR_DRAIN_CHUNK
 # interleaves foreign-thread post draining into a big CQE batch instead of
 # only at the batch's end. See tests/bench_cooperative_queuing.c for the
diff --git a/include/cwist/sys/io/reactor.h b/include/cwist/sys/io/reactor.h
index c30e71841..411e6fed2 100644
--- a/include/cwist/sys/io/reactor.h
+++ b/include/cwist/sys/io/reactor.h
@@ -51,6 +51,28 @@ typedef struct cwist_reactor_post {
 
 bool cwist_reactor_post(cwist_reactor_t *reactor, cwist_reactor_post_t *node);
 
+/* One-shot timers run on the reactor's run thread.  The timer is caller-owned
+ * (typically embedded in the object it belongs to); the reactor keeps a
+ * min-heap of armed timers and shortens its poll wait to the earliest
+ * deadline, so an idle reactor with no armed timers never wakes for them.
+ *
+ * Arm, cancel and the callback all run on the run thread (or before the
+ * reactor starts running).  Re-arming from inside the callback is allowed.
+ * Foreign threads reach a timer through cwist_reactor_post(). */
+typedef struct cwist_reactor_timer {
+    uint64_t deadline_ns; /* CLOCK_MONOTONIC */
+    uint32_t heap_slot;   /* 0 when not armed, else heap index + 1 */
+    void (*cb)(void *ctx);
+    void *ctx;
+} cwist_reactor_timer_t;
+
+void cwist_reactor_timer_init(cwist_reactor_timer_t *timer, void (*cb)(void *ctx), void *ctx);
+/* (Re-)arm @p timer to fire once after @p delay_us microseconds. */
+bool cwist_reactor_timer_arm(cwist_reactor_t *reactor, cwist_reactor_timer_t *timer,
+                             uint64_t delay_us);
+void cwist_reactor_timer_cancel(cwist_reactor_t *reactor, cwist_reactor_timer_t *timer);
+bool cwist_reactor_timer_armed(const cwist_reactor_timer_t *timer);
+
 #ifdef __cplusplus
 }
 #endif
diff --git a/src/sys/io/reactor.c b/src/sys/io/reactor.c
index 013f2b995..b6cc19055 100644
--- a/src/sys/io/reactor.c
+++ b/src/sys/io/reactor.c
@@ -37,6 +37,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #ifdef __linux__
 #include 
@@ -187,6 +188,10 @@ struct cwist_reactor {
     reactor_slot_chunk_t *chunks;
     reactor_event_ctx_t *free_head;  /* Free list threaded through slots. */
     pthread_mutex_t pool_lock;
+    /* Armed one-shot timers, min-heap on deadline_ns.  Run thread only. */
+    cwist_reactor_timer_t **timer_heap;
+    uint32_t timer_n;
+    uint32_t timer_cap;
 #ifdef __linux__
     /* Deferred SQE batching: submissions made by the reactor's own run thread
      * while it dispatches a CQE batch (overwhelmingly connection re-arms, one
@@ -464,6 +469,127 @@ static void reactor_drain_posts(cwist_reactor_t *r) {
     }
 }
 
+/* run flag: cleared by cwist_reactor_stop() from any thread. */
+static bool reactor_running(cwist_reactor_t *r) {
+    return __atomic_load_n(&r->running, __ATOMIC_ACQUIRE) && atomic_load(&g_cwist_running);
+}
+
+/* ---- one-shot timers (min-heap, run thread only) ---- */
+
+/* Upper bound on any poll wait, timers or not: the shutdown flags are only
+ * re-checked between waits (see cwist_reactor_run). */
+#define REACTOR_IDLE_WAIT_NS 100000000ull
+
+static uint64_t reactor_now_ns(void) {
+    struct timespec ts;
+    clock_gettime(CLOCK_MONOTONIC, &ts);
+    return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
+}
+
+static void timer_heap_place(cwist_reactor_t *r, uint32_t i, cwist_reactor_timer_t *t) {
+    r->timer_heap[i] = t;
+    t->heap_slot = i + 1;
+}
+
+static void timer_heap_up(cwist_reactor_t *r, uint32_t i) {
+    cwist_reactor_timer_t *t = r->timer_heap[i];
+    while (i > 0) {
+        uint32_t parent = (i - 1) / 2;
+        if (r->timer_heap[parent]->deadline_ns <= t->deadline_ns) break;
+        timer_heap_place(r, i, r->timer_heap[parent]);
+        i = parent;
+    }
+    timer_heap_place(r, i, t);
+}
+
+static void timer_heap_down(cwist_reactor_t *r, uint32_t i) {
+    cwist_reactor_timer_t *t = r->timer_heap[i];
+    for (;;) {
+        uint32_t child = 2 * i + 1;
+        if (child >= r->timer_n) break;
+        if (child + 1 < r->timer_n &&
+            r->timer_heap[child + 1]->deadline_ns < r->timer_heap[child]->deadline_ns)
+            child++;
+        if (t->deadline_ns <= r->timer_heap[child]->deadline_ns) break;
+        timer_heap_place(r, i, r->timer_heap[child]);
+        i = child;
+    }
+    timer_heap_place(r, i, t);
+}
+
+static void timer_heap_remove(cwist_reactor_t *r, cwist_reactor_timer_t *t) {
+    uint32_t i = t->heap_slot - 1;
+    t->heap_slot = 0;
+    cwist_reactor_timer_t *last = r->timer_heap[--r->timer_n];
+    if (i == r->timer_n) return;
+    timer_heap_place(r, i, last);
+    if (i > 0 && r->timer_heap[(i - 1) / 2]->deadline_ns > last->deadline_ns)
+        timer_heap_up(r, i);
+    else
+        timer_heap_down(r, i);
+}
+
+void cwist_reactor_timer_init(cwist_reactor_timer_t *timer, void (*cb)(void *ctx), void *ctx) {
+    if (!timer) return;
+    timer->deadline_ns = 0;
+    timer->heap_slot = 0;
+    timer->cb = cb;
+    timer->ctx = ctx;
+}
+
+bool cwist_reactor_timer_arm(cwist_reactor_t *r, cwist_reactor_timer_t *timer, uint64_t delay_us) {
+    if (!r || !timer || !timer->cb) return false;
+    if (timer->heap_slot) timer_heap_remove(r, timer);
+    if (r->timer_n == r->timer_cap) {
+        uint32_t cap = r->timer_cap ? r->timer_cap * 2 : 64;
+        cwist_reactor_timer_t **heap = cwist_alloc(cap * sizeof(*heap));
+        if (!heap) return false;
+        if (r->timer_n) memcpy(heap, r->timer_heap, r->timer_n * sizeof(*heap));
+        cwist_free(r->timer_heap);
+        r->timer_heap = heap;
+        r->timer_cap = cap;
+    }
+    timer->deadline_ns = reactor_now_ns() + delay_us * 1000ull;
+    r->timer_n++;
+    timer_heap_place(r, r->timer_n - 1, timer);
+    timer_heap_up(r, r->timer_n - 1);
+    return true;
+}
+
+void cwist_reactor_timer_cancel(cwist_reactor_t *r, cwist_reactor_timer_t *timer) {
+    if (!r || !timer || !timer->heap_slot) return;
+    timer_heap_remove(r, timer);
+}
+
+bool cwist_reactor_timer_armed(const cwist_reactor_timer_t *timer) {
+    return timer && timer->heap_slot != 0;
+}
+
+/* Fire every timer whose deadline has passed.  At most the timers armed on
+ * entry run, so a callback that re-arms itself with a zero delay waits for
+ * the next round instead of spinning here. */
+static void reactor_run_timers(cwist_reactor_t *r) {
+    if (r->timer_n == 0) return;
+    uint64_t now = reactor_now_ns();
+    uint32_t budget = r->timer_n;
+    while (budget-- > 0 && r->timer_n > 0 && r->timer_heap[0]->deadline_ns <= now) {
+        cwist_reactor_timer_t *t = r->timer_heap[0];
+        timer_heap_remove(r, t);
+        t->cb(t->ctx);
+    }
+}
+
+/* Poll wait for this round: the time to the earliest timer, capped at the
+ * idle wait. */
+static uint64_t reactor_wait_ns(const cwist_reactor_t *r) {
+    if (r->timer_n == 0) return REACTOR_IDLE_WAIT_NS;
+    uint64_t now = reactor_now_ns();
+    uint64_t deadline = r->timer_heap[0]->deadline_ns;
+    if (deadline <= now) return 0;
+    uint64_t wait = deadline - now;
+    return wait < REACTOR_IDLE_WAIT_NS ? wait : REACTOR_IDLE_WAIT_NS;
+}
+
 static void reactor_wake_cb(int fd, void *ctx) {
     cwist_reactor_t *r = *(cwist_reactor_t *const *)ctx;
     uint64_t buf[8];
@@ -484,7 +610,7 @@ cwist_reactor_t *cwist_reactor_create(void) {
     cwist_reactor_t *r = cwist_alloc(sizeof(cwist_reactor_t));
     if (!r) return NULL;
     memset(r, 0, sizeof(cwist_reactor_t));
-    r->running = false;
+    __atomic_store_n(&r->running, false, __ATOMIC_RELEASE);
     pthread_mutex_init(&r->pool_lock, NULL);
 #ifdef __linux__
     pthread_mutex_init(&r->impl.sq_lock, NULL);
@@ -625,6 +751,7 @@ void cwist_reactor_destroy(cwist_reactor_t *reactor) {
 #ifdef __linux__
     pthread_mutex_destroy(&reactor->impl.sq_lock);
 #endif
+    cwist_free(reactor->timer_heap);
     reactor_slot_chunk_t *chunk = reactor->chunks;
     while (chunk) {
         reactor_slot_chunk_t *next = chunk->next;
@@ -987,14 +1114,15 @@ static uint64_t reactor_round_budget_us(void) {
 
 void cwist_reactor_run(cwist_reactor_t *reactor) {
     if (!reactor) return;
-    reactor->running = true;
+    __atomic_store_n(&reactor->running, true, __ATOMIC_RELEASE);
 
 #ifdef __linux__
     if (!reactor->impl.use_epoll) {
         reactor->owner = pthread_self();
         const uint64_t cq_grace_ns = reactor_cq_grace_ns();
-        while (reactor->running && atomic_load(&g_cwist_running)) {
+        while (reactor_running(reactor)) {
             reactor_drain_posts(reactor);
+            reactor_run_timers(reactor);
             /* Submit the re-arms queued by the previous dispatch batch under
              * the SQ lock, then wait in a separate call.  The submit enter
              * MUST hold the lock: uring_submit/uring_submit_batch roll the
@@ -1045,7 +1173,11 @@ void cwist_reactor_run(cwist_reactor_t *reactor) {
                 }
             }
 
-            static const struct __kernel_timespec idle_ts = {.tv_sec = 0, .tv_nsec = 100000000};
+            /* Bounded by the idle wait, shortened to the next timer. */
+            const uint64_t wait_ns = reactor_wait_ns(reactor);
+            const struct __kernel_timespec idle_ts = {
+                .tv_sec = (long long)(wait_ns / 1000000000ull),
+                .tv_nsec = (long long)(wait_ns % 1000000000ull)};
             uint32_t to_submit = reactor->sq_unsubmitted;
             reactor->sq_unsubmitted = 0;
             if (to_submit > 0) {
@@ -1177,9 +1309,12 @@ void cwist_reactor_run(cwist_reactor_t *reactor) {
         }
     } else {
         struct epoll_event events[1024];
-        while (reactor->running && atomic_load(&g_cwist_running)) {
+        while (reactor_running(reactor)) {
             reactor_drain_posts(reactor);
-            int n = epoll_wait(reactor->impl.epoll_fd, events, 1024, 100);
+            reactor_run_timers(reactor);
+            /* Round the wait up so a timer is never polled for early. */
+            int wait_ms = (int)((reactor_wait_ns(reactor) + 999999ull) / 1000000ull);
+            int n = epoll_wait(reactor->impl.epoll_fd, events, 1024, wait_ms);
             if (n < 0) {
                 if (errno == EINTR) continue;
                 break;
@@ -1199,9 +1334,12 @@ void cwist_reactor_run(cwist_reactor_t *reactor) {
      * without a signal (cwist_shutdown_request() from another thread) only
      * clears g_cwist_running and closes the listen socket, which wakes
      * no kevent, so the flag must be re-checked periodically. */
-    const struct timespec idle_ts = {.tv_sec = 0, .tv_nsec = 100 * 1000 * 1000};
-    while (reactor->running && atomic_load(&g_cwist_running)) {
+    while (reactor_running(reactor)) {
         reactor_drain_posts(reactor);
+        reactor_run_timers(reactor);
+        const uint64_t wait_ns = reactor_wait_ns(reactor);
+        const struct timespec idle_ts = {.tv_sec = (time_t)(wait_ns / 1000000000ull),
+                                         .tv_nsec = (long)(wait_ns % 1000000000ull)};
         int n = kevent(reactor->impl.kq_fd, NULL, 0, events, 1024, &idle_ts);
         if (n < 0) {
             if (errno == EINTR) continue;
@@ -1220,7 +1358,8 @@ void cwist_reactor_run(cwist_reactor_t *reactor) {
 
 void cwist_reactor_stop(cwist_reactor_t *reactor) {
     if (!reactor) return;
-    reactor->running = false;
+    /* Called from foreign threads (shutdown paths, embedders). */
+    __atomic_store_n(&reactor->running, false, __ATOMIC_RELEASE);
     /* A run thread parked in io_uring_enter(GETEVENTS) on an idle SQPOLL
      * ring sleeps until a CQE arrives: the kernel ignores the enter
      * timeout for SQPOLL rings, so with no pending SQEs the wait never
diff --git a/src/sys/wasi/compat.c b/src/sys/wasi/compat.c
index 9e512944a..ff870d4b3 100644
--- a/src/sys/wasi/compat.c
+++ b/src/sys/wasi/compat.c
@@ -88,6 +88,32 @@ void cwist_reactor_destroy(cwist_reactor_t *reactor) {
     (void)reactor;
 }
 
+void cwist_reactor_timer_init(cwist_reactor_timer_t *timer, void (*cb)(void *ctx), void *ctx) {
+    if (!timer) return;
+    timer->deadline_ns = 0;
+    timer->heap_slot = 0;
+    timer->cb = cb;
+    timer->ctx = ctx;
+}
+
+bool cwist_reactor_timer_arm(cwist_reactor_t *reactor, cwist_reactor_timer_t *timer,
+                             uint64_t delay_us) {
+    (void)reactor;
+    (void)timer;
+    (void)delay_us;
+    return false;
+}
+
+void cwist_reactor_timer_cancel(cwist_reactor_t *reactor, cwist_reactor_timer_t *timer) {
+    (void)reactor;
+    (void)timer;
+}
+
+bool cwist_reactor_timer_armed(const cwist_reactor_timer_t *timer) {
+    (void)timer;
+    return false;
+}
+
 /* --- Metrics / parked-writer fast paths: under WASI 0.2 the real
  * metrics.c and writer_fast.c join the build, so these stubs exist only
  * for preview1 where those units cannot compile. ------------------------ */
diff --git a/tests/test_reactor_timer.c b/tests/test_reactor_timer.c
new file mode 100644
index 000000000..674e96c42
--- /dev/null
+++ b/tests/test_reactor_timer.c
@@ -0,0 +1,132 @@
+#ifndef _GNU_SOURCE
+#define _GNU_SOURCE
+#endif
+/* Reactor one-shot timer test.  Includes the implementation like
+ * test_reactor_wake.c so it runs without the rest of libcwist; run it once per
+ * backend (CWIST_REACTOR_BACKEND=epoll forces epoll on Linux).
+ *
+ * Checks: timers fire in deadline order regardless of arm order, never early;
+ * a cancelled timer never fires; re-arming replaces the deadline; a callback
+ * can re-arm itself.
+ */
+#include 
+#include 
+#include 
+#include 
+#include "../src/sys/io/reactor.c"
+
+void *cwist_alloc(size_t size) {
+    return calloc(1, size);
+}
+void cwist_free(void *ptr) {
+    free(ptr);
+}
+atomic_int g_cwist_running = 1;
+
+static cwist_reactor_t *loop;
+static uint64_t start_ns;
+
+typedef struct {
+    cwist_reactor_timer_t timer;
+    uint64_t delay_us;
+    uint64_t fired_ns;
+    int order;
+} probe_t;
+
+static int fired_count;
+static probe_t probes[5];
+static cwist_reactor_timer_t cancelled, rearmed, repeat, stopper;
+static int cancelled_fired, rearmed_fired, repeat_left = 5;
+static uint64_t rearmed_fired_ns;
+
+static void probe_cb(void *ctx) {
+    probe_t *p = ctx;
+    p->fired_ns = reactor_now_ns();
+    p->order = fired_count++;
+}
+
+static void cancelled_cb(void *ctx) {
+    (void)ctx;
+    cancelled_fired = 1;
+}
+
+static void rearmed_cb(void *ctx) {
+    (void)ctx;
+    rearmed_fired++;
+    rearmed_fired_ns = reactor_now_ns();
+}
+
+static void repeat_cb(void *ctx) {
+    (void)ctx;
+    if (--repeat_left > 0) assert(cwist_reactor_timer_arm(loop, &repeat, 2000));
+}
+
+static void stop_cb(void *ctx) {
+    (void)ctx;
+    cwist_reactor_stop(loop);
+}
+
+static void *run_loop(void *arg) {
+    (void)arg;
+    cwist_reactor_run(loop);
+    return NULL;
+}
+
+int main(void) {
+    loop = cwist_reactor_create();
+    assert(loop);
+    start_ns = reactor_now_ns();
+
+    /* Arm out of deadline order. */
+    const uint64_t delays_us[5] = {40000, 10000, 30000, 5000, 20000};
+    for (int i = 0; i < 5; i++) {
+        probes[i].delay_us = delays_us[i];
+        cwist_reactor_timer_init(&probes[i].timer, probe_cb, &probes[i]);
+        assert(cwist_reactor_timer_arm(loop, &probes[i].timer, delays_us[i]));
+    }
+    cwist_reactor_timer_init(&cancelled, cancelled_cb, NULL);
+    assert(cwist_reactor_timer_arm(loop, &cancelled, 15000));
+    cwist_reactor_timer_cancel(loop, &cancelled);
+    assert(!cwist_reactor_timer_armed(&cancelled));
+
+    /* Re-arm pushes the deadline out: must fire once, near 50 ms. */
+    cwist_reactor_timer_init(&rearmed, rearmed_cb, NULL);
+    assert(cwist_reactor_timer_arm(loop, &rearmed, 1000));
+    assert(cwist_reactor_timer_arm(loop, &rearmed, 50000));
+
+    cwist_reactor_timer_init(&repeat, repeat_cb, NULL);
+    assert(cwist_reactor_timer_arm(loop, &repeat, 2000));
+
+    cwist_reactor_timer_init(&stopper, stop_cb, NULL);
+    assert(cwist_reactor_timer_arm(loop, &stopper, 80000));
+
+    pthread_t th;
+    assert(pthread_create(&th, NULL, run_loop, NULL) == 0);
+    assert(pthread_join(th, NULL) == 0);
+
+    assert(fired_count == 5);
+    for (int i = 0; i < 5; i++) {
+        uint64_t elapsed_us = (probes[i].fired_ns - start_ns) / 1000;
+        assert(elapsed_us >= probes[i].delay_us);
+        /* Generous lateness bound for loaded CI runners. */
+        assert(elapsed_us < probes[i].delay_us + 50000);
+        for (int j = 0; j < 5; j++) {
+            if (probes[j].delay_us < probes[i].delay_us) assert(probes[j].order < probes[i].order);
+        }
+    }
+    assert(!cancelled_fired);
+    assert(rearmed_fired == 1);
+    assert((rearmed_fired_ns - start_ns) / 1000 >= 50000);
+    assert(repeat_left == 0);
+    assert(loop->timer_n == 0);
+
+    cwist_reactor_destroy(loop);
+    printf("test_reactor_timer: all assertions passed (%s)\n",
+#ifdef __linux__
+           getenv("CWIST_REACTOR_BACKEND") ? getenv("CWIST_REACTOR_BACKEND") : "default"
+#else
+           "kqueue"
+#endif
+    );
+    return 0;
+}

From f42c2859442d0db26a7f55f2ec0426f9df7f80cb Mon Sep 17 00:00:00 2001
From: Lee Yunjin 
Date: Mon, 5 Oct 2026 19:34:23 +0900
Subject: [PATCH 7/7] perf(webrtc): run on the cwist reactor; fix browser
 interop

Event loop
- A ctx is now driven by a cwist reactor instead of a private poll()
  thread that woke every 10 ms.  cwist_webrtc_ctx_new() still creates its
  own reactor and thread; the new cwist_webrtc_ctx_new_on() attaches to a
  reactor the caller runs.
- Nothing runs on a fixed tick.  Each conn has one reactor timer (STUN
  retransmit with backoff, DTLS retransmit, 30 s handshake deadline,
  30 s liveness, 30 s expiry for answered offers that never connect), and
  usrsctp's clock is driven only while associations exist: every 10 ms
  after recent traffic, every 250 ms when quiet.
- UDP is read with recvmmsg() and written with sendmmsg(): datagrams
  queued during a callback leave in one call.  SCTP draining and queued
  sends are handled once per callback for every conn it touched.
- Connections are looked up in a hash table instead of a linked list.
- DTLS uses a datagram BIO.  The memory BIO was a byte stream: records
  written back to back were concatenated and read back in 2 KiB slices,
  so a record could be split across two UDP packets.
- Foreign-thread sends go through a per-conn lock-free inbox and wake the
  reactor once per burst.  The queue is capped at 4 MiB
  (cwist_webrtc_conn_send() returns -1 beyond it); queued bytes are
  reported by cwist_webrtc_conn_buffered_amount().
- usrsctp time advances by real elapsed time under one lock, whichever
  ctx ticks it (two ctxs used to each add their own elapsed time).  A
  packet usrsctp emits on another ctx's thread is marshalled to the owner.

Thread safety and lifetimes
- All ctx/conn state is touched on the owner thread only; send, close,
  handle_offer and ctx_free post when called elsewhere.  The old code read
  and wrote the pending queue and the conn list from two threads without
  a lock.
- Conns and ctxs are reference counted, with cwist_webrtc_conn_retain()/
  release() and a close handler (cwist_webrtc_ctx_set_close_handler), so
  closing no longer frees a conn under a pointer the application holds.
- usrsctp's malloc'd receive buffer is no longer passed to cwist_free():
  receive callbacks are gone, all data is drained with usrsctp_recvv().
- The shared static 1 MiB receive buffer is now per ctx.
- A ctx inherited across fork() is detected: handle_offer() returns -1 and
  ctx_free() does nothing in the child.  The example created its ctx
  before cwist_app_listen() forked workers; each worker freed the parent's
  copy at shutdown and segfaulted.  It now creates one ctx per worker.

Protocol fixes found by testing against Chromium
- PPIDs were wrong: DCEP is 50, string 51, binary 53 (RFC 8831 s8); the
  code used 53/50/51.  Our two ends agreed with each other, so the
  loopback test passed while a browser's channel open was echoed back as
  text and its binary messages were dropped as DCEP.  Empty messages now
  use PPID 56/57.
- The message callback now reports the message type so text can be
  echoed as text (API change; the callback gains a
  cwist_webrtc_data_type argument).
- Messages delivered in pieces (SCTP partial delivery) are reassembled
  using MSG_EOR instead of being passed up as separate messages.
- STUN USERNAME is ":" (RFC 8445 s7.2.2); the remote
  ufrag was read from the wrong side, so the conn parked for an answered
  offer was never adopted and leaked.
- SCTP: 1024 streams, fixed 1160-byte path MTU, 10 s heartbeats, abort on
  close, association-loss notifications close the conn.

Tests
- test_webrtc: typed and empty messages, a 200 000-byte message both ways,
  the max-message-size and 4 MiB queue limits, close from a foreign thread
  reaching the peer's close handler, parked-conn adoption, and a ctx on a
  caller-run reactor freed from another thread.  Clean under ASan/UBSan/
  LSan and TSan.
- bench_webrtc (make bench_webrtc): idle cost, throughput, RTT.
- test_webrtc_browser (make target, not in `make test`): headless
  Chromium over CDP against example/webrtc.

Loopback, bench_webrtc 200000 2000, before -> after:
  idle ctx, 1 s           101 -> 11 context switches
  64 B messages           195k -> 970k msg/s
  1 KiB messages          26 -> 162 MB/s
  64 KiB messages         37.6 -> 184 MB/s
  RTT p50                 25 -> 29 us (reactor re-arm + eventfd wake)
---
 Makefile                          |   24 +-
 example/webrtc/README.md          |    9 +-
 example/webrtc/main.c             |   56 +-
 include/cwist/net/webrtc.h        |  105 +-
 src/net/webrtc/dtls.c             |   78 +-
 src/net/webrtc/ice.c              |   18 +-
 src/net/webrtc/sctp.c             |  615 +++++-----
 src/net/webrtc/webrtc.c           | 1749 +++++++++++++++++------------
 src/net/webrtc/webrtc_internal.h  |  458 ++++----
 tests/bench_webrtc.c              |  182 +++
 tests/browser/webrtc_chromium.mjs |  150 +++
 tests/test_webrtc.c               |  367 ++++--
 12 files changed, 2416 insertions(+), 1395 deletions(-)
 create mode 100644 tests/bench_webrtc.c
 create mode 100644 tests/browser/webrtc_chromium.mjs

diff --git a/Makefile b/Makefile
index 1880e4473..8015292c3 100644
--- a/Makefile
+++ b/Makefile
@@ -1530,13 +1530,29 @@ test_webrtc: $(LIB_NAME) $(USRSCTP_LIB) tests/test_webrtc.c
 	$(CC) $(CFLAGS) -o test_webrtc tests/test_webrtc.c $(LIB_NAME) $(LIBS)
 	./test_webrtc
 
-.PHONY: test_webrtc
+# Loopback DataChannel benchmark: idle cost, throughput, RTT. Not part of
+# `make test`; run ./bench_webrtc [small_count] [large_count] after building.
+bench_webrtc: $(LIB_NAME) $(USRSCTP_LIB) tests/bench_webrtc.c
+	$(CC) $(CFLAGS) -o bench_webrtc tests/bench_webrtc.c $(LIB_NAME) $(LIBS)
+
+# Real-browser interop: starts example/webrtc and drives headless Chromium
+# against it (tests/browser/webrtc_chromium.mjs). Needs chromium and Node >= 22;
+# not part of `make test`. Uses port 8080.
+test_webrtc_browser: $(LIB_NAME) $(USRSCTP_LIB)
+	$(MAKE) -C example/webrtc
+	@(cd example/webrtc && exec env CWIST_WORKERS=1 ./webrtc-server > /dev/null 2>&1) & pid=$$!; \
+	sleep 1; \
+	node tests/browser/webrtc_chromium.mjs http://localhost:8080/; rc=$$?; \
+	kill $$pid 2>/dev/null; wait $$pid 2>/dev/null; \
+	exit $$rc
+
+.PHONY: test_webrtc bench_webrtc test_webrtc_browser
 else
 # CWIST_WEBRTC=0: the module and its test are compiled out.
-test_webrtc:
-	@echo "CWIST_WEBRTC=0: skipping test_webrtc"
+test_webrtc bench_webrtc test_webrtc_browser:
+	@echo "CWIST_WEBRTC=0: skipping $@"
 
-.PHONY: test_webrtc
+.PHONY: test_webrtc bench_webrtc test_webrtc_browser
 endif
 
 test_waf: $(LIB_NAME) tests/test_waf.c
diff --git a/example/webrtc/README.md b/example/webrtc/README.md
index 22ffbc0e6..40b566310 100644
--- a/example/webrtc/README.md
+++ b/example/webrtc/README.md
@@ -16,7 +16,8 @@ make
 
 ```sh
 ./webrtc-server
-# signaling: http://localhost:8080/ (webrtc UDP port )
+# signaling: http://localhost:8080/
+# [webrtc] ctx ready in pid  on UDP port    (first offer)
 ```
 
 Open http://localhost:8080/ in a browser, click **Connect**, type a message
@@ -26,7 +27,11 @@ the server console.
 ## Layout
 
 - `main.c` — cwist app serving `index.html` (GET /) and SDP answers
-  (POST /application/sdp), plus the echo logic on the cwist webrtc ctx.
+  (POST /offer, `application/sdp`), plus the echo logic on the cwist webrtc
+  ctx. `cwist_app_listen` forks one HTTP worker per core and a ctx belongs to
+  the process that created it, so each worker creates its own ctx (own UDP
+  port, own reactor thread) on its first offer; the answer carries that
+  port. Message, channel and close handlers run on the ctx's reactor thread.
 - `index.html` — minimal RTCPeerConnection client using a DataChannel.
 
 ## Notes / limitations (MVP)
diff --git a/example/webrtc/main.c b/example/webrtc/main.c
index 2d8979c71..9446aafb8 100644
--- a/example/webrtc/main.c
+++ b/example/webrtc/main.c
@@ -9,18 +9,28 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
+#include 
 
+/* One webrtc ctx per process.  cwist_app_listen() forks HTTP workers, and a
+ * ctx (with its reactor thread) belongs to the process that created it, so
+ * each worker makes its own on its first offer.  Every answer names the UDP
+ * port of the ctx that wrote it, so browsers reach the right process. */
 static cwist_webrtc_ctx *g_webrtc;
+static pthread_once_t g_webrtc_once = PTHREAD_ONCE_INIT;
 
-static void on_message(cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
-                       size_t len, void *user) {
+static void on_message(cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data, size_t len,
+                       cwist_webrtc_data_type type, void *user) {
     (void)user;
     (void)channel;
-    printf("[webrtc] message (%zu bytes): %.*s\n", len, (int)len, (const char *)data);
-    cwist_webrtc_conn_send(conn, channel, data, len, CWIST_WEBRTC_DATA_BINARY);
+    if (type == CWIST_WEBRTC_DATA_STRING)
+        printf("[webrtc] text (%zu bytes): %.*s\n", len, (int)len, (const char *)data);
+    else
+        printf("[webrtc] binary (%zu bytes)\n", len);
+    cwist_webrtc_conn_send(conn, channel, data, len, type); /* echo with the same type */
 }
 
 static void on_channel(cwist_webrtc_conn *conn, uint16_t channel, const char *label,
@@ -30,6 +40,12 @@ static void on_channel(cwist_webrtc_conn *conn, uint16_t channel, const char *la
     printf("[webrtc] channel %u opened by peer (label=%s)\n", channel, label);
 }
 
+static void on_close(cwist_webrtc_conn *conn, void *user) {
+    (void)conn;
+    (void)user;
+    printf("[webrtc] connection closed\n");
+}
+
 static void index_handler(cwist_http_request *req, cwist_http_response *res) {
     (void)req;
     FILE *f = fopen("index.html", "rb");
@@ -50,9 +66,25 @@ static void index_handler(cwist_http_request *req, cwist_http_response *res) {
     cwist_sstring_assign(res->body, html);
 }
 
+static void webrtc_init(void) {
+    g_webrtc = cwist_webrtc_ctx_new(0);
+    if (!g_webrtc) {
+        fprintf(stderr, "failed to create webrtc ctx\n");
+        return;
+    }
+    cwist_webrtc_ctx_set_message_handler(g_webrtc, &on_message, NULL);
+    cwist_webrtc_ctx_set_channel_handler(g_webrtc, &on_channel, NULL);
+    cwist_webrtc_ctx_set_close_handler(g_webrtc, &on_close, NULL);
+    printf("[webrtc] ctx ready in pid %d on UDP port %u\n", (int)getpid(),
+           cwist_webrtc_ctx_port(g_webrtc));
+}
+
 static void offer_handler(cwist_http_request *req, cwist_http_response *res) {
-    static char answer[4096];
-    if (cwist_webrtc_handle_offer(g_webrtc, req->body->data, answer, sizeof(answer)) < 0) {
+    pthread_once(&g_webrtc_once, webrtc_init);
+    /* Handlers run on several HTTP threads at once: no static buffer. */
+    char answer[4096];
+    if (!g_webrtc ||
+        cwist_webrtc_handle_offer(g_webrtc, req->body->data, answer, sizeof(answer)) < 0) {
         cwist_http_header_add(&res->headers, "Content-Type", "text/plain");
         cwist_sstring_assign(res->body, "invalid SDP offer");
         res->status_code = CWIST_HTTP_BAD_REQUEST;
@@ -65,20 +97,12 @@ static void offer_handler(cwist_http_request *req, cwist_http_response *res) {
 }
 
 int main(void) {
-    g_webrtc = cwist_webrtc_ctx_new(0);
-    if (!g_webrtc) {
-        fprintf(stderr, "failed to create webrtc ctx\n");
-        return 1;
-    }
-    cwist_webrtc_ctx_set_message_handler(g_webrtc, &on_message, NULL);
-    cwist_webrtc_ctx_set_channel_handler(g_webrtc, &on_channel, NULL);
-
     cwist_app *app = cwist_app_create();
     cwist_app_get(app, "/", index_handler);
     cwist_app_post(app, "/offer", offer_handler);
-    printf("signaling: http://localhost:8080/ (webrtc UDP port %u)\n",
-           cwist_webrtc_ctx_port(g_webrtc));
+    printf("signaling: http://localhost:8080/\n");
     cwist_app_listen(app, 8080);
+    /* Every worker returns here; each frees the ctx it created, if any. */
     cwist_app_destroy(app);
     cwist_webrtc_ctx_free(g_webrtc);
     return 0;
diff --git a/include/cwist/net/webrtc.h b/include/cwist/net/webrtc.h
index 5361e38fe..f2eeae4e8 100644
--- a/include/cwist/net/webrtc.h
+++ b/include/cwist/net/webrtc.h
@@ -5,6 +5,24 @@
  * endpoint, completes a DTLS handshake (BoringSSL, plain DTLS, no SRTP) over
  * the nominated UDP path, and runs SCTP with DataChannel establishment over
  * the DTLS connection via usrsctp.
+ *
+ * Threading: a ctx runs on one cwist reactor.  cwist_webrtc_ctx_new() creates
+ * a private reactor and thread; cwist_webrtc_ctx_new_on() attaches to a
+ * reactor the caller already runs.  Every callback is invoked on that
+ * reactor's run thread.  cwist_webrtc_conn_send(), cwist_webrtc_conn_close(),
+ * cwist_webrtc_handle_offer() and cwist_webrtc_ctx_free() may be called from
+ * any thread.
+ *
+ * Processes: a ctx belongs to the process that created it.  A forked child
+ * (e.g. a cwist_app_listen() worker) must create its own ctx; on an inherited
+ * one, cwist_webrtc_handle_offer() returns -1 and cwist_webrtc_ctx_free() does
+ * nothing.
+ *
+ * Connection lifetime: a cwist_webrtc_conn pointer passed to a callback is
+ * valid for the duration of that callback.  To use it later or from another
+ * thread, take a reference with cwist_webrtc_conn_retain() and drop it with
+ * cwist_webrtc_conn_release().  After the close handler runs, sends on a
+ * retained conn fail with -1.
  */
 #ifndef __CWIST_NET_WEBRTC_H__
 #define __CWIST_NET_WEBRTC_H__
@@ -12,20 +30,26 @@
 #include 
 #include 
 
+#include 
+
 typedef struct cwist_webrtc_ctx cwist_webrtc_ctx;
 typedef struct cwist_webrtc_conn cwist_webrtc_conn;
 
+/** DataChannel message type (RFC 8831 section 8 payload protocol ids). */
 typedef enum {
-    CWIST_WEBRTC_DATA_BINARY = 0,  /**< PPID 51 */
-    CWIST_WEBRTC_DATA_STRING = 1   /**< PPID 50 */
+    CWIST_WEBRTC_DATA_BINARY = 0,  /**< PPID 53 (57 when empty); ArrayBuffer/Blob in a browser. */
+    CWIST_WEBRTC_DATA_STRING = 1   /**< PPID 51 (56 when empty); UTF-8 string in a browser. */
 } cwist_webrtc_data_type;
 
 /**
- * @brief DataChannel message callback, invoked from the ctx event-loop thread.
+ * @brief DataChannel message callback, invoked on the ctx's reactor thread.
  * @param channel_id SCTP stream id (DataChannel id).
+ * @param type Whether the peer sent a string or binary message; echo it back
+ *             with the same type to preserve it.
  */
 typedef void (*cwist_webrtc_message_cb)(cwist_webrtc_conn *conn, uint16_t channel_id,
-                                        const uint8_t *data, size_t len, void *user);
+                                        const uint8_t *data, size_t len,
+                                        cwist_webrtc_data_type type, void *user);
 
 /**
  * @brief Optional DataChannel open notification (remote peer opened a channel).
@@ -34,18 +58,37 @@ typedef void (*cwist_webrtc_channel_cb)(cwist_webrtc_conn *conn, uint16_t channe
                                         const char *label, void *user);
 
 /**
- * @brief Create a WebRTC context bound to a UDP port (0 picks an ephemeral port).
+ * @brief Optional connection-closed notification.  Runs once per connection
+ *        whose SCTP association came up; after it returns, the conn pointer is
+ *        only valid through references the application still holds.
+ */
+typedef void (*cwist_webrtc_close_cb)(cwist_webrtc_conn *conn, void *user);
+
+/**
+ * @brief Create a WebRTC context bound to a UDP port (0 picks an ephemeral
+ *        port), running on its own reactor thread.
  * @return NULL on failure.
  */
 cwist_webrtc_ctx *cwist_webrtc_ctx_new(uint16_t port);
 
+/**
+ * @brief Create a WebRTC context on a reactor the caller runs.
+ *
+ * The ctx registers its UDP socket and timers with @p reactor and does all of
+ * its work inside that reactor's callbacks; no thread is created.  The
+ * reactor must keep running until cwist_webrtc_ctx_free() returns.
+ * @return NULL on failure.
+ */
+cwist_webrtc_ctx *cwist_webrtc_ctx_new_on(cwist_reactor_t *reactor, uint16_t port);
+
 /**
  * @brief UDP port the context is bound to.
  */
 uint16_t cwist_webrtc_ctx_port(const cwist_webrtc_ctx *ctx);
 
 /**
- * @brief Set the callback for incoming DataChannel messages.
+ * @brief Set the callback for incoming DataChannel messages.  Set handlers
+ *        before answering the first offer.
  */
 void cwist_webrtc_ctx_set_message_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_message_cb cb,
                                           void *user);
@@ -56,6 +99,12 @@ void cwist_webrtc_ctx_set_message_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_me
 void cwist_webrtc_ctx_set_channel_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_channel_cb cb,
                                           void *user);
 
+/**
+ * @brief Set the callback for closed connections.
+ */
+void cwist_webrtc_ctx_set_close_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_close_cb cb,
+                                        void *user);
+
 /**
  * @brief Answer a browser SDP offer (ICE-lite, setup:passive).
  * @param offer The remote SDP offer.
@@ -63,31 +112,65 @@ void cwist_webrtc_ctx_set_channel_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_ch
  * @param out_len Size of answer_out.
  * @return 0 on success, -1 on error.
  *
- * The connection completes asynchronously (ICE nomination, DTLS, SCTP);
- * cwist_webrtc_ctx_poll() must be driven, or the ctx background thread
- * handles it when created with cwist_webrtc_ctx_new().
+ * Safe from any thread.  The connection completes asynchronously (ICE
+ * nomination, DTLS, SCTP) on the ctx's reactor thread.  An answered offer
+ * whose peer never shows up is dropped after 30 seconds.
  */
 int cwist_webrtc_handle_offer(cwist_webrtc_ctx *ctx, const char *offer, char *answer_out,
                               size_t out_len);
 
 /**
- * @brief Number of fully established (SCTP/DTLS up) connections.
+ * @brief Number of connections whose SCTP association is up.
  */
 int cwist_webrtc_ctx_connection_count(const cwist_webrtc_ctx *ctx);
 
 /**
  * @brief Send a message on a DataChannel.
- * @return 0 on success, -1 on error.
+ *
+ * Safe from any thread.  The message is queued if SCTP is not ready or its
+ * send buffer is full; queued bytes are reported by
+ * cwist_webrtc_conn_buffered_amount().
+ * @return 0 when sent or queued; -1 if the conn is closed, @p len exceeds
+ *         262144 bytes (the advertised max-message-size), or the queue already
+ *         holds 4 MiB.
  */
 int cwist_webrtc_conn_send(cwist_webrtc_conn *conn, uint16_t channel_id, const uint8_t *data,
                            size_t len, cwist_webrtc_data_type type);
 
+/**
+ * @brief Bytes accepted by cwist_webrtc_conn_send() and not yet handed to
+ *        SCTP (like RTCDataChannel.bufferedAmount, summed over channels).
+ */
+size_t cwist_webrtc_conn_buffered_amount(const cwist_webrtc_conn *conn);
+
 /**
  * @brief Local certificate fingerprint (SHA-256, colon-separated hex).
  */
 const char *cwist_webrtc_ctx_fingerprint(const cwist_webrtc_ctx *ctx);
 
+/**
+ * @brief Take a reference on @p conn so it stays valid outside a callback.
+ */
+void cwist_webrtc_conn_retain(cwist_webrtc_conn *conn);
+
+/**
+ * @brief Drop a reference taken with cwist_webrtc_conn_retain().
+ */
+void cwist_webrtc_conn_release(cwist_webrtc_conn *conn);
+
+/**
+ * @brief Close a connection (SCTP abort, DTLS teardown).  Safe from any
+ *        thread; the close handler runs on the reactor thread.
+ */
 void cwist_webrtc_conn_close(cwist_webrtc_conn *conn);
+
+/**
+ * @brief Close every connection and free the context.
+ *
+ * Safe from any thread.  For a ctx made with cwist_webrtc_ctx_new_on() and
+ * called off the reactor thread, this waits for the reactor to finish the
+ * teardown, so the reactor must still be running.
+ */
 void cwist_webrtc_ctx_free(cwist_webrtc_ctx *ctx);
 
 #endif
diff --git a/src/net/webrtc/dtls.c b/src/net/webrtc/dtls.c
index 5f50a02bd..5f2746fcb 100644
--- a/src/net/webrtc/dtls.c
+++ b/src/net/webrtc/dtls.c
@@ -3,14 +3,15 @@
  *
  * Provides the minimal DTLS layer for WebRTC: an ephemeral self-signed
  * ECDSA certificate, the SHA-256 certificate fingerprint used in SDP
- * a=fingerprint attributes, and construction of client/server SSL_CTX
- * objects pinned to DTLS 1.2.
+ * a=fingerprint attributes, construction of client/server SSL_CTX
+ * objects pinned to DTLS 1.2, and the datagram BIO the sessions run on.
  */
 #include "webrtc_internal.h"
 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -141,3 +142,76 @@ SSL_CTX *cwist_dtls_ctx_new(int is_server, X509 *cert, EVP_PKEY *pkey) {
 unsigned int cwist_dtls_link_mtu(void) {
     return CWIST_DTLS_MTU;
 }
+
+/* ---- datagram BIO ----
+ *
+ * DTLS needs datagram semantics: one BIO_read returns exactly one datagram and
+ * one BIO_write is exactly one datagram.  A memory BIO is a byte stream, so
+ * records written back to back get concatenated and a fixed-size read can cut
+ * one in half across two UDP packets.  This BIO keeps the boundaries and also
+ * skips the extra copy through the memory buffer: reads hand over the datagram
+ * the event loop is processing, writes go straight to the ctx send batch. */
+
+static BIO_METHOD *g_dgram_method;
+static pthread_once_t g_dgram_once = PTHREAD_ONCE_INIT;
+
+/** @brief Return the current inbound datagram once, then report "retry". */
+static int dgram_bio_read(BIO *bio, char *out, int outl) {
+    struct cwist_webrtc_conn *conn = BIO_get_data(bio);
+    BIO_clear_retry_flags(bio);
+    if (!conn || !conn->dtls_in) {
+        BIO_set_retry_read(bio);
+        return -1;
+    }
+    size_t n = conn->dtls_in_len;
+    if (outl < 0 || n > (size_t)outl)
+        n = outl < 0 ? 0 : (size_t)outl; /* DTLS drops a truncated datagram. */
+    memcpy(out, conn->dtls_in, n);
+    conn->dtls_in = NULL;
+    conn->dtls_in_len = 0;
+    return (int)n;
+}
+
+/** @brief Emit one datagram to the conn's peer. */
+static int dgram_bio_write(BIO *bio, const char *in, int inl) {
+    struct cwist_webrtc_conn *conn = BIO_get_data(bio);
+    BIO_clear_retry_flags(bio);
+    if (!conn || inl < 0) return -1;
+    cwist_webrtc_conn_dtls_out(conn, (const uint8_t *)in, (size_t)inl);
+    return inl;
+}
+
+/** @brief Flush always succeeds (the event loop flushes the batch); nothing
+ *         else is supported. */
+static long dgram_bio_ctrl(BIO *bio, int cmd, long num, void *ptr) {
+    (void)bio;
+    (void)num;
+    (void)ptr;
+    return cmd == BIO_CTRL_FLUSH ? 1 : 0;
+}
+
+/** @brief Mark a new BIO initialised. */
+static int dgram_bio_create(BIO *bio) {
+    BIO_set_init(bio, 1);
+    return 1;
+}
+
+/** @brief Build the shared BIO_METHOD once. */
+static void dgram_method_init(void) {
+    BIO_METHOD *m = BIO_meth_new(BIO_get_new_index() | BIO_TYPE_SOURCE_SINK, "cwist-webrtc-dgram");
+    if (!m) return;
+    if (!BIO_meth_set_read(m, dgram_bio_read) || !BIO_meth_set_write(m, dgram_bio_write) ||
+        !BIO_meth_set_ctrl(m, dgram_bio_ctrl) || !BIO_meth_set_create(m, dgram_bio_create)) {
+        BIO_meth_free(m);
+        return;
+    }
+    g_dgram_method = m;
+}
+
+BIO *cwist_dtls_bio_new(struct cwist_webrtc_conn *conn) {
+    pthread_once(&g_dgram_once, dgram_method_init);
+    if (!g_dgram_method) return NULL;
+    BIO *bio = BIO_new(g_dgram_method);
+    if (bio) BIO_set_data(bio, conn);
+    return bio;
+}
diff --git a/src/net/webrtc/ice.c b/src/net/webrtc/ice.c
index d6ae69741..26b99a755 100644
--- a/src/net/webrtc/ice.c
+++ b/src/net/webrtc/ice.c
@@ -539,10 +539,12 @@ int cwist_ice_stun_parse_response(const uint8_t *buf, size_t len, const uint8_t
 /** Base64url alphabet (no padding) used to encode random ICE credentials. */
 static const char b64url_chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
 
-/** @brief Extract the remote ufrag from the USERNAME attribute of a STUN message.
+/** @brief Extract the sender's ufrag from the USERNAME attribute of a STUN message.
  *
- * The USERNAME attribute holds "remoteufrag:localufrag" (RFC 8445); this
- * returns the part before the first ':'.
+ * A Binding request's USERNAME is ":"
+ * (RFC 8445 section 7.2.2), so for a request we received this returns the
+ * part after the first ':' -- the remote peer's ufrag, as it appears in the
+ * peer's SDP.
  *
  * @param buf Datagram bytes.
  * @param len Number of bytes at @p buf.
@@ -561,11 +563,13 @@ int cwist_ice_stun_get_remote_ufrag(const uint8_t *buf, size_t len, char *out, s
         uint16_t type = rd16(buf + off);
         uint16_t alen = rd16(buf + off + 2);
         if (type == ATTR_USERNAME) {
+            if (off + 4 + alen > end) return -1;
+            const uint8_t *name = buf + off + 4;
+            size_t colon = 0;
+            while (colon < alen && name[colon] != ':') colon++;
+            if (colon == alen) return -1; /* no ':' separator */
             size_t i = 0;
-            while (i < alen && i + 1 < cap && buf[off + 4 + i] != ':') {
-                out[i] = (char)buf[off + 4 + i];
-                i++;
-            }
+            for (size_t j = colon + 1; j < alen && i + 1 < cap; j++) out[i++] = (char)name[j];
             out[i] = '\0';
             return 0;
         }
diff --git a/src/net/webrtc/sctp.c b/src/net/webrtc/sctp.c
index 6ed81a0d5..6b6ff70bf 100644
--- a/src/net/webrtc/sctp.c
+++ b/src/net/webrtc/sctp.c
@@ -2,24 +2,33 @@
  * @brief SCTP DataChannels over DTLS (RFC 8831) and DCEP (RFC 8832).
  *
  * usrsctp runs in AF_CONN raw mode tunneled over the DTLS connection
- * (RFC 8831): the module pumps usrsctp_handle_timers() from the ctx event
- * loop and the global conn_output callback (registered with
- * usrsctp_init_nothreads) hands packets back to the owning connection.
+ * (RFC 8831), initialised without its own threads.  Its global output
+ * callback hands packets to cwist_webrtc_conn_sctp_out(), which encrypts them
+ * on the conn's owner thread.
  *
  * The address handle convention follows usrsctp's ekr_loop example: every
  * endpoint registers its connection pointer as its handle, binds with it,
- * and the offering side "connects" to its own handle. Packets are injected
+ * and the offering side "connects" to its own handle.  Packets are injected
  * with usrsctp_conninput(conn) and replies come back through the same
  * handle, which keeps routing unambiguous when several associations share
- * one usrsctp instance. The answering side listens and accepts; the
- * accepted socket carries no receive callback, so inbound data is drained
- * with usrsctp_recvmsg() from the event loop.
+ * one usrsctp instance.  The answering side listens and accepts.  Inbound
+ * data is drained with usrsctp_recvv() by the event loop; no receive
+ * callbacks are registered, so usrsctp never calls into us with data on an
+ * arbitrary thread.
+ *
+ * Timers: usrsctp keeps one process-wide timer wheel.  cwist_sctp_tick()
+ * advances it by the real time elapsed since the previous tick, whichever
+ * ctx calls it, so several ctxs ticking concurrently cannot make SCTP time
+ * run fast.  A timer that fires may emit a packet for a conn owned by another
+ * reactor; the output path marshals it there.  Teardown takes the same lock
+ * as the tick, so a timer pass never runs against a half-closed conn.
  */
 #include "webrtc_internal.h"
 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
@@ -30,206 +39,133 @@
 #define DCEP_OPEN 0x03 /**< DCEP OPEN: announces a new DataChannel. */
 /**@}*/
 
-/** @name SCTP payload protocol identifiers (RFC 8831) */
+/** @name SCTP payload protocol identifiers (RFC 8831 section 8) */
 /**@{*/
-#define PPID_STRING 50 /**< DataChannel message in UTF-8 string form. */
-#define PPID_BINARY 51 /**< DataChannel message in binary form. */
-#define PPID_DCEP 53   /**< DCEP control message (OPEN/ACK). */
+#define PPID_DCEP 50         /**< DCEP control message (OPEN/ACK). */
+#define PPID_STRING 51       /**< UTF-8 string message. */
+#define PPID_BINARY 53       /**< Binary message. */
+#define PPID_STRING_EMPTY 56 /**< Empty string (sent as one 0x00 byte). */
+#define PPID_BINARY_EMPTY 57 /**< Empty binary message (sent as one 0x00 byte). */
 /**@}*/
 
 /** WebRTC DataChannel well-known SCTP port (RFC 8831, both endpoints use it). */
 #define CWIST_SCTP_PORT 5000
 
-/** @name Per-connection channel state bitmap
- *
- * conn->ch_state tracks two flags per DataChannel id in a packed bit array:
- * bit 0 = channel announced by the peer (DCEP OPEN received), bit 1 = we
- * sent DCEP OPEN for the channel. Any bit set means the channel is usable.
- */
-/**@{*/
-#define CHANNEL_BITS(n) ((n) * 2)                 /**< Bit offset of channel (n)'s flags. */
-#define CH_STATE_BYTES (65536 / 4) /* 2 bits per channel */
-/**@}*/
+/** Streams negotiated in each direction (libwebrtc uses the same count). */
+#define CWIST_SCTP_STREAMS 1024
+
+/** SCTP packet size.  DTLS 1.2 with AES-GCM adds 37 bytes (13 header, 8
+ *  explicit nonce, 16 tag), so the datagram stays under the 1200-byte DTLS
+ *  link MTU with room to spare. */
+#define CWIST_SCTP_MTU 1160
+
+/** SCTP heartbeat interval (ms). */
+#define CWIST_SCTP_HEARTBEAT_MS 10000
+
+/** Socket buffer for each association: holds several max-size messages. */
+#define CWIST_SCTP_SOCKBUF (1 << 20)
+
+/** Serialises usrsctp_handle_timers() and the tick clock, and keeps conn
+ *  teardown out of a timer pass (see the file comment). */
+static pthread_mutex_t g_sctp_lock = PTHREAD_MUTEX_INITIALIZER;
+/** Monotonic time (ms) up to which usrsctp's clock has been advanced. */
+static uint64_t g_sctp_clock_ms;
+/** Guards the one-time usrsctp initialisation. */
+static pthread_once_t g_sctp_once = PTHREAD_ONCE_INIT;
+
+/** @brief CLOCK_MONOTONIC in milliseconds. */
+static uint64_t sctp_now_ms(void) {
+    struct timespec ts;
+    clock_gettime(CLOCK_MONOTONIC, &ts);
+    return (uint64_t)ts.tv_sec * 1000ull + (uint64_t)ts.tv_nsec / 1000000ull;
+}
 
 /**
- * @brief Global usrsctp conn_output callback (AF_CONN raw mode).
- *
- * Hands a usrsctp-generated SCTP packet to the owning connection for
- * transmission over its DTLS link. Registered once via
- * usrsctp_init_nothreads() in cwist_sctp_global_init(). The @p addr handle is
- * the cwist_webrtc_conn pointer registered with usrsctp_register_address().
- *
- * @param addr     Connection handle (cwist_webrtc_conn *); may be NULL.
- * @param buffer   SCTP packet to transmit; ownership stays with usrsctp.
- * @param length   Length of @p buffer in bytes.
- * @param tos      Ignored (AF_CONN).
- * @param set_df   Ignored (AF_CONN).
- * @return 0 on success, -1 if the connection is gone (packet is dropped).
- * @warning Called from usrsctp context; must not block or free @p buffer.
+ * @brief usrsctp's global output callback.
+ * @param addr The conn registered as the AF_CONN address.
+ * @return 0 when handed off, -1 if there is no conn.
  */
 static int sctp_global_output(void *addr, void *buffer, size_t length, uint8_t tos,
                               uint8_t set_df) {
     (void)tos;
     (void)set_df;
     struct cwist_webrtc_conn *conn = addr;
-    if (!conn || conn->state == CWIST_CONN_DEAD)
-        return -1;
+    if (!conn) return -1;
     cwist_webrtc_conn_sctp_out(conn, buffer, length);
     return 0;
 }
 
-/**
- * @brief Initialize the process-wide usrsctp instance. Idempotent.
- *
- * Registers the global conn_output callback (sctp_global_output) and disables
- * UDP tunneling as required by RFC 8831. usrsctp must be pumped with
- * usrsctp_handle_timers() from the caller's event loop afterwards.
- *
- * @return Always 0; later calls are no-ops.
- */
-int cwist_sctp_global_init(void) {
-    static int initialized = 0;
-    if (initialized)
-        return 0;
+/** @brief Initialise usrsctp once: no threads, no UDP encapsulation. */
+static void sctp_init_once(void) {
     usrsctp_init_nothreads(0, &sctp_global_output, NULL);
     /* RFC 8831: SCTP runs directly over DTLS, never over UDP tunneling. */
     usrsctp_sysctl_set_sctp_udp_tunneling_port(0);
-    initialized = 1;
-    return 0;
+    g_sctp_clock_ms = sctp_now_ms();
 }
 
-/**
- * @brief Read a per-channel flag bit from conn->ch_state.
- *
- * @param conn  Connection whose channel bitmap is read.
- * @param ch    DataChannel id (0..65535).
- * @param bit   Flag index within the channel's two bits (0 or 1).
- * @return The flag value, or false if no bitmap is allocated.
- */
-static bool ch_get(struct cwist_webrtc_conn *conn, uint16_t ch, int bit) {
-    if (!conn->ch_state)
-        return false;
-    size_t off = CHANNEL_BITS(ch) + (size_t)bit;
-    return (conn->ch_state[off / 8] >> (off % 8)) & 1u;
+int cwist_sctp_global_init(void) {
+    pthread_once(&g_sctp_once, sctp_init_once);
+    return 0;
 }
 
-/**
- * @brief Set a per-channel flag bit in conn->ch_state.
- *
- * @param conn  Connection whose channel bitmap is updated.
- * @param ch    DataChannel id (0..65535).
- * @param bit   Flag index within the channel's two bits (0 or 1).
- */
-static void ch_set(struct cwist_webrtc_conn *conn, uint16_t ch, int bit) {
-    if (!conn->ch_state)
-        return;
-    size_t off = CHANNEL_BITS(ch) + (size_t)bit;
-    conn->ch_state[off / 8] |= (uint8_t)(1u << (off % 8));
+void cwist_sctp_tick(void) {
+    pthread_mutex_lock(&g_sctp_lock);
+    uint64_t now = sctp_now_ms();
+    if (now > g_sctp_clock_ms) {
+        uint64_t elapsed = now - g_sctp_clock_ms;
+        g_sctp_clock_ms = now;
+        usrsctp_handle_timers((uint32_t)(elapsed > UINT32_MAX ? UINT32_MAX : elapsed));
+    }
+    pthread_mutex_unlock(&g_sctp_lock);
 }
 
-/**
- * @brief Resolve the socket that carries DataChannel traffic.
- *
- * @param conn  Connection to query.
- * @return The accepted socket on the answering side, otherwise the listening
- *         (offering) socket; may be NULL before cwist_sctp_conn_open().
- */
-static struct socket *cwist_sctp_data_sock(struct cwist_webrtc_conn *conn) {
-    return conn->sctp_acc ? conn->sctp_acc : conn->sctp_sock;
+/* ---- per-stream state: 2 bits per stream id ---- */
+
+/** @brief Read flag @p bit (0: opened by peer, 1: OPEN sent) of stream @p ch. */
+static bool ch_get(const struct cwist_webrtc_conn *conn, uint16_t ch, int bit) {
+    size_t off = (size_t)ch * 2 + (size_t)bit;
+    if (off / 8 >= conn->ch_bits_len) return false;
+    return (conn->ch_bits[off / 8] >> (off % 8)) & 1u;
 }
 
 /**
- * @brief Route one received SCTP message to the right handler.
- *
- * DCEP OPEN messages are parsed for their label (truncated to 200 bytes),
- * the channel is marked as peer-opened, a DCEP ACK is sent back, and
- * cwist_webrtc_conn_on_channel_open() is fired. A received DCEP ACK only
- * confirms the channel is ready both ways and is otherwise ignored. Binary
- * and string messages are passed to cwist_webrtc_conn_on_message().
- *
- * @param conn    Owning connection; used for callbacks and channel flags.
- * @param sid     SCTP stream id == DataChannel id.
- * @param ppid    Payload protocol identifier in host byte order (PPID_*).
- * @param msg     Message payload; not NUL-terminated.
- * @param datalen Length of @p msg in bytes.
+ * @brief Set flag @p bit of stream @p ch, growing the bitmap on demand (most
+ *        conns use a handful of low stream ids).
+ * @return 0, or -1 on allocation failure.
  */
-static void dispatch_message(struct cwist_webrtc_conn *conn, uint16_t sid, uint32_t ppid,
-                             const uint8_t *msg, size_t datalen) {
-    if (ppid == PPID_DCEP && datalen >= 1) {
-        if (msg[0] == DCEP_OPEN && datalen >= 12) {
-            uint16_t label_len = (uint16_t)((uint16_t)msg[8] << 8 | msg[9]);
-            char label[201];
-            size_t n = label_len;
-            if (n > sizeof(label) - 1)
-                n = sizeof(label) - 1;
-            if (12 + n > datalen)
-                n = datalen > 12 ? datalen - 12 : 0;
-            memcpy(label, msg + 12, n);
-            label[n] = '\0';
-            ch_set(conn, sid, 0);
-            dcep_send(conn, sid, DCEP_ACK, NULL);
-            cwist_webrtc_conn_on_channel_open(conn, sid, label);
-        }
-        /* ACK (0x02): channel ready both ways. */
-    } else if (ppid == PPID_BINARY) {
-        cwist_webrtc_conn_on_message(conn, sid, msg, datalen, 0);
-    } else if (ppid == PPID_STRING) {
-        cwist_webrtc_conn_on_message(conn, sid, msg, datalen, 1);
+static int ch_set(struct cwist_webrtc_conn *conn, uint16_t ch, int bit) {
+    size_t off = (size_t)ch * 2 + (size_t)bit;
+    if (off / 8 >= conn->ch_bits_len) {
+        uint32_t len = conn->ch_bits_len ? conn->ch_bits_len : 16;
+        while (off / 8 >= len) len *= 2;
+        uint8_t *bits = cwist_alloc(len);
+        if (!bits) return -1;
+        if (conn->ch_bits_len) memcpy(bits, conn->ch_bits, conn->ch_bits_len);
+        cwist_free(conn->ch_bits);
+        conn->ch_bits = bits;
+        conn->ch_bits_len = len;
     }
+    conn->ch_bits[off / 8] |= (uint8_t)(1u << (off % 8));
+    return 0;
 }
 
-/**
- * @brief usrsctp receive callback for the offering (connected) socket.
- *
- * Fires inside usrsctp whenever data arrives on the active side's socket;
- * hands the message to dispatch_message() and frees the usrsctp-allocated
- * buffer. The answering side instead drains with usrsctp_recvv() in
- * cwist_sctp_conn_drain(), so this callback is not installed there.
- *
- * @param sock     Ignored.
- * @param addr     Ignored.
- * @param data     usrsctp-allocated message buffer; freed here.
- * @param datalen  Length of @p data.
- * @param rcv      Receive info carrying the stream id and PPID.
- * @param flags    Ignored.
- * @param ulp_info The cwist_webrtc_conn registered at socket creation.
- * @return Always 1, telling usrsctp the data was consumed.
- */
-static int on_incoming(struct socket *sock, union sctp_sockstore addr, void *data, size_t datalen,
-                       struct sctp_rcvinfo rcv, int flags, void *ulp_info) {
-    (void)sock;
-    (void)addr;
-    (void)flags;
-    struct cwist_webrtc_conn *conn = ulp_info;
-    if (!conn || !data)
-        return 1;
-    dispatch_message(conn, rcv.rcv_sid, ntohl(rcv.rcv_ppid), (const uint8_t *)data, datalen);
-    cwist_free(data);
-    return 1;
+/** @brief The socket carrying the association (accepted socket on the server). */
+static struct socket *sctp_data_sock(const struct cwist_webrtc_conn *conn) {
+    return conn->sctp_acc ? conn->sctp_acc : conn->is_server_role ? NULL : conn->sctp_sock;
 }
 
 /**
- * @brief Send a DCEP control message (OPEN or ACK) on a DataChannel.
- *
- * Builds the message in a stack buffer (max 12-byte header + 200-byte label)
- * and sends it with PPID_DCEP on stream @p channel via the effective data
- * socket. OPEN carries a RELIABLE channel type and the given label;
- * ACK is a bare 12-byte header with no label.
- *
- * @param conn    Owning connection.
- * @param channel DataChannel id == SCTP stream id to send on.
- * @param type    DCEP message type (DCEP_OPEN or DCEP_ACK).
- * @param label   Channel label for OPEN; ignored (may be NULL) for ACK.
- * @return 0 if the message was handed to usrsctp, -1 on send failure.
+ * @brief Send a DCEP control message (OPEN with @p label, or ACK).
+ * @return 0 when sent, 1 when the send buffer is full, -1 on error.
  */
-int dcep_send(struct cwist_webrtc_conn *conn, uint16_t channel, uint8_t type, const char *label) {
+static int dcep_send(struct cwist_webrtc_conn *conn, uint16_t channel, uint8_t type,
+                     const char *label) {
     uint8_t msg[256];
     size_t len = 0;
     msg[len++] = type;
     if (type == DCEP_OPEN) {
         size_t label_len = label ? strlen(label) : 0;
-        if (label_len > 200)
-            label_len = 200;
+        if (label_len > 200) label_len = 200;
         msg[len++] = 0x00; /* DATA_CHANNEL_RELIABLE */
         msg[len++] = 0x00;
         msg[len++] = 0x00; /* priority */
@@ -243,58 +179,94 @@ int dcep_send(struct cwist_webrtc_conn *conn, uint16_t channel, uint8_t type, co
         msg[len++] = 0x00; /* protocol length 0 */
         memcpy(msg + len, label, label_len);
         len += label_len;
-    } else {
-        memset(msg + len, 0, 11); /* ACK is a 12-byte header */
-        len += 11;
     }
 
     struct sctp_sndinfo info;
     memset(&info, 0, sizeof(info));
     info.snd_sid = channel;
     info.snd_ppid = htonl(PPID_DCEP);
-    int rc = usrsctp_sendv(cwist_sctp_data_sock(conn), msg, len, NULL, 0, &info, sizeof(info),
-                           SCTP_SENDV_SNDINFO, 0);
-    return rc >= 0 ? 0 : -1;
+    ssize_t rc = usrsctp_sendv(sctp_data_sock(conn), msg, len, NULL, 0, &info, sizeof(info),
+                               SCTP_SENDV_SNDINFO, 0);
+    if (rc >= 0) return 0;
+    return (errno == EWOULDBLOCK || errno == EAGAIN) ? 1 : -1;
 }
 
-/**
- * @brief Create the per-connection SCTP socket and start (or listen for) the
- * association.
- *
- * Creates a non-blocking AF_CONN socket with SCTP_RECVRCVINFO and SCTP_NODELAY
- * enabled, allocates the channel state bitmap, registers the connection as
- * the AF_CONN address handle, and binds to CWIST_SCTP_PORT. The offering
- * side additionally connect()s to its own handle to send the INIT (EINPROGRESS
- * is treated as in-flight); the answering side listen()s for the association.
- * On any failure all partially created state is torn down.
- *
- * @param conn  Connection to set up; sctp_sock/ch_state are filled in.
- * @return 0 on success, -1 if the socket, bitmap, bind, or listen/connect
- *         step fails.
- * @warning The DTLS link must already be up; the active side's connect only
- *          starts the SCTP handshake, whose packets travel via the
- *          conn_output callback into cwist_webrtc_conn_sctp_out().
- */
-int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn) {
-    conn->sctp_sock = usrsctp_socket(AF_CONN, SOCK_STREAM, IPPROTO_SCTP,
-                                     conn->is_server_role ? NULL : &on_incoming, NULL, 0,
-                                     conn->is_server_role ? NULL : conn);
-    if (!conn->sctp_sock) {
-        return -1;
+/** @brief Route one complete inbound message: DCEP control or user data. */
+static void dispatch_message(struct cwist_webrtc_conn *conn, uint16_t sid, uint32_t ppid,
+                             const uint8_t *msg, size_t datalen) {
+    if (ppid == PPID_DCEP && datalen >= 1) {
+        if (msg[0] == DCEP_OPEN && datalen >= 12) {
+            uint16_t label_len = (uint16_t)((uint16_t)msg[8] << 8 | msg[9]);
+            char label[201];
+            size_t n = label_len;
+            if (n > sizeof(label) - 1) n = sizeof(label) - 1;
+            if (12 + n > datalen) n = datalen - 12;
+            memcpy(label, msg + 12, n);
+            label[n] = '\0';
+            ch_set(conn, sid, 0);
+            dcep_send(conn, sid, DCEP_ACK, NULL);
+            cwist_webrtc_conn_on_channel_open(conn, sid, label);
+        }
+        /* ACK (0x02): the channel we opened is confirmed; nothing to do. */
+    } else if (ppid == PPID_BINARY) {
+        cwist_webrtc_conn_on_message(conn, sid, msg, datalen, 0);
+    } else if (ppid == PPID_STRING) {
+        cwist_webrtc_conn_on_message(conn, sid, msg, datalen, 1);
+    } else if (ppid == PPID_BINARY_EMPTY || ppid == PPID_STRING_EMPTY) {
+        /* SCTP cannot carry a zero-length message: the 0x00 is padding. */
+        cwist_webrtc_conn_on_message(conn, sid, msg, 0, ppid == PPID_STRING_EMPTY);
     }
-    usrsctp_set_non_blocking(conn->sctp_sock, 1);
+}
 
+/** @brief Socket options shared by the listening, connecting and accepted sockets. */
+static void sctp_apply_sockopts(struct socket *sock) {
     int on = 1;
-    usrsctp_setsockopt(conn->sctp_sock, IPPROTO_SCTP, SCTP_RECVRCVINFO, &on, sizeof(on));
-    usrsctp_setsockopt(conn->sctp_sock, IPPROTO_SCTP, SCTP_NODELAY, &on, sizeof(on));
+    usrsctp_set_non_blocking(sock, 1);
+    usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_RECVRCVINFO, &on, sizeof(on));
+    usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_NODELAY, &on, sizeof(on));
 
-    conn->ch_state = cwist_malloc(CH_STATE_BYTES);
-    if (!conn->ch_state) {
-        usrsctp_close(conn->sctp_sock);
-        conn->sctp_sock = NULL;
-        return -1;
-    }
+    /* close() aborts instead of lingering in SHUTDOWN: no timer can fire for
+     * the association after its conn is gone. */
+    struct linger lg = {.l_onoff = 1, .l_linger = 0};
+    usrsctp_setsockopt(sock, SOL_SOCKET, SO_LINGER, &lg, sizeof(lg));
+
+    int buf = CWIST_SCTP_SOCKBUF;
+    usrsctp_setsockopt(sock, SOL_SOCKET, SO_SNDBUF, &buf, sizeof(buf));
+    usrsctp_setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &buf, sizeof(buf));
+
+    struct sctp_initmsg init;
+    memset(&init, 0, sizeof(init));
+    init.sinit_num_ostreams = CWIST_SCTP_STREAMS;
+    init.sinit_max_instreams = CWIST_SCTP_STREAMS;
+    usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_INITMSG, &init, sizeof(init));
+
+    /* Fixed path MTU: the DTLS link MTU is known and PMTU probes would only
+     * get lost inside the tunnel. */
+    struct sctp_paddrparams pp;
+    memset(&pp, 0, sizeof(pp));
+    pp.spp_assoc_id = SCTP_FUTURE_ASSOC;
+    pp.spp_flags = SPP_PMTUD_DISABLE | SPP_HB_ENABLE;
+    pp.spp_pathmtu = CWIST_SCTP_MTU;
+    /* Heartbeats keep both directions talking well inside the 30 s liveness
+     * window even when the peer sends no ICE consent checks. */
+    pp.spp_hbinterval = CWIST_SCTP_HEARTBEAT_MS;
+    usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_PEER_ADDR_PARAMS, &pp, sizeof(pp));
+
+    /* Association loss (abort, shutdown) arrives as a notification so the
+     * conn is closed right away instead of waiting for the liveness timer. */
+    struct sctp_event ev;
+    memset(&ev, 0, sizeof(ev));
+    ev.se_assoc_id = SCTP_ALL_ASSOC;
+    ev.se_on = 1;
+    ev.se_type = SCTP_ASSOC_CHANGE;
+    usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_EVENT, &ev, sizeof(ev));
+}
 
+int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn) {
+    cwist_sctp_global_init();
+    conn->sctp_sock = usrsctp_socket(AF_CONN, SOCK_STREAM, IPPROTO_SCTP, NULL, NULL, 0, NULL);
+    if (!conn->sctp_sock) return -1;
+    sctp_apply_sockopts(conn->sctp_sock);
     usrsctp_register_address(conn);
 
     struct sockaddr_conn local;
@@ -302,58 +274,28 @@ int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn) {
     local.sconn_family = AF_CONN;
     local.sconn_port = htons(CWIST_SCTP_PORT);
     local.sconn_addr = conn;
-    if (usrsctp_bind(conn->sctp_sock, (struct sockaddr *)&local, sizeof(local)) < 0) {
-        usrsctp_deregister_address(conn);
-        cwist_free(conn->ch_state);
-        conn->ch_state = NULL;
-        usrsctp_close(conn->sctp_sock);
-        conn->sctp_sock = NULL;
-        return -1;
-    }
+    if (usrsctp_bind(conn->sctp_sock, (struct sockaddr *)&local, sizeof(local)) < 0) goto fail;
 
     if (conn->is_server_role) {
         /* Passive side must accept the incoming SCTP association. */
-        if (usrsctp_listen(conn->sctp_sock, 1) < 0) {
-            usrsctp_deregister_address(conn);
-            cwist_free(conn->ch_state);
-            conn->ch_state = NULL;
-            usrsctp_close(conn->sctp_sock);
-            conn->sctp_sock = NULL;
-            return -1;
-        }
+        if (usrsctp_listen(conn->sctp_sock, 1) < 0) goto fail;
     } else {
-        /* Active side initiates the SCTP association (sends the INIT).
-         * The remote handle is our own: the answering endpoint's replies
-         * are routed back through the handle recorded from its conninput(),
-         * i.e. its own pointer (see ekr_loop in the usrsctp tree). */
-        struct sockaddr_conn remote;
-        memset(&remote, 0, sizeof(remote));
-        remote.sconn_family = AF_CONN;
-        remote.sconn_port = htons(CWIST_SCTP_PORT);
-        remote.sconn_addr = conn;
+        /* Active side initiates the SCTP association (sends the INIT). */
+        struct sockaddr_conn remote = local;
         if (usrsctp_connect(conn->sctp_sock, (struct sockaddr *)&remote, sizeof(remote)) < 0 &&
-            errno != EINPROGRESS) {
-            usrsctp_deregister_address(conn);
-            cwist_free(conn->ch_state);
-            conn->ch_state = NULL;
-            usrsctp_close(conn->sctp_sock);
-            conn->sctp_sock = NULL;
-            return -1;
-        }
+            errno != EINPROGRESS)
+            goto fail;
     }
     return 0;
+
+fail:
+    cwist_sctp_conn_close(conn);
+    return -1;
 }
 
-/**
- * @brief Tear down all per-connection SCTP state.
- *
- * Closes the accepted socket (if any) and the main socket, then deregisters
- * the AF_CONN handle and frees the channel state bitmap. Safe to call on a
- * connection that was never opened or already closed.
- *
- * @param conn  Connection whose SCTP state is destroyed.
- */
 void cwist_sctp_conn_close(struct cwist_webrtc_conn *conn) {
+    if (!conn->sctp_sock && !conn->sctp_acc) return;
+    pthread_mutex_lock(&g_sctp_lock);
     if (conn->sctp_acc) {
         usrsctp_close(conn->sctp_acc);
         conn->sctp_acc = NULL;
@@ -362,93 +304,101 @@ void cwist_sctp_conn_close(struct cwist_webrtc_conn *conn) {
         usrsctp_close(conn->sctp_sock);
         conn->sctp_sock = NULL;
     }
-    if (conn->ch_state) {
-        usrsctp_deregister_address(conn);
-        cwist_free(conn->ch_state);
-        conn->ch_state = NULL;
-    }
+    usrsctp_deregister_address(conn);
+    pthread_mutex_unlock(&g_sctp_lock);
+    cwist_free(conn->ch_bits);
+    conn->ch_bits = NULL;
+    conn->ch_bits_len = 0;
+    cwist_free(conn->rx_partial);
+    conn->rx_partial = NULL;
+    conn->rx_partial_len = conn->rx_partial_cap = 0;
 }
 
-/**
- * @brief Feed a DTLS-decrypted SCTP packet into usrsctp.
- *
- * Injects the packet into the connection's association; any immediate
- * replies are routed back through sctp_global_output(). A no-op if the
- * socket is not open yet.
- *
- * @param conn  Owning connection (registered AF_CONN handle).
- * @param data  SCTP packet bytes as received over DTLS.
- * @param len   Length of @p data.
- */
 void cwist_sctp_conn_input(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len) {
-    if (conn->sctp_sock)
-        usrsctp_conninput(conn, data, len, 0);
+    if (conn->sctp_sock || conn->sctp_acc) usrsctp_conninput(conn, data, len, 0);
 }
 
 /**
- * @brief Accept the association (answering side) and drain inbound messages.
- *
- * On the passive side, accepts the pending association once and enables
- * non-blocking mode plus SCTP_RECVRCVINFO on the accepted socket. Then reads
- * all queued messages from the effective data socket into a static 1 MiB
- * buffer, skipping notifications and datagrams without complete rcvinfo, and
- * dispatches each message. Runs from the event loop; never blocks.
- *
- * @param conn  Connection to service.
- * @note The receive buffer is a shared static, so this must not be called
- *       concurrently from multiple threads.
+ * @brief Append a piece of a partially delivered message.
+ * @return 0, or -1 if the message would exceed the advertised maximum.
  */
-void cwist_sctp_conn_drain(struct cwist_webrtc_conn *conn) {
-    if (!conn->sctp_sock)
-        return;
+static int rx_partial_append(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len) {
+    size_t need = conn->rx_partial_len + len;
+    if (need > CWIST_WEBRTC_MAX_MESSAGE) return -1;
+    if (need > conn->rx_partial_cap) {
+        size_t cap = conn->rx_partial_cap ? conn->rx_partial_cap : 65536;
+        while (cap < need) cap *= 2;
+        uint8_t *buf = cwist_alloc(cap);
+        if (!buf) return -1;
+        if (conn->rx_partial_len) memcpy(buf, conn->rx_partial, conn->rx_partial_len);
+        cwist_free(conn->rx_partial);
+        conn->rx_partial = buf;
+        conn->rx_partial_cap = cap;
+    }
+    memcpy(conn->rx_partial + conn->rx_partial_len, data, len);
+    conn->rx_partial_len = need;
+    return 0;
+}
+
+int cwist_sctp_conn_drain(struct cwist_webrtc_conn *conn) {
+    if (!conn->sctp_sock && !conn->sctp_acc) return 0;
     if (conn->is_server_role && !conn->sctp_acc) {
         conn->sctp_acc = usrsctp_accept(conn->sctp_sock, NULL, NULL);
-        if (conn->sctp_acc) {
-            int on = 1;
-            usrsctp_set_non_blocking(conn->sctp_acc, 1);
-            usrsctp_setsockopt(conn->sctp_acc, IPPROTO_SCTP, SCTP_RECVRCVINFO, &on,
-                               sizeof(on));
-        }
+        if (!conn->sctp_acc) return 0;
+        sctp_apply_sockopts(conn->sctp_acc);
+        /* One association per conn: the listener has done its job. */
+        pthread_mutex_lock(&g_sctp_lock);
+        usrsctp_close(conn->sctp_sock);
+        conn->sctp_sock = NULL;
+        pthread_mutex_unlock(&g_sctp_lock);
     }
-    struct socket *sock = cwist_sctp_data_sock(conn);
-    if (!sock)
-        return;
-    static uint8_t buf[1 << 20];
+    struct socket *sock = sctp_data_sock(conn);
+    if (!sock) return 0;
+    uint8_t *buf = conn->ctx->sctp_buf;
     for (;;) {
         struct sctp_rcvinfo rcv;
         socklen_t infolen = sizeof(rcv);
-        unsigned int infotype = SCTP_RECVV_RCVINFO;
+        unsigned int infotype = 0;
         int msg_flags = 0;
         struct sockaddr_conn from;
         socklen_t fromlen = sizeof(from);
-        ssize_t n = usrsctp_recvv(sock, buf, sizeof(buf), (struct sockaddr *)&from, &fromlen,
-                                  &rcv, &infolen, &infotype, &msg_flags);
-        (void)infotype;
-        if (n <= 0)
-            break;
-        if (msg_flags & MSG_NOTIFICATION)
+        ssize_t n = usrsctp_recvv(sock, buf, CWIST_WEBRTC_MAX_MESSAGE, (struct sockaddr *)&from,
+                                  &fromlen, &rcv, &infolen, &infotype, &msg_flags);
+        if (n < 0) return (errno == EWOULDBLOCK || errno == EAGAIN) ? 0 : -1;
+        if (n == 0) return -1; /* peer shut the association down */
+        if (msg_flags & MSG_NOTIFICATION) {
+            const union sctp_notification *note = (const union sctp_notification *)buf;
+            if ((size_t)n >= sizeof(note->sn_header) &&
+                note->sn_header.sn_type == SCTP_ASSOC_CHANGE &&
+                (size_t)n >= sizeof(note->sn_assoc_change)) {
+                uint16_t state = note->sn_assoc_change.sac_state;
+                if (state == SCTP_COMM_LOST || state == SCTP_SHUTDOWN_COMP ||
+                    state == SCTP_CANT_STR_ASSOC)
+                    return -1;
+            }
             continue;
-        if ((int)infolen < (int)sizeof(rcv))
+        }
+        if (infotype != SCTP_RECVV_RCVINFO || infolen < (socklen_t)sizeof(rcv)) continue;
+        if (!(msg_flags & MSG_EOR)) {
+            /* Partial delivery: keep the piece until the end of the message. */
+            if (rx_partial_append(conn, buf, (size_t)n) < 0) return -1;
             continue;
-        dispatch_message(conn, rcv.rcv_sid, ntohl(rcv.rcv_ppid), buf, (size_t)n);
+        }
+        if (conn->rx_partial_len) {
+            if (rx_partial_append(conn, buf, (size_t)n) < 0) return -1;
+            size_t total = conn->rx_partial_len;
+            conn->rx_partial_len = 0;
+            dispatch_message(conn, rcv.rcv_sid, ntohl(rcv.rcv_ppid), conn->rx_partial, total);
+        } else {
+            dispatch_message(conn, rcv.rcv_sid, ntohl(rcv.rcv_ppid), buf, (size_t)n);
+        }
+        if (conn->close_requested || atomic_load(&conn->detached)) return 0;
     }
 }
 
-/**
- * @brief Check whether the SCTP association is fully established.
- *
- * On the answering side the association is up once the accepted socket
- * exists; on the offering side the SCTP_STATUS socket option must report
- * SCTP_ESTABLISHED.
- *
- * @param conn  Connection to query.
- * @return true if DataChannel traffic can flow, false otherwise.
- */
 bool cwist_sctp_assoc_established(struct cwist_webrtc_conn *conn) {
-    if (!conn->sctp_sock)
-        return false;
-    if (conn->is_server_role)
-        return conn->sctp_acc != NULL;
+    if (conn->is_server_role) return conn->sctp_acc != NULL;
+    if (!conn->sctp_sock) return false;
     struct sctp_status status;
     socklen_t slen = sizeof(status);
     memset(&status, 0, sizeof(status));
@@ -457,40 +407,33 @@ bool cwist_sctp_assoc_established(struct cwist_webrtc_conn *conn) {
     return status.sstat_state == SCTP_ESTABLISHED;
 }
 
-/**
- * @brief Send a DataChannel message, opening the channel first if needed.
- *
- * Fails unless the association's data socket exists and conn->sctp_ready is
- * set. If neither channel flag is set (channel never announced in either
- * direction), a DCEP OPEN with an auto-generated "dc" label is sent first
- * and the open_sent bit is recorded so the OPEN is sent only once. The
- * payload goes out with PPID_STRING or PPID_BINARY on the channel's stream.
- *
- * @param conn      Owning connection.
- * @param channel   DataChannel id == SCTP stream id.
- * @param data      Message payload; may be NULL when @p len is 0.
- * @param len       Payload length in bytes.
- * @param is_string Non-zero to send as a UTF-8 string message.
- * @return 0 if the message was handed to usrsctp, -1 if the link is not
- *         ready or sending failed.
- */
 int cwist_sctp_send(struct cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
                     size_t len, int is_string) {
-    if (!cwist_sctp_data_sock(conn) || !conn->sctp_ready)
-        return -1;
+    struct socket *sock = sctp_data_sock(conn);
+    if (!sock || !conn->sctp_ready || channel >= CWIST_SCTP_STREAMS) return -1;
     if (!ch_get(conn, channel, 0) && !ch_get(conn, channel, 1)) {
         /* Brand-new channel opened by us: announce it with DCEP OPEN. */
         char label[32];
         snprintf(label, sizeof(label), "dc%u", channel);
-        if (dcep_send(conn, channel, DCEP_OPEN, label) < 0)
-            return -1;
-        ch_set(conn, channel, 1);
+        int rc = dcep_send(conn, channel, DCEP_OPEN, label);
+        if (rc != 0) return rc;
+        if (ch_set(conn, channel, 1) < 0) return -1;
     }
     struct sctp_sndinfo info;
     memset(&info, 0, sizeof(info));
     info.snd_sid = channel;
-    info.snd_ppid = htonl(is_string ? PPID_STRING : PPID_BINARY);
-    int rc = usrsctp_sendv(cwist_sctp_data_sock(conn), data, len, NULL, 0, &info, sizeof(info),
-                           SCTP_SENDV_SNDINFO, 0);
-    return rc >= 0 ? 0 : -1;
+    static const uint8_t empty_pad = 0;
+    if (len == 0) {
+        /* RFC 8831 section 6.6: an empty message is one byte with an
+         * "empty" PPID. */
+        info.snd_ppid = htonl(is_string ? PPID_STRING_EMPTY : PPID_BINARY_EMPTY);
+        data = &empty_pad;
+        len = 1;
+    } else {
+        info.snd_ppid = htonl(is_string ? PPID_STRING : PPID_BINARY);
+    }
+    ssize_t rc =
+        usrsctp_sendv(sock, data, len, NULL, 0, &info, sizeof(info), SCTP_SENDV_SNDINFO, 0);
+    if (rc >= 0) return 0;
+    return (errno == EWOULDBLOCK || errno == EAGAIN) ? 1 : -1;
 }
diff --git a/src/net/webrtc/webrtc.c b/src/net/webrtc/webrtc.c
index 9bc1a22b5..6fac3fdcd 100644
--- a/src/net/webrtc/webrtc.c
+++ b/src/net/webrtc/webrtc.c
@@ -1,9 +1,23 @@
 /** @file webrtc.c
- * @brief WebRTC DataChannel server: ctx, connection state machine, UDP loop.
+ * @brief WebRTC DataChannel context: UDP I/O, ICE-lite, DTLS, SCTP driving.
  *
- * One background thread per ctx drives ICE-lite, the DTLS handshake
- * (BoringSSL over memory BIOs), and SCTP-over-DTLS via usrsctp. Connections
- * are keyed by the nominated remote UDP address.
+ * A ctx is one UDP socket served by one cwist reactor (see webrtc_internal.h
+ * for the threading and lifetime rules).  Work happens only in reactor
+ * callbacks, and every callback follows the same shape:
+ *
+ *   ctx_enter()  - mark this thread as servicing the ctx, cache the time
+ *   ...          - handle datagrams / a timer / a posted request
+ *   ctx_leave()  - service conns touched this round (drain SCTP, flush
+ *                  queued sends, apply closes), then send every queued
+ *                  datagram in one sendmmsg()
+ *
+ * Datagrams are read in batches with recvmmsg().  A DTLS record is decrypted
+ * and fed to usrsctp right away, but draining SCTP and flushing sends waits
+ * until the batch is done, so a burst of packets for one conn costs one drain.
+ * Nothing runs on a fixed tick: each conn has one timer (STUN retransmit,
+ * DTLS retransmit, liveness or park expiry, whichever applies) and the ctx
+ * drives usrsctp's clock only while it holds associations, every 10 ms
+ * after recent traffic and every 250 ms otherwise.
  */
 #include "webrtc_internal.h"
 
@@ -14,911 +28,1214 @@
 #include 
 #include 
 #include 
-#include 
-#include 
 #include 
 #include 
+#include 
 #include 
-#include 
-
-/** @def STUN_RETRANS_MS
- * @brief STUN binding request retransmission interval in milliseconds. */
-#define STUN_RETRANS_MS 300
-/** @def STUN_MAX_ATTEMPTS
- * @brief Maximum STUN binding request transmissions before giving up. */
-#define STUN_MAX_ATTEMPTS 7
-/** @def SCTP_TIMER_MS
- * @brief Event-loop poll timeout / SCTP timer tick in milliseconds. */
-#define SCTP_TIMER_MS 10
-
-/** @struct pending_send
- * @brief Outbound message queued while the SCTP association is not yet ready.
- *
- * Singly linked queue node holding a copy of the payload so the caller of
- * cwist_webrtc_conn_send() can reuse or free its buffer immediately.
- */
-/** @var pending_send::next
- * @brief Next node in the queue, or NULL if this is the tail. */
-/** @var pending_send::channel
- * @brief SCTP stream id (DataChannel id) to send on. */
-/** @var pending_send::is_string
- * @brief Non-zero if the payload is a DataChannel string message, zero for binary. */
-/** @var pending_send::len
- * @brief Payload length in bytes. */
-/** @var pending_send::data
- * @brief Inline payload bytes; allocated with room for @c len bytes after the header. */
-typedef struct pending_send {
-    struct pending_send *next;
-    uint16_t channel;
-    int is_string;
-    size_t len;
-    uint8_t data[];
-} pending_send;
-
-/* ---- cross-TU helper implementations used by sctp.c / ice.c ---- */
-
-/** @fn cwist_webrtc_conn_on_message
- * @brief Dispatch an inbound DataChannel message to the ctx message callback.
- * @param conn  Connection the message arrived on.
- * @param channel  SCTP stream id carrying the message.
- * @param data  Message payload (not NUL-terminated).
- * @param len  Payload length in bytes.
- * @param is_string  Whether the payload is a string message (currently unused).
- *
- * Runs on the ctx event-loop thread.
- */
-void cwist_webrtc_conn_on_message(cwist_webrtc_conn *conn, uint16_t channel,
-                                  const uint8_t *data, size_t len, int is_string) {
-    cwist_webrtc_ctx *ctx = conn->ctx;
-    if (ctx->msg_cb)
-        ctx->msg_cb(conn, channel, data, len, ctx->msg_user);
-    (void)is_string;
+
+/** @name Timing */
+/**@{*/
+#define STUN_RTO_INITIAL_MS 300     /**< First Binding retransmit (client role). */
+#define STUN_RTO_MAX_MS 3000        /**< Retransmit backoff cap. */
+#define STUN_MAX_ATTEMPTS 7         /**< Binding requests before giving up. */
+#define PARK_TIMEOUT_MS 30000       /**< Answered offer with no nomination. */
+#define HANDSHAKE_TIMEOUT_MS 30000  /**< ICE + DTLS must finish within this. */
+#define LIVENESS_TIMEOUT_MS 30000   /**< Silence before an established conn is dropped. */
+#define SCTP_TICK_ACTIVE_MS 10      /**< usrsctp clock step after recent traffic. */
+#define SCTP_TICK_IDLE_MS 250       /**< usrsctp clock step when quiet. */
+#define SCTP_ACTIVE_WINDOW_MS 1000  /**< "Recent" for the tick choice. */
+/**@}*/
+
+/** Most datagrams read in one readiness callback before yielding. */
+#define RX_BUDGET 256
+/** Socket buffer requested for the UDP socket (the kernel may clamp it). */
+#define UDP_SOCKBUF (4 << 20)
+
+/** The ctx this thread is servicing right now, if any. */
+static _Thread_local cwist_webrtc_ctx *tl_ctx;
+
+static void ctx_release(cwist_webrtc_ctx *ctx);
+static void ctx_release_resources(cwist_webrtc_ctx *ctx);
+static void conn_teardown(struct cwist_webrtc_conn *conn);
+static void conn_rearm_timer(struct cwist_webrtc_conn *conn);
+static void ctx_arm_sctp_timer(cwist_webrtc_ctx *ctx);
+static void ctx_note_activity(cwist_webrtc_ctx *ctx);
+static void ctx_teardown_owner(cwist_webrtc_ctx *ctx);
+static void conn_timer_cb(void *arg);
+
+/* ---- time ---- */
+
+/** @brief CLOCK_MONOTONIC in nanoseconds. */
+static uint64_t mono_ns(void) {
+    struct timespec ts;
+    clock_gettime(CLOCK_MONOTONIC, &ts);
+    return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
 }
 
-/** @fn cwist_webrtc_conn_on_channel_open
- * @brief Notify the application that a DataChannel was opened.
- * @param conn  Connection owning the channel.
- * @param channel  Id of the opened SCTP stream / DataChannel.
- * @param label  DataChannel label string.
- *
- * Runs on the ctx event-loop thread.
- */
-void cwist_webrtc_conn_on_channel_open(cwist_webrtc_conn *conn, uint16_t channel,
-                                       const char *label) {
+/* ---- references ---- */
+
+/** @brief Take a ctx reference. */
+static void ctx_retain(cwist_webrtc_ctx *ctx) {
+    atomic_fetch_add_explicit(&ctx->refs, 1, memory_order_relaxed);
+}
+
+void cwist_webrtc_conn_retain(cwist_webrtc_conn *conn) {
+    if (conn) atomic_fetch_add_explicit(&conn->refs, 1, memory_order_relaxed);
+}
+
+void cwist_webrtc_conn_release(cwist_webrtc_conn *conn) {
+    if (!conn || atomic_fetch_sub_explicit(&conn->refs, 1, memory_order_acq_rel) != 1) return;
+    /* Last reference: teardown already released everything but the memory,
+     * unless the conn never got registered (post dropped at ctx teardown). */
     cwist_webrtc_ctx *ctx = conn->ctx;
-    if (ctx->ch_cb)
-        ctx->ch_cb(conn, channel, label, ctx->ch_user);
+    if (conn->ssl) SSL_free(conn->ssl);
+    cwist_free(conn->ch_bits);
+    cwist_free(conn->rx_partial);
+    cwist_free(conn);
+    ctx_release(ctx);
 }
 
-/** @fn cwist_webrtc_conn_sctp_out
- * @brief Feed SCTP output bytes into DTLS.
- * @param conn  Connection whose SCTP stack produced output.
- * @param buffer  SCTP packet bytes.
- * @param len  Number of bytes in @p buffer.
- *
- * Writes the record into the SSL object's write BIO; the sender is
- * responsible for flushing the wbio afterwards. No-op until the SSL object
- * exists.
- */
-void cwist_webrtc_conn_sctp_out(struct cwist_webrtc_conn *conn, const void *buffer, size_t len) {
-    if (!conn->ssl)
-        return;
-    SSL_write(conn->ssl, buffer, (int)len);
+/* ---- owner-thread bracketing ---- */
+
+bool cwist_webrtc_on_owner(const cwist_webrtc_ctx *ctx) {
+    return tl_ctx == ctx;
 }
 
-/** @fn cwist_webrtc_conn_send_raw
- * @brief Send a raw UDP datagram to the connection's remote address.
- * @param conn  Destination connection.
- * @param data  Datagram bytes.
- * @param len  Datagram length.
- * @return  Bytes sent, or -1 on error (see @c errno), as from sendto(2).
- */
-int cwist_webrtc_conn_send_raw(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len) {
-    return sendto(conn->ctx->udp_fd, data, len, 0, (struct sockaddr *)&conn->remote,
-                  conn->remote_len);
-}
-
-/** @fn cwist_webrtc_ctx_find_conn
- * @brief Look up a connection by its nominated remote IPv4 address.
- * @param ctx  Context to search.
- * @param remote  Remote address (address and port must match exactly).
- * @return  Matching connection, or NULL if none.
- * @note  Iterates without holding ctx->lock; safe only from the event-loop
- *        thread or other lock-free call sites in this file.
+/**
+ * @brief Enter a reactor callback for @p ctx.  The outermost entry holds a
+ *        ctx reference, so a handler that frees the ctx cannot pull memory
+ *        out from under the callback still running.
+ * @return The previously serviced ctx (restore it with ctx_leave()).
  */
-struct cwist_webrtc_conn *cwist_webrtc_ctx_find_conn(cwist_webrtc_ctx *ctx,
-                                                     const struct sockaddr_in *remote) {
-    for (struct cwist_webrtc_conn *c = ctx->conns; c; c = c->next) {
-        if (c->remote.sin_addr.s_addr == remote->sin_addr.s_addr &&
-            c->remote.sin_port == remote->sin_port)
-            return c;
+static cwist_webrtc_ctx *ctx_enter(cwist_webrtc_ctx *ctx) {
+    cwist_webrtc_ctx *prev = tl_ctx;
+    if (prev != ctx) {
+        ctx_retain(ctx);
+        tl_ctx = ctx;
+        ctx->now_ns = mono_ns();
     }
-    return NULL;
+    return prev;
 }
 
-/** @fn cwist_webrtc_ctx_add_conn
- * @brief Insert a connection at the head of the ctx connection list.
- * @param ctx  Context owning the list.
- * @param conn  Connection to insert; takes ownership.
- *
- * Takes ctx->lock around the list mutation.
- */
-void cwist_webrtc_ctx_add_conn(cwist_webrtc_ctx *ctx, struct cwist_webrtc_conn *conn) {
-    pthread_mutex_lock(&ctx->lock);
-    conn->next = ctx->conns;
-    ctx->conns = conn;
-    pthread_mutex_unlock(&ctx->lock);
+/** @brief Send every queued datagram (sendmmsg on Linux). */
+static void ctx_tx_flush(cwist_webrtc_ctx *ctx) {
+    uint32_t n = ctx->tx_n;
+    ctx->tx_n = 0;
+    if (n == 0 || ctx->udp_fd < 0) return;
+#ifdef __linux__
+    struct mmsghdr msgs[CWIST_WEBRTC_TX_BATCH];
+    struct iovec iov[CWIST_WEBRTC_TX_BATCH];
+    for (uint32_t i = 0; i < n; i++) {
+        iov[i].iov_base = ctx->tx_bufs + (size_t)i * CWIST_WEBRTC_DGRAM_MAX;
+        iov[i].iov_len = ctx->tx_len[i];
+        memset(&msgs[i].msg_hdr, 0, sizeof(msgs[i].msg_hdr));
+        msgs[i].msg_hdr.msg_name = &ctx->tx_addr[i];
+        msgs[i].msg_hdr.msg_namelen = sizeof(ctx->tx_addr[i]);
+        msgs[i].msg_hdr.msg_iov = &iov[i];
+        msgs[i].msg_hdr.msg_iovlen = 1;
+    }
+    uint32_t off = 0;
+    while (off < n) {
+        int sent = sendmmsg(ctx->udp_fd, msgs + off, n - off, MSG_DONTWAIT);
+        if (sent > 0) {
+            off += (uint32_t)sent;
+            continue;
+        }
+        if (sent < 0 && errno == EINTR) continue;
+        /* EAGAIN/ENOBUFS or a per-destination error: UDP is lossy anyway and
+         * DTLS/SCTP retransmit, so skip this datagram and keep going. */
+        off++;
+    }
+#else
+    for (uint32_t i = 0; i < n; i++) {
+        sendto(ctx->udp_fd, ctx->tx_bufs + (size_t)i * CWIST_WEBRTC_DGRAM_MAX, ctx->tx_len[i], 0,
+               (const struct sockaddr *)&ctx->tx_addr[i], sizeof(ctx->tx_addr[i]));
+    }
+#endif
 }
 
-/** @fn cwist_webrtc_conn_wake
- * @brief Write one byte to the ctx wake pipe to interrupt poll().
- * @param ctx  Context whose event loop should wake.
- *
- * Best-effort and async-signal-style safe: the pipe is O_NONBLOCK, so a
- * full pipe is silently ignored.
- */
-void cwist_webrtc_conn_wake(cwist_webrtc_ctx *ctx) {
-    uint8_t b = 1;
-    ssize_t rc = write(ctx->wake_pipe[1], &b, 1);
-    (void)rc;
+/** @brief Queue one datagram for the end-of-round flush (owner thread). */
+static void ctx_tx_queue(cwist_webrtc_ctx *ctx, const uint8_t *data, size_t len,
+                         const struct sockaddr_in *to) {
+    if (len > CWIST_WEBRTC_DGRAM_MAX || ctx->closed) return;
+    if (ctx->tx_n == CWIST_WEBRTC_TX_BATCH) ctx_tx_flush(ctx);
+    uint32_t i = ctx->tx_n++;
+    memcpy(ctx->tx_bufs + (size_t)i * CWIST_WEBRTC_DGRAM_MAX, data, len);
+    ctx->tx_len[i] = len;
+    ctx->tx_addr[i] = *to;
 }
 
-/** @fn cwist_webrtc_detect_host_ip
- * @brief Pick a best-effort local IPv4 address for host candidates.
- * @param out  Output buffer for a dotted-quad string.
- * @param cap  Capacity of @p out.
- *
- * Writes "127.0.0.1" first, then replaces it with the first non-loopback
- * IPv4 interface address found, if any.
- */
-static void cwist_webrtc_detect_host_ip(char *out, size_t cap) {
-    snprintf(out, cap, "127.0.0.1");
-    struct ifaddrs *ifas = NULL;
-    if (getifaddrs(&ifas) < 0)
-        return;
-    for (struct ifaddrs *ifa = ifas; ifa; ifa = ifa->ifa_next) {
-        if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET)
-            continue;
-        struct sockaddr_in *sin = (struct sockaddr_in *)ifa->ifa_addr;
-        uint32_t a = ntohl(sin->sin_addr.s_addr);
-        if (((a >> 24) & 0xFF) == 127)
-            continue;
-        snprintf(out, cap, "%u.%u.%u.%u", (a >> 24) & 0xFF, (a >> 16) & 0xFF, (a >> 8) & 0xFF,
-                 a & 0xFF);
-        break;
+/** @brief Put @p conn on the end-of-round service list (holds a reference). */
+static void conn_mark_dirty(struct cwist_webrtc_conn *conn) {
+    if (conn->dirty) return;
+    conn->dirty = true;
+    cwist_webrtc_conn_retain(conn);
+    conn->dirty_next = conn->ctx->dirty;
+    conn->ctx->dirty = conn;
+}
+
+/** @brief Hand queued sends to SCTP until it pushes back. */
+static void conn_flush_pending(struct cwist_webrtc_conn *conn) {
+    while (conn->pending_head && conn->sctp_ready) {
+        cwist_webrtc_msg *m = conn->pending_head;
+        int rc = cwist_sctp_send(conn, m->channel, m->data, m->len, m->is_string);
+        if (rc == 1) break; /* send buffer full: retried after the next inbound packet */
+        conn->pending_head = m->next;
+        if (!conn->pending_head) conn->pending_tail = NULL;
+        atomic_fetch_sub_explicit(&conn->buffered, m->len, memory_order_relaxed);
+        cwist_free(m);
     }
-    freeifaddrs(ifas);
 }
 
-/* ---- time helpers ---- */
+/** @brief End-of-round work for one conn: SCTP drain, sends, close. */
+static void conn_service(struct cwist_webrtc_conn *conn) {
+    if (atomic_load(&conn->detached)) return;
+    if (conn->state == CWIST_CONN_ESTABLISHED && !conn->close_requested) {
+        if (cwist_sctp_conn_drain(conn) < 0) conn->close_requested = true;
+        if (!conn->close_requested && !conn->sctp_ready && cwist_sctp_assoc_established(conn)) {
+            conn->sctp_ready = true;
+            conn->notified_open = true;
+            atomic_fetch_add(&conn->ctx->ready_count, 1);
+        }
+        if (!conn->close_requested) conn_flush_pending(conn);
+    }
+    if (conn->close_requested) conn_teardown(conn);
+}
 
-/** @fn now_ts
- * @brief Fill @p ts with the current monotonic clock.
- * @param ts  Output timespec.
- */
-static void now_ts(struct timespec *ts) {
-    clock_gettime(CLOCK_MONOTONIC, ts);
+/** @brief Leave a reactor callback: service dirty conns, flush datagrams. */
+static void ctx_leave(cwist_webrtc_ctx *ctx, cwist_webrtc_ctx *prev) {
+    if (prev != ctx) {
+        /* Servicing can dirty more conns (a message handler sending on
+         * another conn), so loop until the list stays empty. */
+        while (ctx->dirty) {
+            struct cwist_webrtc_conn *list = ctx->dirty;
+            ctx->dirty = NULL;
+            while (list) {
+                struct cwist_webrtc_conn *next = list->dirty_next;
+                list->dirty = false;
+                conn_service(list);
+                cwist_webrtc_conn_release(list);
+                list = next;
+            }
+        }
+        ctx_tx_flush(ctx);
+        tl_ctx = prev;
+        ctx_release(ctx);
+    }
 }
 
-/** @fn ms_until
- * @brief Milliseconds from now until @p ts.
- * @param ts  Target timestamp (monotonic clock).
- * @return  Signed millisecond delta; negative if @p ts is in the past.
- */
-static long ms_until(const struct timespec *ts) {
-    struct timespec now;
-    now_ts(&now);
-    long ms = (ts->tv_sec - now.tv_sec) * 1000 + (ts->tv_nsec - now.tv_nsec) / 1000000;
-    return ms;
+/* ---- connection table ---- */
+
+/** @brief Bucket index for a remote address. */
+static uint32_t conn_hash(const cwist_webrtc_ctx *ctx, const struct sockaddr_in *a) {
+    uint32_t h = a->sin_addr.s_addr * 0x9E3779B1u ^ (uint32_t)a->sin_port * 0x85EBCA77u;
+    h ^= h >> 15;
+    return h & (ctx->nbuckets - 1);
 }
 
-/** @fn in_ms
- * @brief Compute the absolute monotonic timestamp @p ms milliseconds from now.
- * @param ms  Offset in milliseconds (may exceed one second).
- * @param ts  Output timespec.
- */
-static void in_ms(long ms, struct timespec *ts) {
-    now_ts(ts);
-    ts->tv_sec += ms / 1000;
-    ts->tv_nsec += (ms % 1000) * 1000000;
-    if (ts->tv_nsec >= 1000000000) {
-        ts->tv_sec += 1;
-        ts->tv_nsec -= 1000000000;
+/** @brief Look up the conn nominated on @p remote. */
+static struct cwist_webrtc_conn *table_find(const cwist_webrtc_ctx *ctx,
+                                            const struct sockaddr_in *remote) {
+    for (struct cwist_webrtc_conn *c = ctx->buckets[conn_hash(ctx, remote)]; c; c = c->hnext) {
+        if (c->remote.sin_addr.s_addr == remote->sin_addr.s_addr &&
+            c->remote.sin_port == remote->sin_port)
+            return c;
     }
+    return NULL;
 }
 
-/* ---- conn lifecycle ---- */
+/** @brief Double the bucket array once the load factor passes 1. */
+static void table_grow(cwist_webrtc_ctx *ctx) {
+    uint32_t nb = ctx->nbuckets * 2;
+    struct cwist_webrtc_conn **b = cwist_alloc(nb * sizeof(*b));
+    if (!b) return; /* keep the old, longer chains */
+    uint32_t old_n = ctx->nbuckets;
+    struct cwist_webrtc_conn **old = ctx->buckets;
+    ctx->buckets = b;
+    ctx->nbuckets = nb;
+    for (uint32_t i = 0; i < old_n; i++) {
+        struct cwist_webrtc_conn *c = old[i];
+        while (c) {
+            struct cwist_webrtc_conn *next = c->hnext;
+            uint32_t h = conn_hash(ctx, &c->remote);
+            c->hnext = b[h];
+            b[h] = c;
+            c = next;
+        }
+    }
+    cwist_free(old);
+}
 
-/** @fn conn_free
- * @brief Tear down a connection and release all of its resources.
- * @param conn  Connection to destroy; must already be unlinked from ctx->conns.
- *
- * Closes the SCTP association, frees the SSL object, drains the pending-send
- * queue, and frees the connection itself.
- */
-static void conn_free(struct cwist_webrtc_conn *conn) {
-    cwist_sctp_conn_close(conn);
-    if (conn->ssl)
-        SSL_free(conn->ssl);
-    while (conn->pending_head) {
-        pending_send *p = conn->pending_head;
-        conn->pending_head = p->next;
-        cwist_free(p);
+/** @brief Insert @p conn keyed by its remote address (takes the table's reference). */
+static void table_insert(cwist_webrtc_ctx *ctx, struct cwist_webrtc_conn *conn) {
+    if (ctx->nconns + 1 > ctx->nbuckets) table_grow(ctx);
+    uint32_t h = conn_hash(ctx, &conn->remote);
+    conn->hnext = ctx->buckets[h];
+    ctx->buckets[h] = conn;
+    ctx->nconns++;
+    conn->registered = true;
+    conn->parked = false;
+}
+
+/** @brief Unlink @p conn from the table or the parked list. */
+static void table_remove(cwist_webrtc_ctx *ctx, struct cwist_webrtc_conn *conn) {
+    struct cwist_webrtc_conn **pp =
+        conn->parked ? &ctx->parked : &ctx->buckets[conn_hash(ctx, &conn->remote)];
+    while (*pp && *pp != conn) pp = &(*pp)->hnext;
+    if (*pp) {
+        *pp = conn->hnext;
+        if (!conn->parked) ctx->nconns--;
     }
-    cwist_free(conn);
+    conn->hnext = NULL;
+    conn->registered = false;
+    conn->parked = false;
 }
 
-/** @fn conn_new
- * @brief Allocate and minimally initialize a new connection.
- * @param ctx  Owning context.
- * @param remote  Remote address; copied into the connection.
- * @param is_server_role  Non-zero for ICE-lite server role (passive DTLS), zero for client role.
- * @return  New connection in state CWIST_CONN_STUN, or NULL on allocation failure.
- */
-static struct cwist_webrtc_conn *conn_new(cwist_webrtc_ctx *ctx,
-                                          const struct sockaddr_in *remote, int is_server_role) {
+/* ---- conn lifecycle ---- */
+
+/** @brief Allocate a conn (one reference, owned by the caller). */
+static struct cwist_webrtc_conn *conn_new(cwist_webrtc_ctx *ctx, const struct sockaddr_in *remote,
+                                          bool is_server_role) {
     struct cwist_webrtc_conn *conn = cwist_malloc(sizeof(*conn));
-    if (!conn)
-        return NULL;
+    if (!conn) return NULL;
     conn->ctx = ctx;
+    ctx_retain(ctx);
+    atomic_init(&conn->refs, 1);
+    atomic_init(&conn->detached, false);
+    atomic_init(&conn->close_posted, false);
+    atomic_init(&conn->buffered, 0);
     conn->is_server_role = is_server_role;
     conn->state = CWIST_CONN_STUN;
-    conn->remote = *remote;
-    conn->remote_len = sizeof(*remote);
+    if (remote) conn->remote = *remote;
+    conn->created_ns = mono_ns();
+    conn->last_rx_ns = conn->created_ns;
+    conn->stun_rto_ms = STUN_RTO_INITIAL_MS;
+    cwist_reactor_timer_init(&conn->timer, conn_timer_cb, conn);
     return conn;
 }
 
-/** @fn conn_arm_stun_timer
- * @brief Set the STUN retransmission deadline to now + STUN_RETRANS_MS.
- * @param conn  Connection whose stun_next field is updated.
+/**
+ * @brief Close @p conn now (owner thread): SCTP abort, DTLS free, unlink,
+ *        close handler.  Idempotent; memory goes with the last reference.
  */
-static void conn_arm_stun_timer(struct cwist_webrtc_conn *conn) {
-    in_ms(STUN_RETRANS_MS, &conn->stun_next);
-}
-
-/** @fn conn_send_stun_request
- * @brief Build, sign, and transmit a STUN binding request to the peer.
- * @param conn  Client-role connection in state CWIST_CONN_STUN.
- *
- * Uses USERNAME peer_ufrag:ice_ufrag and short-term credentials with
- * peer_pwd. Re-arms the retransmission timer and bumps stun_attempts; on
- * STUN_MAX_ATTEMPTS the timer loop marks the connection dead.
- */
-static void conn_send_stun_request(struct cwist_webrtc_conn *conn) {
-    uint8_t msg[512];
-    char username[128];
-    snprintf(username, sizeof(username), "%s:%s", conn->peer_ufrag, conn->ctx->ice_ufrag);
-    int len = cwist_ice_stun_build_request(msg, sizeof(msg), conn->stun_txid, username);
-    if (len < 0)
-        return;
-    len = cwist_ice_stun_sign_request(msg, sizeof(msg), (size_t)len, conn->peer_pwd);
-    if (len < 0)
-        return;
-    cwist_webrtc_conn_send_raw(conn, msg, (size_t)len);
-    conn_arm_stun_timer(conn);
-    conn->stun_attempts++;
+static void conn_teardown(struct cwist_webrtc_conn *conn) {
+    if (atomic_exchange(&conn->detached, true)) return;
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    conn->state = CWIST_CONN_CLOSED;
+    cwist_reactor_timer_cancel(ctx->reactor, &conn->timer);
+    if (conn->sctp_ready) atomic_fetch_sub(&ctx->ready_count, 1);
+    conn->sctp_ready = false;
+    if (conn->sctp_sock || conn->sctp_acc) {
+        cwist_sctp_conn_close(conn);
+        if (--ctx->sctp_assocs == 0) cwist_reactor_timer_cancel(ctx->reactor, &ctx->sctp_timer);
+    }
+    if (conn->ssl) {
+        SSL_free(conn->ssl);
+        conn->ssl = NULL;
+    }
+    while (conn->pending_head) {
+        cwist_webrtc_msg *m = conn->pending_head;
+        conn->pending_head = m->next;
+        atomic_fetch_sub_explicit(&conn->buffered, m->len, memory_order_relaxed);
+        cwist_free(m);
+    }
+    conn->pending_tail = NULL;
+    bool notify = conn->notified_open;
+    conn->notified_open = false;
+    if (notify && ctx->close_cb) ctx->close_cb(conn, ctx->close_user);
+    if (conn->registered) {
+        table_remove(ctx, conn);
+        cwist_webrtc_conn_release(conn); /* the table's reference */
+    }
 }
 
-/** @fn conn_start_dtls
- * @brief Create the SSL object for a connection and kick off the DTLS handshake.
- * @param conn  Connection about to enter state CWIST_CONN_DTLS.
- *
- * Selects the server or client SSL_CTX by role, attaches memory BIOs, sets
- * MTU 1200, and calls SSL_do_handshake() once so the first flight (ServerHello
- * or ClientHello) is queued in the wbio for the caller to flush.
- * On SSL_new failure the connection is marked CWIST_CONN_DEAD.
- */
+/** @brief Create the DTLS session on the custom datagram BIO and start it. */
 static void conn_start_dtls(struct cwist_webrtc_conn *conn) {
     cwist_webrtc_ctx *ctx = conn->ctx;
     SSL_CTX *ssl_ctx = conn->is_server_role ? ctx->server_ssl_ctx : ctx->client_ssl_ctx;
     conn->ssl = SSL_new(ssl_ctx);
-    if (!conn->ssl) {
-        conn->state = CWIST_CONN_DEAD;
+    BIO *bio = conn->ssl ? cwist_dtls_bio_new(conn) : NULL;
+    if (!bio) {
+        conn->close_requested = true;
+        conn_mark_dirty(conn);
         return;
     }
-    BIO *rbio = BIO_new(BIO_s_mem());
-    BIO *wbio = BIO_new(BIO_s_mem());
-    BIO_set_mem_eof_return(rbio, -1);
-    SSL_set_bio(conn->ssl, rbio, wbio);
+    SSL_set_bio(conn->ssl, bio, bio); /* one BIO for both directions */
     SSL_set_options(conn->ssl, SSL_OP_NO_QUERY_MTU);
-    SSL_set_mtu(conn->ssl, 1200);
+    SSL_set_mtu(conn->ssl, cwist_dtls_link_mtu());
     if (conn->is_server_role)
         SSL_set_accept_state(conn->ssl);
     else
         SSL_set_connect_state(conn->ssl);
     conn->state = CWIST_CONN_DTLS;
-    SSL_do_handshake(conn->ssl);
+    SSL_do_handshake(conn->ssl); /* client: emits ClientHello */
+    conn_rearm_timer(conn);
 }
 
-/** @fn conn_flush_dtls_out
- * @brief Drain the SSL write BIO and send every pending DTLS datagram.
- * @param conn  Connection whose wbio holds outbound records.
- *
- * Sends via cwist_webrtc_conn_send_raw(); stops when the BIO is empty or a
- * read returns a short buffer.
- */
-static void conn_flush_dtls_out(struct cwist_webrtc_conn *conn) {
-    uint8_t buf[2048];
-    for (;;) {
-        int n = BIO_read(SSL_get_wbio(conn->ssl), buf, sizeof(buf));
-        if (n <= 0)
-            break;
-        cwist_webrtc_conn_send_raw(conn, buf, (size_t)n);
-        if (n < (int)sizeof(buf))
-            break;
+/** @brief DTLS finished: open SCTP and start the usrsctp clock. */
+static void conn_on_dtls_up(struct cwist_webrtc_conn *conn) {
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    conn->state = CWIST_CONN_ESTABLISHED;
+    if (cwist_sctp_conn_open(conn) < 0) {
+        conn->close_requested = true;
+        return;
     }
+    if (ctx->sctp_assocs++ == 0) ctx_arm_sctp_timer(ctx);
+    conn_rearm_timer(conn);
 }
 
-/** @fn conn_mark_dead
- * @brief Mark a connection as dead so the timer loop reaps it.
- * @param conn  Connection to mark; actual freeing happens later, off the list.
- */
-static void conn_mark_dead(struct cwist_webrtc_conn *conn) {
-    conn->state = CWIST_CONN_DEAD;
-}
-
-/** @fn conn_service
- * @brief Drive the DTLS handshake and data path for a connection with input pending.
- * @param conn  Connection whose rbio has received data or that needs servicing.
- *
- * In CWIST_CONN_DTLS: advances the handshake; on success opens the SCTP
- * association and moves to CWIST_CONN_ESTABLISHED, on SSL_ERROR_SSL marks
- * the connection dead.
- * In CWIST_CONN_ESTABLISHED: SSL_read() loop feeds decrypted SCTP packets to
- * cwist_sctp_conn_input(), detects peer close (ZERO_RETURN/SYSCALL), tracks
- * sctp_ready via cwist_sctp_assoc_established(), drains outbound SCTP, and
- * flushes the pending-send queue. Finally flushes any DTLS output.
- */
-static void conn_service(struct cwist_webrtc_conn *conn) {
+/** @brief Feed one DTLS datagram through the session (and SCTP above it). */
+static void conn_dtls_input(struct cwist_webrtc_conn *conn, const uint8_t *buf, size_t len) {
+    conn->dtls_in = buf;
+    conn->dtls_in_len = len;
     if (conn->state == CWIST_CONN_DTLS) {
         int rc = SSL_do_handshake(conn->ssl);
         if (rc == 1) {
-            if (cwist_sctp_conn_open(conn) < 0) {
-                conn_mark_dead(conn);
-                return;
-            }
-            conn->state = CWIST_CONN_ESTABLISHED;
+            conn_on_dtls_up(conn);
+        } else if (SSL_get_error(conn->ssl, rc) == SSL_ERROR_SSL) {
+            conn->close_requested = true;
         } else {
-            int err = SSL_get_error(conn->ssl, rc);
-            if (err == SSL_ERROR_SSL)
-                conn_mark_dead(conn);
+            conn_rearm_timer(conn); /* a flight arrived: new retransmit deadline */
         }
     }
-    if (conn->state == CWIST_CONN_ESTABLISHED) {
-        uint8_t buf[65536];
+    if (conn->state == CWIST_CONN_ESTABLISHED && !conn->close_requested) {
         for (;;) {
-            int n = SSL_read(conn->ssl, buf, sizeof(buf));
+            int n = SSL_read(conn->ssl, conn->ctx->plain_buf, CWIST_WEBRTC_MAX_MESSAGE);
             if (n > 0) {
-                cwist_sctp_conn_input(conn, buf, (size_t)n);
+                cwist_sctp_conn_input(conn, conn->ctx->plain_buf, (size_t)n);
                 continue;
             }
             int err = SSL_get_error(conn->ssl, n);
-            if (err == SSL_ERROR_ZERO_RETURN || (err == SSL_ERROR_SYSCALL && n == 0)) {
-                conn_mark_dead(conn);
+            if (err == SSL_ERROR_ZERO_RETURN || err == SSL_ERROR_SSL ||
+                (err == SSL_ERROR_SYSCALL && n == 0))
+                conn->close_requested = true;
+            break;
+        }
+    }
+    conn->dtls_in = NULL;
+    conn->dtls_in_len = 0;
+    conn_mark_dirty(conn);
+}
+
+/** @brief Send (or resend) the client-role Binding request. */
+static void conn_send_stun_request(struct cwist_webrtc_conn *conn) {
+    uint8_t msg[512];
+    char username[128];
+    snprintf(username, sizeof(username), "%s:%s", conn->peer_ufrag, conn->ctx->ice_ufrag);
+    int len = cwist_ice_stun_build_request(msg, sizeof(msg), conn->stun_txid, username);
+    if (len < 0) return;
+    len = cwist_ice_stun_sign_request(msg, sizeof(msg), (size_t)len, conn->peer_pwd);
+    if (len < 0) return;
+    ctx_tx_queue(conn->ctx, msg, (size_t)len, &conn->remote);
+    conn->stun_attempts++;
+}
+
+/* ---- per-conn timer ---- */
+
+/** @brief Point the conn timer at the next deadline its state needs. */
+static void conn_rearm_timer(struct cwist_webrtc_conn *conn) {
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    uint64_t now = ctx->now_ns ? ctx->now_ns : mono_ns();
+    uint64_t delay_us;
+    switch (conn->state) {
+        case CWIST_CONN_STUN:
+            if (conn->parked) {
+                uint64_t end = conn->created_ns + PARK_TIMEOUT_MS * 1000000ull;
+                delay_us = end > now ? (end - now) / 1000 : 0;
+            } else if (!conn->is_server_role) {
+                delay_us = (uint64_t)conn->stun_rto_ms * 1000;
+            } else {
+                uint64_t end = conn->created_ns + HANDSHAKE_TIMEOUT_MS * 1000000ull;
+                delay_us = end > now ? (end - now) / 1000 : 0;
+            }
+            break;
+        case CWIST_CONN_DTLS: {
+            struct timeval tv;
+            uint64_t end = conn->created_ns + HANDSHAKE_TIMEOUT_MS * 1000000ull;
+            uint64_t hs_us = end > now ? (end - now) / 1000 : 0;
+            delay_us = hs_us;
+            if (DTLSv1_get_timeout(conn->ssl, &tv) == 1) {
+                uint64_t t = (uint64_t)tv.tv_sec * 1000000ull + (uint64_t)tv.tv_usec;
+                if (t < delay_us) delay_us = t;
             }
             break;
         }
-        if (conn->state == CWIST_CONN_ESTABLISHED && !conn->sctp_ready &&
-            cwist_sctp_assoc_established(conn)) {
-            conn->sctp_ready = true;
+        case CWIST_CONN_ESTABLISHED: {
+            uint64_t end = conn->last_rx_ns + LIVENESS_TIMEOUT_MS * 1000000ull;
+            delay_us = end > now ? (end - now) / 1000 : 0;
+            break;
         }
-        cwist_sctp_conn_drain(conn);
-        if (conn->sctp_ready) {
-            while (conn->pending_head) {
-                pending_send *p = conn->pending_head;
-                if (cwist_sctp_send(conn, p->channel, p->data, p->len, p->is_string) < 0)
-                    break;
-                conn->pending_head = p->next;
-                if (!conn->pending_head)
-                    conn->pending_tail = NULL;
-                cwist_free(p);
+        default: return;
+    }
+    cwist_reactor_timer_arm(ctx->reactor, &conn->timer, delay_us);
+}
+
+/** @brief Conn timer: retransmit, give up, or check liveness. */
+static void conn_timer_cb(void *arg) {
+    struct cwist_webrtc_conn *conn = arg;
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    cwist_webrtc_ctx *prev = ctx_enter(ctx);
+    uint64_t now = ctx->now_ns;
+    uint64_t age_ms = (now - conn->created_ns) / 1000000ull;
+    switch (conn->state) {
+        case CWIST_CONN_STUN:
+            if (conn->parked || conn->is_server_role) {
+                if (age_ms >= (conn->parked ? PARK_TIMEOUT_MS : HANDSHAKE_TIMEOUT_MS))
+                    conn->close_requested = true;
+            } else if (conn->stun_attempts >= STUN_MAX_ATTEMPTS) {
+                conn->close_requested = true;
+            } else {
+                conn_send_stun_request(conn);
+                conn->stun_rto_ms = conn->stun_rto_ms * 2 > STUN_RTO_MAX_MS ? STUN_RTO_MAX_MS
+                                                                            : conn->stun_rto_ms * 2;
             }
-        }
+            break;
+        case CWIST_CONN_DTLS:
+            if (age_ms >= HANDSHAKE_TIMEOUT_MS) {
+                conn->close_requested = true;
+            } else if (DTLSv1_handle_timeout(conn->ssl) < 0) {
+                conn->close_requested = true;
+            }
+            break;
+        case CWIST_CONN_ESTABLISHED:
+            if ((now - conn->last_rx_ns) / 1000000ull >= LIVENESS_TIMEOUT_MS)
+                conn->close_requested = true;
+            break;
+        default: break;
     }
-    if (conn->ssl)
-        conn_flush_dtls_out(conn);
+    if (conn->close_requested)
+        conn_mark_dirty(conn);
+    else
+        conn_rearm_timer(conn);
+    ctx_leave(ctx, prev);
 }
 
-/* ---- packet handling ---- */
+/* ---- SCTP clock ---- */
 
-/** @fn handle_stun
- * @brief Process a STUN binding request or response.
- * @param ctx  Receiving context.
- * @param buf  Raw datagram.
- * @param len  Datagram length.
- * @param remote  Source address of the datagram.
- *
- * Requests are answered only on ICE-lite server contexts: a valid request
- * gets a binding response, and one with USE-CANDIDATE adopts a parked
- * offer connection (matched by ufrag) or creates a fresh server-role
- * connection, then starts DTLS. Responses are accepted only by client-role
- * connections in state CWIST_CONN_STUN whose transaction id and credentials
- * match; a valid response starts DTLS.
- */
+/** @brief Arm the usrsctp tick: fast after recent traffic, slow when quiet. */
+static void ctx_arm_sctp_timer(cwist_webrtc_ctx *ctx) {
+    uint64_t now = ctx->now_ns ? ctx->now_ns : mono_ns();
+    ctx->sctp_fast = now - ctx->last_activity_ns < SCTP_ACTIVE_WINDOW_MS * 1000000ull;
+    uint64_t step_ms = ctx->sctp_fast ? SCTP_TICK_ACTIVE_MS : SCTP_TICK_IDLE_MS;
+    cwist_reactor_timer_arm(ctx->reactor, &ctx->sctp_timer, step_ms * 1000);
+}
+
+/** @brief Record traffic; switch a slow SCTP tick back to the fast rate. */
+static void ctx_note_activity(cwist_webrtc_ctx *ctx) {
+    ctx->last_activity_ns = ctx->now_ns;
+    if (ctx->sctp_assocs > 0 && !ctx->sctp_fast) ctx_arm_sctp_timer(ctx);
+}
+
+/** @brief Advance usrsctp's clock (fires SCTP retransmit/SACK timers). */
+static void ctx_sctp_timer_cb(void *arg) {
+    cwist_webrtc_ctx *ctx = arg;
+    cwist_webrtc_ctx *prev = ctx_enter(ctx);
+    cwist_sctp_tick();
+    if (ctx->sctp_assocs > 0) ctx_arm_sctp_timer(ctx);
+    ctx_leave(ctx, prev);
+}
+
+/* ---- callbacks used by sctp.c / dtls.c ---- */
+
+void cwist_webrtc_conn_on_message(struct cwist_webrtc_conn *conn, uint16_t channel,
+                                  const uint8_t *data, size_t len, int is_string) {
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    if (ctx->msg_cb)
+        ctx->msg_cb(conn, channel, data, len,
+                    is_string ? CWIST_WEBRTC_DATA_STRING : CWIST_WEBRTC_DATA_BINARY, ctx->msg_user);
+}
+
+void cwist_webrtc_conn_on_channel_open(struct cwist_webrtc_conn *conn, uint16_t channel,
+                                       const char *label) {
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    if (ctx->ch_cb) ctx->ch_cb(conn, channel, label, ctx->ch_user);
+}
+
+void cwist_webrtc_conn_dtls_out(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len) {
+    ctx_tx_queue(conn->ctx, data, len, &conn->remote);
+}
+
+/** @brief An SCTP packet produced off the owner thread, on its way there. */
+typedef struct {
+    cwist_reactor_post_t post; /**< Post node. */
+    struct cwist_webrtc_conn *conn; /**< Referenced while posted. */
+    size_t len;                /**< Packet length. */
+    uint8_t data[];            /**< Packet. */
+} sctp_out_post;
+
+/** @brief Owner side of a marshalled SCTP packet: encrypt and queue it. */
+static void sctp_out_post_cb(void *arg) {
+    sctp_out_post *p = arg;
+    struct cwist_webrtc_conn *conn = p->conn;
+    cwist_webrtc_ctx *prev = ctx_enter(conn->ctx);
+    if (conn->ssl) SSL_write(conn->ssl, p->data, (int)p->len);
+    ctx_leave(conn->ctx, prev);
+    cwist_webrtc_conn_release(conn);
+    cwist_free(p);
+}
+
+void cwist_webrtc_conn_sctp_out(struct cwist_webrtc_conn *conn, const void *buffer, size_t len) {
+    if (cwist_webrtc_on_owner(conn->ctx)) {
+        /* No detached check: teardown closes SCTP (emitting its ABORT)
+         * before it frees the DTLS session. */
+        if (conn->ssl) SSL_write(conn->ssl, buffer, (int)len);
+        return;
+    }
+    /* A usrsctp timer pass on another thread produced this packet. */
+    sctp_out_post *p = cwist_alloc(sizeof(*p) + len);
+    if (!p) return; /* SCTP retransmits */
+    p->conn = conn;
+    p->len = len;
+    memcpy(p->data, buffer, len);
+    p->post.cb = sctp_out_post_cb;
+    p->post.ctx = p;
+    cwist_webrtc_conn_retain(conn);
+    cwist_reactor_post(conn->ctx->reactor, &p->post);
+}
+
+/* ---- inbound datagrams ---- */
+
+/** @brief Handle a STUN message: Binding request (ICE-lite) or response (client). */
 static void handle_stun(cwist_webrtc_ctx *ctx, const uint8_t *buf, size_t len,
                         const struct sockaddr_in *remote) {
     if (cwist_ice_stun_is_binding_request(buf, len)) {
-        if (!ctx->ice_lite_server)
-            return; /* this ctx has not answered any offer: not an ICE-lite endpoint */
-        struct cwist_webrtc_conn *conn = cwist_webrtc_ctx_find_conn(ctx, remote);
+        if (!atomic_load(&ctx->ice_lite_server))
+            return; /* no offer answered yet: not an ICE-lite endpoint */
         if (!cwist_ice_stun_validate_request(buf, len, ctx->ice_pwd))
             return; /* bad MESSAGE-INTEGRITY: drop (RFC 8445 would send a 400) */
         uint8_t resp[256];
-        int rlen = cwist_ice_stun_build_response(resp, sizeof(resp), buf, len, remote,
-                                                 ctx->ice_pwd);
-        if (rlen < 0)
-            return;
-        sendto(ctx->udp_fd, resp, (size_t)rlen, 0, (const struct sockaddr *)remote,
-               sizeof(*remote));
-        if (!conn && cwist_ice_stun_has_use_candidate(buf, len)) {
-            /* Adopt a connection parked by cwist_webrtc_handle_offer() when the
-             * request USERNAME carries its ufrag, else start a fresh one. */
+        int rlen =
+            cwist_ice_stun_build_response(resp, sizeof(resp), buf, len, remote, ctx->ice_pwd);
+        if (rlen < 0) return;
+        ctx_tx_queue(ctx, resp, (size_t)rlen, remote);
+
+        bool nominate = cwist_ice_stun_has_use_candidate(buf, len);
+        struct cwist_webrtc_conn *conn = table_find(ctx, remote);
+        if (conn) {
+            conn->last_rx_ns = ctx->now_ns; /* consent check keeps it alive */
+        } else if (nominate) {
+            /* Adopt the conn parked by cwist_webrtc_handle_offer() when the
+             * USERNAME's remote ufrag matches; otherwise the request is still
+             * authenticated with our password, so start a fresh conn. */
             char rfrag[64] = "";
             cwist_ice_stun_get_remote_ufrag(buf, len, rfrag, sizeof(rfrag));
-            for (struct cwist_webrtc_conn *c = ctx->conns; c; c = c->next) {
-                if (c->is_server_role && c->state == CWIST_CONN_STUN && c->remote.sin_port == 0 &&
-                    (rfrag[0] == '\0' || strcmp(c->peer_ufrag, rfrag) == 0)) {
+            for (struct cwist_webrtc_conn *c = ctx->parked; c; c = c->hnext) {
+                if (rfrag[0] && strcmp(c->peer_ufrag, rfrag) == 0) {
                     conn = c;
                     break;
                 }
             }
-            int fresh = 0;
-            if (!conn) {
-                conn = conn_new(ctx, remote, 1);
-                fresh = 1;
-            }
             if (conn) {
-                conn->remote = *remote;
-                conn->remote_len = sizeof(*remote);
-                if (fresh)
-                    cwist_webrtc_ctx_add_conn(ctx, conn);
+                table_remove(ctx, conn); /* the parked list's reference moves */
+            } else {
+                conn = conn_new(ctx, NULL, true);
+                if (!conn) return;
             }
+            conn->remote = *remote;
+            conn->last_rx_ns = ctx->now_ns;
+            table_insert(ctx, conn);
         }
-        if (conn && conn->state == CWIST_CONN_STUN &&
-            cwist_ice_stun_has_use_candidate(buf, len)) {
-            conn_start_dtls(conn);
-            conn_service(conn);
-        }
+        if (conn && nominate && conn->state == CWIST_CONN_STUN) conn_start_dtls(conn);
         return;
     }
-    if (!cwist_ice_stun_is_message(buf, len))
-        return;
-    /* STUN response: only our client-role connections expect these. */
-    struct cwist_webrtc_conn *conn = cwist_webrtc_ctx_find_conn(ctx, remote);
-    if (!conn || conn->is_server_role || conn->state != CWIST_CONN_STUN)
-        return;
+    /* STUN response: only client-role conns expect these. */
+    struct cwist_webrtc_conn *conn = table_find(ctx, remote);
+    if (!conn || conn->is_server_role || conn->state != CWIST_CONN_STUN) return;
     struct sockaddr_in mapped;
     if (cwist_ice_stun_parse_response(buf, len, conn->stun_txid, conn->peer_pwd, &mapped)) {
+        conn->last_rx_ns = ctx->now_ns;
         conn_start_dtls(conn);
-        conn_service(conn);
     }
 }
 
-/** @fn handle_packet
- * @brief Demux an inbound UDP datagram and dispatch it.
- * @param ctx  Receiving context.
- * @param buf  Raw datagram.
- * @param len  Datagram length.
- * @param remote  Source address of the datagram.
- *
- * STUN messages go to handle_stun(); anything else is treated as a DTLS
- * record for the matching connection and is written into the SSL rbio.
- */
+/** @brief Demultiplex one datagram (RFC 7983): STUN or DTLS. */
 static void handle_packet(cwist_webrtc_ctx *ctx, const uint8_t *buf, size_t len,
                           const struct sockaddr_in *remote) {
-    if (cwist_ice_stun_is_message(buf, len)) {
-        handle_stun(ctx, buf, len, remote);
+    if (len == 0) return;
+    if (buf[0] <= 3) {
+        if (cwist_ice_stun_is_message(buf, len)) handle_stun(ctx, buf, len, remote);
         return;
     }
-    /* DTLS record (content type 20-64). */
-    struct cwist_webrtc_conn *conn = cwist_webrtc_ctx_find_conn(ctx, remote);
-    if (!conn || !conn->ssl || conn->state == CWIST_CONN_STUN)
-        return;
-    if (len > INT32_MAX)
-        return;
-    BIO_write(SSL_get_rbio(conn->ssl), buf, (int)len);
-    conn_service(conn);
+    if (buf[0] < 20 || buf[0] > 63) return; /* not DTLS (RTP/RTCP or garbage) */
+    struct cwist_webrtc_conn *conn = table_find(ctx, remote);
+    if (!conn || !conn->ssl || atomic_load(&conn->detached)) return;
+    conn->last_rx_ns = ctx->now_ns;
+    conn_dtls_input(conn, buf, len);
 }
 
-/* ---- event loop ---- */
+static void udp_readable(int fd, void *payload);
 
-/** @fn loop_run_timers
- * @brief Run per-iteration timer and reaping work for the event loop.
- * @param ctx  Context being serviced.
- *
- * Under ctx->lock, unlinks dead or closed connections from the list, then
- * frees them outside the lock. For live connections: retransmits STUN
- * binding requests on client-role connections (giving up after
- * STUN_MAX_ATTEMPTS), handles expired DTLS retransmission timeouts, and
- * services established connections.
- */
-static void loop_run_timers(cwist_webrtc_ctx *ctx) {
-    struct timespec now;
-    now_ts(&now);
-    struct cwist_webrtc_conn *dead = NULL;
-
-    pthread_mutex_lock(&ctx->lock);
-    struct cwist_webrtc_conn **pp = &ctx->conns;
-    while (*pp) {
-        struct cwist_webrtc_conn *conn = *pp;
-        int reap = conn->state == CWIST_CONN_DEAD || conn->closed;
-        if (reap) {
-            *pp = conn->next;
-            conn->next = dead;
-            dead = conn;
-            continue;
-        }
-        pp = &conn->next;
-    }
-    pthread_mutex_unlock(&ctx->lock);
+/** @brief Arm the UDP read slot. */
+static bool ctx_arm_udp_slot(cwist_webrtc_ctx *ctx) {
+    if (!ctx->owns_reactor) ctx_retain(ctx);
+    ctx->udp_armed = cwist_reactor_add(ctx->reactor, ctx->udp_fd, udp_readable, &ctx, sizeof(ctx));
+    if (!ctx->udp_armed && !ctx->owns_reactor) ctx_release(ctx);
+    return ctx->udp_armed;
+}
 
-    while (dead) {
-        struct cwist_webrtc_conn *next = dead->next;
-        conn_free(dead);
-        dead = next;
+/** @brief Close the UDP socket and wake a waiting cwist_webrtc_ctx_free(). */
+static void ctx_close_fd(cwist_webrtc_ctx *ctx) {
+    if (ctx->udp_fd >= 0) {
+        close(ctx->udp_fd);
+        ctx->udp_fd = -1;
     }
+    pthread_mutex_lock(&ctx->free_mu);
+    ctx->fd_closed = true;
+    pthread_cond_broadcast(&ctx->free_cv);
+    pthread_mutex_unlock(&ctx->free_mu);
+}
 
-    for (struct cwist_webrtc_conn *conn = ctx->conns; conn; conn = conn->next) {
-        if (conn->state == CWIST_CONN_STUN && !conn->is_server_role &&
-            conn->stun_attempts > 0 && ms_until(&conn->stun_next) <= 0) {
-            if (conn->stun_attempts >= STUN_MAX_ATTEMPTS) {
-                conn_mark_dead(conn);
-                continue;
-            }
-            conn_send_stun_request(conn);
-        }
-        if (conn->state == CWIST_CONN_DTLS) {
-            struct timeval tv;
-            if (DTLSv1_get_timeout(conn->ssl, &tv) == 1 && tv.tv_sec == 0 && tv.tv_usec == 0) {
-                DTLSv1_handle_timeout(conn->ssl);
-                conn_service(conn);
-            }
+/** @brief Read slot fired: drain the socket in recvmmsg batches. */
+static void udp_readable(int fd, void *payload) {
+    cwist_webrtc_ctx *ctx = *(cwist_webrtc_ctx **)payload;
+    ctx->udp_armed = false;
+    if (ctx->closed) {
+        /* Woken by teardown: the slot is consumed, so the fd can go. */
+        ctx_close_fd(ctx);
+        if (!ctx->owns_reactor) ctx_release(ctx);
+        return;
+    }
+    cwist_webrtc_ctx *prev = ctx_enter(ctx);
+    int handled = 0;
+    while (handled < RX_BUDGET) {
+#ifdef __linux__
+        struct mmsghdr msgs[CWIST_WEBRTC_RX_BATCH];
+        struct iovec iov[CWIST_WEBRTC_RX_BATCH];
+        struct sockaddr_in from[CWIST_WEBRTC_RX_BATCH];
+        for (int i = 0; i < CWIST_WEBRTC_RX_BATCH; i++) {
+            iov[i].iov_base = ctx->rx_bufs + (size_t)i * CWIST_WEBRTC_DGRAM_MAX;
+            iov[i].iov_len = CWIST_WEBRTC_DGRAM_MAX;
+            memset(&msgs[i].msg_hdr, 0, sizeof(msgs[i].msg_hdr));
+            msgs[i].msg_hdr.msg_name = &from[i];
+            msgs[i].msg_hdr.msg_namelen = sizeof(from[i]);
+            msgs[i].msg_hdr.msg_iov = &iov[i];
+            msgs[i].msg_hdr.msg_iovlen = 1;
         }
-        if (conn->state == CWIST_CONN_ESTABLISHED) {
-            conn_service(conn);
+        int n = recvmmsg(fd, msgs, CWIST_WEBRTC_RX_BATCH, MSG_DONTWAIT, NULL);
+        if (n < 0 && errno == EINTR) continue;
+        if (n <= 0) break;
+        for (int i = 0; i < n; i++) {
+            if (msgs[i].msg_hdr.msg_flags & MSG_TRUNC) continue;
+            if (msgs[i].msg_hdr.msg_namelen < sizeof(struct sockaddr_in) ||
+                from[i].sin_family != AF_INET)
+                continue;
+            handle_packet(ctx, iov[i].iov_base, msgs[i].msg_len, &from[i]);
         }
+        handled += n;
+        if (n < CWIST_WEBRTC_RX_BATCH) break;
+#else
+        struct sockaddr_in from;
+        socklen_t flen = sizeof(from);
+        ssize_t n = recvfrom(fd, ctx->rx_bufs, CWIST_WEBRTC_DGRAM_MAX, MSG_DONTWAIT,
+                             (struct sockaddr *)&from, &flen);
+        if (n < 0 && errno == EINTR) continue;
+        if (n <= 0) break;
+        if (flen >= sizeof(struct sockaddr_in) && from.sin_family == AF_INET)
+            handle_packet(ctx, ctx->rx_bufs, (size_t)n, &from);
+        handled++;
+#endif
     }
+    if (handled > 0) ctx_note_activity(ctx);
+    ctx_leave(ctx, prev);
+    if (!ctx->closed) {
+        /* The slot that fired held a ref; the new slot takes its own. */
+        ctx_arm_udp_slot(ctx);
+    }
+    if (!ctx->owns_reactor) ctx_release(ctx);
 }
 
-/** @fn ctx_thread_main
- * @brief Event-loop thread entry point for a context.
- * @param arg  cwist_webrtc_ctx pointer.
- * @return  Always NULL.
- *
- * Loops until ctx->stop: polls the UDP socket and wake pipe (SCTP_TIMER_MS
- * timeout), drains wake bytes, handles all pending inbound datagrams, feeds
- * elapsed time to usrsctp_handle_timers(), and runs loop_run_timers().
- */
-static void *ctx_thread_main(void *arg) {
-    cwist_webrtc_ctx *ctx = arg;
-    uint8_t buf[65536];
-    struct timespec last_tick;
-    now_ts(&last_tick);
-
-    while (!ctx->stop) {
-        struct pollfd fds[2] = {
-            { .fd = ctx->udp_fd, .events = POLLIN },
-            { .fd = ctx->wake_pipe[0], .events = POLLIN },
-        };
-        int prc = poll(fds, 2, SCTP_TIMER_MS);
-        if (prc < 0) {
-            if (errno == EINTR)
-                continue;
-            break;
-        }
-        if (fds[1].revents & POLLIN) {
-            uint8_t drain[64];
-            while (read(ctx->wake_pipe[0], drain, sizeof(drain)) > 0)
-                ;
-        }
-        if (fds[0].revents & POLLIN) {
-            for (;;) {
-                struct sockaddr_in remote;
-                socklen_t rlen = sizeof(remote);
-                ssize_t n = recvfrom(ctx->udp_fd, buf, sizeof(buf), MSG_DONTWAIT,
-                                     (struct sockaddr *)&remote, &rlen);
-                if (n <= 0)
-                    break;
-                handle_packet(ctx, buf, (size_t)n, &remote);
-            }
-        }
+/* ---- host address ---- */
 
-        struct timespec now;
-        now_ts(&now);
-        long elapsed_ms = (now.tv_sec - last_tick.tv_sec) * 1000 +
-                          (now.tv_nsec - last_tick.tv_nsec) / 1000000;
-        if (elapsed_ms > SCTP_TIMER_MS)
-            elapsed_ms = SCTP_TIMER_MS;
-        if (elapsed_ms < 0)
-            elapsed_ms = 0;
-        if (elapsed_ms > 0) {
-            usrsctp_handle_timers((uint32_t)elapsed_ms);
-            last_tick = now;
-        }
-        loop_run_timers(ctx);
+/** @brief Best-guess non-loopback IPv4 address for the SDP host candidate. */
+static void detect_host_ip(char *out, size_t cap) {
+    snprintf(out, cap, "127.0.0.1");
+    struct ifaddrs *ifas = NULL;
+    if (getifaddrs(&ifas) < 0) return;
+    for (struct ifaddrs *ifa = ifas; ifa; ifa = ifa->ifa_next) {
+        if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) continue;
+        struct sockaddr_in *sin = (struct sockaddr_in *)ifa->ifa_addr;
+        uint32_t a = ntohl(sin->sin_addr.s_addr);
+        if (((a >> 24) & 0xFF) == 127) continue;
+        snprintf(out, cap, "%u.%u.%u.%u", (a >> 24) & 0xFF, (a >> 16) & 0xFF, (a >> 8) & 0xFF,
+                 a & 0xFF);
+        break;
     }
-    return NULL;
+    freeifaddrs(ifas);
 }
 
-/* ---- public API ---- */
+/* ---- ctx lifecycle ---- */
 
-/** @fn cwist_webrtc_ctx_new
- * @brief Create a WebRTC server context on a UDP port.
- * @param port  UDP port to bind; port 0 requests an ephemeral port.
- * @return  New context, or NULL on any initialization failure.
- *
- * Initializes usrsctp, binds a non-blocking UDP socket, creates the wake
- * pipe, generates the DTLS certificate and both SSL_CTXs (server and
- * client), rolls ICE credentials, detects the host IP, and starts the
- * background event-loop thread. ctx->port holds the bound port.
+/** @brief Free the ctx memory once the last reference is gone. */
+static void ctx_release(cwist_webrtc_ctx *ctx) {
+    if (atomic_fetch_sub_explicit(&ctx->refs, 1, memory_order_acq_rel) != 1) return;
+    ctx_release_resources(ctx);
+    pthread_mutex_destroy(&ctx->free_mu);
+    pthread_cond_destroy(&ctx->free_cv);
+    cwist_free(ctx);
+}
+
+/** @brief Release everything but the memory: sockets, TLS state, buffers. */
+static void ctx_release_resources(cwist_webrtc_ctx *ctx) {
+    if (ctx->server_ssl_ctx) SSL_CTX_free(ctx->server_ssl_ctx);
+    if (ctx->client_ssl_ctx) SSL_CTX_free(ctx->client_ssl_ctx);
+    if (ctx->cert) X509_free(ctx->cert);
+    if (ctx->pkey) EVP_PKEY_free(ctx->pkey);
+    ctx->server_ssl_ctx = ctx->client_ssl_ctx = NULL;
+    ctx->cert = NULL;
+    ctx->pkey = NULL;
+    cwist_free(ctx->buckets);
+    cwist_free(ctx->rx_bufs);
+    cwist_free(ctx->plain_buf);
+    cwist_free(ctx->sctp_buf);
+    cwist_free(ctx->tx_bufs);
+    ctx->buckets = NULL;
+    ctx->rx_bufs = ctx->plain_buf = ctx->sctp_buf = ctx->tx_bufs = NULL;
+}
+
+/**
+ * @brief Close every conn and stop the ctx's timers (owner thread, or any
+ *        thread once the reactor no longer runs).  Idempotent.
  */
-cwist_webrtc_ctx *cwist_webrtc_ctx_new(uint16_t port) {
-    if (cwist_sctp_global_init() < 0)
-        return NULL;
+static void ctx_teardown_owner(cwist_webrtc_ctx *ctx) {
+    if (ctx->closed) return;
+    cwist_webrtc_ctx *prev = ctx_enter(ctx);
+    while (ctx->parked) conn_teardown(ctx->parked);
+    for (uint32_t i = 0; i < ctx->nbuckets; i++) {
+        while (ctx->buckets[i]) conn_teardown(ctx->buckets[i]);
+    }
+    cwist_reactor_timer_cancel(ctx->reactor, &ctx->sctp_timer);
+    ctx_tx_flush(ctx); /* SCTP ABORTs from the closes */
+    ctx->closed = true;
+    ctx_leave(ctx, prev); /* dirty conns are detached: only their refs drop */
+}
 
+/** @brief Allocate the ctx, bind the socket, create the DTLS identity. */
+static cwist_webrtc_ctx *ctx_create(cwist_reactor_t *reactor, bool owns_reactor, uint16_t port) {
+    if (!reactor || cwist_sctp_global_init() < 0) return NULL;
     cwist_webrtc_ctx *ctx = cwist_malloc(sizeof(*ctx));
-    if (!ctx)
-        return NULL;
+    if (!ctx) return NULL;
+    ctx->reactor = reactor;
+    ctx->owns_reactor = owns_reactor;
+    ctx->owner_pid = getpid();
+    atomic_init(&ctx->refs, 1);
+    atomic_init(&ctx->ice_lite_server, 0);
+    atomic_init(&ctx->ready_count, 0);
     ctx->udp_fd = -1;
-    ctx->wake_pipe[0] = -1;
-    ctx->wake_pipe[1] = -1;
-    pthread_mutex_init(&ctx->lock, NULL);
+    pthread_mutex_init(&ctx->free_mu, NULL);
+    pthread_cond_init(&ctx->free_cv, NULL);
+    cwist_reactor_timer_init(&ctx->sctp_timer, ctx_sctp_timer_cb, ctx);
 
-    ctx->udp_fd = socket(AF_INET, SOCK_DGRAM, 0);
-    if (ctx->udp_fd < 0)
+    ctx->nbuckets = 64;
+    ctx->buckets = cwist_alloc(ctx->nbuckets * sizeof(*ctx->buckets));
+    ctx->rx_bufs = cwist_alloc((size_t)CWIST_WEBRTC_RX_BATCH * CWIST_WEBRTC_DGRAM_MAX);
+    ctx->tx_bufs = cwist_alloc((size_t)CWIST_WEBRTC_TX_BATCH * CWIST_WEBRTC_DGRAM_MAX);
+    ctx->plain_buf = cwist_alloc(CWIST_WEBRTC_MAX_MESSAGE);
+    ctx->sctp_buf = cwist_alloc(CWIST_WEBRTC_MAX_MESSAGE);
+    if (!ctx->buckets || !ctx->rx_bufs || !ctx->tx_bufs || !ctx->plain_buf || !ctx->sctp_buf)
         goto fail;
+
+    ctx->udp_fd = socket(AF_INET, SOCK_DGRAM, 0);
+    if (ctx->udp_fd < 0) goto fail;
     int flags = fcntl(ctx->udp_fd, F_GETFL, 0);
     fcntl(ctx->udp_fd, F_SETFL, flags | O_NONBLOCK);
+    int buf = UDP_SOCKBUF;
+    setsockopt(ctx->udp_fd, SOL_SOCKET, SO_RCVBUF, &buf, sizeof(buf));
+    setsockopt(ctx->udp_fd, SOL_SOCKET, SO_SNDBUF, &buf, sizeof(buf));
     struct sockaddr_in addr;
     memset(&addr, 0, sizeof(addr));
     addr.sin_family = AF_INET;
     addr.sin_addr.s_addr = htonl(INADDR_ANY);
     addr.sin_port = htons(port);
-    if (bind(ctx->udp_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0)
-        goto fail;
+    if (bind(ctx->udp_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) goto fail;
     socklen_t alen = sizeof(addr);
-    if (getsockname(ctx->udp_fd, (struct sockaddr *)&addr, &alen) < 0)
-        goto fail;
+    if (getsockname(ctx->udp_fd, (struct sockaddr *)&addr, &alen) < 0) goto fail;
     ctx->port = ntohs(addr.sin_port);
 
-    if (pipe(ctx->wake_pipe) < 0)
-        goto fail;
-    for (int i = 0; i < 2; i++) {
-        int fl = fcntl(ctx->wake_pipe[i], F_GETFL, 0);
-        fcntl(ctx->wake_pipe[i], F_SETFL, fl | O_NONBLOCK);
-    }
-
-    if (cwist_dtls_generate_cert(&ctx->cert, &ctx->pkey) < 0)
-        goto fail;
+    if (cwist_dtls_generate_cert(&ctx->cert, &ctx->pkey) < 0) goto fail;
     if (cwist_dtls_fingerprint(ctx->cert, ctx->fingerprint, sizeof(ctx->fingerprint)) < 0)
         goto fail;
     ctx->server_ssl_ctx = cwist_dtls_ctx_new(1, ctx->cert, ctx->pkey);
     ctx->client_ssl_ctx = cwist_dtls_ctx_new(0, NULL, NULL);
-    if (!ctx->server_ssl_ctx || !ctx->client_ssl_ctx)
-        goto fail;
+    if (!ctx->server_ssl_ctx || !ctx->client_ssl_ctx) goto fail;
 
     cwist_ice_random_creds(ctx->ice_ufrag, sizeof(ctx->ice_ufrag), ctx->ice_pwd,
                            sizeof(ctx->ice_pwd));
-    cwist_webrtc_detect_host_ip(ctx->host_ip, sizeof(ctx->host_ip));
-
-    ctx->thread_running = pthread_create(&ctx->thread, NULL, &ctx_thread_main, ctx) == 0;
-    if (!ctx->thread_running)
-        goto fail;
+    detect_host_ip(ctx->host_ip, sizeof(ctx->host_ip));
     return ctx;
 
 fail:
-    cwist_webrtc_ctx_free(ctx);
+    if (ctx->udp_fd >= 0) close(ctx->udp_fd);
+    ctx_release(ctx);
+    return NULL;
+}
+
+/** @brief Run thread of a ctx created by cwist_webrtc_ctx_new(). */
+static void *ctx_thread_main(void *arg) {
+    cwist_webrtc_ctx *ctx = arg;
+    cwist_reactor_run(ctx->reactor);
     return NULL;
 }
 
-/** @fn cwist_webrtc_ctx_port
- * @brief Get the UDP port a context is bound to.
- * @param ctx  Context created by cwist_webrtc_ctx_new().
- * @return  Bound port number.
+cwist_webrtc_ctx *cwist_webrtc_ctx_new(uint16_t port) {
+    cwist_reactor_t *reactor = cwist_reactor_create();
+    if (!reactor) return NULL;
+    cwist_webrtc_ctx *ctx = ctx_create(reactor, true, port);
+    if (!ctx) {
+        cwist_reactor_destroy(reactor);
+        return NULL;
+    }
+    if (!ctx_arm_udp_slot(ctx) || pthread_create(&ctx->thread, NULL, &ctx_thread_main, ctx) != 0) {
+        cwist_webrtc_ctx_free(ctx);
+        return NULL;
+    }
+    ctx->thread_running = true;
+    return ctx;
+}
+
+cwist_webrtc_ctx *cwist_webrtc_ctx_new_on(cwist_reactor_t *reactor, uint16_t port) {
+    cwist_webrtc_ctx *ctx = ctx_create(reactor, false, port);
+    if (!ctx) return NULL;
+    if (!ctx_arm_udp_slot(ctx)) {
+        close(ctx->udp_fd);
+        ctx->udp_fd = -1;
+        ctx_release(ctx);
+        return NULL;
+    }
+    return ctx;
+}
+
+/**
+ * @brief Teardown on a caller-run reactor (owner thread).  If the read slot
+ *        is pending, a datagram to ourselves wakes it and its callback closes
+ *        the fd; otherwise the fd is closed here.
  */
+static void ctx_teardown_on_reactor(cwist_webrtc_ctx *ctx) {
+    ctx_teardown_owner(ctx);
+    if (ctx->udp_armed && ctx->udp_fd >= 0) {
+        struct sockaddr_in self;
+        memset(&self, 0, sizeof(self));
+        self.sin_family = AF_INET;
+        self.sin_port = htons(ctx->port);
+        self.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+        uint8_t b = 0;
+        if (sendto(ctx->udp_fd, &b, 1, 0, (struct sockaddr *)&self, sizeof(self)) == 1) return;
+        /* Could not wake it: cancel the slot and close here. */
+        cwist_reactor_del(ctx->reactor, ctx->udp_fd);
+        ctx->udp_armed = false;
+        ctx_release(ctx); /* the slot's reference */
+    }
+    ctx_close_fd(ctx);
+}
+
+/** @brief Posted teardown for a caller-run reactor. */
+static void ctx_free_post_cb(void *arg) {
+    cwist_webrtc_ctx *ctx = arg;
+    ctx_teardown_on_reactor(ctx);
+    ctx_release(ctx); /* the post's reference */
+}
+
+/** @brief Posted teardown for an owned reactor: close conns, stop the loop. */
+static void ctx_stop_post_cb(void *arg) {
+    cwist_webrtc_ctx *ctx = arg;
+    ctx_teardown_owner(ctx);
+    cwist_reactor_stop(ctx->reactor);
+}
+
+void cwist_webrtc_ctx_free(cwist_webrtc_ctx *ctx) {
+    if (!ctx) return;
+    if (getpid() != ctx->owner_pid) {
+        /* A forked child's copy: its reactor thread exists only in the
+         * parent, and the sockets and memory are the parent's to release.
+         * Leave the copy alone; it goes away with this process. */
+        return;
+    }
+    if (ctx->owns_reactor) {
+        if (ctx->thread_running) {
+            ctx->free_post.cb = ctx_stop_post_cb;
+            ctx->free_post.ctx = ctx;
+            cwist_reactor_post(ctx->reactor, &ctx->free_post);
+            pthread_join(ctx->thread, NULL);
+        }
+        /* The loop is gone (or never ran): finish here.  Destroying the
+         * reactor drains leftover posts, which only drop references. */
+        ctx_teardown_owner(ctx);
+        if (ctx->udp_fd >= 0) {
+            close(ctx->udp_fd);
+            ctx->udp_fd = -1;
+        }
+        cwist_reactor_destroy(ctx->reactor);
+        ctx->reactor = NULL;
+        ctx_release(ctx);
+        return;
+    }
+    if (tl_ctx == ctx) {
+        /* Called from one of our own callbacks: tear down inline; the read
+         * slot's callback finishes the fd close after we return. */
+        ctx_teardown_on_reactor(ctx);
+    } else {
+        ctx_retain(ctx);
+        ctx->free_post.cb = ctx_free_post_cb;
+        ctx->free_post.ctx = ctx;
+        cwist_reactor_post(ctx->reactor, &ctx->free_post);
+        pthread_mutex_lock(&ctx->free_mu);
+        while (!ctx->fd_closed) pthread_cond_wait(&ctx->free_cv, &ctx->free_mu);
+        pthread_mutex_unlock(&ctx->free_mu);
+    }
+    ctx_release(ctx);
+}
+
+/* ---- public accessors ---- */
+
 uint16_t cwist_webrtc_ctx_port(const cwist_webrtc_ctx *ctx) {
     return ctx->port;
 }
 
-/** @fn cwist_webrtc_ctx_fingerprint
- * @brief Get the DTLS certificate fingerprint for SDP signaling.
- * @param ctx  Context created by cwist_webrtc_ctx_new().
- * @return  Colon-separated SHA-256 fingerprint string; valid for the ctx lifetime.
- */
 const char *cwist_webrtc_ctx_fingerprint(const cwist_webrtc_ctx *ctx) {
     return ctx->fingerprint;
 }
 
-/** @fn cwist_webrtc_ctx_set_message_handler
- * @brief Register the DataChannel message callback.
- * @param ctx  Context to configure.
- * @param cb  Callback invoked per inbound message, or NULL to disable.
- * @param user  Opaque pointer passed back to @p cb.
- *
- * The callback runs on the ctx event-loop thread.
- */
 void cwist_webrtc_ctx_set_message_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_message_cb cb,
                                           void *user) {
     ctx->msg_cb = cb;
     ctx->msg_user = user;
 }
 
-/** @fn cwist_webrtc_ctx_set_channel_handler
- * @brief Register the DataChannel-opened callback.
- * @param ctx  Context to configure.
- * @param cb  Callback invoked when a DataChannel opens, or NULL to disable.
- * @param user  Opaque pointer passed back to @p cb.
- *
- * The callback runs on the ctx event-loop thread.
- */
 void cwist_webrtc_ctx_set_channel_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_channel_cb cb,
                                           void *user) {
     ctx->ch_cb = cb;
     ctx->ch_user = user;
 }
 
-/** @fn cwist_webrtc_handle_offer
- * @brief Parse an SDP offer and build the corresponding ICE-lite answer.
- * @param ctx  Context that will own the new connection.
- * @param offer  Remote SDP offer text.
- * @param answer_out  Buffer receiving the generated answer SDP.
- * @param out_len  Capacity of @p answer_out.
- * @return  0 on success, -1 on SDP parse failure or allocation failure.
- *
- * Parks a server-role connection with a wildcard remote address and the
- * offer's ICE credentials; handle_stun() later adopts it when the peer's
- * STUN request nominates a candidate. Also flips the ctx into ICE-lite
- * server mode.
- */
+void cwist_webrtc_ctx_set_close_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_close_cb cb,
+                                        void *user) {
+    ctx->close_cb = cb;
+    ctx->close_user = user;
+}
+
+int cwist_webrtc_ctx_connection_count(const cwist_webrtc_ctx *ctx) {
+    return atomic_load(&ctx->ready_count);
+}
+
+size_t cwist_webrtc_conn_buffered_amount(const cwist_webrtc_conn *conn) {
+    return atomic_load_explicit(&conn->buffered, memory_order_relaxed);
+}
+
+/* ---- offer / connect (any thread) ---- */
+
+/** @brief Owner side of handle_offer: park the conn until its first nomination. */
+static void park_post_cb(void *arg) {
+    struct cwist_webrtc_conn *conn = arg;
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    if (ctx->closed) {
+        cwist_webrtc_conn_release(conn);
+        return;
+    }
+    cwist_webrtc_ctx *prev = ctx_enter(ctx);
+    conn->hnext = ctx->parked;
+    ctx->parked = conn;
+    conn->registered = true;
+    conn->parked = true; /* the post's reference becomes the list's */
+    conn_rearm_timer(conn);
+    ctx_leave(ctx, prev);
+}
+
 int cwist_webrtc_handle_offer(cwist_webrtc_ctx *ctx, const char *offer, char *answer_out,
                               size_t out_len) {
     cwist_sdp_info info;
-    if (cwist_sdp_parse(offer, strlen(offer), &info) < 0)
-        return -1;
-    struct sockaddr_in wildcard;
-    memset(&wildcard, 0, sizeof(wildcard));
-    wildcard.sin_family = AF_INET;
-    wildcard.sin_addr.s_addr = htonl(INADDR_ANY);
-    wildcard.sin_port = 0;
-    struct cwist_webrtc_conn *conn = conn_new(ctx, &wildcard, 1);
-    if (!conn)
+    if (!ctx || !offer || cwist_sdp_parse(offer, strlen(offer), &info) < 0) return -1;
+    if (getpid() != ctx->owner_pid)
+        return -1; /* inherited across fork(): this process has no loop for it */
+    const char *mid = info.mid[0] ? info.mid : "0";
+    if (cwist_sdp_write_answer(answer_out, out_len, ctx->fingerprint, ctx->ice_ufrag, ctx->ice_pwd,
+                               mid, ctx->host_ip, ctx->port) < 0)
         return -1;
+    struct cwist_webrtc_conn *conn = conn_new(ctx, NULL, true);
+    if (!conn) return -1;
     snprintf(conn->peer_ufrag, sizeof(conn->peer_ufrag), "%s", info.ice_ufrag);
     snprintf(conn->peer_pwd, sizeof(conn->peer_pwd), "%s", info.ice_pwd);
-    cwist_webrtc_ctx_add_conn(ctx, conn);
-    ctx->ice_lite_server = 1;
+    atomic_store(&ctx->ice_lite_server, 1);
+    conn->reg_post.cb = park_post_cb;
+    conn->reg_post.ctx = conn;
+    cwist_reactor_post(ctx->reactor, &conn->reg_post);
+    return 0;
+}
 
-    const char *mid = info.mid[0] ? info.mid : "0";
-    return cwist_sdp_write_answer(answer_out, out_len, ctx->fingerprint, ctx->ice_ufrag,
-                                  ctx->ice_pwd, mid, ctx->host_ip, ctx->port);
+/** @brief Owner side of connect: register and send the first Binding request. */
+static void connect_post_cb(void *arg) {
+    struct cwist_webrtc_conn *conn = arg;
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    if (ctx->closed) {
+        cwist_webrtc_conn_release(conn);
+        return;
+    }
+    cwist_webrtc_ctx *prev = ctx_enter(ctx);
+    table_insert(ctx, conn); /* the post's reference becomes the table's */
+    conn_send_stun_request(conn);
+    conn_rearm_timer(conn);
+    ctx_leave(ctx, prev);
 }
 
-/** @fn cwist_webrtc_ctx_connection_count
- * @brief Count connections whose SCTP association is ready.
- * @param ctx  Context to inspect.
- * @return  Number of connections with sctp_ready set.
- */
-int cwist_webrtc_ctx_connection_count(const cwist_webrtc_ctx *ctx) {
-    int n = 0;
-    for (struct cwist_webrtc_conn *c = ctx->conns; c; c = c->next) {
-        if (c->sctp_ready)
-            n++;
-    }
-    return n;
-}
-
-/** @fn cwist_webrtc_conn_send
- * @brief Queue a message for delivery on a DataChannel.
- * @param conn  Target connection.
- * @param channel_id  SCTP stream id / DataChannel id.
- * @param data  Payload bytes.
- * @param len  Payload length; must be at most 1 MiB.
- * @param type  CWIST_WEBRTC_DATA_STRING or CWIST_WEBRTC_DATA_BINARY.
- * @return  0 if queued, -1 if the message is too large or allocation failed.
- *
- * The payload is copied; if the SCTP association is not ready yet the
- * message waits in the connection's pending-send queue and is flushed by
- * conn_service(). Thread-safe; wakes the event loop.
- */
-int cwist_webrtc_conn_send(cwist_webrtc_conn *conn, uint16_t channel_id, const uint8_t *data,
-                           size_t len, cwist_webrtc_data_type type) {
-    if (len > 1 << 20)
-        return -1;
-    pending_send *p = cwist_alloc(sizeof(*p) + len);
-    if (!p)
-        return -1;
-    p->next = NULL;
-    p->channel = channel_id;
-    p->is_string = type == CWIST_WEBRTC_DATA_STRING;
-    p->len = len;
-    memcpy(p->data, data, len);
+cwist_webrtc_conn *cwist_webrtc_connect(cwist_webrtc_ctx *ctx, const struct sockaddr_in *remote,
+                                        const char *peer_ufrag, const char *peer_pwd) {
+    struct cwist_webrtc_conn *conn = conn_new(ctx, remote, false);
+    if (!conn) return NULL;
+    snprintf(conn->peer_ufrag, sizeof(conn->peer_ufrag), "%s", peer_ufrag);
+    snprintf(conn->peer_pwd, sizeof(conn->peer_pwd), "%s", peer_pwd);
+    RAND_bytes(conn->stun_txid, sizeof(conn->stun_txid));
+    cwist_webrtc_conn_retain(conn); /* returned to the caller */
+    conn->reg_post.cb = connect_post_cb;
+    conn->reg_post.ctx = conn;
+    cwist_reactor_post(ctx->reactor, &conn->reg_post);
+    return conn;
+}
+
+/* ---- send / close (any thread) ---- */
 
-    pthread_mutex_lock(&conn->ctx->lock);
+/** @brief Append a message to the pending queue (owner thread). */
+static void conn_enqueue(struct cwist_webrtc_conn *conn, cwist_webrtc_msg *m) {
+    m->next = NULL;
     if (conn->pending_tail)
-        conn->pending_tail->next = p;
+        conn->pending_tail->next = m;
     else
-        conn->pending_head = p;
-    conn->pending_tail = p;
-    pthread_mutex_unlock(&conn->ctx->lock);
-    cwist_webrtc_conn_wake(conn->ctx);
-    return 0;
+        conn->pending_head = m;
+    conn->pending_tail = m;
 }
 
-/** @fn cwist_webrtc_conn_close
- * @brief Request asynchronous teardown of a connection.
- * @param conn  Connection to close.
+/**
+ * @brief Owner side of foreign-thread sends: move the whole inbox onto the
+ *        pending queue in arrival order, then flush once at ctx_leave().
  *
- * Only flags the connection; the event loop unlinks and frees it on its
- * next timer pass. Wakes the event loop.
+ * Producers push onto conn->inbox and post the kick only when the inbox was
+ * empty, so a burst of sends from another thread costs one wakeup, one
+ * service pass and one sendmmsg instead of one each.
  */
-void cwist_webrtc_conn_close(cwist_webrtc_conn *conn) {
-    conn->closed = true;
-    cwist_webrtc_conn_wake(conn->ctx);
+static void kick_post_cb(void *arg) {
+    struct cwist_webrtc_conn *conn = arg;
+    /* acq_rel: producers that see the emptied inbox re-post kick_post, which
+     * must happen after the reactor's reads of it for this run. */
+    cwist_webrtc_msg *list = atomic_exchange_explicit(&conn->inbox, NULL, memory_order_acq_rel);
+    cwist_webrtc_msg *rev = NULL;
+    while (list) {
+        cwist_webrtc_msg *next = list->next;
+        list->next = rev;
+        rev = list;
+        list = next;
+    }
+    if (atomic_load(&conn->detached)) {
+        while (rev) {
+            cwist_webrtc_msg *next = rev->next;
+            atomic_fetch_sub_explicit(&conn->buffered, rev->len, memory_order_relaxed);
+            cwist_free(rev);
+            rev = next;
+        }
+    } else if (rev) {
+        cwist_webrtc_ctx *prev = ctx_enter(conn->ctx);
+        ctx_note_activity(conn->ctx);
+        while (rev) {
+            cwist_webrtc_msg *next = rev->next;
+            conn_enqueue(conn, rev);
+            rev = next;
+        }
+        conn_mark_dirty(conn);
+        ctx_leave(conn->ctx, prev);
+    }
+    cwist_webrtc_conn_release(conn); /* the kick's reference */
 }
 
-/** @fn cwist_webrtc_ctx_free
- * @brief Stop a context's event loop and release all of its resources.
- * @param ctx  Context to destroy; NULL is accepted as a no-op.
- *
- * Signals the thread to stop and joins it, frees every connection, then
- * closes the UDP socket and wake pipe and frees the SSL_CTXs, certificate,
- * and private key.
+/**
+ * @brief Reserve @p len bytes of the conn's send queue.
+ * @return true if it fits under CWIST_WEBRTC_MAX_BUFFERED.
  */
-void cwist_webrtc_ctx_free(cwist_webrtc_ctx *ctx) {
-    if (!ctx)
-        return;
-    ctx->stop = true;
-    cwist_webrtc_conn_wake(ctx);
-    if (ctx->thread_running)
-        pthread_join(ctx->thread, NULL);
-
-    struct cwist_webrtc_conn *conn = ctx->conns;
-    while (conn) {
-        struct cwist_webrtc_conn *next = conn->next;
-        conn_free(conn);
-        conn = next;
-    }
-    if (ctx->udp_fd >= 0)
-        close(ctx->udp_fd);
-    if (ctx->wake_pipe[0] >= 0)
-        close(ctx->wake_pipe[0]);
-    if (ctx->wake_pipe[1] >= 0)
-        close(ctx->wake_pipe[1]);
-    if (ctx->server_ssl_ctx)
-        SSL_CTX_free(ctx->server_ssl_ctx);
-    if (ctx->client_ssl_ctx)
-        SSL_CTX_free(ctx->client_ssl_ctx);
-    if (ctx->cert)
-        X509_free(ctx->cert);
-    if (ctx->pkey)
-        EVP_PKEY_free(ctx->pkey);
-    pthread_mutex_destroy(&ctx->lock);
-    cwist_free(ctx);
+static bool conn_reserve(struct cwist_webrtc_conn *conn, size_t len) {
+    size_t before = atomic_fetch_add_explicit(&conn->buffered, len, memory_order_relaxed);
+    if (before + len > CWIST_WEBRTC_MAX_BUFFERED && before > 0) {
+        atomic_fetch_sub_explicit(&conn->buffered, len, memory_order_relaxed);
+        return false;
+    }
+    return true;
 }
 
-/* ---- internal client-role dialer (loopback tests) ---- */
+int cwist_webrtc_conn_send(cwist_webrtc_conn *conn, uint16_t channel_id, const uint8_t *data,
+                           size_t len, cwist_webrtc_data_type type) {
+    if (!conn || atomic_load(&conn->detached) || len > CWIST_WEBRTC_MAX_MESSAGE ||
+        (len > 0 && !data))
+        return -1;
+    int is_string = type == CWIST_WEBRTC_DATA_STRING;
+    if (cwist_webrtc_on_owner(conn->ctx)) {
+        ctx_note_activity(conn->ctx);
+        if (conn->sctp_ready && !conn->pending_head) {
+            int rc = cwist_sctp_send(conn, channel_id, data, len, is_string);
+            if (rc == 0) return 0;
+            if (rc < 0) return -1;
+        }
+        if (!conn_reserve(conn, len)) return -1;
+        cwist_webrtc_msg *m = cwist_alloc(sizeof(*m) + len);
+        if (!m) {
+            atomic_fetch_sub_explicit(&conn->buffered, len, memory_order_relaxed);
+            return -1;
+        }
+        m->conn = conn;
+        m->len = (uint32_t)len;
+        m->channel = channel_id;
+        m->is_string = (uint8_t)is_string;
+        if (len) memcpy(m->data, data, len);
+        conn_enqueue(conn, m);
+        conn_mark_dirty(conn);
+        return 0;
+    }
+    if (!conn_reserve(conn, len)) return -1;
+    cwist_webrtc_msg *m = cwist_alloc(sizeof(*m) + len);
+    if (!m) {
+        atomic_fetch_sub_explicit(&conn->buffered, len, memory_order_relaxed);
+        return -1;
+    }
+    m->conn = conn;
+    m->len = (uint32_t)len;
+    m->channel = channel_id;
+    m->is_string = (uint8_t)is_string;
+    if (len) memcpy(m->data, data, len);
+    cwist_webrtc_msg *head = atomic_load_explicit(&conn->inbox, memory_order_acquire);
+    do {
+        m->next = head;
+    } while (!atomic_compare_exchange_weak_explicit(&conn->inbox, &head, m, memory_order_acq_rel,
+                                                    memory_order_acquire));
+    if (head == NULL) {
+        /* First message of a batch: wake the owner.  The kick node is reused;
+         * the reactor reads its link before running it, so re-posting from
+         * here while the previous kick runs is safe. */
+        cwist_webrtc_conn_retain(conn);
+        conn->kick_post.cb = kick_post_cb;
+        conn->kick_post.ctx = conn;
+        cwist_reactor_post(conn->ctx->reactor, &conn->kick_post);
+    }
+    return 0;
+}
 
-/** @fn cwist_webrtc_connect
- * @brief Open a client-role connection to a remote ICE-lite endpoint.
- * @param ctx  Context to dial from.
- * @param remote  Remote UDP address.
- * @param peer_ufrag  Remote ICE username fragment for STUN credentials.
- * @param peer_pwd  Remote ICE password for STUN MESSAGE-INTEGRITY.
- * @return  New connection in state CWIST_CONN_STUN, or NULL on failure.
- *
- * Intended for loopback tests. Starts STUN binding requests immediately and
- * performs DTLS in client (active) role once a binding response arrives.
- */
-cwist_webrtc_conn *cwist_webrtc_connect(cwist_webrtc_ctx *ctx, const struct sockaddr_in *remote,
-                                        const char *peer_ufrag, const char *peer_pwd) {
-    struct cwist_webrtc_conn *conn = conn_new(ctx, remote, 0);
-    if (!conn)
-        return NULL;
-    snprintf(conn->peer_ufrag, sizeof(conn->peer_ufrag), "%s", peer_ufrag);
-    snprintf(conn->peer_pwd, sizeof(conn->peer_pwd), "%s", peer_pwd);
-    RAND_bytes(conn->stun_txid, sizeof(conn->stun_txid));
-    cwist_webrtc_ctx_add_conn(ctx, conn);
-    conn_send_stun_request(conn);
-    cwist_webrtc_conn_wake(ctx);
-    return conn;
+/** @brief Owner side of a foreign-thread close. */
+static void close_post_cb(void *arg) {
+    struct cwist_webrtc_conn *conn = arg;
+    if (!atomic_load(&conn->detached)) {
+        cwist_webrtc_ctx *prev = ctx_enter(conn->ctx);
+        conn->close_requested = true;
+        conn_mark_dirty(conn);
+        ctx_leave(conn->ctx, prev);
+    }
+    atomic_store(&conn->close_posted, false);
+    cwist_webrtc_conn_release(conn);
+}
+
+void cwist_webrtc_conn_close(cwist_webrtc_conn *conn) {
+    if (!conn || atomic_load(&conn->detached)) return;
+    if (cwist_webrtc_on_owner(conn->ctx)) {
+        /* Deferred to the end of the round: the caller may be inside a
+         * message callback that is still reading this conn's SCTP socket. */
+        conn->close_requested = true;
+        conn_mark_dirty(conn);
+        return;
+    }
+    if (atomic_exchange(&conn->close_posted, true)) return;
+    cwist_webrtc_conn_retain(conn);
+    conn->close_post.cb = close_post_cb;
+    conn->close_post.ctx = conn;
+    cwist_reactor_post(conn->ctx->reactor, &conn->close_post);
 }
diff --git a/src/net/webrtc/webrtc_internal.h b/src/net/webrtc/webrtc_internal.h
index 46599074c..8b90f9782 100644
--- a/src/net/webrtc/webrtc_internal.h
+++ b/src/net/webrtc/webrtc_internal.h
@@ -1,157 +1,235 @@
-/** @file webrtc_internal.h
- * @brief Shared internals between the webrtc module translation units.
+/**
+ * @file webrtc_internal.h
+ * @brief Internal types and cross-file helpers for the WebRTC DataChannel
+ *        module (ICE-lite + DTLS + SCTP over UDP).
+ *
+ * Threading model: every ctx is bound to one cwist reactor, and all mutable
+ * ctx/conn state is touched only on that reactor's run thread (the "owner").
+ * Other threads reach a ctx or conn through cwist_reactor_post() nodes:
+ * cwist_webrtc_conn_send(), cwist_webrtc_conn_close(),
+ * cwist_webrtc_handle_offer() and cwist_webrtc_ctx_free() all post when
+ * called off the owner thread and act inline when called on it.
+ *
+ * Lifetimes are reference counted.  A conn holds a reference on its ctx; the
+ * ctx's connection table holds a reference on each registered conn; every
+ * in-flight post holds a reference on the object it targets.  Teardown
+ * (closing sockets, freeing SSL/SCTP state) happens on the owner thread and is
+ * separate from freeing the memory, which happens on the last release.
  *
- * Declares the private connection/context types plus the internal API split
- * across ice.c, sdp.c, dtls.c, sctp.c, and webrtc.c. Not part of the public
- *  interface.
+ * Not part of the public API; only the module's .c files and its tests
+ * include this header.
  */
 #ifndef __CWIST_WEBRTC_INTERNAL_H__
 #define __CWIST_WEBRTC_INTERNAL_H__
 
 #include 
+#include 
 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
-#include 
+#include 
 
 struct socket; /* usrsctp */
-struct pending_send;
 
-/**
- * @brief High-level ICE/DTLS/SCTP connection state of a single peer.
- */
+/** Largest DataChannel message accepted either way; advertised in the SDP as
+ *  a=max-message-size. */
+#define CWIST_WEBRTC_MAX_MESSAGE 262144
+/** Per-conn cap on bytes queued by cwist_webrtc_conn_send() and not yet taken
+ *  by SCTP.  Sends beyond it fail instead of growing the queue. */
+#define CWIST_WEBRTC_MAX_BUFFERED (4u << 20)
+/** Largest UDP datagram handled; bigger ones are dropped. */
+#define CWIST_WEBRTC_DGRAM_MAX 2048
+/** Datagrams received per recvmmsg() call. */
+#define CWIST_WEBRTC_RX_BATCH 32
+/** Datagrams queued before a sendmmsg() flush. */
+#define CWIST_WEBRTC_TX_BATCH 64
+
+/** Connection lifecycle. */
 typedef enum {
-    CWIST_CONN_STUN = 0, /* waiting for ICE nomination (or response, client) */
-    CWIST_CONN_DTLS = 1, /* DTLS handshake in flight */
-    CWIST_CONN_ESTABLISHED = 2, /* DTLS up, SCTP pending or up */
-    CWIST_CONN_DEAD = 3
+    CWIST_CONN_STUN = 0,        /**< Waiting for ICE nomination (or a STUN response, client). */
+    CWIST_CONN_DTLS = 1,        /**< DTLS handshake in flight. */
+    CWIST_CONN_ESTABLISHED = 2, /**< DTLS up; SCTP pending or up. */
+    CWIST_CONN_CLOSED = 3       /**< Torn down. */
 } cwist_conn_state;
 
 /**
- * @brief Internal state for one WebRTC peer connection (single remote address).
+ * @brief A DataChannel message on its way into SCTP.
  *
- * Owned by a cwist_webrtc_ctx; linked into ctx->conns. Guarded by ctx->lock
- * for list membership and callback-relevant fields.
- */
+ * Foreign-thread sends travel through the conn's inbox in the same node they
+ * wait in on the pending queue, so a queued send costs one allocation.
+ */
+typedef struct cwist_webrtc_msg {
+    struct cwist_webrtc_msg *next; /**< Inbox / pending-queue link. */
+    struct cwist_webrtc_conn *conn; /**< Target conn (referenced while posted). */
+    uint32_t len;                 /**< Payload length in bytes. */
+    uint16_t channel;             /**< SCTP stream id. */
+    uint8_t is_string;            /**< Non-zero for PPID 50 (string). */
+    uint8_t data[];               /**< Payload. */
+} cwist_webrtc_msg;
+
+/** @brief One peer: ICE state, DTLS session, SCTP association. */
 struct cwist_webrtc_conn {
-    cwist_webrtc_ctx *ctx;
-    int is_server_role; /* 1: passive DTLS role (answering), 0: active (offering) */
-    cwist_conn_state state;
-    bool sctp_ready;
-    struct sockaddr_in remote;
-    socklen_t remote_len;
+    cwist_webrtc_ctx *ctx;   /**< Owning ctx (referenced). */
+    atomic_int refs;         /**< Reference count; memory is freed at zero. */
+    atomic_bool detached;    /**< Set at teardown; API calls fail fast. */
+    atomic_bool close_posted; /**< A foreign close post is in flight. */
+    atomic_size_t buffered;  /**< Bytes queued by sends, not yet taken by SCTP. */
 
-    SSL *ssl;
+    bool is_server_role;     /**< true: answering (DTLS passive); false: offering. */
+    cwist_conn_state state;  /**< Lifecycle state. */
+    bool registered;         /**< In the ctx table or parked list. */
+    bool parked;             /**< Waiting for its first STUN check (remote unknown). */
+    bool sctp_ready;         /**< SCTP association up; sends go straight to SCTP. */
+    bool close_requested;    /**< Close at the end of the current service pass. */
+    bool dirty;              /**< On the ctx dirty list. */
+    bool notified_open;      /**< The close handler is owed a call. */
 
-    struct socket *sctp_sock;
-    struct socket *sctp_acc; /* accepted socket on the answering side */
-    uint8_t *ch_state; /* 2 bits per channel: 0=opened_by_peer, 1=open_sent */
+    struct sockaddr_in remote;              /**< Nominated peer address. */
+    struct cwist_webrtc_conn *hnext;        /**< Hash-bucket / parked-list link. */
+    struct cwist_webrtc_conn *dirty_next;   /**< Dirty-list link. */
 
     /* ICE */
-    char peer_ufrag[64];
-    char peer_pwd[128];
-    uint8_t stun_txid[12];
-    struct timespec stun_next;
-    int stun_attempts;
+    char peer_ufrag[64];     /**< Remote ICE ufrag. */
+    char peer_pwd[128];      /**< Remote ICE password (client role signs with it). */
+    uint8_t stun_txid[12];   /**< Outstanding Binding request (client role). */
+    int stun_attempts;       /**< Binding requests sent so far (client role). */
+    uint32_t stun_rto_ms;    /**< Current retransmit timeout (client role). */
+
+    /* DTLS */
+    SSL *ssl;                /**< DTLS session (custom datagram BIO). */
+    const uint8_t *dtls_in;  /**< Datagram being fed to the BIO, or NULL. */
+    size_t dtls_in_len;      /**< Length of dtls_in. */
+
+    /* SCTP */
+    struct socket *sctp_sock; /**< Listening (server) or connected (client) socket. */
+    struct socket *sctp_acc;  /**< Accepted association socket (server role). */
+    uint8_t *ch_bits;        /**< 2 bits per stream: bit0 opened by peer, bit1 OPEN sent. */
+    uint32_t ch_bits_len;    /**< Size of ch_bits in bytes. */
+    uint8_t *rx_partial;     /**< Reassembly buffer for a message delivered in pieces. */
+    size_t rx_partial_len;   /**< Bytes held in rx_partial. */
+    size_t rx_partial_cap;   /**< Capacity of rx_partial. */
+    cwist_webrtc_msg *pending_head; /**< Sends waiting for SCTP. */
+    cwist_webrtc_msg *pending_tail; /**< Tail of the pending queue. */
+    _Atomic(cwist_webrtc_msg *) inbox; /**< Foreign-thread sends (LIFO stack). */
 
-    bool closed;
-    struct pending_send *pending_head;
-    struct pending_send *pending_tail;
-    struct cwist_webrtc_conn *next;
+    /* Timers and posts */
+    cwist_reactor_timer_t timer;   /**< STUN retransmit / DTLS / liveness / park expiry. */
+    uint64_t created_ns;           /**< Creation time (handshake deadline). */
+    uint64_t last_rx_ns;           /**< Last datagram from the peer (liveness). */
+    cwist_reactor_post_t reg_post;   /**< Registration post (offer/connect). */
+    cwist_reactor_post_t close_post; /**< Foreign-thread close post. */
+    cwist_reactor_post_t kick_post;  /**< Drains the inbox on the owner thread. */
 };
 
-/**
- * @brief Shared context: UDP socket, certificate, callbacks, and connection list.
- *
- * One context owns one UDP socket and serves all peer connections on it.
- * A background thread pumps ICE retransmits and DTLS/SCTP I/O until
- * thread_running goes false.
- */
+/** @brief A UDP endpoint bound to one reactor, serving many conns. */
 struct cwist_webrtc_ctx {
-    int udp_fd;
-    uint16_t port;
-    pthread_t thread;
-    bool thread_running;
-    int wake_pipe[2];
-    bool stop;
-    int ice_lite_server; /* set once an offer has been answered */
+    cwist_reactor_t *reactor; /**< Reactor whose run thread owns this ctx. */
+    bool owns_reactor;        /**< Created by cwist_webrtc_ctx_new(); run on our thread. */
+    pid_t owner_pid;          /**< Process that created the ctx (fork detection). */
+    pthread_t thread;         /**< Run thread when owns_reactor. */
+    bool thread_running;      /**< thread was started. */
+    atomic_int refs;          /**< Reference count (user + conns + posts + armed fd slot). */
+
+    int udp_fd;               /**< Non-blocking UDP socket. */
+    uint16_t port;            /**< Bound UDP port. */
+    bool udp_armed;           /**< A read slot is pending on the reactor. */
+    bool closed;              /**< Teardown ran (owner thread). */
+    atomic_int ice_lite_server; /**< An offer was answered: accept Binding requests. */
+
+    X509 *cert;               /**< Ephemeral DTLS certificate. */
+    EVP_PKEY *pkey;           /**< Its private key. */
+    SSL_CTX *server_ssl_ctx;  /**< DTLS server (passive) context. */
+    SSL_CTX *client_ssl_ctx;  /**< DTLS client (active) context. */
+    char fingerprint[128];    /**< SHA-256 fingerprint of cert, SDP form. */
+    char ice_ufrag[16];       /**< Local ICE ufrag. */
+    char ice_pwd[64];         /**< Local ICE password. */
+    char host_ip[64];         /**< Best-guess local IPv4 for the SDP host candidate. */
 
-    X509 *cert;
-    EVP_PKEY *pkey;
-    SSL_CTX *server_ssl_ctx;
-    SSL_CTX *client_ssl_ctx;
-    char fingerprint[128];
-    char ice_ufrag[16];
-    char ice_pwd[64];
-    char host_ip[64]; /* best-guess local IPv4 for SDP host candidates */
+    cwist_webrtc_message_cb msg_cb; /**< Message handler. */
+    void *msg_user;                 /**< Its user pointer. */
+    cwist_webrtc_channel_cb ch_cb;  /**< Channel-open handler. */
+    void *ch_user;                  /**< Its user pointer. */
+    cwist_webrtc_close_cb close_cb; /**< Connection-closed handler. */
+    void *close_user;               /**< Its user pointer. */
 
-    cwist_webrtc_message_cb msg_cb;
-    void *msg_user;
-    cwist_webrtc_channel_cb ch_cb;
-    void *ch_user;
+    /* Connection table, keyed by remote address. */
+    cwist_webrtc_conn **buckets; /**< Hash buckets (power of two). */
+    uint32_t nbuckets;           /**< Bucket count. */
+    uint32_t nconns;             /**< Conns in the table. */
+    cwist_webrtc_conn *parked;   /**< Answered offers not yet nominated. */
+    cwist_webrtc_conn *dirty;    /**< Conns to service at the end of this round. */
+    atomic_int ready_count;      /**< Conns with SCTP up. */
 
-    cwist_webrtc_conn *conns;
-    pthread_mutex_t lock;
+    /* SCTP clock */
+    cwist_reactor_timer_t sctp_timer; /**< Drives usrsctp timers while associations exist. */
+    int sctp_assocs;                  /**< Conns holding an SCTP socket. */
+    uint64_t last_activity_ns;        /**< Last datagram or send; picks the tick rate. */
+    bool sctp_fast;                   /**< The tick is armed at the fast rate. */
+
+    /* I/O */
+    uint8_t *rx_bufs;   /**< CWIST_WEBRTC_RX_BATCH datagram buffers. */
+    uint8_t *plain_buf; /**< Decrypted DTLS record buffer. */
+    uint8_t *sctp_buf;  /**< usrsctp_recvv() buffer (one full message). */
+    uint8_t *tx_bufs;   /**< CWIST_WEBRTC_TX_BATCH datagram buffers. */
+    size_t tx_len[CWIST_WEBRTC_TX_BATCH];             /**< Queued datagram lengths. */
+    struct sockaddr_in tx_addr[CWIST_WEBRTC_TX_BATCH]; /**< Queued destinations. */
+    uint32_t tx_n;      /**< Datagrams queued. */
+    uint64_t now_ns;    /**< Monotonic time cached at the start of a round. */
+
+    /* Teardown handshake for ctxs on a caller-run reactor. */
+    cwist_reactor_post_t free_post; /**< Teardown post. */
+    pthread_mutex_t free_mu;        /**< Guards fd_closed. */
+    pthread_cond_t free_cv;         /**< Signals fd_closed. */
+    bool fd_closed;                 /**< UDP socket closed by the owner. */
 };
 
-/** @{ @name STUN message types (ice.c) */
+/** @name STUN message types (ice.c) */
+/**@{*/
 #define CWIST_STUN_BINDING_REQUEST 0x0001  /**< STUN Binding request. */
 #define CWIST_STUN_BINDING_RESPONSE 0x0101 /**< STUN Binding success response. */
-/** @} */
+/**@}*/
 
-/**
- * @brief Check whether a datagram looks like a STUN message.
- * @param buf Datagram bytes.
- * @param len Datagram length.
- * @return 1 if @p len covers a STUN header and the magic cookie matches, else 0.
- */
+/** @name ICE-lite STUN (ice.c) */
+/**@{*/
+/** @brief Non-zero if @p buf looks like a STUN message (magic cookie, length). */
 int cwist_ice_stun_is_message(const uint8_t *buf, size_t len);
-/**
- * @brief Check whether a datagram is a STUN Binding request.
- * @return true if it is a STUN message with type CWIST_STUN_BINDING_REQUEST.
- */
+/** @brief true if @p buf is a STUN Binding request. */
 bool cwist_ice_stun_is_binding_request(const uint8_t *buf, size_t len);
-/**
- * @brief Check for the USE-CANDIDATE attribute in a Binding request.
- * @return true if the request carries USE-CANDIDATE (nominated pair).
- */
+/** @brief true if the request carries USE-CANDIDATE (nomination). */
 bool cwist_ice_stun_has_use_candidate(const uint8_t *buf, size_t len);
 /**
- * @brief Extract the remote ufrag from the USERNAME attribute ("remoteufrag:localufrag").
- * @param out Output buffer.
- * @param cap Capacity of @p out.
- * @return 0 on success, -1 if the attribute is missing/malformed.
+ * @brief Extract the sender's ufrag from USERNAME ("recipient:sender",
+ *        RFC 8445 section 7.2.2) -- the peer's SDP ice-ufrag.
+ * @return 0 on success, -1 if USERNAME is missing or has no ':'.
  */
 int cwist_ice_stun_get_remote_ufrag(const uint8_t *buf, size_t len, char *out, size_t cap);
 /**
- * @brief Validate MESSAGE-INTEGRITY of a request against pwd.
- * @retval 1 valid
- * @retval 0 invalid
+ * @brief Check MESSAGE-INTEGRITY (HMAC-SHA1 keyed with @p pwd).
+ * @return 1 if valid, 0 otherwise.
  */
 int cwist_ice_stun_validate_request(const uint8_t *buf, size_t len, const char *pwd);
 /**
- * @brief Build a binding response (XOR-MAPPED-ADDRESS + MI + fingerprint).
- * @return Message length, or -1 on failure/overflow.
+ * @brief Build a signed Binding success response with XOR-MAPPED-ADDRESS.
+ * @return Response length, or -1 if it does not fit in @p cap.
  */
 int cwist_ice_stun_build_response(uint8_t *out, size_t cap, const uint8_t *req, size_t req_len,
                                   const struct sockaddr_in *mapped, const char *pwd);
 /**
- * @brief Build a controlling binding request with USE-CANDIDATE.
- * @param txid 12-byte transaction ID to embed (must match the stored one).
- * @param username "localufrag:remoteufrag".
- * @return Message length, or -1 on failure.
+ * @brief Build an unsigned Binding request (USERNAME, USE-CANDIDATE, ...).
+ * @return Message length, or -1 if it does not fit in @p cap.
  */
 int cwist_ice_stun_build_request(uint8_t *out, size_t cap, const uint8_t txid[12],
                                  const char *username);
 /**
- * @brief Sign a built request (append MI + FINGERPRINT).
- * @return New message length, or -1 on failure.
+ * @brief Append MESSAGE-INTEGRITY and FINGERPRINT to a request.
+ * @return New message length, or -1 if it does not fit in @p cap.
  */
 int cwist_ice_stun_sign_request(uint8_t *msg, size_t cap, size_t msg_len, const char *pwd);
 /**
@@ -161,22 +239,11 @@ int cwist_ice_stun_sign_request(uint8_t *msg, size_t cap, size_t msg_len, const
  */
 int cwist_ice_stun_parse_response(const uint8_t *buf, size_t len, const uint8_t txid[12],
                                   const char *pwd, struct sockaddr_in *mapped);
-/**
- * @brief Generate random ICE ufrag/pwd credentials.
- * @param ufrag Output buffer for the ufrag.
- * @param ufrag_cap Capacity of @p ufrag.
- * @param pwd Output buffer for the password.
- * @param pwd_cap Capacity of @p pwd.
- */
+/** @brief Generate random ICE credentials (ice-chars only). */
 void cwist_ice_random_creds(char *ufrag, size_t ufrag_cap, char *pwd, size_t pwd_cap);
+/**@}*/
 
-/** @{ @name SDP parsing and serialization (sdp.c) */
-
-/**
- * @brief Parsed subset of a remote SDP description needed by the ICE-lite agent.
- *
- * Fields the parser does not find stay zeroed (memset at entry).
- */
+/** @brief Fields the ICE-lite agent needs from an SDP description. */
 typedef struct {
     char ice_ufrag[64];     /**< Remote ICE ufrag. */
     char ice_pwd[128];      /**< Remote ICE password. */
@@ -186,6 +253,8 @@ typedef struct {
     int has_lite;           /**< Non-zero if a=ice-lite was present. */
 } cwist_sdp_info;
 
+/** @name SDP (sdp.c) */
+/**@{*/
 /**
  * @brief Parse the fields the ICE-lite agent needs from an SDP description.
  * @param info Filled on return; missing fields stay zeroed.
@@ -193,154 +262,87 @@ typedef struct {
  */
 int cwist_sdp_parse(const char *sdp, size_t len, cwist_sdp_info *info);
 /**
- * @brief Serialize an SDP answer for the ICE-lite answering endpoint.
- *
- * The answer takes the passive DTLS role and advertises a host candidate for
- * @p host:@p port.
- * @return 0 on success, -1 if the output was truncated or encoding failed.
+ * @brief Write an ICE-lite, DTLS-passive DataChannel answer.
+ * @return 0 on success, -1 if it does not fit in @p cap.
  */
 int cwist_sdp_write_answer(char *out, size_t cap, const char *fingerprint, const char *ufrag,
                            const char *pwd, const char *mid, const char *host, uint16_t port);
 /**
- * @brief Serialize an SDP offer for the full-agent offering endpoint.
- *
- * The offer takes the active DTLS role; candidates are signalled separately.
- * @return 0 on success, -1 if the output was truncated or encoding failed.
+ * @brief Write a DTLS-active DataChannel offer (loopback tests).
+ * @return 0 on success, -1 if it does not fit in @p cap.
  */
 int cwist_sdp_write_offer(char *out, size_t cap, const char *fingerprint, const char *ufrag,
                           const char *pwd, const char *mid);
-/** @} */
-
-/** @{ @name DTLS certificate helpers (dtls.c) */
+/**@}*/
 
-/**
- * @brief Generate a self-signed EC (P-256) certificate/key pair for DTLS.
- * @param cert Receives the certificate (NULL on failure).
- * @param pkey Receives the private key (NULL on failure).
- * @return 0 on success, -1 on failure.
- */
+/** @name DTLS (dtls.c) */
+/**@{*/
+/** @brief Generate an ephemeral self-signed P-256 certificate. @return 0 or -1. */
 int cwist_dtls_generate_cert(X509 **cert, EVP_PKEY **pkey);
-/**
- * @brief Compute the SHA-256 fingerprint of a certificate in "AA:BB:..." form.
- * @param out Output buffer.
- * @param cap Capacity of @p out.
- * @return 0 on success, -1 on failure.
- */
+/** @brief SHA-256 fingerprint of @p cert in SDP form ("AB:CD:..."). @return 0 or -1. */
 int cwist_dtls_fingerprint(X509 *cert, char *out, size_t cap);
-/**
- * @brief Create a DTLS 1.2 SSL_CTX for one endpoint role.
- * @param is_server Non-zero for the passive (server) context; certificate and
- *                  key are only loaded in this mode.
- * @param cert Certificate used by the server context.
- * @param pkey Private key matching @p cert.
- * @return New SSL_CTX, or NULL on failure. Caller frees with SSL_CTX_free.
- * @note Peer fingerprint verification is not enforced; callers must match
- *       the SDP fingerprint out of band.
- */
+/** @brief DTLS 1.2 SSL_CTX for the server (with cert) or client role. */
 SSL_CTX *cwist_dtls_ctx_new(int is_server, X509 *cert, EVP_PKEY *pkey);
-/** @} */
-
-/** @{ @name SCTP / DataChannel layer (sctp.c) */
-
 /**
- * @brief Global usrsctp init (nothreads; caller pumps usrsctp_handle_timers).
- * @return 0 on success. Idempotent.
+ * @brief Datagram BIO for @p conn: reads return conn->dtls_in once, writes go
+ *        to cwist_webrtc_conn_dtls_out() one datagram per call.
  */
+BIO *cwist_dtls_bio_new(struct cwist_webrtc_conn *conn);
+/** @brief DTLS link MTU (bytes per datagram) used for handshake fragmentation. */
+unsigned int cwist_dtls_link_mtu(void);
+/**@}*/
+
+/** @name SCTP (sctp.c) */
+/**@{*/
+/** @brief One-time usrsctp initialisation (no threads, AF_CONN only). @return 0. */
 int cwist_sctp_global_init(void);
-/**
- * @brief Create the per-conn SCTP socket.
- * @param conn Connection whose sctp_sock/sctp_acc are set up.
- * @return 0 on success, -1 on failure.
- * @note If conn->is_server_role is false (active role), also connect() to
- *       start the association.
- */
+/** @brief Advance usrsctp's process-wide timer clock to now. Any thread. */
+void cwist_sctp_tick(void);
+/** @brief Create, bind and listen/connect the conn's SCTP socket. @return 0 or -1. */
 int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn);
-/**
- * @brief Tear down the connection's SCTP socket(s) and free related state.
- */
+/** @brief Abort the association and release SCTP state. Idempotent. */
 void cwist_sctp_conn_close(struct cwist_webrtc_conn *conn);
-/**
- * @brief Feed a DTLS-decrypted SCTP packet into usrsctp.
- */
+/** @brief Feed one decrypted SCTP packet to usrsctp. */
 void cwist_sctp_conn_input(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len);
 /**
- * @brief Accept the association (passive side) and drain queued inbound messages.
+ * @brief Accept (server role), then deliver every complete inbound message.
+ * @return 0 normally, -1 if the association is gone and the conn should close.
  */
-void cwist_sctp_conn_drain(struct cwist_webrtc_conn *conn);
-/**
- * @brief Send a DCEP control message on a channel.
- * @param type DCEP message type (e.g. OPEN, ACK).
- * @param label Channel label; only used for OPEN, truncated to 200 bytes.
- * @return 0 on success, -1 on failure.
- */
-int dcep_send(struct cwist_webrtc_conn *conn, uint16_t channel, uint8_t type,
-              const char *label);
+int cwist_sctp_conn_drain(struct cwist_webrtc_conn *conn);
+/** @brief true once the SCTP association is up. */
+bool cwist_sctp_assoc_established(struct cwist_webrtc_conn *conn);
 /**
- * @brief Send a DataChannel message; sends DCEP OPEN first when the channel is new.
- * @param is_string Non-zero for string messages, zero for binary.
- * @return 0 on success, -1 on failure.
+ * @brief Send one message, announcing the channel with DCEP OPEN first if we
+ *        are opening it.
+ * @return 0 when SCTP took it, 1 when the send buffer is full (retry after the
+ *         next inbound packet), -1 on a hard error (message dropped).
  */
 int cwist_sctp_send(struct cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
                     size_t len, int is_string);
-/**
- * @brief Check whether the SCTP association is established.
- * @return true once the association handshake completed.
- */
-bool cwist_sctp_assoc_established(struct cwist_webrtc_conn *conn);
-/** @} */
+/**@}*/
 
-/** @{ @name Cross-module helpers implemented in webrtc.c */
-
-/**
- * @brief Send a DTLS-encrypted SCTP packet to the peer over the ctx UDP socket.
- * @return Bytes sent, or -1 on error.
- */
-int cwist_webrtc_conn_send_raw(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len);
-/**
- * @brief usrsctp send callback entry point: encrypt via DTLS and transmit.
- * @param buffer SCTP packet produced by usrsctp.
- */
+/** @name Cross-file helpers (webrtc.c) */
+/**@{*/
+/** @brief true when the calling thread is servicing @p ctx right now. */
+bool cwist_webrtc_on_owner(const cwist_webrtc_ctx *ctx);
+/** @brief Queue one datagram to the conn's peer (owner thread). */
+void cwist_webrtc_conn_dtls_out(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len);
+/** @brief usrsctp output for @p conn; marshalled to the owner thread when needed. */
 void cwist_webrtc_conn_sctp_out(struct cwist_webrtc_conn *conn, const void *buffer, size_t len);
-/**
- * @brief Dispatch a channel-open notification to the user ch_cb.
- */
-void cwist_webrtc_conn_on_channel_open(struct cwist_webrtc_conn *conn, uint16_t channel,
-                                       const char *label);
-/**
- * @brief Dispatch an inbound DataChannel message to the user msg_cb.
- */
+/** @brief Deliver a complete inbound message to the message handler. */
 void cwist_webrtc_conn_on_message(struct cwist_webrtc_conn *conn, uint16_t channel,
                                   const uint8_t *data, size_t len, int is_string);
+/** @brief Report a channel the peer opened to the channel handler. */
+void cwist_webrtc_conn_on_channel_open(struct cwist_webrtc_conn *conn, uint16_t channel,
+                                       const char *label);
 /**
- * @brief Find a connection by remote address in the ctx list.
- * @return Matching connection, or NULL if none.
- */
-struct cwist_webrtc_conn *cwist_webrtc_ctx_find_conn(cwist_webrtc_ctx *ctx,
-                                                     const struct sockaddr_in *remote);
-/**
- * @brief Link a connection into the ctx connection list.
- * @note Takes ctx->lock internally.
- */
-void cwist_webrtc_ctx_add_conn(cwist_webrtc_ctx *ctx, struct cwist_webrtc_conn *conn);
-/**
- * @brief Kick off the DTLS handshake for a connection that nominated its pair.
- * @note Performs SSL_do_handshake work on the current state.
- */
-void cwist_webrtc_conn_start_dtls(struct cwist_webrtc_conn *conn);
-/**
- * @brief Write a byte to the ctx wake pipe to interrupt the pump thread's poll.
- */
-void cwist_webrtc_conn_wake(cwist_webrtc_ctx *ctx);
-
-/**
- * @brief Internal client-role dialer (used by loopback tests).
- *
- * Starts ICE against a remote ICE-lite endpoint described by peer_ufrag/peer_pwd
- * from its answer.
- * @return New connection, or NULL on failure.
+ * @brief Offering-side dialer used by the loopback tests: registers a client
+ *        conn to @p remote and starts ICE.
+ * @return A conn reference owned by the caller (cwist_webrtc_conn_release()),
+ *         or NULL.
  */
 cwist_webrtc_conn *cwist_webrtc_connect(cwist_webrtc_ctx *ctx, const struct sockaddr_in *remote,
                                         const char *peer_ufrag, const char *peer_pwd);
-/** @} */
+/**@}*/
 
 #endif
diff --git a/tests/bench_webrtc.c b/tests/bench_webrtc.c
new file mode 100644
index 000000000..2846037ef
--- /dev/null
+++ b/tests/bench_webrtc.c
@@ -0,0 +1,182 @@
+/** @file bench_webrtc.c
+ * @brief WebRTC DataChannel micro-benchmark over UDP loopback.
+ *
+ * Measures three things between an answering ICE-lite ctx and an in-process
+ * offering peer:
+ *   1. idle cost: context switches and CPU time of a ctx with no traffic,
+ *   2. one-way throughput for small and large messages,
+ *   3. round-trip latency (p50/p99) of sequential ping/pong.
+ *
+ * Usage: ./bench_webrtc [small_count] [large_count]
+ */
+#include 
+
+#include "../src/net/webrtc/webrtc_internal.h"
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+typedef struct {
+    atomic_long msgs;
+    atomic_long bytes;
+    atomic_int echo;
+} bench_server;
+
+typedef struct {
+    atomic_long pongs;
+} bench_client;
+
+static uint64_t now_ns(void) {
+    struct timespec ts;
+    clock_gettime(CLOCK_MONOTONIC, &ts);
+    return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
+}
+
+static void server_on_message(cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                              size_t len, cwist_webrtc_data_type type, void *user) {
+    (void)type;
+    bench_server *s = user;
+    atomic_fetch_add(&s->msgs, 1);
+    atomic_fetch_add(&s->bytes, (long)len);
+    if (atomic_load(&s->echo))
+        cwist_webrtc_conn_send(conn, channel, data, len, CWIST_WEBRTC_DATA_BINARY);
+}
+
+static void client_on_message(cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                              size_t len, cwist_webrtc_data_type type, void *user) {
+    (void)type;
+    (void)conn;
+    (void)channel;
+    (void)data;
+    (void)len;
+    bench_client *c = user;
+    atomic_fetch_add(&c->pongs, 1);
+}
+
+/* Retry while the send queue pushes back. */
+static void send_blocking(cwist_webrtc_conn *conn, const uint8_t *data, size_t len) {
+    while (cwist_webrtc_conn_send(conn, 1, data, len, CWIST_WEBRTC_DATA_BINARY) < 0) usleep(50);
+}
+
+/* Spin (yielding) rather than sleep: usleep() granularity would otherwise
+ * dominate the RTT numbers. */
+static int wait_until(atomic_long *v, long target, int timeout_ms) {
+    uint64_t deadline = now_ns() + (uint64_t)timeout_ms * 1000000ull;
+    while (atomic_load(v) < target) {
+        if (now_ns() > deadline) return -1;
+        sched_yield();
+    }
+    return 0;
+}
+
+static void bench_throughput(cwist_webrtc_conn *conn, bench_server *s, long count, size_t size) {
+    uint8_t *buf = malloc(size);
+    memset(buf, 0x5A, size);
+    long base = atomic_load(&s->msgs);
+    uint64_t t0 = now_ns();
+    for (long i = 0; i < count; i++) send_blocking(conn, buf, size);
+    int rc = wait_until(&s->msgs, base + count, 60000);
+    double sec = (double)(now_ns() - t0) / 1e9;
+    long got = atomic_load(&s->msgs) - base;
+    printf("throughput %7zu B x %-7ld: %s%.0f msg/s, %.1f MB/s (%.2fs)\n", size, count,
+           rc ? "TIMEOUT " : "", (double)got / sec, (double)got * (double)size / sec / 1e6, sec);
+    free(buf);
+}
+
+static int cmp_u64(const void *a, const void *b) {
+    uint64_t x = *(const uint64_t *)a, y = *(const uint64_t *)b;
+    return x < y ? -1 : x > y;
+}
+
+static void bench_latency(cwist_webrtc_conn *conn, bench_server *s, bench_client *c, int rounds) {
+    atomic_store(&s->echo, 1);
+    uint64_t *rtt = malloc(sizeof(*rtt) * (size_t)rounds);
+    int done = 0;
+    for (int i = 0; i < rounds; i++) {
+        long base = atomic_load(&c->pongs);
+        uint64_t t0 = now_ns();
+        send_blocking(conn, (const uint8_t *)"ping", 4);
+        if (wait_until(&c->pongs, base + 1, 5000) < 0) break;
+        rtt[done++] = now_ns() - t0;
+    }
+    atomic_store(&s->echo, 0);
+    if (done) {
+        qsort(rtt, (size_t)done, sizeof(*rtt), cmp_u64);
+        printf("latency    rtt x %d: p50 %.1f us, p99 %.1f us, max %.1f us\n", done,
+               (double)rtt[done / 2] / 1e3, (double)rtt[(size_t)done * 99 / 100] / 1e3,
+               (double)rtt[done - 1] / 1e3);
+    }
+    free(rtt);
+}
+
+static void bench_idle(void) {
+    cwist_webrtc_ctx *idle = cwist_webrtc_ctx_new(0);
+    assert(idle);
+    usleep(100000);
+    struct rusage r0, r1;
+    getrusage(RUSAGE_SELF, &r0);
+    usleep(1000000);
+    getrusage(RUSAGE_SELF, &r1);
+    long csw = (r1.ru_nvcsw - r0.ru_nvcsw) + (r1.ru_nivcsw - r0.ru_nivcsw);
+    double cpu_ms = (double)(r1.ru_utime.tv_sec - r0.ru_utime.tv_sec) * 1e3 +
+                    (double)(r1.ru_utime.tv_usec - r0.ru_utime.tv_usec) / 1e3 +
+                    (double)(r1.ru_stime.tv_sec - r0.ru_stime.tv_sec) * 1e3 +
+                    (double)(r1.ru_stime.tv_usec - r0.ru_stime.tv_usec) / 1e3;
+    printf("idle ctx   1s           : %ld context switches, %.2f ms CPU\n", csw, cpu_ms);
+    cwist_webrtc_ctx_free(idle);
+}
+
+int main(int argc, char **argv) {
+    long small_count = argc > 1 ? atol(argv[1]) : 200000;
+    long large_count = argc > 2 ? atol(argv[2]) : 2000;
+
+    bench_idle();
+
+    bench_server s = {0};
+    bench_client c = {0};
+    cwist_webrtc_ctx *server = cwist_webrtc_ctx_new(0);
+    cwist_webrtc_ctx *client = cwist_webrtc_ctx_new(0);
+    assert(server && client);
+    cwist_webrtc_ctx_set_message_handler(server, &server_on_message, &s);
+    cwist_webrtc_ctx_set_message_handler(client, &client_on_message, &c);
+
+    char offer[1024], answer[2048];
+    assert(cwist_sdp_write_offer(offer, sizeof(offer),
+                                 "00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:"
+                                 "44:55:66:77:88:99:AA:BB:CC:DD:EE:FF",
+                                 "benchfrag", "benchpassword0123456789012", "0") == 0);
+    assert(cwist_webrtc_handle_offer(server, offer, answer, sizeof(answer)) == 0);
+    cwist_sdp_info ans;
+    assert(cwist_sdp_parse(answer, strlen(answer), &ans) == 0);
+
+    struct sockaddr_in remote;
+    memset(&remote, 0, sizeof(remote));
+    remote.sin_family = AF_INET;
+    remote.sin_port = htons(cwist_webrtc_ctx_port(server));
+    remote.sin_addr.s_addr = htonl(0x7F000001);
+    cwist_webrtc_conn *conn = cwist_webrtc_connect(client, &remote, ans.ice_ufrag, ans.ice_pwd);
+    assert(conn);
+
+    /* Warm-up: open the channel and wait for the first message to land. */
+    send_blocking(conn, (const uint8_t *)"warm", 4);
+    if (wait_until(&s.msgs, 1, 10000) < 0) {
+        fprintf(stderr, "bench_webrtc: connection did not come up\n");
+        return 1;
+    }
+
+    bench_throughput(conn, &s, small_count, 64);
+    bench_throughput(conn, &s, small_count / 4, 1024);
+    bench_throughput(conn, &s, large_count, 65536);
+    bench_latency(conn, &s, &c, 2000);
+
+    cwist_webrtc_ctx_free(client);
+    cwist_webrtc_ctx_free(server);
+    return 0;
+}
diff --git a/tests/browser/webrtc_chromium.mjs b/tests/browser/webrtc_chromium.mjs
new file mode 100644
index 000000000..3bd42ddae
--- /dev/null
+++ b/tests/browser/webrtc_chromium.mjs
@@ -0,0 +1,150 @@
+// Browser interop check for the WebRTC DataChannel stack.
+//
+// Drives headless Chromium over the DevTools protocol (no Playwright needed;
+// Node >= 22 for the global WebSocket) against the running example server
+// (example/webrtc, POST /offer signaling, DataChannel echo) and checks:
+//   - the DataChannel opens (ICE-lite + DTLS + SCTP + DCEP with a real browser),
+//   - a text message comes back as a string with the same content,
+//   - a 200 000-byte binary message comes back byte for byte,
+//   - an empty string comes back empty,
+// then reports the average round trip over 200 small messages.
+//
+// Usage: node tests/browser/webrtc_chromium.mjs [url]
+//   CHROMIUM=/path/to/chromium overrides the browser binary.
+// `make test_webrtc_browser` builds and starts the example, then runs this.
+import { spawn } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { setTimeout as sleep } from 'node:timers/promises';
+
+const url = process.argv[2] || 'http://localhost:8080/';
+const port = 9300 + Math.floor(Math.random() * 500);
+const profile = mkdtempSync(join(tmpdir(), 'cwist-webrtc-cdp-'));
+const chrome = spawn(process.env.CHROMIUM || 'chromium', [
+  '--headless=new', `--remote-debugging-port=${port}`, '--no-first-run',
+  '--no-default-browser-check', `--user-data-dir=${profile}`, 'about:blank',
+], { stdio: 'ignore' });
+
+async function pageTarget() {
+  for (let i = 0; i < 50; i++) {
+    try {
+      const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
+      const page = list.find((t) => t.type === 'page');
+      if (page) return page;
+    } catch {
+      /* not up yet */
+    }
+    await sleep(200);
+  }
+  throw new Error('chromium did not start');
+}
+
+function cdpClient(ws) {
+  let nextId = 0;
+  const pending = new Map();
+  ws.onmessage = (ev) => {
+    const msg = JSON.parse(ev.data);
+    if (msg.id && pending.has(msg.id)) {
+      pending.get(msg.id)(msg);
+      pending.delete(msg.id);
+    }
+  };
+  return (method, params = {}) =>
+    new Promise((resolve) => {
+      const id = ++nextId;
+      pending.set(id, resolve);
+      ws.send(JSON.stringify({ id, method, params }));
+    });
+}
+
+// Runs inside the page.
+const pageScript = `(async () => {
+  const t0 = performance.now();
+  const pc = new RTCPeerConnection();
+  const dc = pc.createDataChannel('echo');
+  dc.binaryType = 'arraybuffer';
+  const opened = new Promise((res, rej) => {
+    dc.onopen = res;
+    setTimeout(() => rej(new Error('open timeout: ice=' + pc.iceConnectionState +
+                                   ' conn=' + pc.connectionState)), 15000);
+  });
+  await pc.setLocalDescription(await pc.createOffer());
+  const r = await fetch('/offer', { method: 'POST', body: pc.localDescription.sdp });
+  if (!r.ok) throw new Error('offer rejected: HTTP ' + r.status);
+  await pc.setRemoteDescription({ type: 'answer', sdp: await r.text() });
+  await opened;
+  const openMs = performance.now() - t0;
+
+  const inbox = [];
+  let wake = null;
+  dc.onmessage = (ev) => { inbox.push(ev.data); if (wake) wake(); };
+  const next = () => new Promise((res, rej) => {
+    if (inbox.length) return res(inbox.shift());
+    const timer = setTimeout(() => rej(new Error('echo timeout')), 10000);
+    wake = () => { wake = null; clearTimeout(timer); res(inbox.shift()); };
+  });
+
+  dc.send('hello cwist');
+  const text = await next();
+
+  const big = new Uint8Array(200000);
+  for (let i = 0; i < big.length; i++) big[i] = (i * 31) & 255;
+  dc.send(big);
+  const bigEcho = await next();
+  const bigView = bigEcho instanceof ArrayBuffer ? new Uint8Array(bigEcho) : null;
+  let bigOk = !!bigView && bigView.length === big.length;
+  for (let i = 0; bigOk && i < big.length; i++) if (bigView[i] !== big[i]) bigOk = false;
+
+  dc.send('');
+  const empty = await next();
+
+  const rt0 = performance.now();
+  for (let i = 0; i < 200; i++) { dc.send('p' + i); await next(); }
+  const rttMs = (performance.now() - rt0) / 200;
+  pc.close();
+  return JSON.stringify({
+    open_ms: Math.round(openMs),
+    text_type: typeof text, text,
+    big_ok: bigOk, big_len: bigView ? bigView.length : -1,
+    empty_type: typeof empty, empty_len: typeof empty === 'string' ? empty.length : -1,
+    avg_rtt_ms: Number(rttMs.toFixed(3)),
+  });
+})()`;
+
+let failed = false;
+try {
+  const page = await pageTarget();
+  const ws = new WebSocket(page.webSocketDebuggerUrl);
+  await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
+  const send = cdpClient(ws);
+  await send('Page.enable');
+  await send('Page.navigate', { url });
+  await sleep(1000);
+  const res = await send('Runtime.evaluate', {
+    expression: pageScript, awaitPromise: true, returnByValue: true,
+  });
+  ws.close();
+  if (res.result.exceptionDetails) {
+    const d = res.result.exceptionDetails;
+    throw new Error(d.exception?.description || d.text);
+  }
+  const out = JSON.parse(res.result.result.value);
+  console.log('webrtc_chromium:', JSON.stringify(out));
+  const checks = [
+    ['text echoed as a string', out.text_type === 'string' && out.text === 'hello cwist'],
+    ['200000-byte binary echoed intact', out.big_ok],
+    ['empty string echoed', out.empty_type === 'string' && out.empty_len === 0],
+  ];
+  for (const [name, ok] of checks) {
+    console.log(`  ${ok ? 'ok  ' : 'FAIL'} ${name}`);
+    if (!ok) failed = true;
+  }
+} catch (err) {
+  console.log('webrtc_chromium: FAIL', err.message);
+  failed = true;
+} finally {
+  chrome.kill('SIGKILL');
+  rmSync(profile, { recursive: true, force: true });
+}
+process.exitCode = failed ? 1 : 0;
diff --git a/tests/test_webrtc.c b/tests/test_webrtc.c
index c8faabc67..d880c3b40 100644
--- a/tests/test_webrtc.c
+++ b/tests/test_webrtc.c
@@ -1,59 +1,184 @@
 /** @file test_webrtc.c
- * @brief End-to-end WebRTC DataChannel test over UDP loopback.
+ * @brief End-to-end WebRTC DataChannel tests over UDP loopback.
  *
- * Drives a full ICE + DTLS + SCTP exchange between two in-process cwist
- * webrtc contexts (one answering ICE-lite endpoint, one offering peer built
- * on the internal client role) and exchanges DataChannel messages in both
- * directions.
+ * Drives full ICE + DTLS + SCTP exchanges between in-process cwist webrtc
+ * contexts (an answering ICE-lite endpoint and an offering peer built on the
+ * internal client role) and checks:
+ *   - STUN and SDP building blocks,
+ *   - ping/pong in both directions, channel-open notification,
+ *   - a message near the 256 KiB limit (SCTP partial delivery reassembly)
+ *     and rejection of one over it,
+ *   - the 4 MiB send-queue cap while SCTP is not up,
+ *   - close from a foreign thread reaching the peer's close handler,
+ *   - a ctx attached to a caller-run reactor (cwist_webrtc_ctx_new_on) and
+ *     freed from another thread.
  */
 #include 
+#include 
 
 #include "../src/net/webrtc/webrtc_internal.h"
 
 #include 
+#include 
+#include 
+#include 
 #include 
+#include 
 #include 
+#include 
 #include 
 
+#define BIG_LEN 200000
+
 typedef struct test_peer {
-    int got_ping;
-    int got_pong;
-    int got_channel;
+    atomic_int got_ping;
+    atomic_int got_pong;
+    atomic_int got_channel;
+    atomic_int got_big;
+    atomic_int got_text;       /* "hi" received as a string */
+    atomic_int got_empty_text; /* empty string */
+    atomic_int got_empty_bin;  /* empty binary */
+    atomic_int closed;
     char label[64];
-    cwist_webrtc_conn *conn;
 } test_peer;
 
+static uint64_t now_ms(void) {
+    struct timespec ts;
+    clock_gettime(CLOCK_MONOTONIC, &ts);
+    return (uint64_t)ts.tv_sec * 1000ull + (uint64_t)ts.tv_nsec / 1000000ull;
+}
+
+/* Wait until *flag reaches want, up to timeout_ms. */
+static int wait_flag(atomic_int *flag, int want, int timeout_ms) {
+    uint64_t end = now_ms() + (uint64_t)timeout_ms;
+    while (atomic_load(flag) < want) {
+        if (now_ms() > end) return -1;
+        usleep(1000);
+    }
+    return 0;
+}
+
+static int big_payload_ok(const uint8_t *data, size_t len) {
+    if (len != BIG_LEN) return 0;
+    for (size_t i = 0; i < len; i++) {
+        if (data[i] != (uint8_t)(i * 31u)) return 0;
+    }
+    return 1;
+}
+
+/* Track string/empty messages; echo them back with the same type. */
+static int note_typed(test_peer *p, cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                      size_t len, cwist_webrtc_data_type type, int echo) {
+    if (len == 0) {
+        atomic_fetch_add(type == CWIST_WEBRTC_DATA_STRING ? &p->got_empty_text : &p->got_empty_bin,
+                         1);
+    } else if (len == 2 && memcmp(data, "hi", 2) == 0 && type == CWIST_WEBRTC_DATA_STRING) {
+        atomic_fetch_add(&p->got_text, 1);
+    } else {
+        return 0;
+    }
+    if (echo) assert(cwist_webrtc_conn_send(conn, channel, data, len, type) == 0);
+    return 1;
+}
+
 static void server_on_message(cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
-                              size_t len, void *user) {
+                              size_t len, cwist_webrtc_data_type type, void *user) {
     test_peer *p = user;
+    if (note_typed(p, conn, channel, data, len, type, 1)) return;
     if (len == 4 && memcmp(data, "ping", 4) == 0) {
-        p->got_ping = 1;
-        /* Reply on the same channel: server -> client direction. */
+        atomic_fetch_add(&p->got_ping, 1);
+        /* Reply on the same channel from inside the callback (owner path). */
         assert(cwist_webrtc_conn_send(conn, channel, (const uint8_t *)"pong", 4,
                                       CWIST_WEBRTC_DATA_BINARY) == 0);
+    } else if (big_payload_ok(data, len)) {
+        atomic_fetch_add(&p->got_big, 1);
+        /* Echo the big message back: exercises the owner-side queue. */
+        assert(cwist_webrtc_conn_send(conn, channel, data, len, CWIST_WEBRTC_DATA_BINARY) == 0);
     }
 }
 
 static void client_on_message(cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
-                              size_t len, void *user) {
+                              size_t len, cwist_webrtc_data_type type, void *user) {
     test_peer *p = user;
-    p->conn = conn;
+    if (note_typed(p, conn, channel, data, len, type, 0)) return;
     if (len == 4 && memcmp(data, "pong", 4) == 0)
-        p->got_pong = 1;
-    (void)channel;
+        atomic_fetch_add(&p->got_pong, 1);
+    else if (big_payload_ok(data, len))
+        atomic_fetch_add(&p->got_big, 1);
 }
 
 static void peer_on_channel(cwist_webrtc_conn *conn, uint16_t channel, const char *label,
                             void *user) {
     test_peer *p = user;
-    p->conn = conn;
-    p->got_channel = 1;
-    snprintf(p->label, sizeof(p->label), "%s", label);
+    (void)conn;
     (void)channel;
+    snprintf(p->label, sizeof(p->label), "%s", label);
+    atomic_store(&p->got_channel, 1);
 }
 
-int main(void) {
-    /* ICE/STUN unit checks: build, sign, validate, respond. */
+static void peer_on_close(cwist_webrtc_conn *conn, void *user) {
+    test_peer *p = user;
+    (void)conn;
+    atomic_fetch_add(&p->closed, 1);
+}
+
+/* Answer an offer on @p server and dial it from @p client.  The offer carries
+ * the client ctx's real ICE credentials, as a browser's would, so the server
+ * adopts the conn it parked for the offer. */
+static cwist_webrtc_conn *dial(cwist_webrtc_ctx *server, cwist_webrtc_ctx *client) {
+    char offer[1024];
+    assert(cwist_sdp_write_offer(offer, sizeof(offer),
+                                 "00:11:22:33:44:55:66:77:88:99:"
+                                 "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55",
+                                 client->ice_ufrag, client->ice_pwd, "0") == 0);
+    char answer[2048];
+    assert(cwist_webrtc_handle_offer(server, offer, answer, sizeof(answer)) == 0);
+    assert(strstr(answer, "a=ice-lite") != NULL);
+    assert(strstr(answer, "a=setup:passive") != NULL);
+    assert(strstr(answer, "webrtc-datachannel") != NULL);
+    assert(strstr(answer, "a=candidate:") != NULL);
+    cwist_sdp_info ans;
+    assert(cwist_sdp_parse(answer, strlen(answer), &ans) == 0);
+
+    struct sockaddr_in remote;
+    memset(&remote, 0, sizeof(remote));
+    remote.sin_family = AF_INET;
+    remote.sin_port = htons(cwist_webrtc_ctx_port(server));
+    remote.sin_addr.s_addr = htonl(0x7F000001);
+    cwist_webrtc_conn *conn = cwist_webrtc_connect(client, &remote, ans.ice_ufrag, ans.ice_pwd);
+    assert(conn != NULL);
+    return conn;
+}
+
+typedef struct {
+    cwist_reactor_post_t post;
+    cwist_webrtc_ctx *ctx;
+    atomic_int done;
+    int parked;
+    uint32_t nconns;
+} table_probe;
+
+static void table_probe_cb(void *arg) {
+    table_probe *p = arg;
+    p->parked = 0;
+    for (cwist_webrtc_conn *c = p->ctx->parked; c; c = c->hnext) p->parked++;
+    p->nconns = p->ctx->nconns;
+    atomic_store(&p->done, 1);
+}
+
+/* Read the server's connection table on its reactor thread. */
+static void probe_table(cwist_webrtc_ctx *ctx, int *parked, uint32_t *nconns) {
+    table_probe p = {.ctx = ctx};
+    p.post.cb = table_probe_cb;
+    p.post.ctx = &p;
+    assert(cwist_reactor_post(ctx->reactor, &p.post));
+    assert(wait_flag(&p.done, 1, 5000) == 0);
+    *parked = p.parked;
+    *nconns = p.nconns;
+}
+
+static void unit_checks(void) {
+    /* ICE/STUN: build, sign, validate, respond. */
     {
         uint8_t req[512];
         uint8_t txid[12];
@@ -68,8 +193,9 @@ int main(void) {
         assert(cwist_ice_stun_validate_request(req, (size_t)len, "testpassword0123456789") == 1);
         assert(cwist_ice_stun_validate_request(req, (size_t)len, "wrongpassword000000000") == 0);
         char rfrag[64];
+        /* USERNAME is ":"; the sender's ufrag comes back. */
         assert(cwist_ice_stun_get_remote_ufrag(req, (size_t)len, rfrag, sizeof(rfrag)) == 0);
-        assert(strcmp(rfrag, "rfrag") == 0);
+        assert(strcmp(rfrag, "lfrag") == 0);
 
         struct sockaddr_in mapped;
         memset(&mapped, 0, sizeof(mapped));
@@ -87,7 +213,7 @@ int main(void) {
         assert(parsed.sin_addr.s_addr == htonl(0x7F000001));
     }
 
-    /* SDP unit checks. */
+    /* SDP. */
     {
         char offer[1024];
         assert(cwist_sdp_write_offer(offer, sizeof(offer),
@@ -101,79 +227,174 @@ int main(void) {
         assert(strcmp(info.setup, "active") == 0);
         assert(info.has_lite == 0);
     }
+}
 
-    /* End-to-end: offer/answer + ICE + DTLS + SCTP + DataChannel ping/pong. */
+/* Ping/pong, channel open, big message, close handler: both ctxs on their
+ * own reactor threads. */
+static void test_end_to_end(void) {
     cwist_webrtc_ctx *server = cwist_webrtc_ctx_new(0);
     assert(server != NULL);
     assert(cwist_webrtc_ctx_port(server) > 0);
     assert(strchr(cwist_webrtc_ctx_fingerprint(server), ':') != NULL);
+    test_peer *sp = calloc(1, sizeof(*sp));
+    test_peer *cp = calloc(1, sizeof(*cp));
+    cwist_webrtc_ctx_set_message_handler(server, &server_on_message, sp);
+    cwist_webrtc_ctx_set_channel_handler(server, &peer_on_channel, sp);
+    cwist_webrtc_ctx_set_close_handler(server, &peer_on_close, sp);
 
-    test_peer server_peer = { 0 };
-    cwist_webrtc_ctx_set_message_handler(server, &server_on_message, &server_peer);
-    cwist_webrtc_ctx_set_channel_handler(server, &peer_on_channel, &server_peer);
+    cwist_webrtc_ctx *client = cwist_webrtc_ctx_new(0);
+    assert(client != NULL);
+    cwist_webrtc_ctx_set_message_handler(client, &client_on_message, cp);
+    cwist_webrtc_ctx_set_close_handler(client, &peer_on_close, cp);
 
-    char offer[1024];
-    assert(cwist_sdp_write_offer(offer, sizeof(offer), "00:11:22:33:44:55:66:77:88:99:"
-                                                       "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55",
-                                 "browserfrag", "browserpassword0123456789012", "0") == 0);
-    char answer[2048];
-    assert(cwist_webrtc_handle_offer(server, offer, answer, sizeof(answer)) == 0);
-    assert(strstr(answer, "a=ice-lite") != NULL);
-    assert(strstr(answer, "a=setup:passive") != NULL);
-    assert(strstr(answer, "webrtc-datachannel") != NULL);
-    assert(strstr(answer, "a=candidate:") != NULL);
+    cwist_webrtc_conn *conn = dial(server, client);
 
-    cwist_sdp_info ans;
-    assert(cwist_sdp_parse(answer, strlen(answer), &ans) == 0);
+    /* Queued before SCTP is up; flushed once the association comes up. */
+    assert(cwist_webrtc_conn_send(conn, 1, (const uint8_t *)"ping", 4, CWIST_WEBRTC_DATA_BINARY) ==
+           0);
+    assert(wait_flag(&sp->got_ping, 1, 10000) == 0);
+    assert(wait_flag(&sp->got_channel, 1, 1000) == 0);
+    assert(strcmp(sp->label, "dc1") == 0);
+    assert(wait_flag(&cp->got_pong, 1, 5000) == 0);
+    assert(cwist_webrtc_ctx_connection_count(server) == 1);
+    assert(cwist_webrtc_ctx_connection_count(client) == 1);
+    /* The conn parked by handle_offer was adopted, not duplicated. */
+    int parked;
+    uint32_t nconns;
+    probe_table(server, &parked, &nconns);
+    assert(parked == 0);
+    assert(nconns == 1);
 
-    test_peer client_peer = { 0 };
-    cwist_webrtc_ctx *client = cwist_webrtc_ctx_new(0);
-    assert(client != NULL);
-    cwist_webrtc_ctx_set_message_handler(client, &client_on_message, &client_peer);
+    /* Second exchange on the established channel. */
+    assert(cwist_webrtc_conn_send(conn, 1, (const uint8_t *)"ping", 4, CWIST_WEBRTC_DATA_BINARY) ==
+           0);
+    assert(wait_flag(&sp->got_ping, 2, 5000) == 0);
+    assert(wait_flag(&cp->got_pong, 2, 5000) == 0);
 
+    /* Large message both ways: more than one SCTP read, reassembled. */
+    uint8_t *big = malloc(BIG_LEN);
+    for (size_t i = 0; i < BIG_LEN; i++) big[i] = (uint8_t)(i * 31u);
+    assert(cwist_webrtc_conn_send(conn, 1, big, BIG_LEN, CWIST_WEBRTC_DATA_BINARY) == 0);
+    assert(wait_flag(&sp->got_big, 1, 10000) == 0);
+    assert(wait_flag(&cp->got_big, 1, 10000) == 0);
+    free(big);
+
+    /* Message types survive the round trip, including empty messages
+     * (RFC 8831 empty PPIDs). */
+    assert(cwist_webrtc_conn_send(conn, 1, (const uint8_t *)"hi", 2, CWIST_WEBRTC_DATA_STRING) ==
+           0);
+    assert(cwist_webrtc_conn_send(conn, 1, NULL, 0, CWIST_WEBRTC_DATA_STRING) == 0);
+    assert(cwist_webrtc_conn_send(conn, 1, NULL, 0, CWIST_WEBRTC_DATA_BINARY) == 0);
+    assert(wait_flag(&sp->got_text, 1, 5000) == 0);
+    assert(wait_flag(&sp->got_empty_text, 1, 5000) == 0);
+    assert(wait_flag(&sp->got_empty_bin, 1, 5000) == 0);
+    assert(wait_flag(&cp->got_text, 1, 5000) == 0);
+    assert(wait_flag(&cp->got_empty_text, 1, 5000) == 0);
+    assert(wait_flag(&cp->got_empty_bin, 1, 5000) == 0);
+
+    /* Over the advertised max-message-size: rejected up front. */
+    uint8_t *huge = calloc(1, CWIST_WEBRTC_MAX_MESSAGE + 1);
+    assert(cwist_webrtc_conn_send(conn, 1, huge, CWIST_WEBRTC_MAX_MESSAGE + 1,
+                                  CWIST_WEBRTC_DATA_BINARY) == -1);
+    free(huge);
+
+    /* Close from this (foreign) thread: our close handler runs, the SCTP
+     * ABORT reaches the server and its close handler runs too. */
+    cwist_webrtc_conn_close(conn);
+    assert(wait_flag(&cp->closed, 1, 5000) == 0);
+    assert(wait_flag(&sp->closed, 1, 5000) == 0);
+    assert(cwist_webrtc_ctx_connection_count(client) == 0);
+    assert(cwist_webrtc_ctx_connection_count(server) == 0);
+    /* The retained conn outlives its teardown; sends now fail fast. */
+    assert(cwist_webrtc_conn_send(conn, 1, (const uint8_t *)"late", 4, CWIST_WEBRTC_DATA_BINARY) ==
+           -1);
+    cwist_webrtc_conn_release(conn);
+
+    cwist_webrtc_ctx_free(client);
+    cwist_webrtc_ctx_free(server);
+    free(sp);
+    free(cp);
+}
+
+/* Sends queue while SCTP is down, up to the 4 MiB cap. */
+static void test_send_queue_cap(void) {
+    cwist_webrtc_ctx *server = cwist_webrtc_ctx_new(0);
+    cwist_webrtc_ctx *client = cwist_webrtc_ctx_new(0);
+    assert(server && client);
+    /* No offer answered on server: it ignores our Binding requests, so the
+     * conn stays in ICE and every send is queued. */
     struct sockaddr_in remote;
     memset(&remote, 0, sizeof(remote));
     remote.sin_family = AF_INET;
     remote.sin_port = htons(cwist_webrtc_ctx_port(server));
     remote.sin_addr.s_addr = htonl(0x7F000001);
+    cwist_webrtc_conn *conn = cwist_webrtc_connect(client, &remote, "nouser", "nopassword");
+    assert(conn);
 
-    cwist_webrtc_conn *conn = cwist_webrtc_connect(client, &remote, ans.ice_ufrag, ans.ice_pwd);
-    assert(conn != NULL);
+    static uint8_t chunk[65536];
+    size_t accepted = 0;
+    int rc;
+    while ((rc = cwist_webrtc_conn_send(conn, 1, chunk, sizeof(chunk), CWIST_WEBRTC_DATA_BINARY)) ==
+           0)
+        accepted += sizeof(chunk);
+    assert(accepted == CWIST_WEBRTC_MAX_BUFFERED);
+    assert(cwist_webrtc_conn_buffered_amount(conn) == CWIST_WEBRTC_MAX_BUFFERED);
+
+    cwist_webrtc_ctx_free(client);
+    /* Teardown dropped the queue. */
+    assert(cwist_webrtc_conn_buffered_amount(conn) == 0);
+    cwist_webrtc_conn_release(conn);
+    cwist_webrtc_ctx_free(server);
+}
 
-    /* Open channel 1 from the offering side and send "ping". */
-    assert(cwist_webrtc_conn_send(conn, 1, (const uint8_t *)"ping", 4,
-                                  CWIST_WEBRTC_DATA_BINARY) == 0);
+static void *reactor_main(void *arg) {
+    cwist_reactor_run(arg);
+    return NULL;
+}
 
-    int waited_ms = 0;
-    while (waited_ms < 10000 &&
-           !(server_peer.got_channel && client_peer.got_pong && server_peer.got_ping)) {
-        usleep(10000);
-        waited_ms += 10;
-    }
+/* A server ctx on a reactor this test runs itself, freed from this thread
+ * while the reactor keeps running. */
+static void test_external_reactor(void) {
+    cwist_reactor_t *reactor = cwist_reactor_create();
+    assert(reactor);
+    cwist_webrtc_ctx *server = cwist_webrtc_ctx_new_on(reactor, 0);
+    assert(server);
+    test_peer *sp = calloc(1, sizeof(*sp));
+    test_peer *cp = calloc(1, sizeof(*cp));
+    cwist_webrtc_ctx_set_message_handler(server, &server_on_message, sp);
+    cwist_webrtc_ctx_set_close_handler(server, &peer_on_close, sp);
+    pthread_t th;
+    assert(pthread_create(&th, NULL, reactor_main, reactor) == 0);
 
-    assert(server_peer.got_ping);
-    assert(server_peer.got_channel);
-    assert(strcmp(server_peer.label, "dc1") == 0);
-    assert(client_peer.got_pong);
-
-    /* Second message on the now-established channel, both directions again. */
-    server_peer.got_ping = 0;
-    client_peer.got_pong = 0;
-    assert(cwist_webrtc_conn_send(conn, 1, (const uint8_t *)"ping", 4,
-                                  CWIST_WEBRTC_DATA_BINARY) == 0);
-    waited_ms = 0;
-    while (waited_ms < 5000 && !(client_peer.got_pong && server_peer.got_ping)) {
-        usleep(10000);
-        waited_ms += 10;
-    }
-    assert(server_peer.got_ping);
-    assert(client_peer.got_pong);
+    cwist_webrtc_ctx *client = cwist_webrtc_ctx_new(0);
+    assert(client);
+    cwist_webrtc_ctx_set_message_handler(client, &client_on_message, cp);
+    cwist_webrtc_conn *conn = dial(server, client);
+    assert(cwist_webrtc_conn_send(conn, 2, (const uint8_t *)"ping", 4, CWIST_WEBRTC_DATA_BINARY) ==
+           0);
+    assert(wait_flag(&sp->got_ping, 1, 10000) == 0);
+    assert(wait_flag(&cp->got_pong, 1, 5000) == 0);
 
-    assert(cwist_webrtc_ctx_connection_count(server) >= 1);
+    /* Freed off the reactor thread: waits for the teardown, which runs the
+     * server's close handler on the reactor thread. */
+    cwist_webrtc_ctx_free(server);
+    assert(atomic_load(&sp->closed) == 1);
+
+    cwist_reactor_stop(reactor);
+    assert(pthread_join(th, NULL) == 0);
+    cwist_reactor_destroy(reactor);
 
+    cwist_webrtc_conn_release(conn);
     cwist_webrtc_ctx_free(client);
-    cwist_webrtc_ctx_free(server);
+    free(sp);
+    free(cp);
+}
 
+int main(void) {
+    unit_checks();
+    test_end_to_end();
+    test_send_queue_cap();
+    test_external_reactor();
     printf("test_webrtc: all assertions passed\n");
     return 0;
 }