forge_server/transport.rs
1//! The socket a client speaks to, and what a connection needs to answer it.
2//!
3//! One route, `/socket`, at the address the caller bound. Every connection
4//! gets its own subscription, so a second client changes nothing about the
5//! first. **Every subscriber is delivered every update; the connection then
6//! filters to the subjects it asked for**, which is what makes subject
7//! delivery correct and why the fan-out shares one update rather than copying
8//! it per reader.
9
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, Instant};
12
13use axum::Router;
14use axum::routing::get;
15use forge_primitives::{Message, SessionSlot};
16use tokio::net::TcpListener;
17
18use crate::live::Live;
19use crate::surface::ViewSurface;
20use crate::work::WorkCache;
21use crate::{SeatUpdate, SessionUpdate};
22
23pub mod batch;
24mod connection;
25pub mod conversation;
26pub mod envelope;
27pub mod frame;
28mod origin;
29mod probe;
30pub mod wire;
31
32/// The protocol this server speaks.
33///
34/// Fixed rather than negotiated: the server and the core change far more
35/// slowly than a client's visuals do, so a client either speaks this or it
36/// does not, and a mismatch fails plainly instead of silently.
37///
38/// Named here rather than in `connection.rs` so the socket's recorded
39/// contract is filed under it: a bump looks for a record directory that is
40/// not there and fails, which is the honest answer for a client that would
41/// refuse the connection anyway.
42///
43/// **Renaming an update is a bump.** A variant's name is the tag it crosses
44/// under, so a client that knows the old one narrows the frame to nothing and
45/// drops it silently - which is what a version the server bumps exists to
46/// prevent, since the skewed pair is real: the desktop client ships
47/// separately from the binary. `slack_draft_expired` became
48/// `slack_draft_resolved`, and `baselines/socket/1/` keeps the older tag as
49/// the record a v1 server emitted. Nothing reads a past version's directory:
50/// it is an archive, and its staleness is the point rather than a fault.
51///
52/// **Removing an update is a bump too, and v3 is one.** The peer surface
53/// collapsed to `agents__send_message`, which retired
54/// `peer_inflight_stats_changed` along with the per-seat counters and the two
55/// agent-row fields that carried them: a subscriber that still expects the
56/// variant would wait for a badge that never moves again. `baselines/socket/2/`
57/// is the record a v2 server emitted.
58///
59/// **v4 is a bump for the greet's settings and for the binary path.** The
60/// greeting's `settings` gains the `[dictate]` axes a capturing client starts
61/// on, `Command` gains `dictate_stream`, and the socket now takes binary
62/// messages as dictation frames - a whole message kind a v3 client has no
63/// vocabulary for. `baselines/socket/3/` is the record a v3 server emitted.
64///
65/// **v5 is a bump for the take's owner.** A take belongs to the connection
66/// that started it: every `Dictate*` update is forwarded to that connection
67/// alone, a second take on the seat is refused by name, and a connection
68/// going away DROPS its take rather than submitting what arrived. The
69/// record's `composer` loses its `take` and `notice` for the same reason -
70/// a take's meter, phases and words are not a fact any other reader may
71/// draw. `baselines/socket/4/` is the record a v4 server emitted.
72///
73/// **v6 is a bump for the browser's ask and answer.** The socket gains its
74/// first request in the server-to-client direction: `browser_ask` carries one
75/// tool call to whichever client holds the browser role, `browser_answer`
76/// carries the result back under the same id, and a subscription declares
77/// whether a client can host that role at all. A second binary frame kind
78/// joins the dictation frames - a browser answer's image bytes, under its
79/// answer's id - so a client with no vocabulary for any of it would read
80/// frames as nothing rather than as news. `baselines/socket/5/` is the record
81/// a v5 server emitted.
82///
83/// **v7 is a bump for the browser's hand-off field and for the models read's
84/// own shape.** The hand-off's `context` is renamed `profile`, and a
85/// proposal's `candidate` is now `candidates`, ranked rows rather than the
86/// one the server picked, while the in-use row's `sha256` is nullable where
87/// an installed model publishes none. The first two are renames a v6 client
88/// reads as nothing, and the third is a value it dereferences (`slice`), so a
89/// same-version skew is a client that throws while drawing rather than one
90/// that shows less. `baselines/socket/6/` is the record a v6 server emitted.
91///
92/// **v8 is a bump for the board's own read.** A project's `tasks` is renamed
93/// `rows` - each row is its task plus worked time, ages and marks - a row's
94/// `estimate` is an object where a v7 server sent a string, and `artifact` is
95/// gone, folded into `links`. The rename is what makes this a bump rather than
96/// an addition: a v7 client reads `row.tasks.map(...)` and throws on
97/// `undefined` while DRAWING, so a same-version skew would be a page that
98/// never lands rather than one that shows less. `baselines/socket/7/` is the
99/// record a v7 server emitted.
100///
101/// **v9 is a bump for the board's own subject (#2517).** A project's `rows`,
102/// `crons` and `connectors` left the home's project row for a `board` subject
103/// of their own, and the home's fleet rows gained `rows` for the header's
104/// total. Both directions break silently on a same-version skew: a v9 client
105/// asking a v8 server for `board` gets that subject back as an unknown
106/// message, and a v8 client reading a v9 home finds no `rows`, `crons` or
107/// `connectors` under its projects - it draws an empty board and a zero total
108/// until the greeting's version turns it into the notice.
109/// `baselines/socket/8/` is the record a v8 server emitted.
110///
111/// **v10 is a bump for the browser's move to the server.** The browser role
112/// and its ask/answer pair are gone: a client attaches to a profile's
113/// browser as a viewer, sends gestures and takes the controller role, and
114/// receives encoded frames as binary messages. A v9 client would send
115/// `browser_answer` to a server that never asks, and the two would sit
116/// across the socket unable to drive a browser at all.
117/// `baselines/socket/9/` is the record a v9 server emitted.
118///
119/// **v11 is a bump for the browser's tabs.** A viewer attaches to a tab
120/// rather than a profile, the profile verbs are gone, and a strip reads a
121/// `browser_tabs` list instead of `browser_profiles`. A v10 client would
122/// send `browser_profiles` and a `profile` name a v11 server no longer
123/// answers, and would pair every surface state against the wrong tab.
124/// `baselines/socket/10/` is the record a v10 server emitted.
125/// The tab on a file ask and on a download are additive fields under v11: a
126/// v11 client ignores what it does not know, so no reader is left holding a
127/// value it cannot draw, and the version stays.
128///
129/// **v12 is a bump for the client's `showing` message.** A seat's diamond,
130/// failure mark and death trace are spent by the seat being shown on screen,
131/// not by a subscription to it, so a client that keeps every visited seat
132/// subscribed spends nothing until the reader opens one. Both directions
133/// break silently on a same-version skew: a v11 server reads `showing` as a
134/// message it does not know and answers an `error` naming `client_message`,
135/// and a v12 server beside a v11 client never learns what is on screen, so
136/// no seat is ever attached and no mark is ever spent - the rail keeps
137/// diamonds the reader has already looked at. `baselines/socket/11/` is the
138/// record a v11 server emitted.
139///
140/// **v13 is a bump for a hand-off's answer.** The answer carries what it
141/// means, the door in the client that sent it and the connection that
142/// carried it, in place of the bare `done` flag. A v12 client's answer names
143/// only `done` and cannot be decoded, so **nothing refuses the connection**
144/// (the endings it draws are ignored safely), and its answer comes back as
145/// a dispatch refusal, which a client draws where the answer came from: the
146/// command frame is recognised by its kind even when its body no longer
147/// parses, so the person is told the prompt is still waiting instead of
148/// losing the answer to the console. `baselines/socket/12/` is the record a
149/// v12 server emitted.
150///
151/// **v14 is a bump for a web client's perf batch.** `client_perf` carries the
152/// records a page measures, so a client with no shell to write them still
153/// produces its own diagnostics. It shares a number with nothing: v13 was
154/// already the hand-off answer's, and two schemas cannot answer to one
155/// protocol. A v13 server reads the batch as a message it does not know and
156/// answers an `error` naming `client_message`, which the page's own error
157/// path takes as a refused subscribe: an in-flight read can be marked
158/// refused while the connection stays up. `baselines/socket/13/` is the
159/// record a v13 server emitted.
160///
161/// **v15 is a bump for the browser frame's header.** It grew from 14 to 18
162/// bytes: the page's own viewport rides beside the encoded pixels, so a
163/// viewer maps a gesture through the picture it draws rather than sending it
164/// 1:1 into a page laid out at another shape. A v14 client splits the header
165/// at the old offsets and reads the four new bytes as the head of the WebP,
166/// so its decoder fails and the surface draws nothing at all - a silent
167/// blank, which is exactly what the greeting's version is for.
168/// `baselines/socket/14/` is the record a v14 server emitted.
169///
170/// **v16 is a bump for a renamed composer field.** The record's
171/// `composer.compacting` became `composer.machine`, which names a handoff
172/// beside a compaction. A v15 client reads no `compacting` and draws a live
173/// composer through a compaction it cannot see - the same silent wrong state
174/// a growing header caused at v15, reached by a rename instead. The new
175/// client reads the old name as a fallback, so a v15 server beside it still
176/// stands the box down; the bump is what tells an old client its server has
177/// moved. `baselines/socket/15/` is the record a v15 server emitted.
178///
179/// **v17 is a bump for the replay sub-protocol's removal.** The transport's
180/// held copy is the one conversation a seat has: `HistoryReplayed` and the
181/// `ReplayConversation` command it answered are gone, and `AuthRequired`
182/// goes with the event that never produced it. A v16 client holds both names
183/// in its replace list and its composer record; neither frame arrives, so it
184/// draws a seat that never re-reads rather than one that breaks - which is
185/// why the bump, not the client's decode, is what tells it the server moved.
186///
187/// **v18 is a bump for the settings frame.** `settings` carries the whole
188/// effective config, pushed once after the greeting and again after every
189/// applied reload. A v18 client's settings page waits for it, so a v17
190/// server's silence would draw as a page that never loads rather than as a
191/// version skew; the bump is what names the mismatch. `baselines/socket/17/`
192/// is the record a v17 server emitted.
193///
194/// **v20 adds the liveness pair.** `ping` and `pong` are new variants rather
195/// than fields, and a server older than them answers a probe as a message it
196/// does not know - one `error` frame per probe - so the numbers move.
197///
198/// **v21 is a bump for the settings editor.** `settings_edit` is a batched
199/// save and `settings_refused` is the frame that answers one that wrote
200/// nothing; a v20 server reads the save as a message it does not know and
201/// answers an error no page can attribute, and a v20 client would not read
202/// the refusal. `baselines/socket/20/` is the record a v20 server emitted.
203///
204/// **v22 opens a hand-off by page.** `browser_view_page` is a new variant, and
205/// a v21 server reads it as a message it does not know: a hand-off whose tab
206/// number outlived its host would fail to open at all rather than open the
207/// wrong tab, so the numbers move. `baselines/socket/21/` is the record a v21
208/// server emitted.
209///
210/// **v23 takes a voice preview.** `voice_preview` is a new command variant,
211/// and a v22 server reads it as a message it does not know: a page that asked
212/// to hear a voice would be answered by an error rather than by one. The
213/// speaking read's fields ride along without moving the number, because a
214/// client older than them draws the role it always drew. `baselines/socket/22/`
215/// is the record a v22 server emitted.
216///
217/// **v24 answers a document hand-off explicitly and lands its threads.** A
218/// hand-off ends on `approve` or `feedback` with a note rather than a bare
219/// `done`, and a document read's threads carry the `resolution` that places
220/// each one in the document as it reads now. A v23 client already ignores a
221/// field it does not know, so the landing rides along; the ENDING is the
222/// break - it reads `approve` as a command answer no page can attribute -
223/// and the two halves move together. `baselines/socket/23/` is the record a
224/// v23 server emitted.
225///
226/// **v25 is a bump for the Slack draft's answer.** `respond_slack_post`
227/// carries an answer now rather than a boolean: the reader can send a held
228/// draft back with words for the agent, and `slack_draft_resolved` gains the
229/// `sent_back` ending. The draft's payload replaces `tool` with `operation`,
230/// because one `slack__edit` is a replacement or a deletion and one
231/// `slack__react` adds or removes - a card words its verbs from what will
232/// happen. A v24 server reads the new payload as a command it cannot decode,
233/// and a v24 client sends the old one this server refuses, so the numbers
234/// move. `baselines/socket/24/` is the record a v24 server emitted.
235///
236/// **v26 holds a config write for the reader.** `respond_settings_write` is a
237/// new command variant, and the settings MCP tools park a write as a
238/// `settings_write_pending` update a v25 client has no vocabulary for: it
239/// would read the parked write as nothing, and the agent's call would wait on
240/// a dock nobody draws. `baselines/socket/25/` is the record a v25 server
241/// emitted.
242///
243/// **v27 drops the handoff row from the conversation record and the prompt
244/// origin from the wire.** The record's `handoff` field was what a client
245/// drew the block from; the block is gone, so the field goes with it - a v26
246/// client reading a v27 record draws no block from it, and one that watches
247/// the swap still receives the `handoff` update and draws its own block from
248/// that, because it is the old client - though the record no longer carries
249/// the judgment turn, which only that block read. `ChatAppended.origin` and
250/// `PromptUnder.origin` were the dispatch-time row's bookkeeping; the row is
251/// drawn when the CLI takes the prompt (`started`), which can be inside the
252/// running turn when the CLI absorbs it, so nothing carries an origin. A v26
253/// client reads both fields as absent; its dispatch row and its echo are that
254/// client's own code, and a core-answered command leaves its echo standing
255/// because that client has no answered-command set.
256/// `baselines/socket/26/` is the record a v26 server emitted.
257///
258/// **The held Slack draft's new fields did not need a v28.** `thread` and
259/// `tagged` are additive under the rule below: a v27 client ignores them and
260/// draws the raw ts it always drew, so `baselines/socket/27/` is re-recorded
261/// in place rather than a v28 directory being cut.
262///
263/// **v28 holds a vault call for the reader.** `respond_vault_ask` is a new
264/// command variant, and the vault family parks a call as a
265/// `vault_ask_pending` update with a `vault_ask_resolved` ending that a v27
266/// client has no vocabulary for: it would read the parked call as nothing,
267/// and the agent's call would wait on a dock nobody draws.
268///
269/// **v28 also adds the terminal's binary kinds and commands.** A v27 client
270/// reads tag 4 as an unknown binary frame, reports it and drops it, which is
271/// a silent blank surface rather than a refusal - the same ground v4 named
272/// for dictation's binary kind. The attach, detach, resize, pause and close
273/// messages, the `respond_terminal_hand_off` command and the `terminal`
274/// field on the session record ride with it.
275/// `baselines/socket/28/` is the record a v28 server emitted.
276///
277/// **v29 moves a held settings write to a batch.** `settings_write_pending`'s
278/// `ask` carries `edits`, one entry per key, where a v28 client reads the
279/// flat `key`, `current`, `proposed` and `unset` and draws an empty card it
280/// could approve blind. `baselines/socket/29/` is the record a v29 server
281/// emitted.
282///
283/// **v30 adds the biometric proxy's server surface.** The app-level
284/// `biometric_enrol`, the update-only `biometric_rewrite`, `biometric_release`,
285/// `biometric_revoke` and `vault_enrolment_material` commands, the app-level
286/// `vault_lock`, and the
287/// slot-less `biometric_enrolments_changed`, `vault_rotated` and
288/// `vault_lock_changed` updates. A v29 server cannot decode the commands at
289/// all, and a v29 client has no vocabulary for the updates: a settings page
290/// beside a v29 server would draw no enrolment list and would never learn one
291/// changed, and no client would rewrite its sealed bytes or follow a lock.
292/// `baselines/socket/30/` is the record a v30 server emitted.
293///
294/// **v31 names every ask kind.** A roster row's `pending` was `question`,
295/// `permission` or `hand_off`; it is now one kind per ask source, so the
296/// needs-you line can say what the seat actually waits on: `slack_draft`,
297/// `browser_hand_off`, `terminal_hand_off`, `document_hand_off`,
298/// `settings_write` and `vault` replace the shared `hand_off`. A v30 page
299/// narrows each new spelling to `permission` and draws the wrong words, and
300/// a v30 shell refuses the row outright and drops that seat's notification;
301/// the old `hand_off` is gone, so the two halves move together.
302/// `baselines/socket/31/` is the record `record_dir()` requires for the
303/// current version - it is byte-identical to 30 because the census holds
304/// enum and field names rather than values, so it does not itself evidence
305/// the widened kind.
306///
307/// **Additive fields do not bump.** `diff.base_error` is one, carried on the
308/// v16-era record: a client older than the field draws the vaguer line it
309/// always drew, and nothing it sends changes.
310pub const PROTOCOL_VERSION: u32 = 31;
311
312/// The socket's keepalive: how often a connection is asked to prove it is
313/// still there, how long it may stay silent before it is reaped, and how long
314/// it may go without a completed write before a watchdog outside its future
315/// ends it.
316///
317/// **The socket carried none**, so a peer that vanished without closing - a
318/// slept machine, a wedged process, a network that went away - left its
319/// viewer attached and any companion waiting, with neither side told the
320/// other was gone. A test shortens the set rather than waiting the shipping
321/// one out.
322///
323/// **The wedge bound is the task's, not the peer's.** A beat in the
324/// connection's own `select!` cannot fire while the connection is parked in
325/// an await, so `dead` covers a silent peer only while the loop still runs;
326/// `wedge` covers a loop that stopped running, and it counts completed writes
327/// rather than loop turns because one turn can issue several writes.
328#[derive(Clone, Copy, Debug)]
329pub struct Heartbeat {
330 pub every: Duration,
331 pub dead: Duration,
332 pub wedge: Duration,
333}
334
335impl Default for Heartbeat {
336 fn default() -> Self {
337 Self {
338 every: connection::HEARTBEAT_EVERY,
339 dead: connection::HEARTBEAT_DEAD,
340 wedge: connection::WEDGE_DEAD,
341 }
342 }
343}
344
345/// The longest one frame may take to reach the socket before its peer is
346/// judged to have stopped reading.
347///
348/// **A write had no bound**: a peer that vanished with a frame in flight
349/// leaves no ping outstanding, so the keepalive cannot see it, and TCP can
350/// take minutes to report the socket gone (#2473). The floor is the slowest
351/// frame measured - a 3.5 MB snapshot to a slow client, 31.6 s - and 60 s
352/// clears it by close to double and is the keepalive's own dead bound, so the
353/// two mechanisms measure a dead peer on the same scale.
354pub(crate) const WRITE_BOUND: Duration = Duration::from_secs(60);
355
356/// A frame write that outlived [`WRITE_BOUND`].
357///
358/// **A partial frame cannot be resumed**: a client still parsing the frame it
359/// began would read a resumed stream as garbage, so the connection ends
360/// rather than the write being retried.
361#[derive(Debug)]
362pub(crate) struct WriteStalled {
363 pub(crate) bound: Duration,
364 /// How long the write had run when the bound cut it, which is the only
365 /// timing a stalled write ever gets: it never reaches the writer's own
366 /// record.
367 pub(crate) waited: Duration,
368}
369
370impl std::fmt::Display for WriteStalled {
371 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372 write!(
373 f,
374 "the peer stopped reading: a frame write did not complete within {:?}",
375 self.bound
376 )
377 }
378}
379
380impl std::error::Error for WriteStalled {}
381
382/// Wait on one socket write under `bound`, failing as [`WriteStalled`] when it
383/// does not complete in time.
384async fn write_within<T, F>(bound: Duration, write: F) -> anyhow::Result<T>
385where
386 F: std::future::Future<Output = anyhow::Result<T>>,
387{
388 let at = tokio::time::Instant::now();
389 match tokio::time::timeout(bound, write).await {
390 Ok(answered) => answered,
391 Err(_) => Err(anyhow::Error::new(WriteStalled { bound, waited: at.elapsed() })),
392 }
393}
394
395/// What a connection answers from: the surface it reads and dispatches
396/// through, the working-tree cache behind the git read, the conversations
397/// the stream has seeded, the live state a late subscriber cannot
398/// reconstruct for itself, and the `[client]` settings the greeting carries.
399///
400/// The conversations are here rather than on the surface because they are
401/// the TRANSPORT's: they exist so this socket stops reading a whole
402/// transcript per client per request, and a view reading the same seat
403/// through the surface keeps its own copy in its own session state.
404pub struct TransportState {
405 pub surface: Arc<ViewSurface>,
406 pub work: Arc<WorkCache>,
407 pub conversations: Arc<conversation::Conversations>,
408 pub live: Mutex<Live>,
409 /// The settings frame, encoded once per document and shared by every
410 /// connection. `None` until the first document is published; a
411 /// connection that attaches later reads the current one.
412 pub settings: tokio::sync::watch::Sender<Option<Arc<str>>>,
413 /// The server's browser fleet, when one is installed: the connection
414 /// attaches viewers, routes input and takes the controller role through
415 /// it. `None` is a transport with no fleet (tests, a platform with no
416 /// CEF host), where a viewer's attach answers its refusal.
417 pub browser_host: Option<Arc<dyn forge_workspace::browser::BrowserHost>>,
418 /// The socket's keepalive: how often a connection is asked to prove it
419 /// is there, and how long it may stay silent before it is reaped.
420 pub heartbeat: Heartbeat,
421}
422
423impl TransportState {
424 /// The state a running forge serves its socket from, derived from the
425 /// core it is a view of.
426 ///
427 /// **One place, so the pieces cannot drift apart.** Every field here is
428 /// something the socket needs that the workspace owns or that is the
429 /// transport's own.
430 pub fn for_workspace(workspace: &Arc<forge_workspace::Workspace>) -> Self {
431 let documents = workspace.subscribe_settings();
432 let initial = documents.borrow().clone().and_then(|document| encode_settings(&document));
433 let (settings, _settings_rx) = tokio::sync::watch::channel(initial);
434 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
435 runtime.spawn(publish_settings(documents, settings.clone()));
436 }
437 Self {
438 surface: Arc::new(ViewSurface::new(Arc::clone(workspace))),
439 work: Arc::new(WorkCache::new()),
440 conversations: Arc::new(conversation::Conversations::new()),
441 // The process is another viewer of the same seats, so the
442 // attachment count has to see it: a turn finishing on a seat this
443 // process shows is one the reader watched.
444 live: Mutex::new(Live::new()),
445 settings,
446 browser_host: workspace.browser_host(),
447 heartbeat: Heartbeat::default(),
448 }
449 }
450
451 /// [`TransportState::for_workspace`] with the keepalive shortened, so a
452 /// test can watch a silent peer be reaped without waiting a minute of
453 /// real time. Production builds never see it.
454 #[cfg(feature = "testing")]
455 #[must_use]
456 pub fn with_heartbeat(mut self, heartbeat: Heartbeat) -> Self {
457 self.heartbeat = heartbeat;
458 self
459 }
460}
461
462/// A settings watch holding no document, for a transport built by hand (a
463/// test): a connection with no frame draws its page's waiting state.
464#[cfg(any(test, feature = "testing"))]
465pub fn settings_channel() -> tokio::sync::watch::Sender<Option<Arc<str>>> {
466 tokio::sync::watch::channel(None).0
467}
468
469/// Encode one settings document once, so every connection writes the same
470/// bytes rather than building the frame for itself.
471fn encode_settings(document: &forge_primitives::SettingsDocument) -> Option<Arc<str>> {
472 let message = envelope::ServerMessage::Settings { document: document.clone() };
473 match serde_json::to_string(&message) {
474 Ok(text) => Some(Arc::from(text)),
475 Err(error) => {
476 tracing::error!(
477 target: crate::logging::targets::APP_LIFECYCLE,
478 event_name = "settings_unencodable",
479 %error,
480 "the settings frame could not be encoded; connections draw no settings",
481 );
482 None
483 }
484 }
485}
486
487/// Follow the workspace's document and hand each new one to every connection
488/// through the watch, encoded once.
489async fn publish_settings(
490 mut documents: tokio::sync::watch::Receiver<Option<Arc<forge_primitives::SettingsDocument>>>,
491 settings: tokio::sync::watch::Sender<Option<Arc<str>>>,
492) {
493 while documents.changed().await.is_ok() {
494 let current = documents.borrow_and_update().clone();
495 let Some(document) = current else { continue };
496 let Some(encoded) = encode_settings(&document) else { continue };
497 if settings.borrow().as_deref() != Some(encoded.as_ref()) {
498 settings.send_replace(Some(encoded));
499 }
500 }
501}
502
503/// Serve the socket on `listener` until the process ends.
504///
505/// The listener is the caller's rather than bound here, so a test can bind
506/// port 0 and know the address it bound, and so a caller that cannot bind
507/// decides for itself whether that is fatal - which is what keeps a busy
508/// port from becoming a new way for forge to refuse to start.
509pub async fn serve(
510 state: Arc<TransportState>,
511 listener: TcpListener,
512 updates: tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
513) -> anyhow::Result<()> {
514 // The stream is folded ONCE, here, rather than by each connection: the
515 // marks and the composer's state are what the core has said, so they
516 // advance whether or not a client is attached - and a connection folding
517 // its own copy would both multiply the work by the number of clients and
518 // stop the state advancing the moment the last one left.
519 //
520 // **The subscription arrives from the caller, taken before this task was
521 // spawned.** The boot wave runs after `serve_socket` returns, so a seal
522 // the first spawned session emits finds the fold already attached; a
523 // subscription taken in here would be a race the boot order has no reason
524 // to win, and a dropped `Connected` is a seat whose conversation the
525 // transport never holds.
526 //
527 // Observing: the fold renders no prompt, and the boot notice belongs to
528 // the view that does.
529 tokio::spawn(fold_the_stream(Arc::clone(&state), updates));
530
531 let router = Router::new().route("/socket", get(connection::upgrade)).with_state(state);
532 // Each connection carries the peer it was accepted from: the log names
533 // which attached client an open served (#2506).
534 axum::serve(listener, router.into_make_service_with_connect_info::<std::net::SocketAddr>())
535 .await?;
536 Ok(())
537}
538
539/// Fold the core's stream into the live state and the conversations this
540/// transport holds.
541///
542/// **One fold for the whole socket rather than one per connection**, and it
543/// is what seeds a seat: `Connected` arrives here, emitted by the session
544/// task in its own order, so a conversation and the frames that follow it
545/// have one producer. It attaches at boot, before any session spawns, which
546/// is what lets every seat's seal land on a copy.
547async fn fold_the_stream(
548 state: Arc<TransportState>,
549 mut updates: tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
550) {
551 let mut probes = probe::ContextProbe::default();
552 while let Some(frame) = updates.recv().await {
553 // A test's hold, so an encode's catch-up is driven rather than raced.
554 #[cfg(any(test, feature = "testing"))]
555 while state.conversations.fold_is_held() {
556 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
557 }
558 let seq = frame.seq;
559 let update = frame.update;
560 // **One fill at a time per seat.** An encode's catch-up applies from
561 // the window when this loop cannot reach in time, and this loop
562 // receives those same frames afterwards: the seat's own lock is what
563 // makes the watermark check, the apply and the mark one step, so a
564 // frame crosses exactly once whichever path got there first.
565 let fill = update.slot().map(|slot| state.conversations.fill_lock(slot));
566 let filling = fill
567 .as_ref()
568 .map(|lock| lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
569 // **A frame an encode has already folded into the copy is not folded
570 // again** - except a sealing one. A seal re-mints from 1, so its number
571 // can equal the first new frame's, and a watermark that has taken that
572 // frame must not take the seal's history away with it: the reseed it
573 // carries is the only path the new epoch's rows have into the copy.
574 let epoch_frame = matches!(
575 &*update,
576 crate::SessionUpdate::Connected { .. } | crate::SessionUpdate::SessionReplaced { .. }
577 );
578 if !epoch_frame
579 && let (Some(seq), Some(slot)) = (seq, update.slot())
580 && seq <= state.conversations.consumed_for(slot)
581 {
582 continue;
583 }
584 crate::live::Live::lock(&state.live).apply(&update);
585 state.conversations.apply(&update, seq);
586 // **The watermark an answer is measured against.** Marked after the
587 // apply, and only where there is a copy to take it: a seat nothing has
588 // connected on yet holds no frames, so a number marked there would say
589 // a copy has something it has not.
590 //
591 // **An epoch frame RESETS it instead of marking.** That frame is
592 // numbered on the run it ends and the next run counts from 1 again, so
593 // a watermark left standing would skip the new run's first frames -
594 // exactly the numbers it reuses.
595 if let Some(slot) = update.slot() {
596 if epoch_frame {
597 state.conversations.consumed(slot, 0);
598 } else if let Some(seq) = seq
599 && state.conversations.get(slot).is_some()
600 {
601 state.conversations.consumed(slot, seq);
602 }
603 }
604 drop(filling);
605 request_context_usage(&state, &mut probes, &update);
606 }
607}
608
609/// Why the socket owes the core a fresh context reading, which is what an
610/// update says about a seat's held one.
611#[derive(PartialEq, Eq)]
612enum Ask {
613 /// A turn ended, so the reading is older than the transcript it was taken
614 /// from and is asked for again under [`probe`]'s bounds.
615 AfterATurn,
616 /// A compaction settled, so the reading was taken from a transcript that
617 /// no longer exists - wrong rather than merely old, and asked past the
618 /// bounds for that reason.
619 AfterACompaction,
620}
621
622/// Whether this update reports a compaction's own outcome, which is the frame
623/// that ends one.
624///
625/// **The compaction's result field rather than the null status that rides
626/// beside it.** The CLI reports a permission-mode change on that same
627/// `status`/null shape, and a bare null read as a settle would fire the
628/// post-compaction ask - past both bounds - on a mode toggle, mid-compaction,
629/// and spend the ask the settle itself is owed.
630///
631/// A settle with no result field would leave the post-compaction ask unfired
632/// and a reading past the token gate unable to be lowered, which is the state
633/// this bypass exists to close; the field is on the settle in both pinned
634/// captures and a fresh one is where a change to that would show.
635///
636/// The null half is traded for that, and both directions are disclosed: a result
637/// present but null would be read as no settle, and the reading would stand
638/// until the next compaction. No captured frame carries it - the corpus has
639/// exactly three status shapes, a `compacting` string twice and one settle with
640/// a string result - and the null is admitted nowhere else in this predicate.
641fn settles_a_compaction(update: &SessionUpdate) -> bool {
642 let SessionUpdate::ChatAppended { msg: Message::System { subtype, data, .. }, .. } = update
643 else {
644 return false;
645 };
646 subtype == "status" && data.get("compact_result").is_some_and(|result| !result.is_null())
647}
648
649/// What this update asks of the core about a seat's context reading.
650fn context_ask(update: &SessionUpdate) -> Option<(&SessionSlot, Ask)> {
651 let SessionUpdate::ChatAppended { key, msg, .. } = update else {
652 return None;
653 };
654 if settles_a_compaction(update) {
655 return Some((key, Ask::AfterACompaction));
656 }
657 matches!(msg, Message::Result { .. }).then_some((key, Ask::AfterATurn))
658}
659
660/// Ask the core for a fresh context reading on a seat a page shows.
661///
662/// A turn finishing is when a reading stops being true: the transcript grew by
663/// the turn, and the number the seat holds was taken before it. A view
664/// refreshes on the same frame, for the seat it is addressing, and a client
665/// reading a seat is that same act - the answer lands as a
666/// [`SessionUpdate::ContextUsageSnapshot`] on the stream that page is already
667/// reading.
668///
669/// Only a seat a page shows, because the reading exists for the reader and the
670/// probe costs the CLI a walk over its whole transcript. Bounded by [`probe`],
671/// the same limits a view applies to its own ask - except for the
672/// post-compaction ask, which may not be refused by the reading it exists to
673/// replace.
674///
675/// Both records below are `debug` lines and nothing else. The refusal is the one
676/// asserted, and it is emitted from the spawned fold rather than from anything a
677/// test drives - so the test that reads it back holds a subscriber across its
678/// awaits rather than calling the crate's `test_support::logged`, which runs a
679/// closure to completion on the caller's thread and cannot span the fold. The
680/// read path's own ask in `transport::wire` has the same record and no test.
681///
682/// That refusal is not only the no-agent case: the same arm carries a seat with
683/// no stamped session id and one whose session has closed, which is what
684/// `refresh_context_usage` refuses.
685fn request_context_usage(
686 state: &TransportState,
687 probes: &mut probe::ContextProbe,
688 update: &SessionUpdate,
689) {
690 let Some((key, why)) = context_ask(update) else {
691 return;
692 };
693 // A page has to be showing the seat for a turn's ask, because that one is
694 // speculative: the reading may never be drawn.
695 //
696 // **A settled compaction is not speculative - it is corrective, and it
697 // does not wait for a page.** The reading it invalidates is the core's,
698 // and every view reads that one, so a seat nobody is showing keeps a
699 // number that is wrong rather than merely old - and a page opened on it
700 // afterwards reads that number and is never asked, the read path guarding
701 // on a reading being present. Nothing else asks for it either: a turn's
702 // ask is refused by the reading the settle has yet to replace.
703 if why == Ask::AfterATurn && !Live::lock(&state.live).is_attached(key) {
704 return;
705 }
706 let now = Instant::now();
707 match why {
708 Ask::AfterATurn => {
709 if let Err(declined) = probes.admit(key, || state.surface.header(key).context, now) {
710 tracing::debug!(
711 event_name = "context_usage_refresh_skipped",
712 ?declined,
713 slot = %key.display(),
714 "a turn ended on a seat a client shows and its context reading was not asked \
715 for",
716 );
717 return;
718 }
719 }
720 Ask::AfterACompaction => probes.force(key, now),
721 }
722 if let Err(error) = state.surface.refresh_context_usage(key) {
723 tracing::debug!(
724 event_name = "context_usage_request_failed",
725 %error,
726 slot = %key.display(),
727 "a seat a client shows was due a context reading and its probe was not requested",
728 );
729 }
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735
736 /// **The shipping keepalive is the pair the book states.** Those numbers
737 /// are what a client author reads, and the dead one is how fast a call on
738 /// a paused host fails, so a drift here must fail a test rather than
739 /// quietly change both.
740 #[test]
741 fn the_shipping_heartbeat_is_the_documented_pair() {
742 let heartbeat = Heartbeat::default();
743 assert_eq!(heartbeat.every, Duration::from_secs(15), "a ping every 15 s");
744 assert_eq!(heartbeat.dead, Duration::from_secs(60), "and 60 s without an answer");
745 // The book states this bound as four write bounds, and a healthy
746 // connection must never be reaped into a redial loop, so the
747 // relationship is pinned rather than left to drift with the prose.
748 assert_eq!(
749 heartbeat.wedge,
750 Duration::from_secs(240),
751 "and four write bounds before a wedge"
752 );
753 assert!(
754 heartbeat.wedge >= WRITE_BOUND * 4,
755 "the wedge bound must clear the longest bounded operation: {:?} against {:?}",
756 heartbeat.wedge,
757 WRITE_BOUND,
758 );
759 }
760
761 /// **The shipping write bound is the value the book states.** It is what
762 /// the book tells a client author about a peer that stopped reading, so a
763 /// drift must fail a test rather than quietly change it - and it must
764 /// clear the slowest frame measured (31.6 s) with room.
765 ///
766 /// The three claims are ordered value, floor, pairing: a run reports the
767 /// first that fails and the next run reports the next, which is enough for
768 /// a constant this small.
769 #[test]
770 fn the_shipping_write_bound_is_the_documented_value() {
771 assert_eq!(WRITE_BOUND, Duration::from_secs(60), "one frame, 60 s");
772 assert!(
773 WRITE_BOUND > Duration::from_secs_f64(31.6),
774 "the slowest frame measured must not trip the bound: {WRITE_BOUND:?}",
775 );
776 assert_eq!(
777 WRITE_BOUND,
778 Heartbeat::default().dead,
779 "the book says the two bounds match, so the mechanisms measure a dead peer on one \
780 scale",
781 );
782 }
783}