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}