[Add] Pipes (experimental)
Some checks failed
CI / checks (push) Failing after 1m51s

This commit is contained in:
Alex Emmet 2026-07-15 01:42:54 +02:00
commit 089def45d1
37 changed files with 2792 additions and 225 deletions

4
Cargo.lock generated
View file

@ -1163,9 +1163,9 @@ dependencies = [
[[package]] [[package]]
name = "octets" name = "octets"
version = "0.3.5" version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8311fa8ab7a57759b4ff1f851a3048d9ef0effaa0130726426b742d26d8a88e7" checksum = "866cb5af6f3aa3c1b44c3c2d79d22165fbb1b102e1b3fb499864bfe34736ec4b"
[[package]] [[package]]
name = "oid-registry" name = "oid-registry"

View file

@ -62,8 +62,6 @@ mtp-client = { version = "0.1.0", path = "client", optional = true }
mtp-files = { version = "0.1.0", path = "files", optional = true } mtp-files = { version = "0.1.0", path = "files", optional = true }
[features] [features]
default = []
# Serialization # Serialization
serde = ["mtp-crypto/serde"] serde = ["mtp-crypto/serde"]
@ -82,19 +80,14 @@ host = ["dep:mtp-host", "mtp-codec/registry", "transport"]
# MTP client - outgoing QUIC connections to a host. # MTP client - outgoing QUIC connections to a host.
client = ["dep:mtp-client", "transport"] client = ["dep:mtp-client", "transport"]
# Opt into stream-specific host/client facade APIs. The transport itself is
# always framed over QUIC/WebTransport streams for compatibility.
streaming = [
"transport",
"mtp-transport/streaming",
"mtp-host?/streaming",
"mtp-client?/streaming",
]
# Direct access to the framed QUIC transport. Host/client features enable it # Direct access to the framed QUIC transport. Host/client features enable it
# automatically; this feature is useful for low-level integrations. # automatically; this feature is useful for low-level integrations.
transport = ["dep:mtp-transport"] transport = ["dep:mtp-transport"]
# Direct access to the pipes. Pipes can be used to send raw binary
# without after creation overhead.
pipes = ["mtp-common/pipes", "mtp-codec/pipes", "mtp-transport?/pipes", "mtp-host?/pipes", "mtp-client?/pipes"]
# On-disk storage for keyrings (`.mk`) and public key bundles (`.mpkb`). # On-disk storage for keyrings (`.mk`) and public key bundles (`.mpkb`).
# Pulls in `crypto` so the `Keyring` / `PublicKeyBundle` types are in scope. # Pulls in `crypto` so the `Keyring` / `PublicKeyBundle` types are in scope.
files = ["dep:mtp-files", "crypto"] files = ["dep:mtp-files", "crypto"]

View file

@ -19,5 +19,4 @@ rcgen = "0.14"
[features] [features]
crypto = ["dep:mtp-crypto", "mtp-codec/crypto"] crypto = ["dep:mtp-crypto", "mtp-codec/crypto"]
# Enables stream-specific convenience exports and configuration. pipes = ["mtp-common/pipes", "mtp-transport/pipes"]
streaming = ["mtp-transport/streaming"]

View file

@ -1,11 +1,16 @@
use mtp_codec::{CommunicationValue, DataType, DataValue, PROTOCOL_VERSION, Version}; use mtp_codec::{CommunicationValue, DataType, DataValue, PROTOCOL_VERSION, Version};
use mtp_common::CommunicationError; use mtp_common::CommunicationError;
#[cfg(feature = "pipes")]
pub use mtp_common::PipeError;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use rand::Rng; use rand::Rng;
use tokio::sync::{Mutex, mpsc}; use tokio::sync::{Mutex, mpsc};
use tokio::time::{Duration, Instant}; use tokio::time::{Duration, Instant};
#[cfg(feature = "pipes")]
use mtp_transport::PipeReader;
pub use MTPClient as Client; pub use MTPClient as Client;
pub use MTPConnection as Connection; pub use MTPConnection as Connection;
pub use mtp_transport::Policy; pub use mtp_transport::Policy;
@ -13,6 +18,9 @@ pub use mtp_transport::Receiver;
pub use mtp_transport::SendMode; pub use mtp_transport::SendMode;
pub use mtp_transport::Sender; pub use mtp_transport::Sender;
#[cfg(feature = "pipes")]
pub use mtp_transport::PipeWriter;
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
fn unexpected_response_type_error( fn unexpected_response_type_error(
context: &str, context: &str,
@ -27,6 +35,162 @@ fn unexpected_response_type_error(
)) ))
} }
#[cfg(feature = "pipes")]
pub struct PipeHandle {
pipe_id: u32,
description: String,
sender: Sender,
response_rx: tokio::sync::oneshot::Receiver<Result<bool, PipeError>>,
}
#[cfg(feature = "pipes")]
impl PipeHandle {
pub fn pipe_id(&self) -> u32 {
self.pipe_id
}
pub fn description(&self) -> &str {
&self.description
}
pub async fn wait(self) -> Result<Option<PipeWriter>, PipeError> {
match self.response_rx.await {
Ok(Ok(true)) => {
let writer = self
.sender
.open_pipe(self.pipe_id, &self.description)
.await
.map_err(PipeError::from)?;
Ok(Some(writer))
}
Ok(Ok(false)) => Ok(None),
Ok(Err(e)) => Err(e),
Err(_) => Err(PipeError::StreamClosed),
}
}
}
#[cfg(feature = "pipes")]
pub struct PipeRequest {
pipe_id: u32,
description: String,
sender: Sender,
dispatcher: Arc<PipeDispatcher>,
}
#[cfg(feature = "pipes")]
impl PipeRequest {
pub fn id(&self) -> u32 {
self.pipe_id
}
pub fn description(&self) -> &str {
&self.description
}
pub async fn accept(self) -> Result<PipeReader, PipeError> {
let (pipe_tx, pipe_rx) = tokio::sync::oneshot::channel();
{
let mut pending = self.dispatcher.pending_pipes.lock().await;
pending.insert(self.pipe_id, pipe_tx);
}
let resp = CommunicationValue::new(mtp_codec::CommunicationType::PipeResponse)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
self.sender.send(&resp).await.map_err(PipeError::from)?;
let timeout = self.dispatcher.policy.read_timeout;
tokio::time::timeout(timeout, pipe_rx)
.await
.map_err(|_| PipeError::HandshakeTimeout)?
.map_err(|_| PipeError::StreamClosed)
}
pub async fn deny(self) -> Result<(), PipeError> {
let resp = CommunicationValue::new(mtp_codec::CommunicationType::PipeResponse)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
self.sender.send(&resp).await.map_err(PipeError::from)?;
Ok(())
}
}
#[cfg(feature = "pipes")]
struct PipeDispatcher {
pending_creations: Mutex<HashMap<u32, tokio::sync::oneshot::Sender<Result<bool, PipeError>>>>,
pending_pipes: Mutex<HashMap<u32, tokio::sync::oneshot::Sender<PipeReader>>>,
policy: Arc<Policy>,
}
#[cfg(not(feature = "pipes"))]
struct PipeDispatcher;
#[cfg(not(feature = "pipes"))]
pub(crate) struct PipeRequest;
#[cfg(feature = "pipes")]
async fn run_dispatcher(
receiver: Receiver,
sender: Sender,
app_tx: mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
pipe_req_tx: mpsc::Sender<PipeRequest>,
dispatcher: Arc<PipeDispatcher>,
) {
let pipe_req_type =
mtp_codec::CommunicationType::PipeRequest.to_id(&mtp_codec::TypeMap::latest());
let pipe_resp_type =
mtp_codec::CommunicationType::PipeResponse.to_id(&mtp_codec::TypeMap::latest());
loop {
match receiver.receive_event().await {
Ok(mtp_transport::TransportEvent::Message(msg)) => {
if msg.get_type() == pipe_req_type {
let pipe_id = msg.get_id();
let description = msg
.get_str(DataType::Description)
.unwrap_or("")
.to_string();
let req = PipeRequest {
pipe_id,
description,
sender: sender.clone(),
dispatcher: dispatcher.clone(),
};
let _ = pipe_req_tx.send(req).await;
continue;
}
if msg.get_type() == pipe_resp_type {
let pipe_id = msg.get_id();
let accepted = msg.get_bool(DataType::Accepted).unwrap_or(false);
let mut pending = dispatcher.pending_creations.lock().await;
if let Some(tx) = pending.remove(&pipe_id) {
let _ = tx.send(Ok(accepted));
}
continue;
}
if app_tx.send(Ok(msg)).await.is_err() {
break;
}
}
Ok(mtp_transport::TransportEvent::Pipe(reader)) => {
let pipe_id = reader.pipe_id();
let mut pending = dispatcher.pending_pipes.lock().await;
if let Some(tx) = pending.remove(&pipe_id) {
let _ = tx.send(reader);
}
}
Err(e) => {
if app_tx.send(Err(e)).await.is_err() {
break;
}
}
}
}
}
pub struct ClientConfig { pub struct ClientConfig {
pub url: String, pub url: String,
pub tls: ClientTlsConfig, pub tls: ClientTlsConfig,
@ -129,6 +293,10 @@ pub struct MTPConnection {
pub receiver: Receiver, pub receiver: Receiver,
pub description: Option<String>, pub description: Option<String>,
ping: Option<PingSession>, ping: Option<PingSession>,
app_rx: Mutex<mpsc::Receiver<Result<CommunicationValue, CommunicationError>>>,
pipe_req_rx: Mutex<mpsc::Receiver<PipeRequest>>,
pipe_dispatcher: Arc<PipeDispatcher>,
_dispatcher_task: tokio::task::JoinHandle<()>,
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
pub auth_state: AuthState, pub auth_state: AuthState,
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
@ -180,7 +348,7 @@ impl MTPConnection {
let tm = mtp_codec::TypeMap::latest(); let tm = mtp_codec::TypeMap::latest();
loop { loop {
let response = self.receiver.receive().await?; let response = self.receive().await?;
if response.get_id() != request_id { if response.get_id() != request_id {
continue; continue;
} }
@ -200,6 +368,55 @@ impl MTPConnection {
return Ok(response); return Ok(response);
} }
} }
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
#[cfg(feature = "pipes")]
{
let mut rx = self.app_rx.lock().await;
match rx.recv().await {
Some(result) => result,
None => Err(CommunicationError::StreamClosed),
}
}
#[cfg(not(feature = "pipes"))]
{
self.receiver.receive().await
}
}
}
#[cfg(feature = "pipes")]
impl MTPConnection {
pub async fn create_pipe(&self, description: &str) -> Result<PipeHandle, PipeError> {
let pipe_id = rand::random::<u32>();
let (tx, rx) = tokio::sync::oneshot::channel();
{
let mut pending = self.pipe_dispatcher.pending_creations.lock().await;
pending.insert(pipe_id, tx);
}
let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest)
.with_id(pipe_id)
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
self.sender.send(&request).await.map_err(PipeError::from)?;
Ok(PipeHandle {
pipe_id,
description: description.to_string(),
sender: self.sender.clone(),
response_rx: rx,
})
}
pub async fn receive_pipe(&self) -> Result<PipeRequest, CommunicationError> {
let mut rx = self.pipe_req_rx.lock().await;
match rx.recv().await {
Some(req) => Ok(req),
None => Err(CommunicationError::StreamClosed),
}
}
} }
fn start_ping_session( fn start_ping_session(
@ -287,16 +504,71 @@ fn connection_from_parts(
#[cfg(feature = "crypto")] client_id: u64, #[cfg(feature = "crypto")] client_id: u64,
) -> MTPConnection { ) -> MTPConnection {
let ping = start_ping_session(&config, sender.clone(), &receiver); let ping = start_ping_session(&config, sender.clone(), &receiver);
MTPConnection {
version: PROTOCOL_VERSION, #[cfg(feature = "pipes")]
sender, {
receiver, let (app_tx, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
description: config.description, config.policy.receiver_queue_capacity,
ping, );
#[cfg(feature = "crypto")] let (pipe_req_tx, pipe_req_rx) = mpsc::channel::<PipeRequest>(
auth_state, config.policy.receiver_queue_capacity,
#[cfg(feature = "crypto")] );
client_id,
let dispatcher = Arc::new(PipeDispatcher {
pending_creations: Mutex::new(HashMap::new()),
pending_pipes: Mutex::new(HashMap::new()),
policy: Arc::new(config.policy),
});
let dispatcher_clone = dispatcher.clone();
let sender_clone = sender.clone();
let dispatcher_task = tokio::spawn(run_dispatcher(
receiver.clone(),
sender_clone,
app_tx,
pipe_req_tx,
dispatcher_clone,
));
MTPConnection {
version: PROTOCOL_VERSION,
sender,
receiver,
app_rx: Mutex::new(app_rx),
pipe_req_rx: Mutex::new(pipe_req_rx),
pipe_dispatcher: dispatcher,
description: config.description,
ping,
_dispatcher_task: dispatcher_task,
#[cfg(feature = "crypto")]
auth_state,
#[cfg(feature = "crypto")]
client_id,
}
}
#[cfg(not(feature = "pipes"))]
{
let (_, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(1);
let (_, pipe_req_rx) = mpsc::channel::<PipeRequest>(1);
let dispatcher = Arc::new(PipeDispatcher);
let task = tokio::spawn(async {});
MTPConnection {
version: PROTOCOL_VERSION,
sender,
receiver,
app_rx: Mutex::new(app_rx),
pipe_req_rx: Mutex::new(pipe_req_rx),
pipe_dispatcher: dispatcher,
description: config.description,
ping,
_dispatcher_task: task,
#[cfg(feature = "crypto")]
auth_state,
#[cfg(feature = "crypto")]
client_id,
}
} }
} }

View file

@ -12,6 +12,6 @@ byteorder = "1.5"
rand = { version = "0.8", features = ["std", "std_rng"] } rand = { version = "0.8", features = ["std", "std_rng"] }
[features] [features]
default = []
registry = ["mtp-type-map/registry"] registry = ["mtp-type-map/registry"]
crypto = ["dep:mtp-crypto", "mtp-crypto/mlkem-tls"] crypto = ["dep:mtp-crypto", "mtp-crypto/mlkem-tls"]
pipes = ["mtp-type-map/pipes"]

View file

@ -6,6 +6,9 @@ edition = "2024"
[dependencies] [dependencies]
thiserror = "2.0.18" thiserror = "2.0.18"
[features]
pipes = []
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
wtransport = { version = "0.7.1", default-features = false, features = [ wtransport = { version = "0.7.1", default-features = false, features = [
"aws-lc-rs", "aws-lc-rs",

View file

@ -244,6 +244,45 @@ impl Eq for CommunicationError {}
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
impl Eq for CommunicationError {} impl Eq for CommunicationError {}
/* ================================ PipeError ================================ */
#[cfg(feature = "pipes")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PipeError {
Rejected,
HandshakeTimeout,
StreamClosed,
IoError(String),
ConnectionClosed,
}
#[cfg(feature = "pipes")]
impl std::fmt::Display for PipeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PipeError::Rejected => write!(f, "pipe request was rejected"),
PipeError::HandshakeTimeout => write!(f, "pipe handshake timed out"),
PipeError::StreamClosed => write!(f, "pipe stream closed unexpectedly"),
PipeError::IoError(s) => write!(f, "pipe I/O error: {s}"),
PipeError::ConnectionClosed => write!(f, "connection closed"),
}
}
}
#[cfg(feature = "pipes")]
impl std::error::Error for PipeError {}
#[cfg(feature = "pipes")]
impl From<CommunicationError> for PipeError {
fn from(e: CommunicationError) -> Self {
match e {
CommunicationError::StreamClosed => PipeError::StreamClosed,
CommunicationError::ConnectionError(_) => PipeError::ConnectionClosed,
other => PipeError::IoError(other.to_string()),
}
}
}
/* ================================ TESTS ================================ */ /* ================================ TESTS ================================ */
#[cfg(test)] #[cfg(test)]
mod communication_error_tests { mod communication_error_tests {
@ -291,3 +330,55 @@ mod communication_error_tests {
assert!(format!("{}", e).contains("refused")); assert!(format!("{}", e).contains("refused"));
} }
} }
/* ================================ PipeError TESTS ================================ */
#[cfg(feature = "pipes")]
#[cfg(test)]
mod pipe_error_tests {
use super::*;
#[test]
fn test_pipe_error_display() {
assert_eq!(
format!("{}", PipeError::Rejected),
"pipe request was rejected"
);
assert_eq!(
format!("{}", PipeError::HandshakeTimeout),
"pipe handshake timed out"
);
assert_eq!(
format!("{}", PipeError::StreamClosed),
"pipe stream closed unexpectedly"
);
assert_eq!(
format!("{}", PipeError::ConnectionClosed),
"connection closed"
);
assert_eq!(
format!("{}", PipeError::IoError("boom".into())),
"pipe I/O error: boom"
);
}
#[test]
fn test_pipe_error_from_stream_closed() {
let pe: PipeError = CommunicationError::StreamClosed.into();
assert_eq!(pe, PipeError::StreamClosed);
}
#[test]
fn test_pipe_error_from_connection_error() {
let pe: PipeError = CommunicationError::ConnectionError(
wtransport::error::ConnectionError::TimedOut,
)
.into();
assert_eq!(pe, PipeError::ConnectionClosed);
}
#[test]
fn test_pipe_error_from_other() {
let pe: PipeError = CommunicationError::StreamError.into();
assert_eq!(pe, PipeError::IoError("Stream Error".into()));
}
}

View file

@ -12,6 +12,9 @@ mtp = { path = "/path/to/mtp", features = ["client"] }
# Add crypto for auth_connect / auth_register: # Add crypto for auth_connect / auth_register:
mtp = { path = "/path/to/mtp", features = ["client", "crypto"] } mtp = { path = "/path/to/mtp", features = ["client", "crypto"] }
# Add pipes for raw binary streams:
mtp = { path = "/path/to/mtp", features = ["client", "pipes"] }
``` ```
## ClientConfig ## ClientConfig
@ -28,16 +31,18 @@ let config = ClientConfig::new("https://host.example.com:4433")
.with_ping_timestamp(true); .with_ping_timestamp(true);
``` ```
| Field | Type | Description | | Field | Type | Default | Description |
|---------------|--------------------|-----------------------------------------------------| |-------------------------|--------------------|------------------|---------------------------------------------|
| `url` | `String` | `https://host:port` address of the MTP host | | `url` | `String` | required | Host URL (`https://host:port`) |
| `tls` | `ClientTlsConfig` | `SystemRoots` or `PinnedPem(pem_bytes)` | | `tls` | `ClientTlsConfig` | `SystemRoots` | `SystemRoots` or `PinnedPem(Vec<u8>)` |
| `client_id` | `u64` | Client identifier (ignored during `auth_register`) | | `client_id` | `u64` | `0` | Client identifier (for login) |
| `description` | `Option<String>` | Optional label sent during handshake (e.g. `"phone"`) | | `description` | `Option<String>` | `None` | Optional label sent to host |
| `ping_interval` | `Duration` | Interval between Ping frames; zero disables pings | | `policy` | `Policy` | default | Transport policy (timeouts, send mode) |
| `max_missed_pings` | `usize` | Unanswered Ping frames allowed before the connection closes | | `ping_interval` | `Duration` | `Duration::ZERO` | Interval between protocol Ping frames |
| `ping_timestamp` | `bool` | Adds a `Timestamp` entry to each Ping frame | | `ping_jitter` | `Option<Duration>` | `None` | Random jitter added to each interval |
| `auth_timeout` | `Duration` (crypto) | Authentication handshake timeout (default 30s) | | `max_missed_pings` | `usize` | `3` | Disconnect after this many unanswered Pings |
| `ping_timestamp` | `bool` | `true` | Include a `Timestamp` data entry in Ping |
| `auth_timeout` (crypto) | `Duration` | `30s` | Max time for auth handshake |
### TLS Certificate Handling ### TLS Certificate Handling
@ -310,6 +315,111 @@ Sends a close frame and signals the peer. The `Sender::close()` spawns an async
task that sends the frame, waits for `force_close_delay` (default 300ms), then task that sends the frame, waits for `force_close_delay` (default 300ms), then
force-closes the QUIC connection if the peer has not already done so. force-closes the QUIC connection if the peer has not already done so.
## Pipes
With the `pipes` feature enabled, the client can open **raw binary streams**
to the host. A Pipe is a unidirectional QUIC stream that carries a lightweight
`PipeRequest` handshake frame, then transitions to raw bytes with zero per-frame
overhead.
### Enabling Pipes
Add the `pipes` feature to your dependency:
```toml
[dependencies]
mtp = { path = "/path/to/mtp", features = ["client", "pipes"] }
```
### Creating a Pipe
```rust
use mtp::client::MTPClient;
use tokio::io::AsyncWriteExt;
let conn = MTPClient::connect(config).await?;
// Initiate a pipe request
let handle = conn.create_pipe("file-transfer").await?;
// Wait for the host to accept or reject
match handle.wait().await? {
Some(mut writer) => {
writer.write_all(b"raw binary data").await?;
writer.finish().await?; // graceful close
}
None => {
println!("host rejected the pipe");
}
}
```
### PipeHandle
```rust
pub struct PipeHandle {
pipe_id: u32,
description: String,
}
```
| Method | Returns | Description |
|--------|---------|-------------|
| `wait()` | `Result<Option<PipeWriter>, PipeError>` | Block until the host responds. `Some(writer)` if accepted, `None` if rejected. |
`PipeHandle` consumes itself on `wait()`, so you cannot poll it multiple times.
### PipeWriter
```rust
pub struct PipeWriter {
// wraps a QUIC SendStream
}
```
`PipeWriter` implements `tokio::io::AsyncWrite`. After the handshake succeeds,
writes go directly to the QUIC stream with no framing overhead.
| Method | Returns | Description |
|--------|---------|-------------|
| `finish()` | `Result<(), CommunicationError>` | Gracefully close the stream (sends FIN) |
| `abort()` | `Result<(), ClosedStream>` | Abruptly reset the stream |
```rust
use tokio::io::AsyncWriteExt;
let mut writer = handle.wait().await?.unwrap();
writer.write_all(b"chunk 1").await?;
writer.write_all(b"chunk 2").await?;
writer.finish().await?;
```
### PipeError
```rust
pub enum PipeError {
Rejected, // pipe request was rejected
HandshakeTimeout, // pipe handshake timed out
StreamClosed, // pipe stream closed unexpectedly
IoError(String), // pipe I/O error
ConnectionClosed, // connection closed
}
```
`PipeError` implements `std::error::Error` and can be converted from
`CommunicationError` via `PipeError::from()`.
### Do Not Use `receiver.receive()` for Pipes
When the `pipes` feature is active, `conn.receiver.receive()` will **skip**
`PipeResponse` frames and may return them as ordinary messages if called from
the wrong task. Use the facade methods:
- `conn.receive()` to receive normal `CommunicationValue` messages
- `conn.create_pipe(description)` to initiate a new pipe
These methods are internally synchronised and safe to call from separate tasks.
## Crypto Containers ## Crypto Containers
With the `crypto` feature, `DataValue` supports encrypted, signed, and With the `crypto` feature, `DataValue` supports encrypted, signed, and

View file

@ -13,6 +13,9 @@ mtp = { path = "/path/to/mtp", features = ["host"] }
# Add crypto for authenticated connections: # Add crypto for authenticated connections:
mtp = { path = "/path/to/mtp", features = ["host", "crypto"] } mtp = { path = "/path/to/mtp", features = ["host", "crypto"] }
# Add pipes for raw binary streams:
mtp = { path = "/path/to/mtp", features = ["host", "pipes"] }
``` ```
## HostConfig ## HostConfig
@ -306,6 +309,133 @@ let desc_id = DataTypeId(tm.data_id_enum(DataType::Description).unwrap());
let value = msg.get_data(desc_id); let value = msg.get_data(desc_id);
``` ```
## Pipes
With the `pipes` feature enabled, the host can accept **raw binary streams**
from clients. A Pipe is a unidirectional QUIC stream opened by the client that
carries a lightweight `PipeRequest` handshake frame, then transitions to raw
bytes with zero per-frame overhead.
### Enabling Pipes
Add the `pipes` feature to your dependency:
```toml
[dependencies]
mtp = { path = "/path/to/mtp", features = ["host", "pipes"] }
```
### Receiving Pipe Requests
When `pipes` is enabled, **do not call `conn.receiver.receive()` directly**.
Instead, use `conn.receive()` for normal messages and `conn.receive_pipe()`
for incoming pipe requests. A background dispatcher task routes events
internally so the two channels do not race.
```rust
use mtp::host::{MTPHost, PipeRequest};
use tokio::io::AsyncReadExt;
while let Some(conn) = host.accept().await? {
tokio::spawn(async move {
loop {
tokio::select! {
Ok(msg) = conn.receive() => {
// handle normal CommunicationValue
}
Ok(req) = conn.receive_pipe() => {
handle_pipe(req).await;
}
else => break,
}
}
});
}
async fn handle_pipe(req: PipeRequest) {
println!("Pipe {} requested: {}", req.id(), req.description());
// Accept or deny...
}
```
### PipeRequest
```rust
pub struct PipeRequest {
// pipe_id assigned by the creator
// description provided by the creator
}
```
| Method | Returns | Description |
|--------|---------|-------------|
| `id()` | `u32` | The pipe ID chosen by the creator |
| `description()` | `&str` | Creator-provided label (e.g. `"file-transfer"`) |
| `accept()` | `Result<PipeReader, PipeError>` | Accept the pipe; returns an `AsyncRead` stream |
| `deny()` | `Result<(), PipeError>` | Reject the pipe |
### Accepting a Pipe
```rust
use tokio::io::AsyncReadExt;
async fn handle_pipe(req: PipeRequest) {
match req.accept().await {
Ok(mut reader) => {
let mut buf = Vec::new();
if let Err(e) = reader.read_to_end(&mut buf).await {
eprintln!("pipe read error: {e}");
}
println!("received {} bytes", buf.len());
}
Err(e) => {
eprintln!("pipe accept failed: {e}");
}
}
}
```
`PipeReader` implements `tokio::io::AsyncRead`. The stream reads until the
creator calls `PipeWriter::finish()` or the connection closes.
### Rejecting a Pipe
```rust
async fn handle_pipe(req: PipeRequest) {
if !should_allow(&req) {
req.deny().await.ok();
return;
}
// ... accept
}
```
### PipeError
```rust
pub enum PipeError {
Rejected, // pipe request was rejected
HandshakeTimeout, // pipe handshake timed out
StreamClosed, // pipe stream closed unexpectedly
IoError(String), // pipe I/O error
ConnectionClosed, // connection closed
}
```
`PipeError` implements `std::error::Error` and can be converted from
`CommunicationError` via `PipeError::from()`.
### Important: Do Not Use `receiver.receive()` with Pipes
When the `pipes` feature is active, `conn.receiver.receive()` will **skip**
`PipeRequest` frames and may return them as ordinary messages if called from
the wrong task. Always use the facade methods:
- `conn.receive()` -- normal `CommunicationValue` messages
- `conn.receive_pipe()` -- incoming `PipeRequest` objects
These methods are internally synchronised and safe to call from separate tasks.
## Host Callbacks ## Host Callbacks
### get_existing_user ### get_existing_user

View file

@ -231,6 +231,82 @@ await MTPClient.create({
Use `pings: true` for the default interval. Use `pings: true` for the default interval.
## Pipes
Pipes are raw binary streams over QUIC. A pipe starts with a lightweight `PipeRequest` handshake frame, then the stream carries raw bytes with zero per-frame overhead. Pipes are unidirectional; the peer that initiates the pipe writes, and the peer that accepts it reads.
### Outgoing Pipes
`createPipe` sends a `PipeRequest` frame and returns a handle. Call `wait()` to block until the remote peer accepts or denies:
```typescript
const handle = await client.createPipe("file-transfer");
const writer = await handle.wait();
if (writer == null) {
console.log("host denied the pipe");
return;
}
await writer.write(new Uint8Array([0x01, 0x02, 0x03]));
await writer.write(chunk);
await writer.close();
```
`writer.close()` sends a QUIC stream FIN. `writer.abort()` resets the stream abruptly. Each `write` resolves when the chunk has been handed to the transport; it does not wait for the peer to consume it.
The handle and writer expose `pipeId` and `description`:
```typescript
console.log(handle.pipeId, handle.description);
console.log(writer.pipeId);
```
### Incoming Pipes
Set a handler to receive pipe requests from the remote peer:
```typescript
client.setOnPipeRequest((request) => {
console.log("incoming pipe", request.pipeId, request.description);
// accept or deny asynchronously
});
```
Accept a request to receive a `PipeReader`:
```typescript
client.setOnPipeRequest(async (request) => {
if (request.description === "file-transfer") {
const reader = await client.acceptPipe(request.pipeId);
while (true) {
const chunk = await reader.read();
if (chunk == null) break; // stream closed by peer
processChunk(chunk);
}
} else {
await client.denyPipe(request.pipeId);
}
});
```
`reader.read()` resolves with a `Uint8Array` or `null` when the peer closes the stream. The reader exposes `pipeId` and `description`:
```typescript
console.log(reader.pipeId, reader.description);
```
### Pipe Handshake
1. The initiator calls `createPipe(description)`; the SDK sends a `PipeRequest` frame with a random `pipeId` and the description.
2. The receiver's `setOnPipeRequest` callback fires with `{ pipeId, description }`.
3. The receiver calls `acceptPipe(pipeId)`; the SDK sends a `PipeResponse` with `Accepted = true` and opens a new unidirectional stream for raw data.
4. The initiator's `handle.wait()` resolves with a `PipeWriter` bound to that stream.
5. If the receiver calls `denyPipe(pipeId)`, `handle.wait()` resolves with `null`.
Pipes share the same WebTransport session as message frames; they do not need a separate connection.
## Logger Events ## Logger Events
The SDK logger receives parsed events: The SDK logger receives parsed events:
@ -333,4 +409,48 @@ const confirmedId = await rawClient.auth_connect(
); );
``` ```
### Raw Pipes
The raw `WasmClient` exposes the same pipe operations as the SDK wrapper:
```typescript
// Incoming pipe requests
rawClient.set_on_pipe_request((event) => {
const { pipeId, description } = event;
// accept or deny
});
// Outgoing pipe
const handle = await rawClient.create_pipe("file-transfer");
const writer = await handle.wait();
if (writer) {
await writer.write(new Uint8Array([0x01, 0x02]));
await writer.close();
}
// Accept incoming pipe
const reader = await rawClient.accept_pipe(pipeId);
const chunk = await reader.read();
// Deny incoming pipe
await rawClient.deny_pipe(pipeId);
```
Raw `PipeWriter` and `PipeReader` have the same interface as the SDK types:
```typescript
interface PipeWriter {
write(data: Uint8Array): Promise<void>;
close(): Promise<void>;
abort(): void;
readonly pipeId: number;
}
interface PipeReader {
read(): Promise<Uint8Array | null>;
readonly pipeId: number;
readonly description: string;
}
```
A `WasmClient` manages one active WebTransport session. Create a new instance for independent connections, and call `free()` or `[Symbol.dispose]()` on raw WASM objects when you want to release memory eagerly. A `WasmClient` manages one active WebTransport session. Create a new instance for independent connections, and call `free()` or `[Symbol.dispose]()` on raw WASM objects when you want to release memory eagerly.

1
example/.gitignore vendored
View file

@ -11,5 +11,6 @@ web-client/public/host_public_key_bundle.hex
web-client/public/mtp_dev_cert_hash.txt web-client/public/mtp_dev_cert_hash.txt
web-client/dist/ web-client/dist/
client.id
*.mk *.mk
*.mpkb *.mpkb

6
example/Cargo.lock generated
View file

@ -208,6 +208,7 @@ name = "client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"mtp", "mtp",
"rand 0.8.6",
"tokio", "tokio",
] ]
@ -902,6 +903,7 @@ dependencies = [
"mtp-crypto", "mtp-crypto",
"mtp-files", "mtp-files",
"mtp-host", "mtp-host",
"mtp-transport",
"mtp-type-map", "mtp-type-map",
] ]
@ -915,6 +917,7 @@ dependencies = [
"mtp-transport", "mtp-transport",
"rand 0.8.6", "rand 0.8.6",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
@ -984,9 +987,11 @@ dependencies = [
"log", "log",
"mtp-codec", "mtp-codec",
"mtp-common", "mtp-common",
"rcgen",
"rustls", "rustls",
"rustls-native-certs", "rustls-native-certs",
"tokio", "tokio",
"tracing",
"wtransport", "wtransport",
] ]
@ -1574,6 +1579,7 @@ dependencies = [
"mtp", "mtp",
"rcgen", "rcgen",
"serde_json", "serde_json",
"time",
"tokio", "tokio",
] ]

1
example/client.id Normal file
View file

@ -0,0 +1 @@
1000

View file

@ -8,5 +8,6 @@ name = "client"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
mtp = { version = "0.1.0", path = "../../", features = ["client", "crypto", "files"] } mtp = { version = "0.1.0", path = "../../", features = ["client", "crypto", "files", "pipes"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
rand = "0.8"

View file

@ -1,5 +1,6 @@
mod auth; mod auth;
mod messages; mod messages;
mod pipes;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
@ -38,6 +39,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (conn, keyring) = auth::connect_or_register(config, host_public_key, "client").await?; let (conn, keyring) = auth::connect_or_register(config, host_public_key, "client").await?;
messages::send_and_receive(&conn, &keyring, &server_bundle).await?; messages::send_and_receive(&conn, &keyring, &server_bundle).await?;
println!("\n--- Pipe demo ---");
pipes::run_pipe_demo(&conn, 1).await?;
conn.sender.close();
println!("\nDone"); println!("\nDone");
Ok(()) Ok(())
} }

View file

@ -96,13 +96,12 @@ pub async fn send_and_receive(
println!("Sending: {msg}"); println!("Sending: {msg}");
conn.sender.send(&msg).await?; conn.sender.send(&msg).await?;
match conn.receiver.receive().await { match conn.receive().await {
Ok(resp) => { Ok(resp) => {
println!("Received: {resp}"); println!("Received: {resp}");
} }
Err(e) => eprintln!("Receive error: {e}"), Err(e) => eprintln!("Receive error: {e}"),
} }
conn.sender.close();
Ok(()) Ok(())
} }

137
example/client/src/pipes.rs Normal file
View file

@ -0,0 +1,137 @@
use mtp::client::MTPConnection;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot;
use tokio::time::{Duration, Instant};
pub async fn run_pipe_demo(
conn: &MTPConnection,
iterations: usize,
) -> Result<(), Box<dyn std::error::Error>> {
let sizes = [64, 256, 1024, 4096];
let mut all_elapsed = Vec::with_capacity(sizes.len() * iterations);
let mut all_data_only = Vec::with_capacity(sizes.len() * iterations);
for (i, &size) in sizes.iter().enumerate() {
let mut size_elapsed = Vec::with_capacity(iterations);
let mut size_data_only = Vec::with_capacity(iterations);
for run in 0..iterations {
let random_bytes: Vec<u8> = (0..size).map(|_| rand::random::<u8>()).collect();
let description = format!("pipe-demo-{i}-run{run}");
println!(" [pipe {i}.{run}] creating pipe ({size} bytes): {description}");
let handle = conn.create_pipe(&description).await?;
let pipe_id = handle.pipe_id();
println!(" [pipe {i}.{run}] create_pipe returned (pipe_id={pipe_id})");
// Overall timer starts before any I/O
let overall_start = Instant::now();
// Channel to capture the instant the writer actually starts writing
let (write_start_tx, write_start_rx) = oneshot::channel();
let write_bytes = random_bytes.clone();
let writer_handle = tokio::spawn(async move {
println!(" [pipe {i}.{run}] writer: waiting for server accept ...");
match handle.wait().await {
Ok(Some(mut writer)) => {
// Record the instant we begin writing
let _ = write_start_tx.send(Instant::now());
println!(
" [pipe {i}.{run}] writer: pipe accepted (pipe_id={pipe_id}), writing {} bytes ...",
write_bytes.len()
);
writer
.write_all(&write_bytes)
.await
.map_err(|e| mtp::common::PipeError::IoError(e.to_string()))?;
writer
.finish()
.await
.map_err(|e| mtp::common::PipeError::IoError(e.to_string()))?;
println!(" [pipe {i}.{run}] writer: data sent and finished");
Ok::<(), mtp::common::PipeError>(())
}
Ok(None) => {
eprintln!(" [pipe {i}.{run}] writer: pipe denied by server");
Err(mtp::common::PipeError::Rejected)
}
Err(e) => {
eprintln!(" [pipe {i}.{run}] writer: error: {e}");
Err(e)
}
}
});
println!(" [pipe {i}.{run}] waiting for server's return pipe via receive_pipe() ...");
let pipe_req = conn.receive_pipe().await?;
println!(
" [pipe {i}.{run}] received return pipe: id={} desc={:?}",
pipe_req.id(),
pipe_req.description()
);
let mut reader = pipe_req.accept().await?;
println!(" [pipe {i}.{run}] return pipe accepted, reading data ...");
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await?;
let overall_elapsed = overall_start.elapsed();
// Receive the instant the writer started writing
let data_start = write_start_rx.await?;
let data_only_elapsed = Instant::now() - data_start;
match writer_handle.await {
Ok(Ok(())) => {}
Ok(Err(e)) => eprintln!(" [pipe {i}.{run}] writer error: {e}"),
Err(e) => eprintln!(" [pipe {i}.{run}] writer task panicked: {e}"),
}
let matches = buf == random_bytes;
println!(
" [pipe {i}.{run}] round-trip: {} bytes, \
total={:.3}ms, data-only={:.3}ms, match={matches}",
size,
overall_elapsed.as_secs_f64() * 1000.0,
data_only_elapsed.as_secs_f64() * 1000.0,
);
size_elapsed.push(overall_elapsed);
size_data_only.push(data_only_elapsed);
all_elapsed.push(overall_elapsed);
all_data_only.push(data_only_elapsed);
}
// ---- per-size averages ----
let avg_total = average_duration(&size_elapsed);
let avg_data = average_duration(&size_data_only);
println!(
" [pipe {i}] AVERAGE for size {size}: \
total={avg_total:.3}ms, data-only={avg_data:.3}ms \
(over {iterations} runs)"
);
}
// ---- overall averages ----
let overall_total = average_duration(&all_elapsed);
let overall_data = average_duration(&all_data_only);
println!(
" [summary] OVERALL AVERAGE loopback time: \
total={overall_total:.3}ms, data-only={overall_data:.3}ms \
({} measurements)",
all_elapsed.len()
);
Ok(())
}
/// Helper: average a slice of Durations without overflowing.
fn average_duration(durations: &[Duration]) -> f64 {
if durations.is_empty() {
return 0.0;
}
let sum_ms: f64 = durations.iter().map(|d| d.as_secs_f64() * 1000.0).sum();
sum_ms / durations.len() as f64
}

View file

@ -8,9 +8,10 @@ name = "server"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
mtp = { version = "0.1.0", path = "../../", features = ["crypto", "host", "files"] } mtp = { version = "0.1.0", path = "../../", features = ["crypto", "host", "files", "pipes"] }
rcgen = "0.14" rcgen = "0.14"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
serde_json = { version = "1" } serde_json = { version = "1" }
hex = "0.4" hex = "0.4"
base64 = "0.22" base64 = "0.22"
time = "0.3"

View file

@ -4,7 +4,6 @@ mod keys;
mod tls; mod tls;
use mtp::host::{AuthenticationPolicy, HostConfig, MTPHost}; use mtp::host::{AuthenticationPolicy, HostConfig, MTPHost};
use mtp::type_map::TypeMap; use mtp::type_map::TypeMap;
use std::future::Future; use std::future::Future;
use std::path::Path; use std::path::Path;
@ -28,6 +27,54 @@ fn dev_cert_paths() -> (String, String) {
(cert, key) (cert, key)
} }
async fn handle_pipe_loopback(
conn: &mtp::host::MTPConnection,
req: mtp::host::PipeRequest,
) -> Result<(), Box<dyn std::error::Error>> {
let pipe_id = req.id();
println!(
" [loopback] Pipe request: id={pipe_id} description={:?}",
req.description()
);
println!(" [loopback] Calling accept() for pipe {pipe_id} ...");
let mut reader = req.accept().await?;
println!(" [loopback] Pipe {pipe_id} accepted, reading data ...");
let mut buf = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut reader, &mut buf).await?;
println!(
" [loopback] Pipe {pipe_id} read {} bytes, creating return pipe ...",
buf.len()
);
let handle = conn.create_pipe("loopback").await?;
println!(
" [loopback] Return pipe created (id={}), waiting for client ...",
handle.pipe_id()
);
match handle.wait().await? {
Some(mut writer) => {
println!(
" [loopback] Client accepted return pipe, writing {} bytes ...",
buf.len()
);
tokio::io::AsyncWriteExt::write_all(&mut writer, &buf).await?;
writer.finish().await?;
println!(
" [loopback] Pipe {pipe_id} loopback complete ({} bytes)",
buf.len()
);
}
None => {
eprintln!(" [loopback] Return pipe denied by client for pipe {pipe_id}");
}
}
Ok(())
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (cert_path, key_path) = dev_cert_paths(); let (cert_path, key_path) = dev_cert_paths();
@ -39,8 +86,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (_host_id, host_keyring) = keys::load_or_generate_host_keys("host.mk")?; let (_host_id, host_keyring) = keys::load_or_generate_host_keys("host.mk")?;
keys::export_host_public_keys(&host_keyring)?; keys::export_host_public_keys(&host_keyring)?;
// The keyring is moved into the host config; keep a copy for decrypting the
// demo payloads clients encrypt to our KEM public key.
let decrypt_keyring = mtp::crypto::Keyring::from_bytes(&host_keyring.to_bytes()) let decrypt_keyring = mtp::crypto::Keyring::from_bytes(&host_keyring.to_bytes())
.expect("re-load host keyring for decryption"); .expect("re-load host keyring for decryption");
@ -116,20 +161,47 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let tm: &TypeMap = conn.codec.registry().get(&conn.version).unwrap(); let tm: &TypeMap = conn.codec.registry().get(&conn.version).unwrap();
match conn.receiver.receive().await { println!("Waiting for messages / pipe requests ...");
Ok(msg) => { loop {
println!("Received: {msg}"); tokio::select! {
let response = handlers::process_and_respond( biased;
&msg,
tm, pipe_req = conn.receive_pipe() => {
conn.client_public_key.as_ref(), match pipe_req {
&decrypt_keyring, Ok(req) => {
); println!(" Pipe request: id={} desc={:?}", req.id(), req.description());
println!("Sending: {response}"); if let Err(e) = handle_pipe_loopback(&conn, req).await {
conn.sender.send(&response).await?; eprintln!(" Pipe loopback error: {e}");
} }
Err(e) => { }
eprintln!("Receive error: {e}"); Err(e) => {
println!("Pipe channel closed: {e}");
break;
}
}
}
msg = conn.receive() => {
match msg {
Ok(msg) => {
println!("Received: {msg}");
let response = handlers::process_and_respond(
&msg,
tm,
conn.client_public_key.as_ref(),
&decrypt_keyring,
);
println!("Sending: {response}");
if let Err(e) = conn.sender.send(&response).await {
eprintln!("Send error: {e}");
break;
}
}
Err(e) => {
println!("Connection ended: {e}");
break;
}
}
}
} }
} }

View file

@ -1,7 +1,9 @@
use std::fs;
use std::path::Path;
use base64::Engine; use base64::Engine;
use rcgen::{CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType};
use std::fs;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::path::Path;
use time::{Duration, OffsetDateTime};
pub fn load_or_generate_tls( pub fn load_or_generate_tls(
cert_path: &str, cert_path: &str,
@ -19,8 +21,26 @@ pub fn load_or_generate_tls(
if let Some(parent) = Path::new(key_path).parent() { if let Some(parent) = Path::new(key_path).parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
let key_pair = rcgen::KeyPair::generate()?;
let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])?; let key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?;
let mut params = CertificateParams::new(vec!["localhost".into()])?;
params.not_before = OffsetDateTime::now_utc() - Duration::minutes(5);
params.not_after = OffsetDateTime::now_utc() + Duration::days(13);
params
.subject_alt_names
.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
params
.subject_alt_names
.push(SanType::IpAddress(IpAddr::V6(Ipv6Addr::new(
0, 0, 0, 0, 0, 0, 0, 1,
))));
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
params.is_ca = IsCa::NoCa;
let cert = params.self_signed(&key_pair)?; let cert = params.self_signed(&key_pair)?;
let cert_str = cert.pem(); let cert_str = cert.pem();

View file

@ -1,39 +1,89 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MTP Web Client</title> <title>MTP Web Client</title>
<style> <style>
body { background: #111; color: #eee; font-family: "Public Sans", sans-serif; } body {
label, input, textarea { display: block; margin-bottom: 0.5rem; } background: #111;
input, textarea, button { font-family: "Public Sans", sans-serif; } color: #eee;
input, textarea { background: #222; color: #eee; } font-family: "Public Sans", sans-serif;
#status, #key-status { white-space: pre-wrap; } }
.state { color: #ff0; } label,
.received { color: #0ff; } input,
.error { color: #f00; } textarea {
</style> display: block;
</head> margin-bottom: 0.5rem;
<body> }
<h1>MTP WebTransport Client</h1> input,
<label for="server-url">Server URL</label> textarea,
<input id="server-url" value="https://127.0.0.1:8080" /> button {
font-family: "Public Sans", sans-serif;
}
input,
textarea {
background: #222;
color: #eee;
}
#status,
#key-status {
white-space: pre-wrap;
}
.state {
color: #ff0;
}
.received {
color: #0ff;
}
.error {
color: #f00;
}
.pipe {
color: #0f0;
}
hr {
border-color: #444;
margin: 1.5rem 0;
}
</style>
</head>
<body>
<h1>MTP WebTransport Client</h1>
<label for="server-url">Server URL</label>
<input id="server-url" value="https://localhost:8080" />
<label for="host-public-key">Host public key bundle hex</label> <label for="host-public-key">Host public key bundle hex</label>
<textarea id="host-public-key" placeholder="Paste PublicKeyBundle bytes as hex"></textarea> <textarea
id="host-public-key"
placeholder="Paste PublicKeyBundle bytes as hex"
></textarea>
<label for="client-credentials">Saved SDK credentials</label> <label for="client-credentials">Saved SDK credentials</label>
<textarea id="client-credentials" readonly></textarea> <textarea id="client-credentials" readonly></textarea>
<div> <div>
<button id="generate-keypair" type="button">Use new credentials</button> <button id="generate-keypair" type="button">
<button id="connect" type="button" disabled>Connect</button> Use new credentials
<button id="clear-keys" type="button">Clear saved keys</button> </button>
</div> <button id="connect" type="button" disabled>Connect</button>
<button id="clear-keys" type="button">Clear saved keys</button>
</div>
<div id="key-status">Initializing...</div> <hr />
<div id="status"></div> <h2>Pipe Demo</h2>
<script type="module" src="/src/main.ts"></script> <div>
</body> <button id="stream-mic" type="button" disabled>
Stream Microphone (pipe loopback)
</button>
<button id="stop-mic" type="button" disabled>
Stop Microphone
</button>
</div>
<div id="pipe-status"></div>
<div id="key-status">Initializing...</div>
<div id="status"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html> </html>

View file

@ -1,14 +1,28 @@
import { MTPClient } from "mtp"; import { MTPClient } from "mtp";
import type { MTPCredentialStorage, MTPLogEvent, ParsedFrame } from "mtp"; import type {
MTPCredentialStorage,
MTPLogEvent,
MTPPipeReader,
ParsedFrame,
} from "mtp";
const STATUS = document.getElementById("status")!; const STATUS = document.getElementById("status")!;
const KEY_STATUS = document.getElementById("key-status")!; const KEY_STATUS = document.getElementById("key-status")!;
const SERVER_URL = document.getElementById("server-url") as HTMLInputElement; const SERVER_URL = document.getElementById("server-url") as HTMLInputElement;
const HOST_PUBLIC_KEY = document.getElementById("host-public-key") as HTMLTextAreaElement; const HOST_PUBLIC_KEY = document.getElementById(
const CLIENT_CREDENTIALS = document.getElementById("client-credentials") as HTMLTextAreaElement; "host-public-key",
const GENERATE_KEYPAIR = document.getElementById("generate-keypair") as HTMLButtonElement; ) as HTMLTextAreaElement;
const CLIENT_CREDENTIALS = document.getElementById(
"client-credentials",
) as HTMLTextAreaElement;
const GENERATE_KEYPAIR = document.getElementById(
"generate-keypair",
) as HTMLButtonElement;
const CONNECT = document.getElementById("connect") as HTMLButtonElement; const CONNECT = document.getElementById("connect") as HTMLButtonElement;
const CLEAR_KEYS = document.getElementById("clear-keys") as HTMLButtonElement; const CLEAR_KEYS = document.getElementById("clear-keys") as HTMLButtonElement;
const STREAM_MIC = document.getElementById("stream-mic") as HTMLButtonElement;
const STOP_MIC = document.getElementById("stop-mic") as HTMLButtonElement;
const PIPE_STATUS = document.getElementById("pipe-status")!;
const CREDENTIALS_KEY = "mtp-web-client-credentials"; const CREDENTIALS_KEY = "mtp-web-client-credentials";
const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key"; const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key";
@ -22,6 +36,13 @@ type SavedKeys = {
let clientId: bigint | null = null; let clientId: bigint | null = null;
let devCertHash = ""; let devCertHash = "";
let activeClient: ReturnType<typeof createClient> extends Promise<infer T>
? T
: never;
let micStream: MediaStream | null = null;
let mediaRecorder: MediaRecorder | null = null;
let pipeSendCount = 0;
let pendingPipeReaders: MTPPipeReader[] = [];
const credentialStorage: MTPCredentialStorage = { const credentialStorage: MTPCredentialStorage = {
getItem: (key) => localStorage.getItem(key), getItem: (key) => localStorage.getItem(key),
@ -36,6 +57,13 @@ function log(msg: string, cls = "") {
STATUS.appendChild(line); STATUS.appendChild(line);
} }
function pipeLog(msg: string, cls = "pipe") {
const line = document.createElement("div");
line.textContent = msg;
if (cls) line.className = cls;
PIPE_STATUS.appendChild(line);
}
function renderStructured(value: unknown): string { function renderStructured(value: unknown): string {
return JSON.stringify(value, (_key, item) => { return JSON.stringify(value, (_key, item) => {
if (typeof item === "bigint") { if (typeof item === "bigint") {
@ -70,13 +98,16 @@ function setKeyStatus(msg: string) {
} }
function bytesToHex(bytes: Uint8Array): string { function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
} }
function hexToBytes(value: string): Uint8Array { function hexToBytes(value: string): Uint8Array {
const hex = value.replace(/[^0-9a-fA-F]/g, ""); const hex = value.replace(/[^0-9a-fA-F]/g, "");
if (hex.length === 0) throw new Error("host public key is required"); if (hex.length === 0) throw new Error("host public key is required");
if (hex.length % 2 !== 0) throw new Error("host public key hex has an odd length"); if (hex.length % 2 !== 0)
throw new Error("host public key hex has an odd length");
const bytes = new Uint8Array(hex.length / 2); const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i += 1) { for (let i = 0; i < bytes.length; i += 1) {
@ -87,7 +118,10 @@ function hexToBytes(value: string): Uint8Array {
function saveHostPublicKey() { function saveHostPublicKey() {
try { try {
localStorage.setItem(HOST_PUBLIC_KEY_KEY, bytesToHex(hexToBytes(HOST_PUBLIC_KEY.value))); localStorage.setItem(
HOST_PUBLIC_KEY_KEY,
bytesToHex(hexToBytes(HOST_PUBLIC_KEY.value)),
);
} catch { } catch {
localStorage.removeItem(HOST_PUBLIC_KEY_KEY); localStorage.removeItem(HOST_PUBLIC_KEY_KEY);
} }
@ -102,7 +136,9 @@ function loadKeys() {
if (!raw) { if (!raw) {
CLIENT_CREDENTIALS.value = ""; CLIENT_CREDENTIALS.value = "";
setKeyStatus("No saved SDK credentials. The next connection will generate and store a reusable keyring."); setKeyStatus(
"No saved SDK credentials. The next connection will generate and store a reusable keyring.",
);
return; return;
} }
@ -127,11 +163,13 @@ function loadKeys() {
async function loadHostPublicKey() { async function loadHostPublicKey() {
try { try {
const response = await fetch("/host_public_key_bundle.hex", { cache: "no-store" }); const response = await fetch("/host_public_key_bundle.hex", {
cache: "no-store",
});
if (!response.ok) return; if (!response.ok) return;
const hostPublicKey = (await response.text()).trim(); const hostPublicKey = (await response.text()).trim();
if (!hostPublicKey) return; if (!hostPublicKey || !/^[0-9a-f]+$/i.test(hostPublicKey)) return;
HOST_PUBLIC_KEY.value = hostPublicKey; HOST_PUBLIC_KEY.value = hostPublicKey;
saveHostPublicKey(); saveHostPublicKey();
@ -143,11 +181,14 @@ async function loadHostPublicKey() {
async function loadDevCertHash() { async function loadDevCertHash() {
try { try {
const response = await fetch("/mtp_dev_cert_hash.txt", { cache: "no-store" }); const response = await fetch(`/mtp_dev_cert_hash.txt?t=${Date.now()}`, {
cache: "no-store",
});
if (!response.ok) return; if (!response.ok) return;
devCertHash = (await response.text()).trim(); const hash = (await response.text()).trim();
if (devCertHash) { if (/^[0-9a-f]{64}$/i.test(hash)) {
devCertHash = hash;
log(`Loaded WebTransport certificate hash: ${devCertHash}`); log(`Loaded WebTransport certificate hash: ${devCertHash}`);
} }
} catch { } catch {
@ -157,14 +198,47 @@ async function loadDevCertHash() {
async function initWasm() { async function initWasm() {
log("Loading WASM module..."); log("Loading WASM module...");
await MTPClient.create({ url: SERVER_URL.value, storage: credentialStorage, credentialsStorageKey: CREDENTIALS_KEY }); await MTPClient.create({
url: SERVER_URL.value,
storage: credentialStorage,
credentialsStorageKey: CREDENTIALS_KEY,
});
const supported = MTPClient.isSupported(); const supported = MTPClient.isSupported();
log(`WASM loaded. WebTransport supported: ${supported}`); log(`WASM loaded. WebTransport supported: ${supported}`);
CONNECT.disabled = !supported; CONNECT.disabled = !supported;
} }
async function createClient() {
const hostPk = hexToBytes(HOST_PUBLIC_KEY.value);
await loadDevCertHash();
const serverUrl = SERVER_URL.value.trim();
const serverCertificateHashes = devCertHash ? [devCertHash] : undefined;
const client = await MTPClient.create({
url: serverUrl,
hostPublicKey: hostPk,
storage: credentialStorage,
credentialsStorageKey: CREDENTIALS_KEY,
serverCertificateHashes,
pings: { intervalMs: 30_000 },
logger(event) {
log(
renderLoggerEvent(event),
event.hint === "error"
? "error"
: event.type === "state"
? "state"
: "",
);
},
});
return client;
}
async function connect() { async function connect() {
STATUS.textContent = ""; STATUS.textContent = "";
PIPE_STATUS.textContent = "";
if (!MTPClient.isSupported()) { if (!MTPClient.isSupported()) {
log("WebTransport is not supported in this browser.", "error"); log("WebTransport is not supported in this browser.", "error");
@ -175,25 +249,19 @@ async function connect() {
await loadDevCertHash(); await loadDevCertHash();
const serverUrl = SERVER_URL.value.trim(); const serverUrl = SERVER_URL.value.trim();
const serverCertificateHashes = devCertHash ? [`sha-256:${devCertHash}`] : undefined; const serverCertificateHashes = devCertHash ? [devCertHash] : undefined;
if (serverCertificateHashes) { if (serverCertificateHashes) {
log(`Pinning WebTransport certificate hash: ${serverCertificateHashes[0]}`); log(`Pinning WebTransport certificate hash: ${serverCertificateHashes[0]}`);
} else { } else {
log("No WebTransport certificate hash loaded; relying on browser trust store.", "state"); log(
"No WebTransport certificate hash loaded; relying on browser trust store.",
"state",
);
} }
try { try {
const client = await MTPClient.create({ const client = await createClient();
url: serverUrl, activeClient = client;
hostPublicKey: hostPk,
storage: credentialStorage,
credentialsStorageKey: CREDENTIALS_KEY,
serverCertificateHashes,
pings: { intervalMs: 30_000 },
logger(event) {
log(renderLoggerEvent(event), event.hint === "error" ? "error" : event.type === "state" ? "state" : "");
},
});
client.subscribe("Pong", (frame: ParsedFrame) => { client.subscribe("Pong", (frame: ParsedFrame) => {
log(`Subscribed Pong: ${formatParsedFrame(frame)}`, "received"); log(`Subscribed Pong: ${formatParsedFrame(frame)}`, "received");
@ -205,31 +273,164 @@ async function connect() {
log(`Connected as client ${activeClientId}`); log(`Connected as client ${activeClientId}`);
log("\nSending typed Ping..."); log("\nSending typed Ping...");
await client.send("Ping", { await client.send(
Description: "MTP web client send ping", "Ping",
Timestamp: BigInt(Date.now()), {
}, { sender: activeClientId }); Description: "MTP web client send ping",
Timestamp: BigInt(Date.now()),
},
{ sender: activeClientId },
);
log("Typed Ping sent."); log("Typed Ping sent.");
log("\nRequesting Pong by Ping frame id..."); log("\nRequesting Pong by Ping frame id...");
const response = await client.request("Ping", { const response = await client.request(
Description: "MTP web client request ping", "Ping",
Timestamp: BigInt(Date.now()), {
}, { sender: activeClientId, responseType: "Pong" }); Description: "MTP web client request ping",
Timestamp: BigInt(Date.now()),
},
{ sender: activeClientId, responseType: "Pong" },
);
log(`Request response: ${formatParsedFrame(response)}`, "received"); log(`Request response: ${formatParsedFrame(response)}`, "received");
log("\nClient running. Waiting for incoming messages..."); log("\nClient running. Waiting for incoming messages...");
STREAM_MIC.disabled = false;
log("\nPipe demo ready. Click 'Stream Microphone' to start.", "pipe");
} catch (error) { } catch (error) {
log(`[error] ${error}`, "error"); log(`[error] ${error}`, "error");
} }
} }
async function startMicStreaming() {
if (!activeClient) {
pipeLog("No active client connection.", "error");
return;
}
try {
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (e) {
pipeLog(`Microphone access denied: ${e}`, "error");
return;
}
STREAM_MIC.disabled = true;
STOP_MIC.disabled = false;
pipeLog("Microphone acquired. Creating pipe ...");
const handle = await activeClient.createPipe("mic-audio");
pipeLog(
`Pipe created (id=${handle.pipeId}). Waiting for server to accept ...`,
);
// Handle incoming pipe requests from the server (loopback return pipes)
activeClient.setOnPipeRequest(async (request) => {
pipeLog(
`Incoming return pipe: id=${request.pipeId} desc=${request.description}`,
);
try {
const reader = await activeClient!.acceptPipe(request.pipeId);
pendingPipeReaders.push(reader);
readLoopbackPipe(reader);
} catch (e) {
pipeLog(`Failed to accept return pipe: ${e}`, "error");
}
});
const writer = await handle.wait();
if (!writer) {
pipeLog("Pipe denied by server.", "error");
stopMicStreaming();
return;
}
pipeLog(`Pipe accepted. Streaming microphone (pipe id=${writer.pipeId}) ...`);
// Stream microphone audio via MediaRecorder
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: "audio/webm";
mediaRecorder = new MediaRecorder(micStream, { mimeType });
mediaRecorder.ondataavailable = async (event) => {
if (event.data.size === 0 || !activeClient) return;
const startTime = performance.now();
pipeSendCount++;
const chunkNum = pipeSendCount;
try {
const buffer = await event.data.arrayBuffer();
const data = new Uint8Array(buffer);
await writer.write(data);
pipeLog(
` [chunk ${chunkNum}] sent ${data.length} bytes (${(performance.now() - startTime).toFixed(1)}ms write)`,
);
} catch (e) {
pipeLog(` [chunk ${chunkNum}] send error: ${e}`, "error");
}
};
mediaRecorder.start(200); // emit data every 200ms
pipeLog("Streaming started (200ms chunks).");
}
async function readLoopbackPipe(reader: MTPPipeReader) {
const startTime = performance.now();
let totalBytes = 0;
let chunkCount = 0;
try {
while (true) {
const data = await reader.read();
if (data == null) break; // EOF
totalBytes += data.length;
chunkCount++;
}
} catch (e) {
pipeLog(` Return pipe read error: ${e}`, "error");
return;
}
const elapsed = performance.now() - startTime;
pipeLog(
` Return pipe complete: ${chunkCount} chunks, ${totalBytes} bytes, ` +
`delay=${elapsed.toFixed(1)}ms`,
);
// Clean up the reader from the pending list
const idx = pendingPipeReaders.indexOf(reader);
if (idx >= 0) pendingPipeReaders.splice(idx, 1);
}
async function stopMicStreaming() {
if (mediaRecorder && mediaRecorder.state !== "inactive") {
mediaRecorder.stop();
mediaRecorder = null;
}
if (micStream) {
micStream.getTracks().forEach((track) => track.stop());
micStream = null;
}
// Close pending pipe readers
pendingPipeReaders = [];
STREAM_MIC.disabled = false;
STOP_MIC.disabled = true;
pipeLog("Microphone streaming stopped.");
}
GENERATE_KEYPAIR.addEventListener("click", () => { GENERATE_KEYPAIR.addEventListener("click", () => {
try { try {
clientId = null; clientId = null;
localStorage.removeItem(CREDENTIALS_KEY); localStorage.removeItem(CREDENTIALS_KEY);
CLIENT_CREDENTIALS.value = ""; CLIENT_CREDENTIALS.value = "";
setKeyStatus("Cleared saved credentials. The next connection will generate a new reusable keyring."); setKeyStatus(
"Cleared saved credentials. The next connection will generate a new reusable keyring.",
);
log("Cleared saved SDK credentials."); log("Cleared saved SDK credentials.");
} catch (e) { } catch (e) {
log(`Credential reset failed: ${e}`, "error"); log(`Credential reset failed: ${e}`, "error");
@ -257,6 +458,17 @@ CLEAR_KEYS.addEventListener("click", () => {
log("Cleared saved SDK credentials and host public key."); log("Cleared saved SDK credentials and host public key.");
}); });
STREAM_MIC.addEventListener("click", () => {
startMicStreaming().catch((e) => {
pipeLog(`Pipe streaming error: ${e}`, "error");
console.error(e);
});
});
STOP_MIC.addEventListener("click", () => {
stopMicStreaming();
});
HOST_PUBLIC_KEY.addEventListener("change", saveHostPublicKey); HOST_PUBLIC_KEY.addEventListener("change", saveHostPublicKey);
initWasm() initWasm()

View file

@ -1,16 +1,41 @@
import { defineConfig } from 'vite'; import { defineConfig, type Plugin } from 'vite';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { mtp } from 'mtp/vite'; import { mtp } from 'mtp/vite';
const devCertDir = path.resolve(__dirname, '../dev-cert'); const exampleDir = path.resolve(__dirname, '..');
const devCertDir = path.join(exampleDir, 'dev-cert');
const certPath = process.env.MTP_DEV_CERT ?? path.join(devCertDir, 'cert.pem'); const certPath = process.env.MTP_DEV_CERT ?? path.join(devCertDir, 'cert.pem');
const keyPath = process.env.MTP_DEV_KEY ?? path.join(devCertDir, 'key.pem'); const keyPath = process.env.MTP_DEV_KEY ?? path.join(devCertDir, 'key.pem');
const hasDevCert = fs.existsSync(certPath) && fs.existsSync(keyPath); const hasDevCert = fs.existsSync(certPath) && fs.existsSync(keyPath);
const devFiles: Record<string, string> = {
'/host_public_key_bundle.hex': path.join(exampleDir, 'host_public_key_bundle.hex'),
'/mtp_dev_cert_hash.txt': path.join(devCertDir, 'sha256.txt'),
};
function devFileServe(): Plugin {
return {
name: 'dev-file-serve',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const target = devFiles[req.url?.split('?')[0] ?? ''];
if (!target) return next();
fs.readFile(target, (err, data) => {
if (err) return next();
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Cache-Control', 'no-store');
res.end(data);
});
});
},
};
}
export default defineConfig({ export default defineConfig({
plugins: [mtp({ typeMaps: '../../example/type-maps.yaml' })], plugins: [mtp({ typeMaps: '../../example/type-maps.yaml' }), devFileServe()],
server: { server: {
https: hasDevCert https: hasDevCert
? { ? {

View file

@ -9,10 +9,10 @@ mtp-codec = { version = "0.1.0", path = "../codec", features = ["registry"] }
mtp-transport = { version = "0.1.0", path = "../transport", features = ["host"] } mtp-transport = { version = "0.1.0", path = "../transport", features = ["host"] }
mtp-crypto = { version = "0.1.0", path = "../crypto", optional = true } mtp-crypto = { version = "0.1.0", path = "../crypto", optional = true }
rand = "0.8" rand = "0.8"
tokio = { version = "1", features = ["time"] } tokio = { version = "1", features = ["time", "sync"] }
[features] [features]
crypto = ["dep:mtp-crypto", "mtp-codec/crypto"] crypto = ["dep:mtp-crypto", "mtp-codec/crypto"]
# Stream mode is optional at the facade boundary. The underlying transport
# remains framed/stream based so the default API stays backwards compatible. pipes = ["mtp-common/pipes", "mtp-transport/pipes"]
streaming = ["mtp-transport/streaming"]

View file

@ -3,13 +3,22 @@ use mtp_codec::{
registry::{Registry, VersionedCodec}, registry::{Registry, VersionedCodec},
}; };
use mtp_common::CommunicationError; use mtp_common::CommunicationError;
#[cfg(feature = "pipes")]
pub use mtp_common::PipeError;
use std::net::IpAddr; use std::net::IpAddr;
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
use std::pin::Pin; use std::pin::Pin;
use std::{error::Error, fmt}; use std::{error::Error, fmt};
#[cfg(feature = "pipes")]
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
use tokio::time::Duration; use tokio::time::Duration;
#[cfg(feature = "pipes")]
use mtp_transport::PipeReader;
pub use MTPConnection as Connection; pub use MTPConnection as Connection;
pub use MTPHost as Host; pub use MTPHost as Host;
pub use mtp_transport::Policy; pub use mtp_transport::Policy;
@ -17,6 +26,9 @@ pub use mtp_transport::Receiver;
pub use mtp_transport::SendMode; pub use mtp_transport::SendMode;
pub use mtp_transport::Sender; pub use mtp_transport::Sender;
#[cfg(feature = "pipes")]
pub use mtp_transport::PipeWriter;
/* ---- async callback type aliases ---- */ /* ---- async callback type aliases ---- */
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
pub type GetExistingClient = Box< pub type GetExistingClient = Box<
@ -47,6 +59,162 @@ pub enum AuthenticationPolicy {
Unauthenticated, Unauthenticated,
} }
#[cfg(feature = "pipes")]
pub struct PipeHandle {
pipe_id: u32,
description: String,
sender: Sender,
response_rx: tokio::sync::oneshot::Receiver<Result<bool, PipeError>>,
}
#[cfg(feature = "pipes")]
impl PipeHandle {
pub fn pipe_id(&self) -> u32 {
self.pipe_id
}
pub fn description(&self) -> &str {
&self.description
}
pub async fn wait(self) -> Result<Option<PipeWriter>, PipeError> {
match self.response_rx.await {
Ok(Ok(true)) => {
let writer = self
.sender
.open_pipe(self.pipe_id, &self.description)
.await
.map_err(PipeError::from)?;
Ok(Some(writer))
}
Ok(Ok(false)) => Ok(None),
Ok(Err(e)) => Err(e),
Err(_) => Err(PipeError::StreamClosed),
}
}
}
#[cfg(feature = "pipes")]
pub struct PipeRequest {
pipe_id: u32,
description: String,
sender: Sender,
dispatcher: Arc<PipeDispatcher>,
}
#[cfg(feature = "pipes")]
impl PipeRequest {
pub fn id(&self) -> u32 {
self.pipe_id
}
pub fn description(&self) -> &str {
&self.description
}
pub async fn accept(self) -> Result<PipeReader, PipeError> {
let (pipe_tx, pipe_rx) = tokio::sync::oneshot::channel();
{
let mut pending = self.dispatcher.pending_pipes.lock().await;
pending.insert(self.pipe_id, pipe_tx);
}
let resp = CommunicationValue::new(mtp_codec::CommunicationType::PipeResponse)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
self.sender.send(&resp).await.map_err(PipeError::from)?;
let timeout = self.dispatcher.policy.read_timeout;
tokio::time::timeout(timeout, pipe_rx)
.await
.map_err(|_| PipeError::HandshakeTimeout)?
.map_err(|_| PipeError::StreamClosed)
}
pub async fn deny(self) -> Result<(), PipeError> {
let resp = CommunicationValue::new(mtp_codec::CommunicationType::PipeResponse)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
self.sender.send(&resp).await.map_err(PipeError::from)?;
Ok(())
}
}
#[cfg(feature = "pipes")]
struct PipeDispatcher {
pending_creations: Mutex<HashMap<u32, tokio::sync::oneshot::Sender<Result<bool, PipeError>>>>,
pending_pipes: Mutex<HashMap<u32, tokio::sync::oneshot::Sender<PipeReader>>>,
policy: Arc<mtp_transport::Policy>,
}
#[cfg(not(feature = "pipes"))]
struct PipeDispatcher;
#[cfg(not(feature = "pipes"))]
pub(crate) struct PipeRequest;
#[cfg(feature = "pipes")]
async fn run_dispatcher(
receiver: Receiver,
sender: Sender,
app_tx: mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
pipe_req_tx: mpsc::Sender<PipeRequest>,
dispatcher: Arc<PipeDispatcher>,
) {
let pipe_req_type =
mtp_codec::CommunicationType::PipeRequest.to_id(&mtp_codec::TypeMap::latest());
let pipe_resp_type =
mtp_codec::CommunicationType::PipeResponse.to_id(&mtp_codec::TypeMap::latest());
loop {
match receiver.receive_event().await {
Ok(mtp_transport::TransportEvent::Message(msg)) => {
if msg.get_type() == pipe_req_type {
let pipe_id = msg.get_id();
let description = msg
.get_str(DataType::Description)
.unwrap_or("")
.to_string();
let req = PipeRequest {
pipe_id,
description,
sender: sender.clone(),
dispatcher: dispatcher.clone(),
};
let _ = pipe_req_tx.send(req).await;
continue;
}
if msg.get_type() == pipe_resp_type {
let pipe_id = msg.get_id();
let accepted = msg.get_bool(DataType::Accepted).unwrap_or(false);
let mut pending = dispatcher.pending_creations.lock().await;
if let Some(tx) = pending.remove(&pipe_id) {
let _ = tx.send(Ok(accepted));
}
continue;
}
if app_tx.send(Ok(msg)).await.is_err() {
break;
}
}
Ok(mtp_transport::TransportEvent::Pipe(reader)) => {
let pipe_id = reader.pipe_id();
let mut pending = dispatcher.pending_pipes.lock().await;
if let Some(tx) = pending.remove(&pipe_id) {
let _ = tx.send(reader);
}
}
Err(e) => {
if app_tx.send(Err(e)).await.is_err() {
break;
}
}
}
}
}
/* Host configuration. */ /* Host configuration. */
pub struct HostConfig { pub struct HostConfig {
pub ip: IpAddr, pub ip: IpAddr,
@ -180,7 +348,11 @@ pub struct MTPConnection {
pub codec: VersionedCodec, pub codec: VersionedCodec,
pub sender: Sender, pub sender: Sender,
pub receiver: Receiver, pub receiver: Receiver,
app_rx: Mutex<mpsc::Receiver<Result<CommunicationValue, CommunicationError>>>,
pipe_req_rx: Mutex<mpsc::Receiver<PipeRequest>>,
pipe_dispatcher: Arc<PipeDispatcher>,
pub description: Option<String>, pub description: Option<String>,
_dispatcher_task: tokio::task::JoinHandle<()>,
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
pub auth_state: AuthState, pub auth_state: AuthState,
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
@ -242,11 +414,10 @@ impl MTPHost {
Ok(result) => result, Ok(result) => result,
Err(_) => Err(AcceptError::AuthenticationTimedOut), Err(_) => Err(AcceptError::AuthenticationTimedOut),
}; };
return Ok(self.configure_pongs(connection?)); return connection;
} }
AuthenticationPolicy::AllowAuthentication => { AuthenticationPolicy::AllowAuthentication => {
let connection = self.accept_allow_auth(sender, receiver).await?; return self.accept_allow_auth(sender, receiver).await;
return Ok(self.configure_pongs(connection));
} }
AuthenticationPolicy::Unauthenticated => { AuthenticationPolicy::Unauthenticated => {
let first_msg = match receiver.receive().await { let first_msg = match receiver.receive().await {
@ -278,19 +449,16 @@ impl MTPHost {
DataValue::Str(s) => Some(s.clone()), DataValue::Str(s) => Some(s.clone()),
_ => None, _ => None,
}; };
Ok(self.configure_pongs(Some(MTPConnection { Ok(Some(self.connection_from_parts(
version: negotiated,
codec,
sender, sender,
receiver, receiver,
negotiated,
codec,
description, description,
#[cfg(feature = "crypto")] AuthState::Unauthenticated,
auth_state: AuthState::Unauthenticated, rand::random(),
#[cfg(feature = "crypto")] None,
client_id: rand::random(), )))
#[cfg(feature = "crypto")]
client_public_key: None,
})))
} }
} }
@ -318,13 +486,13 @@ impl MTPHost {
DataValue::Str(s) => Some(s.clone()), DataValue::Str(s) => Some(s.clone()),
_ => None, _ => None,
}; };
return Ok(self.configure_pongs(Some(MTPConnection { return Ok(Some(self.connection_from_parts(
version: negotiated,
codec,
sender, sender,
receiver, receiver,
negotiated,
codec,
description, description,
}))); )));
} }
} }
@ -336,16 +504,204 @@ impl MTPHost {
&self.registry &self.registry
} }
fn configure_pongs(&self, connection: Option<MTPConnection>) -> Option<MTPConnection> { #[cfg(not(feature = "crypto"))]
if let Some(connection) = connection { fn connection_from_parts(
&self,
sender: Sender,
receiver: Receiver,
version: Version,
codec: VersionedCodec,
description: Option<String>,
) -> MTPConnection {
#[cfg(feature = "pipes")]
{
if self.config.send_pongs { if self.config.send_pongs {
connection receiver.respond_to_pings(sender.clone());
.receiver
.respond_to_pings(connection.sender.clone());
} }
Some(connection)
} else { let (app_tx, app_rx) =
None mpsc::channel(self.config.policy.receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) =
mpsc::channel(self.config.policy.receiver_queue_capacity);
let dispatcher = Arc::new(PipeDispatcher {
pending_creations: Mutex::new(HashMap::new()),
pending_pipes: Mutex::new(HashMap::new()),
policy: Arc::new(self.config.policy),
});
let dispatcher_clone = dispatcher.clone();
let receiver_clone = receiver.clone();
let sender_clone = sender.clone();
let task = tokio::spawn(run_dispatcher(
receiver_clone,
sender_clone,
app_tx,
pipe_req_tx,
dispatcher_clone,
));
MTPConnection {
version,
codec,
sender,
receiver,
app_rx: Mutex::new(app_rx),
pipe_req_rx: Mutex::new(pipe_req_rx),
pipe_dispatcher: dispatcher,
description,
_dispatcher_task: task,
}
}
#[cfg(not(feature = "pipes"))]
{
if self.config.send_pongs {
receiver.respond_to_pings(sender.clone());
}
let (_, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(1);
let (_, pipe_req_rx) = mpsc::channel::<PipeRequest>(1);
let dispatcher = Arc::new(PipeDispatcher);
let task = tokio::spawn(async {});
MTPConnection {
version,
codec,
sender,
receiver,
app_rx: Mutex::new(app_rx),
pipe_req_rx: Mutex::new(pipe_req_rx),
pipe_dispatcher: dispatcher,
description,
_dispatcher_task: task,
}
}
}
#[cfg(feature = "crypto")]
fn connection_from_parts(
&self,
sender: Sender,
receiver: Receiver,
version: Version,
codec: VersionedCodec,
description: Option<String>,
auth_state: AuthState,
client_id: u64,
client_public_key: Option<mtp_crypto::PublicKeyBundle>,
) -> MTPConnection {
#[cfg(feature = "pipes")]
{
if self.config.send_pongs {
receiver.respond_to_pings(sender.clone());
}
let (app_tx, app_rx) =
mpsc::channel(self.config.policy.receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) =
mpsc::channel(self.config.policy.receiver_queue_capacity);
let dispatcher = Arc::new(PipeDispatcher {
pending_creations: Mutex::new(HashMap::new()),
pending_pipes: Mutex::new(HashMap::new()),
policy: Arc::new(self.config.policy),
});
let dispatcher_clone = dispatcher.clone();
let receiver_clone = receiver.clone();
let sender_clone = sender.clone();
let task = tokio::spawn(run_dispatcher(
receiver_clone,
sender_clone,
app_tx,
pipe_req_tx,
dispatcher_clone,
));
MTPConnection {
version,
codec,
sender,
receiver,
app_rx: Mutex::new(app_rx),
pipe_req_rx: Mutex::new(pipe_req_rx),
pipe_dispatcher: dispatcher,
description,
_dispatcher_task: task,
auth_state,
client_id,
client_public_key,
}
}
#[cfg(not(feature = "pipes"))]
{
if self.config.send_pongs {
receiver.respond_to_pings(sender.clone());
}
let (_, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(1);
let (_, pipe_req_rx) = mpsc::channel::<PipeRequest>(1);
let dispatcher = Arc::new(PipeDispatcher);
let task = tokio::spawn(async {});
MTPConnection {
version,
codec,
sender,
receiver,
app_rx: Mutex::new(app_rx),
pipe_req_rx: Mutex::new(pipe_req_rx),
pipe_dispatcher: dispatcher,
description,
_dispatcher_task: task,
auth_state,
client_id,
client_public_key,
}
}
}
}
#[cfg(feature = "pipes")]
impl MTPConnection {
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
let mut rx = self.app_rx.lock().await;
match rx.recv().await {
Some(result) => result,
None => Err(CommunicationError::StreamClosed),
}
}
pub async fn create_pipe(&self, description: &str) -> Result<PipeHandle, PipeError> {
let pipe_id = rand::random::<u32>();
let (tx, rx) = tokio::sync::oneshot::channel();
{
let mut pending = self.pipe_dispatcher.pending_creations.lock().await;
pending.insert(pipe_id, tx);
}
let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest)
.with_id(pipe_id)
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
self.sender.send(&request).await.map_err(PipeError::from)?;
Ok(PipeHandle {
pipe_id,
description: description.to_string(),
sender: self.sender.clone(),
response_rx: rx,
})
}
pub async fn receive_pipe(&self) -> Result<PipeRequest, CommunicationError> {
let mut rx = self.pipe_req_rx.lock().await;
match rx.recv().await {
Some(req) => Ok(req),
None => Err(CommunicationError::StreamClosed),
} }
} }
} }
@ -668,16 +1024,16 @@ impl MTPHost {
let codec = VersionedCodec::for_version(self.registry.clone(), negotiated.clone()) let codec = VersionedCodec::for_version(self.registry.clone(), negotiated.clone())
.expect("negotiated version must be registered"); .expect("negotiated version must be registered");
Ok(Some(MTPConnection { Ok(Some(self.connection_from_parts(
version: negotiated,
codec,
sender, sender,
receiver, receiver,
negotiated,
codec,
description, description,
auth_state: AuthState::Authenticated, AuthState::Authenticated,
client_id: assigned_id, assigned_id,
client_public_key: Some(client_bundle), Some(client_bundle),
})) )))
} }
/* /*
@ -782,16 +1138,16 @@ impl MTPHost {
}; };
let codec = VersionedCodec::for_version(self.registry.clone(), negotiated.clone()) let codec = VersionedCodec::for_version(self.registry.clone(), negotiated.clone())
.expect("negotiated version must be registered"); .expect("negotiated version must be registered");
return Ok(Some(MTPConnection { return Ok(Some(self.connection_from_parts(
version: negotiated,
codec,
sender, sender,
receiver, receiver,
negotiated,
codec,
description, description,
auth_state: AuthState::Unauthenticated, AuthState::Unauthenticated,
client_id: rand::random(), rand::random(),
client_public_key: None, None,
})); )));
} }
sender.close(); sender.close();

View file

@ -2,6 +2,7 @@ import initWasm, {
ConnectionConfig, ConnectionConfig,
ConnectionState, ConnectionState,
WasmClient, WasmClient,
WasmPipeHandle,
keyring_generate, keyring_generate,
} from "mtp/raw"; } from "mtp/raw";
import * as bindings from "mtp/raw"; import * as bindings from "mtp/raw";
@ -287,6 +288,30 @@ export interface MTPRequestOptions extends MTPSendOptions {
responseType?: MTPCommunicationType; responseType?: MTPCommunicationType;
} }
export interface MTPPipeWriter {
write(data: Uint8Array): Promise<void>;
close(): Promise<void>;
abort(): void;
readonly pipeId: number;
}
export interface MTPPipeReader {
read(): Promise<Uint8Array | null>;
readonly pipeId: number;
readonly description: string;
}
export interface MTPPipeRequest {
pipeId: number;
description: string;
}
export interface MTPOutgoingPipeHandle {
readonly pipeId: number;
readonly description: string;
wait(): Promise<MTPPipeWriter | null>;
}
type InternalCredentials = Omit< type InternalCredentials = Omit<
MTPCredentials, MTPCredentials,
"clientId" | "keyring" | "hostPublicKey" "clientId" | "keyring" | "hostPublicKey"
@ -1546,6 +1571,80 @@ export class MTPClient {
return this.encryptedDeviceSecretProvider.getEncryptedDeviceSecret(query); return this.encryptedDeviceSecretProvider.getEncryptedDeviceSecret(query);
} }
setOnPipeRequest(
handler: ((request: MTPPipeRequest) => void) | null,
): void {
if (handler == null) {
this.raw.client.set_on_pipe_request(null);
return;
}
this.raw.client.set_on_pipe_request(
(event: { pipeId: number; description: string }) => {
emit(this.#options.logger, {
hint: "info",
type: "PipeRequest",
data: event,
direction: "recv",
});
handler({ pipeId: event.pipeId, description: event.description });
},
);
}
async createPipe(description: string): Promise<MTPOutgoingPipeHandle> {
if (typeof description !== "string") {
throw new TypeError("description must be a string");
}
const handle: WasmPipeHandle = await this.raw.client.create_pipe(
description,
);
const sdk = this;
return {
pipeId: handle.pipeId,
description: handle.description,
async wait(): Promise<MTPPipeWriter | null> {
const result = await handle.wait();
if (result == null) {
return null;
}
emit(sdk.#options.logger, {
hint: "info",
type: "PipeCreated",
data: { pipeId: result.pipeId },
direction: "send",
});
return result as unknown as MTPPipeWriter;
},
};
}
async acceptPipe(pipeId: number): Promise<MTPPipeReader> {
if (typeof pipeId !== "number" || !Number.isFinite(pipeId)) {
throw new TypeError("pipeId must be a finite number");
}
const reader = await this.raw.client.accept_pipe(pipeId);
emit(this.#options.logger, {
hint: "info",
type: "PipeAccepted",
data: { pipeId: reader.pipeId, description: reader.description },
direction: "send",
});
return reader as unknown as MTPPipeReader;
}
async denyPipe(pipeId: number): Promise<void> {
if (typeof pipeId !== "number" || !Number.isFinite(pipeId)) {
throw new TypeError("pipeId must be a finite number");
}
await this.raw.client.deny_pipe(pipeId);
emit(this.#options.logger, {
hint: "info",
type: "PipeDenied",
data: { pipeId },
direction: "send",
});
}
disconnect(): void { disconnect(): void {
this.raw.client.stop_protocol_pings(); this.raw.client.stop_protocol_pings();
this.raw.client.disconnect(); this.raw.client.disconnect();

View file

@ -25,12 +25,7 @@ name = "integration"
required-features = ["host"] required-features = ["host"]
[features] [features]
default = []
# Enables hosting a MTP server # Enables hosting a MTP server
host = [] host = []
# Documents and exposes the framed uni-directional stream transport used by
# the host and client facades. The transport is stream based by design, so pipes = ["mtp-codec/pipes"]
# keeping this as a marker feature lets facade crates opt into their
# stream-specific convenience exports without making the core transport
# unusable for existing consumers.
streaming = []

View file

@ -1,4 +1,6 @@
use crate::ConnectionHandle; use crate::ConnectionHandle;
#[cfg(feature = "pipes")]
use crate::pipe::PipeReader;
use mtp_codec::CommunicationValue; use mtp_codec::CommunicationValue;
use mtp_common::CommunicationError; use mtp_common::CommunicationError;
use std::sync::Arc; use std::sync::Arc;
@ -7,6 +9,13 @@ use tokio::time::{Duration, sleep, timeout};
use wtransport::Connection; use wtransport::Connection;
use tracing::{debug, info, instrument, trace}; use tracing::{debug, info, instrument, trace};
#[cfg(feature = "pipes")]
#[derive(Debug)]
pub enum TransportEvent {
Message(CommunicationValue),
Pipe(PipeReader),
}
const APPLICATION_CLOSE_REASON: &str = "mtp-close"; const APPLICATION_CLOSE_REASON: &str = "mtp-close";
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -418,6 +427,43 @@ impl Sender {
&self.handle &self.handle
} }
#[cfg(feature = "pipes")]
#[instrument(skip(self, description), level = "trace")]
pub async fn open_pipe(
&self,
pipe_id: u32,
description: &str,
) -> Result<crate::pipe::PipeWriter, CommunicationError> {
if self.handle.is_closed() {
return Err(self
.handle
.close_reason()
.unwrap_or(CommunicationError::UseAfterClosed));
}
if self.connection.quic_connection().close_reason().is_some() {
let reason = self
.handle
.close_reason()
.unwrap_or(CommunicationError::StreamClosed);
self.handle.close(Some(reason.clone()));
return Err(reason);
}
let mut stream = Self::open_uni_stream(&self.connection, &self.policy).await?;
let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest)
.with_id(pipe_id)
.add_typed_default(
mtp_codec::DataType::Description,
mtp_codec::DataValue::Str(description.to_string()),
);
Self::write_frame(&mut stream, &request, &self.policy).await?;
Ok(crate::pipe::PipeWriter { stream })
}
#[instrument(skip(self), level = "trace")] #[instrument(skip(self), level = "trace")]
pub fn close(&self) { pub fn close(&self) {
info!(target = "mtp.transport", "fire-and-forget close requested"); info!(target = "mtp.transport", "fire-and-forget close requested");
@ -518,12 +564,25 @@ impl Sender {
} }
} }
/// Single-consumer framed message receiver. /// Framed message receiver.
/// ///
/// `receive()` is intended to be driven by one task at a time. Internally the /// When the `pipes` feature is disabled, `receive()` is intended to be driven
/// underlying `mpsc::Receiver` is protected by a mutex so the type remains /// by one task at a time. When `pipes` is enabled, an internal dispatcher task
/// `Sync`, but it is not a multi-consumer queue. /// consumes events from the channel; applications should use the
/// `MTPConnection::receive()` and `MTPConnection::receive_pipe()` methods
/// instead of calling `receiver.receive()` directly.
///
/// The type is cheaply cloneable: all clones share the same internal channel.
pub struct Receiver { pub struct Receiver {
inner: Arc<ReceiverInner>,
}
struct ReceiverInner {
#[cfg(feature = "pipes")]
msg_rx: Mutex<mpsc::Receiver<Result<CommunicationValue, CommunicationError>>>,
#[cfg(feature = "pipes")]
pipe_rx: Mutex<mpsc::Receiver<PipeReader>>,
#[cfg(not(feature = "pipes"))]
rx: Mutex<mpsc::Receiver<Result<CommunicationValue, CommunicationError>>>, rx: Mutex<mpsc::Receiver<Result<CommunicationValue, CommunicationError>>>,
_accept_task: tokio::task::JoinHandle<()>, _accept_task: tokio::task::JoinHandle<()>,
handle: Arc<ConnectionHandle>, handle: Arc<ConnectionHandle>,
@ -531,26 +590,39 @@ pub struct Receiver {
queue_notify: Arc<Notify>, queue_notify: Arc<Notify>,
} }
impl Clone for Receiver {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
}
impl Drop for Receiver {
fn drop(&mut self) {
if Arc::strong_count(&self.inner) == 1 {
self.inner._accept_task.abort();
}
}
}
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct PingControl { struct PingControl {
pong_sender: Option<Sender>, pong_sender: Option<Sender>,
pong_observer: Option<mpsc::UnboundedSender<CommunicationValue>>, pong_observer: Option<mpsc::UnboundedSender<CommunicationValue>>,
} }
impl Drop for Receiver {
fn drop(&mut self) {
// The accept loop holds clones of the connection and the shared
// ConnectionHandle. Without this, dropping a Receiver without first
// closing the connection would leave that task running forever. Abort
// it directly rather than closing the shared handle, so a still-live
// Sender on the same connection is unaffected. abort() is a no-op if
// the task already finished (e.g. the connection was closed).
self._accept_task.abort();
}
}
impl Receiver { impl Receiver {
pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self { pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self {
#[cfg(feature = "pipes")]
let (msg_tx, msg_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
policy.receiver_queue_capacity,
);
#[cfg(feature = "pipes")]
let (pipe_tx, pipe_rx) = mpsc::channel::<PipeReader>(
policy.receiver_queue_capacity,
);
#[cfg(not(feature = "pipes"))]
let (tx, rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>( let (tx, rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
policy.receiver_queue_capacity, policy.receiver_queue_capacity,
); );
@ -581,7 +653,12 @@ impl Receiver {
let mut close_rx = conn_handle.subscribe_close(); let mut close_rx = conn_handle.subscribe_close();
loop { loop {
if tx.capacity() == 0 { #[cfg(feature = "pipes")]
let cap_full = msg_tx.capacity() == 0 || pipe_tx.capacity() == 0;
#[cfg(not(feature = "pipes"))]
let cap_full = tx.capacity() == 0;
if cap_full {
trace!(target = "mtp.transport", "accept loop paused: receiver queue full"); trace!(target = "mtp.transport", "accept loop paused: receiver queue full");
tokio::select! { tokio::select! {
_ = close_rx.changed() => { _ = close_rx.changed() => {
@ -611,6 +688,11 @@ impl Receiver {
Ok(permit) => permit, Ok(permit) => permit,
Err(_) => break, Err(_) => break,
}; };
#[cfg(feature = "pipes")]
let msg_tx_stream = msg_tx.clone();
#[cfg(feature = "pipes")]
let pipe_tx_stream = pipe_tx.clone();
#[cfg(not(feature = "pipes"))]
let tx_stream = tx.clone(); let tx_stream = tx.clone();
let stream_handle = conn_handle.clone(); let stream_handle = conn_handle.clone();
let stream_policy = accept_policy.clone(); let stream_policy = accept_policy.clone();
@ -625,6 +707,9 @@ impl Receiver {
&& frame_count >= max_frames && frame_count >= max_frames
{ {
let close_error = CommunicationError::StreamError; let close_error = CommunicationError::StreamError;
#[cfg(feature = "pipes")]
let _ = msg_tx_stream.send(Err(close_error.clone())).await;
#[cfg(not(feature = "pipes"))]
let _ = tx_stream.send(Err(close_error.clone())).await; let _ = tx_stream.send(Err(close_error.clone())).await;
stream_handle.close(Some(close_error)); stream_handle.close(Some(close_error));
break; break;
@ -633,6 +718,40 @@ impl Receiver {
match Self::read_one_frame(&mut s, &stream_policy).await { match Self::read_one_frame(&mut s, &stream_policy).await {
Ok(ReceivedFrame::Message(msg)) => { Ok(ReceivedFrame::Message(msg)) => {
frame_count += 1; frame_count += 1;
#[cfg(feature = "pipes")]
{
let pipe_request_type =
mtp_codec::CommunicationType::PipeRequest
.to_id(&mtp_codec::TypeMap::latest());
if msg.get_type() == pipe_request_type
&& frame_count == 1
{
let pipe_id = msg.get_id();
let description = msg
.get_str(mtp_codec::DataType::Description)
.unwrap_or("")
.to_string();
let pipe_reader = crate::pipe::PipeReader {
stream: s,
description,
pipe_id,
};
if pipe_tx_stream
.send(pipe_reader)
.await
.is_err()
{
stream_handle.close(Some(
CommunicationError::StreamClosed,
));
}
break;
}
}
let ping_type = mtp_codec::CommunicationType::Ping let ping_type = mtp_codec::CommunicationType::Ping
.to_id(&mtp_codec::TypeMap::latest()); .to_id(&mtp_codec::TypeMap::latest());
let pong_type = mtp_codec::CommunicationType::Pong let pong_type = mtp_codec::CommunicationType::Pong
@ -674,12 +793,24 @@ impl Receiver {
continue; continue;
} }
#[cfg(feature = "pipes")]
if msg_tx_stream
.send(Ok(msg))
.await
.is_err()
{
break;
}
#[cfg(not(feature = "pipes"))]
if tx_stream.send(Ok(msg)).await.is_err() { if tx_stream.send(Ok(msg)).await.is_err() {
break; break;
} }
} }
Ok(ReceivedFrame::ClosedByPeer) => { Ok(ReceivedFrame::ClosedByPeer) => {
let close_error = CommunicationError::StreamClosed; let close_error = CommunicationError::StreamClosed;
#[cfg(feature = "pipes")]
let _ = msg_tx_stream.send(Err(close_error.clone())).await;
#[cfg(not(feature = "pipes"))]
let _ = tx_stream.send(Err(close_error.clone())).await; let _ = tx_stream.send(Err(close_error.clone())).await;
stream_handle.close(Some(close_error)); stream_handle.close(Some(close_error));
break; break;
@ -697,6 +828,9 @@ impl Receiver {
other => other, other => other,
}; };
#[cfg(feature = "pipes")]
let _ = msg_tx_stream.send(Err(close_error.clone())).await;
#[cfg(not(feature = "pipes"))]
let _ = tx_stream.send(Err(close_error.clone())).await; let _ = tx_stream.send(Err(close_error.clone())).await;
stream_handle.close(Some(close_error)); stream_handle.close(Some(close_error));
break; break;
@ -709,6 +843,9 @@ impl Receiver {
Ok(Err(_e)) => { Ok(Err(_e)) => {
// A connection error from accept_uni means the connection is permanently closed. // A connection error from accept_uni means the connection is permanently closed.
let close_error = CommunicationError::StreamClosed; let close_error = CommunicationError::StreamClosed;
#[cfg(feature = "pipes")]
let _ = msg_tx.send(Err(close_error.clone())).await;
#[cfg(not(feature = "pipes"))]
let _ = tx.send(Err(close_error.clone())).await; let _ = tx.send(Err(close_error.clone())).await;
conn_handle.close(Some(close_error)); conn_handle.close(Some(close_error));
break; break;
@ -717,6 +854,9 @@ impl Receiver {
Err(_) => { Err(_) => {
if accept_connection.quic_connection().close_reason().is_some() { if accept_connection.quic_connection().close_reason().is_some() {
let close_error = CommunicationError::StreamClosed; let close_error = CommunicationError::StreamClosed;
#[cfg(feature = "pipes")]
let _ = msg_tx.send(Err(close_error.clone())).await;
#[cfg(not(feature = "pipes"))]
let _ = tx.send(Err(close_error.clone())).await; let _ = tx.send(Err(close_error.clone())).await;
conn_handle.close(Some(close_error)); conn_handle.close(Some(close_error));
break; break;
@ -733,17 +873,24 @@ impl Receiver {
}); });
Self { Self {
rx: Mutex::new(rx), inner: Arc::new(ReceiverInner {
_accept_task: accept_task, #[cfg(feature = "pipes")]
handle, msg_rx: Mutex::new(msg_rx),
ping_control, #[cfg(feature = "pipes")]
queue_notify, pipe_rx: Mutex::new(pipe_rx),
#[cfg(not(feature = "pipes"))]
rx: Mutex::new(rx),
_accept_task: accept_task,
handle,
ping_control,
queue_notify,
}),
} }
} }
/* Respond to reserved Ping frames without exposing them to application I/O. */ /* Respond to reserved Ping frames without exposing them to application I/O. */
pub fn respond_to_pings(&self, sender: Sender) { pub fn respond_to_pings(&self, sender: Sender) {
if let Ok(mut control) = self.ping_control.try_write() { if let Ok(mut control) = self.inner.ping_control.try_write() {
control.pong_sender = Some(sender); control.pong_sender = Some(sender);
} else { } else {
log::warn!("[Receiver] could not register Ping responder: control lock busy"); log::warn!("[Receiver] could not register Ping responder: control lock busy");
@ -752,7 +899,7 @@ impl Receiver {
/* Route reserved Pong frames to a connection-level observer. */ /* Route reserved Pong frames to a connection-level observer. */
pub fn observe_pongs(&self, observer: mpsc::UnboundedSender<CommunicationValue>) { pub fn observe_pongs(&self, observer: mpsc::UnboundedSender<CommunicationValue>) {
if let Ok(mut control) = self.ping_control.try_write() { if let Ok(mut control) = self.inner.ping_control.try_write() {
control.pong_observer = Some(observer); control.pong_observer = Some(observer);
} else { } else {
log::warn!("[Receiver] could not register Pong observer: control lock busy"); log::warn!("[Receiver] could not register Pong observer: control lock busy");
@ -837,44 +984,163 @@ impl Receiver {
#[instrument(skip(self), level = "trace")] #[instrument(skip(self), level = "trace")]
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> { pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
if self.handle.is_closed() { if self.inner.handle.is_closed() {
return Err(self return Err(self
.inner
.handle .handle
.close_reason() .close_reason()
.unwrap_or(CommunicationError::StreamClosed)); .unwrap_or(CommunicationError::StreamClosed));
} }
let mut rx = self.rx.lock().await; #[cfg(feature = "pipes")]
match rx.recv().await { {
Some(result) => { let mut rx = self.inner.msg_rx.lock().await;
self.queue_notify.notify_one(); match rx.recv().await {
result Some(Ok(msg)) => {
self.inner.queue_notify.notify_one();
Ok(msg)
}
Some(Err(e)) => Err(e),
None => Err(self
.inner
.handle
.close_reason()
.unwrap_or(CommunicationError::StreamClosed)),
} }
_ => Err(self }
#[cfg(not(feature = "pipes"))]
{
let mut rx = self.inner.rx.lock().await;
match rx.recv().await {
Some(result) => {
self.inner.queue_notify.notify_one();
result
}
None => Err(self
.inner
.handle
.close_reason()
.unwrap_or(CommunicationError::StreamClosed)),
}
}
}
#[cfg(feature = "pipes")]
#[instrument(skip(self), level = "trace")]
pub async fn receive_event(&self) -> Result<TransportEvent, CommunicationError> {
if self.inner.handle.is_closed() {
return Err(self
.inner
.handle
.close_reason()
.unwrap_or(CommunicationError::StreamClosed));
}
let mut msg_rx = self.inner.msg_rx.lock().await;
let mut pipe_rx = self.inner.pipe_rx.lock().await;
tokio::select! {
msg = msg_rx.recv() => {
match msg {
Some(Ok(val)) => {
self.inner.queue_notify.notify_one();
Ok(TransportEvent::Message(val))
}
Some(Err(e)) => Err(e),
None => Err(self
.inner
.handle
.close_reason()
.unwrap_or(CommunicationError::StreamClosed)),
}
}
pipe = pipe_rx.recv() => {
match pipe {
Some(reader) => {
self.inner.queue_notify.notify_one();
Ok(TransportEvent::Pipe(reader))
}
None => Err(self
.inner
.handle
.close_reason()
.unwrap_or(CommunicationError::StreamClosed)),
}
}
}
}
#[cfg(feature = "pipes")]
#[instrument(skip(self), level = "trace")]
pub async fn receive_pipe(&self) -> Result<PipeReader, CommunicationError> {
if self.inner.handle.is_closed() {
return Err(self
.inner
.handle
.close_reason()
.unwrap_or(CommunicationError::StreamClosed));
}
let mut rx = self.inner.pipe_rx.lock().await;
match rx.recv().await {
Some(reader) => {
self.inner.queue_notify.notify_one();
Ok(reader)
}
None => Err(self
.inner
.handle .handle
.close_reason() .close_reason()
.unwrap_or(CommunicationError::StreamClosed)), .unwrap_or(CommunicationError::StreamClosed)),
} }
} }
#[cfg(feature = "pipes")]
pub fn try_receive_pipe(&self) -> Result<Option<PipeReader>, CommunicationError> {
if self.inner.handle.is_closed() {
return Err(self
.inner
.handle
.close_reason()
.unwrap_or(CommunicationError::StreamClosed));
}
match self.inner.pipe_rx.try_lock() {
Ok(mut rx) => match rx.try_recv() {
Ok(reader) => {
self.inner.queue_notify.notify_one();
Ok(Some(reader))
}
Err(mpsc::error::TryRecvError::Empty) => Ok(None),
Err(mpsc::error::TryRecvError::Disconnected) => {
return Err(self
.inner
.handle
.close_reason()
.unwrap_or(CommunicationError::StreamClosed));
}
},
Err(_) => Ok(None),
}
}
pub fn handle(&self) -> &Arc<ConnectionHandle> { pub fn handle(&self) -> &Arc<ConnectionHandle> {
&self.handle &self.inner.handle
} }
pub fn close(&self) { pub fn close(&self) {
self.handle.close(None); self.inner.handle.close(None);
} }
pub fn is_open(&self) -> bool { pub fn is_open(&self) -> bool {
self.handle.is_open() self.inner.handle.is_open()
} }
pub fn is_closed(&self) -> bool { pub fn is_closed(&self) -> bool {
self.handle.is_closed() self.inner.handle.is_closed()
} }
pub fn close_reason(&self) -> Option<CommunicationError> { pub fn close_reason(&self) -> Option<CommunicationError> {
self.handle.close_reason() self.inner.handle.close_reason()
} }
} }

View file

@ -2,8 +2,16 @@ pub mod client;
pub mod connection; pub mod connection;
pub mod connection_handle; pub mod connection_handle;
#[cfg(feature = "pipes")]
pub mod pipe;
pub use connection::{Policy, Receiver, SendMode, Sender}; pub use connection::{Policy, Receiver, SendMode, Sender};
#[cfg(feature = "pipes")]
pub use connection::TransportEvent;
#[cfg(feature = "pipes")]
pub use pipe::{PipeReader, PipeWriter};
pub use client::connect; pub use client::connect;
pub use connection_handle::ConnectionHandle; pub use connection_handle::ConnectionHandle;

69
transport/src/pipe.rs Normal file
View file

@ -0,0 +1,69 @@
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
#[derive(Debug)]
pub struct PipeWriter {
pub(crate) stream: wtransport::SendStream,
}
impl PipeWriter {
pub async fn finish(mut self) -> Result<(), mtp_common::CommunicationError> {
self.stream
.finish()
.await
.map_err(|e| {
log::warn!("[PipeWriter] finish failed: {e}");
mtp_common::CommunicationError::StreamWriteError(e)
})
}
pub fn abort(&mut self) -> Result<(), wtransport::error::ClosedStream> {
self.stream.reset(wtransport::VarInt::from_u32(0))
}
}
impl AsyncWrite for PipeWriter {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
Pin::new(&mut self.stream).poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.stream).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.stream).poll_shutdown(cx)
}
}
#[derive(Debug)]
pub struct PipeReader {
pub(crate) stream: wtransport::RecvStream,
pub(crate) description: String,
pub(crate) pipe_id: u32,
}
impl PipeReader {
pub fn description(&self) -> &str {
&self.description
}
pub fn pipe_id(&self) -> u32 {
self.pipe_id
}
}
impl AsyncRead for PipeReader {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.stream).poll_read(cx, buf)
}
}

View file

@ -5,11 +5,12 @@ edition = "2024"
build = "build.rs" build = "build.rs"
[features] [features]
default = []
# Enables multi-version type-map constructors, builtin_type_maps(), and # Enables multi-version type-map constructors, builtin_type_maps(), and
# the Registry struct for version negotiation. Used by host, not client. # the Registry struct for version negotiation. Used by host, not client.
registry = [] registry = []
pipes = []
[dependencies] [dependencies]
[build-dependencies] [build-dependencies]

View file

@ -116,6 +116,21 @@ const RESERVED_COMM_TYPES: &[ReservedEntry] = &[
name: "GatewayTimeout", name: "GatewayTimeout",
id: 22, id: 22,
}, },
#[cfg(feature = "pipes")]
ReservedEntry {
name: "PipeRequest",
id: 23,
},
#[cfg(feature = "pipes")]
ReservedEntry {
name: "PipeResponse",
id: 24,
},
#[cfg(feature = "pipes")]
ReservedEntry {
name: "PipeAbort",
id: 25,
},
]; ];
const RESERVED_DATA_TYPES: &[ReservedEntry] = &[ const RESERVED_DATA_TYPES: &[ReservedEntry] = &[
@ -168,6 +183,10 @@ const RESERVED_DATA_TYPES: &[ReservedEntry] = &[
name: "ErrorMessage", name: "ErrorMessage",
id: 12, id: 12,
}, },
ReservedEntry {
name: "Accepted",
id: 13,
},
]; ];
fn main() { fn main() {

View file

@ -23,9 +23,13 @@ getrandom-v02 = { package = "getrandom", version = "0.2", features = ["js"] }
mtp-common = { version = "0.1.0", path = "../common" } mtp-common = { version = "0.1.0", path = "../common" }
mtp-type-map = { version = "0.1.0", path = "../type-map" } mtp-type-map = { version = "0.1.0", path = "../type-map" }
mtp-codec = { version = "0.1.0", path = "../codec", features = ["crypto"] } mtp-codec = { version = "0.1.0", path = "../codec", features = ["crypto", "pipes"] }
mtp-crypto = { version = "0.1.0", path = "../crypto", features = ["wasm"] } mtp-crypto = { version = "0.1.0", path = "../crypto", features = ["wasm"] }
[dev-dependencies] [dev-dependencies]
wasm-bindgen-test = "0.3" wasm-bindgen-test = "0.3"
hex = "0.4" hex = "0.4"
[features]
default = []
pipes = []

View file

@ -13,6 +13,7 @@ use mtp_crypto::SignatureScheme;
use crate::config::ConnectionConfig; use crate::config::ConnectionConfig;
use crate::error::js_error; use crate::error::js_error;
use crate::pipe::PipeReader;
use crate::transport::WasmTransport; use crate::transport::WasmTransport;
struct PendingRequest { struct PendingRequest {
@ -25,6 +26,57 @@ struct PingTimer {
closure: Closure<dyn FnMut()>, closure: Closure<dyn FnMut()>,
} }
#[wasm_bindgen(typescript_custom_section)]
const PIPE_HANDLE_TS: &str = r#"
export interface WasmPipeHandle {
wait(): Promise<PipeWriter | null>;
readonly pipeId: number;
readonly description: string;
}
"#;
#[wasm_bindgen]
pub struct WasmPipeHandle {
pipe_id: u32,
description: String,
transport: WasmTransport,
response_rx: Rc<RefCell<Option<oneshot::Receiver<Result<bool, JsValue>>>>>,
}
#[wasm_bindgen]
impl WasmPipeHandle {
pub async fn wait(&self) -> Result<JsValue, JsValue> {
let rx = self
.response_rx
.borrow_mut()
.take()
.ok_or_else(|| js_error("handle already consumed"))?;
let accepted = rx
.await
.map_err(|_| js_error("pipe handle channel closed"))?;
match accepted {
Ok(true) => {
let writer = self.transport.open_pipe(self.pipe_id, &self.description).await?;
Ok(JsValue::from(writer))
}
Ok(false) => Ok(JsValue::NULL),
Err(e) => Err(e),
}
}
#[wasm_bindgen(getter)]
pub fn pipe_id(&self) -> u32 {
self.pipe_id
}
#[wasm_bindgen(getter)]
pub fn description(&self) -> String {
self.description.clone()
}
}
fn frame_property(frame: &JsValue, key: &str) -> Option<JsValue> { fn frame_property(frame: &JsValue, key: &str) -> Option<JsValue> {
js_sys::Reflect::get(frame, &JsValue::from_str(key)) js_sys::Reflect::get(frame, &JsValue::from_str(key))
.ok() .ok()
@ -110,6 +162,22 @@ fn reject_pending_requests(
} }
} }
fn reject_pending_pipe_creations(
pending: &Rc<RefCell<HashMap<u32, oneshot::Sender<Result<bool, JsValue>>>>>,
message: &str,
) {
let pending = std::mem::take(&mut *pending.borrow_mut());
for (_, tx) in pending {
let _ = tx.send(Err(js_error(message)));
}
}
fn random_pipe_id() -> Result<u32, JsValue> {
let mut bytes = [0u8; 4];
getrandom::fill(&mut bytes).map_err(|_| js_error("rng failed"))?;
Ok(u32::from_be_bytes(bytes))
}
fn raw_frame_preview(bytes: &[u8]) -> String { fn raw_frame_preview(bytes: &[u8]) -> String {
let shown = bytes.len().min(256); let shown = bytes.len().min(256);
let mut preview = hex::encode(&bytes[..shown]); let mut preview = hex::encode(&bytes[..shown]);
@ -261,6 +329,9 @@ pub struct WasmClient {
next_subscription_id: Rc<Cell<u32>>, next_subscription_id: Rc<Cell<u32>>,
pending_requests: Rc<RefCell<HashMap<u32, PendingRequest>>>, pending_requests: Rc<RefCell<HashMap<u32, PendingRequest>>>,
ping_timer: Rc<RefCell<Option<PingTimer>>>, ping_timer: Rc<RefCell<Option<PingTimer>>>,
pending_pipe_creations: Rc<RefCell<HashMap<u32, oneshot::Sender<Result<bool, JsValue>>>>>,
pending_pipes: Rc<RefCell<HashMap<u32, oneshot::Sender<PipeReader>>>>,
on_pipe_request: Rc<RefCell<Option<js_sys::Function>>>,
} }
#[wasm_bindgen] #[wasm_bindgen]
@ -282,6 +353,9 @@ impl WasmClient {
next_subscription_id: Rc::new(Cell::new(1)), next_subscription_id: Rc::new(Cell::new(1)),
pending_requests: Rc::new(RefCell::new(HashMap::new())), pending_requests: Rc::new(RefCell::new(HashMap::new())),
ping_timer: Rc::new(RefCell::new(None)), ping_timer: Rc::new(RefCell::new(None)),
pending_pipe_creations: Rc::new(RefCell::new(HashMap::new())),
pending_pipes: Rc::new(RefCell::new(HashMap::new())),
on_pipe_request: Rc::new(RefCell::new(None)),
} }
} }
@ -686,9 +760,96 @@ impl WasmClient {
} }
self.subscriptions.borrow_mut().clear(); self.subscriptions.borrow_mut().clear();
self.reject_pending_requests("disconnected"); self.reject_pending_requests("disconnected");
reject_pending_pipe_creations(&self.pending_pipe_creations, "disconnected");
self.set_state(ConnectionState::Disconnected); self.set_state(ConnectionState::Disconnected);
} }
/// Set the callback invoked when a remote peer opens a pipe request.
/// The callback receives a plain JS object `{ pipeId: number, description: string }`.
#[wasm_bindgen]
pub fn set_on_pipe_request(&self, callback: Option<js_sys::Function>) {
*self.on_pipe_request.borrow_mut() = callback;
}
/// Initiate an outgoing pipe. Returns a `WasmPipeHandle` whose `wait()`
/// method resolves after the remote peer accepts (or denies) the request.
#[wasm_bindgen]
pub async fn create_pipe(&self, description: &str) -> Result<WasmPipeHandle, JsValue> {
let transport = self
.transport
.borrow()
.clone()
.ok_or_else(|| js_error("not connected"))?;
let pipe_id = random_pipe_id()?;
let (tx, rx) = oneshot::channel();
self.pending_pipe_creations
.borrow_mut()
.insert(pipe_id, tx);
let request =
CommunicationValue::new(CommunicationType::PipeRequest).with_id(pipe_id).add_typed_default(
DataType::Description,
DataValue::Str(description.to_string()),
);
let request_bytes = request
.to_bytes()
.map_err(|e| js_error(&format!("encode failed: {}", e)))?;
transport.send_frame(&request_bytes).await?;
Ok(WasmPipeHandle {
pipe_id,
description: description.to_string(),
transport,
response_rx: Rc::new(RefCell::new(Some(rx))),
})
}
/// Accept an incoming pipe request (identified by `pipe_id`). Sends a
/// `PipeResponse` with `Accepted = true` and returns a `PipeReader` once
/// the remote peer opens the pipe stream.
#[wasm_bindgen]
pub async fn accept_pipe(&self, pipe_id: u32) -> Result<PipeReader, JsValue> {
let transport = self
.transport
.borrow()
.clone()
.ok_or_else(|| js_error("not connected"))?;
let resp = CommunicationValue::new(CommunicationType::PipeResponse)
.with_id(pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
let resp_bytes = resp
.to_bytes()
.map_err(|e| js_error(&format!("encode failed: {}", e)))?;
transport.send_frame(&resp_bytes).await?;
let (tx, rx) = oneshot::channel();
self.pending_pipes.borrow_mut().insert(pipe_id, tx);
rx.await
.map_err(|_| js_error("pipe closed before stream arrived"))
}
/// Deny an incoming pipe request. Sends a `PipeResponse` with
/// `Accepted = false`.
#[wasm_bindgen]
pub async fn deny_pipe(&self, pipe_id: u32) -> Result<(), JsValue> {
let transport = self
.transport
.borrow()
.clone()
.ok_or_else(|| js_error("not connected"))?;
let resp = CommunicationValue::new(CommunicationType::PipeResponse)
.with_id(pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
let resp_bytes = resp
.to_bytes()
.map_err(|e| js_error(&format!("encode failed: {}", e)))?;
transport.send_frame(&resp_bytes).await
}
fn set_state(&self, new_state: ConnectionState) { fn set_state(&self, new_state: ConnectionState) {
self.state.set(new_state); self.state.set(new_state);
@ -738,10 +899,68 @@ impl WasmClient {
let pending_requests = self.pending_requests.clone(); let pending_requests = self.pending_requests.clone();
let loop_pending_requests = pending_requests.clone(); let loop_pending_requests = pending_requests.clone();
let ping_timer = self.ping_timer.clone(); let ping_timer = self.ping_timer.clone();
let pending_pipe_creations = self.pending_pipe_creations.clone();
let pending_pipes = self.pending_pipes.clone();
let on_pipe_request = self.on_pipe_request.clone();
let loop_pipe_creations = pending_pipe_creations.clone();
wasm_bindgen_futures::spawn_local(async move { wasm_bindgen_futures::spawn_local(async move {
loop_transport loop_transport
.receive_loop( .receive_loop_with_pipes(
move |frame: JsValue| { move |frame: JsValue| {
let message_type = frame_type(&frame);
if let Some(ref msg_type) = message_type {
if msg_type == "PipeRequest" {
let pipe_id = frame_id(&frame).unwrap_or(0);
let description = frame_property(&frame, "data")
.and_then(|data| {
let desc = js_sys::Reflect::get(
&data,
&JsValue::from_str("Description"),
)
.ok()?;
desc.as_string()
})
.unwrap_or_default();
let cb = on_pipe_request.borrow();
if let Some(ref callback) = *cb {
let obj = js_sys::Object::new();
let _ = js_sys::Reflect::set(
&obj,
&"pipeId".into(),
&JsValue::from_f64(pipe_id as f64),
);
let _ = js_sys::Reflect::set(
&obj,
&"description".into(),
&JsValue::from_str(&description),
);
let _ = callback.call1(&JsValue::NULL, &obj.into());
}
return;
}
if msg_type == "PipeResponse" {
let pipe_id = frame_id(&frame).unwrap_or(0);
let accepted = frame_property(&frame, "data")
.and_then(|data| {
let acc = js_sys::Reflect::get(
&data,
&JsValue::from_str("Accepted"),
)
.ok()?;
acc.as_bool()
})
.unwrap_or(false);
let mut pending = loop_pipe_creations.borrow_mut();
if let Some(tx) = pending.remove(&pipe_id) {
let _ = tx.send(Ok(accepted));
}
return;
}
}
route_incoming_frame( route_incoming_frame(
&frame, &frame,
&on_msg, &on_msg,
@ -750,11 +969,19 @@ impl WasmClient {
); );
}, },
on_err.clone(), on_err.clone(),
move |pipe_reader: PipeReader| {
let pipe_id = pipe_reader.pipe_id();
let mut pending = pending_pipes.borrow_mut();
if let Some(tx) = pending.remove(&pipe_id) {
let _ = tx.send(pipe_reader);
}
},
) )
.await; .await;
state.set(ConnectionState::Disconnected); state.set(ConnectionState::Disconnected);
stop_ping_timer(&ping_timer); stop_ping_timer(&ping_timer);
reject_pending_requests(&pending_requests, "disconnected"); reject_pending_requests(&pending_requests, "disconnected");
reject_pending_pipe_creations(&pending_pipe_creations, "disconnected");
}); });
} }

View file

@ -4,6 +4,7 @@ pub mod crypto;
pub mod error; pub mod error;
pub mod frame; pub mod frame;
pub mod logging; pub mod logging;
pub mod pipe;
pub mod subscription; pub mod subscription;
pub mod transport; pub mod transport;

140
wasm/src/pipe.rs Normal file
View file

@ -0,0 +1,140 @@
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
use crate::error::js_error;
use crate::transport::release_writer_lock;
#[wasm_bindgen(typescript_custom_section)]
const PIPE_TS: &str = r#"
export interface PipeWriter {
write(data: Uint8Array): Promise<void>;
close(): Promise<void>;
abort(): void;
readonly pipeId: number;
}
export interface PipeReader {
read(): Promise<Uint8Array | null>;
readonly pipeId: number;
readonly description: string;
}
"#;
#[wasm_bindgen]
pub struct PipeWriter {
writer: JsValue,
pipe_id: u32,
}
impl PipeWriter {
pub fn new(writer: JsValue, pipe_id: u32) -> Self {
Self { writer, pipe_id }
}
}
#[wasm_bindgen]
impl PipeWriter {
pub async fn write(&mut self, data: &[u8]) -> Result<(), JsValue> {
let chunk = js_sys::Uint8Array::from(data);
let write_fn = js_sys::Reflect::get(&self.writer, &JsValue::from_str("write"))
.map_err(|_| js_error("missing write"))?
.dyn_into::<js_sys::Function>()
.map_err(|_| js_error("write not a function"))?;
let write_promise = write_fn
.call1(&self.writer, &chunk)
.map_err(|e| js_error(&format!("write failed: {:?}", e)))?;
JsFuture::from(write_promise.unchecked_into::<js_sys::Promise>()).await?;
Ok(())
}
pub async fn close(self) -> Result<(), JsValue> {
let close_fn = js_sys::Reflect::get(&self.writer, &JsValue::from_str("close"))
.map_err(|_| js_error("missing close"))?
.dyn_into::<js_sys::Function>()
.map_err(|_| js_error("close not a function"))?;
let close_promise = close_fn
.call0(&self.writer)
.map_err(|e| js_error(&format!("close failed: {:?}", e)))?;
if let Err(e) =
JsFuture::from(close_promise.unchecked_into::<js_sys::Promise>()).await
{
crate::transport::log_stream_error_code(&e, "pipe writer close");
}
release_writer_lock(&self.writer);
Ok(())
}
pub fn abort(&mut self) -> Result<(), JsValue> {
let abort_fn = js_sys::Reflect::get(&self.writer, &JsValue::from_str("abort"))
.map_err(|_| js_error("missing abort"))?
.dyn_into::<js_sys::Function>()
.map_err(|_| js_error("abort not a function"))?;
let _ = abort_fn.call0(&self.writer);
release_writer_lock(&self.writer);
Ok(())
}
pub fn pipe_id(&self) -> u32 {
self.pipe_id
}
}
#[wasm_bindgen]
pub struct PipeReader {
reader: JsValue,
description: String,
pipe_id: u32,
pending: Vec<u8>,
}
impl PipeReader {
pub fn new(reader: JsValue, pipe_id: u32, description: String, pending: Vec<u8>) -> Self {
Self {
reader,
pipe_id,
description,
pending,
}
}
}
#[wasm_bindgen]
impl PipeReader {
pub async fn read(&mut self) -> Result<JsValue, JsValue> {
if !self.pending.is_empty() {
let data = std::mem::take(&mut self.pending);
return Ok(js_sys::Uint8Array::from(&data[..]).into());
}
let read_fn = js_sys::Reflect::get(&self.reader, &JsValue::from_str("read"))
.map_err(|_| js_error("missing read"))?
.dyn_into::<js_sys::Function>()
.map_err(|_| js_error("read not a function"))?;
let promise = read_fn
.call0(&self.reader)
.map_err(|_| js_error("read call failed"))?
.unchecked_into::<js_sys::Promise>();
let result = JsFuture::from(promise).await?;
let done = js_sys::Reflect::get(&result, &JsValue::from_str("done"))
.ok()
.and_then(|v| v.as_bool())
.unwrap_or(true);
if done {
return Ok(JsValue::NULL);
}
let value = js_sys::Reflect::get(&result, &JsValue::from_str("value"))
.map_err(|_| js_error("missing value"))?;
Ok(js_sys::Uint8Array::new(&value).into())
}
pub fn pipe_id(&self) -> u32 {
self.pipe_id
}
pub fn description(&self) -> String {
self.description.clone()
}
}

View file

@ -1,4 +1,4 @@
use std::cell::RefCell; use std::cell::{Cell, RefCell};
use std::rc::Rc; use std::rc::Rc;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
@ -11,7 +11,7 @@ use crate::frame::parse_frame_value;
const CLOSE_FRAME_LEN: u32 = u32::MAX; const CLOSE_FRAME_LEN: u32 = u32::MAX;
/// Logs the `streamErrorCode` from a stream-level WebTransportError (STOP_SENDING / RESET_STREAM). Session errors are skipped. /// Logs the `streamErrorCode` from a stream-level WebTransportError (STOP_SENDING / RESET_STREAM). Session errors are skipped.
fn log_stream_error_code(error: &JsValue, context: &str) { pub(crate) fn log_stream_error_code(error: &JsValue, context: &str) {
let source = js_sys::Reflect::get(error, &JsValue::from_str("source")) let source = js_sys::Reflect::get(error, &JsValue::from_str("source"))
.ok() .ok()
.and_then(|v| v.as_string()); .and_then(|v| v.as_string());
@ -70,7 +70,7 @@ fn resolve_stream_readable(recv_stream: &JsValue) -> Result<JsValue, JsValue> {
} }
/// Releases a writer's lock so an abandoned writer isn't treated as an abort (which sends STOP_SENDING). /// Releases a writer's lock so an abandoned writer isn't treated as an abort (which sends STOP_SENDING).
fn release_writer_lock(writer: &JsValue) { pub(crate) fn release_writer_lock(writer: &JsValue) {
if let Ok(release) = js_sys::Reflect::get(writer, &JsValue::from_str("releaseLock")) if let Ok(release) = js_sys::Reflect::get(writer, &JsValue::from_str("releaseLock"))
.and_then(|f| f.dyn_into::<js_sys::Function>().map_err(Into::into)) .and_then(|f| f.dyn_into::<js_sys::Function>().map_err(Into::into))
{ {
@ -79,7 +79,7 @@ fn release_writer_lock(writer: &JsValue) {
} }
/// Releases a reader's lock so an abandoned reader isn't treated as a cancel (which sends STOP_SENDING). /// Releases a reader's lock so an abandoned reader isn't treated as a cancel (which sends STOP_SENDING).
fn release_reader_lock(reader: &JsValue) { pub(crate) fn release_reader_lock(reader: &JsValue) {
if let Ok(release) = js_sys::Reflect::get(reader, &JsValue::from_str("releaseLock")) if let Ok(release) = js_sys::Reflect::get(reader, &JsValue::from_str("releaseLock"))
.and_then(|f| f.dyn_into::<js_sys::Function>().map_err(Into::into)) .and_then(|f| f.dyn_into::<js_sys::Function>().map_err(Into::into))
{ {
@ -119,6 +119,8 @@ pub struct WasmTransport {
stream_reader: Rc<RefCell<Option<JsValue>>>, stream_reader: Rc<RefCell<Option<JsValue>>>,
/// Bytes already read from the current stream but not yet consumed as a frame. /// Bytes already read from the current stream but not yet consumed as a frame.
buffer: Rc<RefCell<Vec<u8>>>, buffer: Rc<RefCell<Vec<u8>>>,
/// Set to `true` when `open_next_stream` succeeds; cleared after the first frame is parsed.
new_stream_frame: Rc<Cell<bool>>,
} }
impl WasmTransport { impl WasmTransport {
@ -177,6 +179,7 @@ impl WasmTransport {
streams_reader: Rc::new(RefCell::new(None)), streams_reader: Rc::new(RefCell::new(None)),
stream_reader: Rc::new(RefCell::new(None)), stream_reader: Rc::new(RefCell::new(None)),
buffer: Rc::new(RefCell::new(Vec::new())), buffer: Rc::new(RefCell::new(Vec::new())),
new_stream_frame: Rc::new(Cell::new(false)),
}) })
} }
@ -309,6 +312,7 @@ impl WasmTransport {
.map_err(|_| js_error("stream getReader call failed"))?; .map_err(|_| js_error("stream getReader call failed"))?;
*self.stream_reader.borrow_mut() = Some(reader); *self.stream_reader.borrow_mut() = Some(reader);
self.new_stream_frame.set(true);
Ok(true) Ok(true)
} }
@ -451,6 +455,136 @@ impl WasmTransport {
} }
} }
/// Pipe-aware receive loop. Identical to `receive_loop` but detects
/// `PipeRequest` as the first frame on a new incoming stream and routes
/// the stream to `on_pipe` instead of `on_message`.
pub async fn receive_loop_with_pipes<F, G>(
&self,
mut on_message: F,
on_error: js_sys::Function,
mut on_pipe: G,
) where
F: FnMut(JsValue),
G: FnMut(crate::pipe::PipeReader),
{
let pipe_request_type = mtp_codec::CommunicationType::PipeRequest
.to_id(&mtp_codec::TypeMap::latest());
loop {
match self.next_frame().await {
Ok(FrameOutcome::Frame(frame)) => {
let is_first = self.new_stream_frame.get();
if is_first {
self.new_stream_frame.set(false);
if let Ok(comm) = mtp_codec::CommunicationValue::from_bytes(&frame)
&& comm.get_type() == pipe_request_type
{
let pipe_id = comm.get_id();
let description = comm
.get_str(mtp_codec::DataType::Description)
.unwrap_or("")
.to_string();
let pending = {
let mut buf = self.buffer.borrow_mut();
std::mem::take(&mut *buf)
};
if let Some(reader) = self.stream_reader.borrow_mut().take() {
let pipe_reader = crate::pipe::PipeReader::new(
reader,
pipe_id,
description,
pending,
);
on_pipe(pipe_reader);
}
continue;
}
}
match parse_frame_value(&frame) {
Ok(parsed) => {
on_message(parsed);
}
Err(e) => {
let message = e.as_string().unwrap_or_else(|| format!("{:?}", e));
let _ =
on_error.call1(&JsValue::NULL, &JsValue::from_str(&message));
}
}
}
Ok(FrameOutcome::Closed) | Ok(FrameOutcome::Ended) => break,
Err(e) => {
let _ = on_error.call1(&JsValue::NULL, &e);
break;
}
}
}
}
/// Open a new outgoing unidirectional stream and write a `PipeRequest`
/// frame as the first frame. Returns a `PipeWriter` whose underlying
/// `WritableStream` remains open for subsequent raw-data writes.
pub async fn open_pipe(
&self,
pipe_id: u32,
description: &str,
) -> Result<crate::pipe::PipeWriter, JsValue> {
let create_stream = js_sys::Reflect::get(
&self.inner,
&JsValue::from_str("createUnidirectionalStream"),
)?
.dyn_into::<js_sys::Function>()
.map_err(|_| js_error("createUnidirectionalStream not a function"))?;
let stream_promise = create_stream
.call0(&self.inner)?
.dyn_into::<js_sys::Promise>()
.map_err(|_| js_error("createUnidirectionalStream did not return a Promise"))?;
let stream = JsFuture::from(stream_promise).await?;
let writable_or_stream = resolve_stream_writable(&stream)?;
let writer_val = js_sys::Reflect::get(&writable_or_stream, &JsValue::from_str("getWriter"))
.map_err(|_| js_error("missing getWriter"))?
.dyn_into::<js_sys::Function>()
.map_err(|_| js_error("getWriter not a function"))?
.call0(&writable_or_stream)
.map_err(|_| js_error("getWriter call failed"))?;
let request = mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest)
.with_id(pipe_id)
.add_typed_default(
mtp_codec::DataType::Description,
mtp_codec::DataValue::Str(description.to_string()),
);
let frame_bytes = request
.to_bytes()
.map_err(|e| js_error(&format!("encode failed: {}", e)))?;
let len = frame_bytes.len() as u32;
let mut wire = Vec::with_capacity(4 + frame_bytes.len());
wire.extend_from_slice(&len.to_be_bytes());
wire.extend_from_slice(&frame_bytes);
let chunk = js_sys::Uint8Array::from(&wire[..]);
let write_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("write"))
.map_err(|_| js_error("missing write"))?
.dyn_into::<js_sys::Function>()
.map_err(|_| js_error("write not a function"))?;
let write_promise = write_fn
.call1(&writer_val, &chunk)
.map_err(|e| js_error(&format!("write failed: {:?}", e)))?;
if let Err(e) =
JsFuture::from(write_promise.unchecked_into::<js_sys::Promise>()).await
{
log_stream_error_code(&e, "open_pipe write");
release_writer_lock(&writer_val);
return Err(e);
}
Ok(crate::pipe::PipeWriter::new(writer_val, pipe_id))
}
pub fn close(&self) { pub fn close(&self) {
// Release reader locks before closing so they aren't treated as cancels. // Release reader locks before closing so they aren't treated as cancels.
if let Some(reader) = self.stream_reader.borrow_mut().take() { if let Some(reader) = self.stream_reader.borrow_mut().take() {