mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-30 03:13:28 +00:00
Merge pull request #1591 from jesieleo/fix/electron-profile-status-jank
fix(electron): 修复从CCR启动ZCode导致CCR Electron界面卡顿的问题
This commit is contained in:
commit
d3cd06b6fe
5 changed files with 190 additions and 18 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [] }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
44
packages/ui/test/unit/polling.test.ts
Normal file
44
packages/ui/test/unit/polling.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue