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}