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}