diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index d5f26ce..f54dd8f 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -1227,7 +1227,12 @@ pub(crate) fn current_core_status( .and_then(|path| find_core_process_ids(path).first().copied()), None => None, }; - let running = process_id.is_some() && management_port_open.unwrap_or(true); + // A tracked process remains running even if a single management-port probe + // times out. The port probe is still required before discovering an + // untracked process, but using it to override a known live PID makes the UI + // oscillate between running and stopped while continuing to show that PID. + let running = process_id.is_some(); + let ready = running && management_port_open.unwrap_or(true); let current_version = read_core_metadata(&install_dir).map(|metadata| metadata.version); let message = if starting { @@ -1243,6 +1248,7 @@ pub(crate) fn current_core_status( Ok(CoreStatus { installed, running, + ready, starting, managed: managed_pid.is_some(), process_id, @@ -1260,7 +1266,15 @@ pub(crate) fn is_management_port_open(port: u16) -> bool { let Ok(address) = core_management_address(&listen_host, port) else { return false; }; - TcpStream::connect_timeout(&address, Duration::from_millis(150)).is_ok() + for attempt in 0..3 { + if TcpStream::connect_timeout(&address, Duration::from_millis(150)).is_ok() { + return true; + } + if attempt < 2 { + thread::sleep(Duration::from_millis(50)); + } + } + false } fn core_management_address(listen_host: &str, port: u16) -> Result { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5de041a..3de79c5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -436,6 +436,7 @@ struct CorePlatform { struct CoreStatus { installed: bool, running: bool, + ready: bool, starting: bool, managed: bool, process_id: Option, diff --git a/src-tauri/src/tests/core_runtime.rs b/src-tauri/src/tests/core_runtime.rs index d03c43a..0c24da5 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -195,9 +195,10 @@ fn updating_stops_an_adopted_core_without_a_port_before_replacing_its_config() { assert!(ready); assert!( - !status.running, - "the orphan has no listening management port" + status.running, + "the tracked orphan remains a running process without a listening management port" ); + assert!(!status.ready, "the orphan is not management-ready"); assert!( locked_result.is_err(), "the orphan must hold a real Windows file lock" @@ -366,6 +367,44 @@ fn core_process_state_tracks_and_releases_adopted_processes() { assert_eq!(state.managed_pid(), None); } +#[test] +fn tracked_core_stays_running_when_a_management_port_probe_misses() { + let state = CoreProcessState::new(false); + let binary_path = env::current_exe().unwrap(); + let process_id = std::process::id(); + state + .adopt_process_ids(&binary_path, vec![process_id]) + .unwrap(); + + // Port zero cannot be the configured management endpoint. This models a + // transient failed health probe while the tracked process is still alive. + let status = current_core_status(Some(&state), Some(0)).unwrap(); + state.clear_adopted_processes().unwrap(); + + assert!(status.running); + assert!(!status.ready); + assert_eq!(status.process_id, Some(process_id)); +} + +#[test] +fn tracked_core_is_ready_when_its_management_port_accepts_connections() { + let state = CoreProcessState::new(false); + let binary_path = env::current_exe().unwrap(); + let process_id = std::process::id(); + state + .adopt_process_ids(&binary_path, vec![process_id]) + .unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + let status = current_core_status(Some(&state), Some(port)).unwrap(); + state.clear_adopted_processes().unwrap(); + + assert!(status.running); + assert!(status.ready); + assert_eq!(status.process_id, Some(process_id)); +} + #[test] fn successful_core_install_remains_successful_when_restart_succeeds() { let result = combine_install_and_restart_results(Ok("installed"), Ok(())); diff --git a/src-tauri/src/tests/platform.rs b/src-tauri/src/tests/platform.rs index f3ed130..c13da07 100644 --- a/src-tauri/src/tests/platform.rs +++ b/src-tauri/src/tests/platform.rs @@ -8,6 +8,7 @@ fn windows_tray_presentation_tracks_core_state_and_busy_actions() { let mut status = CoreStatus { installed: false, running: false, + ready: false, starting: false, managed: false, process_id: None, diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 686d87f..21ea7ca 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -1790,7 +1790,7 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { let mut next_inbox_cleanup_at = tokio::time::Instant::now() + Duration::from_secs(60 * 60); let mut next_inbox_recovery_at = tokio::time::Instant::now(); let mut next_core_check_at = tokio::time::Instant::now(); - let mut core_running = false; + let mut core_ready = false; loop { if token.is_cancelled() { return; @@ -1852,17 +1852,17 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { } if tokio::time::Instant::now() >= next_core_check_at { let process_state = app.state::(); - core_running = current_core_status(Some(process_state.inner()), Some(config.port)) - .map(|status| status.running) + core_ready = current_core_status(Some(process_state.inner()), Some(config.port)) + .map(|status| status.ready) .unwrap_or(false); next_core_check_at = tokio::time::Instant::now() + Duration::from_secs(2); } - if !core_running { + if !core_ready { subscription = None; subscription_config = None; redis_queue = RedisUsageQueueSource::default(); subscribe_retry_at = tokio::time::Instant::now(); - set_collector_status(&app, "waiting-core", "等待内核启动", None); + set_collector_status(&app, "waiting-core", "等待内核就绪", None); retry_seconds = 1; wait_or_cancel(&token, 1).await; continue; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9014096..615755e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -17,6 +17,8 @@ "height": 800, "minWidth": 640, "minHeight": 600, + "additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows", + "backgroundThrottling": "disabled", "visible": false } ], diff --git a/src/App.tsx b/src/App.tsx index 16d32a3..56d4ddc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -135,7 +135,7 @@ function AppContent() { const languageMenuRef = useRef(null); const languageButtonRef = useRef(null); const { status } = useCoreRuntime(); - const coreRunning = Boolean(status?.running); + const coreReady = Boolean(status?.ready); const activePage = pages.find((page) => page.id === active) ?? pages[0]; const ActivePage = activePage.component; const selectedLanguage = languageOptions.find((option) => option.value === locale) @@ -149,10 +149,10 @@ function AppContent() { : '', ].filter(Boolean).join(' · '); useEffect(() => { - if (!canOpenAppPage(active, coreRunning)) { + if (!canOpenAppPage(active, coreReady)) { setActive('home'); } - }, [active, coreRunning]); + }, [active, coreReady]); useEffect(() => { if (!languageMenuOpen) return undefined; @@ -230,7 +230,7 @@ function AppContent() { }, [windowsClosePrompt]); const select = (pageId: PageId) => { - if (!canOpenAppPage(pageId, coreRunning)) { + if (!canOpenAppPage(pageId, coreReady)) { return; } setActive(pageId); @@ -294,7 +294,7 @@ function AppContent() {