Skip to main content

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", &params).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", &params).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", &params);
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", &params);
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}