v0.8.4: config load failures are no longer silent (diagnoses the reset-on-reopen bug)

A user reported that after Save-config, closing the UI, and reopening,
every form field was blank — but the config.json on disk still had all
the right values.

The culprit in the UI was load_form()'s swallow-errors pattern:

  let existing = if path.exists() {
      Config::load(&path).ok()   // .ok() threw away the error
  } else { ... };
  if let Some(c) = existing { /* populate form */ } else { /* defaults */ }

When Config::load returned an Err, .ok() silently converted to None,
the form went back to defaults, and the user had no signal at all
that the load had failed or WHY. On every platform I could test
(macOS / Linux) the round-trip works fine with a real round-trip test
added in config.rs (config::rt_tests::round_trip_all_current_fields
and round_trip_minimal_fields_only — both green). So whatever's
failing for this specific reporter is environment-specific (weird
filesystem encoding, partial write, different field shape from an
older version, … TBD). Without visibility we can't diagnose it.

Changes:

1. load_form() now returns (FormState, Option<String>). The String
   is a user-facing error message (with the full path + the
   underlying parse/validate reason) when Config::load fails on an
   existing file.
2. main() plumbs that error into App's initial toast, which sticks
   for 30 seconds (vs the normal 5 for regular toasts) so users who
   only open the UI briefly still see it.
3. Added tracing::info! in load_form for the success path too —
   the Recent log panel now always shows either 'config: loaded OK
   from <path>' or 'Config at <path> failed to load: <reason>' on
   startup, regardless of toast timing.
4. Added two regression-guard tests in config.rs covering the
   full-fields and minimal-fields save shapes the UI emits.

Next time a user reports this: they'll have the exact error in the
toast + the Recent log panel, and we can fix the actual bug instead
of shooting blind.
This commit is contained in:
therealaleph
2026-04-22 17:19:31 +03:00
parent 5371bfc7d5
commit 790212cf51
4 changed files with 112 additions and 13 deletions
Generated
+1 -1
View File
@@ -1317,7 +1317,7 @@ dependencies = [
[[package]] [[package]]
name = "mhrv-rs" name = "mhrv-rs"
version = "0.8.3" version = "0.8.4"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mhrv-rs" name = "mhrv-rs"
version = "0.8.3" version = "0.8.4"
edition = "2021" edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT" license = "MIT"
+44 -11
View File
@@ -46,7 +46,8 @@ fn main() -> eframe::Result<()> {
.spawn(move || background_thread(shared_bg, cmd_rx)) .spawn(move || background_thread(shared_bg, cmd_rx))
.expect("failed to spawn background thread"); .expect("failed to spawn background thread");
let form = load_form(); let (form, load_err) = load_form();
let initial_toast = load_err.map(|e| (e, Instant::now()));
let options = eframe::NativeOptions { let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
@@ -66,7 +67,7 @@ fn main() -> eframe::Result<()> {
cmd_tx, cmd_tx,
form, form,
last_poll: Instant::now(), last_poll: Instant::now(),
toast: None, toast: initial_toast,
})) }))
}), }),
) )
@@ -161,17 +162,42 @@ struct SniRow {
enabled: bool, enabled: bool,
} }
fn load_form() -> FormState { fn load_form() -> (FormState, Option<String>) {
// Try the user-data config first, then the cwd fallback. Report WHY load
// fails so the user isn't silently shown a blank form (issue: user reports
// 'settings saved to file but not loaded back'). Without this signal the
// failure is invisible — `.ok()` swallows it and the form looks fresh.
let path = data_dir::config_path(); let path = data_dir::config_path();
let cwd = PathBuf::from("config.json"); let cwd = PathBuf::from("config.json");
let existing = if path.exists() {
Config::load(&path).ok() let (existing, load_err): (Option<Config>, Option<String>) = if path.exists() {
tracing::info!("config: attempting load from {}", path.display());
match Config::load(&path) {
Ok(c) => {
tracing::info!("config: loaded OK from {}", path.display());
(Some(c), None)
}
Err(e) => {
let msg = format!("Config at {} failed to load: {}", path.display(), e);
tracing::warn!("{}", msg);
(None, Some(msg))
}
}
} else if cwd.exists() { } else if cwd.exists() {
Config::load(&cwd).ok() tracing::info!("config: attempting fallback load from {}", cwd.display());
match Config::load(&cwd) {
Ok(c) => (Some(c), None),
Err(e) => {
let msg = format!("Config at {} failed to load: {}", cwd.display(), e);
tracing::warn!("{}", msg);
(None, Some(msg))
}
}
} else { } else {
None tracing::info!("config: no config found at {} — starting with defaults", path.display());
(None, None)
}; };
if let Some(c) = existing { let form = if let Some(c) = existing {
let sid = match &c.script_id { let sid = match &c.script_id {
Some(ScriptId::One(s)) => s.clone(), Some(ScriptId::One(s)) => s.clone(),
Some(ScriptId::Many(v)) => v.join("\n"), Some(ScriptId::Many(v)) => v.join("\n"),
@@ -225,7 +251,8 @@ fn load_form() -> FormState {
google_ip_validation:true, google_ip_validation:true,
scan_batch_size:500 scan_batch_size:500
} }
} };
(form, load_err)
} }
/// Build the initial `sni_pool` list shown in the editor. /// Build the initial `sni_pool` list shown in the editor.
@@ -762,9 +789,15 @@ impl eframe::App for App {
} }
}); });
// Transient toast at the bottom. // Transient toast at the bottom. Config-load failures stick for
// 30s instead of 5 because they explain why the form looks empty.
if let Some((msg, t)) = &self.toast { if let Some((msg, t)) = &self.toast {
if t.elapsed() < Duration::from_secs(5) { let ttl = if msg.contains("failed to load") {
Duration::from_secs(30)
} else {
Duration::from_secs(5)
};
if t.elapsed() < ttl {
ui.add_space(4.0); ui.add_space(4.0);
ui.colored_label(egui::Color32::from_rgb(200, 170, 80), msg); ui.colored_label(egui::Color32::from_rgb(200, 170, 80), msg);
} else { } else {
+66
View File
@@ -214,3 +214,69 @@ mod tests {
assert!(cfg.validate().is_err()); assert!(cfg.validate().is_err());
} }
} }
#[cfg(test)]
mod rt_tests {
use super::*;
#[test]
fn round_trip_all_current_fields() {
// Regression guard: make sure a config written by the UI (all current
// optional fields present and populated) loads back cleanly.
let json = r#"{
"mode": "apps_script",
"google_ip": "216.239.38.120",
"front_domain": "www.google.com",
"script_id": "AKfyc_TEST",
"auth_key": "testtesttest",
"listen_host": "127.0.0.1",
"listen_port": 8085,
"socks5_port": 8086,
"log_level": "info",
"verify_ssl": true,
"upstream_socks5": "127.0.0.1:50529",
"parallel_relay": 2,
"sni_hosts": ["www.google.com", "drive.google.com"],
"fetch_ips_from_api": true,
"max_ips_to_scan": 50,
"scan_batch_size": 100,
"google_ip_validation": true
}"#;
let tmp = std::env::temp_dir().join("mhrv-rt-test.json");
std::fs::write(&tmp, json).unwrap();
let cfg = Config::load(&tmp).expect("config should load");
assert_eq!(cfg.mode, "apps_script");
assert_eq!(cfg.auth_key, "testtesttest");
assert_eq!(cfg.listen_port, 8085);
assert_eq!(cfg.upstream_socks5.as_deref(), Some("127.0.0.1:50529"));
assert_eq!(cfg.parallel_relay, 2);
assert_eq!(
cfg.sni_hosts.as_ref().unwrap(),
&vec!["www.google.com".to_string(), "drive.google.com".to_string()]
);
assert_eq!(cfg.fetch_ips_from_api, true);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn round_trip_minimal_fields_only() {
// User saves with defaults for everything optional. This is what the
// UI's save button actually writes for a first-run user.
let json = r#"{
"mode": "apps_script",
"google_ip": "216.239.38.120",
"front_domain": "www.google.com",
"script_id": "A",
"auth_key": "secretkey123",
"listen_host": "127.0.0.1",
"listen_port": 8085,
"log_level": "info",
"verify_ssl": true
}"#;
let tmp = std::env::temp_dir().join("mhrv-rt-min.json");
std::fs::write(&tmp, json).unwrap();
let cfg = Config::load(&tmp).expect("minimal config should load");
assert_eq!(cfg.mode, "apps_script");
let _ = std::fs::remove_file(&tmp);
}
}