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}