Skip to content

Commit 23636b1

Browse files
Merge pull request #222 from loss-and-quick/feat/pending-restart
feat(desktop): pending-restart signal for stale settings + live non-tun mode switch
2 parents a4b1c09 + 9fdcba8 commit 23636b1

21 files changed

Lines changed: 444 additions & 19 deletions

File tree

crates/kasumi-backend/src/lifecycle.rs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use regex::Regex;
1212
use tokio::process::Child;
1313

1414
use kasumi_core::core::default_tun_for;
15+
use kasumi_core::core_config::CoreConfig;
1516
use kasumi_core::enums::{CoreEngine, NO_TUN_MARKER, TunEngine, tun_from_marker};
1617
use kasumi_core::hev_config::build_hev_config;
1718
use kasumi_core::singbox_config::build_singbox_bridge_config;
@@ -59,11 +60,13 @@ pub fn random_tun_iface() -> String {
5960

6061
/// Build the config for `profile_id` (else the active profile), write it and the
6162
/// engine marker, and return the resolved [`StartDataPath`] (engine, TUN engine,
62-
/// external-engine tuning, SOCKS port, proxy mode).
63+
/// external-engine tuning, SOCKS port, proxy mode) together with the exact
64+
/// [`CoreConfig`] that was written — the in-memory baseline a running data path
65+
/// is later diffed against (the on-disk file may be tuned further after start).
6366
pub async fn resolve_and_write_config(
6467
platform: &dyn Platform,
6568
profile_id: Option<&str>,
66-
) -> Result<StartDataPath, CommandError> {
69+
) -> Result<(StartDataPath, CoreConfig), CommandError> {
6770
let paths = platform.paths();
6871
let state = read_json::<AppState>(&paths.app_state).await;
6972
let id = profile_id
@@ -98,13 +101,16 @@ pub async fn resolve_and_write_config(
98101
} else {
99102
ProxyMode::Tun
100103
};
101-
Ok(StartDataPath {
102-
engine,
103-
tun,
104-
tun_opts,
105-
socks_port,
106-
mode,
107-
})
104+
Ok((
105+
StartDataPath {
106+
engine,
107+
tun,
108+
tun_opts,
109+
socks_port,
110+
mode,
111+
},
112+
built,
113+
))
108114
}
109115

110116
/// The core engine's on-disk label (written to `paths.engine_file` at config
@@ -600,12 +606,15 @@ mod tests {
600606
.unwrap();
601607

602608
// No explicit id → uses active_id.
603-
let opts = resolve_and_write_config(&p, None).await.unwrap();
609+
let (opts, built) = resolve_and_write_config(&p, None).await.unwrap();
604610
assert_eq!(opts.engine, CoreEngine::Xray);
605611
assert_eq!(opts.tun, TunEngine::Tun2socks);
606612
assert_eq!(opts.socks_port, 11080);
607613
// TestPlatform doesn't support proxy modes → always normalized to tun.
608614
assert_eq!(opts.mode, ProxyMode::Tun);
615+
// The returned build mirrors what was written.
616+
assert_eq!(built.engine, CoreEngine::Xray);
617+
assert!(built.config["outbounds"].is_array());
609618
assert_eq!(
610619
read_text(&p.paths().engine_file).await.as_deref(),
611620
Some("xray")

crates/kasumi-backend/src/service.rs

Lines changed: 227 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ use std::time::Duration;
1515
use tokio::sync::{Mutex, broadcast};
1616

1717
use kasumi_core::contract::{FetchMode, PushFrame, RunState, ServiceStatus, SubAppliedEvent};
18-
use kasumi_core::state::{AppState, DEFAULT_DELAY_TEST_URL};
18+
use kasumi_core::core_config::{CoreConfig, MutationEffect, mutation_effect};
19+
use kasumi_core::state::{AppState, DEFAULT_DELAY_TEST_URL, DEFAULT_LOCAL_SOCKS_PORT, ProxyMode};
1920

2021
use crate::commands::{self, Command, CommandError, Response};
2122
use crate::fs::read_text;
@@ -64,6 +65,15 @@ pub struct Service {
6465
/// Latest connectivity-probe result; the watchdog refreshes it, `current_status`
6566
/// overlays it onto a process-up state to tell Connected from NoInternet.
6667
connectivity: StdMutex<Connectivity>,
68+
/// The exact post-tune build + proxy mode the running data path was started
69+
/// with — the baseline settings mutations are diffed against. `None` while
70+
/// stopped. Deliberately in-memory (not the on-disk config): the start path
71+
/// may tune the written file further, so a disk diff would never settle.
72+
running_config: StdMutex<Option<(CoreConfig, ProxyMode)>>,
73+
/// Whether the running data path no longer matches the saved settings (one
74+
/// restart applies them). Recomputed on mutations and lifecycle edges only;
75+
/// `current_status` just reports it.
76+
pending_restart: AtomicBool,
6777
}
6878

6979
impl Service {
@@ -84,6 +94,8 @@ impl Service {
8494
sub_attempts: Mutex::new(HashMap::new()),
8595
auto_started: AtomicBool::new(false),
8696
connectivity: StdMutex::new(Connectivity::Unknown),
97+
running_config: StdMutex::new(None),
98+
pending_restart: AtomicBool::new(false),
8799
})
88100
}
89101

@@ -111,6 +123,10 @@ impl Service {
111123
// concurrent edits serialize into one consistent result.
112124
let _g = self.state_write.lock().await;
113125
let state = commands::run_mutation(&*self.platform, &intent).await?;
126+
// A mutation never restarts the data path; instead compare what
127+
// runs against what the new state would start (still under the
128+
// lock so a concurrent edit can't interleave its own recompute).
129+
self.refresh_pending_restart(&state).await;
114130
Ok(Response::State(Box::new(state)))
115131
}
116132
Command::ApplySubscription { sub_id } => {
@@ -149,24 +165,27 @@ impl Service {
149165
})
150166
.await
151167
.map_err(|e| e.to_string())?;
152-
let opts = resolve_and_write_config(&*self.platform, id.as_deref())
168+
let (opts, built) = resolve_and_write_config(&*self.platform, id.as_deref())
153169
.await
154170
.map_err(|e| e.0)?;
155171
let (mode, engine, socks_port) = (opts.mode, opts.engine, opts.socks_port);
156172
if let Err(e) = self.platform.start_data_path(opts).await {
157173
// A failed bring-up must not leave a previously-set OS proxy
158174
// pointing at a dead port.
159175
self.platform.clear_os_proxy().await;
176+
self.note_data_path_stopped();
160177
return Err(e.to_string());
161178
}
162179
// With the data-path up, align the OS proxy with the mode (set for
163180
// system/pac, cleared otherwise — covers mode switches).
164181
self.platform.set_os_proxy(mode, engine, socks_port).await;
182+
self.note_data_path_started(built, mode);
165183
Ok(())
166184
}
167185
LifecycleCmd::Stop => {
168186
// A stopped core must never leave the OS pointed at a dead port.
169187
self.platform.clear_os_proxy().await;
188+
self.note_data_path_stopped();
170189
self.platform
171190
.stop_data_path(StopDataPath::default())
172191
.await
@@ -186,13 +205,20 @@ impl Service {
186205
})
187206
.await
188207
.map_err(|e| e.to_string())?;
189-
let opts = resolve_and_write_config(&*self.platform, None)
208+
let (opts, built) = resolve_and_write_config(&*self.platform, None)
190209
.await
191210
.map_err(|e| e.0)?;
192-
self.platform
193-
.start_data_path(opts)
194-
.await
195-
.map_err(|e| e.to_string())
211+
let mode = opts.mode;
212+
match self.platform.start_data_path(opts).await {
213+
Ok(()) => {
214+
self.note_data_path_started(built, mode);
215+
Ok(())
216+
}
217+
Err(e) => {
218+
self.note_data_path_stopped();
219+
Err(e.to_string())
220+
}
221+
}
196222
} else if let Some(f) = self.platform.app_filter() {
197223
f.reload_app_filter().await.map_err(|e| e.to_string())
198224
} else {
@@ -202,6 +228,65 @@ impl Service {
202228
}
203229
}
204230

231+
/// Record a successful data-path start: the given build + mode become the
232+
/// baseline mutations are diffed against, and nothing is pending anymore.
233+
fn note_data_path_started(&self, built: CoreConfig, mode: ProxyMode) {
234+
*self.running_config.lock().unwrap() = Some((built, mode));
235+
self.pending_restart.store(false, Ordering::SeqCst);
236+
}
237+
238+
/// Drop the running-config baseline: with nothing running there is nothing
239+
/// that could be stale, so the pending flag clears too.
240+
fn note_data_path_stopped(&self) {
241+
*self.running_config.lock().unwrap() = None;
242+
self.pending_restart.store(false, Ordering::SeqCst);
243+
}
244+
245+
/// After a settings mutation, decide what it means for the running data path:
246+
/// nothing (stopped, or the mutated state doesn't build), a live OS-proxy
247+
/// re-point (mode moved within the non-tun family, identical build), or a
248+
/// flip of `pending_restart`. Emits a status frame when the flag changes so
249+
/// clients react immediately instead of waiting for the next push tick.
250+
async fn refresh_pending_restart(&self, state: &AppState) {
251+
// Clone the baseline out so no std lock is held across the build below.
252+
let Some((running_cfg, running_mode)) = self.running_config.lock().unwrap().clone() else {
253+
return;
254+
};
255+
let Some(active_id) = state.active_id.as_deref() else {
256+
return;
257+
};
258+
// A state that can't build (e.g. the active profile was just removed) says
259+
// nothing about the running path — leave the flag as it is.
260+
let Ok(next_cfg) = commands::build_profile_config(&*self.platform, active_id).await else {
261+
return;
262+
};
263+
// Same normalization as the start path: no proxy-mode support → tun.
264+
let next_mode = if self.platform.supports_proxy_modes() {
265+
state.settings.proxy_mode
266+
} else {
267+
ProxyMode::Tun
268+
};
269+
match mutation_effect(&running_cfg, running_mode, &next_cfg, next_mode) {
270+
MutationEffect::LiveModeSwitch(mode) => {
271+
let socks_port = state
272+
.settings
273+
.local_socks_port
274+
.unwrap_or(DEFAULT_LOCAL_SOCKS_PORT);
275+
self.platform
276+
.set_os_proxy(mode, next_cfg.engine, socks_port)
277+
.await;
278+
if let Some(running) = self.running_config.lock().unwrap().as_mut() {
279+
running.1 = mode;
280+
}
281+
}
282+
MutationEffect::SetPending(pending) => {
283+
if self.pending_restart.swap(pending, Ordering::SeqCst) != pending {
284+
self.emit_status().await;
285+
}
286+
}
287+
}
288+
}
289+
205290
/// Build the full status frame (runtime facts + active id + running-core label).
206291
/// `None` when the platform's state probe fails. Both shells use it for the
207292
/// initial frame a client gets on connect.
@@ -234,6 +319,7 @@ impl Service {
234319
service,
235320
active_id,
236321
core,
322+
pending_restart: self.pending_restart.load(Ordering::SeqCst),
237323
})
238324
}
239325

@@ -401,6 +487,7 @@ impl Service {
401487
let _g = this.serialize.lock().await;
402488
let _ = this.platform.stop_data_path(StopDataPath::default()).await;
403489
drop(_g);
490+
this.note_data_path_stopped();
404491
this.set_connectivity(Connectivity::Unknown);
405492
this.emit_status().await;
406493
continue;
@@ -528,6 +615,12 @@ mod tests {
528615
fn paths(&self) -> &BackendPaths {
529616
&self.paths
530617
}
618+
fn supports_proxy_modes(&self) -> bool {
619+
true
620+
}
621+
async fn set_os_proxy(&self, mode: ProxyMode, _engine: Engine, _socks_port: u16) {
622+
self.log(&format!("os_proxy:{mode:?}"));
623+
}
531624
async fn start_data_path(&self, opts: StartDataPath) -> anyhow::Result<()> {
532625
self.log(&format!("start:{:?}", opts.engine));
533626
self.running.store(true, Ordering::SeqCst);
@@ -689,6 +782,133 @@ mod tests {
689782
assert_eq!(ids, vec!["a", "b"]);
690783
}
691784

785+
/// Mutate the persisted settings through the service (the UI's write path).
786+
async fn set_settings(
787+
svc: &Service,
788+
edit: impl FnOnce(&mut kasumi_core::state::AdvancedSettings),
789+
) {
790+
let Response::State(state) = svc.dispatch(Command::ReadState).await.unwrap() else {
791+
panic!()
792+
};
793+
let mut settings = state.settings.clone();
794+
edit(&mut settings);
795+
svc.dispatch(Command::Mutate {
796+
intent: Box::new(kasumi_core::mutate::MutationIntent::SetSettings {
797+
settings: Box::new(settings),
798+
}),
799+
})
800+
.await
801+
.unwrap();
802+
}
803+
804+
async fn pending_restart(svc: &Service) -> bool {
805+
svc.current_status().await.unwrap().pending_restart
806+
}
807+
808+
#[tokio::test]
809+
async fn config_mutation_flags_pending_restart_until_restart() {
810+
let (platform, _d) = RecordingPlatform::new();
811+
seed_active(&platform).await;
812+
let svc = Service::new(platform.clone() as Arc<dyn Platform>).await;
813+
814+
svc.dispatch(Command::Start { profile_id: None })
815+
.await
816+
.unwrap();
817+
assert!(!pending_restart(&svc).await);
818+
819+
// A setting that lands in the built core config → the running path is stale.
820+
let mut rx = svc.subscribe();
821+
set_settings(&svc, |s| s.fragment = true).await;
822+
assert!(pending_restart(&svc).await);
823+
// The flag flip pushed a status frame immediately.
824+
let PushFrame::Status { value } = rx.recv().await.unwrap() else {
825+
panic!("expected status")
826+
};
827+
assert!(value.pending_restart);
828+
829+
// Reverting the edit settles the running path back to non-stale.
830+
set_settings(&svc, |s| s.fragment = false).await;
831+
assert!(!pending_restart(&svc).await);
832+
833+
// Restart applies whatever is saved and clears the flag.
834+
set_settings(&svc, |s| s.fragment = true).await;
835+
assert!(pending_restart(&svc).await);
836+
svc.dispatch(Command::Restart { profile_id: None })
837+
.await
838+
.unwrap();
839+
assert!(!pending_restart(&svc).await);
840+
}
841+
842+
#[tokio::test]
843+
async fn ui_only_mutation_keeps_pending_restart_clear() {
844+
let (platform, _d) = RecordingPlatform::new();
845+
seed_active(&platform).await;
846+
let svc = Service::new(platform.clone() as Arc<dyn Platform>).await;
847+
svc.dispatch(Command::Start { profile_id: None })
848+
.await
849+
.unwrap();
850+
851+
// Neither setting reaches the built config: no restart needed.
852+
set_settings(&svc, |s| {
853+
s.delay_test_url = Some("https://probe.example/gen".into())
854+
})
855+
.await;
856+
set_settings(&svc, |s| s.log_rotate_max_kb = 1024).await;
857+
assert!(!pending_restart(&svc).await);
858+
}
859+
860+
#[tokio::test]
861+
async fn mutation_while_stopped_keeps_pending_restart_clear() {
862+
let (platform, _d) = RecordingPlatform::new();
863+
seed_active(&platform).await;
864+
let svc = Service::new(platform.clone() as Arc<dyn Platform>).await;
865+
866+
// Nothing runs, so nothing can be stale — even for a config-level edit.
867+
set_settings(&svc, |s| s.fragment = true).await;
868+
assert!(!pending_restart(&svc).await);
869+
870+
// A start from the mutated state runs it as saved: still nothing pending.
871+
svc.dispatch(Command::Start { profile_id: None })
872+
.await
873+
.unwrap();
874+
assert!(!pending_restart(&svc).await);
875+
876+
// Stop drops the baseline and the flag stays down for later edits.
877+
svc.dispatch(Command::Stop).await.unwrap();
878+
set_settings(&svc, |s| s.fragment = false).await;
879+
assert!(!pending_restart(&svc).await);
880+
}
881+
882+
#[tokio::test]
883+
async fn non_tun_mode_switch_applies_live_without_pending_restart() {
884+
let (platform, _d) = RecordingPlatform::new();
885+
seed_active(&platform).await;
886+
let svc = Service::new(platform.clone() as Arc<dyn Platform>).await;
887+
888+
set_settings(&svc, |s| s.proxy_mode = ProxyMode::ProxyOnly).await;
889+
svc.dispatch(Command::Start { profile_id: None })
890+
.await
891+
.unwrap();
892+
platform.calls.lock().unwrap().clear();
893+
894+
// proxy-only → system: identical build, no tun involved — re-point the OS
895+
// proxy live instead of demanding a restart.
896+
set_settings(&svc, |s| s.proxy_mode = ProxyMode::System).await;
897+
assert!(!pending_restart(&svc).await);
898+
assert!(
899+
platform
900+
.calls
901+
.lock()
902+
.unwrap()
903+
.iter()
904+
.any(|c| c == "os_proxy:System")
905+
);
906+
907+
// system → tun crosses the tun boundary: a restart is required.
908+
set_settings(&svc, |s| s.proxy_mode = ProxyMode::Tun).await;
909+
assert!(pending_restart(&svc).await);
910+
}
911+
692912
#[tokio::test]
693913
async fn stateless_command_still_works_through_service() {
694914
let (platform, _d) = RecordingPlatform::new();

0 commit comments

Comments
 (0)