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    /// Sets the total.
133    #[must_use]
134    pub const fn with_total(mut self, total: f64) -> Self {
135        self.total = Some(total);
136        self
137    }
138
139    /// Sets the message.
140    #[must_use]
141    pub fn with_message(mut self, message: impl Into<Cow<'static, str>>) -> Self {
142        self.message = Some(message.into());
143        self
144    }
145}
146
147/// The furthest progress reported for one request so far.
148///
149/// One per in-flight request, held on the in-flight entry, so it is dropped
150/// exactly when the request is — there is no table of stale cursors to age out.
151#[derive(Debug, Clone, Copy, PartialEq, Default)]
152pub struct ProgressCursor {
153    furthest: Option<f64>,
154}
155
156impl ProgressCursor {
157    /// A cursor that has reported nothing.
158    #[must_use]
159    pub const fn new() -> Self {
160        Self { furthest: None }
161    }
162
163    /// Records an update and says whether it should go on the wire.
164    ///
165    /// `false` for an update that does not move the cursor forward. The first
166    /// update always moves it, including one reporting `0.0`, because a request
167    /// that said "tell me about this" is owed at least one notification and a
168    /// zero-valued first update is how a handler says "started".
169    ///
170    /// `NaN` is refused rather than compared. It would fail every ordering
171    /// test — including the `is_finite` one a reader expects to find here — and
172    /// so would wedge the cursor at whatever it last reached, silently
173    /// suppressing every later update. A value that is not a number is not
174    /// progress.
175    #[must_use]
176    pub fn advance(&mut self, update: &ProgressUpdate) -> bool {
177        let progress = update.progress;
178        if !progress.is_finite() {
179            return false;
180        }
181        if let Some(furthest) = self.furthest
182            && progress <= furthest
183        {
184            return false;
185        }
186        self.furthest = Some(progress);
187        true
188    }
189
190    /// The furthest value reported so far.
191    #[must_use]
192    pub const fn furthest(&self) -> Option<f64> {
193        self.furthest
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use std::sync::Arc;
200
201    use super::{ProgressCursor, ProgressToken, ProgressUpdate};
202    use crate::domain::RequestId;
203
204    fn token() -> ProgressToken {
205        ProgressToken::number(1)
206    }
207
208    #[test]
209    fn the_first_update_always_moves_the_cursor() {
210        let mut cursor = ProgressCursor::new();
211        assert!(cursor.advance(&ProgressUpdate::new(token(), 0.0)));
212        assert_eq!(cursor.furthest(), Some(0.0));
213    }
214
215    /// "This should increase every time progress is made, even if the total is
216    /// unknown" — the clause the cursor exists to enforce.
217    #[test]
218    fn a_repeated_or_going_backwards_update_is_not_emitted() {
219        let mut cursor = ProgressCursor::new();
220        for (value, expected) in [
221            (1.0, true),  // the first update
222            (2.0, true),  // forward
223            (2.0, false), // repeated
224            (1.5, false), // backwards
225            (2.0, false), // back to the furthest
226            (3.0, true),  // forward past the furthest
227        ] {
228            let emitted = cursor.advance(&ProgressUpdate::new(token(), value));
229            assert_eq!(emitted, expected, "progress {value}");
230        }
231        assert_eq!(cursor.furthest(), Some(3.0));
232    }
233
234    #[test]
235    fn a_non_finite_progress_is_not_progress() {
236        let mut cursor = ProgressCursor::new();
237        assert!(!cursor.advance(&ProgressUpdate::new(token(), f64::NAN)));
238        assert!(!cursor.advance(&ProgressUpdate::new(token(), f64::INFINITY)));
239        assert_eq!(cursor.furthest(), None, "a refused value was recorded");
240        // The cursor is still usable, which is the point of refusing rather
241        // than wedging.
242        assert!(cursor.advance(&ProgressUpdate::new(token(), 1.0)));
243    }
244
245    #[test]
246    fn a_token_reads_out_of_every_shape_the_schema_allows() {
247        assert_eq!(
248            ProgressToken::from_json(&serde_json::json!("abc")),
249            Some(ProgressToken::text("abc"))
250        );
251        assert_eq!(
252            ProgressToken::from_json(&serde_json::json!(7)),
253            Some(ProgressToken::number(7))
254        );
255    }
256
257    /// Everything else is not a `ProgressToken`, and a request that carries one
258    /// of these still has to work — it just gets no progress.
259    #[test]
260    fn every_other_shape_is_not_a_token() {
261        for value in [
262            serde_json::json!(true),
263            serde_json::json!({ "a": 1 }),
264            serde_json::json!([1]),
265            serde_json::json!(null),
266            // A number outside i64, and a fractional one. The schema says
267            // `string | number`; a JSON number that will not fit the id type
268            // this crate uses for the same concept is refused rather than
269            // wrapped.
270            serde_json::json!(1.5),
271        ] {
272            assert_eq!(ProgressToken::from_json(&value), None, "{value}");
273        }
274    }
275
276    #[test]
277    fn a_token_writes_back_as_the_json_it_arrived_as() {
278        assert_eq!(ProgressToken::number(7).to_json(), "7");
279        assert_eq!(ProgressToken::text("abc").to_json(), "\"abc\"");
280    }
281
282    /// The DEL case from T09, in a different field: `{:?}` is Rust syntax, not
283    /// a legal JSON escape, and a token is written into a document verbatim.
284    #[test]
285    fn a_control_character_in_a_token_survives_into_json() {
286        let written = ProgressToken::text("a\u{7f}b").to_json().into_owned();
287        // DEL is legal unescaped inside a JSON string, so the document is not
288        // escaped — it has to *parse*, and parse back to the same character.
289        // A `{:?}`-based `to_json` would emit `a\u{7f}b`, which is Rust
290        // syntax and not JSON at all.
291        assert_eq!(
292            serde_json::from_str::<serde_json::Value>(&written).ok(),
293            Some(serde_json::json!("a\u{7f}b")),
294            "the written document should be JSON carrying the same character: {written:?}"
295        );
296    }
297
298    /// The two non-`Copy` newtypes are siblings for the same reason, and a
299    /// clone of either has to be a refcount bump rather than a copy.
300    #[test]
301    fn a_cloned_token_shares_its_allocation() {
302        let a = ProgressToken::text("shared");
303        let b = a.clone();
304        assert_matches::assert_matches!(
305            (&a, &b),
306            (ProgressToken::Text(left), ProgressToken::Text(right))
307                if Arc::ptr_eq(left, right),
308            "clone copied the string instead of sharing it"
309        );
310        // And it is usable as a key beside the other opaque handle.
311        let mut seen = std::collections::HashMap::new();
312        seen.insert(RequestId::text("req"), a);
313        assert_eq!(seen.len(), 1);
314    }
315
316    #[test]
317    fn total_and_message_are_optional_and_attach_once() {
318        let update = ProgressUpdate::new(token(), 3.0)
319            .with_total(10.0)
320            .with_message(String::from("reading"));
321        assert_eq!(update.total, Some(10.0));
322        assert_eq!(update.message.as_deref(), Some("reading"));
323    }
324}