protocol_droid/adapters/primary/transport/http/exchange.rs
1//! Server-initiated requests over Streamable HTTP — sampling, elicitation.
2//!
3//! # Why this is more than "write the request onto the tail"
4//!
5//! A server-initiated request is the one message shape in the protocol that
6//! does not terminate. A tool asks the peer a question and then waits; the
7//! answer arrives on a *later* `POST`, as a JSON-RPC **response** — a message
8//! with an id and no method.
9//!
10//! So there are two halves and neither is optional. The server writes
11//! `sampling/createMessage` onto the stream the in-flight `tools/call` opened,
12//! and the router has to recognise the peer's answer when it arrives rather than
13//! dispatching it as a request of its own. Without the recognition the answer
14//! fails to find a method, comes back as a JSON-RPC error, and the tool keeps
15//! waiting for an answer the server has already decided does not exist. The
16//! symptom the peer sees is "the server did not wait for my answer", which is
17//! exactly backwards.
18//!
19//! # Why the table is keyed by request id and nothing else
20//!
21//! The id is the only thing the peer's answer echoes, so it is the only thing a
22//! correlation can rest on. A `result` and an `error` with the same id are both
23//! answers — the difference is in the body, and the body is what the waiter
24//! receives — so the entry is not split by outcome.
25//!
26//! Ids are minted from a per-table counter rather than derived from the request
27//! being answered. A server-initiated request is a request *this* server made,
28//! so it has its own id space: reusing the peer's would collide on every
29//! `tools/call` whose id happened to match.
30//!
31//! # Why an unknown id is not an error
32//!
33//! JSON-RPC says an answer to an unknown request is discarded, and this server
34//! cannot tell "the tool already gave up" from "the peer answered twice" from
35//! "that id was never ours". All three are silently dropped, because the
36//! alternative is inventing a request to attach the answer to.
37//!
38//! # Why a dropped waiter is a named refusal
39//!
40//! A client that never answers is a client that walked away, and this layer has
41//! no way to tell that from one that is still thinking — so the waiter waits,
42//! bounded by its session. When the session ends the table goes with it and
43//! nothing leaks. A timeout here would be a policy decision this layer has no
44//! standing to make.
45
46use std::collections::HashMap;
47use std::sync::Arc;
48use std::sync::Mutex;
49use std::sync::MutexGuard;
50use std::sync::PoisonError;
51use std::sync::atomic::AtomicBool;
52use std::sync::atomic::AtomicI64;
53use std::sync::atomic::Ordering;
54
55use serde_json::Value;
56use tokio::sync::oneshot;
57
58use crate::adapters::primary::transport::http::stream::EventTail;
59use crate::application::server::context::{CallbackExchange, ExchangeError};
60use crate::domain::ir::JsonObject;
61
62/// The stream a session's server-initiated messages go out on.
63type Tail = EventTail<Value>;
64
65/// The table of server-initiated requests a session is waiting on.
66///
67/// Cheap to clone: a session's exchange and the router answering its requests
68/// are two handles to one table, never two tables. That matters because a
69/// request and the answer completing it must land on the same session, and
70/// sharing is what makes that structural rather than a convention.
71#[derive(Debug, Clone, Default)]
72pub struct ExchangeTable {
73 /// The waiters, and the counter they draw ids from.
74 ///
75 /// One mutex over both rather than a `Mutex<HashMap>` plus a separate
76 /// atomic: minting an id and registering under it is one step, and two
77 /// steps would leave a window where the id is reserved but unclaimed.
78 inner: Arc<Mutex<Table>>,
79 /// Whether anything is draining the session's tail.
80 ///
81 /// A server answering a `POST` with one JSON document is not draining the
82 /// tail, because there is no body left to drain it into. A tool that
83 /// elicits on such a server pushes its request into the ring and then
84 /// waits for an answer that has nowhere to be read, which is a hang with
85 /// no error and no timeout — the worst shape a protocol defect can take.
86 ///
87 /// So this is checked before the wait rather than left to be discovered by
88 /// the peer, and the check answers with a refusal naming the reason.
89 /// Outside the mutex because it never changes after construction.
90 readable: Arc<AtomicBool>,
91}
92
93/// The first id a session's server-initiated requests are minted from.
94///
95/// Above every id the peer is likely to use. Not a range with a documented
96/// ceiling, because JSON-RPC gives an id no bound to exceed — what matters is
97/// that this space and the peer's do not meet, and starting a peer at `1` is
98/// the only claim either side can make about what the other will reach.
99const ID_ORIGIN: i64 = 1 << 40;
100
101/// What the table holds.
102#[derive(Debug)]
103struct Table {
104 /// Outstanding requests, by the id the peer will echo.
105 waiters: HashMap<i64, oneshot::Sender<Answer>>,
106 /// The next id to hand out, starting at [`ID_ORIGIN`].
107 next_id: AtomicI64,
108}
109
110/// What a peer's answer carries: the peer's own result, or its own error.
111pub type Answer = Result<JsonObject, ExchangeError>;
112
113impl Default for Table {
114 fn default() -> Self {
115 Self {
116 waiters: HashMap::new(),
117 next_id: AtomicI64::new(ID_ORIGIN),
118 }
119 }
120}
121
122impl ExchangeTable {
123 /// A table with nothing in flight and nothing reading the tail.
124 ///
125 /// The unreadable default is the safe one. A session that has not said it
126 /// is served as a stream has not established that anything is reading, so
127 /// the honest answer to "may I ask the peer a question?" is no.
128 #[must_use]
129 pub fn new() -> Self {
130 Self::default()
131 }
132
133 /// A table whose session's tail this server drains.
134 ///
135 /// Called once, when a session is opened against a server configured to
136 /// answer with an event stream. Every later [`Self::new`] on the same
137 /// handle — and there are none, this is the only constructor that sets it
138 /// — inherits the answer.
139 #[must_use]
140 pub fn readable() -> Self {
141 Self {
142 readable: Arc::new(AtomicBool::new(true)),
143 ..Self::default()
144 }
145 }
146
147 /// Whether a server-initiated request can be asked and answered here.
148 #[must_use]
149 pub fn is_readable(&self) -> bool {
150 self.readable.load(Ordering::Relaxed)
151 }
152
153 /// Records a waiter under a fresh id, returning it.
154 ///
155 /// The id is minted here rather than by the caller so registering a waiter
156 /// and claiming its id cannot come apart.
157 ///
158 /// It starts at [`ID_ORIGIN`] rather than `1`, and that is load-bearing in
159 /// a way that is invisible until it is wrong. The session engine keeps its
160 /// own in-flight map keyed by the *peer's* request ids, and a minted
161 /// server-initiated id that overlaps that space makes the engine report
162 /// "request 7 is already in flight" for a request the peer never sent twice.
163 /// The two id spaces share one JSON-RPC channel, so they have to be
164 /// disjoint even though nothing else forces them to be.
165 fn register(&self, sender: oneshot::Sender<Answer>) -> i64 {
166 let mut table = self.lock();
167 let id = table.next_id.fetch_add(1, Ordering::Relaxed);
168 table.waiters.insert(id, sender);
169 id
170 }
171
172 /// Hands a peer's answer to whoever is waiting for `id`.
173 ///
174 /// `false` when the id is unknown — a reply to a request that already
175 /// completed, was refused, or never existed. All three are the same from
176 /// here and all three are correctly dropped.
177 pub fn resolve(&self, id: i64, answer: Answer) -> bool {
178 let Some(waiter) = self.lock().waiters.remove(&id) else {
179 return false;
180 };
181 waiter.send(answer).is_ok()
182 }
183
184 /// How many requests are outstanding.
185 ///
186 /// For a diagnostics line and a test. Taking the lock is why this is not on
187 /// any per-frame path.
188 #[must_use]
189 pub fn outstanding(&self) -> usize {
190 self.lock().waiters.len()
191 }
192
193 /// The lock, recovered from a poisoned one.
194 ///
195 /// Recovered rather than propagated: the values held here are channels
196 /// whose only failure mode is "the peer never answered", and a panic in an
197 /// unrelated task must not turn one dropped connection into a refusal of
198 /// every subsequent request on the session.
199 fn lock(&self) -> MutexGuard<'_, Table> {
200 self.inner.lock().unwrap_or_else(PoisonError::into_inner)
201 }
202}
203
204/// The exchange handed to a session's [`PeerSession`](crate::application::server::context::PeerSession).
205///
206/// Holds the table it records into and the stream it writes to. Two handles to
207/// one session, kept together so a request and the answer that completes it
208/// cannot end up on different sessions.
209#[derive(Clone)]
210pub struct HttpExchange {
211 /// Where in-flight requests are recorded.
212 table: ExchangeTable,
213 /// Writes a request onto the session's event stream.
214 publish: Publisher,
215}
216
217/// How a request reaches the session's stream. A closure rather than a tail
218/// because the legacy transport's stream carries a different frame type, and
219/// the rendezvous with the answer is the same for both.
220type Publisher = std::sync::Arc<dyn Fn(Value) + Send + Sync>;
221
222impl core::fmt::Debug for HttpExchange {
223 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
224 f.debug_struct("HttpExchange")
225 .field("table", &self.table)
226 .finish_non_exhaustive()
227 }
228}
229
230impl HttpExchange {
231 /// An exchange publishing into `tail` and recording into `table`.
232 #[must_use]
233 pub fn new(table: ExchangeTable, tail: Tail) -> Self {
234 Self::publishing(table, move |request| {
235 tail.push(request);
236 })
237 }
238
239 /// An exchange that hands each request to `publish` and records into
240 /// `table`.
241 #[must_use]
242 pub fn publishing(
243 table: ExchangeTable,
244 publish: impl Fn(Value) + Send + Sync + 'static,
245 ) -> Self {
246 Self {
247 table,
248 publish: std::sync::Arc::new(publish),
249 }
250 }
251
252 /// The table this session's answers are routed back through.
253 #[must_use]
254 pub const fn table(&self) -> &ExchangeTable {
255 &self.table
256 }
257}
258
259impl CallbackExchange for HttpExchange {
260 /// Publishes a server-initiated request and waits for its answer.
261 ///
262 /// The wait is the part a `POST`-shaped transport makes awkward: the
263 /// request goes out on the stream the *current* request opened, and the
264 /// answer comes back on a later `POST` this future does not own. The table
265 /// is the rendezvous — this side parks on a oneshot, and
266 /// [`ExchangeTable::resolve`] completes it from whichever task happens to be
267 /// reading the peer's next request.
268 async fn exchange(
269 &self,
270 method: &str,
271 params: &crate::domain::ir::JsonObject,
272 ) -> Result<crate::domain::ir::JsonObject, ExchangeError> {
273 // Asked before anything is pushed or waited on. Pushing first would
274 // leave a request in the ring that the peer never sees and a wait on a
275 // channel that can only be closed by the session ending, which reads
276 // from the outside as a server that stopped responding. Refusing here
277 // gives the tool an error it can put in its result, so the peer is
278 // told "this cannot be asked here" instead of waiting for an answer
279 // that was never on its way.
280 if !self.table.is_readable() {
281 return Err(ExchangeError::new(
282 "this server answers with a single JSON document, so a tool \
283 cannot ask the peer a question and wait: set \
284 Streaming::EventStream to offer elicitation or sampling",
285 ));
286 }
287 let (sender, receiver) = oneshot::channel();
288 let id = self.table.register(sender);
289
290 let mut request = serde_json::Map::new();
291 request.insert("jsonrpc".to_owned(), Value::from("2.0"));
292 request.insert("id".to_owned(), Value::from(id));
293 request.insert("method".to_owned(), Value::from(method));
294 // `params` is copied through verbatim: it was built by the caller as a
295 // `JsonObject`, so `as_str` is already the member text and there is
296 // one parse of it in the process rather than a round trip through
297 // `Value` that would only be re-parsed on the way out.
298 request.insert(
299 "params".to_owned(),
300 serde_json::from_str::<Value>(params.as_str())
301 .map_err(|_| ExchangeError::new("the request's params were not JSON"))?,
302 );
303 // A `push` cannot fail — it evicts the oldest event if the ring is
304 // full — so there is no refusal to write here and inventing one would
305 // be a failure this transport cannot actually have.
306 (self.publish)(Value::Object(request));
307
308 // A dropped waiter means the session ended under the tool, which the
309 // tool sees as a refusal it can handle — so it is named rather than
310 // left as a bare channel error.
311 receiver
312 .await
313 .map_err(|_| ExchangeError::new("the session ended before the peer answered"))?
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::{ExchangeTable, HttpExchange};
320 use crate::adapters::primary::transport::http::stream::EventTail;
321 use crate::application::server::context::CallbackExchange;
322 use crate::domain::ir::JsonObject;
323
324 type TestError = Box<dyn std::error::Error>;
325
326 /// A ring big enough for the handful of events these tests push.
327 fn capacity() -> Result<std::num::NonZeroUsize, TestError> {
328 Ok(4.try_into()?)
329 }
330
331 /// An unreadable exchange refuses, and the refusal names the setting that
332 /// would make it possible.
333 ///
334 /// The wording is the point as much as the refusal. A tool that cannot ask
335 /// its question reports the reason back to whoever called it, and
336 /// "something went wrong" sends that caller looking in the wrong place —
337 /// the tool, the client, the peer — when the answer is one setting on one
338 /// server.
339 #[tokio::test]
340 async fn an_unreadable_exchange_names_the_setting() -> Result<(), TestError> {
341 let exchange = HttpExchange::new(ExchangeTable::new(), EventTail::new(capacity()?));
342 let params = JsonObject::new("{}")?;
343
344 let Err(error) = exchange.exchange("elicitation/create", ¶ms).await else {
345 return Err("an exchange with no reader was answered".into());
346 };
347 let message = error.to_string();
348 if !message.contains("EventStream") {
349 return Err(format!("the refusal does not name the setting: {message}").into());
350 }
351 Ok(())
352 }
353
354 /// Nothing is pushed and nothing is left waiting when the exchange is
355 /// refused.
356 ///
357 /// The refusal has to happen *before* the register-and-push, or the
358 /// request sits in the ring the peer will never read and the table holds a
359 /// waiter nothing can complete — the same leak, one step later.
360 #[tokio::test]
361 async fn a_refused_exchange_leaves_nothing_in_flight() -> Result<(), TestError> {
362 let table = ExchangeTable::new();
363 let exchange = HttpExchange::new(table.clone(), EventTail::new(capacity()?));
364 let params = JsonObject::new("{}")?;
365
366 let _ = exchange.exchange("elicitation/create", ¶ms).await;
367
368 if table.outstanding() != 0 {
369 return Err("a refused exchange left a waiter behind".into());
370 }
371 Ok(())
372 }
373
374 /// A readable exchange still registers a waiter, so the refusal above is
375 /// about the mode and not about the plumbing being broken.
376 #[tokio::test]
377 async fn a_readable_exchange_registers_a_waiter() -> Result<(), TestError> {
378 let table = ExchangeTable::readable();
379 let exchange = HttpExchange::new(table.clone(), EventTail::new(capacity()?));
380 let params = JsonObject::new("{}")?;
381
382 // The exchange is left pending rather than awaited: resolving it needs
383 // the peer's answer, which is the point of the table.
384 let pending = exchange.exchange("elicitation/create", ¶ms);
385 tokio::pin!(pending);
386 let _ = tokio::time::timeout(std::time::Duration::from_millis(50), &mut pending).await;
387
388 if table.outstanding() != 1 {
389 return Err("a readable exchange registered no waiter".into());
390 }
391
392 // A second question is asked only to prove the first one is still
393 // outstanding: two waiters means the table did not answer the first
394 // from its own ring. It is polled rather than awaited — an exchange
395 // resolves when the *peer* answers, and nothing here plays the peer,
396 // so awaiting it is a hang rather than an assertion.
397 let second = exchange.exchange("elicitation/create", ¶ms);
398 tokio::pin!(second);
399 let answered =
400 tokio::time::timeout(std::time::Duration::from_millis(50), &mut second).await;
401 if answered.is_ok() {
402 return Err("an exchange answered without the peer".into());
403 }
404 if table.outstanding() != 2 {
405 return Err("the second exchange registered no waiter of its own".into());
406 }
407 Ok(())
408 }
409
410 /// The default is unreadable, which is the direction that fails safe.
411 #[test]
412 fn the_default_table_is_unreadable() {
413 assert!(!ExchangeTable::new().is_readable());
414 assert!(ExchangeTable::readable().is_readable());
415 }
416}