fix(core): stabilize runtime status and separate service readiness
This commit is contained in:
1 parent
e9805b395d
commit
9ff9a6c317
11 files changed
+82
-20
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -436,6 +436,7 @@ struct CorePlatform {
|
||||
struct CoreStatus {
|
||||
installed: bool,
|
||||
running: bool,
|
||||
ready: bool,
|
||||
starting: bool,
|
||||
managed: bool,
|
||||
process_id: Option<u32>,
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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')}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user