Rust Async Internals -- RIPDPI
Purpose
Guide agents through async patterns specific to RIPDPI: JNI-to-async bridging, the io_loop event-driven architecture, tokio runtime configuration for Android NDK, CancellationToken-based shutdown, and common select!/join! pitfalls.
Triggers
- "Why is the tunnel blocking / slow?"
- "How does JNI call into async Rust here?"
- "How does select! work and what are the pitfalls?"
- "How is the tokio runtime configured?"
- "How does cancellation / shutdown work?"
Project async architecture
RIPDPI has two independent tokio runtimes bridged from JNI:
Tunnel runtime (
ripdpi-tunnel-android): sharedmulti_thread(2)runtime stored in aOnceCell<Arc<Runtime>>. A dedicatedstd::threadcallsruntime.block_on(run_tunnel(...))so the JNI thread is not blocked.Proxy runtime (
ripdpi-android):run_proxy_with_embedded_controlblocks the calling JNI thread directly (the Android Service thread). Uses anIdleGuarddrop-safety pattern to reset state on panic.
JNI-to-async bridge pattern
// ripdpi-tunnel-android/src/session/lifecycle.rs -- the canonical pattern
//
// JNI create_session() returns a jlong handle.
// JNI start_session() spawns a std::thread that calls runtime.block_on():
let worker = std::thread::Builder::new()
.name("ripdpi-tunnel-worker".into())
.spawn(move || {
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
runtime.block_on(run_tunnel(config, fd, cancel, stats))
}));
// Handle Ok/Err/panic, update last_error and telemetry
});
// The JNI thread returns immediately; stop_session() cancels via token.
Key rules for this pattern:
- Never call
block_onfrom a JNI callback thread directly for long-running work -- spawn a dedicated thread so the JNI call returns promptly. - Duplicate file descriptors (
nix::unistd::dup) before passing to async -- Android VpnService can revoke the original fd at any time. - Wrap
block_onincatch_unwind-- a panic must not unwind through JNI.
Required tokio version floor
Do not downgrade tokio below these versions:
- ≥ 1.42.1 — fixes a
broadcast::Sender::clone()soundness bug (missing synchronization forSend + !Syncpayloads) and aCancellationTokenrace where futures that polled toReadybefore the token fired were not cancelled. RIPDPI's connection-level abort paths rely on the cancellation fix. See the tokio CHANGELOG and PR #7462. - ≥ 1.51.1 — fixes a file-descriptor leak when an
io_uringopenoperation is cancelled before completion.ripdpi-tunnel-corecancels in-flight FS/IO operations on session teardown; below this version the leaked fds accumulate until the process exits. See PR #7983.
If cargo tree -i tokio shows a version below the floor, promote it in native/rust/Cargo.toml workspace dependencies (never downgrade a transitive dep to work around a breaking change — open an upstream issue instead).
Runtime configuration for Android NDK
// ripdpi-tunnel-android/src/session/registry.rs
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2) // constrained for mobile battery/CPU
.thread_stack_size(1024 * 1024) // 1 MiB -- Android default is small
.thread_name("ripdpi-tunnel-tokio")
.enable_all()
.build()
Android-specific considerations:
worker_threads(2)balances throughput vs battery drain on mobile.- Explicit
thread_stack_size(1 MiB)-- Android NDK default stack is often too small for deep async state machines. - The runtime is stored in
OnceCell<Arc<Runtime>>and shared across sessions to avoid repeated thread pool creation. current_threadruntime is used only in tests and the DNS resolver's synchronousresolve_blocking()path.
io_loop event-driven architecture
The core tunnel runs a single-task 6-phase loop (io_loop_task in
ripdpi-tunnel-core/src/io_loop.rs):
- Drain TUN fd -- read raw IP packets via
AsyncFd::try_io, classify - smoltcp poll -- advance TCP state machines in userspace
- New sessions -- detect ESTABLISHED sockets, spawn
TcpSessiontasks - Duplex bridge -- pump data between smoltcp sockets and session tasks
- Flush tx_queue -- write smoltcp packets back to TUN fd
- Wait --
select!on TUN readable / smoltcp timer / UDP / DNS / cancel
Phase 6 select pattern:
tokio::select! {
_ = tun.readable() => {},
_ = tokio::time::sleep(smol_delay) => {},
udp_event = udp_rx.recv() => { handle_udp_event(...) }
dns_result = async { ... }, if dns_resp_rx.is_some() => { ... }
_ = cancel.cancelled() => { break; }
}
This is NOT a typical "spawn per connection" design. One task owns the entire
smoltcp stack. Individual TCP/UDP sessions ARE spawned as separate tokio tasks
that communicate back via mpsc channels and tokio::io::duplex pairs.
The smoltcp ↔ duplex bridge uses a NoopWaker-based manual poll pattern: see the ## io_loop event-driven architecture section above for the full treatment of try_read_duplex / try_write_duplex in io_loop/bridge.rs:19-45. The short version: the bridge calls poll_read / poll_write directly from the io_loop tick with a discarded waker. Consequence: the try_*_duplex family must NEVER be called from inside an async await — a Poll::Pending under the NoopWaker stalls the task permanently. If you find yourself writing async fn wrappers around duplex streams in this crate, stop and consult the io_loop architecture notes.
CancellationToken pattern (used throughout RIPDPI)
// Parent creates token, passes child tokens to spawned work
let cancel = CancellationToken::new();
let child_cancel = cancel.child_token();
tokio::spawn(async move {
tokio::select! {
_ = child_cancel.cancelled() => { /* clean shutdown */ }
_ = do_work() => {}
}
});
// Later: cancel.cancel() propagates to all child tokens
RIPDPI uses tokio_util::sync::CancellationToken (not tokio::sync::Notify)
for structured shutdown. The tunnel session state machine transitions through
Ready -> Starting -> Running -> Destroyed, with the token stored in the
Starting and Running variants.
Debugging async issues
- Task starvation: Look for blocking calls in async context. The io_loop is especially sensitive -- a single blocked poll starves all TCP sessions.
- Deadlock at shutdown: Check that
cancel.cancelled()is in everyselect!loop. Missing it causes tasks that never terminate. - fd leaks: Verify
OwnedFdcleanup on all error paths in JNI lifecycle functions. The dup'd fd must be closed even ifstart_sessionfails.
Note: tokio-console is not practical for this project -- it requires the
tokio_unstable cfg flag and console-subscriber, which add overhead and
complexity to Android NDK cross-compilation. Use tracing spans and
RUST_LOG filtering instead.
Pitfall catalog
The following async-Rust pitfalls have their own catalog page so this skill stays focused on RIPDPI's architecture. Read references/async-pitfall-catalog.md when authoring or reviewing async code touched by any of them:
- Blocking syscalls inside async fn
tokio::select!/tokio::join!semantics and cancellation surprises- Cancel-safety annotation discipline + library method cancel-safety table
- Spawn-and-join firewall for non-cancellable critical sections
- Extended
CancellationTokenpatterns: child tokens,DropGuard,run_until_cancelled - Structured concurrency status as of Rust 1.94
- Async-Drop contracts of pooled resource libraries (sqlx, deadpool, tokio::fs::File)
- Async closures and the
AsyncFnfamily (Rust 1.85+) - HRTB pitfalls in
Fncallbacks - Async + shared
&mut Statein event loops Pinnecessity in FFI typesimpl Trait(RPIT) lifetime overcapture in edition 2024tokio::time::timeoutcooperative — never fires on non-yielding futuresJoinSetdrop cannot abortspawn_blockingthreadsspawn_blockingpool exhaustion from long-lived tasksblock_in_placepanics oncurrent_threadruntimebroadcastreceiver silently drops messages onLaggedstd::sync::Mutexguard across.awaitdeadlocks silentlyasync fnin traits: not object-safe, noSendbound by default
Related skills
.claude/skills/rust-debugging/-- GDB/LLDB debugging of async Rust.claude/skills/rust-profiling/-- cargo-flamegraph with async stack frames.claude/skills/memory-model/-- memory ordering in async contexts
Source: po4yka/RIPDPI — distributed by TomeVault.