From a6f0d8a7ea8187933fd55f30bc2d16a3c3c0042f Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Fri, 21 Aug 2026 13:38:16 -0700 Subject: [PATCH 1/2] Close the iroh endpoint gracefully on mobile stop EzvpnHandle::stop only aborted the run task and dropped the runtime, which sends nothing on the wire: the server kept the client's address lease until the 30 s QUIC idle timeout. Keep a clone of the session's endpoint in the handle and close it (CONNECTION_CLOSE) on a short-lived teardown thread that owns the runtime, bounded by a 3 s timeout built inside the runtime context. The server now logs the disconnect immediately. Also note in the build script and Android design doc that development and testing happen on the arm64 Android VM, with the physical device reserved for the signed release APK. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QedowxAkQAV7HoeJu8ZYZC --- build-android.sh | 5 ++-- docs/Android-App.md | 4 ++- src/ffi.rs | 68 ++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/build-android.sh b/build-android.sh index b6fa7c3..acce12b 100755 --- a/build-android.sh +++ b/build-android.sh @@ -33,8 +33,9 @@ set -euo pipefail PROFILE="${1:-release}" -# arm64-v8a is every current phone; armeabi-v7a covers 32-bit-only devices -# (e.g. the 2013 Nexus 7 used for on-device testing); x86_64 is the emulator. +# arm64-v8a is every current phone and the arm64 Android VM used for +# development/testing; armeabi-v7a covers 32-bit-only devices (e.g. the 2013 +# Nexus 7 that only gets the signed release APK); x86_64 is the stock emulator. ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}" # Minimum Android API level the .so links against (must be <= the app's # minSdk). 29 = Android 10. diff --git a/docs/Android-App.md b/docs/Android-App.md index 427bc7c..a8d438a 100644 --- a/docs/Android-App.md +++ b/docs/Android-App.md @@ -32,7 +32,9 @@ In scope: a routed prefix are carved back out of the tunnel (see below). - **Always-on VPN** — the service accepts the system's always-on start and connects the last-used profile. -- **Real-device testing** — developed against a physical device over adb. +- **On-device testing** — developed and tested on an adb-connected arm64 + Android emulator (a `VpnService` cannot run on the JVM); the physical device + only receives the signed release APK. Out of scope (by design): diff --git a/src/ffi.rs b/src/ffi.rs index a964f90..84da671 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -86,6 +86,12 @@ use crate::tunnel::mobile::{MobileConfig, MobileSession}; /// to end on its own at the same moment. pub(crate) type ExitHook = Box) + Send + 'static>; +/// Upper bound on the graceful endpoint close in [`EzvpnHandle::stop`]. The +/// CONNECTION_CLOSE goes out immediately; this only caps how long the +/// teardown thread waits for the peer's acknowledgement before dropping the +/// runtime. +const STOP_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); + /// Opaque handle owned by the app side. Created by [`ezvpn_connect`], freed by /// [`ezvpn_stop`]. pub struct EzvpnHandle { @@ -101,6 +107,9 @@ pub struct EzvpnHandle { /// Clone of the live iroh connection, kept so [`ezvpn_conn_path`] can /// snapshot its paths on demand after `ezvpn_run` consumed the session. connection: iroh::endpoint::Connection, + /// Clone of the session's iroh endpoint, kept so [`EzvpnHandle::stop`] can + /// close it gracefully after `ezvpn_run` consumed the session. + endpoint: iroh::Endpoint, /// Configured custom relays, retained so [`ezvpn_conn_path`] can probe their /// `/healthz` on demand. relay_config: RelayConfig, @@ -432,6 +441,7 @@ pub(crate) fn connect_inner(json: &str) -> Result<(EzvpnHandle, String), String> .to_string(); let connection = session.connection(); + let endpoint = session.endpoint(); Ok(( EzvpnHandle { runtime, @@ -439,6 +449,7 @@ pub(crate) fn connect_inner(json: &str) -> Result<(EzvpnHandle, String), String> task: None, stopped: Arc::new(AtomicBool::new(false)), connection, + endpoint, relay_config, }, result_json, @@ -552,19 +563,62 @@ impl EzvpnHandle { Ok(()) } - /// The shared body of [`ezvpn_stop`]: abort the loop (if any) and shut the - /// runtime down without blocking the caller. Consumes (frees) the handle. + /// The shared body of [`ezvpn_stop`]: abort the loop (if any), close the + /// iroh endpoint gracefully, and shut the runtime down — all without + /// blocking the caller. Consumes (frees) the handle. + /// + /// The graceful close matters: merely dropping the endpoint sends nothing, + /// so the server would only notice the client is gone at the QUIC idle + /// timeout (30 s) and keep the address lease until then. `Endpoint::close` + /// sends CONNECTION_CLOSE, so the server frees the lease immediately. It + /// needs the runtime alive to drive the socket, so the teardown runs on a + /// short-lived thread that owns the runtime, bounded by + /// [`STOP_CLOSE_TIMEOUT`]. pub(crate) fn stop(self: Box) { // Silence the exit hook first: the abort below only lands at the // task's next await point, and the loop may already be past its last. self.stopped.store(true, Ordering::Release); - if let Some(task) = &self.task { + let EzvpnHandle { + runtime, + session, + task, + endpoint, + connection, + .. + } = *self; + if let Some(task) = &task { task.abort(); } - // Drop any still-pending (never-run) session and shut the runtime down - // without blocking the caller; tasks are aborted above. - self.runtime.shutdown_background(); - // `self` (Box) drops here, freeing the allocation. + // Release our own references to the connection so the close below + // has nothing else keeping it open; the aborted task drops its own + // copies at its next await point. + drop(connection); + drop(session); + + let teardown = move || { + let started = std::time::Instant::now(); + log::info!("ezvpn stop: closing endpoint"); + // The timeout's timer must be created inside the runtime context + // (`tokio::time::timeout` registers it eagerly), so build it in the + // async block rather than on this plain thread. + let close = runtime.block_on(async { + tokio::time::timeout(STOP_CLOSE_TIMEOUT, endpoint.close()).await + }); + match close { + Ok(()) => log::info!("ezvpn stop: endpoint closed in {:?}", started.elapsed()), + Err(_) => log::warn!("ezvpn stop: endpoint close timed out after {STOP_CLOSE_TIMEOUT:?}"), + } + drop(task); + runtime.shutdown_background(); + }; + if let Err(e) = std::thread::Builder::new() + .name("ezvpn-stop".into()) + .spawn(teardown) + { + // No thread available: give up on the graceful close rather than + // block the caller (the server falls back to its idle timeout). + log::warn!("ezvpn stop: cannot spawn teardown thread ({e}); closing ungracefully"); + } } } From 9e77fb63820fdea58b76340ab70e469974e96724 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Fri, 21 Aug 2026 14:03:03 -0700 Subject: [PATCH 2/2] Bump version to 0.0.42 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QedowxAkQAV7HoeJu8ZYZC --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4855acc..04be2d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -922,7 +922,7 @@ dependencies = [ [[package]] name = "ezvpn" -version = "0.0.41" +version = "0.0.42" dependencies = [ "android_logger", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index f51efd9..7f10e41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ezvpn" -version = "0.0.41" +version = "0.0.42" edition = "2024" description = "IP-over-QUIC VPN tunnel via iroh P2P" readme = "README.md"