From 22be266b9339ca0439762b6b5a474045c5de7a42 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Tue, 15 Sep 2026 18:36:28 -0700 Subject: [PATCH 1/4] Cap the reconnect backoff at 60s on iOS The 5 min cap is sized for the CLI and desktop clients, which sit through outages unattended for days. An iOS session is temporary by nature: it lives only as long as the app, and the user who started it is typically watching, so a wait of minutes between attempts reads as a hang rather than saving anything. The iOS build keeps a 60s cap; everything else in the policy (doubling series, jitter, foreground wake, rebuild gating) is unchanged. The backoff math takes the cap as a parameter so the unit test covers both values from any host, and the docs and FFI comments state the iOS cap. Co-Authored-By: Claude Fable 5.1 --- README.md | 7 ++- crates/flextunnel-core/src/proxy/client.rs | 51 +++++++++++++++++----- crates/flextunnel-ffi/src/lib.rs | 6 +-- docs/architecture.md | 10 +++-- ios/flextunnel.h | 2 +- 5 files changed, 55 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 502a835..ca19d26 100644 --- a/README.md +++ b/README.md @@ -618,12 +618,15 @@ Auto-reconnect is **enabled by default** (`auto_reconnect = true`); pass - A failed connection attempt — the **first one included** — or a lost connection is retried with **exponential backoff + jitter** (1s doubling to - 5 min), indefinitely, unless `--max-reconnect-attempts` caps it or + 5 min; 60s on iOS, where a session is temporary by nature and a wait of + minutes would read as a hang), indefinitely, unless + `--max-reconnect-attempts` caps it or auto-reconnect is disabled. A server that is down, or not up yet, is the ordinary case, not a reason to exit: the client waits it out and connects when the server appears. - A long outage is cheap to sit through: once the backoff reaches its cap the - client makes one bounded connect attempt every five minutes. Repeated + CLI or desktop client makes one bounded connect attempt every five + minutes. Repeated failures escalate to rebuilding the iroh endpoint from scratch after the third one, and then at most every 30 minutes for as long as the outage lasts (see [`docs/architecture.md`](docs/architecture.md#reconnect-policy-client)). diff --git a/crates/flextunnel-core/src/proxy/client.rs b/crates/flextunnel-core/src/proxy/client.rs index 10e3cf9..2ab16fc 100644 --- a/crates/flextunnel-core/src/proxy/client.rs +++ b/crates/flextunnel-core/src/proxy/client.rs @@ -34,7 +34,16 @@ use tokio::sync::{Semaphore, watch}; /// cheap to sit through — at the price of noticing the server's return up to /// five minutes late. Events that make an earlier attempt worthwhile cut the /// wait short (see [`ProxyClient::wait_backoff`]). +/// +/// That cap is sized for the CLI and desktop clients, which run unattended +/// for days. An iOS session is temporary by nature — it lives only as long +/// as the app, and the user who started it is typically looking at it — so +/// a wait of minutes between attempts would read as a hang; the iOS build +/// caps at 60s instead. +#[cfg(not(target_os = "ios"))] const RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(300); +#[cfg(target_os = "ios")] +const RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(60); /// Escalate to a full endpoint rebuild once this many consecutive attempts of /// an outage have failed. The attempts before it get the cheap /// `network_change()` nudge, which repairs dead UDP sockets; a wedge that @@ -255,10 +264,16 @@ impl ServerForwarder { /// Shared with [`crate::proxy::bridge`], whose reconnect policy mirrors the /// client's. pub(crate) fn calculate_backoff(attempt: u32) -> Duration { - // 2^(attempt-1) seconds; the shift is bounded well past where the cap + bounded_backoff(attempt, RECONNECT_BACKOFF_MAX) +} + +/// [`calculate_backoff`] with the cap as a parameter, so tests cover the cap +/// of every platform from any host. +fn bounded_backoff(attempt: u32, cap: Duration) -> Duration { + // 2^(attempt-1) seconds; the shift is bounded well past where any cap // takes over, so it can never overflow. let shift = attempt.saturating_sub(1).min(16); - let secs = (1u64 << shift).min(RECONNECT_BACKOFF_MAX.as_secs()); + let secs = (1u64 << shift).min(cap.as_secs()); let jitter = rand::rng().random_range(0..=RECONNECT_JITTER_MAX_MS); Duration::from_secs(secs) + Duration::from_millis(jitter) } @@ -2176,22 +2191,34 @@ mod tests { ); } - /// The backoff doubles from 1s and settles at the cap, jitter aside. + /// The backoff doubles from 1s and settles at the cap, jitter aside — + /// checked for both platform caps (5 min unattended, 60s on iOS) since a + /// test host only ever compiles one of them into `calculate_backoff`. #[test] fn backoff_doubles_to_the_cap() { let jitter = Duration::from_millis(RECONNECT_JITTER_MAX_MS); - for (attempt, secs) in [(0, 1), (1, 1), (2, 2), (3, 4), (7, 64), (9, 256)] { - let b = calculate_backoff(attempt); - let base = Duration::from_secs(secs); - assert!(b >= base && b <= base + jitter, "attempt {attempt}: {b:?}"); + let five_min = Duration::from_secs(300); + let one_min = Duration::from_secs(60); + for (attempt, secs) in [(0, 1), (1, 1), (2, 2), (3, 4), (6, 32)] { + for cap in [five_min, one_min] { + let b = bounded_backoff(attempt, cap); + let base = Duration::from_secs(secs); + assert!(b >= base && b <= base + jitter, "attempt {attempt}: {b:?}"); + } } + // 2^8 = 256s is under the unattended cap but over the iOS one. + let b = bounded_backoff(9, five_min); + assert!(b >= Duration::from_secs(256) && b <= Duration::from_secs(256) + jitter); + let b = bounded_backoff(9, one_min); + assert!(b >= one_min && b <= one_min + jitter); for attempt in [10, 11, 20, u32::MAX] { - let b = calculate_backoff(attempt); - assert!( - b >= RECONNECT_BACKOFF_MAX && b <= RECONNECT_BACKOFF_MAX + jitter, - "attempt {attempt}: {b:?}" - ); + for cap in [five_min, one_min] { + let b = bounded_backoff(attempt, cap); + assert!(b >= cap && b <= cap + jitter, "attempt {attempt}: {b:?}"); + } } + // The platform constant is one of the two. + assert!([five_min, one_min].contains(&RECONNECT_BACKOFF_MAX)); } /// An outage's first rebuild comes after the third consecutive failure; diff --git a/crates/flextunnel-ffi/src/lib.rs b/crates/flextunnel-ffi/src/lib.rs index c3bd2ec..042e5c1 100644 --- a/crates/flextunnel-ffi/src/lib.rs +++ b/crates/flextunnel-ffi/src/lib.rs @@ -552,9 +552,9 @@ pub unsafe extern "C" fn flextunnel_close_listeners(handle: *const FlextunnelHan /// heartbeat — the connection's only periodic traffic — slows from 10s to 60s, /// keeping the cellular radio in its low-power state almost the whole time; the /// foreground flip snaps it back and sends any overdue beat immediately, and if -/// the core is sitting out a reconnect backoff (up to 5 min once a long outage -/// has pushed it to the cap) it ends that wait and attempts at once with a -/// fresh backoff series. Idempotent; safe to call with the same value +/// the core is sitting out a reconnect backoff (up to 60s on iOS once an +/// outage has pushed it to the cap) it ends that wait and attempts at once +/// with a fresh backoff series. Idempotent; safe to call with the same value /// repeatedly. /// /// Returns 1 on success and -1 for a null handle. diff --git a/docs/architecture.md b/docs/architecture.md index 9a51d09..4c6ce7f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -224,7 +224,10 @@ Implemented in `ProxyClient::run` / `handle_failure`: - Every recoverable failure (`ConnectionLost` / `Network` / `Signaling` — see `ProxyError::is_recoverable`) is retried with **exponential backoff + - jitter** (1s doubling to `RECONNECT_BACKOFF_MAX`, 5 min), indefinitely, + jitter** (1s doubling to `RECONNECT_BACKOFF_MAX`: 5 min, or 60s on iOS — + an iOS session is temporary by nature, living only as long as the app with + its user typically watching, so a wait of minutes would read as a hang + rather than save anything), indefinitely, unless `--max-reconnect-attempts` caps it or `--no-auto-reconnect` disables it. The first attempt is no different from any later one: a server that is down or not up yet is the ordinary case (boot order, maintenance), not an @@ -233,7 +236,8 @@ Implemented in `ProxyClient::run` / `handle_failure`: - Permanent errors (`AuthenticationFailed` / `Config`) never retry — the same credential and config would fail the same way every time. - A long outage costs one bounded connect (`CONNECT_TIMEOUT`) per attempt on - the endpoint the client already holds, once every five minutes at the cap. + the endpoint the client already holds, once every five minutes at the + unattended cap. Two events cut a backoff step short with a fresh series: the device reporting its network path back (`set_network_available`) and the embedding app coming to the foreground (`set_background(false)`), so a user who is @@ -369,7 +373,7 @@ defenses. | `TUNNEL_OPEN_TIMEOUT` | 30s | `proxy/client.rs` | | `CONNECT_TIMEOUT` (server dial) | 10s | `proxy/dial.rs` | | `MAX_CONCURRENT_CONNECTIONS` | 1024 | `proxy/server.rs` | -| reconnect backoff | 1s → 5 min + ≤500ms jitter | `proxy/client.rs` | +| reconnect backoff | 1s → 5 min (60s on iOS) + ≤500ms jitter | `proxy/client.rs` | | `REBUILD_ENDPOINT_ATTEMPTS` / `REBUILD_ENDPOINT_MIN_INTERVAL` (client endpoint rebuild) | 3rd failure, then ≥30 min apart | `proxy/client.rs` | | `MAX_HANDSHAKE_SIZE` | 64 KiB | `proxy/signaling.rs` | | `MAX_CONTROL_MSG_SIZE` | 16 KiB | `proxy/signaling.rs` | diff --git a/ios/flextunnel.h b/ios/flextunnel.h index f1f8208..6fb09bf 100644 --- a/ios/flextunnel.h +++ b/ios/flextunnel.h @@ -148,7 +148,7 @@ int flextunnel_close_listeners(const FlextunnelHandle *handle); * traffic — slows from 10s to 60s so an idle session wakes the cellular radio * once a minute instead of six times; the foreground flip snaps it back and * sends any overdue beat immediately, and ends any reconnect backoff in - * progress (up to 5 min once a long outage has pushed it to the cap) so the + * progress (up to 60s on iOS once an outage has pushed it to the cap) so the * next attempt runs at once with a fresh backoff series. Idempotent. * * Returns 1 on success and -1 for a NULL handle. From 4ee90a1be5bf792d4c486353e1de5cd513dbe34c Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Tue, 15 Sep 2026 18:42:15 -0700 Subject: [PATCH 2/4] Cap the reconnect backoff at 60s everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5 min cap waits too long between attempts for every client, not just iOS: each probe is one bounded connect on an endpoint the client already holds, so the minutes bought almost nothing while delaying the notice of a server's return by that much. Go back to the single 60s cap this had before the reconnect revamp — one cap for desktop, CLI and mobile alike, so the platform split (and the test helper that took the cap as a parameter to cover both) goes away with it. Everything else in the policy is unchanged: the doubling series from 1s, jitter, retrying the first attempt, the network/foreground wake, and the endpoint rebuild after the third failure then at most every 30 minutes. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 7 +-- crates/flextunnel-cli/src/tui/view.rs | 2 +- crates/flextunnel-core/src/proxy/client.rs | 68 +++++++--------------- crates/flextunnel-ffi/src/lib.rs | 6 +- docs/architecture.md | 12 ++-- docs/systemd.md | 6 +- ios/flextunnel.h | 2 +- 7 files changed, 35 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index ca19d26..6cd82b8 100644 --- a/README.md +++ b/README.md @@ -618,15 +618,12 @@ Auto-reconnect is **enabled by default** (`auto_reconnect = true`); pass - A failed connection attempt — the **first one included** — or a lost connection is retried with **exponential backoff + jitter** (1s doubling to - 5 min; 60s on iOS, where a session is temporary by nature and a wait of - minutes would read as a hang), indefinitely, unless - `--max-reconnect-attempts` caps it or + 60s), indefinitely, unless `--max-reconnect-attempts` caps it or auto-reconnect is disabled. A server that is down, or not up yet, is the ordinary case, not a reason to exit: the client waits it out and connects when the server appears. - A long outage is cheap to sit through: once the backoff reaches its cap the - CLI or desktop client makes one bounded connect attempt every five - minutes. Repeated + client makes one bounded connect attempt a minute. Repeated failures escalate to rebuilding the iroh endpoint from scratch after the third one, and then at most every 30 minutes for as long as the outage lasts (see [`docs/architecture.md`](docs/architecture.md#reconnect-policy-client)). diff --git a/crates/flextunnel-cli/src/tui/view.rs b/crates/flextunnel-cli/src/tui/view.rs index cb50f97..8591c20 100644 --- a/crates/flextunnel-cli/src/tui/view.rs +++ b/crates/flextunnel-cli/src/tui/view.rs @@ -106,7 +106,7 @@ fn header_lines(s: &StatusSnapshot) -> Vec> { first.push(Span::styled(format!(" for {}", format_uptime(secs)), DIM)); } // While down, say how far the retry loop has got and when it tries again, - // so a backoff step of minutes reads as waiting, not stuck. + // so a backoff step reads as waiting, not stuck. if s.failed_attempts > 0 { let next = match s.next_attempt_secs { Some(secs) if secs > 0 => format!("next in {}", format_uptime(secs)), diff --git a/crates/flextunnel-core/src/proxy/client.rs b/crates/flextunnel-core/src/proxy/client.rs index 2ab16fc..a94e7f8 100644 --- a/crates/flextunnel-core/src/proxy/client.rs +++ b/crates/flextunnel-core/src/proxy/client.rs @@ -27,22 +27,14 @@ use tokio::sync::{Semaphore, watch}; /// Reconnect backoff: base 1s, doubling per consecutive failed attempt, capped /// here. The early steps (1s, 2s, 4s, …) catch a server restart within a -/// minute or two; past them the doubling runs on up to the cap, so a server -/// that stays down for hours or days is probed once every five minutes for as -/// long as it takes. Each probe is one bounded connect ([`CONNECT_TIMEOUT`]) -/// on the endpoint the client already holds, so an outage of any length is -/// cheap to sit through — at the price of noticing the server's return up to -/// five minutes late. Events that make an earlier attempt worthwhile cut the -/// wait short (see [`ProxyClient::wait_backoff`]). -/// -/// That cap is sized for the CLI and desktop clients, which run unattended -/// for days. An iOS session is temporary by nature — it lives only as long -/// as the app, and the user who started it is typically looking at it — so -/// a wait of minutes between attempts would read as a hang; the iOS build -/// caps at 60s instead. -#[cfg(not(target_os = "ios"))] -const RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(300); -#[cfg(target_os = "ios")] +/// minute or two; past them the doubling settles at the cap, so a server that +/// stays down for hours or days is probed once a minute for as long as it +/// takes. Each probe is one bounded connect ([`CONNECT_TIMEOUT`]) on the +/// endpoint the client already holds, so an outage of any length is cheap to +/// sit through while still noticing the server's return within a minute — a +/// wait of several minutes saves little and reads as a hang to anyone +/// watching. Events that make an earlier attempt worthwhile cut the wait +/// short (see [`ProxyClient::wait_backoff`]). const RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(60); /// Escalate to a full endpoint rebuild once this many consecutive attempts of /// an outage have failed. The attempts before it get the cheap @@ -264,16 +256,10 @@ impl ServerForwarder { /// Shared with [`crate::proxy::bridge`], whose reconnect policy mirrors the /// client's. pub(crate) fn calculate_backoff(attempt: u32) -> Duration { - bounded_backoff(attempt, RECONNECT_BACKOFF_MAX) -} - -/// [`calculate_backoff`] with the cap as a parameter, so tests cover the cap -/// of every platform from any host. -fn bounded_backoff(attempt: u32, cap: Duration) -> Duration { - // 2^(attempt-1) seconds; the shift is bounded well past where any cap + // 2^(attempt-1) seconds; the shift is bounded well past where the cap // takes over, so it can never overflow. let shift = attempt.saturating_sub(1).min(16); - let secs = (1u64 << shift).min(cap.as_secs()); + let secs = (1u64 << shift).min(RECONNECT_BACKOFF_MAX.as_secs()); let jitter = rand::rng().random_range(0..=RECONNECT_JITTER_MAX_MS); Duration::from_secs(secs) + Duration::from_millis(jitter) } @@ -877,7 +863,7 @@ impl ProxyClient { /// reconnect. While the path is up this is a plain backoff sleep, except /// that a mid-sleep loss switches to parking, and that the embedding app /// coming to the foreground ends the sleep the same way a restored path - /// does — a backoff step sized for an unattended outage (up to + /// does — a backoff step sized for a long outage (up to /// [`RECONNECT_BACKOFF_MAX`]) must not keep a user who is looking waiting. /// /// Returns whether the wait was cut short by one of those events (the @@ -2191,34 +2177,22 @@ mod tests { ); } - /// The backoff doubles from 1s and settles at the cap, jitter aside — - /// checked for both platform caps (5 min unattended, 60s on iOS) since a - /// test host only ever compiles one of them into `calculate_backoff`. + /// The backoff doubles from 1s and settles at the cap, jitter aside. #[test] fn backoff_doubles_to_the_cap() { let jitter = Duration::from_millis(RECONNECT_JITTER_MAX_MS); - let five_min = Duration::from_secs(300); - let one_min = Duration::from_secs(60); for (attempt, secs) in [(0, 1), (1, 1), (2, 2), (3, 4), (6, 32)] { - for cap in [five_min, one_min] { - let b = bounded_backoff(attempt, cap); - let base = Duration::from_secs(secs); - assert!(b >= base && b <= base + jitter, "attempt {attempt}: {b:?}"); - } + let b = calculate_backoff(attempt); + let base = Duration::from_secs(secs); + assert!(b >= base && b <= base + jitter, "attempt {attempt}: {b:?}"); } - // 2^8 = 256s is under the unattended cap but over the iOS one. - let b = bounded_backoff(9, five_min); - assert!(b >= Duration::from_secs(256) && b <= Duration::from_secs(256) + jitter); - let b = bounded_backoff(9, one_min); - assert!(b >= one_min && b <= one_min + jitter); - for attempt in [10, 11, 20, u32::MAX] { - for cap in [five_min, one_min] { - let b = bounded_backoff(attempt, cap); - assert!(b >= cap && b <= cap + jitter, "attempt {attempt}: {b:?}"); - } + for attempt in [7, 9, 10, 20, u32::MAX] { + let b = calculate_backoff(attempt); + assert!( + b >= RECONNECT_BACKOFF_MAX && b <= RECONNECT_BACKOFF_MAX + jitter, + "attempt {attempt}: {b:?}" + ); } - // The platform constant is one of the two. - assert!([five_min, one_min].contains(&RECONNECT_BACKOFF_MAX)); } /// An outage's first rebuild comes after the third consecutive failure; diff --git a/crates/flextunnel-ffi/src/lib.rs b/crates/flextunnel-ffi/src/lib.rs index 042e5c1..6c3f866 100644 --- a/crates/flextunnel-ffi/src/lib.rs +++ b/crates/flextunnel-ffi/src/lib.rs @@ -552,9 +552,9 @@ pub unsafe extern "C" fn flextunnel_close_listeners(handle: *const FlextunnelHan /// heartbeat — the connection's only periodic traffic — slows from 10s to 60s, /// keeping the cellular radio in its low-power state almost the whole time; the /// foreground flip snaps it back and sends any overdue beat immediately, and if -/// the core is sitting out a reconnect backoff (up to 60s on iOS once an -/// outage has pushed it to the cap) it ends that wait and attempts at once -/// with a fresh backoff series. Idempotent; safe to call with the same value +/// the core is sitting out a reconnect backoff (up to 60s once a long outage +/// has pushed it to the cap) it ends that wait and attempts at once with a +/// fresh backoff series. Idempotent; safe to call with the same value /// repeatedly. /// /// Returns 1 on success and -1 for a null handle. diff --git a/docs/architecture.md b/docs/architecture.md index 4c6ce7f..2ab3029 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -224,10 +224,7 @@ Implemented in `ProxyClient::run` / `handle_failure`: - Every recoverable failure (`ConnectionLost` / `Network` / `Signaling` — see `ProxyError::is_recoverable`) is retried with **exponential backoff + - jitter** (1s doubling to `RECONNECT_BACKOFF_MAX`: 5 min, or 60s on iOS — - an iOS session is temporary by nature, living only as long as the app with - its user typically watching, so a wait of minutes would read as a hang - rather than save anything), indefinitely, + jitter** (1s doubling to `RECONNECT_BACKOFF_MAX`, 60s), indefinitely, unless `--max-reconnect-attempts` caps it or `--no-auto-reconnect` disables it. The first attempt is no different from any later one: a server that is down or not up yet is the ordinary case (boot order, maintenance), not an @@ -236,12 +233,11 @@ Implemented in `ProxyClient::run` / `handle_failure`: - Permanent errors (`AuthenticationFailed` / `Config`) never retry — the same credential and config would fail the same way every time. - A long outage costs one bounded connect (`CONNECT_TIMEOUT`) per attempt on - the endpoint the client already holds, once every five minutes at the - unattended cap. + the endpoint the client already holds, once a minute at the cap. Two events cut a backoff step short with a fresh series: the device reporting its network path back (`set_network_available`) and the embedding app coming to the foreground (`set_background(false)`), so a user who is - looking never waits out a step sized for an unattended outage. + looking never waits out a step sized for a long outage. - An outage's **third** consecutive failure escalates to a **full endpoint rebuild** (`ClientEndpoint::rebuild`), repeated at most every `REBUILD_ENDPOINT_MIN_INTERVAL` (30 min) for as long as the outage lasts; the @@ -373,7 +369,7 @@ defenses. | `TUNNEL_OPEN_TIMEOUT` | 30s | `proxy/client.rs` | | `CONNECT_TIMEOUT` (server dial) | 10s | `proxy/dial.rs` | | `MAX_CONCURRENT_CONNECTIONS` | 1024 | `proxy/server.rs` | -| reconnect backoff | 1s → 5 min (60s on iOS) + ≤500ms jitter | `proxy/client.rs` | +| reconnect backoff | 1s → 60s + ≤500ms jitter | `proxy/client.rs` | | `REBUILD_ENDPOINT_ATTEMPTS` / `REBUILD_ENDPOINT_MIN_INTERVAL` (client endpoint rebuild) | 3rd failure, then ≥30 min apart | `proxy/client.rs` | | `MAX_HANDSHAKE_SIZE` | 64 KiB | `proxy/signaling.rs` | | `MAX_CONTROL_MSG_SIZE` | 16 KiB | `proxy/signaling.rs` | diff --git a/docs/systemd.md b/docs/systemd.md index 05293b1..2136837 100644 --- a/docs/systemd.md +++ b/docs/systemd.md @@ -57,12 +57,12 @@ The client already supervises itself where it matters: - Auto-reconnect (on by default) retries every failed connection attempt and every lost connection internally with exponential backoff (1s doubling to - 5 min), indefinitely — the first attempt included. A server that is down + 60s), indefinitely — the first attempt included. A server that is down when the unit starts, or a network that isn't up yet at boot, is waited out, not exited on: the client connects when the server appears, and there is no user-manager `network-online.target` to order against nor any need - for one. A long outage costs one bounded connect attempt every five - minutes. Reconnects that keep failing escalate to rebuilding the iroh + for one. A long outage costs one bounded connect attempt a minute. + Reconnects that keep failing escalate to rebuilding the iroh endpoint from scratch (after the third failure, then at most every 30 minutes) — the in-process equivalent of a unit restart, covering wedges (a dead relay link, stale path state) that only a fresh endpoint repairs. The diff --git a/ios/flextunnel.h b/ios/flextunnel.h index 6fb09bf..7164be0 100644 --- a/ios/flextunnel.h +++ b/ios/flextunnel.h @@ -148,7 +148,7 @@ int flextunnel_close_listeners(const FlextunnelHandle *handle); * traffic — slows from 10s to 60s so an idle session wakes the cellular radio * once a minute instead of six times; the foreground flip snaps it back and * sends any overdue beat immediately, and ends any reconnect backoff in - * progress (up to 60s on iOS once an outage has pushed it to the cap) so the + * progress (up to 60s once a long outage has pushed it to the cap) so the * next attempt runs at once with a fresh backoff series. Idempotent. * * Returns 1 on success and -1 for a NULL handle. From eab07a9fc070e258d927559740ef7bc956c69eff Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Tue, 15 Sep 2026 19:32:28 -0700 Subject: [PATCH 3/4] Bump version to 0.0.80 for flextunnel packages Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9fbd646..0a25836 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1945,7 +1945,7 @@ dependencies = [ [[package]] name = "flextunnel-cli" -version = "0.0.79" +version = "0.0.80" dependencies = [ "anyhow", "clap", @@ -1961,7 +1961,7 @@ dependencies = [ [[package]] name = "flextunnel-core" -version = "0.0.79" +version = "0.0.80" dependencies = [ "anyhow", "askama", @@ -1992,7 +1992,7 @@ dependencies = [ [[package]] name = "flextunnel-desktop" -version = "0.0.79" +version = "0.0.80" dependencies = [ "aes-gcm", "anyhow", @@ -2021,7 +2021,7 @@ dependencies = [ [[package]] name = "flextunnel-ffi" -version = "0.0.79" +version = "0.0.80" dependencies = [ "flextunnel-core", "iroh", diff --git a/Cargo.toml b/Cargo.toml index ef6bf0d..277bcaa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ default-members = [ ] [workspace.package] -version = "0.0.79" +version = "0.0.80" edition = "2024" description = "SOCKS5/HTTP-proxy-over-QUIC split tunnel via iroh P2P" From 9a1e7db1e1b4ce065cee840a5dde3c3862b195bd Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Tue, 15 Sep 2026 19:35:03 -0700 Subject: [PATCH 4/4] Say port forwarding in the package description The one-line description still described the client as a proxy only, which has been half the story since forwards landed: a client can run with no proxy listener at all and serve nothing but its declared port forwards. The crate description, the CLI's --help line, the two crate-level docs and the README's opening paragraph now name both. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.toml | 2 +- README.md | 10 +++++++--- crates/flextunnel-cli/src/main.rs | 5 +++-- crates/flextunnel-core/src/lib.rs | 3 ++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 277bcaa..169f1c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ default-members = [ [workspace.package] version = "0.0.80" edition = "2024" -description = "SOCKS5/HTTP-proxy-over-QUIC split tunnel via iroh P2P" +description = "SOCKS5/HTTP proxy and port forwards over QUIC — split tunnel via iroh P2P" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index 6cd82b8..0f31a44 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ # flextunnel -A SOCKS5/HTTP-proxy-over-QUIC split tunnel. The **client** runs optional local -SOCKS5 and HTTP proxy listeners. Each request is matched +A SOCKS5/HTTP-proxy- and port-forward-over-QUIC split tunnel. The **client** +runs optional local SOCKS5 and HTTP proxy listeners, plus optional port +forwards that send a local port straight to one server-side address. Each +proxy request is matched against the server-pushed tunnel set: routed targets are tunneled as reliable QUIC bi-streams to the **server**, which performs **DNS resolution and the outbound TCP connection from its own network**, then pipes bytes back; off-list -targets are connected directly from the client device. +targets are connected directly from the client device. A forwarded port does no +matching on the client: everything it accepts goes to the server, which +enforces its routed set there. This lets you reach hosts that are only reachable from the server side — a private network, the server's own `localhost`, or names that only resolve via diff --git a/crates/flextunnel-cli/src/main.rs b/crates/flextunnel-cli/src/main.rs index c8b9f0f..9ee9d96 100644 --- a/crates/flextunnel-cli/src/main.rs +++ b/crates/flextunnel-cli/src/main.rs @@ -1,6 +1,7 @@ //! flextunnel //! -//! A SOCKS5/HTTP-proxy-over-QUIC split tunnel via iroh P2P connections. The +//! A SOCKS5/HTTP-proxy- and port-forward-over-QUIC split tunnel via iroh P2P +//! connections. The //! client runs optional local SOCKS5/HTTP proxy listeners and server-direct //! port forwards (declared in its config; `flextunnel client control` shows //! their state); routed @@ -40,7 +41,7 @@ use flextunnel_core::{auth, config, secret}; #[derive(Parser)] #[command(name = "flextunnel")] #[command(version)] -#[command(about = "SOCKS5/HTTP-proxy-over-QUIC split tunnel via iroh P2P")] +#[command(about = "SOCKS5/HTTP proxy and port forwards over QUIC — split tunnel via iroh P2P")] struct Args { #[command(subcommand)] command: Command, diff --git a/crates/flextunnel-core/src/lib.rs b/crates/flextunnel-core/src/lib.rs index 1f4786e..d7ee28d 100644 --- a/crates/flextunnel-core/src/lib.rs +++ b/crates/flextunnel-core/src/lib.rs @@ -1,6 +1,7 @@ //! flextunnel //! -//! A SOCKS5/HTTP-proxy-over-QUIC split tunnel via iroh P2P connections. The +//! A SOCKS5/HTTP-proxy- and port-forward-over-QUIC split tunnel via iroh P2P +//! connections. The //! clients may run local SOCKS5/HTTP proxy listeners or server-direct loopback //! forwards; routed targets are reliable QUIC bi-streams to the server, which //! resolves DNS and connects from its own network. Uses a fixed ALPN for