Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/livekit_net_self_test_ffi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
livekit-net: patch
livekit-uniffi: patch
livekit-api: patch
livekit: patch
livekit-ffi: patch
---

Add `self_test_http_get` / `self_test_ws_echo` / `has_http_client` / `has_ws_client` UniFFI exports so foreign hosts can exercise the transport seam end-to-end.
8 changes: 7 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,10 @@ jobs:
shell: bash
run: |
cargo test --verbose --target ${{ matrix.target }} -p livekit-net --features native-tokio
cargo test --verbose --target ${{ matrix.target }} -p livekit-api --features signal-client-tokio
cargo test --verbose --target ${{ matrix.target }} -p livekit-api --features signal-client-tokio

- name: Build livekit-net uniffi feature (foreign transport bindings)
shell: bash
run: |
cargo build --verbose --target ${{ matrix.target }} -p livekit-net --features uniffi
cargo build --verbose --target ${{ matrix.target }} -p livekit-net --features uniffi,native-tokio
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions livekit-net/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ repository.workspace = true
[features]
default = []

# UniFFI bindings: exposes the transport seam across the FFI so a host can
# implement WsClient/HttpClient and register it. Orthogonal to the native
# backends — enable alongside a native-* backend for a host-overridable
# transport that falls back to native.
uniffi = ["dep:uniffi"]

# Native backend bundles — each pairs the net libraries with a runtime.
native-tokio = ["__native", "__native-tokio", "tokio"]
native-async = ["__native", "__native-async", "async"]
Expand Down Expand Up @@ -50,6 +56,13 @@ __native-async = ["dep:async-tungstenite", "dep:isahc", "dep:tokio", "dep:future
[dependencies]
async-trait = "0.1"

# Optional: scaffolding for the `uniffi` feature. `scaffolding-ffi-buffer-fns`
# matches livekit-datatrack/livekit-uniffi so the ABI is consistent when these
# crates are combined into one cdylib. No runtime feature: the exported traits
# are `with_foreign` (host-driven futures) and the exported setters are sync, so
# the crate stays runtime-agnostic.
uniffi = { workspace = true, features = ["scaffolding-ffi-buffer-fns"], optional = true }

# Direct path dep (not workspace inheritance) so default-features = false is
# honored: cargo ignores a member's default-features override on an inherited dep,
# which would drag in livekit-runtime's default `tokio` and trip its one-runtime
Expand Down
47 changes: 47 additions & 0 deletions livekit-net/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ pub use types::{Header, HttpResponse, TransportError};

use std::sync::{Arc, OnceLock};

#[cfg(feature = "uniffi")]
uniffi::setup_scaffolding!();

/// Render a URL for logging with secrets stripped: userinfo (`user:password@`)
/// and the query string (which can carry an access token). Keeps scheme, host,
/// port, and path.
Expand All @@ -45,16 +48,60 @@ static HTTP: OnceLock<Arc<dyn HttpClient>> = OnceLock::new();
///
/// Independent of [`set_http_client`]: a consumer that only needs HTTP (e.g. a
/// token source) can register that alone, and vice versa.
#[cfg_attr(feature = "uniffi", uniffi::export)]
pub fn set_ws_client(c: Arc<dyn WsClient>) {
let _ = WS.set(c);
}

/// Register the process-wide HTTP client. Call once at startup, before the first
/// request. A later call is ignored (first registration wins).
#[cfg_attr(feature = "uniffi", uniffi::export)]
pub fn set_http_client(c: Arc<dyn HttpClient>) {
let _ = HTTP.set(c);
}

/// Self-test: GET `url` via the registered HTTP client; returns the full response
/// (status + headers + body) so callers can assert the whole struct round-trips the FFI.
/// Errors if no client is registered or the transport fails.
#[cfg_attr(feature = "uniffi", uniffi::export)]
pub async fn self_test_http_get(url: String) -> Result<HttpResponse, TransportError> {
let c =
http_client().ok_or_else(|| TransportError::Other("no http client registered".into()))?;
c.request(HttpMethod::Get, url, Vec::new(), None).await
}

/// Self-test: connect, send `payload`, receive one frame, close; return the echoed bytes.
/// Errors if no client is registered, the transport fails, or the peer closes first.
#[cfg_attr(feature = "uniffi", uniffi::export)]
pub async fn self_test_ws_echo(url: String, payload: Vec<u8>) -> Result<Vec<u8>, TransportError> {
let c = ws_client().ok_or_else(|| TransportError::Other("no ws client registered".into()))?;
let conn = c.connect(url, Vec::new(), 5_000).await?.connection;
conn.send(payload).await?;
let got = conn.recv().await?.ok_or(TransportError::Closed)?;
conn.close().await;
Ok(got)
}
Comment on lines +66 to +83

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Built-in network self-test calls can crash when triggered from a foreign host

The new self-test calls are made available to foreign callers (uniffi::export at livekit-net/src/lib.rs:66) without declaring which background worker should run them, so on builds that use the built-in networking they can abort the process instead of returning an error.
Impact: A host app that calls the self-test probes on a build with the built-in transport, without first registering its own transport, can hit a hard crash rather than a clean failure.

Missing tokio async_runtime on exported async functions while the native fallback uses tokio-based reqwest/tokio-tungstenite

self_test_http_get (livekit-net/src/lib.rs:67-71) and self_test_ws_echo (livekit-net/src/lib.rs:76-83) resolve the client via http_client() / ws_client(), which fall back to native::NativeTransport when nothing was registered (livekit-net/src/lib.rs:105-139). On --features uniffi,native-tokio (a combination the PR explicitly adds to CI, .github/workflows/tests.yml:178-179), that transport drives reqwest / tokio-tungstenite futures which require an active tokio reactor. UniFFI polls exported async functions on its own foreign executor thread unless the export is annotated with #[uniffi::export(async_runtime = "tokio")], so there is no tokio context and the tokio I/O driver panics ("there is no reactor running"). The livekit-net/Cargo.toml:59-64 comment asserts "No runtime feature: the exported traits are with_foreign ... and the exported setters are sync", which is no longer true now that async free functions that can drive native Rust futures are exported.

Prompt for agents
The newly exported async functions self_test_http_get and self_test_ws_echo in livekit-net/src/lib.rs can end up driving the built-in native transport (reqwest / tokio-tungstenite) when no foreign client has been registered, because http_client()/ws_client() fall back to native::NativeTransport on __native builds. UniFFI polls exported async functions on its own executor with no tokio runtime context unless the export declares async_runtime = "tokio", which would panic for tokio-based I/O. Consider either restricting these probes to the registered-client case (return an error instead of using the native fallback when no client is registered, i.e. read WS/HTTP OnceLocks directly), or gating the exports so the tokio async_runtime attribute is used when a tokio native backend is compiled in. Also update the stale rationale comment in livekit-net/Cargo.toml that claims no runtime feature is needed because all exported functions are sync.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


/// Test probe: whether a host has explicitly registered an HTTP client via
/// [`set_http_client`].
///
/// Reports registration, not resolvability: on native builds [`http_client`]
/// still yields the built-in client when this returns `false`.
#[cfg_attr(feature = "uniffi", uniffi::export)]
pub fn has_http_client() -> bool {
HTTP.get().is_some()
}

/// Test probe: whether a host has explicitly registered a WebSocket client via
/// [`set_ws_client`].
///
/// Reports registration, not resolvability: on native builds [`ws_client`] still
/// yields the built-in client when this returns `false`.
#[cfg_attr(feature = "uniffi", uniffi::export)]
pub fn has_ws_client() -> bool {
WS.get().is_some()
}
Comment on lines +63 to +103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Test-only helper functions are added to the crate's permanent public API

Four self-test helpers are made permanently public (pub async fn self_test_http_get at livekit-net/src/lib.rs:67 and the neighbouring probes) even when the bindings feature is off, so the library's supported surface grows with functions that exist only to exercise tests.
Impact: Consumers see and can depend on test scaffolding as if it were product API, which then cannot be changed without a breaking release.

AGENTS.md rule on new public API surface

AGENTS.md states: "When introducing new API surface, always default to private or pub(crate) unless there is a specific reason to expose publicly" and "Introduce new public APIs sparingly". The only stated reason for these helpers is FFI self-testing, so they should at minimum be gated behind #[cfg(feature = "uniffi")] (with the test using that feature), rather than exported unconditionally at livekit-net/src/lib.rs:63-95.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


/// Resolve the process-wide WebSocket client.
///
/// Returns the explicitly registered client if any; otherwise, on native builds,
Expand Down
5 changes: 5 additions & 0 deletions livekit-net/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use std::sync::Arc;

/// A single open WebSocket connection. Control frames (ping/pong, close handshake)
/// are the implementation's own responsibility; only binary application frames cross here.
#[cfg_attr(feature = "uniffi", uniffi::export(with_foreign))]
#[async_trait::async_trait]
pub trait WsConnection: Send + Sync + 'static {
/// Send one binary application frame.
Expand All @@ -31,12 +32,14 @@ pub trait WsConnection: Send + Sync + 'static {
///
/// A record wrapper, not a bare `Arc<dyn WsConnection>`: uniffi 0.31 cannot
/// lift a trait object returned from an async `with_foreign` method.
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct WsConnectResult {
pub connection: Arc<dyn WsConnection>,
}

/// A host- or Rust-provided WebSocket transport. Opens the LiveKit signalling
/// WebSocket; knows nothing about LiveKit/protobuf.
#[cfg_attr(feature = "uniffi", uniffi::export(with_foreign))]
#[async_trait::async_trait]
pub trait WsClient: Send + Sync {
/// Open a WebSocket. `url` is the full ws(s):// URL including query string.
Expand All @@ -51,6 +54,7 @@ pub trait WsClient: Send + Sync {
}

/// The HTTP method for an [`HttpClient::request`].
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Expand All @@ -64,6 +68,7 @@ pub enum HttpMethod {
/// Implementors provide the single [`request`](HttpClient::request) primitive;
/// [`HttpClientExt`] layers `get`/`post` on top, so adding verbs never widens the
/// implementation (or, for foreign impls, the FFI) surface.
#[cfg_attr(feature = "uniffi", uniffi::export(with_foreign))]
#[async_trait::async_trait]
pub trait HttpClient: Send + Sync {
/// Perform one HTTP request, sending `body` if present.
Expand Down
3 changes: 3 additions & 0 deletions livekit-net/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
use std::fmt;

/// A single HTTP/WebSocket request header.
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
#[derive(Debug, Clone)]
pub struct Header {
pub name: String,
pub value: String,
}

/// The result of an HTTP request performed by the transport.
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
#[derive(Debug, Clone)]
pub struct HttpResponse {
pub status: u16,
Expand All @@ -31,6 +33,7 @@ pub struct HttpResponse {
}

/// Errors a transport implementation may return. Mapped onto `SignalError` by the caller.
#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
#[derive(Debug, Clone)]
pub enum TransportError {
Timeout,
Expand Down
78 changes: 78 additions & 0 deletions livekit-net/tests/self_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright 2026 LiveKit, Inc. (Apache-2.0)
use livekit_net::{
has_http_client, has_ws_client, self_test_http_get, self_test_ws_echo, set_http_client,
set_ws_client, Header, HttpMethod, HttpResponse, TransportError, WsClient, WsConnectResult,
WsConnection,
};
use std::sync::{Arc, Mutex};

struct EchoConn {
buf: Mutex<Option<Vec<u8>>>,
}

#[async_trait::async_trait]
impl WsConnection for EchoConn {
async fn send(&self, frame: Vec<u8>) -> Result<(), TransportError> {
*self.buf.lock().unwrap() = Some(frame);
Ok(())
}
async fn recv(&self) -> Result<Option<Vec<u8>>, TransportError> {
Ok(self.buf.lock().unwrap().take())
}
async fn close(&self) {}
}

struct EchoWsClient;

#[async_trait::async_trait]
impl WsClient for EchoWsClient {
async fn connect(
&self,
_url: String,
_headers: Vec<Header>,
_timeout_ms: u64,
) -> Result<WsConnectResult, TransportError> {
Ok(WsConnectResult { connection: Arc::new(EchoConn { buf: Mutex::new(None) }) })
}
}

struct CannedHttpClient;

#[async_trait::async_trait]
impl livekit_net::HttpClient for CannedHttpClient {
async fn request(
&self,
_method: HttpMethod,
_url: String,
_headers: Vec<Header>,
_body: Option<Vec<u8>>,
) -> Result<HttpResponse, TransportError> {
Ok(HttpResponse {
status: 201,
headers: vec![Header { name: "x-test".into(), value: "1".into() }],
body: b"hello".to_vec(),
})
}
}

#[tokio::test]
async fn self_tests_round_trip_through_registered_clients() {
// Own test binary ⇒ fresh OnceLock, so nothing is registered yet. The probes
// report registration only, so a native build's built-in fallback doesn't
// show up here.
assert!(!has_http_client());
assert!(!has_ws_client());

set_http_client(Arc::new(CannedHttpClient));
set_ws_client(Arc::new(EchoWsClient));
assert!(has_http_client());
assert!(has_ws_client());

let resp = self_test_http_get("http://example/x".into()).await.unwrap();
assert_eq!(resp.status, 201);
assert_eq!(resp.body, b"hello");
assert_eq!(resp.headers.len(), 1);

let echoed = self_test_ws_echo("ws://example/x".into(), b"ping".to_vec()).await.unwrap();
assert_eq!(echoed, b"ping");
}
1 change: 1 addition & 0 deletions livekit-uniffi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ publish = false
livekit-protocol = { workspace = true }
livekit-api = { workspace = true, default-features = false, features = ["access-token"] }
livekit-datatrack = { workspace = true, features = ["uniffi"] }
livekit-net = { workspace = true, features = ["uniffi"] }
uniffi = { workspace = true, features = ["scaffolding-ffi-buffer-fns", "tokio"] }
log = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt-multi-thread"] }
Expand Down
2 changes: 2 additions & 0 deletions livekit-uniffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@ pub mod common;
/// Global async runtime.
pub mod runtime;

extern crate livekit_net;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

uniffi::setup_scaffolding!();
Loading