Skip to main content

netget/server/spice/
mod.rs

1//! SPICE server — the remote display protocol of QEMU/KVM, spoken by hand over TCP.
2//!
3//! A SPICE session is several TCP connections, one per **channel**, each opened with its own
4//! link handshake: the client links a **main** channel first (connection id 0), learns its
5//! session id from `SPICE_MSG_MAIN_INIT`, asks for the channel list, and then links a
6//! **display** and an **inputs** channel carrying that session id. This module owns all of it:
7//! the link (`SpiceLinkMess` → `SpiceLinkReply` with an RSA-1024 key → auth mechanism → the
8//! RSA-OAEP ticket → link result), the data framing, the channels, and every pixel.
9//!
10//! The model owns three things, and never a pixel or the password:
11//!
12//! - **admission** — `spice_connection_request` is raised once per session, after Rust has
13//!   checked the ticket against the configured password; only `spice_accept_connection`
14//!   admits.
15//! - **the screen** — `spice_render_display` with VNC's drawing-command vocabulary, rendered
16//!   here and sent as uncompressed 32-bit bitmaps on the one primary surface.
17//! - **reactions** to `spice_key_event` / `spice_mouse_event`, and notifications.
18//!
19//! ## Failing closed
20//!
21//! - Admission with no usable answer (backend down, nothing usable, an executor refusal)
22//!   **refuses the link** (`SPICE_LINK_ERR_PERMISSION_DENIED`): an outage must never admit.
23//! - The first screen with no usable answer draws a **placeholder** with a fixed caption. A
24//!   SPICE surface is created black, so a client left with nothing could not tell "the model
25//!   drew black" from "the model never answered".
26//! - An input event with no usable answer **keeps the last frame** and sends a fixed-text
27//!   `SPICE_MSG_NOTIFY` (WARN) on the main channel. SPICE's display is server-push — no request
28//!   is waiting on an answer — so painting a placeholder over a screen the user is working in
29//!   would destroy what the model last decided, while the notify keeps "no answer" distinct
30//!   from `spice_no_change` on the wire as well as in the log.
31//!
32//! Every outcome writes exactly one `decision=` line.
33pub mod actions;
34pub mod keys;
35pub mod wire;
36
37use crate::display::{Color, DisplayCanvas, DisplayCommand};
38use crate::llm::action_helper::call_llm;
39use crate::llm::actions::protocol_trait::ActionResult;
40use crate::llm::ollama_client::OllamaClient;
41use crate::logging::emit::{Log, Sink};
42use crate::protocol::Event;
43use crate::server::connection::ConnectionId;
44use crate::state::app_state::AppState;
45use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
46use crate::state::ServerId;
47use actions::SpiceProtocol;
48use anyhow::{Context, Result};
49use serde_json::json;
50use std::collections::HashMap;
51use std::net::SocketAddr;
52use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
53use std::sync::Arc;
54use std::time::Duration;
55use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf};
56use tokio::net::TcpStream;
57use tokio::sync::{mpsc, watch, Mutex};
58use wire::{channel, common_cap, link_err, msg, msgc, ReadError};
59
60/// How long to wait for a new connection's link message. SPICE is client-speaks-first: a
61/// viewer writes its `SpiceLinkMess` as soon as the socket opens.
62const FIRST_BYTE_READ_TIMEOUT: Duration = Duration::from_secs(30);
63
64/// How long a channel may be silent once it has spoken. A viewer watching an unchanging screen
65/// sends nothing on any channel — the display is server-push and the main channel is idle — so
66/// a short bound would close healthy sessions. Half an hour, as VNC, turns "forever" into a
67/// bound on connections that may be unauthenticated (a server with no password).
68const IDLE_BETWEEN_MESSAGES_TIMEOUT: Duration = Duration::from_secs(1800);
69
70/// Concurrent TCP connections, counting every channel of every session.
71const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
72
73/// The largest message body a client may send on any channel, and this server's declared
74/// `max_inbound_bytes`. Client messages are small — the largest a real client sends is agent
75/// data (2 KiB chunks) — and the size is a peer-chosen `u32` refused before allocation.
76pub const MAX_CLIENT_MESSAGE_BYTES: u32 = 64 * 1024;
77
78/// What a peer over [`MAX_CONNECTIONS`] is told: an error-only link reply
79/// (`SPICE_LINK_ERR_ERROR`). SPICE is client-speaks-first, so the reply is ahead of the
80/// client's link message; a viewer that reads it reports a link error rather than a reset.
81const CONNECTION_CAP_REFUSAL: &[u8] = &cap_refusal();
82
83const fn cap_refusal() -> [u8; wire::LINK_HEADER_BYTES + wire::LINK_REPLY_BYTES] {
84    let mut b = [0u8; wire::LINK_HEADER_BYTES + wire::LINK_REPLY_BYTES];
85    b[0] = b'R';
86    b[1] = b'E';
87    b[2] = b'D';
88    b[3] = b'Q';
89    b[4] = wire::VERSION_MAJOR as u8;
90    b[8] = wire::VERSION_MINOR as u8;
91    b[12] = wire::LINK_REPLY_BYTES as u8;
92    b[16] = link_err::ERROR as u8;
93    // Capability counts 0 and the offset: the bytes after the key.
94    b[16 + 4 + wire::TICKET_PUBKEY_BYTES + 8] = wire::LINK_REPLY_BYTES as u8;
95    b
96}
97
98/// The placeholder screen's colours: a background no colour name produces by accident.
99pub const PLACEHOLDER_BACKGROUND: Color = Color::rgb(24, 26, 34);
100const PLACEHOLDER_FOREGROUND: Color = Color::rgb(203, 213, 225);
101/// The notify text for an input event the model gave no usable answer to. Fixed: nothing
102/// NetGet knows about the failure reaches the peer.
103pub const NO_ANSWER_NOTICE: &str = "NetGet: no answer for this input; the screen is unchanged";
104
105type Reader = crate::server::accept_bounded::IdleTimeoutReader<ReadHalf<TcpStream>>;
106type Writer = Arc<Mutex<WriteHalf<TcpStream>>>;
107
108/// Startup configuration, validated by `actions.rs`.
109pub struct Config {
110    pub width: u32,
111    pub height: u32,
112    pub password: Option<String>,
113    pub name: String,
114}
115
116pub struct SpiceServer;
117
118struct Shared {
119    config: Config,
120    key: rsa::RsaPrivateKey,
121    pubkey: Vec<u8>,
122    sessions: std::sync::Mutex<HashMap<u32, Arc<Session>>>,
123    app_state: Arc<AppState>,
124    llm_client: OllamaClient,
125    status_tx: mpsc::UnboundedSender<String>,
126    server_id: ServerId,
127    image_ids: AtomicU64,
128}
129
130/// One client's session: the main channel and the display and inputs channels linked to it.
131struct Session {
132    id: u32,
133    main: Mutex<Option<Arc<ChannelWriter>>>,
134    display: Mutex<DisplayState>,
135    display_attached: AtomicBool,
136    inputs_attached: AtomicBool,
137    mouse_mode: AtomicU32,
138    closed: watch::Sender<bool>,
139}
140
141/// The display channel's writer and the current frame. The mutex is the display's write lock:
142/// a frame's strips and the MARK after the first one go out under it, so two frames never
143/// interleave and a frame never precedes SURFACE_CREATE.
144#[derive(Default)]
145struct DisplayState {
146    writer: Option<Arc<ChannelWriter>>,
147    frame: Option<Arc<Vec<u8>>>,
148    marked: bool,
149}
150
151impl Session {
152    fn close(&self) {
153        let _ = self.closed.send(true);
154    }
155}
156
157/// One channel's write side: the framing (mini or full header) and the serial.
158struct ChannelWriter {
159    half: Writer,
160    mini: bool,
161    serial: AtomicU64,
162    app_state: Arc<AppState>,
163    server_id: ServerId,
164    connection_id: ConnectionId,
165}
166
167impl ChannelWriter {
168    async fn send(&self, msg_type: u16, body: &[u8]) -> std::io::Result<()> {
169        let len = {
170            let mut w = self.half.lock().await;
171            let serial = self.serial.fetch_add(1, Ordering::SeqCst) + 1;
172            let mut buf = wire::data_header(self.mini, serial, msg_type, body.len() as u32);
173            buf.extend_from_slice(body);
174            w.write_all(&buf).await?;
175            w.flush().await?;
176            buf.len()
177        };
178        self.app_state
179            .update_connection_stats(
180                self.server_id,
181                self.connection_id,
182                None,
183                Some(len as u64),
184                None,
185                Some(1),
186            )
187            .await;
188        Ok(())
189    }
190}
191
192/// What the model said about one event.
193#[derive(Default)]
194struct Decision {
195    accept: bool,
196    reject: bool,
197    redraw: Option<Vec<DisplayCommand>>,
198    no_change: bool,
199    notify: Vec<(u32, String)>,
200    close: bool,
201    /// Set when no usable answer arrived; the reason stays in the log.
202    no_answer: bool,
203}
204
205impl SpiceServer {
206    pub async fn spawn(
207        listen_addr: SocketAddr,
208        llm_client: OllamaClient,
209        app_state: Arc<AppState>,
210        status_tx: mpsc::UnboundedSender<String>,
211        server_id: ServerId,
212        config: Config,
213    ) -> Result<SocketAddr> {
214        // One key per server, generated off the async workers: RSA key generation is the most
215        // expensive thing this module does, and spice-server pays it on every link.
216        let key = tokio::task::spawn_blocking(wire::generate_ticket_key)
217            .await
218            .context("ticket key generation")??;
219        let pubkey = wire::ticket_public_key(&key)?;
220        let listener =
221            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
222        let local_addr = listener.local_addr()?;
223        // The E2E harness parses the port out of everything after the last "on ".
224        Log::new(Some(&status_tx)).info(format!("SPICE server listening on {local_addr}"));
225        Log::new(Some(&status_tx)).debug(format!(
226            "SPICE surface {}x{}, name '{}', password {}",
227            config.width,
228            config.height,
229            config.name,
230            if config.password.is_some() {
231                "required"
232            } else {
233                "not required"
234            }
235        ));
236        let shared = Arc::new(Shared {
237            config,
238            key,
239            pubkey,
240            sessions: std::sync::Mutex::new(HashMap::new()),
241            app_state: app_state.clone(),
242            llm_client,
243            status_tx: status_tx.clone(),
244            server_id,
245            image_ids: AtomicU64::new(1),
246        });
247        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
248        let registrar = app_state.clone();
249        let accept = tokio::spawn(async move {
250            loop {
251                match crate::server::accept_bounded::accept_bounded(
252                    &listener,
253                    &limiter,
254                    CONNECTION_CAP_REFUSAL,
255                    "SPICE",
256                    Some(&status_tx),
257                )
258                .await
259                {
260                    Ok((stream, remote, permit)) => {
261                        let shared = shared.clone();
262                        let local = stream.local_addr().unwrap_or(local_addr);
263                        let owner = app_state.clone();
264                        // Tracked, not detached: stop_server aborts this task too.
265                        owner
266                            .spawn_server_task(server_id, async move {
267                                let _permit = permit;
268                                if let Err(e) =
269                                    Self::handle_connection(shared.clone(), stream, remote, local)
270                                        .await
271                                {
272                                    Log::new(Some(&shared.status_tx)).debug(format!(
273                                        "SPICE connection from {remote} ended: {e}"
274                                    ));
275                                }
276                            })
277                            .await;
278                    }
279                    Err(e) => {
280                        Log::new(Some(&status_tx))
281                            .error(format!("SPICE accept failed, stopping loop: {e}"));
282                        break;
283                    }
284                }
285            }
286        });
287        registrar.register_server_task(server_id, accept).await;
288        Ok(local_addr)
289    }
290
291    async fn handle_connection(
292        shared: Arc<Shared>,
293        stream: TcpStream,
294        remote: SocketAddr,
295        local: SocketAddr,
296    ) -> Result<()> {
297        let app_state = shared.app_state.clone();
298        let server_id = shared.server_id;
299        let connection_id = ConnectionId::new(app_state.get_next_unified_id().await);
300        let (read_half, write_half) = tokio::io::split(stream);
301        let mut reader: Reader = crate::server::accept_bounded::IdleTimeoutReader::with_first(
302            read_half,
303            FIRST_BYTE_READ_TIMEOUT,
304            IDLE_BETWEEN_MESSAGES_TIMEOUT,
305        );
306        let writer: Writer = Arc::new(Mutex::new(write_half));
307        let now = crate::utils::clock::Instant::now();
308        app_state
309            .add_connection_to_server(
310                server_id,
311                ConnectionState {
312                    id: connection_id,
313                    remote_addr: remote,
314                    local_addr: local,
315                    bytes_sent: 0,
316                    bytes_received: 0,
317                    packets_sent: 0,
318                    packets_received: 0,
319                    last_activity: now,
320                    status: ConnectionStatus::Active,
321                    status_changed_at: now,
322                    protocol_info: ProtocolConnectionInfo::empty(),
323                },
324            )
325            .await;
326        let _ = shared.status_tx.send("__UPDATE_UI__".to_string());
327        // `[ disconnect this peer ]` half-closes this channel's socket through the generic peer
328        // task; the client then closes its end and this channel's loop reads EOF. The drawing
329        // verbs are not executed there: a frame belongs to the session's display channel.
330        let peer_rx = crate::server::peer_support::register_peer_channel(
331            &app_state,
332            server_id,
333            connection_id.as_u32(),
334        )
335        .await;
336        crate::server::peer_support::spawn_peer_command_task(
337            peer_rx,
338            Arc::new(SpiceProtocol::new()),
339            app_state.clone(),
340            server_id,
341            connection_id.as_u32(),
342            writer.clone(),
343            shared.status_tx.clone(),
344        );
345
346        let result = Self::serve(&shared, &mut reader, &writer, connection_id, remote).await;
347
348        {
349            let mut w = writer.lock().await;
350            let _ = w.shutdown().await;
351        }
352        app_state
353            .remove_peer_handle(server_id, connection_id.as_u32())
354            .await;
355        app_state
356            .remove_connection_from_server(server_id, connection_id)
357            .await;
358        let _ = shared.status_tx.send("__UPDATE_UI__".to_string());
359        result
360    }
361
362    /// The link stage, then the channel.
363    async fn serve(
364        shared: &Arc<Shared>,
365        reader: &mut Reader,
366        writer: &Writer,
367        connection_id: ConnectionId,
368        remote: SocketAddr,
369    ) -> Result<()> {
370        let log = Log::new(Some(&shared.status_tx));
371        let mut header = [0u8; wire::LINK_HEADER_BYTES];
372        reader.read_exact(&mut header).await?;
373        let size = match wire::parse_link_header(&header, wire::LINK_MESS_BYTES) {
374            Ok(size) => size,
375            Err(wire::LinkHeaderError(code)) => {
376                log.warn(format!(
377                    "SPICE link from {remote} refused decision=protocol_error: {}",
378                    link_err::name(code)
379                ));
380                write_raw(writer, &wire::LinkReply::error(code)).await?;
381                return Ok(());
382            }
383        };
384        let mut body = vec![0u8; size as usize];
385        reader.read_exact(&mut body).await?;
386        let link = match wire::LinkMess::decode(&body) {
387            Ok(l) => l,
388            Err(e) => {
389                log.warn(format!(
390                    "SPICE link from {remote} refused decision=protocol_error: {e}"
391                ));
392                write_raw(writer, &wire::LinkReply::error(link_err::INVALID_DATA)).await?;
393                return Ok(());
394            }
395        };
396        let channel_caps = if link.channel_type == channel::MAIN {
397            vec![wire::caps_word(&[wire::main_cap::NAME_AND_UUID])]
398        } else {
399            Vec::new()
400        };
401        write_raw(
402            writer,
403            &wire::LinkReply {
404                error: link_err::OK,
405                pubkey: shared.pubkey.clone(),
406                common_caps: vec![wire::caps_word(&[
407                    common_cap::AUTH_SELECTION,
408                    common_cap::AUTH_SPICE,
409                    common_cap::MINI_HEADER,
410                ])],
411                channel_caps,
412            }
413            .encode(),
414        )
415        .await?;
416
417        if wire::has_cap(&link.common_caps, common_cap::AUTH_SELECTION) {
418            let mechanism = reader.read_u32_le().await?;
419            if mechanism != wire::AUTH_MECHANISM_SPICE {
420                log.warn(format!(
421                    "SPICE link from {remote} refused decision=protocol_error: auth mechanism \
422                     {mechanism} (only SPICE tickets are offered)"
423                ));
424                write_raw(writer, &link_err::INVALID_DATA.to_le_bytes()).await?;
425                return Ok(());
426            }
427        }
428        let mut ticket = [0u8; wire::TICKET_BYTES];
429        reader.read_exact(&mut ticket).await?;
430        if let Some(password) = &shared.config.password {
431            let good = wire::decrypt_ticket(&shared.key, &ticket)
432                .is_some_and(|t| wire::secrets_equal(&t, password.as_bytes()));
433            if !good {
434                // The protocol refused this peer before any model was asked; the password
435                // itself is never logged.
436                log.warn(format!(
437                    "SPICE {} link from {remote} refused decision=protocol_reject_bad_ticket: \
438                     the ticket does not carry the configured password",
439                    channel::name(link.channel_type)
440                ));
441                write_raw(writer, &link_err::PERMISSION_DENIED.to_le_bytes()).await?;
442                return Ok(());
443            }
444        }
445        let mini = wire::has_cap(&link.common_caps, common_cap::MINI_HEADER);
446        let channel_writer = Arc::new(ChannelWriter {
447            half: writer.clone(),
448            mini,
449            serial: AtomicU64::new(0),
450            app_state: shared.app_state.clone(),
451            server_id: shared.server_id,
452            connection_id,
453        });
454
455        match link.channel_type {
456            channel::MAIN => {
457                Self::admit_main(shared, reader, channel_writer, &link, connection_id, remote).await
458            }
459            channel::DISPLAY | channel::INPUTS => {
460                let session = shared
461                    .sessions
462                    .lock()
463                    .ok()
464                    .and_then(|s| s.get(&link.connection_id).cloned());
465                let Some(session) = session else {
466                    log.warn(format!(
467                        "SPICE {} link from {remote} refused decision=protocol_error: no session \
468                         {:#010x}",
469                        channel::name(link.channel_type),
470                        link.connection_id
471                    ));
472                    write_raw(writer, &link_err::BAD_CONNECTION_ID.to_le_bytes()).await?;
473                    return Ok(());
474                };
475                let slot = if link.channel_type == channel::DISPLAY {
476                    &session.display_attached
477                } else {
478                    &session.inputs_attached
479                };
480                if link.channel_id != 0 || slot.swap(true, Ordering::SeqCst) {
481                    log.warn(format!(
482                        "SPICE {} channel {} link from {remote} refused decision=protocol_error: \
483                         not available",
484                        channel::name(link.channel_type),
485                        link.channel_id
486                    ));
487                    write_raw(writer, &link_err::CHANNEL_NOT_AVAILABLE.to_le_bytes()).await?;
488                    return Ok(());
489                }
490                write_raw(writer, &link_err::OK.to_le_bytes()).await?;
491                log.info(format!(
492                    "SPICE {} channel linked for session {:#010x}",
493                    channel::name(link.channel_type),
494                    session.id
495                ));
496                let result = if link.channel_type == channel::DISPLAY {
497                    Self::run_display(shared, &session, reader, channel_writer, connection_id).await
498                } else {
499                    Self::run_inputs(shared, &session, reader, channel_writer, connection_id).await
500                };
501                slot.store(false, Ordering::SeqCst);
502                result
503            }
504            other => {
505                log.warn(format!(
506                    "SPICE {} link from {remote} refused decision=protocol_error: channel not \
507                     offered",
508                    channel::name(other)
509                ));
510                write_raw(writer, &link_err::CHANNEL_NOT_AVAILABLE.to_le_bytes()).await?;
511                Ok(())
512            }
513        }
514    }
515
516    /// Ask the model whether to admit a main channel, then run it.
517    async fn admit_main(
518        shared: &Arc<Shared>,
519        reader: &mut Reader,
520        writer: Arc<ChannelWriter>,
521        link: &wire::LinkMess,
522        connection_id: ConnectionId,
523        remote: SocketAddr,
524    ) -> Result<()> {
525        let caps: Vec<&str> = [
526            (common_cap::AUTH_SELECTION, "auth_selection"),
527            (common_cap::AUTH_SPICE, "auth_spice"),
528            (common_cap::AUTH_SASL, "auth_sasl"),
529            (common_cap::MINI_HEADER, "mini_header"),
530        ]
531        .iter()
532        .filter(|(bit, _)| wire::has_cap(&link.common_caps, *bit))
533        .map(|(_, n)| *n)
534        .collect();
535        let event = Event::new(
536            &actions::CONNECTION_REQUEST_EVENT,
537            json!({
538                "client": remote.to_string(),
539                "password_configured": shared.config.password.is_some(),
540                "width": shared.config.width,
541                "height": shared.config.height,
542                "client_capabilities": caps,
543            }),
544        );
545        let decision = consult(shared, connection_id, &event).await;
546        if !decision.accept || decision.reject || decision.close {
547            write_raw(&writer.half, &link_err::PERMISSION_DENIED.to_le_bytes()).await?;
548            return Ok(());
549        }
550        let frame = match decision.redraw {
551            Some(commands) => {
552                match render_bgrx(shared.config.width, shared.config.height, commands).await {
553                    Ok(f) => Some(Arc::new(f)),
554                    Err(e) => {
555                        Log::new(Some(&shared.status_tx))
556                            .error(format!("SPICE first screen could not be rendered: {e:#}"));
557                        None
558                    }
559                }
560            }
561            None => None,
562        };
563        let (closed, _) = watch::channel(false);
564        let session = {
565            let Ok(mut sessions) = shared.sessions.lock() else {
566                anyhow::bail!("the session table is poisoned");
567            };
568            let mut id = rand::random::<u32>();
569            while id == 0 || sessions.contains_key(&id) {
570                id = rand::random::<u32>();
571            }
572            let session = Arc::new(Session {
573                id,
574                main: Mutex::new(None),
575                display: Mutex::new(DisplayState {
576                    frame,
577                    ..Default::default()
578                }),
579                display_attached: AtomicBool::new(false),
580                inputs_attached: AtomicBool::new(false),
581                mouse_mode: AtomicU32::new(wire::MOUSE_MODE_CLIENT),
582                closed,
583            });
584            sessions.insert(id, session.clone());
585            session
586        };
587        write_raw(&writer.half, &link_err::OK.to_le_bytes()).await?;
588        Log::new(Some(&shared.status_tx)).info(format!(
589            "SPICE session {:#010x} opened for {remote}",
590            session.id
591        ));
592        let result = Self::run_main(
593            shared,
594            &session,
595            reader,
596            writer,
597            link,
598            connection_id,
599            decision.notify,
600        )
601        .await;
602        // The main channel is the session: when it goes, every channel goes.
603        session.close();
604        if let Ok(mut sessions) = shared.sessions.lock() {
605            sessions.remove(&session.id);
606        }
607        *session.main.lock().await = None;
608        Log::new(Some(&shared.status_tx))
609            .info(format!("SPICE session {:#010x} closed", session.id));
610        result
611    }
612
613    async fn run_main(
614        shared: &Arc<Shared>,
615        session: &Arc<Session>,
616        reader: &mut Reader,
617        writer: Arc<ChannelWriter>,
618        link: &wire::LinkMess,
619        connection_id: ConnectionId,
620        notices: Vec<(u32, String)>,
621    ) -> Result<()> {
622        let supported = wire::MOUSE_MODE_SERVER | wire::MOUSE_MODE_CLIENT;
623        writer
624            .send(
625                msg::MAIN_INIT,
626                &wire::main_init(
627                    session.id,
628                    1,
629                    supported,
630                    session.mouse_mode.load(Ordering::SeqCst),
631                    0,
632                    0,
633                    multimedia_time(),
634                    0,
635                ),
636            )
637            .await?;
638        if wire::has_cap(&link.channel_caps, wire::main_cap::NAME_AND_UUID) {
639            writer
640                .send(msg::MAIN_NAME, &wire::main_name(&shared.config.name))
641                .await?;
642        }
643        *session.main.lock().await = Some(writer.clone());
644        for (severity, text) in notices {
645            send_notify(session, severity, &text).await;
646        }
647        let mut closed = session.closed.subscribe();
648        loop {
649            let message = tokio::select! {
650                m = wire::read_message(reader, writer.mini, MAX_CLIENT_MESSAGE_BYTES) => m,
651                _ = closed.wait_for(|c| *c) => return Ok(()),
652            };
653            let (msg_type, body) = match message {
654                Ok(m) => m,
655                Err(e) => return end_of_channel(shared, "main", e),
656            };
657            count_in(shared, connection_id, &body).await;
658            match msg_type {
659                msgc::MAIN_ATTACH_CHANNELS => {
660                    writer
661                        .send(
662                            msg::MAIN_CHANNELS_LIST,
663                            &wire::channels_list(&[(channel::DISPLAY, 0), (channel::INPUTS, 0)]),
664                        )
665                        .await?;
666                }
667                msgc::MAIN_MOUSE_MODE_REQUEST => {
668                    let mode = wire::Cursor::new(&body).u16("mouse mode")? as u32;
669                    if mode & supported != 0 && mode.count_ones() == 1 {
670                        session.mouse_mode.store(mode, Ordering::SeqCst);
671                    }
672                    writer
673                        .send(
674                            msg::MAIN_MOUSE_MODE,
675                            &wire::mouse_mode(
676                                supported as u16,
677                                session.mouse_mode.load(Ordering::SeqCst) as u16,
678                            ),
679                        )
680                        .await?;
681                }
682                other => tracing::trace!("SPICE main channel message {other} ignored"),
683            }
684        }
685    }
686
687    async fn run_display(
688        shared: &Arc<Shared>,
689        session: &Arc<Session>,
690        reader: &mut Reader,
691        writer: Arc<ChannelWriter>,
692        connection_id: ConnectionId,
693    ) -> Result<()> {
694        let (w, h) = (shared.config.width, shared.config.height);
695        let have_frame = {
696            let mut display = session.display.lock().await;
697            writer
698                .send(
699                    msg::DISPLAY_SURFACE_CREATE,
700                    &wire::surface_create(
701                        0,
702                        w,
703                        h,
704                        wire::SURFACE_FMT_32_XRGB,
705                        wire::SURFACE_FLAGS_PRIMARY,
706                    ),
707                )
708                .await?;
709            if let Some(frame) = display.frame.clone() {
710                send_frame(shared, &writer, &frame).await?;
711                writer.send(msg::DISPLAY_MARK, &[]).await?;
712                display.marked = true;
713            }
714            display.writer = Some(writer.clone());
715            display.frame.is_some()
716        };
717        if !have_frame {
718            let event = Event::new(
719                &actions::DISPLAY_READY_EVENT,
720                json!({"width": w, "height": h}),
721            );
722            let decision = consult(shared, connection_id, &event).await;
723            apply(shared, session, decision, true).await;
724        }
725        let mut closed = session.closed.subscribe();
726        let result = loop {
727            let message = tokio::select! {
728                m = wire::read_message(reader, writer.mini, MAX_CLIENT_MESSAGE_BYTES) => m,
729                _ = closed.wait_for(|c| *c) => break Ok(()),
730            };
731            match message {
732                Ok((msg_type, body)) => {
733                    count_in(shared, connection_id, &body).await;
734                    tracing::trace!("SPICE display channel message {msg_type} ignored");
735                }
736                Err(e) => break end_of_channel(shared, "display", e),
737            }
738        };
739        let mut display = session.display.lock().await;
740        display.writer = None;
741        display.marked = false;
742        result
743    }
744
745    async fn run_inputs(
746        shared: &Arc<Shared>,
747        session: &Arc<Session>,
748        reader: &mut Reader,
749        writer: Arc<ChannelWriter>,
750        connection_id: ConnectionId,
751    ) -> Result<()> {
752        let log = Log::new(Some(&shared.status_tx));
753        writer.send(msg::INPUTS_INIT, &0u16.to_le_bytes()).await?;
754        let (w, h) = (shared.config.width as i64, shared.config.height as i64);
755        let (mut x, mut y) = (w / 2, h / 2);
756        let mut decoder = keys::Decoder::default();
757        let mut held: Vec<(u8, bool)> = Vec::new();
758        let mut motions: u32 = 0;
759        let mut closed = session.closed.subscribe();
760        loop {
761            let message = tokio::select! {
762                m = wire::read_message(reader, writer.mini, MAX_CLIENT_MESSAGE_BYTES) => m,
763                _ = closed.wait_for(|c| *c) => return Ok(()),
764            };
765            let (msg_type, body) = match message {
766                Ok(m) => m,
767                Err(e) => return end_of_channel(shared, "inputs", e),
768            };
769            count_in(shared, connection_id, &body).await;
770            let mut c = wire::Cursor::new(&body);
771            let strokes = match msg_type {
772                msgc::INPUTS_KEY_DOWN | msgc::INPUTS_KEY_UP => {
773                    decoder.feed_code(c.u32("key code")?)
774                }
775                msgc::INPUTS_KEY_SCANCODE => body.iter().filter_map(|b| decoder.feed(*b)).collect(),
776                msgc::INPUTS_MOUSE_MOTION | msgc::INPUTS_MOUSE_POSITION => {
777                    if msg_type == msgc::INPUTS_MOUSE_MOTION {
778                        let dx = c.i32("dx")? as i64;
779                        let dy = c.i32("dy")? as i64;
780                        x = (x + dx).clamp(0, w - 1);
781                        y = (y + dy).clamp(0, h - 1);
782                    } else {
783                        x = (c.u32("x")? as i64).clamp(0, w - 1);
784                        y = (c.u32("y")? as i64).clamp(0, h - 1);
785                    }
786                    // Movement is logged, not modelled: a viewer sends one per pixel.
787                    log.debug_to(Sink::Both, format!("SPICE pointer at {x},{y}"));
788                    motions += 1;
789                    if motions.is_multiple_of(4) {
790                        // SPICE_INPUT_MOTION_ACK_BUNCH: a viewer stops sending motion when
791                        // eight go unacknowledged.
792                        writer.send(msg::INPUTS_MOUSE_MOTION_ACK, &[]).await?;
793                    }
794                    Vec::new()
795                }
796                msgc::INPUTS_MOUSE_PRESS | msgc::INPUTS_MOUSE_RELEASE => {
797                    let b = c.u8("button")?;
798                    let state = c.u16("buttons state")?;
799                    if msg_type == msgc::INPUTS_MOUSE_RELEASE {
800                        log.debug(format!(
801                            "SPICE {} button released at {x},{y}",
802                            wire::button::name(b)
803                        ));
804                    } else {
805                        let mode = if session.mouse_mode.load(Ordering::SeqCst)
806                            == wire::MOUSE_MODE_CLIENT
807                        {
808                            "client"
809                        } else {
810                            "server"
811                        };
812                        let event = Event::new(
813                            &actions::MOUSE_EVENT,
814                            json!({
815                                "x": x, "y": y,
816                                "button": wire::button::name(b),
817                                "buttons": wire::button::names_in(state | wire::button::mask(b)),
818                                "mouse_mode": mode,
819                            }),
820                        );
821                        let decision = consult(shared, connection_id, &event).await;
822                        if !apply(shared, session, decision, false).await {
823                            return Ok(());
824                        }
825                    }
826                    Vec::new()
827                }
828                other => {
829                    tracing::trace!("SPICE inputs channel message {other} ignored");
830                    Vec::new()
831                }
832            };
833            for stroke in strokes {
834                let key = (stroke.scancode, stroke.extended);
835                if stroke.released {
836                    held.retain(|k| *k != key);
837                    continue;
838                }
839                if !held.contains(&key) {
840                    held.push(key);
841                }
842                if keys::modifier(stroke.scancode, stroke.extended).is_some() {
843                    continue;
844                }
845                let has =
846                    |m: keys::Modifier| held.iter().any(|(s, e)| keys::modifier(*s, *e) == Some(m));
847                let shift = has(keys::Modifier::Shift);
848                let text = (!stroke.extended)
849                    .then(|| keys::char_for(stroke.scancode, shift))
850                    .flatten();
851                let event = Event::new(
852                    &actions::KEY_EVENT,
853                    json!({
854                        "key": keys::key_name(stroke.scancode, stroke.extended, shift),
855                        "text": text.map(|c| c.to_string()),
856                        "scancode": stroke.scancode,
857                        "extended": stroke.extended,
858                        "shift": shift,
859                        "ctrl": has(keys::Modifier::Ctrl),
860                        "alt": has(keys::Modifier::Alt),
861                    }),
862                );
863                let decision = consult(shared, connection_id, &event).await;
864                if !apply(shared, session, decision, false).await {
865                    return Ok(());
866                }
867            }
868        }
869    }
870}
871
872/// Write link-stage bytes, which have no data header.
873async fn write_raw(writer: &Writer, bytes: &[u8]) -> std::io::Result<()> {
874    let mut w = writer.lock().await;
875    w.write_all(bytes).await?;
876    w.flush().await
877}
878
879async fn count_in(shared: &Shared, connection_id: ConnectionId, body: &[u8]) {
880    shared
881        .app_state
882        .update_connection_stats(
883            shared.server_id,
884            connection_id,
885            Some(body.len() as u64 + 6),
886            None,
887            Some(1),
888            None,
889        )
890        .await;
891}
892
893/// How a channel's read loop ended. EOF is the client leaving; an oversized message is
894/// refused here, before its body was read.
895fn end_of_channel(shared: &Shared, name: &str, e: ReadError) -> Result<()> {
896    let log = Log::new(Some(&shared.status_tx));
897    match e {
898        ReadError::Io(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
899            log.info(format!("SPICE {name} channel closed by the client"));
900            Ok(())
901        }
902        ReadError::TooLarge { .. } | ReadError::SubList => {
903            log.warn(format!(
904                "SPICE {name} channel closed decision=fail_closed_message_too_large: {e}"
905            ));
906            Ok(())
907        }
908        ReadError::Io(e) => Err(e.into()),
909    }
910}
911
912fn multimedia_time() -> u32 {
913    crate::utils::clock::SystemTime::now()
914        .duration_since(crate::utils::clock::UNIX_EPOCH)
915        .map(|d| d.as_millis() as u32)
916        .unwrap_or(0)
917}
918
919/// Send a notification on the session's main channel, if it has one.
920async fn send_notify(session: &Session, severity: u32, text: &str) {
921    let main = session.main.lock().await.clone();
922    if let Some(main) = main {
923        let stamp = crate::utils::clock::SystemTime::now()
924            .duration_since(crate::utils::clock::UNIX_EPOCH)
925            .map(|d| d.as_nanos() as u64)
926            .unwrap_or(0);
927        let _ = main
928            .send(msg::NOTIFY, &wire::notify(stamp, severity, 0, text))
929            .await;
930    }
931}
932
933/// Make `pixels` the session's screen, and send it if a display channel is attached.
934async fn publish_frame(shared: &Shared, session: &Session, pixels: Vec<u8>) {
935    let pixels = Arc::new(pixels);
936    let mut display = session.display.lock().await;
937    display.frame = Some(pixels.clone());
938    let Some(writer) = display.writer.clone() else {
939        return;
940    };
941    let sent = async {
942        send_frame(shared, &writer, &pixels).await?;
943        if !display.marked {
944            writer.send(msg::DISPLAY_MARK, &[]).await?;
945        }
946        std::io::Result::Ok(())
947    }
948    .await;
949    match sent {
950        Ok(()) => display.marked = true,
951        Err(e) => {
952            Log::new(Some(&shared.status_tx)).debug(format!("SPICE frame not sent: {e}"));
953            display.writer = None;
954        }
955    }
956}
957
958/// The whole surface as one DRAW_COPY of an uncompressed bitmap, as spice-server sends a
959/// full-screen update: one message, so a client sees the frame land at once (spice-gtk's
960/// `spicy-screenshot` writes its file on the first invalidation it sees).
961async fn send_frame(shared: &Shared, writer: &ChannelWriter, pixels: &[u8]) -> std::io::Result<()> {
962    let (w, h) = (shared.config.width, shared.config.height);
963    let id = shared.image_ids.fetch_add(1, Ordering::SeqCst);
964    let body = wire::draw_copy_bitmap(0, 0, 0, w, h, id, pixels);
965    writer.send(msg::DISPLAY_DRAW_COPY, &body).await
966}
967
968/// Carry out a decision about the screen. `first_screen` draws the placeholder when there is
969/// no usable answer; otherwise the last frame stays and a notify says so. Returns false when
970/// the session was closed.
971async fn apply(shared: &Shared, session: &Session, decision: Decision, first_screen: bool) -> bool {
972    for (severity, text) in &decision.notify {
973        send_notify(session, *severity, text).await;
974    }
975    if decision.close {
976        session.close();
977        return false;
978    }
979    let (w, h) = (shared.config.width, shared.config.height);
980    if let Some(commands) = decision.redraw {
981        match render_bgrx(w, h, commands).await {
982            Ok(pixels) => publish_frame(shared, session, pixels).await,
983            Err(e) => Log::new(Some(&shared.status_tx))
984                .error(format!("SPICE screen could not be rendered: {e:#}")),
985        }
986    } else if decision.no_answer {
987        if first_screen {
988            match render_bgrx(w, h, placeholder("the model gave no screen to draw", h)).await {
989                Ok(pixels) => publish_frame(shared, session, pixels).await,
990                Err(e) => Log::new(Some(&shared.status_tx))
991                    .error(format!("SPICE placeholder could not be rendered: {e:#}")),
992            }
993        } else {
994            send_notify(session, wire::NOTIFY_SEVERITY_WARN, NO_ANSWER_NOTICE).await;
995        }
996    } else if first_screen && !decision.no_change && session.display.lock().await.frame.is_none() {
997        // Only a notification came back for the first screen: something must be on it.
998        if let Ok(pixels) = render_bgrx(w, h, placeholder("no screen has been drawn yet", h)).await
999        {
1000            publish_frame(shared, session, pixels).await;
1001        }
1002    }
1003    true
1004}
1005
1006fn placeholder(reason: &str, height: u32) -> Vec<DisplayCommand> {
1007    vec![
1008        DisplayCommand::SetBackground {
1009            color: PLACEHOLDER_BACKGROUND,
1010        },
1011        DisplayCommand::DrawText {
1012            x: 24,
1013            y: height / 2,
1014            text: format!("NetGet SPICE: {reason}"),
1015            font_size: 18,
1016            color: PLACEHOLDER_FOREGROUND,
1017        },
1018    ]
1019}
1020
1021/// Rasterise drawing commands into the surface's pixels: BGRX, `width * 4` bytes a row, which
1022/// is SPICE's 32-bit bitmap format (a little-endian xRGB word per pixel). Off the async
1023/// workers, so a panic in the canvas is an error here rather than a dead task.
1024async fn render_bgrx(width: u32, height: u32, commands: Vec<DisplayCommand>) -> Result<Vec<u8>> {
1025    tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
1026        let mut canvas = DisplayCanvas::new(width, height);
1027        canvas.add_commands(commands);
1028        let image = canvas
1029            .try_render()
1030            .context("invalid SPICE display commands")?;
1031        let mut out = Vec::new();
1032        out.try_reserve_exact(width as usize * height as usize * 4)
1033            .context("unable to allocate the SPICE surface")?;
1034        for px in image.pixels() {
1035            out.extend_from_slice(&[px[2], px[1], px[0], 0]);
1036        }
1037        Ok(out)
1038    })
1039    .await
1040    .context("SPICE rendering failed")?
1041}
1042
1043/// Ask the handlers (script → static → model) about `event`. Never fails: the outcome,
1044/// including "no usable answer", is in the decision, and exactly one `decision=` line is
1045/// logged for it.
1046async fn consult(shared: &Shared, connection_id: ConnectionId, event: &Event) -> Decision {
1047    let log = Log::new(Some(&shared.status_tx));
1048    let mut d = Decision::default();
1049    let id = &event.event_type.id;
1050    let execution = match call_llm(
1051        &shared.llm_client,
1052        &shared.app_state,
1053        shared.server_id,
1054        Some(connection_id),
1055        event,
1056        &SpiceProtocol::new(),
1057    )
1058    .await
1059    {
1060        Ok(x) => x,
1061        Err(e) => {
1062            let tag = match crate::utils::wire_failure::WireFailure::classify(&e) {
1063                crate::utils::wire_failure::WireFailure::Overloaded => "fail_closed_llm_overloaded",
1064                crate::utils::wire_failure::WireFailure::Unavailable => "fail_closed_llm_error",
1065            };
1066            log.error(format!(
1067                "SPICE {id} on {connection_id} not answered decision={tag}: {e}"
1068            ));
1069            d.no_answer = true;
1070            return d;
1071        }
1072    };
1073    for m in execution.messages {
1074        let _ = shared.status_tx.send(m);
1075    }
1076    let mut results = Vec::new();
1077    flatten(execution.protocol_results, &mut results);
1078    let mut usable = false;
1079    let mut undecodable = false;
1080    for r in results {
1081        match r {
1082            ActionResult::Custom { name, data } => {
1083                usable = true;
1084                match name.as_str() {
1085                    actions::RESULT_ACCEPT => d.accept = true,
1086                    actions::RESULT_REJECT => d.reject = true,
1087                    actions::RESULT_NO_CHANGE => d.no_change = true,
1088                    actions::RESULT_NOTIFY => d.notify.push((
1089                        data["severity"].as_u64().unwrap_or(0) as u32,
1090                        data["message"].as_str().unwrap_or_default().to_string(),
1091                    )),
1092                    actions::RESULT_DISPLAY => {
1093                        match serde_json::from_value::<Vec<DisplayCommand>>(
1094                            data["commands"].clone(),
1095                        ) {
1096                            Ok(c) => d.redraw = Some(c),
1097                            Err(_) => undecodable = true,
1098                        }
1099                    }
1100                    _ => usable = false,
1101                }
1102            }
1103            ActionResult::CloseConnection => {
1104                usable = true;
1105                d.close = true;
1106            }
1107            _ => {}
1108        }
1109    }
1110    let admission = event.event_type.id == actions::CONNECTION_REQUEST_EVENT.id;
1111    if undecodable {
1112        log.error(format!(
1113            "SPICE {id} on {connection_id} decision=fail_closed_bad_action: the render result \
1114             could not be decoded"
1115        ));
1116        d.no_answer = true;
1117        d.accept = false;
1118    } else if !usable {
1119        let detail = execution
1120            .failures
1121            .iter()
1122            .map(|f| format!("{}: {}", f.action, f.error))
1123            .collect::<Vec<_>>()
1124            .join("; ");
1125        if detail.is_empty() {
1126            log.warn(format!(
1127                "SPICE {id} on {connection_id} produced no usable action decision=model_silent"
1128            ));
1129        } else {
1130            log.error(format!(
1131                "SPICE {id} on {connection_id} produced no usable action \
1132                 decision=fail_closed_bad_action ({detail})"
1133            ));
1134        }
1135        d.no_answer = true;
1136    } else if d.close || d.reject || (admission && !d.accept) {
1137        log.info(format!(
1138            "SPICE {id} on {connection_id} decision=model_reject"
1139        ));
1140    } else {
1141        log.info(format!(
1142            "SPICE {id} on {connection_id} decision=model_answer"
1143        ));
1144    }
1145    d
1146}
1147
1148fn flatten(results: Vec<ActionResult>, out: &mut Vec<ActionResult>) {
1149    for r in results {
1150        match r {
1151            ActionResult::Multiple(inner) => flatten(inner, out),
1152            other => out.push(other),
1153        }
1154    }
1155}