From ef868f81a110ff05148fcfda29361bfe1352ef29 Mon Sep 17 00:00:00 2001 From: liangxu Date: Mon, 20 Apr 2026 11:07:58 +0800 Subject: [PATCH] feat(desktop): add hot view retain/release lifecycle and process metrics Replace single-shot acquireHotView with retainHotView/releaseHotView plus a minute-level reaper that closes views idle beyond 5 minutes and never evicts a view while it still has live retainers. Wrap adapter calls in try/finally so a failing publish/query still releases the view. Sample process metrics every minute and expose hot-view policy and process snapshot via the runtime snapshot for diagnostics. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/desktop-client/src/main/bootstrap.ts | 4 + .../src/main/process-metrics.ts | 110 ++++++++++++++++++ .../src/main/runtime-controller.ts | 76 ++++++------ .../src/main/runtime-snapshot.ts | 5 +- apps/desktop-client/src/main/view-pool.ts | 109 +++++++++++++++-- 5 files changed, 261 insertions(+), 43 deletions(-) create mode 100644 apps/desktop-client/src/main/process-metrics.ts diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index fd631be..5cbf27e 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -8,6 +8,7 @@ import type { import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from "@geo/shared-types"; import { bindPublishAccount, openPublishAccountConsole } from "./account-binder"; +import { initProcessMetricsSampler } from "./process-metrics"; import { registerRendererDevtoolsProxyTarget } from "./renderer-devtools-proxy"; import { refreshRuntimeAccounts, @@ -22,6 +23,7 @@ import { initSingleInstance } from "./single-instance"; import { initTransport, listDesktopPublishTasks, retryDesktopPublishTask } from "./transport/api-client"; import { initTray } from "./tray"; import { STANDARD_USER_AGENT } from "./user-agent"; +import { startHotViewReaper } from "./view-pool"; app.commandLine.appendSwitch( "disable-features", @@ -183,6 +185,8 @@ app.whenReady().then(async () => { await initSessionRegistry(); initTransport(); initScheduler(); + initProcessMetricsSampler(); + startHotViewReaper(); mainWindow = await createMainWindow(); initTray(() => { if (!mainWindow) { diff --git a/apps/desktop-client/src/main/process-metrics.ts b/apps/desktop-client/src/main/process-metrics.ts new file mode 100644 index 0000000..67e9468 --- /dev/null +++ b/apps/desktop-client/src/main/process-metrics.ts @@ -0,0 +1,110 @@ +import { app } from "electron/main"; +import type { ProcessMemoryInfo, ProcessMetric } from "electron/main"; + +const PROCESS_METRICS_INTERVAL_MS = 60_000; +const PROCESS_METRICS_MAX_SAMPLES = 12; +const PROCESS_METRICS_RECENT_WINDOW = 5; + +interface SampledProcessMetric { + pid: number; + creationTime: number; + type: ProcessMetric["type"]; + name: string | null; + serviceName: string | null; + workingSetSize: number; + peakWorkingSetSize: number; + privateBytes: number | null; + percentCPUUsage: number; + sandboxed: boolean | null; +} + +interface ProcessMetricsSample { + capturedAt: number; + mainProcess: { + pid: number; + memory: ProcessMemoryInfo | null; + }; + totals: { + processCount: number; + workingSetSize: number; + privateBytes: number; + }; + processes: SampledProcessMetric[]; +} + +const samples: ProcessMetricsSample[] = []; +let samplerHandle: ReturnType | null = null; + +function normalizeProcessMetric(metric: ProcessMetric): SampledProcessMetric { + return { + pid: metric.pid, + creationTime: metric.creationTime, + type: metric.type, + name: metric.name ?? null, + serviceName: metric.serviceName ?? null, + workingSetSize: metric.memory.workingSetSize, + peakWorkingSetSize: metric.memory.peakWorkingSetSize, + privateBytes: metric.memory.privateBytes ?? null, + percentCPUUsage: Number(metric.cpu.percentCPUUsage.toFixed(2)), + sandboxed: metric.sandboxed ?? null, + }; +} + +async function captureMainProcessMemory(): Promise { + try { + return await process.getProcessMemoryInfo(); + } catch (error) { + console.warn("[desktop-metrics] process.getProcessMemoryInfo failed", error); + return null; + } +} + +export async function sampleProcessMetrics(): Promise { + const [mainProcessMemory, processMetrics] = await Promise.all([ + captureMainProcessMemory(), + Promise.resolve(app.getAppMetrics()), + ]); + + const processes = processMetrics.map(normalizeProcessMetric); + const sample: ProcessMetricsSample = { + capturedAt: Date.now(), + mainProcess: { + pid: process.pid, + memory: mainProcessMemory, + }, + totals: { + processCount: processes.length, + workingSetSize: processes.reduce((total, item) => total + item.workingSetSize, 0), + privateBytes: processes.reduce((total, item) => total + (item.privateBytes ?? 0), 0), + }, + processes, + }; + + samples.push(sample); + if (samples.length > PROCESS_METRICS_MAX_SAMPLES) { + samples.splice(0, samples.length - PROCESS_METRICS_MAX_SAMPLES); + } +} + +export function initProcessMetricsSampler(): void { + if (samplerHandle) { + return; + } + + void sampleProcessMetrics(); + samplerHandle = setInterval(() => { + void sampleProcessMetrics(); + }, PROCESS_METRICS_INTERVAL_MS); + samplerHandle.unref?.(); +} + +export function getProcessMetricsSnapshot() { + const latestSample = samples.at(-1) ?? null; + return { + intervalMs: PROCESS_METRICS_INTERVAL_MS, + sampleCount: samples.length, + latestCapturedAt: latestSample?.capturedAt ?? null, + latestSample, + recentSamples: samples.slice(-PROCESS_METRICS_RECENT_WINDOW), + }; +} diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index e31e64f..f57d52b 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -27,7 +27,7 @@ import { normalizeAccountPlatformUid, sameAccountPlatformUid, } from "../shared/account-identity"; -import { acquireHotView } from "./view-pool"; +import { releaseHotView, retainHotView } from "./view-pool"; import { getLeaseManagerSnapshot, noteLeaseExtended, noteLeaseReleased, setActiveLease } from "./lease-manager"; import { initScheduler, @@ -939,25 +939,29 @@ async function executeTaskAdapter( const viewHandle = adapter.executionMode === "session" || adapter.executionMode === "playwright" ? null - : acquireHotView(task.accountId || task.id); + : retainHotView(task.accountId || task.id); - const result = await adapter.publish( - { - taskId: task.id, - accountId: task.accountId || task.id, - session: sessionHandle.session, - view: viewHandle?.view ?? null, - signal, - phase: "initial", - article: await loadPublishArticle(task), - reportProgress(stage: string) { - updateTaskProgress(task.id, `publish adapter progress: ${stage}`); + try { + return await adapter.publish( + { + taskId: task.id, + accountId: task.accountId || task.id, + session: sessionHandle.session, + view: viewHandle?.view ?? null, + signal, + phase: "initial", + article: await loadPublishArticle(task), + reportProgress(stage: string) { + updateTaskProgress(task.id, `publish adapter progress: ${stage}`); + }, }, - }, - payload, - ); - - return result; + payload, + ); + } finally { + if (viewHandle) { + releaseHotView(viewHandle.accountId); + } + } } const adapter = selectMonitorAdapter(task.platform); @@ -968,24 +972,28 @@ async function executeTaskAdapter( const viewHandle = adapter.executionMode === "session" || adapter.executionMode === "playwright" ? null - : acquireHotView(task.accountId || task.id); + : retainHotView(task.accountId || task.id); - const result = await adapter.query( - { - taskId: task.id, - accountId: task.accountId || task.id, - session: sessionHandle.session, - view: viewHandle?.view ?? null, - signal, - phase: "initial", - reportProgress(stage: string) { - updateTaskProgress(task.id, `monitor adapter progress: ${stage}`); + try { + return await adapter.query( + { + taskId: task.id, + accountId: task.accountId || task.id, + session: sessionHandle.session, + view: viewHandle?.view ?? null, + signal, + phase: "initial", + reportProgress(stage: string) { + updateTaskProgress(task.id, `monitor adapter progress: ${stage}`); + }, }, - }, - payload, - ); - - return result; + payload, + ); + } finally { + if (viewHandle) { + releaseHotView(viewHandle.accountId); + } + } } function buildScaffoldResult( diff --git a/apps/desktop-client/src/main/runtime-snapshot.ts b/apps/desktop-client/src/main/runtime-snapshot.ts index 3ef5f2d..93835b1 100644 --- a/apps/desktop-client/src/main/runtime-snapshot.ts +++ b/apps/desktop-client/src/main/runtime-snapshot.ts @@ -1,5 +1,6 @@ import { app } from "electron/main"; +import { getProcessMetricsSnapshot } from "./process-metrics"; import { getCircuitBreakerState } from "./circuit-breaker"; import { collectRecoverySnapshot } from "./crash-recovery"; import { getKeepAlivePlan } from "./keep-alive"; @@ -10,7 +11,7 @@ import { getSchedulerState } from "./scheduler"; import { getSessionHandle, listSessionHandleSnapshots } from "./session-registry"; import { getTransportSnapshot } from "./transport/api-client"; import { describeVaultBackend } from "./vault"; -import { listHotViews } from "./view-pool"; +import { getHotViewPoolPolicy, listHotViews } from "./view-pool"; import { normalizeAccountPlatformUid } from "../shared/account-identity"; function minutesAhead(now: number, minutes: number): number { @@ -151,6 +152,8 @@ function createLiveRuntimeSnapshot(controller: ReturnType(); +const HOT_VIEW_IDLE_TTL_MS = 5 * 60_000; +const HOT_VIEW_REAPER_INTERVAL_MS = 60_000; +let reaperHandle: ReturnType | null = null; + +function closeView(handle: HotViewHandle): void { + if (!handle.view.webContents.isDestroyed()) { + handle.view.webContents.close(); + } +} + +function touchHandle(handle: HotViewHandle): HotViewHandle { + handle.activatedAt = Date.now(); + return handle; +} + +function demoteViewInternal(accountId: string): boolean { + const handle = hotViews.get(accountId); + if (!handle) { + return false; + } + + closeView(handle); + hotViews.delete(accountId); + return true; +} + +export function startHotViewReaper(): void { + if (reaperHandle) { + return; + } + + reaperHandle = setInterval(() => { + reapIdleHotViews(); + }, HOT_VIEW_REAPER_INTERVAL_MS); + reaperHandle.unref?.(); +} export function acquireHotView(accountId: string): HotViewHandle { + startHotViewReaper(); + const existing = hotViews.get(accountId); if (existing) { - existing.activatedAt = Date.now(); - return existing; + return touchHandle(existing); } const handle: HotViewHandle = { @@ -26,26 +65,80 @@ export function acquireHotView(accountId: string): HotViewHandle { }, }), activatedAt: Date.now(), + lastReleasedAt: Date.now(), + retainCount: 0, }; hotViews.set(accountId, handle); return handle; } -export function demoteView(accountId: string): void { +export function retainHotView(accountId: string): HotViewHandle { + const handle = touchHandle(acquireHotView(accountId)); + handle.retainCount += 1; + handle.lastReleasedAt = null; + return handle; +} + +export function releaseHotView(accountId: string): void { const handle = hotViews.get(accountId); if (!handle) { return; } - if (!handle.view.webContents.isDestroyed()) { - handle.view.webContents.close(); + if (handle.retainCount > 0) { + handle.retainCount -= 1; + } + + if (handle.retainCount === 0) { + handle.lastReleasedAt = Date.now(); } - hotViews.delete(accountId); } -export function listHotViews(): Array> { - return [...hotViews.values()].map(({ accountId, activatedAt }) => ({ +export function demoteView(accountId: string): boolean { + const handle = hotViews.get(accountId); + if (!handle || handle.retainCount > 0) { + return false; + } + + return demoteViewInternal(accountId); +} + +export function reapIdleHotViews( + now = Date.now(), + idleTTLms = HOT_VIEW_IDLE_TTL_MS, +): string[] { + const reclaimed: string[] = []; + + for (const [accountId, handle] of hotViews.entries()) { + if (handle.retainCount > 0) { + continue; + } + + const idleSince = handle.lastReleasedAt ?? handle.activatedAt; + if (now - idleSince < idleTTLms) { + continue; + } + + if (demoteViewInternal(accountId)) { + reclaimed.push(accountId); + } + } + + return reclaimed; +} + +export function listHotViews(): Array> { + return [...hotViews.values()].map(({ accountId, activatedAt, lastReleasedAt, retainCount }) => ({ accountId, activatedAt, + lastReleasedAt, + retainCount, })); } + +export function getHotViewPoolPolicy() { + return { + idleTTLms: HOT_VIEW_IDLE_TTL_MS, + reapIntervalMs: HOT_VIEW_REAPER_INTERVAL_MS, + }; +}