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" 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"); + } } }