Merge pull request #1591 from jesieleo/fix/electron-profile-status-jank

fix(electron): 修复从CCR启动ZCode导致CCR Electron界面卡顿的问题
This commit is contained in:
musi 2026-07-28 08:33:03 +08:00 committed by GitHub
commit d3cd06b6fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 190 additions and 18 deletions

View file

@ -986,11 +986,34 @@ function isProcessAlive(pid: number | undefined): boolean {
}
}
function isProfileAppRunning(entry: Pick<RunningProfileApp, "pid" | "pidIsLauncher" | "userDataDir">): boolean {
if (profileAppMainPid(entry)) {
type ProfileAppRunningEntry = Pick<RunningProfileApp, "pid" | "pidIsLauncher" | "userDataDir">;
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<RunningProfileApp, "userDataDir">): number | undefined {
@ -1136,10 +1159,7 @@ async function waitForProfileAppStart(entry: Pick<RunningProfileApp, "pid" | "pi
if (entry.spawnError) {
return false;
}
if (profileAppMainPid(entry)) {
return true;
}
if (!entry.pidIsLauncher && isProcessAlive(entry.pid)) {
if (isProfileAppRunning(entry)) {
return true;
}
if (process.platform !== "win32" && !entry.pidIsLauncher && !isProcessAlive(entry.pid)) {
@ -1147,7 +1167,7 @@ async function waitForProfileAppStart(entry: Pick<RunningProfileApp, "pid" | "pi
}
await sleep(100);
}
return !entry.spawnError && (Boolean(profileAppMainPid(entry)) || (!entry.pidIsLauncher && isProcessAlive(entry.pid)));
return !entry.spawnError && isProfileAppRunning(entry);
}
async function waitForStableProfileAppStart(
@ -1161,7 +1181,7 @@ async function waitForStableProfileAppStart(
if (entry.spawnError) {
return false;
}
const running = Boolean(profileAppMainPid(entry)) || (!entry.pidIsLauncher && isProcessAlive(entry.pid));
const running = isProfileAppRunning(entry);
if (running) {
runningSince ??= Date.now();
if (Date.now() - runningSince >= stableMs) {

View file

@ -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);
});

View file

@ -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<void> {
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: [] }));
}
}

View file

@ -4,6 +4,44 @@ type PollingOptions = {
immediate?: boolean;
};
function isPlainSnapshotObject(value: unknown): value is Record<string, unknown> {
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<T>(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;

View file

@ -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);
});