fix(core): stabilize runtime status and separate service readiness

This commit is contained in:
lzt404 committed 2026-09-19 00:36:18 +08:00
1 parent e9805b395d
commit 9ff9a6c317
11 files changed
+82 -20

No files matched your search

+16 -2
View File
@@ -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<SocketAddr, String> {
+1
View File
@@ -436,6 +436,7 @@ struct CorePlatform {
struct CoreStatus {
installed: bool,
running: bool,
ready: bool,
starting: bool,
managed: bool,
process_id: Option<u32>,
+41 -2
View File
@@ -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(()));
+1
View File
@@ -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,
+5 -5
View File
@@ -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::<CoreProcessState>();
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;
+2
View File
@@ -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
}
],
+6 -6
View File
@@ -135,7 +135,7 @@ function AppContent() {
const languageMenuRef = useRef<HTMLDivElement>(null);
const languageButtonRef = useRef<HTMLButtonElement>(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() {
<nav className="nav-section" aria-label={t('app.navigation')}>
{pages.filter((page) => page.id !== 'easy').map((page) => {
const Icon = page.icon;
const locked = !canOpenAppPage(page.id, coreRunning);
const locked = !canOpenAppPage(page.id, coreReady);
const updateIndicator = page.id === 'versions'
? appUpdateIndicatorState(hasUpdate, coreHasUpdate, appUpdateProcessing)
: null;
@@ -437,7 +437,7 @@ function AppContent() {
<div className="workspace">
<main className="content">
{isAlwaysAvailablePage(activePage.id) || coreRunning ? (
{isAlwaysAvailablePage(activePage.id) || coreReady ? (
activePage.id === 'easy' ? (
<EasyModePage
onExit={() => select('home')}
+1
View File
@@ -5,6 +5,7 @@ import { listen } from '@tauri-apps/api/event';
export type CoreStatus = {
installed: boolean;
running: boolean;
ready: boolean;
starting: boolean;
managed: boolean;
processId: number | null;
+2 -2
View File
@@ -4,6 +4,6 @@ export function isAlwaysAvailablePage(pageId: string) {
return alwaysAvailablePages.has(pageId);
}
export function canOpenAppPage(pageId: string, coreRunning: boolean) {
return coreRunning || isAlwaysAvailablePage(pageId);
export function canOpenAppPage(pageId: string, coreReady: boolean) {
return coreReady || isAlwaysAvailablePage(pageId);
}
+3 -2
View File
@@ -180,6 +180,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
const currentVersion = coreStatus?.currentVersion ?? '';
const coreInstalled = Boolean(coreStatus?.installed);
const coreRunning = Boolean(coreStatus?.running);
const coreReady = Boolean(coreStatus?.ready);
const coreProcessBusy = processBusy || Boolean(coreStatus?.starting);
const statusTone = statusError ? 'error' : coreRunning ? 'success' : 'neutral';
@@ -326,8 +327,8 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
)}
</div>
</div>
<span className={`state-pill ${coreRunning ? 'success' : 'neutral'}`}>
{coreRunning ? t('kernel.access.connectable') : t('kernel.access.waiting')}
<span className={`state-pill ${coreReady ? 'success' : 'neutral'}`}>
{coreReady ? t('kernel.access.connectable') : t('kernel.access.waiting')}
</span>
</div>
+4 -1
View File
@@ -465,7 +465,10 @@ export function UsageRecordsPage() {
let disposed = false;
let unlisten: (() => void) | null = null;
const refresh = () => {
if (!disposed && !document.hidden) void loadData(true, true);
// WebView2 may classify an unfocused or occluded window on another monitor
// as hidden. Keep usage refreshes independent of Page Visibility so both
// record events and the fallback poll continue to update the current view.
if (!disposed) void loadData(true, true);
};
listen('usage-records-updated', refresh)
.then((stop) => {