From 8ea1cd6e19cd28b3bc3f630921e964773216cf36 Mon Sep 17 00:00:00 2001 From: jesieleo <90036937+jesieleo@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:09:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(electron):=20=E9=81=BF=E5=85=8D=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E7=8A=B6=E6=80=81=E8=BD=AE=E8=AF=A2=E9=98=BB=E5=A1=9E?= =?UTF-8?q?=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/profiles/launch-service.ts | 38 +++++++++--- .../profile-app-process-detection.test.mjs | 61 ++++++++++++++++++- packages/ui/src/pages/home/App.tsx | 27 +++++--- packages/ui/src/pages/home/shared/polling.ts | 38 ++++++++++++ packages/ui/test/unit/polling.test.ts | 44 +++++++++++++ 5 files changed, 190 insertions(+), 18 deletions(-) create mode 100644 packages/ui/test/unit/polling.test.ts diff --git a/packages/core/src/profiles/launch-service.ts b/packages/core/src/profiles/launch-service.ts index a0afa9b1..3aa5f280 100644 --- a/packages/core/src/profiles/launch-service.ts +++ b/packages/core/src/profiles/launch-service.ts @@ -986,11 +986,34 @@ function isProcessAlive(pid: number | undefined): boolean { } } -function isProfileAppRunning(entry: Pick): boolean { - if (profileAppMainPid(entry)) { +type ProfileAppRunningEntry = Pick; + +type ProfileAppProcessProbe = { + isProcessAlive: (pid: number | undefined) => boolean; + profileAppMainPid: (entry: ProfileAppRunningEntry) => number | undefined; +}; + +const defaultProfileAppProcessProbe: ProfileAppProcessProbe = { + isProcessAlive, + profileAppMainPid +}; + +function profileAppRunningWithProbe(entry: ProfileAppRunningEntry, probe: ProfileAppProcessProbe): boolean { + if (!entry.pidIsLauncher && probe.isProcessAlive(entry.pid)) { return true; } - return !entry.pidIsLauncher && isProcessAlive(entry.pid); + return Boolean(probe.profileAppMainPid(entry)); +} + +function isProfileAppRunning(entry: ProfileAppRunningEntry): boolean { + return profileAppRunningWithProbe(entry, defaultProfileAppProcessProbe); +} + +export function isProfileAppRunningWithProbeForTest( + entry: ProfileAppRunningEntry, + probe: ProfileAppProcessProbe +): boolean { + return profileAppRunningWithProbe(entry, probe); } function profileAppMainPid(entry: Pick): number | undefined { @@ -1136,10 +1159,7 @@ async function waitForProfileAppStart(entry: Pick= stableMs) { diff --git a/packages/core/test/unit/profiles/profile-app-process-detection.test.mjs b/packages/core/test/unit/profiles/profile-app-process-detection.test.mjs index e5f451dc..5641cd47 100644 --- a/packages/core/test/unit/profiles/profile-app-process-detection.test.mjs +++ b/packages/core/test/unit/profiles/profile-app-process-detection.test.mjs @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { isProfileAppMainProcessCommandForTest } from "@ccr/core/profiles/launch-service.ts"; +import { + isProfileAppMainProcessCommandForTest, + isProfileAppRunningWithProbeForTest +} from "@ccr/core/profiles/launch-service.ts"; const userDataDir = "/Users/example/.claude-code-router/profiles/codex/codex/.claude-code-router/codex-app-user-data/codex"; @@ -14,3 +17,59 @@ test("profile app process detection ignores persistent Chromium helper processes assert.equal(isProfileAppMainProcessCommandForTest(crashpad, userDataDir), false); assert.equal(isProfileAppMainProcessCommandForTest("/Applications/ChatGPT.app/Contents/MacOS/ChatGPT", userDataDir), false); }); + +test("profile app liveness prefers a tracked application pid before process discovery", () => { + let discoveryCalls = 0; + const running = isProfileAppRunningWithProbeForTest( + { pid: 1234, pidIsLauncher: false, userDataDir }, + { + isProcessAlive: () => true, + profileAppMainPid: () => { + discoveryCalls += 1; + return undefined; + } + } + ); + + assert.equal(running, true); + assert.equal(discoveryCalls, 0); +}); + +test("profile app liveness falls back to process discovery after the tracked pid exits", () => { + let discoveryCalls = 0; + const running = isProfileAppRunningWithProbeForTest( + { pid: 1234, pidIsLauncher: false, userDataDir }, + { + isProcessAlive: () => false, + profileAppMainPid: () => { + discoveryCalls += 1; + return 5678; + } + } + ); + + assert.equal(running, true); + assert.equal(discoveryCalls, 1); +}); + +test("profile app liveness uses process discovery for launcher pids", () => { + let pidChecks = 0; + let discoveryCalls = 0; + const running = isProfileAppRunningWithProbeForTest( + { pid: 1234, pidIsLauncher: true, userDataDir }, + { + isProcessAlive: () => { + pidChecks += 1; + return true; + }, + profileAppMainPid: () => { + discoveryCalls += 1; + return undefined; + } + } + ); + + assert.equal(running, false); + assert.equal(pidChecks, 0); + assert.equal(discoveryCalls, 1); +}); diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index b8f959dc..967c951d 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -34,7 +34,7 @@ import { useMemo, useReducedMotion, useRef, useState, validateVirtualModelDraft, ViewId, VirtualModelDraft, virtualModelProfileFromDraft, virtualModelProfilesUseMediaTools } from "./shared/index"; -import { startVisiblePolling } from "./shared/polling"; +import { preserveEqualPollingSnapshot, startVisiblePolling } from "./shared/polling"; import { AppDialogStack, LightToast, MainLayout, OnboardingLayout, shouldCheckForUpdateOnOpen } from "./components/index"; @@ -366,10 +366,20 @@ function App() { void window.ccr.getPluginMarketplace().then(setPluginMarketplace).catch(() => setPluginMarketplace([])); const unsubscribeOpenSettings = window.ccr.onOpenSettingsRequest(openSettingsDialog); const unsubscribeOpenUpdate = window.ccr.onOpenUpdateRequest(openUpdateDialog); - const refreshRuntimeStatus = () => { - void window.ccr?.getGatewayStatus().then(setGatewayStatus); - void window.ccr?.getProxyStatus().then(setProxyStatus); - void refreshProfileRuntimeStatus(); + const refreshRuntimeStatus = async () => { + const ccr = window.ccr; + if (!ccr) { + return; + } + await Promise.allSettled([ + ccr.getGatewayStatus().then((next) => { + setGatewayStatus((current) => preserveEqualPollingSnapshot(current, next)); + }), + ccr.getProxyStatus().then((next) => { + setProxyStatus((current) => preserveEqualPollingSnapshot(current, next)); + }), + refreshProfileRuntimeStatus() + ]); }; const stopPolling = startVisiblePolling(refreshRuntimeStatus, 2000); return () => { @@ -2832,13 +2842,14 @@ function App() { async function refreshProfileRuntimeStatus(): Promise { if (!window.ccr?.getProfileRuntimeStatus) { - setProfileRuntimeStatus({ profiles: [] }); + setProfileRuntimeStatus((current) => preserveEqualPollingSnapshot(current, { profiles: [] })); return; } try { - setProfileRuntimeStatus(await window.ccr.getProfileRuntimeStatus()); + const next = await window.ccr.getProfileRuntimeStatus(); + setProfileRuntimeStatus((current) => preserveEqualPollingSnapshot(current, next)); } catch { - setProfileRuntimeStatus({ profiles: [] }); + setProfileRuntimeStatus((current) => preserveEqualPollingSnapshot(current, { profiles: [] })); } } diff --git a/packages/ui/src/pages/home/shared/polling.ts b/packages/ui/src/pages/home/shared/polling.ts index 695de118..f9e28316 100644 --- a/packages/ui/src/pages/home/shared/polling.ts +++ b/packages/ui/src/pages/home/shared/polling.ts @@ -4,6 +4,44 @@ type PollingOptions = { immediate?: boolean; }; +function isPlainSnapshotObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function pollingSnapshotsEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => pollingSnapshotsEqual(value, right[index])) + ); + } + if (!isPlainSnapshotObject(left) || !isPlainSnapshotObject(right)) { + return false; + } + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every((key) => + Object.prototype.hasOwnProperty.call(right, key) && + pollingSnapshotsEqual(left[key], right[key]) + ) + ); +} + +export function preserveEqualPollingSnapshot(current: T, next: T): T { + return pollingSnapshotsEqual(current, next) ? current : next; +} + export function startVisiblePolling(refresh: PollingRefresh, intervalMs: number, options: PollingOptions = {}): () => void { let cancelled = false; let inFlight = false; diff --git a/packages/ui/test/unit/polling.test.ts b/packages/ui/test/unit/polling.test.ts new file mode 100644 index 00000000..fac06f61 --- /dev/null +++ b/packages/ui/test/unit/polling.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { preserveEqualPollingSnapshot } from "@ccr/ui/pages/home/shared/polling.ts"; + +test("polling snapshots preserve the current reference when nested content is unchanged", () => { + const current = { + profiles: [ + { + botGateway: { outboxCount: 0, state: "connected" }, + profileId: "zcode", + state: "running" + } + ] + }; + const next = { + profiles: [ + { + state: "running", + profileId: "zcode", + botGateway: { state: "connected", outboxCount: 0 } + } + ] + }; + + assert.equal(preserveEqualPollingSnapshot(current, next), current); +}); + +test("polling snapshots use the next reference when nested content changes", () => { + const current = { + profiles: [{ profileId: "zcode", state: "running" }] + }; + const next = { + profiles: [{ profileId: "zcode", state: "stopped" }] + }; + + assert.equal(preserveEqualPollingSnapshot(current, next), next); +}); + +test("polling snapshots treat array order as meaningful", () => { + const current = { targetHosts: ["api.openai.com", "chatgpt.com"] }; + const next = { targetHosts: ["chatgpt.com", "api.openai.com"] }; + + assert.equal(preserveEqualPollingSnapshot(current, next), next); +});