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 relay in parsed.relay_urls {
88            addr = addr.with_relay_url(relay);
89        }
90        for a in &parsed.direct_addrs {
91            addr = addr.with_ip_addr(*a);
92        }
93        if let Some(addrs) = direct_addrs {
94            for a in addrs {
95                addr = addr.with_ip_addr(*a);
96            }
97        }
98
99        let conn = endpoint
100            .raw()
101            .connect(addr, ALPN_DUPLEX)
102            .await
103            .map_err(|e| CoreError::connection_failed(format!("connect session: {e}")))?;
104
105        let handle = endpoint.handles().insert_session(SessionEntry { conn })?;
106
107        Ok(Self { endpoint, handle })
108    }
109
110    /// Accept an incoming session (QUIC connection) from a remote peer.
111    ///
112    /// Blocks until a peer connects.  Returns the new session, or `None` if
113    /// the endpoint is shutting down.
114    pub async fn accept(endpoint: IrohEndpoint) -> Result<Option<Self>, CoreError> {
115        let incoming = match endpoint.raw().accept().await {
116            Some(inc) => inc,
117            None => return Ok(None),
118        };
119
120        let conn = incoming
121            .await
122            .map_err(|e| CoreError::connection_failed(format!("accept session: {e}")))?;
123
124        // Enforce the negotiated ALPN: only accept connections that negotiated
125        // the duplex session protocol. A connection negotiated for plain HTTP
126        // must not be surfaced as a raw session (protocol confusion).
127        let alpn = conn.alpn();
128        if alpn != ALPN_DUPLEX {
129            let detail = format!(
130                "accept session: unexpected ALPN {:?}",
131                String::from_utf8_lossy(alpn)
132            );
133            conn.close(0u32.into(), b"unexpected ALPN");
134            return Err(CoreError::connection_failed(detail));
135        }
136
137        let handle = endpoint.handles().insert_session(SessionEntry { conn })?;
138
139        Ok(Some(Self { endpoint, handle }))
140    }
141
142    fn conn(&self) -> Result<Connection, CoreError> {
143        self.endpoint
144            .handles()
145            .lookup_session(self.handle)
146            .map(|s| s.conn.clone())
147            .ok_or_else(|| CoreError::invalid_handle(self.handle))
148    }
149
150    /// Return the remote peer's public key.
151    pub fn remote_id(&self) -> Result<iroh::PublicKey, CoreError> {
152        self.conn().map(|c| c.remote_id())
153    }
154
155    /// Open a new bidirectional stream on this session.
156    ///
157    /// Returns [`FfiDuplexStream`] with `read_handle` / `write_handle` backed by
158    /// body channels — the same interface used by fetch and raw_connect.
159    pub async fn create_bidi_stream(&self) -> Result<FfiDuplexStream, CoreError> {
160        let conn = self.conn()?;
161
162        let (send, recv) = conn
163            .open_bi()
164            .await
165            .map_err(|e| CoreError::connection_failed(format!("open_bi: {e}")))?;
166
167        wrap_bidi_stream(self.endpoint.handles(), send, recv)
168    }
169
170    /// Accept the next incoming bidirectional stream from the remote side.
171    ///
172    /// Blocks until the remote opens a stream, or returns `None` when the
173    /// connection is closed.
174    pub async fn next_bidi_stream(&self) -> Result<Option<FfiDuplexStream>, CoreError> {
175        let conn = self.conn()?;
176
177        match conn.accept_bi().await {
178            Ok((send, recv)) => Ok(Some(wrap_bidi_stream(self.endpoint.handles(), send, recv)?)),
179            Err(e) if is_connection_closed(&e) => Ok(None),
180            Err(e) => Err(CoreError::connection_failed(format!("accept_bi: {e}"))),
181        }
182    }
183
184    /// Close this session's QUIC connection.
185    ///
186    /// The session handle is **not** removed here — [`closed`](Self::closed)
187    /// does the cleanup after `conn.closed()` resolves.  This avoids a race
188    /// where `close()` removes the handle before a concurrently-running
189    /// `closed()` future can look it up.
190    ///
191    /// `close_code` is an application-level error code (maps to QUIC VarInt).
192    /// `reason` is a human-readable string sent to the peer.
193    pub fn close(&self, close_code: u64, reason: &str) -> Result<(), CoreError> {
194        let conn = self.conn()?;
195        let code = iroh::endpoint::VarInt::from_u64(close_code).map_err(|_| {
196            CoreError::invalid_input(format!(
197                "close_code {close_code} exceeds QUIC VarInt max (2^62 - 1)"
198            ))
199        })?;
200        conn.close(code, reason.as_bytes());
201        Ok(())
202    }
203
204    /// Wait for the QUIC handshake to complete on this session.
205    ///
206    /// Resolves immediately if the handshake has already completed.
207    pub async fn ready(&self) -> Result<(), CoreError> {
208        // Validate handle exists — keeps error behavior consistent with other session APIs.
209        let _conn = self.conn()?;
210        // iroh connections are fully established by the time Session::connect returns,
211        // so ready always resolves immediately. Kept for WebTransport API compatibility.
212        Ok(())
213    }
214
215    /// Wait for the session to close and return the close information.
216    ///
217    /// Blocks until the connection is closed by either side.  Removes the
218    /// session from the registry so resources are freed.
219    ///
220    /// If the handle has already been removed (e.g. by a concurrent
221    /// `closed()` call), returns a default `CloseInfo` instead of an error.
222    pub async fn closed(&self) -> Result<CloseInfo, CoreError> {
223        let conn = match self.conn() {
224            Ok(c) => c,
225            Err(_) => {
226                // Handle already removed — another `closed()` or endpoint
227                // shutdown beat us to it.
228                return Ok(CloseInfo {
229                    close_code: 0,
230                    reason: String::new(),
231                });
232            }
233        };
234        let err = conn.closed().await;
235        // Connection is dead — clean up the registry entry.
236        self.endpoint.handles().remove_session(self.handle);
237        let (close_code, reason) = parse_connection_error(&err);
238        Ok(CloseInfo { close_code, reason })
239    }
240
241    /// Open a new unidirectional (send-only) stream on this session.
242    ///
243    /// Returns a write handle backed by a body channel.
244    pub async fn create_uni_stream(&self) -> Result<u64, CoreError> {
245        let conn = self.conn()?;
246        let send = conn
247            .open_uni()
248            .await
249            .map_err(|e| CoreError::connection_failed(format!("open_uni: {e}")))?;
250
251        let handles = self.endpoint.handles();
252        let (send_writer, send_reader) = handles.make_body_channel();
253        let write_handle = handles.insert_writer(send_writer)?;
254        tokio::spawn(pump_body_to_quic_send(send_reader, send));
255
256        Ok(write_handle)
257    }
258
259    /// Accept the next incoming unidirectional (receive-only) stream.
260    ///
261    /// Returns a read handle, or `None` when the connection is closed.
262    pub async fn next_uni_stream(&self) -> Result<Option<u64>, CoreError> {
263        let conn = self.conn()?;
264
265        match conn.accept_uni().await {
266            Ok(recv) => {
267                let handles = self.endpoint.handles();
268                let (recv_writer, recv_reader) = handles.make_body_channel();
269                let read_handle = handles.insert_reader(recv_reader)?;
270                tokio::spawn(pump_quic_recv_to_body(recv, recv_writer));
271                Ok(Some(read_handle))
272            }
273            Err(e) if is_connection_closed(&e) => Ok(None),
274            Err(e) => Err(CoreError::connection_failed(format!("accept_uni: {e}"))),
275        }
276    }
277
278    /// Send a datagram on this session.
279    ///
280    /// Fails if `data.len()` exceeds [`Session::max_datagram_size`].
281    pub fn send_datagram(&self, data: &[u8]) -> Result<(), CoreError> {
282        let conn = self.conn()?;
283        conn.send_datagram(bytes::Bytes::copy_from_slice(data))
284            .map_err(|e| match e {
285                iroh::endpoint::SendDatagramError::TooLarge => CoreError::body_too_large(
286                    "datagram exceeds path MTU; check Session::max_datagram_size()",
287                ),
288                _ => CoreError::internal(format!("send_datagram: {e}")),
289            })
290    }
291
292    /// Receive the next datagram from this session.
293    ///
294    /// Blocks until a datagram arrives, or returns `None` when the connection closes.
295    pub async fn recv_datagram(&self) -> Result<Option<Vec<u8>>, CoreError> {
296        let conn = self.conn()?;
297        match conn.read_datagram().await {
298            Ok(data) => Ok(Some(data.to_vec())),
299            Err(e) if is_connection_closed(&e) => Ok(None),
300            Err(e) => Err(CoreError::connection_failed(format!("recv_datagram: {e}"))),
301        }
302    }
303
304    /// Return the current maximum datagram payload size for this session.
305    ///
306    /// Returns `None` if datagrams are not supported on the current path.
307    pub fn max_datagram_size(&self) -> Result<Option<usize>, CoreError> {
308        let conn = self.conn()?;
309        Ok(conn.max_datagram_size())
310    }
311}
312
313// ── Helpers ───────────────────────────────────────────────────────────────────
314
315/// Wrap raw QUIC send/recv streams into body-channel–backed `FfiDuplexStream`.
316fn wrap_bidi_stream(
317    handles: &HandleStore,
318    send: iroh::endpoint::SendStream,
319    recv: iroh::endpoint::RecvStream,
320) -> Result<FfiDuplexStream, CoreError> {
321    let mut guard = handles.insert_guard();
322
323    // Receive side: pump from QUIC recv → BodyWriter → BodyReader (JS reads via nextChunk).
324    let (recv_writer, recv_reader) = handles.make_body_channel();
325    let read_handle = guard.insert_reader(recv_reader)?;
326    tokio::spawn(pump_quic_recv_to_body(recv, recv_writer));
327
328    // Send side: pump from BodyReader (JS writes via sendChunk) → QUIC send.
329    let (send_writer, send_reader) = handles.make_body_channel();
330    let write_handle = guard.insert_writer(send_writer)?;
331    tokio::spawn(pump_body_to_quic_send(send_reader, send));
332
333    guard.commit();
334    Ok(FfiDuplexStream {
335        read_handle,
336        write_handle,
337    })
338}
339
340/// Extract close code and reason from a QUIC `ConnectionError`.
341fn parse_connection_error(err: &iroh::endpoint::ConnectionError) -> (u64, String) {
342    match err {
343        iroh::endpoint::ConnectionError::ApplicationClosed(info) => {
344            let code: u64 = info.error_code.into();
345            let reason = String::from_utf8_lossy(&info.reason).into_owned();
346            (code, reason)
347        }
348        other => (0, other.to_string()),
349    }
350}