Skip to main content

netget/server/webrtc/
mod.rs

1//! WebRTC server: real peer connections carrying **data channels only** (no media).
2//!
3//! # Signalling
4//!
5//! WebRTC needs an out-of-band channel to exchange SDP. This server embeds one: it binds a
6//! TCP listener on its configured port and speaks a tiny JSON protocol over WebSocket
7//! (`tokio-tungstenite`, already declared by the `webrtc` feature). One WebSocket
8//! connection carries exactly one peer:
9//!
10//! ```text
11//! peer  -> {"type":"offer","peer_id":"alice","sdp":{"type":"offer","sdp":"v=0\r\n..."}}
12//! netget-> {"type":"answer","peer_id":"alice","sdp":{"type":"answer","sdp":"v=0\r\n..."}}
13//! netget-> {"type":"rejected","peer_id":"alice","reason":"..."}        (LLM said no)
14//! netget-> {"type":"error","message":"..."}                            (malformed input)
15//! ```
16//!
17//! ICE is **not** trickled: the offerer must gather its candidates before sending the offer,
18//! and this server gathers all of its own before it answers. That removes the ICE-candidate
19//! relay entirely, which is what makes a self-contained single-socket signalling channel
20//! sufficient.
21//!
22//! The peer connection's lifetime is tied to its signalling WebSocket: when the socket
23//! closes, the `RTCPeerConnection` is closed too. That is deliberate — it gives deterministic
24//! cleanup for a protocol whose transport is otherwise invisible to the process.
25//!
26//! # Division of labour
27//!
28//! Rust owns the transport (webrtc-rs does ICE/DTLS/SCTP). The LLM decides *content*:
29//! whether to accept an offer at all (`webrtc_offer_received` -> `accept_offer` /
30//! `reject_offer`) and what to say on the data channel (`webrtc_peer_connected` and
31//! `webrtc_message_received` -> `send_message` / `disconnect` / `wait_for_more`). No raw
32//! SDP is ever handed to the model: it sees a structured summary of the offer and answers
33//! with a decision, and this file keeps the SDP.
34//!
35//! The offer path is **fail-closed**. An LLM error, or a reply with no decision action in
36//! it, rejects the offer; only an explicit `accept_offer` accepts. A model's explicit
37//! `reject_offer` carries its own reason, so refusal and silence are distinguishable in the
38//! signalling frame the peer receives.
39//!
40//! # Failure reaches the peer as a category, never as an error string
41//!
42//! Nothing derived from an internal error is written to the signalling socket or the data
43//! channel. A backend failure on the offer path rejects with
44//! [`crate::utils::WireFailure::prefixed_text`], and one on the data-channel path writes the
45//! same category text on the channel rather than going silent and leaving the peer waiting.
46//! The three outcomes stay apart in the log via a `decision=` tag: `model_reject` (the model
47//! refused), `fail_closed_no_decision` (the model answered nothing usable), and
48//! `fail_closed_backend_overloaded` / `fail_closed_backend_error` (the call itself failed —
49//! kept distinct so an operator can tell a saturated backend from a broken one, which is the
50//! `503`-vs-`500` distinction in a protocol that has no status codes of its own).
51
52pub mod actions;
53
54use anyhow::{Context, Result};
55use futures::{SinkExt, StreamExt};
56use std::collections::HashMap;
57use std::net::SocketAddr;
58use std::sync::Arc;
59use tokio::net::{TcpListener, TcpStream};
60use tokio::sync::{mpsc, Mutex};
61use tokio_tungstenite::{
62    accept_async_with_config,
63    tungstenite::{protocol::WebSocketConfig, Message},
64};
65
66/// Longest a peer may take to complete the signalling WebSocket upgrade.
67const SIGNALLING_HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
68
69/// Largest signalling message accepted, and this server's declared `max_inbound_bytes`.
70/// One SDP offer or answer; a large real one is a few kilobytes.
71///
72/// tungstenite enforces it on the length a frame header declares, before the payload is read,
73/// and on the reassembled message. It is the largest thing NetGet itself buffers from a peer:
74/// data-channel messages are read by webrtc-rs into a fixed 65 535-byte buffer (a larger one
75/// closes the channel inside the crate) and SCTP reassembly is capped by webrtc-sctp's 1 MiB
76/// receive window, which drops DATA once full. Over the limit the peer gets a WebSocket close
77/// with code 1009 (Message Too Big) and the connection ends.
78pub const SIGNALLING_MAX_MESSAGE_BYTES: usize = 256 * 1024;
79
80/// The close frame for a signalling message over [`SIGNALLING_MAX_MESSAGE_BYTES`]: RFC 6455's
81/// 1009, with a fixed reason.
82fn message_too_big_close() -> Message {
83    Message::Close(Some(tokio_tungstenite::tungstenite::protocol::CloseFrame {
84        code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Size,
85        reason: "message too big".into(),
86    }))
87}
88
89/// How long a signalling connection may go without a single frame from the peer.
90///
91/// The signalling WebSocket *is* the peer's lifetime here — the
92/// peer connection it negotiates is torn down when it closes — so between the answer and the end
93/// of the session it is normally silent while the data channel carries the traffic, and the
94/// handshake deadline says nothing about that stretch. The bound is on the peer's **liveness**, not its conversation: at half of it the server
95/// sends a Ping (`accept_bounded::watch_idle_with_probe`), every RFC 6455 endpoint answers a Ping
96/// with a Pong by itself — a browser tab, tungstenite, NetGet's own client — and any inbound
97/// frame resets the clock. What reaches the bound is a peer that has stopped reading or vanished
98/// without a FIN, and it is closed with 1001 ("going away") and `decision=idle_timeout`.
99///
100/// 600 seconds, the number `websocket` uses and for the same reason: NetGet's own WebRTC client
101/// (`src/client/webrtc/mod.rs`) does not read its signalling socket while its connected-event
102/// model turn runs, and a `manual` rule parks that turn for up to 300 seconds; twice that window
103/// keeps the operator's own peer. Declared as the `idle_timeout_secs` startup parameter.
104pub const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
105
106/// Largest number of data-channel events queued behind one in-flight LLM call.
107///
108/// Each queued event costs its own model round-trip when it is drained, so a peer
109/// flooding the channel during a slow call both grows this without bound and buys
110/// itself an unbounded number of calls. Dropping the oldest keeps the newest
111/// traffic, which is what a conversation cares about.
112const MAX_QUEUED_EVENTS: usize = 64;
113use tracing::{debug, error, info, trace, warn};
114use webrtc::api::interceptor_registry::register_default_interceptors;
115use webrtc::api::media_engine::MediaEngine;
116use webrtc::api::APIBuilder;
117use webrtc::data_channel::data_channel_message::DataChannelMessage;
118use webrtc::data_channel::RTCDataChannel;
119use webrtc::ice_transport::ice_server::RTCIceServer;
120use webrtc::interceptor::registry::Registry;
121use webrtc::peer_connection::configuration::RTCConfiguration;
122use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState;
123use webrtc::peer_connection::sdp::sdp_type::RTCSdpType;
124use webrtc::peer_connection::sdp::session_description::RTCSessionDescription;
125use webrtc::peer_connection::RTCPeerConnection;
126
127use crate::llm::action_helper::call_llm;
128use crate::llm::ollama_client::OllamaClient;
129use crate::llm::ActionResult;
130use crate::protocol::Event;
131use crate::server::connection::ConnectionId;
132use crate::state::app_state::AppState;
133use crate::state::ServerId;
134use crate::utils::WireFailure;
135use actions::{
136    WEBRTC_MESSAGE_RECEIVED_EVENT, WEBRTC_OFFER_RECEIVED_EVENT, WEBRTC_PEER_CONNECTED_EVENT,
137};
138
139/// Unique identifier for a WebRTC peer
140pub type PeerId = String;
141
142/// Longest peer identifier accepted from the wire.
143const MAX_PEER_ID_LEN: usize = 128;
144
145/// Concurrent signalling connections this server admits before it starts refusing.
146///
147/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. **`max_peers` is not this
148/// bound and cannot be**: it gates *accepted offers*, so it counts peers that have already
149/// completed a WebSocket handshake, sent an SDP offer and been approved by a handler, and a
150/// stranger who merely connects is nowhere near it. [`SIGNALLING_HANDSHAKE_TIMEOUT`] bounds
151/// how long one such stranger holds a socket, but ten seconds times an unbounded arrival rate
152/// is still unbounded, so this is the only bound on the *number* of pre-admission peers.
153///
154/// The house default sits above `max_peers` on purpose. A signalling connection carries
155/// exactly one peer for its lifetime (a second offer on the same socket is refused below), so
156/// in a healthy server the two counts are nearly the same; the headroom is what a peer
157/// negotiating *now* needs in order to reach `max_peers` and be told so in WebRTC's own
158/// vocabulary — a `Rejected` frame naming the limit — rather than being turned away at the
159/// accept with a generic 503. At the default `max_peers` of 32 that headroom is ample.
160///
161/// **Said plainly, because it is the one place these two numbers fight**: `max_peers` is a
162/// startup parameter and may be set as high as 65535, and above 256 this constant becomes the
163/// effective ceiling — offers 257 and beyond are refused at the accept rather than by the
164/// peer registry. That is deliberate rather than an oversight. `accept_bounded`'s own rule is
165/// that a bound decided by configuration is a bound an attacker can ask you to raise, so the
166/// resource cap stays fixed and the configurable one may only make the server *stricter*.
167const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
168
169/// The body of [`CONNECTION_CAP_REFUSAL`].
170const CONNECTION_CAP_BODY: &str = "Too many signalling connections, try again later.\n";
171
172/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
173///
174/// **WebRTC's own vocabulary does not reach this far, and HTTP's does.** This server has a
175/// `Rejected` signalling frame that names the reason, and it is what a peer over `max_peers`
176/// receives — but a `Rejected` frame is a *WebSocket text message*, and a peer refused at the
177/// accept has not completed the RFC 6455 upgrade, so it has no frame parser running to read
178/// one. Writing signalling JSON onto a socket still expecting an HTTP response is the mis-parse
179/// this codebase refuses to ship.
180///
181/// The handshake it *is* in the middle of is an ordinary HTTP/1.1 GET (RFC 6455 §4.1), so 503
182/// with `Retry-After` is inside the protocol rather than beside it, and every WebSocket client
183/// surfaces a non-101 status as a failed handshake carrying that code. Sending it before the
184/// request head has arrived is legal — RFC 9112 §3.3 lets a server answer before the request is
185/// complete — and `Connection: close` tells a client still writing to stop and read.
186static CONNECTION_CAP_REFUSAL: std::sync::LazyLock<Vec<u8>> = std::sync::LazyLock::new(|| {
187    format!(
188        "HTTP/1.1 503 Service Unavailable\r\nRetry-After: 30\r\nContent-Type: \
189         text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
190        CONNECTION_CAP_BODY.len(),
191        CONNECTION_CAP_BODY
192    )
193    .into_bytes()
194});
195
196/// Name used for the `ActionResult::Custom` carrying the model's offer decision.
197pub(crate) const OFFER_DECISION_RESULT: &str = "webrtc_offer_decision";
198
199/// Signalling frames exchanged over the WebSocket.
200#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
201#[serde(tag = "type")]
202pub enum WebRtcSignal {
203    /// Peer -> server: an SDP offer with all ICE candidates already gathered.
204    #[serde(rename = "offer")]
205    Offer {
206        peer_id: String,
207        /// Either `{"type":"offer","sdp":"..."}` or the raw SDP string.
208        sdp: serde_json::Value,
209    },
210
211    /// Server -> peer: the SDP answer, ICE candidates included.
212    #[serde(rename = "answer")]
213    Answer {
214        peer_id: String,
215        sdp: serde_json::Value,
216    },
217
218    /// Server -> peer: the LLM declined this offer.
219    #[serde(rename = "rejected")]
220    Rejected { peer_id: String, reason: String },
221
222    /// Server -> peer: the frame could not be processed at all.
223    #[serde(rename = "error")]
224    Error { message: String },
225}
226
227/// Connection state for LLM processing (one machine per peer)
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229enum ConnectionState {
230    Idle,
231    Processing,
232    Accumulating,
233}
234
235/// One data-channel event awaiting the LLM.
236#[derive(Debug, Clone)]
237enum PeerEvent {
238    Connected { channel_label: String },
239    Message { text: String, is_binary: bool },
240}
241
242/// Per-peer data
243struct PeerData {
244    state: ConnectionState,
245    /// Messages that arrived while an LLM call for this peer was in flight.
246    queued: Vec<PeerEvent>,
247    peer_connection: Arc<RTCPeerConnection>,
248    data_channel: Option<Arc<RTCDataChannel>>,
249    connection_id: ConnectionId,
250}
251
252/// WebRTC server data shared across all peers
253pub struct WebRtcServerData {
254    /// Peer connections indexed by peer ID
255    peers: Arc<Mutex<HashMap<PeerId, PeerData>>>,
256    /// WebRTC API for creating peer connections
257    api: Arc<webrtc::api::API>,
258    /// ICE server configuration (empty means host candidates only)
259    ice_servers: Vec<RTCIceServer>,
260    /// Refuse offers once this many peers are connected
261    max_peers: usize,
262}
263
264impl WebRtcServerData {
265    /// Build the webrtc-rs API object and peer table.
266    ///
267    /// `ice_server_urls` is empty by default: with no STUN/TURN configured the server offers
268    /// host candidates only, which is what works on localhost and a LAN, and — importantly —
269    /// contacts no external endpoint. A caller that needs NAT traversal passes STUN/TURN URLs
270    /// through the `ice_servers` startup parameter.
271    pub fn new(ice_server_urls: Vec<String>, max_peers: usize) -> Result<Self> {
272        let mut m = MediaEngine::default();
273        let registry = Registry::new();
274        let registry = register_default_interceptors(registry, &mut m)?;
275
276        let api = APIBuilder::new()
277            .with_media_engine(m)
278            .with_interceptor_registry(registry)
279            .build();
280
281        let ice_servers = ice_server_urls
282            .into_iter()
283            .map(|url| RTCIceServer {
284                urls: vec![url],
285                ..Default::default()
286            })
287            .collect();
288
289        Ok(Self {
290            peers: Arc::new(Mutex::new(HashMap::new())),
291            api: Arc::new(api),
292            ice_servers,
293            max_peers,
294        })
295    }
296
297    /// Accept an SDP offer from a peer and produce the answer.
298    ///
299    /// The peer is inserted into the peer table *before* the remote description is applied,
300    /// because `on_data_channel` can fire as soon as SCTP comes up and must find it there.
301    #[allow(clippy::too_many_arguments)]
302    async fn accept_offer(
303        self: &Arc<Self>,
304        peer_id: PeerId,
305        offer: RTCSessionDescription,
306        connection_id: ConnectionId,
307        signalling_addr: SocketAddr,
308        llm_client: OllamaClient,
309        app_state: Arc<AppState>,
310        status_tx: mpsc::UnboundedSender<String>,
311        server_id: ServerId,
312    ) -> Result<serde_json::Value> {
313        info!("WebRTC server accepting offer from peer {}", peer_id);
314
315        let config = RTCConfiguration {
316            ice_servers: self.ice_servers.clone(),
317            ..Default::default()
318        };
319
320        let peer_connection = Arc::new(
321            self.api
322                .new_peer_connection(config)
323                .await
324                .context("Failed to create RTCPeerConnection")?,
325        );
326
327        // Register the peer before anything can fire a callback that looks for it.
328        {
329            let mut peers = self.peers.lock().await;
330            peers.insert(
331                peer_id.clone(),
332                PeerData {
333                    state: ConnectionState::Idle,
334                    queued: Vec::new(),
335                    peer_connection: Arc::clone(&peer_connection),
336                    data_channel: None,
337                    connection_id,
338                },
339            );
340        }
341
342        let ctx = PeerCtx {
343            server_data: Arc::clone(self),
344            peer_id: peer_id.clone(),
345            connection_id,
346            server_id,
347            app_state: Arc::clone(&app_state),
348            llm_client,
349            status_tx: status_tx.clone(),
350        };
351
352        // Incoming data channel (the offerer creates it; we only ever accept one).
353        let dc_ctx = ctx.clone();
354        peer_connection.on_data_channel(Box::new(move |data_channel: Arc<RTCDataChannel>| {
355            let ctx = dc_ctx.clone();
356            Box::pin(async move {
357                let label = data_channel.label().to_string();
358                info!(
359                    "WebRTC server received data channel '{}' from peer {}",
360                    label, ctx.peer_id
361                );
362
363                {
364                    let mut peers = ctx.server_data.peers.lock().await;
365                    if let Some(peer) = peers.get_mut(&ctx.peer_id) {
366                        peer.data_channel = Some(Arc::clone(&data_channel));
367                    }
368                }
369
370                let open_ctx = ctx.clone();
371                let open_label = label.clone();
372                data_channel.on_open(Box::new(move || {
373                    let ctx = open_ctx.clone();
374                    let channel_label = open_label.clone();
375                    Box::pin(async move {
376                        info!(
377                            "WebRTC data channel '{}' open for peer {}",
378                            channel_label, ctx.peer_id
379                        );
380                        let _ = ctx
381                            .status_tx
382                            .send(format!("[SERVER] WebRTC peer {} connected", ctx.peer_id));
383                        let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
384                        ctx.handle_peer_event(PeerEvent::Connected { channel_label })
385                            .await;
386                    })
387                }));
388
389                let msg_ctx = ctx.clone();
390                data_channel.on_message(Box::new(move |msg: DataChannelMessage| {
391                    let ctx = msg_ctx.clone();
392                    Box::pin(async move {
393                        let text = String::from_utf8_lossy(&msg.data).to_string();
394                        trace!(
395                            "WebRTC server received {} bytes from peer {}",
396                            msg.data.len(),
397                            ctx.peer_id
398                        );
399                        ctx.record_traffic(msg.data.len() as u64, 0).await;
400                        ctx.handle_peer_event(PeerEvent::Message {
401                            text,
402                            is_binary: !msg.is_string,
403                        })
404                        .await;
405                    })
406                }));
407            })
408        }));
409
410        // Connection teardown.
411        let state_ctx = ctx.clone();
412        peer_connection.on_peer_connection_state_change(Box::new(
413            move |state: RTCPeerConnectionState| {
414                let ctx = state_ctx.clone();
415                Box::pin(async move {
416                    debug!("WebRTC peer {} connection state: {:?}", ctx.peer_id, state);
417                    if matches!(
418                        state,
419                        RTCPeerConnectionState::Failed
420                            | RTCPeerConnectionState::Closed
421                            | RTCPeerConnectionState::Disconnected
422                    ) {
423                        ctx.teardown(&format!("connection state {:?}", state)).await;
424                    }
425                })
426            },
427        ));
428
429        // Offer -> answer. Anything that fails from here on must not leave a half-registered
430        // peer behind, so each error path unregisters before returning.
431        let answer = match Self::negotiate(&peer_connection, offer).await {
432            Ok(answer) => answer,
433            Err(e) => {
434                ctx.teardown("negotiation failed").await;
435                return Err(e);
436            }
437        };
438
439        // Register the connection with the server instance for the TUI / access log.
440        use crate::state::server::{
441            ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
442        };
443        let now = crate::utils::clock::Instant::now();
444        let unspecified = SocketAddr::from(([0, 0, 0, 0], 0));
445        let conn_state = ServerConnectionState {
446            id: connection_id,
447            // WebRTC is peer-to-peer over ICE, so there is no single stable remote
448            // address for the *media* path; the signalling socket's address is the
449            // one thing that is both stable and true, so it is what the dashboard
450            // and the access log show.
451            //
452            // This comment used to say the address "is recorded by the caller
453            // instead". No caller did: the row read 0.0.0.0:0 for the life of
454            // every peer. A comment asserting that something happens elsewhere is
455            // worth checking — that is the whole reason this one survived.
456            remote_addr: signalling_addr,
457            local_addr: unspecified,
458            bytes_sent: 0,
459            bytes_received: 0,
460            packets_sent: 0,
461            packets_received: 0,
462            last_activity: now,
463            status: ConnectionStatus::Active,
464            status_changed_at: now,
465            protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
466                "peer_id": peer_id,
467                "state": "Idle",
468            })),
469        };
470        app_state
471            .add_connection_to_server(server_id, conn_state)
472            .await;
473
474        info!("WebRTC server generated SDP answer for peer {}", peer_id);
475        serde_json::to_value(&answer).context("Failed to serialise SDP answer")
476    }
477
478    /// Apply the offer, create the answer and wait for ICE gathering to finish.
479    async fn negotiate(
480        peer_connection: &Arc<RTCPeerConnection>,
481        offer: RTCSessionDescription,
482    ) -> Result<RTCSessionDescription> {
483        peer_connection
484            .set_remote_description(offer)
485            .await
486            .context("Peer's SDP offer was rejected by the WebRTC stack")?;
487
488        let answer = peer_connection
489            .create_answer(None)
490            .await
491            .context("Failed to create SDP answer")?;
492
493        // `gathering_complete_promise` must be taken before the local description is set,
494        // otherwise gathering can finish before anyone is listening and the promise never
495        // resolves.
496        let mut gather_complete = peer_connection.gathering_complete_promise().await;
497        peer_connection
498            .set_local_description(answer)
499            .await
500            .context("Failed to set local description")?;
501        let _ = gather_complete.recv().await;
502
503        peer_connection
504            .local_description()
505            .await
506            .context("No local description after ICE gathering")
507    }
508
509    /// Number of peers currently registered.
510    pub async fn peer_count(&self) -> usize {
511        self.peers.lock().await.len()
512    }
513
514    /// Whether a peer id is already taken.
515    pub async fn has_peer(&self, peer_id: &str) -> bool {
516        self.peers.lock().await.contains_key(peer_id)
517    }
518
519    /// List all active peer IDs
520    pub async fn list_peers(&self) -> Vec<String> {
521        self.peers.lock().await.keys().cloned().collect()
522    }
523
524    /// Send a text message to a specific peer over its data channel.
525    pub async fn send_to_peer(&self, peer_id: &str, message: String) -> Result<()> {
526        let channel = {
527            let peers = self.peers.lock().await;
528            let peer = peers
529                .get(peer_id)
530                .with_context(|| format!("Peer {} not found", peer_id))?;
531            peer.data_channel.clone()
532        };
533        let channel =
534            channel.with_context(|| format!("Data channel not open for peer {}", peer_id))?;
535        channel
536            .send_text(message)
537            .await
538            .with_context(|| format!("Failed to send to peer {}", peer_id))?;
539        Ok(())
540    }
541}
542
543/// Everything a peer callback needs, in one cheaply cloned bundle.
544///
545/// Callbacks are `FnMut` boxes that must own their captures, so without this every callback
546/// body would begin with eight `Arc::clone` lines.
547#[derive(Clone)]
548struct PeerCtx {
549    server_data: Arc<WebRtcServerData>,
550    peer_id: PeerId,
551    connection_id: ConnectionId,
552    server_id: ServerId,
553    app_state: Arc<AppState>,
554    llm_client: OllamaClient,
555    status_tx: mpsc::UnboundedSender<String>,
556}
557
558impl PeerCtx {
559    /// Per-peer state machine: Idle -> Processing -> (Accumulating) -> Idle.
560    ///
561    /// Only one LLM call per peer is ever in flight. Events that arrive during a call are
562    /// queued and drained by the task that owns the machine, so nothing is dropped and no
563    /// two calls race on the same data channel.
564    async fn handle_peer_event(&self, event: PeerEvent) {
565        // Claim the machine, or queue and return.
566        let claimed = {
567            let mut peers = self.server_data.peers.lock().await;
568            match peers.get_mut(&self.peer_id) {
569                Some(peer) => match peer.state {
570                    ConnectionState::Idle => {
571                        peer.state = ConnectionState::Processing;
572                        true
573                    }
574                    ConnectionState::Processing | ConnectionState::Accumulating => {
575                        peer.state = ConnectionState::Accumulating;
576                        // Bounded: this was an unbounded `Vec` fed by whatever the
577                        // peer sends while one LLM call is in flight, and each
578                        // entry later costs its own round-trip. Dropping the
579                        // oldest keeps the newest traffic, which is what a
580                        // conversation cares about, and the WARN says so rather
581                        // than losing messages silently.
582                        if peer.queued.len() >= MAX_QUEUED_EVENTS {
583                            peer.queued.remove(0);
584                            warn!(
585                                "WebRTC peer {} queued more than {} events during one LLM \
586                                 call; dropping the oldest",
587                                self.peer_id, MAX_QUEUED_EVENTS
588                            );
589                        }
590                        peer.queued.push(event.clone());
591                        false
592                    }
593                },
594                None => {
595                    warn!("WebRTC event for unknown peer {} — dropping", self.peer_id);
596                    false
597                }
598            }
599        };
600        if !claimed {
601            return;
602        }
603
604        let mut current = event;
605        loop {
606            self.process_event(current).await;
607
608            let next = {
609                let mut peers = self.server_data.peers.lock().await;
610                match peers.get_mut(&self.peer_id) {
611                    Some(peer) if !peer.queued.is_empty() => {
612                        peer.state = ConnectionState::Processing;
613                        Some(peer.queued.remove(0))
614                    }
615                    Some(peer) => {
616                        peer.state = ConnectionState::Idle;
617                        None
618                    }
619                    None => None,
620                }
621            };
622            match next {
623                Some(event) => current = event,
624                None => break,
625            }
626        }
627    }
628
629    /// One LLM round-trip for one data-channel event, and the actions it produced.
630    async fn process_event(&self, event: PeerEvent) {
631        let netget_event = match &event {
632            PeerEvent::Connected { channel_label } => Event::new(
633                &WEBRTC_PEER_CONNECTED_EVENT,
634                serde_json::json!({
635                    "peer_id": self.peer_id,
636                    "channel_label": channel_label,
637                }),
638            ),
639            PeerEvent::Message { text, is_binary } => Event::new(
640                &WEBRTC_MESSAGE_RECEIVED_EVENT,
641                serde_json::json!({
642                    "peer_id": self.peer_id,
643                    "message": text,
644                    "is_binary": is_binary,
645                }),
646            ),
647        };
648
649        let protocol = crate::server::WebRtcProtocol::new();
650        let result = match call_llm(
651            &self.llm_client,
652            &self.app_state,
653            self.server_id,
654            Some(self.connection_id),
655            &netget_event,
656            &protocol,
657        )
658        .await
659        {
660            Ok(result) => result,
661            Err(e) => {
662                // Silence would leave the peer waiting on a data channel that will never
663                // answer. It gets a category on that channel; the error stays in the log.
664                let failure = WireFailure::classify(&e);
665                let decision = backend_failure_tag(failure);
666                error!(
667                    "WebRTC LLM call failed for peer {} ({}) (decision={}): {}",
668                    self.peer_id,
669                    netget_event.id(),
670                    decision,
671                    e
672                );
673                let _ = self.status_tx.send(format!(
674                    "[ERROR] WebRTC peer {} (decision={}): no response generated ({})",
675                    self.peer_id, decision, e
676                ));
677                self.send_on_channel(failure.prefixed_text().to_string())
678                    .await;
679                return;
680            }
681        };
682
683        let mut outputs: Vec<Vec<u8>> = Vec::new();
684        let mut close = false;
685        for action_result in &result.protocol_results {
686            collect_result(action_result, &mut outputs, &mut close);
687        }
688
689        for bytes in outputs {
690            self.send_on_channel(String::from_utf8_lossy(&bytes).to_string())
691                .await;
692        }
693
694        if close {
695            info!("WebRTC closing peer {} on model request", self.peer_id);
696            self.teardown("closed by model").await;
697        }
698    }
699
700    /// Add one data-channel message to this connection's counters.
701    ///
702    /// Nothing called `update_connection_stats` here, so the rail's `↓/↑` columns
703    /// stayed at the zeros the connection was registered with for the life of
704    /// every peer — a live data channel was indistinguishable from an idle one.
705    /// The 10-second idle sweep is not a hazard: `connectionless()` is correctly
706    /// not declared, so it never looks at these connections.
707    async fn record_traffic(&self, received: u64, sent: u64) {
708        self.app_state
709            .update_connection_stats(
710                self.server_id,
711                self.connection_id,
712                (received > 0).then_some(received),
713                (sent > 0).then_some(sent),
714                (received > 0).then_some(1),
715                (sent > 0).then_some(1),
716            )
717            .await;
718    }
719
720    /// Write one text message on this peer's data channel, if it has an open one.
721    async fn send_on_channel(&self, text: String) {
722        let channel = {
723            let peers = self.server_data.peers.lock().await;
724            peers
725                .get(&self.peer_id)
726                .and_then(|peer| peer.data_channel.clone())
727        };
728        match channel {
729            Some(channel) => match channel.send_text(text).await {
730                Ok(n) => {
731                    trace!("WebRTC sent {} bytes to peer {}", n, self.peer_id);
732                    self.record_traffic(0, n as u64).await;
733                }
734                Err(e) => error!("WebRTC failed to send to peer {}: {}", self.peer_id, e),
735            },
736            None => warn!(
737                "WebRTC has no open data channel for peer {}; response dropped",
738                self.peer_id
739            ),
740        }
741    }
742
743    /// Remove the peer, close its `RTCPeerConnection` and drop its connection record.
744    ///
745    /// Idempotent: the peer connection state callback and the signalling socket's cleanup
746    /// both call it, and whichever loses removes nothing.
747    async fn teardown(&self, reason: &str) {
748        let peer = {
749            let mut peers = self.server_data.peers.lock().await;
750            peers.remove(&self.peer_id)
751        };
752        let Some(peer) = peer else {
753            return;
754        };
755
756        info!("WebRTC peer {} torn down: {}", self.peer_id, reason);
757        let _ = peer.peer_connection.close().await;
758        self.app_state
759            .remove_connection_from_server(self.server_id, peer.connection_id)
760            .await;
761        let _ = self.status_tx.send(format!(
762            "[SERVER] WebRTC peer {} disconnected",
763            self.peer_id
764        ));
765        let _ = self.status_tx.send("__UPDATE_UI__".to_string());
766    }
767}
768
769/// Flatten an `ActionResult` (which may nest via `Multiple`) into outputs and a close flag.
770fn collect_result(result: &ActionResult, outputs: &mut Vec<Vec<u8>>, close: &mut bool) {
771    match result {
772        ActionResult::Output(bytes) => outputs.push(bytes.clone()),
773        ActionResult::CloseConnection => *close = true,
774        ActionResult::Multiple(inner) => {
775            for r in inner {
776                collect_result(r, outputs, close);
777            }
778        }
779        ActionResult::WaitForMore | ActionResult::NoAction | ActionResult::Custom { .. } => {}
780    }
781}
782
783/// The model's verdict on an incoming offer.
784enum OfferDecision {
785    Accept,
786    /// Refuse the offer. `reason` is peer-visible free text and must never carry an internal
787    /// error; `decision` is the log tag that keeps a model refusal, a model silence and a
788    /// backend failure distinguishable after the fact.
789    Reject {
790        reason: String,
791        decision: &'static str,
792    },
793}
794
795/// WebRTC server: WebSocket signalling in front of webrtc-rs peer connections.
796pub struct WebRtcServer;
797
798impl WebRtcServer {
799    /// Bind the signalling listener and start accepting peers.
800    ///
801    /// Returns only once the socket is bound, so a bind failure surfaces as
802    /// `ServerStatus::Error` rather than a server that reports Running and listens to
803    /// nothing.
804    #[allow(clippy::too_many_arguments)]
805    pub async fn spawn_with_llm_actions(
806        listen_addr: SocketAddr,
807        llm_client: OllamaClient,
808        app_state: Arc<AppState>,
809        status_tx: mpsc::UnboundedSender<String>,
810        server_id: ServerId,
811        ice_server_urls: Vec<String>,
812        max_peers: usize,
813        idle_timeout: std::time::Duration,
814    ) -> Result<SocketAddr> {
815        let listener = TcpListener::bind(listen_addr)
816            .await
817            .with_context(|| format!("WebRTC signalling failed to bind {}", listen_addr))?;
818        let local_addr = listener
819            .local_addr()
820            .context("WebRTC signalling socket has no local address")?;
821
822        let server_data = Arc::new(WebRtcServerData::new(ice_server_urls, max_peers)?);
823
824        info!("WebRTC signalling listening on {}", local_addr);
825        let _ = status_tx.send(format!(
826            "[INFO] WebRTC data-channel server, WebSocket SDP signalling, listening on {}",
827            local_addr
828        ));
829
830        let accept_state = Arc::clone(&app_state);
831        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
832        let cap_status_tx = status_tx.clone();
833        let accept_handle = tokio::spawn(async move {
834            loop {
835                match crate::server::accept_bounded::accept_bounded(
836                    &listener,
837                    &limiter,
838                    &CONNECTION_CAP_REFUSAL,
839                    "WEBRTC",
840                    Some(&cap_status_tx),
841                )
842                .await
843                {
844                    Ok((stream, remote_addr, permit)) => {
845                        let server_data = Arc::clone(&server_data);
846                        let app_state = Arc::clone(&app_state);
847                        let status_tx = status_tx.clone();
848                        let llm_client = llm_client.clone();
849                        // Tracked, not detached: stop_server must abort this task too.
850                        let task_owner = app_state.clone();
851                        task_owner
852                            .spawn_server_task(server_id, async move {
853                                // Released when this task ends, and this task is the whole of
854                                // the connection: `handle_signalling` spawns a writer task but
855                                // `.await`s it on its only exit path, and the peer connection
856                                // it negotiates is torn down there too, so `MAX_CONNECTIONS`
857                                // caps live signalling connections rather than accepts.
858                                let _permit = permit;
859                                if let Err(e) = Self::handle_signalling(
860                                    stream,
861                                    remote_addr,
862                                    server_data,
863                                    app_state,
864                                    status_tx,
865                                    llm_client,
866                                    server_id,
867                                    idle_timeout,
868                                )
869                                .await
870                                {
871                                    debug!(
872                                        "WebRTC signalling connection from {} ended: {}",
873                                        remote_addr, e
874                                    );
875                                }
876                            })
877                            .await;
878                    }
879                    Err(e) => {
880                        error!("WebRTC signalling accept failed: {}", e);
881                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
882                    }
883                }
884            }
885        });
886
887        accept_state
888            .register_server_task(server_id, accept_handle)
889            .await;
890
891        Ok(local_addr)
892    }
893
894    /// One signalling WebSocket = one peer.
895    #[allow(clippy::too_many_arguments)]
896    async fn handle_signalling(
897        stream: TcpStream,
898        remote_addr: SocketAddr,
899        server_data: Arc<WebRtcServerData>,
900        app_state: Arc<AppState>,
901        status_tx: mpsc::UnboundedSender<String>,
902        llm_client: OllamaClient,
903        server_id: ServerId,
904        idle_timeout: std::time::Duration,
905    ) -> Result<()> {
906        // Two bounds, both absent before and both reachable by anyone who can open
907        // a TCP connection — `max_peers` gates *accepted offers*, so it bounds
908        // neither of these:
909        //
910        // * The handshake gets a deadline. A socket that connects and never sends
911        //   an HTTP upgrade parked inside `accept_async` forever, holding a task
912        //   and an fd.
913        // * Frames get a size limit. Bare `accept_async` takes tungstenite's
914        //   defaults of 64 MiB per message and 16 MiB per frame, so a single
915        //   pre-admission peer could make the server buffer 64 MiB. Signalling
916        //   here carries one SDP offer or answer; the largest real one is a few
917        //   kilobytes, so 256 KiB is generous by two orders of magnitude.
918        let config = WebSocketConfig {
919            max_message_size: Some(SIGNALLING_MAX_MESSAGE_BYTES),
920            max_frame_size: Some(SIGNALLING_MAX_MESSAGE_BYTES),
921            ..Default::default()
922        };
923        let ws = tokio::time::timeout(
924            SIGNALLING_HANDSHAKE_TIMEOUT,
925            accept_async_with_config(stream, Some(config)),
926        )
927        .await
928        .map_err(|_| {
929            anyhow::anyhow!(
930                "WebSocket handshake from {} did not complete within {:?}",
931                remote_addr,
932                SIGNALLING_HANDSHAKE_TIMEOUT
933            )
934        })?
935        .context("WebSocket handshake failed")?;
936        debug!(
937            "WebRTC signalling connection established with {}",
938            remote_addr
939        );
940
941        let (mut ws_tx, mut ws_rx) = ws.split();
942
943        // A writer task owns the sink so the read loop never blocks on a slow peer.
944        let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Message>();
945        let writer = tokio::spawn(async move {
946            while let Some(msg) = out_rx.recv().await {
947                if ws_tx.send(msg).await.is_err() {
948                    break;
949                }
950            }
951            let _ = ws_tx.close().await;
952        });
953
954        let mut peer_ctx: Option<PeerCtx> = None;
955
956        // Touched by every inbound frame, Pongs included, and busy while one is handled.
957        let activity = Arc::new(crate::server::accept_bounded::ConnectionActivity::new());
958
959        loop {
960            let keepalive_tx = out_tx.clone();
961            let frame = tokio::select! {
962                frame = ws_rx.next() => frame,
963                _ = crate::server::accept_bounded::watch_idle_with_probe(
964                    Arc::clone(&activity),
965                    idle_timeout,
966                    move || {
967                        let _ = keepalive_tx.send(Message::Ping(
968                            crate::server::accept_bounded::KEEPALIVE_PING_PAYLOAD.to_vec(),
969                        ));
970                    },
971                ) => {
972                    info!(
973                        "WebRTC signalling from {} sent no frame for {}s, not even a Pong; \
974                         closing decision=idle_timeout",
975                        remote_addr,
976                        idle_timeout.as_secs()
977                    );
978                    let _ = out_tx.send(Message::Close(Some(
979                        tokio_tungstenite::tungstenite::protocol::CloseFrame {
980                            code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Away,
981                            reason: "idle timeout".into(),
982                        },
983                    )));
984                    break;
985                }
986            };
987            let Some(frame) = frame else { break };
988            // Busy for the rest of this iteration. The offer decision below runs inline, so the
989            // watchdog is not even polled while it is outstanding — but the guard's release is
990            // what gives the connection a fresh bound afterwards. Without it, a decision parked
991            // for a human longer than the bound would come back to a clock that had long run
992            // out, and the peer would be closed the instant its answer was sent.
993            let _busy = activity.busy();
994            let frame = match frame {
995                Ok(frame) => frame,
996                Err(tokio_tungstenite::tungstenite::Error::Capacity(e)) => {
997                    warn!(
998                        "WebRTC signalling message from {} over {} bytes \
999                         decision=fail_closed_message_too_large: {}",
1000                        remote_addr, SIGNALLING_MAX_MESSAGE_BYTES, e
1001                    );
1002                    let _ = out_tx.send(message_too_big_close());
1003                    break;
1004                }
1005                Err(e) => {
1006                    debug!("WebRTC signalling read error from {}: {}", remote_addr, e);
1007                    break;
1008                }
1009            };
1010
1011            let text = match frame {
1012                Message::Text(text) => text,
1013                Message::Close(_) => break,
1014                Message::Binary(_) => {
1015                    send_signal(
1016                        &out_tx,
1017                        &WebRtcSignal::Error {
1018                            message: "signalling frames must be JSON text".to_string(),
1019                        },
1020                    );
1021                    continue;
1022                }
1023                Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => continue,
1024            };
1025
1026            let signal: WebRtcSignal = match serde_json::from_str(&text) {
1027                Ok(signal) => signal,
1028                Err(e) => {
1029                    // serde's message quotes the offending input and names internal types, so
1030                    // it is logged rather than sent - see `crate::utils::wire_failure`.
1031                    debug!("WebRTC signalling frame did not parse: {e}");
1032                    send_signal(
1033                        &out_tx,
1034                        &WebRtcSignal::Error {
1035                            message: "malformed signalling frame".to_string(),
1036                        },
1037                    );
1038                    continue;
1039                }
1040            };
1041
1042            let (peer_id, sdp) = match signal {
1043                WebRtcSignal::Offer { peer_id, sdp } => (peer_id, sdp),
1044                other => {
1045                    send_signal(
1046                        &out_tx,
1047                        &WebRtcSignal::Error {
1048                            message: format!(
1049                                "only 'offer' frames are accepted by this server, got '{}'",
1050                                signal_name(&other)
1051                            ),
1052                        },
1053                    );
1054                    continue;
1055                }
1056            };
1057
1058            if peer_ctx.is_some() {
1059                send_signal(
1060                    &out_tx,
1061                    &WebRtcSignal::Error {
1062                        message: "this signalling connection already carries a peer; open a new \
1063                                  WebSocket for another peer"
1064                            .to_string(),
1065                    },
1066                );
1067                continue;
1068            }
1069
1070            let peer_id = peer_id.trim().to_string();
1071            if peer_id.is_empty() || peer_id.chars().count() > MAX_PEER_ID_LEN {
1072                send_signal(
1073                    &out_tx,
1074                    &WebRtcSignal::Error {
1075                        message: format!("peer_id must be 1..={} characters", MAX_PEER_ID_LEN),
1076                    },
1077                );
1078                continue;
1079            }
1080
1081            if server_data.has_peer(&peer_id).await {
1082                send_signal(
1083                    &out_tx,
1084                    &WebRtcSignal::Error {
1085                        message: format!("peer_id '{}' is already connected", peer_id),
1086                    },
1087                );
1088                continue;
1089            }
1090
1091            if server_data.peer_count().await >= server_data.max_peers {
1092                send_signal(
1093                    &out_tx,
1094                    &WebRtcSignal::Rejected {
1095                        peer_id: peer_id.clone(),
1096                        reason: format!(
1097                            "server is at its max_peers limit of {}",
1098                            server_data.max_peers
1099                        ),
1100                    },
1101                );
1102                continue;
1103            }
1104
1105            let offer = match parse_offer(&sdp) {
1106                Ok(offer) => offer,
1107                Err(e) => {
1108                    debug!("WebRTC SDP offer was unusable: {e}");
1109                    send_signal(
1110                        &out_tx,
1111                        &WebRtcSignal::Error {
1112                            message: "unusable SDP offer".to_string(),
1113                        },
1114                    );
1115                    continue;
1116                }
1117            };
1118
1119            let connection_id = ConnectionId::new(app_state.get_next_unified_id().await);
1120
1121            let decision = Self::decide_offer(
1122                &peer_id,
1123                remote_addr,
1124                &offer,
1125                connection_id,
1126                &llm_client,
1127                &app_state,
1128                server_id,
1129            )
1130            .await;
1131
1132            match decision {
1133                OfferDecision::Reject { reason, decision } => {
1134                    info!(
1135                        "WebRTC offer from {} rejected (decision={}): {}",
1136                        peer_id, decision, reason
1137                    );
1138                    let _ = status_tx.send(format!(
1139                        "[SERVER] WebRTC offer from {} rejected (decision={}): {}",
1140                        peer_id, decision, reason
1141                    ));
1142                    send_signal(
1143                        &out_tx,
1144                        &WebRtcSignal::Rejected {
1145                            peer_id: peer_id.clone(),
1146                            reason,
1147                        },
1148                    );
1149                }
1150                OfferDecision::Accept => {
1151                    match server_data
1152                        .accept_offer(
1153                            peer_id.clone(),
1154                            offer,
1155                            connection_id,
1156                            remote_addr,
1157                            llm_client.clone(),
1158                            Arc::clone(&app_state),
1159                            status_tx.clone(),
1160                            server_id,
1161                        )
1162                        .await
1163                    {
1164                        Ok(answer) => {
1165                            peer_ctx = Some(PeerCtx {
1166                                server_data: Arc::clone(&server_data),
1167                                peer_id: peer_id.clone(),
1168                                connection_id,
1169                                server_id,
1170                                app_state: Arc::clone(&app_state),
1171                                llm_client: llm_client.clone(),
1172                                status_tx: status_tx.clone(),
1173                            });
1174                            send_signal(
1175                                &out_tx,
1176                                &WebRtcSignal::Answer {
1177                                    peer_id: peer_id.clone(),
1178                                    sdp: answer,
1179                                },
1180                            );
1181                        }
1182                        Err(e) => {
1183                            // webrtc-rs' error names library internals; log it, and tell the
1184                            // peer only that negotiation did not complete.
1185                            error!(
1186                                "WebRTC failed to answer peer {} (decision=negotiation_failed): {}",
1187                                peer_id, e
1188                            );
1189                            send_signal(
1190                                &out_tx,
1191                                &WebRtcSignal::Error {
1192                                    message: "the peer connection could not be established"
1193                                        .to_string(),
1194                                },
1195                            );
1196                        }
1197                    }
1198                }
1199            }
1200        }
1201
1202        // The peer connection's lifetime is the signalling socket's lifetime.
1203        if let Some(ctx) = peer_ctx {
1204            ctx.teardown("signalling connection closed").await;
1205        }
1206        drop(out_tx);
1207        let _ = writer.await;
1208        Ok(())
1209    }
1210
1211    /// Ask the LLM whether to accept this offer. Anything other than an explicit
1212    /// `accept_offer` is a refusal.
1213    #[allow(clippy::too_many_arguments)]
1214    async fn decide_offer(
1215        peer_id: &str,
1216        remote_addr: SocketAddr,
1217        offer: &RTCSessionDescription,
1218        connection_id: ConnectionId,
1219        llm_client: &OllamaClient,
1220        app_state: &Arc<AppState>,
1221        server_id: ServerId,
1222    ) -> OfferDecision {
1223        let summary = summarise_offer(&offer.sdp);
1224        let event = Event::new(
1225            &WEBRTC_OFFER_RECEIVED_EVENT,
1226            serde_json::json!({
1227                "peer_id": peer_id,
1228                "remote_addr": remote_addr.to_string(),
1229                "requests_data_channel": summary.data_channel,
1230                "media_kinds": summary.media_kinds,
1231            }),
1232        );
1233
1234        let protocol = crate::server::WebRtcProtocol::new();
1235        let result = match call_llm(
1236            llm_client,
1237            app_state,
1238            server_id,
1239            Some(connection_id),
1240            &event,
1241            &protocol,
1242        )
1243        .await
1244        {
1245            Ok(result) => result,
1246            Err(e) => {
1247                // The peer gets a category; the error itself stays in the log. See
1248                // `crate::utils::wire_failure`.
1249                let failure = WireFailure::classify(&e);
1250                let decision = backend_failure_tag(failure);
1251                error!(
1252                    "WebRTC offer decision failed for peer {} (decision={}): {}",
1253                    peer_id, decision, e
1254                );
1255                return OfferDecision::Reject {
1256                    reason: failure.prefixed_text().to_string(),
1257                    decision,
1258                };
1259            }
1260        };
1261
1262        for action_result in &result.protocol_results {
1263            if let Some(decision) = offer_decision(action_result) {
1264                return decision;
1265            }
1266        }
1267
1268        OfferDecision::Reject {
1269            reason: "the model returned no accept_offer or reject_offer action; refusing by \
1270                     default"
1271                .to_string(),
1272            decision: "fail_closed_no_decision",
1273        }
1274    }
1275}
1276
1277/// The `decision=` log tag for a backend failure.
1278///
1279/// Overload and everything else are kept apart because they are the same distinction a
1280/// protocol with status codes expresses as 503 vs 500: one says "retry", the other does not.
1281/// The signalling protocol has no codes, so the split lives in the log and in the two
1282/// `WireFailure` texts.
1283fn backend_failure_tag(failure: WireFailure) -> &'static str {
1284    if failure.is_overloaded() {
1285        "fail_closed_backend_overloaded"
1286    } else {
1287        "fail_closed_backend_error"
1288    }
1289}
1290
1291/// Extract an offer decision from an action result, descending into `Multiple`.
1292fn offer_decision(result: &ActionResult) -> Option<OfferDecision> {
1293    match result {
1294        ActionResult::Custom { name, data } if name == OFFER_DECISION_RESULT => {
1295            if data
1296                .get("accept")
1297                .and_then(|v| v.as_bool())
1298                .unwrap_or(false)
1299            {
1300                Some(OfferDecision::Accept)
1301            } else {
1302                Some(OfferDecision::Reject {
1303                    reason: data
1304                        .get("reason")
1305                        .and_then(|v| v.as_str())
1306                        .unwrap_or("the model rejected this offer")
1307                        .to_string(),
1308                    decision: "model_reject",
1309                })
1310            }
1311        }
1312        ActionResult::Multiple(inner) => inner.iter().find_map(offer_decision),
1313        _ => None,
1314    }
1315}
1316
1317/// Frame name for error messages.
1318fn signal_name(signal: &WebRtcSignal) -> &'static str {
1319    match signal {
1320        WebRtcSignal::Offer { .. } => "offer",
1321        WebRtcSignal::Answer { .. } => "answer",
1322        WebRtcSignal::Rejected { .. } => "rejected",
1323        WebRtcSignal::Error { .. } => "error",
1324    }
1325}
1326
1327/// Queue a signalling frame for the writer task.
1328fn send_signal(out_tx: &mpsc::UnboundedSender<Message>, signal: &WebRtcSignal) {
1329    match serde_json::to_string(signal) {
1330        Ok(json) => {
1331            let _ = out_tx.send(Message::Text(json));
1332        }
1333        Err(e) => error!("WebRTC failed to serialise signalling frame: {}", e),
1334    }
1335}
1336
1337/// Accept either `{"type":"offer","sdp":"..."}` or a bare SDP string.
1338///
1339/// The SDP body is parsed here, before the model is consulted: an offer webrtc-rs cannot
1340/// read is not worth an LLM round-trip, and `set_remote_description` would only reject it
1341/// afterwards — with the peer already counted as a decision the model made.
1342fn parse_offer(sdp: &serde_json::Value) -> Result<RTCSessionDescription> {
1343    let raw = match sdp.as_str() {
1344        Some(raw) => raw.to_string(),
1345        None => {
1346            let description: RTCSessionDescription = serde_json::from_value(sdp.clone())
1347                .context("expected {\"type\":\"offer\",\"sdp\":\"...\"} or an SDP string")?;
1348            if description.sdp_type != RTCSdpType::Offer {
1349                anyhow::bail!(
1350                    "expected an SDP of type 'offer', got '{}'",
1351                    description.sdp_type
1352                );
1353            }
1354            description.sdp
1355        }
1356    };
1357
1358    RTCSessionDescription::offer(raw).context("SDP could not be parsed as an offer")
1359}
1360
1361/// Structured, model-facing view of an SDP offer.
1362struct OfferSummary {
1363    data_channel: bool,
1364    media_kinds: Vec<String>,
1365}
1366
1367/// Summarise an SDP offer for the model.
1368///
1369/// The model never sees the SDP itself — it is long, it is not something a model can
1370/// usefully edit, and an action parameter carrying it would violate the structured-fields
1371/// rule. It sees only what it needs to decide: whether a data channel is requested and what
1372/// media, if any, the peer wants (this server supports none).
1373fn summarise_offer(sdp: &str) -> OfferSummary {
1374    let mut data_channel = false;
1375    let mut media_kinds = Vec::new();
1376    for line in sdp.lines() {
1377        let Some(media) = line.trim().strip_prefix("m=") else {
1378            continue;
1379        };
1380        let kind = media.split_whitespace().next().unwrap_or("");
1381        match kind {
1382            "application" => data_channel = true,
1383            "" => {}
1384            other => {
1385                let other = other.to_string();
1386                if !media_kinds.contains(&other) {
1387                    media_kinds.push(other);
1388                }
1389            }
1390        }
1391    }
1392    OfferSummary {
1393        data_channel,
1394        media_kinds,
1395    }
1396}