Skip to main content

protocol_droid/domain/session/
progress.rs

1//! Progress: the opaque token a request carries, and the cursor that guards it.
2//!
3//! # The token is the peer's, not ours
4//!
5//! The schema is unambiguous — `ProgressToken = string | number` in every
6//! revision from 2024-11-05 to 2026-07-28, and the value is described as
7//! opaque. This crate therefore never interprets it, never derives one, and
8//! never substitutes a value of its own: it is carried through the request, out
9//! onto each notification, and matched back. A token this side invented would
10//! be a token the peer cannot correlate, which is the one outcome the mechanism
11//! exists to prevent.
12//!
13//! # Why the cursor exists
14//!
15//! The specification says progress "should increase every time progress is
16//! made, even if the total is unknown". That is a SHOULD, so this side does not
17//! reject the request for violating it — it declines to emit the update. A peer
18//! that renders progress as a bar would otherwise be able to walk backwards
19//! because a handler recomputed a percentage, and the notification is
20//! out-of-band, so nothing upstream would notice.
21
22use core::fmt;
23use std::borrow::Cow;
24use std::sync::Arc;
25
26use serde_json::Value;
27
28/// The opaque handle a request carries to ask for progress updates.
29///
30/// # Why this is the second non-`Copy` newtype
31///
32/// [`crate::domain::RequestId`] is not `Copy` because it wraps an `Arc<str>`,
33/// and this does too, for the same reason: a progress token is stored on the
34/// in-flight entry when the request arrives and read again for every update
35/// until it completes, so a notification re-uses the request's allocation
36/// instead of making one. `RequestId`'s doc comment says it is the only such
37/// newtype; this is the one exception, and it is the same reason rather than a
38/// new one — a token duplicated by value is a correlation the peer did not ask
39/// for.
40#[derive(Debug, Clone, PartialEq, Eq, Hash)]
41pub enum ProgressToken {
42    /// A numeric token.
43    Number(i64),
44    /// A string token.
45    Text(Arc<str>),
46}
47
48impl ProgressToken {
49    /// A numeric token.
50    #[must_use]
51    pub const fn number(value: i64) -> Self {
52        Self::Number(value)
53    }
54
55    /// A string token.
56    #[must_use]
57    pub fn text(value: impl AsRef<str>) -> Self {
58        Self::Text(Arc::from(value.as_ref()))
59    }
60
61    /// Reads a token out of the JSON a request carried.
62    ///
63    /// The one place the wire shape is known, and it is the shape the vendored
64    /// schema states: a string or a number, nothing else. A boolean, an object
65    /// or a fractional number is not a `ProgressToken`, and returning `None`
66    /// for one is what lets the request proceed with no progress rather than
67    /// failing — the token is a request for updates, not a requirement for the
68    /// request to be valid.
69    #[must_use]
70    pub fn from_json(value: &Value) -> Option<Self> {
71        match value {
72            Value::String(text) => Some(Self::text(text)),
73            Value::Number(number) => number.as_i64().map(Self::number),
74            _ => None,
75        }
76    }
77
78    /// The token as JSON, for a codec to write back.
79    ///
80    /// A string is quoted, so the result pastes into a document without the
81    /// caller needing to know which arm it got. Borrowed rather than owned: the
82    /// codec serialises it into a buffer it already holds.
83    #[must_use]
84    pub fn to_json(&self) -> Cow<'_, str> {
85        match self {
86            Self::Number(value) => Cow::Owned(value.to_string()),
87            // `serde_json` rather than `{:?}`: the latter emits Rust escapes,
88            // and a token containing a control character would produce a
89            // document that is not JSON. The fallback is an empty JSON string
90            // because a lossy answer is recoverable and malformed JSON is not.
91            Self::Text(value) => {
92                serde_json::to_string(value.as_ref()).map_or(Cow::Borrowed("\"\""), Cow::Owned)
93            }
94        }
95    }
96}
97
98impl fmt::Display for ProgressToken {
99    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100        match self {
101            Self::Number(value) => write!(formatter, "{value}"),
102            Self::Text(value) => formatter.write_str(value),
103        }
104    }
105}
106
107/// One progress update to put on the wire.
108#[derive(Debug, Clone, PartialEq)]
109pub struct ProgressUpdate {
110    /// The token the request carried.
111    pub token: ProgressToken,
112    /// How far along it is. Increases monotonically.
113    pub progress: f64,
114    /// How far along it has to be, when the handler knows.
115    pub total: Option<f64>,
116    /// An optional description of the current step.
117    pub message: Option<Cow<'static, str>>,
118}
119
120impl ProgressUpdate {
121    /// An update with a progress value and nothing else.
122    #[must_use]
123    pub const fn new(token: ProgressToken, progress: f64) -> Self {
124        Self {
125            token,
126            progress,
127            total: None,
128            message: None,
129        }
130    }
131
132    /// How far along it has to be, when the handler knows.
133    ///
134    /// Left unset when the handler does not know the total, and the wire
135    /// carries no `total` at all.
136    #[must_use]
137    pub const fn with_total(mut self, total: f64) -> Self {
138        self.total = Some(total);
139        self
140    }
141
142    /// A description of the current step.
143    ///
144    /// Left unset when there is no description of the current step.
145    #[must_use]
146    pub fn with_message(mut self, message: impl Into<Cow<'static, str>>) -> Self {
147        self.message = Some(message.into());
148        self
149    }
150}
151
152/// The furthest progress reported for one request so far.
153///
154/// One per in-flight request, held on the in-flight entry, so it is dropped
155/// exactly when the request is — there is no table of stale cursors to age out.
156#[derive(Debug, Clone, Copy, PartialEq, Default)]
157pub struct ProgressCursor {
158    furthest: Option<f64>,
159}
160
161impl ProgressCursor {
162    /// A cursor that has reported nothing.
163    #[must_use]
164    pub const fn new() -> Self {
165        Self { furthest: None }
166    }
167
168    /// Records an update and says whether it should go on the wire.
169    ///
170    /// `false` for an update that does not move the cursor forward. The first
171    /// update always moves it, including one reporting `0.0`, because a request
172    /// that said "tell me about this" is owed at least one notification and a
173    /// zero-valued first update is how a handler says "started".
174    ///
175    /// `NaN` is refused rather than compared. It would fail every ordering
176    /// test — including the `is_finite` one a reader expects to find here — and
177    /// so would wedge the cursor at whatever it last reached, silently
178    /// suppressing every later update. A value that is not a number is not
179    /// progress.
180    #[must_use]
181    pub fn advance(&mut self, update: &ProgressUpdate) -> bool {
182        let progress = update.progress;
183        if !progress.is_finite() {
184            return false;
185        }
186        if let Some(furthest) = self.furthest
187            && progress <= furthest
188        {
189            return false;
190        }
191        self.furthest = Some(progress);
192        true
193    }
194
195    /// The furthest value reported so far.
196    #[must_use]
197    pub const fn furthest(&self) -> Option<f64> {
198        self.furthest
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use std::sync::Arc;
205
206    use super::{ProgressCursor, ProgressToken, ProgressUpdate};
207    use crate::domain::RequestId;
208
209    fn token() -> ProgressToken {
210        ProgressToken::number(1)
211    }
212
213    #[test]
214    fn the_first_update_always_moves_the_cursor() {
215        let mut cursor = ProgressCursor::new();
216        assert!(cursor.advance(&ProgressUpdate::new(token(), 0.0)));
217        assert_eq!(cursor.furthest(), Some(0.0));
218    }
219
220    /// "This should increase every time progress is made, even if the total is
221    /// unknown" — the clause the cursor exists to enforce.
222    #[test]
223    fn a_repeated_or_going_backwards_update_is_not_emitted() {
224        let mut cursor = ProgressCursor::new();
225        for (value, expected) in [
226            (1.0, true),  // the first update
227            (2.0, true),  // forward
228            (2.0, false), // repeated
229            (1.5, false), // backwards
230            (2.0, false), // back to the furthest
231            (3.0, true),  // forward past the furthest
232        ] {
233            let emitted = cursor.advance(&ProgressUpdate::new(token(), value));
234            assert_eq!(emitted, expected, "progress {value}");
235        }
236        assert_eq!(cursor.furthest(), Some(3.0));
237    }
238
239    #[test]
240    fn a_non_finite_progress_is_not_progress() {
241        let mut cursor = ProgressCursor::new();
242        assert!(!cursor.advance(&ProgressUpdate::new(token(), f64::NAN)));
243        assert!(!cursor.advance(&ProgressUpdate::new(token(), f64::INFINITY)));
244        assert_eq!(cursor.furthest(), None, "a refused value was recorded");
245        // The cursor is still usable, which is the point of refusing rather
246        // than wedging.
247        assert!(cursor.advance(&ProgressUpdate::new(token(), 1.0)));
248    }
249
250    #[test]
251    fn a_token_reads_out_of_every_shape_the_schema_allows() {
252        assert_eq!(
253            ProgressToken::from_json(&serde_json::json!("abc")),
254            Some(ProgressToken::text("abc"))
255        );
256        assert_eq!(
257            ProgressToken::from_json(&serde_json::json!(7)),
258            Some(ProgressToken::number(7))
259        );
260    }
261
262    /// Everything else is not a `ProgressToken`, and a request that carries one
263    /// of these still has to work — it just gets no progress.
264    #[test]
265    fn every_other_shape_is_not_a_token() {
266        for value in [
267            serde_json::json!(true),
268            serde_json::json!({ "a": 1 }),
269            serde_json::json!([1]),
270            serde_json::json!(null),
271            // A number outside i64, and a fractional one. The schema says
272            // `string | number`; a JSON number that will not fit the id type
273            // this crate uses for the same concept is refused rather than
274            // wrapped.
275            serde_json::json!(1.5),
276        ] {
277            assert_eq!(ProgressToken::from_json(&value), None, "{value}");
278        }
279    }
280
281    #[test]
282    fn a_token_writes_back_as_the_json_it_arrived_as() {
283        assert_eq!(ProgressToken::number(7).to_json(), "7");
284        assert_eq!(ProgressToken::text("abc").to_json(), "\"abc\"");
285    }
286
287    /// The DEL case from T09, in a different field: `{:?}` is Rust syntax, not
288    /// a legal JSON escape, and a token is written into a document verbatim.
289    #[test]
290    fn a_control_character_in_a_token_survives_into_json() {
291        let written = ProgressToken::text("a\u{7f}b").to_json().into_owned();
292        // DEL is legal unescaped inside a JSON string, so the document is not
293        // escaped — it has to *parse*, and parse back to the same character.
294        // A `{:?}`-based `to_json` would emit `a\u{7f}b`, which is Rust
295        // syntax and not JSON at all.
296        assert_eq!(
297            serde_json::from_str::<serde_json::Value>(&written).ok(),
298            Some(serde_json::json!("a\u{7f}b")),
299            "the written document should be JSON carrying the same character: {written:?}"
300        );
301    }
302
303    /// The two non-`Copy` newtypes are siblings for the same reason, and a
304    /// clone of either has to be a refcount bump rather than a copy.
305    #[test]
306    fn a_cloned_token_shares_its_allocation() {
307        let a = ProgressToken::text("shared");
308        let b = a.clone();
309        assert_matches::assert_matches!(
310            (&a, &b),
311            (ProgressToken::Text(left), ProgressToken::Text(right))
312                if Arc::ptr_eq(left, right),
313            "clone copied the string instead of sharing it"
314        );
315        // And it is usable as a key beside the other opaque handle.
316        let mut seen = std::collections::HashMap::new();
317        seen.insert(RequestId::text("req"), a);
318        assert_eq!(seen.len(), 1);
319    }
320
321    #[test]
322    fn total_and_message_are_optional_and_attach_once() {
323        let update = ProgressUpdate::new(token(), 3.0)
324            .with_total(10.0)
325            .with_message(String::from("reading"));
326        assert_eq!(update.total, Some(10.0));
327        assert_eq!(update.message.as_deref(), Some("reading"));
328    }
329}