Skip to main content

majordomus_cli/http/
events.rs

1//! The live channel: one WebSocket over which this process reports what its executions are
2//! doing.
3//!
4//! It is an adapter and holds no execution logic. It subscribes to
5//! [`crate::execution::ExecutionStore`], renders what arrives, and closes; every decision
6//! about what an execution is, what may happen to it and what an event means was already
7//! taken in [`crate::execution`]. That is what makes an audit trail, a notifier or a
8//! second protocol something that subscribes here rather than something that has to be
9//! threaded through the engine.
10//!
11//! # The protocol
12//!
13//! ```text
14//! GET /events                                  every execution of this process, live
15//! GET /events?execution=<id>                   one execution, its whole retained history, then live
16//! GET /events?execution=<id>&after=<sequence>  the same, from a cursor: what a reconnect asks for
17//! GET /events?execution=<a>&execution=<b>      several executions, each its history, then live
18//! ```
19//!
20//! The subscription is the request target, so there is nothing to send and nothing to
21//! acknowledge: a reconnect is a new URL with the sequence the client last saw, and the
22//! server replays the gap before it goes live.
23//!
24//! Sequences are dense per execution, so a connection that follows several executions keeps
25//! a cursor for each. The one `after` starts every one of those cursors: each execution
26//! named is replayed from the events after it, up to [`MAX_REPLAY`] each. A client that
27//! reconnects to several executions sends the lowest sequence it saw among them and drops,
28//! per execution, what it already holds; with one execution that is exactly the sequence it
29//! last saw, as it always was. What the server writes is one JSON document
30//! per text frame, each with a `type` and a `data`: either a
31//! [`crate::execution::ExecutionEvent`], or one of the [`StreamMessage`] control messages
32//! below. A client that does not recognise a `type` must ignore that frame; that is what
33//! lets a variant be added without a version change.
34//!
35//! The server pings on a heartbeat and never reads a frame. A client that goes away is
36//! noticed within [`LOOK`] where the server can ask the connection's socket (the read
37//! deadline's sweeper finds it, `super::deadline`), and otherwise when the next write
38//! fails, which the heartbeat guarantees happens; a client that sends anything is ignored,
39//! and the reason is in [`super::ws`]. Beside the ping, which is a
40//! control frame a browser never shows to a page, the same quiet interval writes a
41//! `stream.heartbeat` text frame, so a client can tell a quiet channel from a dead one.
42//!
43//! A replay is paged until it is caught up: a cursor further behind than one page
44//! ([`MAX_REPLAY`]) receives every retained event after it, not the first page and then
45//! the live stream with the rest missing. An event whose frame would exceed
46//! [`ws::MAX_FRAME_BYTES`] is written with its unbounded value — a completed execution's
47//! `output`, a created one's `input` — replaced by a [`Withheld`] reference naming where the
48//! whole value is read, rather than refused and taken for a client that went away.
49
50use std::collections::HashMap;
51use std::sync::atomic::{AtomicUsize, Ordering};
52use std::sync::Arc;
53use std::time::Duration;
54
55use schemars::JsonSchema;
56use serde::{Deserialize, Serialize};
57
58use crate::execution::{
59    Delivery, EventPayload, ExecutionEvent, ExecutionId, ExecutionStore, Filter, PROTOCOL_VERSION,
60};
61
62use super::router::{Request, Response};
63use super::ws;
64
65/// Where the live channel is mounted. Not under the capability prefix: it is not a
66/// capability, it declares no input or output schema, and it answers no JSON over HTTP.
67pub const PATH: &str = "/events";
68
69/// How often the server pings a quiet connection.
70pub const HEARTBEAT: Duration = Duration::from_secs(20);
71
72/// How many live channels one process serves at once. Beyond it, a connection is refused
73/// with a reason rather than accepted and starved.
74pub const MAX_CONNECTIONS: usize = 64;
75
76/// How many retained events one page of a replay reads, per execution a scoped subscription
77/// follows. The replay reads pages until it is caught up, so this bounds one read of the
78/// store, not the replay.
79pub const MAX_REPLAY: usize = 1_000;
80
81/// A message about the stream itself, rather than about an execution.
82///
83/// Same envelope as an event — a `type` and a `data` — so a client has one reader.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
85#[serde(tag = "type", content = "data")]
86pub enum StreamMessage {
87    /// The first frame of every connection: what was subscribed to, what was replayed, and
88    /// what the client should expect.
89    #[serde(rename = "stream.ready")]
90    Ready {
91        /// The event protocol this server speaks, [`PROTOCOL_VERSION`].
92        protocol_version: String,
93        /// What this connection follows.
94        scope: Scope,
95        /// The executions followed, for a scoped connection.
96        #[serde(skip_serializing_if = "Vec::is_empty")]
97        executions: Vec<String>,
98        /// How many retained events were written before this connection went live, over
99        /// every execution it follows.
100        replayed: usize,
101        /// Whether the history before the replay had already been dropped by the store's
102        /// bound, for any execution followed, so a client knows its picture starts in the
103        /// middle.
104        truncated: bool,
105        /// How many seconds of quiet before the server pings.
106        heartbeat_seconds: u64,
107    },
108    /// The channel is quiet and alive: written with the ping on every quiet heartbeat, as
109    /// a text frame a page can see, so a client that has heard nothing for a few heartbeats
110    /// knows the connection is dead and reconnects from its cursor.
111    #[serde(rename = "stream.heartbeat")]
112    Heartbeat {
113        /// When it was written, RFC 3339 in UTC.
114        at: String,
115    },
116    /// The client fell behind and events were dropped rather than buffered. It must read
117    /// the snapshot and the history again, from the last sequence it saw.
118    #[serde(rename = "stream.lagged")]
119    Lagged {
120        /// How many events were dropped.
121        dropped: u64,
122    },
123    /// The server is closing this connection.
124    #[serde(rename = "stream.closing")]
125    Closing {
126        /// Why.
127        reason: String,
128    },
129}
130
131/// What a connection follows.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
133#[serde(rename_all = "snake_case")]
134pub enum Scope {
135    /// Every execution of this process.
136    All,
137    /// The executions named in the request target.
138    Executions,
139}
140
141/// What an event carries in place of a value too large for one frame: why it was withheld,
142/// how large it is, and where the whole value is read.
143///
144/// The event is still the same typed event, with its sequence, so nothing about the
145/// execution's story is lost — only the value is read elsewhere, from the snapshot.
146///
147/// ```
148/// use majordomus_cli::http::events::Withheld;
149/// let w = Withheld {
150///     withheld: "too_large".into(),
151///     bytes: 2_000_000,
152///     limit: 1_048_576,
153///     itself: "/api/v1/executions/get?id=e1".into(),
154/// };
155/// let json = serde_json::to_value(&w).unwrap();
156/// assert_eq!(json["self"], "/api/v1/executions/get?id=e1", "the reference is `self` on the wire");
157/// assert!(json.get("itself").is_none());
158/// assert_eq!(serde_json::from_value::<Withheld>(json).unwrap(), w);
159/// ```
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
161pub struct Withheld {
162    /// Why: always `too_large`.
163    pub withheld: String,
164    /// The size of the value as JSON, in bytes.
165    pub bytes: usize,
166    /// The largest frame this server writes, [`ws::MAX_FRAME_BYTES`].
167    pub limit: usize,
168    /// The snapshot that carries the whole value: `GET /api/v1/executions/get?id=…`.
169    #[serde(rename = "self")]
170    pub itself: String,
171}
172
173/// How many live channels this process has open.
174static OPEN: AtomicUsize = AtomicUsize::new(0);
175
176/// How many live channels are open right now, for the health and perf projections.
177pub fn open_connections() -> usize {
178    OPEN.load(Ordering::Relaxed)
179}
180
181/// An accepted connection, before the socket is handed over.
182pub struct Accepted {
183    /// The value to answer `Sec-WebSocket-Accept` with.
184    pub accept: String,
185    stream: LiveStream,
186}
187
188impl Accepted {
189    /// Serve the connection on an upgraded socket until it ends. Blocking; the caller runs
190    /// it on a thread of its own.
191    pub fn serve(self, socket: Box<dyn std::io::Write + Send>) {
192        self.stream.run(socket, || false);
193    }
194
195    /// [`Accepted::serve`], also ending as soon as `hung_up` says the client has gone: asked
196    /// every [`LOOK`] while the channel is quiet, so a closed channel stops counting as use
197    /// of the server within a second, not at its next write (I2131).
198    ///
199    /// A client that has already gone ends the channel at the first look, long before the
200    /// heartbeat would have found out by writing:
201    ///
202    /// ```
203    /// use std::sync::Arc;
204    /// use std::time::{Duration, Instant};
205    /// use majordomus_cli::execution::{ExecutionStore, Limits};
206    /// use majordomus_cli::http::events::{accept, PATH};
207    /// use majordomus_cli::http::Request;
208    ///
209    /// let store = Arc::new(ExecutionStore::new(Limits::default()));
210    /// let req = Request::parse_target("GET", PATH, vec![]).with_headers(vec![
211    ///     ("Upgrade".into(), "websocket".into()),
212    ///     ("Connection".into(), "Upgrade".into()),
213    ///     ("Sec-WebSocket-Key".into(), "dGhlIHNhbXBsZSBub25jZQ==".into()),
214    ///     ("Sec-WebSocket-Version".into(), "13".into()),
215    /// ]);
216    /// let accepted = accept(&store, &req, "/api/v1/executions/get").expect("an upgrade");
217    /// let started = Instant::now();
218    /// accepted.serve_until(Box::new(Vec::new()), || true);
219    /// assert!(started.elapsed() < Duration::from_secs(5), "a gone client ends the channel");
220    /// ```
221    pub fn serve_until(self, socket: Box<dyn std::io::Write + Send>, hung_up: impl Fn() -> bool) {
222        self.stream.run(socket, hung_up);
223    }
224}
225
226/// How often a quiet channel asks whether its client has hung up: the idle timer's own tick,
227/// so a channel that closes is no longer counted as use by the next time the timer looks.
228pub const LOOK: Duration = Duration::from_secs(1);
229
230struct LiveStream {
231    store: Arc<ExecutionStore>,
232    filter: Filter,
233    after: u64,
234    /// How long the channel stays quiet before it pings and writes a heartbeat.
235    heartbeat: Duration,
236    /// The snapshot route a [`Withheld`] reference names, from the registry.
237    snapshot: String,
238}
239
240/// Decide what a request to [`PATH`] means: an accepted connection, or the response to
241/// send instead.
242///
243/// The origin is checked here for the same reason it is checked for a state-changing
244/// request: a page on another origin can open a WebSocket to this server, and the
245/// same-origin policy does not stop it. This does.
246///
247/// `snapshot` is the route of `executions.get`, as the registry declares it: what a
248/// [`Withheld`] reference names as the place the whole value is read.
249pub fn accept(
250    store: &Arc<ExecutionStore>,
251    req: &Request,
252    snapshot: &str,
253) -> Result<Accepted, Response> {
254    if !ws::is_upgrade(req) {
255        return Err(Response::error(
256            426,
257            "upgrade_required",
258            &format!(
259                "{PATH} is a WebSocket. Open it with a WebSocket client; the same events are readable over HTTP at {}executions/events",
260                crate::capability::model::HttpExposure::PREFIX
261            ),
262        ));
263    }
264    if let Some(origin) = req.header("origin") {
265        let host = req.header("host").unwrap_or_default();
266        let ours = [format!("http://{host}"), format!("https://{host}")];
267        if !ours.iter().any(|a| a == origin) {
268            tracing::warn!(
269                origin = origin,
270                "a live channel from another origin was refused"
271            );
272            return Err(Response::error(
273                403,
274                "forbidden",
275                &format!("a WebSocket from origin '{origin}' is refused: this server accepts one from its own origin only"),
276            ));
277        }
278    }
279    let accept = match ws::handshake(req) {
280        ws::Handshake::Accept(value) => value,
281        ws::Handshake::Refuse { status, reason } => {
282            return Err(Response::error(status, "invalid_input", &reason))
283        }
284    };
285    if OPEN.load(Ordering::Relaxed) >= MAX_CONNECTIONS {
286        return Err(Response::error(
287            503,
288            "unavailable",
289            &format!("this process serves {MAX_CONNECTIONS} live channels at once and they are all in use"),
290        ));
291    }
292    let mut executions = Vec::new();
293    let mut after = 0u64;
294    for (key, value) in &req.query {
295        match key.as_str() {
296            "execution" => match ExecutionId::parse(value) {
297                // named twice is followed once, and replayed once
298                Some(id) if executions.contains(&id) => {}
299                Some(id) => executions.push(id),
300                None => {
301                    return Err(Response::error(
302                        400,
303                        "invalid_input",
304                        &format!("'{value}' is not an execution id"),
305                    ))
306                }
307            },
308            "after" => match value.parse::<u64>() {
309                Ok(n) => after = n,
310                Err(_) => {
311                    return Err(Response::error(
312                        400,
313                        "invalid_input",
314                        &format!("after '{value}' is not a sequence number"),
315                    ))
316                }
317            },
318            other => {
319                return Err(Response::error(
320                    400,
321                    "invalid_input",
322                    &format!("unknown parameter '{other}'; {PATH} takes 'execution' and 'after'"),
323                ))
324            }
325        }
326    }
327    let filter = if executions.is_empty() {
328        Filter::All
329    } else {
330        Filter::Only(executions)
331    };
332    Ok(Accepted {
333        accept,
334        stream: LiveStream {
335            store: Arc::clone(store),
336            filter,
337            after,
338            heartbeat: HEARTBEAT,
339            snapshot: snapshot.to_string(),
340        },
341    })
342}
343
344impl LiveStream {
345    fn run(self, mut socket: Box<dyn std::io::Write + Send>, hung_up: impl Fn() -> bool) {
346        let open = OPEN.fetch_add(1, Ordering::Relaxed) + 1;
347        tracing::debug!(connections = open, "a live channel opened");
348        let (subscriber, rx) = self.store.subscribe(self.filter.clone());
349        // subscribe first, replay second, and skip in the live stream anything the replay
350        // already wrote: that is what leaves no gap between the history and the stream and
351        // no event written twice. Sequences are dense per execution, so the mark of what was
352        // written is one cursor per execution followed, each starting at `after`: a single
353        // mark across executions would take one execution's events for ones already written
354        // as soon as another had gone further.
355        let mut written: HashMap<ExecutionId, u64> = HashMap::new();
356        let mut replayed = Vec::new();
357        let mut truncated = false;
358        match &self.filter {
359            Filter::Only(ids) => {
360                // each cursor is set as its replay is collected rather than as it is written:
361                // a socket that fails mid-replay ends the connection before the live stream
362                // reads a cursor, so the two only differ on a connection that is already gone
363                for id in ids {
364                    let (events, cut) = self.replay(id);
365                    truncated |= cut;
366                    let cursor = events.iter().map(|e| e.sequence).fold(self.after, u64::max);
367                    replayed.extend(events);
368                    written.insert(id.clone(), cursor);
369                }
370            }
371            // an all-executions connection has no cursor to replay from; a client that needs
372            // history asks for it per execution over HTTP
373            Filter::All => {}
374        }
375        let ready = StreamMessage::Ready {
376            protocol_version: PROTOCOL_VERSION.to_string(),
377            scope: match self.filter {
378                Filter::All => Scope::All,
379                Filter::Only(_) => Scope::Executions,
380            },
381            executions: match &self.filter {
382                Filter::All => Vec::new(),
383                Filter::Only(ids) => ids.iter().map(|i| i.to_string()).collect(),
384            },
385            replayed: replayed.len(),
386            truncated,
387            heartbeat_seconds: HEARTBEAT.as_secs(),
388        };
389        let mut alive = write(&mut socket, &ready);
390        for event in replayed {
391            if !alive {
392                break;
393            }
394            alive = self.write_event(&mut socket, &event);
395        }
396        // quiet since: the heartbeat is written after this long without a frame, and the
397        // wait for the next delivery is cut into looks at whether the client has hung up
398        let mut quiet = std::time::Instant::now();
399        while alive {
400            let until_heartbeat = self.heartbeat.saturating_sub(quiet.elapsed());
401            match rx.recv_timeout(until_heartbeat.min(LOOK)) {
402                Ok(Delivery::Event(event)) => {
403                    // only a scoped connection has cursors; an unscoped one replayed nothing
404                    if let Some(cursor) = written.get_mut(&event.execution_id) {
405                        if event.sequence <= *cursor {
406                            continue; // the replay already wrote it
407                        }
408                        *cursor = event.sequence;
409                    }
410                    alive = self.write_event(&mut socket, &event);
411                    quiet = std::time::Instant::now();
412                }
413                Ok(Delivery::Lagged { dropped }) => {
414                    alive = write(&mut socket, &StreamMessage::Lagged { dropped });
415                    quiet = std::time::Instant::now();
416                }
417                Err(std::sync::mpsc::RecvTimeoutError::Timeout) if hung_up() => {
418                    tracing::debug!("the client of a live channel hung up");
419                    break;
420                }
421                Err(std::sync::mpsc::RecvTimeoutError::Timeout)
422                    if quiet.elapsed() < self.heartbeat => {}
423                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
424                    // the ping keeps intermediaries and the protocol honest; the heartbeat
425                    // is the frame a page can see, so a dead channel is told from a quiet one
426                    alive = ws::write_ping(&mut socket).is_ok()
427                        && write(
428                            &mut socket,
429                            &StreamMessage::Heartbeat {
430                                at: crate::peers::rfc3339(std::time::SystemTime::now()),
431                            },
432                        );
433                    quiet = std::time::Instant::now();
434                }
435                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
436                    let _ = write(
437                        &mut socket,
438                        &StreamMessage::Closing {
439                            reason: "this server is shutting down".into(),
440                        },
441                    );
442                    let _ = ws::write_close(&mut socket, 1001, "going away");
443                    break;
444                }
445            }
446        }
447        self.store.unsubscribe(subscriber);
448        let open = OPEN.fetch_sub(1, Ordering::Relaxed) - 1;
449        tracing::debug!(connections = open, "a live channel closed");
450    }
451
452    /// Every retained event of one execution after the cursor, page by page until caught
453    /// up, and whether the history before the first had already been dropped.
454    ///
455    /// One page is [`MAX_REPLAY`]; the whole replay is bounded by the store's own
456    /// retention, so the loop ends. Taking only the first page would leave the events
457    /// between it and the moment of subscription unwritten, with `stream.ready` claiming
458    /// nothing was missing.
459    fn replay(&self, id: &ExecutionId) -> (Vec<Arc<ExecutionEvent>>, bool) {
460        let Some(first) = self.store.events(id, self.after, MAX_REPLAY) else {
461            return (Vec::new(), false);
462        };
463        let truncated = first.truncated;
464        let mut more = first.more;
465        let mut cursor = first.last_sequence;
466        let mut events = first.events;
467        while more {
468            let Some(page) = self.store.events(id, cursor, MAX_REPLAY) else {
469                break;
470            };
471            if page.events.is_empty() {
472                break;
473            }
474            more = page.more;
475            cursor = page.last_sequence;
476            events.extend(page.events);
477        }
478        (events, truncated)
479    }
480
481    /// Write one execution event; one too large for a frame is written with its unbounded
482    /// value replaced by a [`Withheld`] reference, so the channel stays open.
483    fn write_event(
484        &self,
485        socket: &mut Box<dyn std::io::Write + Send>,
486        event: &ExecutionEvent,
487    ) -> bool {
488        let Ok(text) = serde_json::to_string(event) else {
489            tracing::error!("an execution event could not be rendered as JSON");
490            return true;
491        };
492        if text.len() <= ws::MAX_FRAME_BYTES {
493            return write_frame(socket, &text);
494        }
495        match self.withhold(event) {
496            Some(lighter) => write(socket, &lighter),
497            None => {
498                // no payload but the two unbounded values can reach the bound; one that
499                // does is skipped rather than taken for a client that went away, and the
500                // client's gap check reads it from the history over HTTP
501                tracing::warn!(
502                    event = %event.event_id,
503                    bytes = text.len(),
504                    "an execution event too large for one frame was not written"
505                );
506                true
507            }
508        }
509    }
510
511    /// The event with its unbounded value replaced by a reference, when it has one.
512    fn withhold(&self, event: &ExecutionEvent) -> Option<ExecutionEvent> {
513        let reference = |value: &serde_json::Value| {
514            let bytes = serde_json::to_string(value).map_or(0, |t| t.len());
515            serde_json::to_value(Withheld {
516                withheld: "too_large".into(),
517                bytes,
518                limit: ws::MAX_FRAME_BYTES,
519                itself: format!(
520                    "{}?id={}",
521                    self.snapshot,
522                    super::router::percent_encode(event.execution_id.as_str())
523                ),
524            })
525            .unwrap_or_default()
526        };
527        let payload = match &event.payload {
528            EventPayload::Completed { output } => EventPayload::Completed {
529                output: reference(output),
530            },
531            EventPayload::Created {
532                capability,
533                title,
534                input,
535            } => EventPayload::Created {
536                capability: capability.clone(),
537                title: title.clone(),
538                input: reference(input),
539            },
540            _ => return None,
541        };
542        Some(ExecutionEvent {
543            payload,
544            ..event.clone()
545        })
546    }
547}
548
549/// Write one value as a text frame; `false` when the client is gone.
550fn write<T: Serialize>(socket: &mut Box<dyn std::io::Write + Send>, value: &T) -> bool {
551    let Ok(text) = serde_json::to_string(value) else {
552        tracing::error!("an execution event could not be rendered as JSON");
553        return true;
554    };
555    write_frame(socket, &text)
556}
557
558/// Write one rendered document as a text frame; `false` when the client is gone.
559fn write_frame(socket: &mut Box<dyn std::io::Write + Send>, text: &str) -> bool {
560    match ws::write_text(socket.as_mut(), text) {
561        Ok(()) => true,
562        Err(e) => {
563            tracing::debug!("a live channel went away: {e}");
564            false
565        }
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::execution::{
573        Actor, ActorKind, EventPayload, ExecutionState, Limits, ProgressView, RepositoryRef,
574    };
575
576    /// The snapshot route the registry declares for `executions.get`, as the router
577    /// passes it.
578    const SNAPSHOT: &str = "/api/v1/executions/get";
579
580    /// The tests' accept: the router's, with the snapshot route it passes.
581    fn accept(store: &Arc<ExecutionStore>, req: &Request) -> Result<Accepted, Response> {
582        super::accept(store, req, SNAPSHOT)
583    }
584
585    fn upgrade_request(target: &str) -> Request {
586        Request::parse_target("GET", target, vec![]).with_headers(vec![
587            ("Upgrade".into(), "websocket".into()),
588            ("Connection".into(), "Upgrade".into()),
589            (
590                "Sec-WebSocket-Key".into(),
591                "dGhlIHNhbXBsZSBub25jZQ==".into(),
592            ),
593            ("Sec-WebSocket-Version".into(), "13".into()),
594        ])
595    }
596
597    /// Create an execution in a store: its `execution.created` is sequence 1.
598    fn create(store: &ExecutionStore, id: &ExecutionId) {
599        store.create(
600            id.clone(),
601            "demo.x",
602            "Demo",
603            serde_json::json!({}),
604            true,
605            Actor::of(ActorKind::Internal),
606            RepositoryRef {
607                name: "r".into(),
608                id: "i".into(),
609                branch: None,
610            },
611        );
612    }
613
614    fn store_with_one() -> (Arc<ExecutionStore>, ExecutionId) {
615        let store = Arc::new(ExecutionStore::new(Limits::default()));
616        let id = ExecutionId::fresh();
617        create(&store, &id);
618        store.publish(&id, EventPayload::Started);
619        store.publish(
620            &id,
621            EventPayload::Progress(ProgressView {
622                current: 1,
623                total: Some(1),
624                message: None,
625            }),
626        );
627        store.publish(
628            &id,
629            EventPayload::Completed {
630                output: serde_json::json!({}),
631            },
632        );
633        (store, id)
634    }
635
636    #[test]
637    fn a_plain_get_is_told_what_this_path_is_and_where_the_same_events_are() {
638        let (store, _) = store_with_one();
639        let response = accept(&store, &Request::parse_target("GET", PATH, vec![]))
640            .err()
641            .expect("a plain GET is not a connection");
642        assert_eq!(response.status, 426);
643        assert!(response.body.text().contains("executions/events"));
644    }
645
646    #[test]
647    fn a_foreign_origin_is_refused_before_the_handshake() {
648        let (store, _) = store_with_one();
649        let req = upgrade_request(PATH).with_headers({
650            let mut h = upgrade_request(PATH).headers;
651            h.push(("Host".into(), "127.0.0.1:8741".into()));
652            h.push(("Origin".into(), "https://evil.example".into()));
653            h
654        });
655        let response = accept(&store, &req).err().expect("refused");
656        assert_eq!(response.status, 403);
657        let ours = upgrade_request(PATH).with_headers({
658            let mut h = upgrade_request(PATH).headers;
659            h.push(("Host".into(), "127.0.0.1:8741".into()));
660            h.push(("Origin".into(), "http://127.0.0.1:8741".into()));
661            h
662        });
663        assert!(accept(&store, &ours).is_ok(), "our own page is not foreign");
664    }
665
666    #[test]
667    fn the_target_is_the_whole_subscription_and_is_validated() {
668        let (store, id) = store_with_one();
669        assert!(
670            accept(&store, &upgrade_request(PATH)).is_ok(),
671            "every execution"
672        );
673        assert!(accept(
674            &store,
675            &upgrade_request(&format!("{PATH}?execution={id}&after=2"))
676        )
677        .is_ok());
678        for bad in [
679            format!("{PATH}?execution=../../etc/passwd"),
680            format!("{PATH}?execution={id}&after=soon"),
681            format!("{PATH}?follow=everything"),
682        ] {
683            let response = accept(&store, &upgrade_request(&bad)).err().expect(&bad);
684            assert_eq!(response.status, 400, "{bad}");
685        }
686    }
687
688    /// The whole loop over a socket that is a `Vec`: the ready frame, the replay from a
689    /// cursor, and the end when the client goes away. It proves the ordering contract —
690    /// history first, live second, nothing written twice — without a network, and it proves
691    /// the only way a connection ends in production: the next write fails.
692    #[test]
693    fn a_connection_replays_from_its_cursor_and_ends_when_the_client_goes() {
694        let (store, id) = store_with_one();
695        let accepted = accept(
696            &store,
697            &upgrade_request(&format!("{PATH}?execution={id}&after=2")),
698        )
699        .expect("accepted");
700        assert_eq!(accepted.accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
701        // the client reads three frames and then goes away, which is what a closed tab is
702        let sink = SharedSink::new(3);
703        let writer = Box::new(sink.clone());
704        let handle = std::thread::spawn(move || accepted.serve(writer));
705        // one more event, so the connection finds the client gone at its next write rather
706        // than at its next heartbeat: the same code path, without the wait
707        nudge(&store, &id);
708        handle
709            .join()
710            .expect("the connection thread ended by itself");
711
712        let frames = sink.frames();
713        let types: Vec<String> = frames
714            .iter()
715            .filter_map(|f| serde_json::from_str::<serde_json::Value>(f).ok())
716            .map(|v| v["type"].as_str().unwrap_or_default().to_string())
717            .collect();
718        assert_eq!(
719            types,
720            ["stream.ready", "execution.progress", "execution.completed"],
721            "the first frame says what was subscribed to, then the cursor is honoured"
722        );
723        let ready: serde_json::Value = serde_json::from_str(&frames[0]).unwrap();
724        assert_eq!(ready["data"]["protocol_version"], PROTOCOL_VERSION);
725        assert_eq!(ready["data"]["scope"], "executions");
726        assert_eq!(
727            ready["data"]["replayed"], 2,
728            "sequences 3 and 4, not 1 and 2"
729        );
730        assert_eq!(ready["data"]["truncated"], false);
731        assert_eq!(ready["data"]["heartbeat_seconds"], HEARTBEAT.as_secs());
732        assert_eq!(store.subscribers(), 0, "its subscription was ended");
733        // the events describe the state the snapshot holds
734        assert_eq!(
735            store.get(&id).map(|e| e.state),
736            Some(ExecutionState::Succeeded)
737        );
738    }
739
740    /// A connection that follows every execution replays nothing: there is no single
741    /// cursor across executions, and a client that needs history asks per execution.
742    #[test]
743    fn an_unscoped_connection_replays_nothing_and_says_so() {
744        let (store, _) = store_with_one();
745        let accepted = accept(&store, &upgrade_request(PATH)).expect("accepted");
746        let sink = SharedSink::new(1);
747        let writer = Box::new(sink.clone());
748        let handle = std::thread::spawn(move || accepted.serve(writer));
749        let id = store.list(None, None, 1)[0].id.clone();
750        nudge(&store, &id);
751        handle.join().expect("ended");
752        let ready: serde_json::Value = serde_json::from_str(&sink.frames()[0]).unwrap();
753        assert_eq!(ready["type"], "stream.ready");
754        assert_eq!(ready["data"]["scope"], "all");
755        assert_eq!(ready["data"]["replayed"], 0);
756        assert!(ready["data"]["executions"].is_null(), "it names none");
757    }
758
759    /// Sequences are dense per execution, so a connection that follows two of them holds a
760    /// cursor for each. With one high-water mark across both, the second execution's live
761    /// events were taken for ones already written as soon as the first had gone further:
762    /// here B's sequences 1 and 2 are at or below A's 3.
763    #[test]
764    fn a_connection_to_two_executions_delivers_every_event_of_each() {
765        let store = Arc::new(ExecutionStore::new(Limits::default()));
766        let (a, b) = (ExecutionId::fresh(), ExecutionId::fresh());
767        let accepted = accept(
768            &store,
769            &upgrade_request(&format!("{PATH}?execution={a}&execution={b}")),
770        )
771        .expect("accepted");
772        // the ready frame, A's three events and B's two; the next write finds the client gone
773        let sink = SharedSink::new(6);
774        let writer = Box::new(sink.clone());
775        let handle = std::thread::spawn(move || accepted.serve(writer));
776        wait_subscribed(&store);
777        create(&store, &a);
778        store.publish(&a, EventPayload::Started);
779        store.publish(
780            &a,
781            EventPayload::Progress(ProgressView {
782                current: 1,
783                total: Some(2),
784                message: None,
785            }),
786        );
787        create(&store, &b);
788        store.publish(&b, EventPayload::Started);
789        until_gone(&store, &a, &handle);
790        handle.join().expect("ended");
791
792        let written = sequences(&sink.frames());
793        assert_eq!(
794            of(&written, &a)[..3],
795            [1, 2, 3],
796            "A's events, in order: {written:?}"
797        );
798        assert_eq!(
799            of(&written, &b),
800            [1, 2],
801            "every event of B arrives although A has gone further: {written:?}"
802        );
803    }
804
805    /// A connection that follows several executions replays each one's retained history
806    /// from the cursor, and its ready frame counts the whole replay. An execution named
807    /// twice is followed, and replayed, once.
808    #[test]
809    fn a_connection_to_two_executions_replays_each_from_the_cursor() {
810        let (store, a) = store_with_one(); // sequences 1..4
811        let b = ExecutionId::fresh();
812        create(&store, &b);
813        store.publish(&b, EventPayload::Started); // sequences 1..2
814        let accepted = accept(
815            &store,
816            &upgrade_request(&format!(
817                "{PATH}?execution={a}&execution={b}&execution={a}&after=1"
818            )),
819        )
820        .expect("accepted");
821        // the ready frame, A's 2..4 and B's 2; the next write finds the client gone
822        let sink = SharedSink::new(5);
823        let writer = Box::new(sink.clone());
824        let handle = std::thread::spawn(move || accepted.serve(writer));
825        wait_subscribed(&store);
826        until_gone(&store, &a, &handle);
827        handle.join().expect("ended");
828
829        let frames = sink.frames();
830        let ready: serde_json::Value = serde_json::from_str(&frames[0]).unwrap();
831        assert_eq!(ready["type"], "stream.ready");
832        assert_eq!(
833            ready["data"]["executions"],
834            serde_json::json!([a.to_string(), b.to_string()]),
835            "each execution is named once"
836        );
837        assert_eq!(ready["data"]["replayed"], 4, "A's 2..4 and B's 2");
838        assert_eq!(ready["data"]["truncated"], false);
839        let written = sequences(&frames);
840        assert_eq!(of(&written, &a)[..3], [2, 3, 4], "{written:?}");
841        assert_eq!(of(&written, &b), [2], "{written:?}");
842    }
843
844    /// The execution id and sequence of every execution event among the frames, in order.
845    fn sequences(frames: &[String]) -> Vec<(String, u64)> {
846        frames
847            .iter()
848            .filter_map(|f| serde_json::from_str::<serde_json::Value>(f).ok())
849            .filter_map(|v| {
850                Some((
851                    v["execution_id"].as_str()?.to_string(),
852                    v["sequence"].as_u64()?,
853                ))
854            })
855            .collect()
856    }
857
858    /// The sequences written for one execution.
859    fn of(written: &[(String, u64)], id: &ExecutionId) -> Vec<u64> {
860        let id = id.to_string();
861        written
862            .iter()
863            .filter(|(e, _)| *e == id)
864            .map(|(_, s)| *s)
865            .collect()
866    }
867
868    /// Wait until the connection has subscribed, so what is published next reaches it live.
869    fn wait_subscribed(store: &Arc<ExecutionStore>) {
870        for _ in 0..200 {
871            if store.subscribers() > 0 {
872                return;
873            }
874            std::thread::sleep(Duration::from_millis(5));
875        }
876        panic!("the connection never subscribed");
877    }
878
879    /// Publish log lines on an execution until the connection finds its client gone, so a
880    /// test ends at a write rather than at a heartbeat, whatever was written before.
881    fn until_gone(
882        store: &Arc<ExecutionStore>,
883        id: &ExecutionId,
884        handle: &std::thread::JoinHandle<()>,
885    ) {
886        for _ in 0..500 {
887            if handle.is_finished() {
888                return;
889            }
890            store.publish(
891                id,
892                EventPayload::Log {
893                    stream: crate::execution::LogStream::Handler,
894                    message: "is anyone there".into(),
895                },
896            );
897            std::thread::sleep(Duration::from_millis(10));
898        }
899        panic!("the connection never noticed its client had gone");
900    }
901
902    /// A reconnect further behind than one replay page receives every retained event
903    /// after its cursor, densely, before it goes live: the first page alone would leave the
904    /// rest unwritten while `stream.ready` claimed nothing was missing.
905    #[test]
906    fn a_reconnect_further_behind_than_one_page_is_replayed_without_a_gap() {
907        let (store, id) = store_with_one();
908        for n in 0..(MAX_REPLAY + 300) {
909            store.publish(
910                &id,
911                EventPayload::Log {
912                    stream: crate::execution::LogStream::Handler,
913                    message: format!("line {n}"),
914                },
915            );
916        }
917        let last = store.get(&id).expect("retained").last_sequence;
918        let after = 2u64;
919        let behind = usize::try_from(last - after).unwrap();
920        assert!(
921            behind > MAX_REPLAY,
922            "the cursor is more than one page behind"
923        );
924        let mut accepted = accept(
925            &store,
926            &upgrade_request(&format!("{PATH}?execution={id}&after={after}")),
927        )
928        .expect("accepted");
929        // a short heartbeat, so a replay that stopped early ends in a failed assertion
930        // rather than in a wait: its unused writes are spent on heartbeats at once
931        accepted.stream.heartbeat = Duration::from_millis(1);
932        // the ready frame and every replayed event; the nudge is the first write refused
933        let sink = SharedSink::new(1 + behind);
934        let writer = Box::new(sink.clone());
935        let handle = std::thread::spawn(move || accepted.serve(writer));
936        nudge(&store, &id);
937        handle.join().expect("ended");
938
939        let frames: Vec<serde_json::Value> = sink
940            .frames()
941            .iter()
942            .map(|f| serde_json::from_str(f).unwrap())
943            .collect();
944        assert_eq!(frames[0]["type"], "stream.ready");
945        assert_eq!(
946            frames[0]["data"]["replayed"], behind,
947            "the whole gap, not one page"
948        );
949        let sequences: Vec<u64> = frames[1..]
950            .iter()
951            .map(|f| f["sequence"].as_u64().expect("an event"))
952            .collect();
953        let expected: Vec<u64> = (after + 1..=last).collect();
954        assert_eq!(
955            sequences, expected,
956            "every retained sequence after the cursor, densely"
957        );
958    }
959
960    /// A quiet channel writes a heartbeat a page can see beside the ping it cannot, on the
961    /// same interval, so a client can tell a quiet channel from a dead one.
962    #[test]
963    fn a_quiet_channel_writes_a_heartbeat_a_page_can_see() {
964        let (store, id) = store_with_one();
965        let last = store.get(&id).expect("retained").last_sequence;
966        let mut accepted = accept(
967            &store,
968            &upgrade_request(&format!("{PATH}?execution={id}&after={last}")),
969        )
970        .expect("accepted");
971        accepted.stream.heartbeat = Duration::from_millis(20);
972        // the ready frame, one ping and one heartbeat; the next ping finds the client gone
973        let sink = SharedSink::new(3);
974        let writer = Box::new(sink.clone());
975        std::thread::spawn(move || accepted.serve(writer))
976            .join()
977            .expect("the connection ended by itself at the next quiet interval");
978        let frames: Vec<serde_json::Value> = sink
979            .frames()
980            .iter()
981            .map(|f| serde_json::from_str(f).unwrap())
982            .collect();
983        let types: Vec<&str> = frames
984            .iter()
985            .map(|f| f["type"].as_str().unwrap_or_default())
986            .collect();
987        assert_eq!(types, ["stream.ready", "stream.heartbeat"]);
988        assert!(
989            frames[1]["data"]["at"]
990                .as_str()
991                .is_some_and(|at| at.ends_with('Z')),
992            "{}",
993            frames[1]
994        );
995    }
996
997    /// A quiet channel whose client hangs up ends within a look, with nothing written after
998    /// its ready frame: before, it learned only when its next heartbeat failed, twenty
999    /// seconds on, and the idle timer counted it as an open channel all that time (I2131).
1000    #[test]
1001    fn a_quiet_channel_whose_client_hangs_up_ends_within_a_look_without_a_write() {
1002        let (store, id) = store_with_one();
1003        let last = store.get(&id).expect("retained").last_sequence;
1004        let accepted = accept(
1005            &store,
1006            &upgrade_request(&format!("{PATH}?execution={id}&after={last}")),
1007        )
1008        .expect("accepted");
1009        // a socket that never refuses a write: only the hang-up can end this channel
1010        let sink = SharedSink::new(usize::MAX);
1011        let writer = Box::new(sink.clone());
1012        let gone = Arc::new(std::sync::atomic::AtomicBool::new(false));
1013        let asked = Arc::clone(&gone);
1014        let handle = std::thread::spawn(move || {
1015            accepted.serve_until(writer, move || asked.load(Ordering::SeqCst))
1016        });
1017        std::thread::sleep(Duration::from_millis(300));
1018        assert!(
1019            !handle.is_finished(),
1020            "a client that is there keeps its channel"
1021        );
1022        gone.store(true, Ordering::SeqCst);
1023        let hung_up = std::time::Instant::now();
1024        handle.join().expect("the channel ended by itself");
1025        assert!(
1026            hung_up.elapsed() < LOOK + Duration::from_millis(500),
1027            "the hang-up was noticed after {:?}, not within a look",
1028            hung_up.elapsed()
1029        );
1030        assert!(
1031            LOOK < HEARTBEAT,
1032            "a look is sooner than the heartbeat it replaces"
1033        );
1034        let types: Vec<String> = sink
1035            .frames()
1036            .iter()
1037            .map(|f| serde_json::from_str::<serde_json::Value>(f).unwrap()["type"].to_string())
1038            .collect();
1039        assert_eq!(
1040            types,
1041            ["\"stream.ready\""],
1042            "nothing was written to a client that left"
1043        );
1044        assert_eq!(store.subscribers(), 0, "its subscription was ended");
1045    }
1046
1047    /// An output larger than one frame is written as a reference to the snapshot that
1048    /// carries it, and the channel stays open: before, the refused frame was taken for a
1049    /// client that went away, and every subscriber was dropped.
1050    #[test]
1051    fn an_output_too_large_for_a_frame_is_named_not_fatal() {
1052        let store = Arc::new(ExecutionStore::new(Limits::default()));
1053        let id = ExecutionId::fresh();
1054        store.create(
1055            id.clone(),
1056            "demo.x",
1057            "Demo",
1058            serde_json::json!({}),
1059            true,
1060            Actor::of(ActorKind::Internal),
1061            RepositoryRef {
1062                name: "r".into(),
1063                id: "i".into(),
1064                branch: None,
1065            },
1066        );
1067        store.publish(&id, EventPayload::Started);
1068        let big = "x".repeat(ws::MAX_FRAME_BYTES + 1024);
1069        store.publish(
1070            &id,
1071            EventPayload::Completed {
1072                output: serde_json::json!({ "text": big }),
1073            },
1074        );
1075        let mut accepted = accept(
1076            &store,
1077            &upgrade_request(&format!("{PATH}?execution={id}&after=2")),
1078        )
1079        .expect("accepted");
1080        accepted.stream.heartbeat = Duration::from_millis(20);
1081        // ready, the completed event, the nudge's log line; the next ping is refused
1082        let sink = SharedSink::new(3);
1083        let writer = Box::new(sink.clone());
1084        let handle = std::thread::spawn(move || accepted.serve(writer));
1085        nudge(&store, &id);
1086        handle.join().expect("ended");
1087
1088        let raw = sink.frames();
1089        assert!(raw.iter().all(|f| f.len() <= ws::MAX_FRAME_BYTES));
1090        let frames: Vec<serde_json::Value> = raw
1091            .iter()
1092            .map(|f| serde_json::from_str(f).unwrap())
1093            .collect();
1094        let types: Vec<&str> = frames
1095            .iter()
1096            .map(|f| f["type"].as_str().unwrap_or_default())
1097            .collect();
1098        assert_eq!(
1099            types,
1100            ["stream.ready", "execution.completed", "execution.log"],
1101            "the event is written, and the channel is still there for the next one"
1102        );
1103        let completed = &frames[1];
1104        assert_eq!(
1105            completed["sequence"], 3,
1106            "the same event, at its own sequence"
1107        );
1108        let output = &completed["data"]["output"];
1109        assert_eq!(output["withheld"], "too_large", "{output}");
1110        assert!(output["bytes"].as_u64().unwrap() > ws::MAX_FRAME_BYTES as u64);
1111        assert_eq!(output["limit"], ws::MAX_FRAME_BYTES);
1112        assert_eq!(output["self"], format!("{SNAPSHOT}?id={id}"));
1113        // the store itself keeps the whole value for the snapshot to serve
1114        assert_eq!(
1115            store.get(&id).and_then(|e| e.output).expect("an output")["text"]
1116                .as_str()
1117                .map(str::len),
1118            Some(ws::MAX_FRAME_BYTES + 1024)
1119        );
1120    }
1121
1122    /// One more event on an execution, so a connection whose client has gone finds out at
1123    /// its next write instead of at its next heartbeat. A log line changes no state.
1124    fn nudge(store: &Arc<ExecutionStore>, id: &crate::execution::ExecutionId) {
1125        // the subscriber is registered before the replay, so this arrives after it
1126        for _ in 0..20 {
1127            std::thread::sleep(Duration::from_millis(10));
1128            if store.subscribers() > 0 {
1129                break;
1130            }
1131        }
1132        store.publish(
1133            id,
1134            EventPayload::Log {
1135                stream: crate::execution::LogStream::Handler,
1136                message: "the client is gone by now".into(),
1137            },
1138        );
1139    }
1140
1141    /// A `Write` that keeps the WebSocket frames it was given and then goes away, so the
1142    /// connection loop can be run without a socket. It decodes only what this server
1143    /// writes: unmasked, unfragmented frames.
1144    #[derive(Clone)]
1145    struct SharedSink {
1146        bytes: Arc<std::sync::Mutex<Vec<u8>>>,
1147        /// How many frames it accepts before the writes start failing, as a closed tab's
1148        /// socket does.
1149        accepts: Arc<AtomicUsize>,
1150    }
1151
1152    impl SharedSink {
1153        fn new(accepts: usize) -> Self {
1154            SharedSink {
1155                bytes: Arc::new(std::sync::Mutex::new(Vec::new())),
1156                accepts: Arc::new(AtomicUsize::new(accepts)),
1157            }
1158        }
1159
1160        fn frames(&self) -> Vec<String> {
1161            let bytes = self.bytes.lock().unwrap();
1162            let mut out = Vec::new();
1163            let mut i = 0usize;
1164            while i + 2 <= bytes.len() {
1165                let opcode = bytes[i] & 0x0f;
1166                let short = bytes[i + 1] as usize;
1167                let (len, header) = match short {
1168                    126 => (u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize, 4),
1169                    127 => (
1170                        u64::from_be_bytes(bytes[i + 2..i + 10].try_into().unwrap()) as usize,
1171                        10,
1172                    ),
1173                    n => (n, 2),
1174                };
1175                let start = i + header;
1176                let end = start + len;
1177                if end > bytes.len() {
1178                    break;
1179                }
1180                if opcode == 0x1 {
1181                    out.push(String::from_utf8_lossy(&bytes[start..end]).into_owned());
1182                }
1183                i = end;
1184            }
1185            out
1186        }
1187    }
1188
1189    impl std::io::Write for SharedSink {
1190        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1191            if self.accepts.load(Ordering::SeqCst) == 0 {
1192                return Err(std::io::Error::new(
1193                    std::io::ErrorKind::BrokenPipe,
1194                    "the client went away",
1195                ));
1196            }
1197            self.accepts.fetch_sub(1, Ordering::SeqCst);
1198            self.bytes.lock().unwrap().extend_from_slice(buf);
1199            Ok(buf.len())
1200        }
1201        fn flush(&mut self) -> std::io::Result<()> {
1202            Ok(())
1203        }
1204    }
1205}