Skip to main content

netget/server/can/
mod.rs

1//! CAN bus (SocketCAN) server — NetGet as an ECU on somebody's vehicle bus.
2//!
3//! CAN is a broadcast bus, not a network. There is no addressing, no routing, no session and no
4//! authentication: every frame reaches every node, and each node decides for itself whether the
5//! 11- or 29-bit identifier is one it answers. Arbitration is by identifier — the numerically
6//! lowest wins the bus — which is a priority scheme, not a security one.
7//!
8//! **That is what makes an LLM-driven ECU simulator worth building, and it is the same property
9//! that makes it dangerous.** A NetGet instance on a real vehicle bus can claim any identifier
10//! and impersonate any controller, and nothing on the bus can tell. See `CLAUDE.md`.
11//!
12//! # Two transports, one codec
13//!
14//! The frame format lives in [`frame`] and is pure. This module is the thin layer that moves
15//! those bytes, and [`transport`] is the thinner one that touches the OS:
16//!
17//! * **`transport: "socketcan"`** (the default) — a real `AF_CAN` socket, which exists **only in
18//!   the Linux kernel**. On macOS and Windows `spawn()` returns an error naming the reason
19//!   rather than reporting a server that is up and hearing nothing. This code has never been
20//!   compiled or run; see `CLAUDE.md`.
21//! * **`transport: "udp"`** — a testing transport carrying the same `struct can_frame` /
22//!   `struct canfd_frame` octets as UDP datagram payloads, so the whole event → handler/LLM →
23//!   action → frame path runs unprivileged. No real CAN node speaks it.
24//!
25//! # LLM failure → silence, and there is no alternative
26//!
27//! CAN has no error *reply*. The thing called an error frame is not a message: it is six
28//! dominant bits transmitted **on top of** a frame in flight, destroying it for every node on the
29//! bus, after which the transmitting controller increments an error counter. Enough of them make
30//! it error-passive and then bus-off, at which point it has disconnected itself. Emitting one to
31//! signal "the backend is down" would corrupt other nodes' traffic and could take this node — or
32//! with enough repetition, another — off the bus entirely.
33//!
34//! So a failure transmits **nothing**, and nothing derived from the error ever reaches the wire.
35//! Silence is also completely ordinary on CAN: almost every node ignores almost every frame. The
36//! distinction therefore survives only in the log, tagged `decision=` the way
37//! `src/server/radius/` tags its own:
38//!
39//! | Tag | Meaning |
40//! |---|---|
41//! | `decision=no_policy` | No instruction and no handler — nothing is transmitted and **no LLM call is made** |
42//! | `decision=model_reject` | The model answered `no_response` — a real decision |
43//! | `decision=model_silent` | The model returned nothing usable |
44//! | `decision=fail_closed_overloaded` | The call failed and the backend is saturated (retryable) |
45//! | `decision=fail_closed_llm_error` | The call failed otherwise |
46
47pub mod actions;
48pub mod frame;
49pub mod transport;
50
51use anyhow::{bail, Context, Result};
52use std::net::SocketAddr;
53use std::sync::{Arc, Mutex};
54use tokio::net::UdpSocket;
55use tokio::sync::mpsc;
56use tracing::{debug, error, info, trace};
57
58use crate::llm::action_helper::call_llm;
59use crate::llm::ollama_client::OllamaClient;
60use crate::protocol::Event;
61use crate::server::connection::ConnectionId;
62use crate::state::app_state::AppState;
63use crate::{console_debug, console_error, console_info};
64use actions::{
65    CanProtocol, CAN_BUS_STATE_CHANGED_EVENT, CAN_ERROR_FRAME_EVENT, CAN_FRAME_RECEIVED_EVENT,
66    DEFAULT_INTERFACE, NO_RESPONSE_ACTION,
67};
68use frame::{BusState, CanFrame};
69use transport::{FrameSink, TransportKind};
70
71/// How long the blocking `AF_CAN` read parks before checking whether it has been asked to stop.
72#[cfg(target_os = "linux")]
73const CAN_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
74
75/// Everything the startup parameters configure. Every field here is read.
76#[derive(Debug, Clone)]
77struct CanConfig {
78    transport: TransportKind,
79    interface: String,
80    udp_peer: Option<SocketAddr>,
81}
82
83impl CanConfig {
84    /// Parse and cross-check the startup parameters.
85    ///
86    /// Errors propagate with `?` — never `unwrap()`. An undeclared key or a wrong-typed value has
87    /// to name itself and leave no half-registered server behind, which is why this runs before
88    /// anything is opened or bound.
89    fn from_params(
90        params: Option<&crate::protocol::StartupParams>,
91        ctx_interface: Option<String>,
92    ) -> Result<Self> {
93        let Some(params) = params else {
94            return Ok(Self {
95                transport: TransportKind::SocketCan,
96                interface: ctx_interface.unwrap_or_else(|| DEFAULT_INTERFACE.to_string()),
97                udp_peer: None,
98            });
99        };
100
101        let transport = TransportKind::parse(params.get_optional_string("transport")?.as_deref())?;
102
103        // The startup parameter wins over `SpawnContext::interface`, which MCP cannot set at all.
104        let interface = params
105            .get_optional_string("interface")?
106            .or(ctx_interface)
107            .unwrap_or_else(|| DEFAULT_INTERFACE.to_string());
108
109        if interface.trim().is_empty() {
110            bail!("interface must be a CAN interface name such as 'can0' or 'vcan0', not empty");
111        }
112
113        let udp_peer = match params.get_optional_string("udp_peer")? {
114            None => None,
115            Some(text) => {
116                if transport != TransportKind::Udp {
117                    bail!(
118                        "udp_peer was given but transport is \"socketcan\", where it would do \
119                         nothing. Set transport to \"udp\" or drop udp_peer."
120                    );
121                }
122                Some(text.parse::<SocketAddr>().with_context(|| {
123                    format!("udp_peer must be HOST:PORT (e.g. 127.0.0.1:34567), got '{text}'")
124                })?)
125            }
126        };
127
128        Ok(Self {
129            transport,
130            interface,
131            udp_peer,
132        })
133    }
134}
135
136/// The parts of the running server every per-frame task needs.
137struct CanContext {
138    llm_client: OllamaClient,
139    app_state: Arc<AppState>,
140    status_tx: mpsc::UnboundedSender<String>,
141    protocol: Arc<CanProtocol>,
142    server_id: crate::state::ServerId,
143    /// What to call the bus in events and in the log: the interface name on SocketCAN, or
144    /// `udp:HOST:PORT` on the test transport, so nothing can mistake one for the other.
145    interface_label: String,
146    /// The controller's last known error-confinement state.
147    ///
148    /// A plain `std::sync::Mutex` holding a `Copy` value: it is read, compared and written inside
149    /// one short critical section with no `.await` anywhere near it.
150    bus_state: Mutex<BusState>,
151}
152
153/// CAN bus server.
154pub struct CanServer;
155
156impl CanServer {
157    /// Start a CAN server.
158    ///
159    /// Returns `Err` — never a server left sitting in `Running` — when the platform has no
160    /// `AF_CAN`, when the interface does not exist or is down, when the UDP socket cannot be
161    /// bound, or when a startup parameter is unusable. Every one of those is detected before this
162    /// function returns, so `server_startup.rs` records `ServerStatus::Error` with the reason.
163    pub async fn spawn_with_llm_actions(
164        listen_addr: SocketAddr,
165        ctx_interface: Option<String>,
166        llm_client: OllamaClient,
167        app_state: Arc<AppState>,
168        status_tx: mpsc::UnboundedSender<String>,
169        server_id: crate::state::ServerId,
170        startup_params: Option<crate::protocol::StartupParams>,
171    ) -> Result<SocketAddr> {
172        // Parameters first: an unusable one must fail before anything is opened or bound.
173        let config = CanConfig::from_params(startup_params.as_ref(), ctx_interface)?;
174
175        match config.transport {
176            TransportKind::Udp => {
177                Self::spawn_udp(
178                    listen_addr,
179                    config,
180                    llm_client,
181                    app_state,
182                    status_tx,
183                    server_id,
184                )
185                .await
186            }
187            TransportKind::SocketCan => {
188                Self::spawn_socketcan(config, llm_client, app_state, status_tx, server_id).await
189            }
190        }
191    }
192
193    /// The testing transport: SocketCAN frame structs carried in UDP datagrams.
194    async fn spawn_udp(
195        listen_addr: SocketAddr,
196        config: CanConfig,
197        llm_client: OllamaClient,
198        app_state: Arc<AppState>,
199        status_tx: mpsc::UnboundedSender<String>,
200        server_id: crate::state::ServerId,
201    ) -> Result<SocketAddr> {
202        // Awaited, so a bind failure comes back as `Err` rather than being logged from a detached
203        // task while the server sits in `Running`.
204        let socket = Arc::new(UdpSocket::bind(listen_addr).await.with_context(|| {
205            format!("failed to bind the CAN UDP test transport to {listen_addr}")
206        })?);
207        let local_addr = socket.local_addr()?;
208
209        console_info!(
210            status_tx,
211            "CAN listening on {} (transport=udp — a TEST transport carrying SocketCAN frame \
212             structs in datagrams; no real CAN node speaks it)",
213            local_addr
214        );
215
216        let ctx = Arc::new(CanContext {
217            llm_client,
218            app_state: app_state.clone(),
219            status_tx: status_tx.clone(),
220            protocol: Arc::new(CanProtocol::new()),
221            server_id,
222            interface_label: format!("udp:{local_addr}"),
223            bus_state: Mutex::new(BusState::ErrorActive),
224        });
225
226        let sink = FrameSink::Udp {
227            socket: socket.clone(),
228            configured_peer: config.udp_peer,
229            last_peer: Arc::new(Mutex::new(None)),
230        };
231
232        let receive_ctx = ctx.clone();
233        let receive_sink = sink.clone();
234        let loop_state = app_state.clone();
235        let receive_handle = tokio::spawn(async move {
236            // 72 octets is the largest SocketCAN frame struct; the buffer is generous so an
237            // oversized datagram is *seen* and rejected by the codec rather than silently cut to
238            // a length that happens to decode.
239            let mut buffer = vec![0u8; 4096];
240            loop {
241                match socket.recv_from(&mut buffer).await {
242                    Ok((n, peer)) => {
243                        if let FrameSink::Udp { last_peer, .. } = &receive_sink {
244                            *last_peer.lock().expect("can peer mutex poisoned") = Some(peer);
245                        }
246                        let frame = match CanFrame::from_wire_bytes(&buffer[..n]) {
247                            Ok(frame) => frame,
248                            Err(e) => {
249                                // Anyone can send anything to a UDP port; this is not an
250                                // operator's problem.
251                                debug!("CAN ignoring an undecodable datagram ({n} bytes): {e}");
252                                continue;
253                            }
254                        };
255                        let ctx = receive_ctx.clone();
256                        let sink = receive_sink.clone();
257                        // Tracked, not detached: stop_server must abort this task too.
258                        let task_owner = loop_state.clone();
259                        task_owner
260                            .spawn_server_task(server_id, async move {
261                                Self::handle_frame(frame, Some(peer), local_addr, ctx, sink).await;
262                            })
263                            .await;
264                    }
265                    Err(e) => {
266                        console_error!(receive_ctx.status_tx, "CAN UDP receive error: {}", e);
267                        break;
268                    }
269                }
270            }
271        });
272
273        app_state
274            .register_server_task(server_id, receive_handle)
275            .await;
276
277        Ok(local_addr)
278    }
279
280    /// The real transport: an `AF_CAN` socket on a Linux CAN interface.
281    ///
282    /// **Never compiled, never executed.** `AF_CAN` is a Linux kernel address family; there is no
283    /// macOS or Windows equivalent to port to. Everything below the codec is therefore bring-up
284    /// code, and `metadata().notes` says so rather than implying otherwise.
285    #[cfg(target_os = "linux")]
286    async fn spawn_socketcan(
287        config: CanConfig,
288        llm_client: OllamaClient,
289        app_state: Arc<AppState>,
290        status_tx: mpsc::UnboundedSender<String>,
291        server_id: crate::state::ServerId,
292    ) -> Result<SocketAddr> {
293        use socketcan::{ShouldRetry, Socket};
294
295        console_info!(
296            status_tx,
297            "CAN opening AF_CAN socket on interface {}",
298            config.interface
299        );
300
301        let ctx = Arc::new(CanContext {
302            llm_client,
303            app_state: app_state.clone(),
304            status_tx: status_tx.clone(),
305            protocol: Arc::new(CanProtocol::new()),
306            server_id,
307            interface_label: config.interface.clone(),
308            bus_state: Mutex::new(BusState::ErrorActive),
309        });
310
311        // Opening the socket is the step that can fail for reasons the operator must see (no such
312        // interface, interface down), so it must not be fire-and-forget: the outcome comes back
313        // over a oneshot and this function returns `Ok` only once the socket is genuinely open.
314        // ARP, DataLink and ICMP each shipped the fire-and-forget version of this and were each
315        // fixed separately.
316        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<()>>();
317
318        // `JoinHandle::abort()` cannot interrupt a thread parked in a blocking read, so the loop
319        // is stopped cooperatively and the read has a timeout that bounds how long that takes.
320        let stop = crate::utils::StopSignal::new();
321        let stop_in_loop = stop.clone();
322
323        let (frame_tx, frame_rx) = std::sync::mpsc::channel::<Vec<u8>>();
324        let sink = FrameSink::SocketCan(frame_tx.clone());
325
326        let loop_interface = config.interface.clone();
327        let loop_ctx = ctx.clone();
328        let loop_sink = sink.clone();
329
330        tokio::task::spawn_blocking(move || {
331            // Two sockets on the same interface rather than one shared between threads: a
332            // transmit blocked behind a busy bus must not stall the receive loop, and `recv_own_msgs`
333            // is off so the reader never sees what the writer sent. `lldp` opens two pcap handles
334            // for the same reason.
335            let open = || -> Result<(socketcan::CanFdSocket, socketcan::CanFdSocket)> {
336                let rx = transport::linux::open(&loop_interface)?;
337                rx.set_read_timeout(CAN_READ_TIMEOUT).with_context(|| {
338                    format!("failed to set a read timeout on '{loop_interface}'")
339                })?;
340                let tx = transport::linux::open(&loop_interface)?;
341                Ok((rx, tx))
342            };
343
344            let (rx_socket, tx_socket) = match open() {
345                Ok(sockets) => {
346                    let _ = ready_tx.send(Ok(()));
347                    sockets
348                }
349                Err(e) => {
350                    console_error!(loop_ctx.status_tx, "CAN startup failed: {:#}", e);
351                    let _ = ready_tx.send(Err(e));
352                    return;
353                }
354            };
355
356            // Transmission runs on its own thread: `write_frame` blocks while the controller
357            // arbitrates for the bus, and the receive loop must not stall behind it.
358            std::thread::spawn(move || {
359                while let Ok(bytes) = frame_rx.recv() {
360                    let outgoing = match CanFrame::from_wire_bytes(&bytes)
361                        .and_then(|frame| transport::linux::to_socketcan(&frame))
362                    {
363                        Ok(outgoing) => outgoing,
364                        Err(e) => {
365                            error!("CAN cannot build the frame to transmit: {e:#}");
366                            continue;
367                        }
368                    };
369                    if let Err(e) = tx_socket.write_frame(&outgoing) {
370                        error!("CAN failed to transmit frame: {e}");
371                    }
372                }
373            });
374
375            let runtime = tokio::runtime::Handle::current();
376            let nowhere: SocketAddr = "0.0.0.0:0"
377                .parse()
378                .expect("0.0.0.0:0 is a valid socket address");
379
380            loop {
381                if stop_in_loop.is_stopped() {
382                    console_info!(
383                        loop_ctx.status_tx,
384                        "CAN socket on {} stopping",
385                        loop_interface
386                    );
387                    break;
388                }
389                match rx_socket.read_frame() {
390                    Ok(any) => {
391                        let frame = transport::linux::from_socketcan(&any);
392                        let ctx = loop_ctx.clone();
393                        let sink = loop_sink.clone();
394                        runtime.spawn(async move {
395                            Self::handle_frame(frame, None, nowhere, ctx, sink).await;
396                        });
397                    }
398                    // The read timeout is what bounds how long a stop takes to notice on a
399                    // silent bus.
400                    Err(e) if e.should_retry() => continue,
401                    Err(e) => {
402                        console_error!(loop_ctx.status_tx, "CAN receive error: {}", e);
403                        break;
404                    }
405                }
406            }
407
408            // Dropping the last sender ends the transmit thread, closing its socket with it.
409            drop(frame_tx);
410            drop(rx_socket);
411        });
412
413        match ready_rx.await {
414            Ok(Ok(())) => {}
415            Ok(Err(e)) => return Err(e),
416            Err(_) => {
417                return Err(anyhow::anyhow!(
418                    "the CAN receive task on '{}' exited before signalling readiness",
419                    config.interface
420                ))
421            }
422        }
423
424        app_state
425            .register_server_task(server_id, stop.park_task())
426            .await;
427
428        console_info!(status_tx, "CAN active on {}", config.interface);
429
430        // A CAN interface has no address and no port. `server_startup` records this and the
431        // dashboard shows the interface instead.
432        Ok("0.0.0.0:0"
433            .parse()
434            .expect("0.0.0.0:0 is a valid socket address"))
435    }
436
437    /// Every platform without `AF_CAN`: refuse, with the reason.
438    ///
439    /// Refusing is not the same as hiding. The protocol stays registered and stays visible to the
440    /// model, so an operator on macOS gets `ServerStatus::Error` explaining that `AF_CAN` is a
441    /// Linux kernel address family and pointing at `vcan` and at the UDP test transport — rather
442    /// than a protocol that is mysteriously absent, or worse, one sitting in `Running` having
443    /// heard nothing.
444    #[cfg(not(target_os = "linux"))]
445    async fn spawn_socketcan(
446        config: CanConfig,
447        _llm_client: OllamaClient,
448        _app_state: Arc<AppState>,
449        status_tx: mpsc::UnboundedSender<String>,
450        _server_id: crate::state::ServerId,
451    ) -> Result<SocketAddr> {
452        console_error!(
453            status_tx,
454            "CAN cannot start on interface {}: {}",
455            config.interface,
456            transport::UNSUPPORTED_PLATFORM_MESSAGE
457        );
458        transport::socketcan_unavailable()
459    }
460
461    /// Turn one received frame into the right event, and hand it to the policy.
462    async fn handle_frame(
463        frame: CanFrame,
464        peer: Option<SocketAddr>,
465        local_addr: SocketAddr,
466        ctx: Arc<CanContext>,
467        sink: FrameSink,
468    ) {
469        console_debug!(
470            ctx.status_tx,
471            "CAN {} rx {}",
472            ctx.interface_label,
473            frame.describe()
474        );
475        trace!("CAN rx raw: {:?}", frame);
476
477        // Connectionless bookkeeping: one entry per frame heard, which the 10-second idle sweep
478        // reaps because `metadata()` declares `.connectionless()`. A CAN bus has no connections
479        // at all; these exist so the dashboard's ↓/↑ counters have something to count.
480        let connection_id = ConnectionId::new(ctx.app_state.get_next_unified_id().await);
481        {
482            use crate::state::server::{
483                ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
484            };
485            let now = crate::utils::clock::Instant::now();
486            let nowhere: SocketAddr = "0.0.0.0:0"
487                .parse()
488                .expect("0.0.0.0:0 is a valid socket address");
489            let conn = ServerConnectionState {
490                id: connection_id,
491                remote_addr: peer.unwrap_or(nowhere),
492                local_addr,
493                bytes_sent: 0,
494                bytes_received: frame.data.len() as u64,
495                packets_sent: 0,
496                packets_received: 1,
497                last_activity: now,
498                status: ConnectionStatus::Active,
499                status_changed_at: now,
500                protocol_info: ProtocolConnectionInfo::empty(),
501            };
502            ctx.app_state
503                .add_connection_to_server(ctx.server_id, conn)
504                .await;
505            let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
506        }
507
508        let mut data = frame.to_event_data();
509        data.insert(
510            "interface".into(),
511            serde_json::json!(ctx.interface_label.clone()),
512        );
513        data.insert(
514            "connection_id".into(),
515            serde_json::json!(connection_id.to_string()),
516        );
517
518        if frame.error {
519            // A state change is reported *in addition to* the error frame that carried it: the
520            // two answer different questions, and an operator's handler may reasonably key on
521            // either. The transition is computed under a short non-async critical section.
522            let transition = frame.bus_state().and_then(|observed| {
523                let mut state = ctx.bus_state.lock().expect("can bus state mutex poisoned");
524                let previous = *state;
525                if previous == observed {
526                    None
527                } else {
528                    *state = observed;
529                    Some((previous, observed))
530                }
531            });
532
533            let error_event = Event::new(
534                &CAN_ERROR_FRAME_EVENT,
535                serde_json::Value::Object(data.clone()),
536            );
537            Self::dispatch_event(error_event, Some(connection_id), ctx.clone(), sink.clone()).await;
538
539            if let Some((previous, observed)) = transition {
540                console_info!(
541                    ctx.status_tx,
542                    "CAN {} bus state {} -> {}",
543                    ctx.interface_label,
544                    previous.as_str(),
545                    observed.as_str()
546                );
547                let mut state_data = data;
548                state_data.insert("bus_state".into(), serde_json::json!(observed.as_str()));
549                state_data.insert(
550                    "previous_state".into(),
551                    serde_json::json!(previous.as_str()),
552                );
553                let state_event = Event::new(
554                    &CAN_BUS_STATE_CHANGED_EVENT,
555                    serde_json::Value::Object(state_data),
556                );
557                Self::dispatch_event(state_event, Some(connection_id), ctx, sink).await;
558            }
559            return;
560        }
561
562        let event = Event::new(&CAN_FRAME_RECEIVED_EVENT, serde_json::Value::Object(data));
563        Self::dispatch_event(event, Some(connection_id), ctx, sink).await;
564    }
565
566    /// Ask the operator's policy (handler, script or model) what to transmit, and transmit it —
567    /// or nothing, which is a complete and ordinary answer on a CAN bus.
568    async fn dispatch_event(
569        event: Event,
570        connection_id: Option<ConnectionId>,
571        ctx: Arc<CanContext>,
572        sink: FrameSink,
573    ) {
574        let event_id = event.event_type.id.clone();
575
576        // With no operator policy there is no ECU to simulate, so there is nothing to answer
577        // with — and a real CAN bus carries thousands of frames a second, so a model round-trip
578        // per frame would be ruinous as well as pointless. `lldp`, `arp` and `ospf` gate on the
579        // same condition for the same reason.
580        if !operator_wants_dynamic(&ctx.app_state, ctx.server_id, &event_id).await {
581            debug!(
582                "CAN {event_id} decision=no_policy: no instruction and no handler, nothing \
583                 transmitted and no LLM call"
584            );
585            let _ = ctx.status_tx.send(format!(
586                "CAN {event_id} decision=no_policy: listening only (no instruction or handler)"
587            ));
588            return;
589        }
590
591        match call_llm(
592            &ctx.llm_client,
593            &ctx.app_state,
594            ctx.server_id,
595            connection_id,
596            &event,
597            ctx.protocol.as_ref(),
598        )
599        .await
600        {
601            Ok(result) => {
602                for message in &result.messages {
603                    info!("{}", message);
604                    let _ = ctx.status_tx.send(format!("[INFO] {}", message));
605                }
606
607                let mut sent = 0usize;
608                for protocol_result in &result.protocol_results {
609                    for bytes in protocol_result.get_all_output() {
610                        // Decoded back into a frame so the log says what went out in CAN terms
611                        // and not as a hex blob, and so a malformed struct is caught before it
612                        // reaches the bus rather than after.
613                        let frame = match CanFrame::from_wire_bytes(&bytes) {
614                            Ok(frame) => frame,
615                            Err(e) => {
616                                console_error!(
617                                    ctx.status_tx,
618                                    "CAN cannot transmit a malformed frame: {:#}",
619                                    e
620                                );
621                                continue;
622                            }
623                        };
624                        match sink.send(&frame).await {
625                            Ok(_) => {
626                                sent += 1;
627                                console_debug!(
628                                    ctx.status_tx,
629                                    "CAN {} tx {}",
630                                    ctx.interface_label,
631                                    frame.describe()
632                                );
633                            }
634                            Err(e) => {
635                                console_error!(ctx.status_tx, "CAN transmit failed: {:#}", e);
636                            }
637                        }
638                    }
639                }
640
641                if sent == 0 {
642                    // Nothing went out. On the bus that is indistinguishable from a node that
643                    // simply does not own this identifier — which is the overwhelmingly common
644                    // case — so the tag is the only place the difference survives.
645                    let rejected = result.raw_actions.iter().any(|a| {
646                        a.get("type").and_then(|t| t.as_str()) == Some(NO_RESPONSE_ACTION)
647                    });
648                    let decision = if rejected {
649                        "model_reject"
650                    } else {
651                        "model_silent"
652                    };
653                    info!(
654                        "CAN {event_id} decision={decision}: nothing transmitted (silence is \
655                         normal on a CAN bus)"
656                    );
657                    let _ = ctx.status_tx.send(format!(
658                        "CAN {event_id} decision={decision}: nothing transmitted"
659                    ));
660                }
661            }
662            Err(e) => {
663                // Fail closed, and closed for CAN means silence. There is no error reply to
664                // send: a CAN error frame is six dominant bits transmitted on top of a frame in
665                // flight, which destroys that frame for every node and, repeated, drives
666                // controllers error-passive and then bus-off. Signalling a NetGet outage that way
667                // would corrupt other nodes' traffic. Nothing derived from `e` reaches the bus —
668                // only this log line and the status stream, both local.
669                let category = crate::utils::WireFailure::classify(&e);
670                let decision = if category.is_overloaded() {
671                    "fail_closed_overloaded"
672                } else {
673                    "fail_closed_llm_error"
674                };
675                error!(
676                    "CAN {event_id} decision={decision}: nothing transmitted, CAN has no error \
677                     reply and an error frame would corrupt traffic in flight ({}): {}",
678                    category.text(),
679                    e
680                );
681                let _ = ctx.status_tx.send(format!(
682                    "✗ CAN {event_id} decision={decision}: nothing transmitted ({}): {}",
683                    category.text(),
684                    e
685                ));
686            }
687        }
688    }
689}
690
691/// True when the operator opted into dynamic behaviour: a non-empty server instruction, or an
692/// event handler configured for this event.
693///
694/// False means listen and say nothing — for CAN, because with no configured ECU there is no
695/// identifier this node owns and nothing honest to transmit.
696async fn operator_wants_dynamic(
697    state: &AppState,
698    server_id: crate::state::ServerId,
699    event_id: &str,
700) -> bool {
701    state
702        .with_server_mut(server_id, |server| {
703            let has_instruction = !server.instruction.trim().is_empty();
704            let has_handler = server
705                .event_handler_config
706                .as_ref()
707                .map(|c| c.find_handler(event_id).is_some())
708                .unwrap_or(false);
709            has_instruction || has_handler
710        })
711        .await
712        .unwrap_or(false)
713}