Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 

README.md

custom-runtime

custom-runtime demonstrates that WebRTC.rs is genuinely runtime-agnostic: it implements the webrtc::runtime::Runtime trait over async-executor and async-io — neither Tokio nor smol — and drives every runtime capability the WebRTC stack needs through it.

It doubles as the acceptance test for the runtime abstraction. Building it with neither runtime-tokio nor runtime-smol proves nothing in the library is hard-wired to a built-in runtime.

Instructions

Build custom-runtime

cargo build --example custom-runtime

Run custom-runtime

The interesting invocation disables both built-in runtimes, so the only Runtime in the process is the one the example defines:

cargo run --no-default-features --example custom-runtime

Expected output:

running on a custom runtime: my-runtime
sleep(50ms) took 50.304959ms
tick 0
tick 1
tick 2
timeout on a pending future => Err(Elapsed)
hello from a spawned task
udp: received "ping" from 127.0.0.1:54321
resolved localhost:3478 -> [[::1]:3478, 127.0.0.1:3478]

Socket binding may be denied in restricted environments (sandboxes, some CI); the UDP step then reports udp: skipped rather than failing.

What it implements

Eight required Runtime methods, plus the three socket traits:

Method Backed by
spawn async_executor::Executor on a 2-thread pool
sleep, interval async_io::Timer
wrap_udp_socket, wrap_tcp_listener, connect_tcp async_io::Async<T>
resolve_host std's resolver, offloaded to a thread
block_on futures_lite::future::block_on

spawn_reactor, yield_now and name have working defaults, so they are optional. timeout needs no implementation at all — it is derived generically from sleep.

The socket wrapper sends and receives one datagram per syscall: it ignores Transmit::segment_size and leaves max_gso_segments / max_gro_segments at their default of 1, so the driver never asks it to batch. A production runtime would enable UDP GSO/GRO the way the built-in ones do — construct a webrtc::runtime::UdpSocketState at wrap time, report its max_gso_segments() / gro_segments(), and pass the whole Transmit to its send. Its recv already takes the same bufs / meta slices as poll_recv and returns the same message count, so the receive path forwards them rather than adapting anything; what it does need is to translate a count of 0 into the host runtime's not-ready protocol, and to ensure every filled message reports a stride of at least 1. See src/runtime/tokio.rs or smol.rs.

Reporting max_gro_segments() > 1 is the one place a wrapper can quietly hurt itself. It tells the driver how large a receive buffer to allocate, and a coalesced segment is bounded by the path MTU, not by the largest datagram your application sends — size against the latter and the kernel drops the tail of each coalesced read, which shows up as unexplained packet loss rather than an error. The driver handles this for buffers it allocates; a runtime that reads into buffers of its own, such as one demultiplexing a shared socket across several connections, owns the same bound in its own loop. AsyncUdpSocket::max_gro_segments documents the rule.

Using it for a real connection

The runtime is injected per connection, so a single process can run some connections on a custom runtime and others on a built-in one:

let runtime: Arc<dyn Runtime> = Arc::new(MyRuntime::new());

let pc = PeerConnectionBuilder::new()
    .with_runtime(runtime.clone())
    .with_handler(Arc::new(MyHandler))
    .with_udp_addrs(vec!["0.0.0.0:0"])
    .build()
    .await?;

There is no global registry to install into and nothing to override — which is what keeps per-connection runtime choice possible.

Writing your own

  1. Implement Runtime (the 8 required methods above).
  2. Implement AsyncUdpSocket, AsyncTcpListener and AsyncTcpStream for your socket types. AsyncUdpSocket is poll-based: supply poll_send and poll_recv, and the four async convenience methods come from defaults.
  3. Return your task handle from spawn via JoinHandle::new(Box::new(..)), implementing JoinHandleInner for it.
  4. Pass it to with_runtime.

No #[cfg] edits, no fork, and no changes to library internals.

One thing to get right, and the reason this example is worth reading: poll_send/poll_recv must be ordered to match your reactor's readiness semantics. Event- or ticket-based reactors (async-io, and this example) must attempt the syscall first and consult readiness only on WouldBlock — otherwise datagrams already queued in the socket buffer generate no fresh readiness event and a burst stalls. Reactors with cached, level-triggered readiness (Tokio) instead need the operation to report WouldBlock back through their own wrapper (try_io) so the cache is invalidated.