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