diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs index dab231d..5dd025f 100644 --- a/ostp-client/src/bridge.rs +++ b/ostp-client/src/bridge.rs @@ -23,6 +23,12 @@ use crate::tunnel::{ProxyEvent, ProxyToClientMsg}; /// candidate address is tried. const UOT_CONNECT_TIMEOUT: Duration = Duration::from_secs(4); +/// How long to keep retrying a resume-triggered reconnect before handing the +/// problem back to the ordinary stall path. That path is what releases the +/// system proxy, so this is really a bound on how long the machine may be left +/// with no working internet at all after waking. +const RESUME_RECONNECT_GIVE_UP: Duration = Duration::from_secs(45); + static SOCKET_PROTECTOR: std::sync::OnceLock bool + Send + Sync>> = std::sync::OnceLock::new(); pub fn set_socket_protector(f: F) @@ -147,6 +153,11 @@ pub struct Bridge { /// fire at all. Retrying until success removes the dependency on either. forced_reconnect_pending: bool, last_forced_reconnect_try: Instant, + /// Wall-clock start of the current resume-reconnect campaign, used to bound + /// it. Wall clock rather than Instant because the monotonic clock does not + /// advance across suspend on Windows, so it cannot measure anything that + /// begins at wake. + forced_reconnect_started: Option, } impl Bridge { @@ -185,6 +196,7 @@ impl Bridge { last_valid_recv: Instant::now(), forced_reconnect_pending: false, last_forced_reconnect_try: Instant::now(), + forced_reconnect_started: None, }) } @@ -268,9 +280,45 @@ impl Bridge { "Resumed after ~{}s suspend — forcing clean reconnect", wall_gap.as_secs() ))).await; self.forced_reconnect_pending = true; + self.forced_reconnect_started = Some(SystemTime::now()); self.last_forced_reconnect_try = Instant::now() - Duration::from_secs(60); } + // Give up if resume reconnects keep failing. Retrying forever + // looks harmless but is not: the system proxy stays pointed at + // our local listener the whole time, so the machine has NO + // working internet — not merely no tunnel — while the UI sits + // on "connecting". Handing the retry to the ordinary keepalive + // path restores the proxy through its hard-timeout branch, + // which force=true deliberately skips. + // + // Measured on the wall clock: Instant does not advance across + // suspend on Windows (QPC stops), so a monotonic deadline can + // not bound anything that starts at wake. + if self.forced_reconnect_pending { + let pending_for = self + .forced_reconnect_started + .and_then(|t| t.elapsed().ok()) + .unwrap_or_default(); + if pending_for > RESUME_RECONNECT_GIVE_UP { + self.forced_reconnect_pending = false; + self.forced_reconnect_started = None; + let _ = tx.send(UiEvent::Log(format!( + "Reconnect after suspend failed for {}s — releasing the system \ + proxy so normal traffic works; will keep retrying in the \ + background", + pending_for.as_secs() + ))).await; + // Make the ordinary stall path fire on the next + // keepalive tick: it is the one that tears the proxy + // back down (or, with kill switch on, deliberately + // keeps blocking). + self.last_valid_recv = Instant::now() + .checked_sub(Duration::from_secs(3600)) + .unwrap_or_else(Instant::now); + } + } + // Keep retrying a resume-triggered reconnect until one lands. // The first attempt fires within half a second of waking, when // the NIC is typically still reassociating, so treating it as @@ -286,6 +334,7 @@ impl Bridge { // success check rather than "we tried". if self.last_valid_recv.elapsed() < Duration::from_secs(3) { self.forced_reconnect_pending = false; + self.forced_reconnect_started = None; let _ = tx.send(UiEvent::Log("Reconnected after suspend".into())).await; } } diff --git a/ostp-gui/src/main.js b/ostp-gui/src/main.js index 6722974..58f83ed 100644 --- a/ostp-gui/src/main.js +++ b/ostp-gui/src/main.js @@ -660,6 +660,13 @@ function loadSettingsIntoForm() { updateClientVisibility(); } +// Last values actually pushed to the OS / backend, so repeated saves that did +// not change them stay free. Undefined until the first save, which is correct: +// the first one should apply. +let lastAppliedAutostart; +let lastAppliedTunnelConfig; +let hotReloadTimer; + function collectAndSaveSettings() { const s = { tun: inTun.checked, @@ -686,19 +693,41 @@ function collectAndSaveSettings() { fragChunk: parseInt(inFragChunk.value) || 2, fragSleep: !isNaN(parseInt(inFragSleep.value)) ? parseInt(inFragSleep.value) : 2, }; + // Cheap and local: safe to run on every debounced keystroke. saveClientSettings(s); updateClientVisibility(); - // Set autostart - invoke('set_autostart', { enable: s.launchStartup }).catch(() => {}); + // Everything below talks to the OS or restarts the tunnel. Running it per + // keystroke is what made typing in the exclusion fields lag by seconds: the + // 400ms debounce fires during natural pauses in typing, and each firing hit + // the Windows registry and then tore down and rebuilt the tunnel. - // Hot-reload exclusions if connected + // Only touch autostart when it actually changed — this is a registry write. + if (s.launchStartup !== lastAppliedAutostart) { + lastAppliedAutostart = s.launchStartup; + invoke('set_autostart', { enable: s.launchStartup }).catch(() => {}); + } + + // Hot-reload the tunnel only when something it actually reads has changed, + // and on a much longer debounce: a reload is disruptive, so it should land + // once the user has stopped editing rather than between keystrokes. if (appState === 'connected') { - const cfg = buildConfig(); - if (cfg) { - invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) }) - .then(() => invoke('reload_tunnel')) - .catch(() => {}); + const tunnelRelevant = JSON.stringify([ + s.tun, s.killSwitch, s.mux, s.muxSessions, s.mtu, s.dns, s.socks, + s.exDomains, s.exIps, s.exProcs, s.junkEnabled, s.junkPcMin, s.junkPcMax, + s.junkPsMin, s.junkPsMax, s.tcpFrag, s.fragChunk, s.fragSleep, + ]); + if (tunnelRelevant !== lastAppliedTunnelConfig) { + clearTimeout(hotReloadTimer); + hotReloadTimer = setTimeout(() => { + lastAppliedTunnelConfig = tunnelRelevant; + const cfg = buildConfig(); + if (cfg) { + invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) }) + .then(() => invoke('reload_tunnel')) + .catch(() => {}); + } + }, 1500); } } }