Skip to main content

strop_ui_protocol/
client.rs

1//! The pure client state machine (0056 AR10): a readonly cache of the
2//! backend's published view plus the resynchronization rules. No IO, no
3//! process, no clock — the stdio transport lives in [`crate::driver`].
4//!
5//! Rules (AR09 §8):
6//! - A snapshot is valid against any prior state; it clears poisoning.
7//! - A delta is valid only against its `base`; anything else — a dropped
8//!   or reordered delta, a changed pane set, a foreign incarnation —
9//!   poisons the client until an explicit resync.
10//! - A poisoned client stops accepting actions. It retains its last
11//!   known view (known outcomes are never fabricated away) and recovers
12//!   only through a complete current snapshot.
13
14use crate::message::{
15    BackendInfo, EffectRequest, ServerMessage, ShutdownReason, ViewDelta, ViewSnapshot,
16};
17use crate::BaseStamp;
18
19/// Why the client needs a complete current snapshot.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ResyncReason {
22    /// A delta's base is not the applied generation (a publication was
23    /// dropped or reordered on the wire).
24    DeltaBaseMismatch { expected: u64, found: u64 },
25    /// A delta arrived from another backend incarnation.
26    IncarnationChanged { current: u64 },
27    /// The link closed; no further publications will arrive.
28    LinkLost,
29}
30
31/// What one applied server message changed.
32#[derive(Debug, Clone, PartialEq)]
33pub enum ClientEvent {
34    /// The view moved to this generation (snapshot or delta).
35    View { generation: u64 },
36    /// The client is poisoned; resync before acting again.
37    ResyncRequired { reason: ResyncReason },
38    /// The backend requests a host effect.
39    Effect { id: u64, effect: EffectRequest },
40    /// The backend is exiting.
41    Closed { reason: ShutdownReason },
42}
43
44/// Client-side misuse that no resync can fix.
45#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
46pub enum ClientError {
47    #[error("the client is poisoned; resync before acting")]
48    Poisoned,
49    #[error("no view has been published yet")]
50    NoView,
51}
52
53/// The readonly view cache and its resynchronization state.
54#[derive(Debug)]
55pub struct Client {
56    incarnation: u64,
57    view: Option<ViewSnapshot>,
58    poisoned: Option<ResyncReason>,
59}
60
61impl Client {
62    /// Construct from the handshake's backend identity.
63    pub fn new(backend: &BackendInfo) -> Self {
64        Self {
65            incarnation: backend.incarnation,
66            view: None,
67            poisoned: None,
68        }
69    }
70
71    /// The applied view generation (0 before the first publication).
72    pub fn generation(&self) -> u64 {
73        self.view.as_ref().map_or(0, |view| view.generation)
74    }
75
76    /// The cached view, if one has been published.
77    pub fn view(&self) -> Option<&ViewSnapshot> {
78        self.view.as_ref()
79    }
80
81    /// Why the client is poisoned, if it is.
82    pub fn poisoned(&self) -> Option<ResyncReason> {
83        self.poisoned
84    }
85
86    /// The base stamp for the next action. Poisoned clients and clients
87    /// that never saw a publication may not act (AR09: no optimistic
88    /// replay of uncertain state).
89    pub fn base_stamp(&self) -> Result<BaseStamp, ClientError> {
90        if self.poisoned.is_some() {
91            return Err(ClientError::Poisoned);
92        }
93        let view = self.view.as_ref().ok_or(ClientError::NoView)?;
94        Ok(BaseStamp {
95            incarnation: self.incarnation,
96            generation: view.generation,
97        })
98    }
99
100    /// Apply one server message to the cache. Control messages (`ack`,
101    /// `error`) carry no view state and are the transport's business;
102    /// they return no events here.
103    pub fn apply(&mut self, message: &ServerMessage) -> Vec<ClientEvent> {
104        match message {
105            ServerMessage::Snapshot { incarnation, view } => {
106                let mut events = Vec::new();
107                if *incarnation != self.incarnation {
108                    // The backend restarted: its snapshot is the truth,
109                    // but the identity change is observable.
110                    events.push(ClientEvent::ResyncRequired {
111                        reason: ResyncReason::IncarnationChanged {
112                            current: *incarnation,
113                        },
114                    });
115                    self.incarnation = *incarnation;
116                }
117                self.poisoned = None;
118                let generation = view.generation;
119                self.view = Some(view.clone());
120                events.push(ClientEvent::View { generation });
121                events
122            }
123            ServerMessage::Delta { incarnation, delta } => self.apply_delta(*incarnation, delta),
124            ServerMessage::Effect { id, effect } => vec![ClientEvent::Effect {
125                id: *id,
126                effect: effect.clone(),
127            }],
128            ServerMessage::Bye { reason } => {
129                self.poisoned = Some(ResyncReason::LinkLost);
130                vec![ClientEvent::Closed { reason: *reason }]
131            }
132            ServerMessage::Welcome { backend, .. } => {
133                if backend.incarnation != self.incarnation {
134                    self.incarnation = backend.incarnation;
135                    self.view = None;
136                    self.poisoned = None;
137                    vec![ClientEvent::ResyncRequired {
138                        reason: ResyncReason::IncarnationChanged {
139                            current: backend.incarnation,
140                        },
141                    }]
142                } else {
143                    Vec::new()
144                }
145            }
146            ServerMessage::Ack { .. } | ServerMessage::Error { .. } => Vec::new(),
147        }
148    }
149
150    /// Mark the link lost (transport EOF/error). The last known view is
151    /// retained; actions stop until an explicit recovery.
152    pub fn link_lost(&mut self) {
153        self.poisoned = Some(ResyncReason::LinkLost);
154    }
155
156    fn apply_delta(&mut self, incarnation: u64, delta: &ViewDelta) -> Vec<ClientEvent> {
157        let poison = |client: &mut Self, reason| {
158            client.poisoned = Some(reason);
159            vec![ClientEvent::ResyncRequired { reason }]
160        };
161        if incarnation != self.incarnation {
162            return poison(
163                self,
164                ResyncReason::IncarnationChanged {
165                    current: incarnation,
166                },
167            );
168        }
169        if self.poisoned.is_some() {
170            // Already poisoned: only a snapshot recovers.
171            return Vec::new();
172        }
173        // Validate against immutable facts first; the mutable fold runs
174        // only after every poison check has passed.
175        let Some((generation, pane_count)) = self
176            .view
177            .as_ref()
178            .map(|view| (view.generation, view.panes.len()))
179        else {
180            return poison(
181                self,
182                ResyncReason::DeltaBaseMismatch {
183                    expected: 0,
184                    found: delta.base,
185                },
186            );
187        };
188        if delta.base != generation || delta.panes.len() != pane_count {
189            // A delta applies only onto its exact base, aligned with the
190            // base's pane set.
191            return poison(
192                self,
193                ResyncReason::DeltaBaseMismatch {
194                    expected: generation,
195                    found: delta.base,
196                },
197            );
198        }
199        let Some(view) = self.view.as_mut() else {
200            unreachable!("the view was present for validation above");
201        };
202        for (pane, change) in view.panes.iter_mut().zip(&delta.panes) {
203            if let crate::message::PaneDelta::Changed(changed) = change {
204                *pane = changed.clone();
205            }
206        }
207        if let Some(geometry) = delta.geometry {
208            view.geometry = geometry;
209        }
210        if let Some(active_pane) = delta.active_pane {
211            view.active_pane = active_pane;
212        }
213        if let Some(state) = &delta.state {
214            view.state = state.clone();
215        }
216        view.generation = delta.generation;
217        vec![ClientEvent::View {
218            generation: delta.generation,
219        }]
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::message::{Geometry, PaneDelta, PaneSnapshot, ViewBounds};
227
228    fn backend(incarnation: u64) -> BackendInfo {
229        BackendInfo {
230            name: "strop".into(),
231            version: "0.0.0".into(),
232            build: None,
233            incarnation,
234        }
235    }
236
237    fn pane(lines: &[&str]) -> PaneSnapshot {
238        PaneSnapshot {
239            document: serde_json::from_value(serde_json::json!({"slot":0,"generation":0})).unwrap(),
240            revision: strop_core::id::BufferRevision::new(0),
241            bounds: ViewBounds::Complete,
242            cursor: 0,
243            view_top: 0,
244            hscroll: 0,
245            terminal_input: false,
246            overlays: false,
247            rect: Default::default(),
248            budget: Default::default(),
249            window_top: 0,
250            lines: lines.iter().map(|line| line.to_string()).collect(),
251        }
252    }
253
254    fn snapshot(generation: u64, lines: &[&str]) -> ViewSnapshot {
255        ViewSnapshot {
256            generation,
257            geometry: Geometry {
258                columns: 80,
259                rows: 24,
260            },
261            active_pane: 0,
262            panes: vec![pane(lines)],
263            state: serde_json::json!({"mode":"NORMAL"}),
264        }
265    }
266
267    fn delta(base: u64, generation: u64, lines: &[&str]) -> ViewDelta {
268        ViewDelta {
269            base,
270            generation,
271            geometry: None,
272            active_pane: None,
273            panes: vec![PaneDelta::Changed(pane(lines))],
274            state: None,
275        }
276    }
277
278    #[test]
279    fn snapshot_then_deltas_apply_in_order() {
280        let mut client = Client::new(&backend(1));
281        client.apply(&ServerMessage::Snapshot {
282            incarnation: 1,
283            view: snapshot(1, &["a"]),
284        });
285        assert_eq!(client.generation(), 1);
286        client.apply(&ServerMessage::Delta {
287            incarnation: 1,
288            delta: delta(1, 2, &["ab"]),
289        });
290        assert_eq!(client.generation(), 2);
291        assert_eq!(client.view().unwrap().panes[0].lines, ["ab"]);
292        assert!(client.poisoned().is_none());
293        assert_eq!(
294            client.base_stamp().unwrap(),
295            BaseStamp {
296                incarnation: 1,
297                generation: 2
298            }
299        );
300    }
301
302    #[test]
303    fn a_dropped_delta_poisons_until_resync() {
304        let mut client = Client::new(&backend(1));
305        client.apply(&ServerMessage::Snapshot {
306            incarnation: 1,
307            view: snapshot(1, &["a"]),
308        });
309        // The 1→2 delta is dropped; 2→3 arrives against an unknown base.
310        let events = client.apply(&ServerMessage::Delta {
311            incarnation: 1,
312            delta: delta(2, 3, &["abc"]),
313        });
314        assert_eq!(
315            events,
316            vec![ClientEvent::ResyncRequired {
317                reason: ResyncReason::DeltaBaseMismatch {
318                    expected: 1,
319                    found: 2
320                }
321            }]
322        );
323        assert_eq!(client.generation(), 1, "never applied onto a wrong base");
324        assert_eq!(
325            client.base_stamp().unwrap_err(),
326            ClientError::Poisoned,
327            "actions stop after the drop"
328        );
329        // Further deltas cannot unpoison; only a snapshot can.
330        client.apply(&ServerMessage::Delta {
331            incarnation: 1,
332            delta: delta(3, 4, &["abcd"]),
333        });
334        assert!(client.poisoned().is_some());
335        client.apply(&ServerMessage::Snapshot {
336            incarnation: 1,
337            view: snapshot(4, &["abcd"]),
338        });
339        assert!(client.poisoned().is_none());
340        assert_eq!(client.view().unwrap().panes[0].lines, ["abcd"]);
341    }
342
343    #[test]
344    fn a_foreign_incarnation_poisons_deltas() {
345        let mut client = Client::new(&backend(1));
346        client.apply(&ServerMessage::Snapshot {
347            incarnation: 1,
348            view: snapshot(1, &["a"]),
349        });
350        let events = client.apply(&ServerMessage::Delta {
351            incarnation: 2,
352            delta: delta(1, 2, &["ab"]),
353        });
354        assert_eq!(
355            events,
356            vec![ClientEvent::ResyncRequired {
357                reason: ResyncReason::IncarnationChanged { current: 2 }
358            }]
359        );
360        assert_eq!(client.generation(), 1);
361    }
362
363    #[test]
364    fn a_pane_set_mismatch_poisons() {
365        let mut client = Client::new(&backend(1));
366        client.apply(&ServerMessage::Snapshot {
367            incarnation: 1,
368            view: snapshot(1, &["a"]),
369        });
370        let mut two_panes = delta(1, 2, &["ab"]);
371        two_panes.panes.push(PaneDelta::Unchanged);
372        client.apply(&ServerMessage::Delta {
373            incarnation: 1,
374            delta: two_panes,
375        });
376        assert!(client.poisoned().is_some());
377        assert_eq!(client.generation(), 1);
378    }
379
380    #[test]
381    fn bye_stops_actions_but_retains_the_known_view() {
382        let mut client = Client::new(&backend(1));
383        client.apply(&ServerMessage::Snapshot {
384            incarnation: 1,
385            view: snapshot(1, &["a"]),
386        });
387        client.apply(&ServerMessage::Bye {
388            reason: ShutdownReason::Requested,
389        });
390        assert_eq!(client.poisoned(), Some(ResyncReason::LinkLost));
391        assert_eq!(
392            client.view().unwrap().panes[0].lines,
393            ["a"],
394            "known outcomes are retained"
395        );
396        assert_eq!(client.base_stamp().unwrap_err(), ClientError::Poisoned);
397    }
398
399    /// Property (seeded, deterministic): over random publication
400    /// sequences with random drops, the client applies a delta only onto
401    /// its exact base, poisons on the first gap, stays poisoned through
402    /// further deltas, and a snapshot always recovers to the published
403    /// truth.
404    #[test]
405    fn random_publication_sequences_never_diverge() {
406        // Knuth LCG, the repo's deterministic-generator precedent.
407        struct Lcg(u64);
408        impl Lcg {
409            fn next(&mut self) -> u64 {
410                self.0 = self
411                    .0
412                    .wrapping_mul(6364136223846793005)
413                    .wrapping_add(1442695040888963407);
414                self.0 >> 33
415            }
416            fn below(&mut self, n: u64) -> u64 {
417                self.next() % n
418            }
419        }
420
421        for seed in 0..64u64 {
422            let mut rand = Lcg(seed.wrapping_mul(0x9E3779B97F4A7C15) | 1);
423            let mut client = Client::new(&backend(7));
424            // The reference model: the truth the backend published.
425            let mut truth: Option<(u64, Vec<String>)> = None;
426            let mut poisoned_reference = true; // no view yet
427            for step in 0..40u64 {
428                let generation = step / 2 + 1;
429                let text = format!("s{seed}g{generation}");
430                let message = if step % 2 == 0 || truth.is_none() {
431                    truth = Some((generation, vec![text.clone()]));
432                    poisoned_reference = false;
433                    ServerMessage::Snapshot {
434                        incarnation: 7,
435                        view: snapshot(generation, &[&text]),
436                    }
437                } else {
438                    let (base, _) = truth.clone().unwrap();
439                    truth = Some((generation, vec![text.clone()]));
440                    // Randomly drop the publication (client never sees it)
441                    // or deliver a delta against the published base.
442                    if rand.below(3) == 0 {
443                        poisoned_reference = true;
444                        continue; // dropped on the wire
445                    }
446                    ServerMessage::Delta {
447                        incarnation: 7,
448                        delta: delta(base, generation, &[&text]),
449                    }
450                };
451                client.apply(&message);
452                match (&client.view().cloned(), &truth) {
453                    (Some(view), Some((_, lines))) if !poisoned_reference => {
454                        assert_eq!(&view.panes[0].lines, lines, "seed {seed} step {step}");
455                        assert!(client.poisoned().is_none());
456                    }
457                    _ => {
458                        if poisoned_reference {
459                            assert!(
460                                client.poisoned().is_some() || client.view().is_none(),
461                                "seed {seed} step {step}: a gap must poison"
462                            );
463                        }
464                    }
465                }
466                // Invariant at every step: the cached generation is always
467                // one the backend actually published, reached by an intact
468                // chain from a snapshot.
469                if let Some(view) = client.view() {
470                    assert!(view.generation <= generation);
471                    if client.poisoned().is_none() {
472                        assert_eq!(Some(view.generation), truth.as_ref().map(|t| t.0));
473                    }
474                }
475            }
476        }
477    }
478}