@@ -15,7 +15,8 @@ use std::time::Duration;
1515use tokio:: sync:: { Mutex , broadcast} ;
1616
1717use 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
2021use crate :: commands:: { self , Command , CommandError , Response } ;
2122use 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
6979impl 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