Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
5 changes: 3 additions & 2 deletions build-android.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/Android-App.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
68 changes: 61 additions & 7 deletions src/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ use crate::tunnel::mobile::{MobileConfig, MobileSession};
/// to end on its own at the same moment.
pub(crate) type ExitHook = Box<dyn FnOnce(Result<(), String>) + 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 {
Expand All @@ -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,
Expand Down Expand Up @@ -432,13 +441,15 @@ pub(crate) fn connect_inner(json: &str) -> Result<(EzvpnHandle, String), String>
.to_string();

let connection = session.connection();
let endpoint = session.endpoint();
Ok((
EzvpnHandle {
runtime,
session: Some(session),
task: None,
stopped: Arc::new(AtomicBool::new(false)),
connection,
endpoint,
relay_config,
},
result_json,
Expand Down Expand Up @@ -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<Self>) {
// 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");
}
}
}

Expand Down
Loading