beck_rt/
session.rs

1//! One subscription: the client half of the tier crossing the splitter found.
2//!
3//! The `page` signal is `@on(client)` and its input is `@on(data)`, so exactly one edge crosses,
4//! and this is what the compiler synthesises for it (§4.3 stage 3): "the server side gets a diff
5//! operator (DOM patches for Mode-A components), the client side a resumable `(subscription, seq)`
6//! consumer; `send` becomes the upstream command channel into the ingress."
7
8use std::sync::Arc;
9
10use anyhow::Result;
11use beck_core::Html;
12use futures_util::{SinkExt, StreamExt};
13use tokio_tungstenite::tungstenite::Message;
14
15use crate::app::App;
16use crate::patch::{DataFrame, PatchFrame};
17use crate::protocol::{ClientMsg, Resumption, ServerMsg};
18use crate::telemetry::telemetry;
19use beck_core::delta;
20use beck_core::diff::{diff, Op};
21use beck_core::render::Mode;
22use beck_core::Value;
23
24/// Anything that behaves like a websocket connection: the upgraded socket in the server, and an
25/// in-memory duplex in the tests.
26pub trait Socket:
27    futures_util::Sink<Message, Error = tokio_tungstenite::tungstenite::Error>
28    + futures_util::Stream<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
29    + Unpin
30{
31}
32
33impl<T> Socket for T where
34    T: futures_util::Sink<Message, Error = tokio_tungstenite::tungstenite::Error>
35        + futures_util::Stream<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
36        + Unpin
37{
38}
39
40/// Drive one subscription until the socket closes.
41pub async fn run<S: Socket>(app: Arc<App>, socket: S) -> Result<()> {
42    run_as(app, socket, None).await
43}
44
45/// The same, for a connection whose identity was already decided at the HTTP upgrade.
46///
47/// A browser logged in through [`crate::oidc`] carries its credential in a **cookie**, which the
48/// `hello` frame cannot see and must not: putting the token in the frame would mean putting it in
49/// the document, where a script can read it. So the upgrade verifies, and hands the result here —
50/// and when it does, the frame's `actor` is not consulted at all.
51pub async fn run_as<S: Socket>(
52    app: Arc<App>,
53    mut socket: S,
54    verified: Option<crate::identity::Actor>,
55) -> Result<()> {
56    // A guard, not a pair of calls: a session ends by returning, by erroring, or by the socket
57    // dying, and a gauge that only decrements on the happy path drifts upward forever.
58    let _connected = SessionGuard::new();
59    let Some((sub, from, claimed, path)) = wait_for_hello(&mut socket).await? else {
60        return Ok(());
61    };
62
63    // The one place a socket's actor is decided, when the upgrade did not already decide it.
64    // Before this existed the claim *was* the actor, and every ownership check in every program
65    // was enforced against a value the caller chose (`docs/42` §42.6, `docs/43` §43.4).
66    let actor = match verified {
67        Some(actor) => actor,
68        None => match app.identity().verify(&claimed) {
69            Ok(a) => a,
70            Err(why) => {
71                // The operator learns which refusal it was; the client learns that it was refused.
72                tracing::warn!(sub = %sub, reason = why.reason(), "identity refused");
73                telemetry().unauthenticated.incr();
74                send_json(&mut socket, &ServerMsg::error(why.message())).await?;
75                return Ok(());
76            }
77        },
78    };
79
80    // In the roster from here until this function returns, whichever way it returns. Joining
81    // *before* the first render is what makes a connecting client see itself: the page it is sent
82    // is the page of a world it is already in.
83    let _here = app.presence().join(crate::program::Viewer::actor(&actor));
84
85    let floor = app.floor().await?;
86    let head = app.head();
87    let how = match from {
88        // The client holds nothing, so there is nothing to be a difference from.
89        None => Resumption::Fresh,
90        // Position zero is always reachable whatever the log's floor is: the state at zero is the
91        // fold's initial accumulator, which is reconstructed rather than read.
92        Some(0) => Resumption::Resumed {
93            from: 0,
94            replayed: head,
95        },
96        // The gap is unreachable — the log was trimmed, or this `seq` is from another lifetime of
97        // the application. Reset, and say so rather than pretending.
98        Some(n) if n < floor || n > head => Resumption::Reset { from: n },
99        Some(n) => Resumption::Resumed {
100            from: n,
101            replayed: head - n,
102        },
103    };
104
105    let who = Subscriber { actor, path };
106
107    // Contributing from here for as long as this subscription lives, and joined *before* the first
108    // render for the reason the roster above is: the page a client is first sent is the page of a
109    // world it is already in, and a client that could not see its own cursor would think the
110    // feature was broken. A program that reads no awareness contributes nothing and holds no row.
111    let mine = match app.runtime().contribution_of(&who)? {
112        Some(v) => Some(app.awareness().join(who.actor.name(), v)),
113        None => None,
114    };
115
116    // The one branch a rendering mode makes to a subscription.
117    match app.runtime().placed().render.mode {
118        Mode::Server => mode_a(app, socket, sub, who, how, mine).await,
119        Mode::Client => mode_b(app, socket, sub, who, how, mine).await,
120    }
121}
122
123/// Who this subscription is, and where.
124///
125/// The identity half is an [`crate::identity::Actor`], which only a provider can mint. The route
126/// half is a `String` the client sent, which nothing verifies and nothing should — a route is not
127/// evidence of anything, and [`beck_core::render`] is where that difference stops being a comment
128/// and becomes a rule about which pages may render in a browser.
129///
130/// It is one value rather than two arguments because everything downstream takes a
131/// [`crate::program::Viewer`], and a route threaded separately would be a second parameter that
132/// every render path could forget.
133pub(crate) struct Subscriber {
134    actor: crate::identity::Actor,
135    path: String,
136}
137
138impl crate::program::Viewer for Subscriber {
139    fn actor(&self) -> &str {
140        self.actor.name()
141    }
142
143    fn claims(&self) -> &std::collections::BTreeMap<std::sync::Arc<str>, std::sync::Arc<str>> {
144        self.actor.claims()
145    }
146
147    fn path(&self) -> &str {
148        &self.path
149    }
150}
151
152/// Mode A: the server renders, and the frames are DOM patches.
153async fn mode_a<S: Socket>(
154    app: Arc<App>,
155    mut socket: S,
156    sub: String,
157    who: Subscriber,
158    how: Resumption,
159    mine: Option<crate::awareness::Guard>,
160) -> Result<()> {
161    // Subscribe *before* reading the current view, so an event that lands in between wakes us
162    // rather than being missed.
163    let mut version = app.subscribe();
164    // Declared *before* the engine so it is dropped after it: what the shared dataflow holds
165    // changes when this subscription's engine goes, and sampling before that would leave the gauge
166    // describing arrangements the process has just released.
167    let _shared = SharedGauge(app.clone());
168    // One engine per subscription: §5.3's per-subscriber operators, and the arrangements they
169    // hold. With sharing on that is *only* the per-session operators — everything above them is one
170    // dataflow the application holds. It is created before the first render so that render is the
171    // engine's own cold start rather than a recompute the engine then has to catch up with.
172    //
173    // It is also this subscription's membership of the shared dataflow's reader set, and dropping
174    // it is how the dataflow learns the subscription is over (`docs/23` §23.19's lifecycle).
175    let mut engine = app.view_engine()?;
176    let mut arranged = Arranged::new();
177    // `seq` comes back from the render rather than from `app.head()` afterwards: it is the version
178    // the page reflects, and this frame will be the one a resuming client asks for the difference
179    // from.
180    let (view_now, seq) = app.maintain(&mut engine, &who).await?;
181    arranged.update(engine.arranged());
182    report_shared(&app);
183
184    let ops = match how {
185        // The client has nothing we can trust: hand it the whole frame. Same format, same
186        // interpreter — a reset is just a patch that happens to replace the root.
187        Resumption::Fresh | Resumption::Reset { .. } => vec![Op::Replace {
188            path: vec![],
189            html: view_now.clone(),
190        }],
191        // The client has the view as of `from`: send it exactly the difference.
192        Resumption::Resumed { from, .. } => {
193            let then = app.state_at(from).await?;
194            let view_then = app.runtime().view(&then, &who)?;
195            diff(&view_then, &view_now)
196        }
197    };
198    let initial = (!ops.is_empty()).then(|| PatchFrame::new(seq, ops).to_json());
199
200    drive(
201        &app,
202        &mut socket,
203        sub,
204        who,
205        seq,
206        how,
207        initial,
208        &mut version,
209        mine,
210        Feed::Dom {
211            engine,
212            arranged,
213            last: view_now,
214        },
215    )
216    .await
217}
218
219/// Mode B: the browser renders, and the frames are state diffs.
220///
221/// Note what this function does not construct: a view engine, and therefore no per-session
222/// arrangements. That is D5's "less server work per user", and it is a consequence of the mode
223/// rather than an optimisation applied to it.
224async fn mode_b<S: Socket>(
225    app: Arc<App>,
226    mut socket: S,
227    sub: String,
228    who: Subscriber,
229    how: Resumption,
230    mine: Option<crate::awareness::Guard>,
231) -> Result<()> {
232    // Subscribe *before* reading the state, for the same reason Mode A does: an event that lands
233    // in between has to wake us rather than be missed.
234    let mut version = app.subscribe();
235    // The state and the position it reflects, read together: a client told "this is the state at
236    // 41" when it is the state at 42 would apply the next patch to the wrong base, and every patch
237    // after that would be wrong too.
238    let (state, seq) = app.read_snapshot(|s, q| (s.clone(), q)).await;
239
240    let frame = match how {
241        Resumption::Fresh | Resumption::Reset { .. } => DataFrame::whole(seq, &state),
242        Resumption::Resumed { from, .. } => {
243            let then = app.state_at(from).await?;
244            Some(DataFrame::Ops {
245                seq,
246                ops: delta::diff(&then, &state),
247            })
248        }
249    };
250    let initial = frame.filter(|f| !f.is_empty()).map(|f| f.to_json());
251
252    drive(
253        &app,
254        &mut socket,
255        sub,
256        who,
257        seq,
258        how,
259        initial,
260        &mut version,
261        mine,
262        Feed::Data { last: state },
263    )
264    .await
265}
266
267/// What this subscription sends when the state moves — §5.1's table, as two variants.
268///
269/// The variants are deliberately different sizes: a Mode A subscription carries a view engine and
270/// its arrangements, a Mode B one carries a `Value`. Boxing the larger to even them out would hide
271/// the asymmetry that is the mode's whole point (D5's "less server work per user"), for one
272/// allocation per connection.
273#[allow(clippy::large_enum_variant)]
274enum Feed {
275    /// The server renders per subscriber and streams the difference between two pages.
276    Dom {
277        engine: beck_core::engine::Engine,
278        arranged: Arranged,
279        last: Html,
280    },
281    /// The browser renders, so what moves is the accumulator.
282    Data { last: Value },
283}
284
285impl Feed {
286    /// The frame this subscriber is owed now, and the position it brings them to.
287    ///
288    /// `None` means nothing changed *for this subscriber* — the common case on a busy application,
289    /// and the reason an idle connection costs no bytes in either mode.
290    async fn advance(
291        &mut self,
292        app: &Arc<App>,
293        who: &Subscriber,
294    ) -> Result<(Option<serde_json::Value>, u64)> {
295        match self {
296            Feed::Dom {
297                engine,
298                arranged,
299                last,
300            } => {
301                let (view, at) = app.maintain(engine, who).await?;
302                arranged.update(engine.arranged());
303                report_shared(app);
304                let started = std::time::Instant::now();
305                let ops = diff(last, &view);
306                telemetry().diff.record(started.elapsed());
307                *last = view;
308                Ok((
309                    (!ops.is_empty()).then(|| PatchFrame::new(at, ops).to_json()),
310                    at,
311                ))
312            }
313            Feed::Data { last } => {
314                let (state, at) = app.read_snapshot(|s, q| (s.clone(), q)).await;
315                let started = std::time::Instant::now();
316                let ops = delta::diff(last, &state);
317                telemetry().diff.record(started.elapsed());
318                *last = state;
319                Ok((
320                    (!ops.is_empty()).then(|| DataFrame::Ops { seq: at, ops }.to_json()),
321                    at,
322                ))
323            }
324        }
325    }
326}
327
328#[allow(clippy::too_many_arguments)]
329async fn drive<S: Socket>(
330    app: &Arc<App>,
331    socket: &mut S,
332    sub: String,
333    mut who: Subscriber,
334    mut seq: u64,
335    how: Resumption,
336    initial: Option<serde_json::Value>,
337    version: &mut tokio::sync::watch::Receiver<u64>,
338    mine: Option<crate::awareness::Guard>,
339    mut feed: Feed,
340) -> Result<()> {
341    // A second thing this subscription may have to wake on, and only when the program asked: a
342    // page that never mentions `presence` must not re-render because somebody else connected.
343    // Which is a compile-time fact, so this is a property of the program rather than a heuristic.
344    let mut here = app
345        .runtime()
346        .placed()
347        .roles
348        .view_reads_presence
349        .then(|| app.presence().watch());
350    // And a third, on the same terms: a page that reads no awareness must not re-render because
351    // somebody else navigated. `Roles::awareness` is the compile-time fact that says so.
352    let mut aware = app
353        .runtime()
354        .placed()
355        .roles
356        .awareness
357        .is_some()
358        .then(|| app.awareness().watch());
359    // How a subscriber was brought up to date is exactly the distinction Phase 0 got wrong twice
360    // (§18.5 item 1): an ack means committed, a frame means your view has caught up.
361    tracing::info!(seq, sub = %sub, how = how.label(), "subscribed");
362    send_json(socket, &ServerMsg::welcome(&sub, seq, how)).await?;
363    if let Some(frame) = initial {
364        send_json(socket, &frame).await?;
365    }
366
367    // The highest seq this client was told about but has not yet seen reflected in its view. Only
368    // a client waiting on its own command is sent an "up to date" notice.
369    let mut awaiting: Option<u64> = None;
370
371    let mut draining = app.draining();
372    loop {
373        tokio::select! {
374            // A drained server hands its subscriptions back rather than holding them open: the
375            // client reconnects, to this process or to the one that replaced it (§5.2).
376            _ = draining.changed() => {
377                if *draining.borrow() {
378                    tracing::info!(sub = %sub, "draining: ending the subscription");
379                    break;
380                }
381            }
382            // The roster moved: somebody arrived or left. Nothing in the log moved, so `seq` does
383            // not, and what this sends is a patch labelled with the position it already had.
384            changed = wait(&mut here), if here.is_some() => {
385                if changed.is_err() {
386                    break; // the application is gone
387                }
388                let (frame, _) = feed.advance(app, &who).await?;
389                if let Some(frame) = frame {
390                    send_json(socket, &frame).await?;
391                }
392            }
393            // The awareness roster moved: somebody navigated, or arrived, or left. Nothing in the
394            // log moved, so `seq` does not — the same shape as the roster arm above.
395            changed = wait(&mut aware), if aware.is_some() => {
396                if changed.is_err() {
397                    break; // the application is gone
398                }
399                let (frame, _) = feed.advance(app, &who).await?;
400                if let Some(frame) = frame {
401                    send_json(socket, &frame).await?;
402                }
403            }
404            changed = version.changed() => {
405                if changed.is_err() {
406                    break; // the application is gone
407                }
408                let (frame, at) = feed.advance(app, &who).await?;
409                seq = at;
410                if let Some(frame) = frame {
411                    send_json(socket, &frame).await?;
412                    awaiting = awaiting.filter(|w| *w > seq);
413                } else if let Some(w) = awaiting {
414                    // No frame is owed, but this client is waiting on its own command. Tell it
415                    // where its view stands, or it waits forever (§18.5 item 1).
416                    if seq >= w {
417                        send_json(socket, &ServerMsg::up_to_date(seq)).await?;
418                        awaiting = None;
419                    }
420                }
421            }
422            incoming = socket.next() => {
423                let Some(message) = incoming else { break };
424                let text = match message? {
425                    Message::Text(t) => t,
426                    Message::Close(_) => break,
427                    Message::Ping(p) => {
428                        socket.send(Message::Pong(p)).await?;
429                        continue;
430                    }
431                    _ => continue,
432                };
433                match ClientMsg::parse(&text) {
434                    Ok(ClientMsg::Cmd { id, command }) => {
435                        let decoded = match app.runtime().decode_command(&command) {
436                            Ok(v) => v,
437                            Err(e) => {
438                                send_json(socket, &ServerMsg::nack(&id, &e.to_string())).await?;
439                                continue;
440                            }
441                        };
442                        match app.propose(id.clone(), who.actor.clone(), decoded).await {
443                            Ok(at) => {
444                                send_json(socket, &ServerMsg::ack(&id, at)).await?;
445                                if at > seq {
446                                    awaiting = Some(at);
447                                }
448                            }
449                            Err(why) => {
450                                send_json(socket, &ServerMsg::nack(&id, &why)).await?;
451                            }
452                        }
453                    }
454                    Ok(ClientMsg::Nav { path }) => {
455                        // The route is a field of the `Session` the view is rendered against, so a
456                        // navigation is a re-render and nothing else — no route table, no second
457                        // rendering path, and no code in the runtime that knows what a route is.
458                        //
459                        // In Mode B this produces no frame at all, which is right rather than a
460                        // gap: the browser holds the state and renders its own page, and what the
461                        // server needs the route for is the `Session` it hands `validate`.
462                        if who.path != path {
463                            who.path = path;
464                            telemetry().navigations.incr();
465                            // This client's contribution is a function of its session, so a
466                            // navigation republishes it. Everybody else's re-render comes from the
467                            // registry waking their `aware` arm; this one's comes from the frame
468                            // below, which it was going to send anyway.
469                            if let Some(mine) = &mine {
470                                mine.publish(
471                                    app.runtime()
472                                        .contribution_of(&who)?
473                                        .unwrap_or(beck_core::Value::Unit),
474                                );
475                            }
476                            let (frame, at) = feed.advance(app, &who).await?;
477                            seq = at;
478                            if let Some(frame) = frame {
479                                send_json(socket, &frame).await?;
480                            }
481                        }
482                    }
483                    Ok(ClientMsg::Ping) => send_json(socket, &serde_json::json!({"t":"pong"})).await?,
484                    Ok(ClientMsg::Hello { .. }) => {}
485                    Err(e) => {
486                        telemetry().bad_messages.incr();
487                        tracing::debug!(error = %e, "unparseable client message");
488                    }
489                }
490            }
491        }
492    }
493    Ok(())
494}
495
496/// Wait on an optional watch, so `select!` can have an arm that is only sometimes armed.
497///
498/// The `if here.is_some()` guard is what disables the arm, and a disabled arm's expression is still
499/// *evaluated* — only its future is never polled — so the call has to be legal with no receiver.
500async fn wait(
501    here: &mut Option<tokio::sync::watch::Receiver<beck_core::Value>>,
502) -> Result<(), tokio::sync::watch::error::RecvError> {
503    match here {
504        Some(rx) => rx.changed().await,
505        None => std::future::pending().await,
506    }
507}
508
509async fn wait_for_hello<S: Socket>(
510    socket: &mut S,
511) -> Result<Option<(String, Option<u64>, String, String)>> {
512    while let Some(message) = socket.next().await {
513        match message? {
514            Message::Text(t) => match ClientMsg::parse(&t) {
515                Ok(ClientMsg::Hello {
516                    sub,
517                    seq,
518                    actor,
519                    path,
520                }) => return Ok(Some((sub, seq, actor, path))),
521                Ok(_) => continue,
522                Err(e) => {
523                    telemetry().bad_messages.incr();
524                    tracing::debug!(error = %e, "unparseable hello");
525                    continue;
526                }
527            },
528            Message::Close(_) => return Ok(None),
529            _ => continue,
530        }
531    }
532    Ok(None)
533}
534
535/// Holds this subscription's share of the arranged-entries gauge.
536///
537/// §5.3 names per-session memory as a metric to export, and `docs/23-incremental-views-report.md`
538/// §23.19 recorded that `Engine::footprint` computed one and nothing exported it. This exports the
539/// unit that scales — arrangement *entries*, `O(operators)` to read — rather than bytes, which
540/// would need a walk of the accumulator on every render.
541///
542/// A guard rather than a pair of calls, for the same reason [`SessionGuard`] is one: a subscription
543/// ends by returning, by erroring or by its socket dying, and a gauge that only releases its share
544/// on the happy path drifts upward until it is describing connections that closed hours ago.
545/// Sample what the **one** shared dataflow holds, after a render has just moved it.
546///
547/// Three numbers, and they answer different questions: `arranged` is what a fanout costs once,
548/// `retained` is how far behind the laggiest subscriber is, and `releases` is how often the process
549/// has thrown the arrangements away because nobody was connected. A render is the right moment for
550/// all three — it is when they change, and it is `O(operators)` to read (`docs/23`).
551fn report_shared(app: &Arc<App>) {
552    let shared = app.shared_dataflow();
553    telemetry().shared_arranged.set(shared.arranged());
554    telemetry().shared_retained.set(shared.retained() as u64);
555    telemetry().shared_releases.sync(shared.releases());
556}
557
558/// Re-samples the shared dataflow's numbers when a subscription ends.
559///
560/// A guard rather than a call at the end of `run`, for the reason [`Arranged`] is one: a
561/// subscription ends by returning, by erroring or by its socket dying. And it matters more here
562/// than it looks — the *last* subscription to end is the one that releases the arrangements, so
563/// without this the gauge sits at whatever the fanout was holding for as long as the process is
564/// idle, which is the one moment an operator most wants it to say zero.
565struct SharedGauge(Arc<App>);
566
567impl Drop for SharedGauge {
568    fn drop(&mut self) {
569        report_shared(&self.0);
570    }
571}
572
573struct Arranged(u64);
574
575impl Arranged {
576    fn new() -> Arranged {
577        Arranged(0)
578    }
579
580    fn update(&mut self, now: u64) {
581        telemetry().session_arranged.adjust(self.0, now);
582        self.0 = now;
583    }
584}
585
586impl Drop for Arranged {
587    fn drop(&mut self) {
588        telemetry().session_arranged.adjust(self.0, 0);
589    }
590}
591
592/// Holds the active-session count for as long as a session is running.
593struct SessionGuard;
594
595impl SessionGuard {
596    fn new() -> SessionGuard {
597        telemetry().sessions.incr();
598        SessionGuard
599    }
600}
601
602impl Drop for SessionGuard {
603    fn drop(&mut self) {
604        telemetry().sessions.decr();
605    }
606}
607
608async fn send_json<S: Socket>(socket: &mut S, value: &serde_json::Value) -> Result<()> {
609    let text = value.to_string();
610    // Counted here rather than at each call site: every frame the server sends goes through this
611    // function, so the count cannot drift from what was actually written to a socket.
612    telemetry().patch_frames.incr();
613    telemetry().patch_bytes.add(text.len() as u64);
614    socket.send(Message::Text(text.into())).await?;
615    Ok(())
616}