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) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 11:07:58 +08:00
parent a46ed4b9f6
commit ef868f81a1
5 changed files with 261 additions and 43 deletions
+101 -8
View File
@@ -5,15 +5,54 @@ export interface HotViewHandle {
accountId: string;
view: ElectronWebContentsView;
activatedAt: number;
lastReleasedAt: number | null;
retainCount: number;
}
const hotViews = new Map<string, HotViewHandle>();
const HOT_VIEW_IDLE_TTL_MS = 5 * 60_000;
const HOT_VIEW_REAPER_INTERVAL_MS = 60_000;
let reaperHandle: ReturnType<typeof setInterval> | 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<Pick<HotViewHandle, "accountId" | "activatedAt">> {
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<Pick<HotViewHandle, "accountId" | "activatedAt" | "lastReleasedAt" | "retainCount">> {
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,
};
}