Skip to main content

iroh_http_core/ffi/
session.rs

1//! `Session` — a QUIC connection to a single remote peer.
2//!
3//! [`Session::connect`] establishes (or accepts via [`Session::accept`]) a QUIC
4//! connection and returns a session value backed by an opaque handle.
5//! Bidirectional streams, unidirectional streams, and datagrams are all
6//! accessible through methods on [`Session`].
7//!
8//! The shape mirrors W3C WebTransport: every method has a direct counterpart
9//! on the `WebTransport` interface.
10//!
11// Legitimate FFI wiring — uses the disallowed types intentionally.
12#![allow(clippy::disallowed_types)]
13//! Lives under `mod ffi` (Slice E, #187) because `Session` is `u64`-handle-
14//! shaped: it wraps a slotmap entry in [`crate::ffi::handles::HandleStore`]
15//! and returns [`crate::FfiDuplexStream`]. A pure-Rust QUIC session API would
16//! sit under `mod http` and yield typed stream values directly; that is not
17//! today's API. Until it is, this module belongs on the FFI side.
18
19use iroh::endpoint::Connection;
20use serde::Serialize;
21
22use crate::{
23    ffi::handles::{HandleStore, SessionEntry},
24    ffi::pumps::{pump_body_to_quic_send, pump_quic_recv_to_body},
25    parse_node_addr, CoreError, FfiDuplexStream, IrohEndpoint, ALPN_DUPLEX,
26};
27
28/// Returns `true` if the connection error means "connection ended" rather
29/// than a protocol-level bug.  Used to return `None` instead of `Err`.
30fn is_connection_closed(err: &iroh::endpoint::ConnectionError) -> bool {
31    use iroh::endpoint::ConnectionError::*;
32    matches!(
33        err,
34        ApplicationClosed(_) | ConnectionClosed(_) | Reset | TimedOut | LocallyClosed
35    )
36}
37
38/// Close information returned when a session ends.
39#[derive(Debug, Clone, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct CloseInfo {
42    pub close_code: u64,
43    pub reason: String,
44}
45
46/// A QUIC session to a single remote peer.
47///
48/// Cheap to clone — this is just an [`IrohEndpoint`] ref-clone plus a `u64`
49/// handle into the endpoint's session registry. Sessions are not pooled;
50/// closing one session has no effect on other sessions to the same peer.
51#[derive(Clone)]
52pub struct Session {
53    endpoint: IrohEndpoint,
54    handle: u64,
55}
56
57impl Session {
58    /// Reconstruct a `Session` from a handle previously obtained via
59    /// [`Session::connect`] or [`Session::accept`].
60    ///
61    /// FFI adapters use this to rehydrate a session from a `u64` handle that
62    /// crossed a language boundary.
63    pub fn from_handle(endpoint: IrohEndpoint, handle: u64) -> Self {
64        Self { endpoint, handle }
65    }
66
67    /// The opaque handle identifying this session inside the endpoint registry.
68    pub fn handle(&self) -> u64 {
69        self.handle
70    }
71
72    /// Establish a session (QUIC connection) to a remote peer.
73    ///
74    /// Each call creates a **dedicated** QUIC connection — sessions are not
75    /// pooled. Closing one session handle cannot affect other sessions to the
76    /// same peer. (Fetch operations continue to use the shared pool for
77    /// efficiency; sessions opt out because [`Session::close`] closes the
78    /// underlying connection.)
79    pub async fn connect(
80        endpoint: IrohEndpoint,
81        remote_node_id: &str,
82        direct_addrs: Option<&[std::net::SocketAddr]>,
83    ) -> Result<Self, CoreError> {
84        let parsed = parse_node_addr(remote_node_id)?;
85        let node_id = parsed.node_id;
86        let mut addr = iroh::EndpointAddr::new(node_id);
87        for a in &parsed.direct_addrs {
88            addr = addr.with_ip_addr(*a);
89        }
90        if let Some(addrs) = direct_addrs {
91            for a in addrs {
92                addr = addr.with_ip_addr(*a);
93            }
94        }
95
96        let conn = endpoint
97            .raw()
98            .connect(addr, ALPN_DUPLEX)
99            .await
100            .map_err(|e| CoreError::connection_failed(format!("connect session: {e}")))?;
101
102        let handle = endpoint.handles().insert_session(SessionEntry { conn })?;
103
104        Ok(Self { endpoint, handle })
105    }
106
107    /// Accept an incoming session (QUIC connection) from a remote peer.
108    ///
109    /// Blocks until a peer connects.  Returns the new session, or `None` if
110    /// the endpoint is shutting down.
111    pub async fn accept(endpoint: IrohEndpoint) -> Result<Option<Self>, CoreError> {
112        let incoming = match endpoint.raw().accept().await {
113            Some(inc) => inc,
114            None => return Ok(None),
115        };
116
117        let conn = incoming
118            .await
119            .map_err(|e| CoreError::connection_failed(format!("accept session: {e}")))?;
120
121        let handle = endpoint.handles().insert_session(SessionEntry { conn })?;
122
123        Ok(Some(Self { endpoint, handle }))
124    }
125
126    fn conn(&self) -> Result<Connection, CoreError> {
127        self.endpoint
128            .handles()
129            .lookup_session(self.handle)
130            .map(|s| s.conn.clone())
131            .ok_or_else(|| CoreError::invalid_handle(self.handle))
132    }
133
134    /// Return the remote peer's public key.
135    pub fn remote_id(&self) -> Result<iroh::PublicKey, CoreError> {
136        self.conn().map(|c| c.remote_id())
137    }
138
139    /// Open a new bidirectional stream on this session.
140    ///
141    /// Returns [`FfiDuplexStream`] with `read_handle` / `write_handle` backed by
142    /// body channels — the same interface used by fetch and raw_connect.
143    pub async fn create_bidi_stream(&self) -> Result<FfiDuplexStream, CoreError> {
144        let conn = self.conn()?;
145
146        let (send, recv) = conn
147            .open_bi()
148            .await
149            .map_err(|e| CoreError::connection_failed(format!("open_bi: {e}")))?;
150
151        wrap_bidi_stream(self.endpoint.handles(), send, recv)
152    }
153
154    /// Accept the next incoming bidirectional stream from the remote side.
155    ///
156    /// Blocks until the remote opens a stream, or returns `None` when the
157    /// connection is closed.
158    pub async fn next_bidi_stream(&self) -> Result<Option<FfiDuplexStream>, CoreError> {
159        let conn = self.conn()?;
160
161        match conn.accept_bi().await {
162            Ok((send, recv)) => Ok(Some(wrap_bidi_stream(self.endpoint.handles(), send, recv)?)),
163            Err(e) if is_connection_closed(&e) => Ok(None),
164            Err(e) => Err(CoreError::connection_failed(format!("accept_bi: {e}"))),
165        }
166    }
167
168    /// Close this session's QUIC connection.
169    ///
170    /// The session handle is **not** removed here — [`closed`](Self::closed)
171    /// does the cleanup after `conn.closed()` resolves.  This avoids a race
172    /// where `close()` removes the handle before a concurrently-running
173    /// `closed()` future can look it up.
174    ///
175    /// `close_code` is an application-level error code (maps to QUIC VarInt).
176    /// `reason` is a human-readable string sent to the peer.
177    pub fn close(&self, close_code: u64, reason: &str) -> Result<(), CoreError> {
178        let conn = self.conn()?;
179        let code = iroh::endpoint::VarInt::from_u64(close_code).map_err(|_| {
180            CoreError::invalid_input(format!(
181                "close_code {close_code} exceeds QUIC VarInt max (2^62 - 1)"
182            ))
183        })?;
184        conn.close(code, reason.as_bytes());
185        Ok(())
186    }
187
188    /// Wait for the QUIC handshake to complete on this session.
189    ///
190    /// Resolves immediately if the handshake has already completed.
191    pub async fn ready(&self) -> Result<(), CoreError> {
192        // Validate handle exists — keeps error behavior consistent with other session APIs.
193        let _conn = self.conn()?;
194        // iroh connections are fully established by the time Session::connect returns,
195        // so ready always resolves immediately. Kept for WebTransport API compatibility.
196        Ok(())
197    }
198
199    /// Wait for the session to close and return the close information.
200    ///
201    /// Blocks until the connection is closed by either side.  Removes the
202    /// session from the registry so resources are freed.
203    ///
204    /// If the handle has already been removed (e.g. by a concurrent
205    /// `closed()` call), returns a default `CloseInfo` instead of an error.
206    pub async fn closed(&self) -> Result<CloseInfo, CoreError> {
207        let conn = match self.conn() {
208            Ok(c) => c,
209            Err(_) => {
210                // Handle already removed — another `closed()` or endpoint
211                // shutdown beat us to it.
212                return Ok(CloseInfo {
213                    close_code: 0,
214                    reason: String::new(),
215                });
216            }
217        };
218        let err = conn.closed().await;
219        // Connection is dead — clean up the registry entry.
220        self.endpoint.handles().remove_session(self.handle);
221        let (close_code, reason) = parse_connection_error(&err);
222        Ok(CloseInfo { close_code, reason })
223    }
224
225    /// Open a new unidirectional (send-only) stream on this session.
226    ///
227    /// Returns a write handle backed by a body channel.
228    pub async fn create_uni_stream(&self) -> Result<u64, CoreError> {
229        let conn = self.conn()?;
230        let send = conn
231            .open_uni()
232            .await
233            .map_err(|e| CoreError::connection_failed(format!("open_uni: {e}")))?;
234
235        let handles = self.endpoint.handles();
236        let (send_writer, send_reader) = handles.make_body_channel();
237        let write_handle = handles.insert_writer(send_writer)?;
238        tokio::spawn(pump_body_to_quic_send(send_reader, send));
239
240        Ok(write_handle)
241    }
242
243    /// Accept the next incoming unidirectional (receive-only) stream.
244    ///
245    /// Returns a read handle, or `None` when the connection is closed.
246    pub async fn next_uni_stream(&self) -> Result<Option<u64>, CoreError> {
247        let conn = self.conn()?;
248
249        match conn.accept_uni().await {
250            Ok(recv) => {
251                let handles = self.endpoint.handles();
252                let (recv_writer, recv_reader) = handles.make_body_channel();
253                let read_handle = handles.insert_reader(recv_reader)?;
254                tokio::spawn(pump_quic_recv_to_body(recv, recv_writer));
255                Ok(Some(read_handle))
256            }
257            Err(e) if is_connection_closed(&e) => Ok(None),
258            Err(e) => Err(CoreError::connection_failed(format!("accept_uni: {e}"))),
259        }
260    }
261
262    /// Send a datagram on this session.
263    ///
264    /// Fails if `data.len()` exceeds [`Session::max_datagram_size`].
265    pub fn send_datagram(&self, data: &[u8]) -> Result<(), CoreError> {
266        let conn = self.conn()?;
267        conn.send_datagram(bytes::Bytes::copy_from_slice(data))
268            .map_err(|e| match e {
269                iroh::endpoint::SendDatagramError::TooLarge => CoreError::body_too_large(
270                    "datagram exceeds path MTU; check Session::max_datagram_size()",
271                ),
272                _ => CoreError::internal(format!("send_datagram: {e}")),
273            })
274    }
275
276    /// Receive the next datagram from this session.
277    ///
278    /// Blocks until a datagram arrives, or returns `None` when the connection closes.
279    pub async fn recv_datagram(&self) -> Result<Option<Vec<u8>>, CoreError> {
280        let conn = self.conn()?;
281        match conn.read_datagram().await {
282            Ok(data) => Ok(Some(data.to_vec())),
283            Err(e) if is_connection_closed(&e) => Ok(None),
284            Err(e) => Err(CoreError::connection_failed(format!("recv_datagram: {e}"))),
285        }
286    }
287
288    /// Return the current maximum datagram payload size for this session.
289    ///
290    /// Returns `None` if datagrams are not supported on the current path.
291    pub fn max_datagram_size(&self) -> Result<Option<usize>, CoreError> {
292        let conn = self.conn()?;
293        Ok(conn.max_datagram_size())
294    }
295}
296
297// ── Helpers ───────────────────────────────────────────────────────────────────
298
299/// Wrap raw QUIC send/recv streams into body-channel–backed `FfiDuplexStream`.
300fn wrap_bidi_stream(
301    handles: &HandleStore,
302    send: iroh::endpoint::SendStream,
303    recv: iroh::endpoint::RecvStream,
304) -> Result<FfiDuplexStream, CoreError> {
305    let mut guard = handles.insert_guard();
306
307    // Receive side: pump from QUIC recv → BodyWriter → BodyReader (JS reads via nextChunk).
308    let (recv_writer, recv_reader) = handles.make_body_channel();
309    let read_handle = guard.insert_reader(recv_reader)?;
310    tokio::spawn(pump_quic_recv_to_body(recv, recv_writer));
311
312    // Send side: pump from BodyReader (JS writes via sendChunk) → QUIC send.
313    let (send_writer, send_reader) = handles.make_body_channel();
314    let write_handle = guard.insert_writer(send_writer)?;
315    tokio::spawn(pump_body_to_quic_send(send_reader, send));
316
317    guard.commit();
318    Ok(FfiDuplexStream {
319        read_handle,
320        write_handle,
321    })
322}
323
324/// Extract close code and reason from a QUIC `ConnectionError`.
325fn parse_connection_error(err: &iroh::endpoint::ConnectionError) -> (u64, String) {
326    match err {
327        iroh::endpoint::ConnectionError::ApplicationClosed(info) => {
328            let code: u64 = info.error_code.into();
329            let reason = String::from_utf8_lossy(&info.reason).into_owned();
330            (code, reason)
331        }
332        other => (0, other.to_string()),
333    }
334}