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}