Skip to main content

piw/
server.rs

1//! `piw serve`: a WebSocket server exposing run views over the live replay
2//! protocol. The server is a run reader like any other — it never writes
3//! database runs — and binds to localhost by default because database runs contain
4//! private data.
5
6use crate::protocol::{ClientMessage, PatchOp, ServerMessage, PROTOCOL_ID};
7use crate::source::RunSource;
8use anyhow::{Context, Result};
9use futures_util::{SinkExt, StreamExt};
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::sync::Arc;
13use tokio::net::{TcpListener, TcpStream};
14use tokio::sync::{broadcast, Mutex};
15use tokio_tungstenite::tungstenite::Message;
16
17/// Broadcast from the refresh loop to every connection task.
18#[derive(Clone, Debug)]
19enum Update {
20    Runs(Vec<serde_json::Value>),
21    Patch {
22        run_id: String,
23        revision: u64,
24        patch: Vec<PatchOp>,
25    },
26}
27
28pub struct ServeOptions {
29    pub database_path: PathBuf,
30    pub bind: String,
31}
32
33pub async fn serve(options: ServeOptions) -> Result<()> {
34    let listener = TcpListener::bind(&options.bind)
35        .await
36        .with_context(|| format!("binding {}", options.bind))?;
37    eprintln!(
38        "piw serve: watching {} on ws://{}/ws",
39        options.database_path.display(),
40        listener.local_addr()?
41    );
42    serve_on(listener, options.database_path).await
43}
44
45/// Accept-loop core, split out so tests can bind an ephemeral port.
46pub async fn serve_on(listener: TcpListener, database_path: PathBuf) -> Result<()> {
47    // The protocol has no authentication, so a reachable server hands run
48    // database runs to anyone. Refuse non-loopback listeners here, at the single
49    // entry point every caller goes through; view remote runs through an
50    // SSH tunnel instead.
51    let local = listener.local_addr()?;
52    if !local.ip().is_loopback() {
53        anyhow::bail!(
54            "refusing to serve on non-loopback address {local}: the live replay \
55             protocol is unauthenticated; bind to 127.0.0.1 and use an SSH tunnel \
56             for remote access"
57        );
58    }
59    let source = Arc::new(Mutex::new(RunSource::new(&database_path)?));
60    let (updates_tx, _) = broadcast::channel::<Update>(256);
61
62    // Refresh loop: wake on filesystem changes (plus a slow safety tick for
63    // the possibly-interrupted timer) and broadcast the resulting patches.
64    {
65        let source = Arc::clone(&source);
66        let updates_tx = updates_tx.clone();
67        tokio::spawn(async move {
68            loop {
69                tokio::time::sleep(std::time::Duration::from_millis(250)).await;
70                let outcome = source.lock().await.refresh_all();
71                for (run_id, revision, patch) in outcome.patches {
72                    let _ = updates_tx.send(Update::Patch {
73                        run_id,
74                        revision,
75                        patch,
76                    });
77                }
78                if outcome.listing_changed {
79                    let _ = updates_tx.send(Update::Runs(source.lock().await.summaries()));
80                }
81            }
82        });
83    }
84
85    loop {
86        let (stream, _addr) = listener.accept().await?;
87        let source = Arc::clone(&source);
88        let updates_rx = updates_tx.subscribe();
89        tokio::spawn(async move {
90            let _ = handle_connection(stream, source, updates_rx).await;
91        });
92    }
93}
94
95async fn send(
96    sink: &mut (impl SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin),
97    message: &ServerMessage,
98) -> Result<()> {
99    let text = serde_json::to_string(message)?;
100    sink.send(Message::Text(text.into())).await?;
101    Ok(())
102}
103
104// The error type (a full HTTP response) is dictated by tungstenite's
105// handshake callback signature.
106#[allow(clippy::result_large_err)]
107fn reject_browser_origins(
108    request: &tokio_tungstenite::tungstenite::handshake::server::Request,
109    response: tokio_tungstenite::tungstenite::handshake::server::Response,
110) -> Result<
111    tokio_tungstenite::tungstenite::handshake::server::Response,
112    tokio_tungstenite::tungstenite::handshake::server::ErrorResponse,
113> {
114    if request.headers().contains_key("origin") {
115        let mut rejection = tokio_tungstenite::tungstenite::handshake::server::ErrorResponse::new(
116            Some("browser origins are not allowed".to_string()),
117        );
118        *rejection.status_mut() = tokio_tungstenite::tungstenite::http::StatusCode::FORBIDDEN;
119        return Err(rejection);
120    }
121    Ok(response)
122}
123
124async fn handle_connection(
125    stream: TcpStream,
126    source: Arc<Mutex<RunSource>>,
127    mut updates_rx: broadcast::Receiver<Update>,
128) -> Result<()> {
129    // Browsers always send an Origin header; native clients do not. The
130    // protocol is unauthenticated, so a web page must never be able to read
131    // SQLite workflow state by opening a WebSocket to localhost — reject any
132    // browser-originated handshake outright.
133    let ws = tokio_tungstenite::accept_hdr_async(stream, reject_browser_origins).await?;
134    let (mut sink, mut reads) = ws.split();
135    send(
136        &mut sink,
137        &ServerMessage::Hello {
138            protocol: PROTOCOL_ID.to_string(),
139        },
140    )
141    .await?;
142
143    let mut watching_runs = false;
144    // Last revision sent per watched run; a broadcast patch is forwarded only
145    // when it is exactly the next revision, otherwise the client gets a fresh
146    // snapshot (covers the subscribe/broadcast race).
147    let mut watched: HashMap<String, u64> = HashMap::new();
148
149    loop {
150        tokio::select! {
151            incoming = reads.next() => {
152                let Some(incoming) = incoming else { break };
153                let message = match incoming {
154                    Ok(Message::Text(text)) => text,
155                    Ok(Message::Close(_)) => break,
156                    Ok(_) => continue,
157                    Err(_) => break,
158                };
159                let Ok(request) = serde_json::from_str::<ClientMessage>(&message) else {
160                    // Unknown message types must be ignored.
161                    continue;
162                };
163                match request {
164                    ClientMessage::WatchRuns => {
165                        watching_runs = true;
166                        let runs = source.lock().await.summaries();
167                        send(&mut sink, &ServerMessage::Runs { runs }).await?;
168                    }
169                    ClientMessage::WatchRun { run_id } => {
170                        // Snapshot under the lock, send after releasing it: a
171                        // slow client must not stall the refresh loop.
172                        let snapshot = {
173                            let source = source.lock().await;
174                            source.get(&run_id).map(|entry| (entry.revision, entry.view()))
175                        };
176                        match snapshot {
177                            Some((revision, view)) => {
178                                watched.insert(run_id.clone(), revision);
179                                send(&mut sink, &ServerMessage::RunSnapshot {
180                                    run_id,
181                                    revision,
182                                    view,
183                                }).await?;
184                            }
185                            None => {
186                                send(&mut sink, &ServerMessage::Error {
187                                    message: format!("unknown run {run_id}"),
188                                    run_id: Some(run_id),
189                                }).await?;
190                            }
191                        }
192                    }
193                    ClientMessage::UnwatchRun { run_id } => {
194                        watched.remove(&run_id);
195                    }
196                    ClientMessage::FetchArtifact { run_id, path } => {
197                        send(&mut sink, &ServerMessage::Error {
198                            message: format!("artifact {path} not available; values are stored in SQLite"),
199                            run_id: Some(run_id),
200                        }).await?;
201                    }
202                }
203            }
204            update = updates_rx.recv() => {
205                match update {
206                    Ok(Update::Runs(runs)) => {
207                        if watching_runs {
208                            send(&mut sink, &ServerMessage::Runs { runs }).await?;
209                        }
210                    }
211                    Ok(Update::Patch { run_id, revision, patch }) => {
212                        let Some(&last) = watched.get(&run_id) else { continue };
213                        if revision == last + 1 {
214                            watched.insert(run_id.clone(), revision);
215                            send(&mut sink, &ServerMessage::RunPatch { run_id, revision, patch }).await?;
216                        } else if revision > last {
217                            // Missed one (lagged broadcast): resnapshot.
218                            let snapshot = {
219                                let source = source.lock().await;
220                                source.get(&run_id).map(|entry| (entry.revision, entry.view()))
221                            };
222                            if let Some((revision, view)) = snapshot {
223                                watched.insert(run_id.clone(), revision);
224                                send(&mut sink, &ServerMessage::RunSnapshot { run_id, revision, view }).await?;
225                            }
226                        }
227                    }
228                    Err(broadcast::error::RecvError::Lagged(_)) => {
229                        // Dropped updates: resnapshot everything we watch.
230                        let run_ids: Vec<String> = watched.keys().cloned().collect();
231                        for run_id in run_ids {
232                            let snapshot = {
233                                let source = source.lock().await;
234                                source.get(&run_id).map(|entry| (entry.revision, entry.view()))
235                            };
236                            if let Some((revision, view)) = snapshot {
237                                watched.insert(run_id.clone(), revision);
238                                send(&mut sink, &ServerMessage::RunSnapshot { run_id, revision, view }).await?;
239                            }
240                        }
241                    }
242                    Err(broadcast::error::RecvError::Closed) => break,
243                }
244            }
245        }
246    }
247    Ok(())
248}