From 13ea2c50e3bc6765fceb5cb875934a26766e1c98 Mon Sep 17 00:00:00 2001 From: Danil Didkovskiy <93273044+Danil-Didkovskiy@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:37:31 +0300 Subject: [PATCH 1/3] Add video to README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 4701b17..9b70fad 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Live macOS system resources at a glance, plus a searchable process explorer. Built with [MōBrowser](https://teamdev.com/mobrowser/), React, and TypeScript, with a small native module for the metrics. +https://github.com/user-attachments/assets/5c2ef538-3f27-4711-8f60-ac12e7706483 + ## What it does - **System overview.** CPU, memory, network, disk, uptime, and CPU temperature. From 8a53733333981ab7be6f95020b2ba246812f539b Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Thu, 18 Jun 2026 21:35:02 +0300 Subject: [PATCH 2/3] Fix memory growth for process detail histories --- .../processes/process-explorer-view.tsx | 2 +- .../processes/use-process-histories.ts | 193 +++++++++++++++--- 2 files changed, 166 insertions(+), 29 deletions(-) diff --git a/src/renderer/components/processes/process-explorer-view.tsx b/src/renderer/components/processes/process-explorer-view.tsx index 1305717..a41e7f1 100644 --- a/src/renderer/components/processes/process-explorer-view.tsx +++ b/src/renderer/components/processes/process-explorer-view.tsx @@ -159,7 +159,7 @@ export function ProcessExplorerView({ active }: { active: boolean }) { // True while the detail view is inspecting a past tick; freezes history so // the inspected tick cannot scroll off the graph (see useProcessHistories). const [inspecting, setInspecting] = useState(false); - const readHistory = useProcessHistories(snapshot, inspecting); + const readHistory = useProcessHistories(snapshot, inspecting, detail?.key); const { actions, actionsBusy, actionMessage, runAction } = useProcessActions( detail, pull, diff --git a/src/renderer/components/processes/use-process-histories.ts b/src/renderer/components/processes/use-process-histories.ts index c125919..05904cc 100644 --- a/src/renderer/components/processes/use-process-histories.ts +++ b/src/renderer/components/processes/use-process-histories.ts @@ -9,6 +9,12 @@ import { } from "@/domain/process-list"; import { HISTORY_CAPACITY, pushSample, type HistorySample } from "@/domain/sample-history"; +/** Maximum number of process-detail histories retained for quick revisits. */ +const RETAINED_HISTORY_LIMIT = 12; + +/** How long an inactive retained history may sit in memory before pruning. */ +const INACTIVE_HISTORY_TTL_MS = 10 * 60 * 1000; + /** * A process's retained trails (oldest first): the CPU and memory totals that * drive the graph, plus the per-tick member breakdown behind the detail's @@ -64,27 +70,122 @@ function appendTick(prior: Trails | undefined, tick: TrailSample): Trails { }; } +/** Applies the bounded live-history policy to the key set sampled each tick. */ +function pruneTrackedKeys( + previous: ReadonlySet, + activeKey: string | undefined, + accessedAt: ReadonlyMap, + now: number, +): Set { + const keep = new Set(); + if (activeKey !== undefined) { + keep.add(activeKey); + } + + const inactiveKeys = [...previous] + .filter((key) => key !== activeKey) + .filter((key) => now - (accessedAt.get(key) ?? 0) <= INACTIVE_HISTORY_TTL_MS) + .sort((left, right) => (accessedAt.get(right) ?? 0) - (accessedAt.get(left) ?? 0)); + + for (const key of inactiveKeys) { + if (keep.size >= RETAINED_HISTORY_LIMIT) { + break; + } + keep.add(key); + } + + return keep; +} + +/** Removes trails for histories that are no longer part of the live cache. */ +function pruneTrailCache( + previous: Map, + retainedKeys: ReadonlySet, +): Map { + if (previous.size === retainedKeys.size && [...previous.keys()].every((key) => retainedKeys.has(key))) { + return previous; + } + + const next = new Map(); + for (const [key, trails] of previous) { + if (retainedKeys.has(key)) { + next.set(key, trails); + } + } + return next; +} + +/** Drops side-channel cache records for histories that were evicted. */ +function pruneCacheRecords( + accessRecords: Map, + sampledRevisions: Map, + bufferedTicks: Map, + retainedKeys: ReadonlySet, +): void { + for (const key of accessRecords.keys()) { + if (!retainedKeys.has(key)) { + accessRecords.delete(key); + } + } + for (const key of sampledRevisions.keys()) { + if (!retainedKeys.has(key)) { + sampledRevisions.delete(key); + } + } + for (const key of bufferedTicks.keys()) { + if (!retainedKeys.has(key)) { + bufferedTicks.delete(key); + } + } +} + /** - * Keeps short CPU/memory trails for process details the user has opened. + * Keeps short CPU/memory/member trails for a bounded set of recent details. * * While `frozen` (the detail view is inspecting a past tick), incoming ticks * are buffered aside instead of appended, so the inspected tick cannot scroll * off the graph and no data is lost. When inspection ends the buffered ticks * are spliced back on in arrival order, capacity-trimmed - a seamless catch-up - * rather than a gap. Main keeps collecting throughout; only this renderer-side - * ring is held. + * rather than a gap. Recently viewed details stay live in a bounded recency + * cache, so quick back-and-forth navigation shows current graphs without + * letting member breakdowns accumulate for the renderer lifetime. */ export function useProcessHistories( snapshot: ProcessSnapshot, frozen: boolean, + activeKey: string | undefined, ): (key: string, sort: SortMode) => ProcessHistory { const [trailsByKey, setTrailsByKey] = useState>(() => new Map()); const trackedKeys = useRef(new Set()); - const lastRevision = useRef(null); + const accessedAt = useRef(new Map()); + const sampledRevisionByKey = useRef(new Map()); const frozenRef = useRef(frozen); // Ticks that arrived while frozen, per key, oldest first - replayed on thaw. const buffer = useRef(new Map()); + useEffect(() => { + const now = Date.now(); + if (activeKey !== undefined) { + accessedAt.current.set(activeKey, now); + } + + const candidates = new Set(trackedKeys.current); + if (activeKey !== undefined) { + candidates.add(activeKey); + } + trackedKeys.current = pruneTrackedKeys(candidates, activeKey, accessedAt.current, now); + pruneCacheRecords( + accessedAt.current, + sampledRevisionByKey.current, + buffer.current, + trackedKeys.current, + ); + + setTrailsByKey((previous) => { + return pruneTrailCache(previous, trackedKeys.current); + }); + }, [activeKey]); + useEffect(() => { const wasFrozen = frozenRef.current; frozenRef.current = frozen; @@ -112,48 +213,84 @@ export function useProcessHistories( }, [frozen]); useEffect(() => { - if (lastRevision.current === snapshot.revision) { - return; + const now = Date.now(); + if (activeKey !== undefined) { + accessedAt.current.set(activeKey, now); } - lastRevision.current = snapshot.revision; + trackedKeys.current = pruneTrackedKeys( + trackedKeys.current, + activeKey, + accessedAt.current, + now, + ); + + pruneCacheRecords( + accessedAt.current, + sampledRevisionByKey.current, + buffer.current, + trackedKeys.current, + ); + if (trackedKeys.current.size === 0) { + setTrailsByKey((previous) => { + return pruneTrailCache(previous, trackedKeys.current); + }); return; } - const ticks = sampleTrails(snapshot, trackedKeys.current); - if (frozenRef.current) { - // Leave trails untouched so the inspected tick stays stable; stash this - // tick instead, capped per key so a long freeze still bounds memory. - for (const [key, tick] of ticks) { - const queued = buffer.current.get(key) ?? []; - queued.push(tick); - buffer.current.set(key, queued.slice(-HISTORY_CAPACITY)); - } + const unsampledKeys = new Set( + [...trackedKeys.current].filter((key) => sampledRevisionByKey.current.get(key) !== snapshot.revision), + ); + if (unsampledKeys.size === 0) { + setTrailsByKey((previous) => { + return pruneTrailCache(previous, trackedKeys.current); + }); return; } - // Live: drop tracked keys whose target has vanished, then append. - for (const key of trackedKeys.current) { - if (!ticks.has(key)) { + const ticks = sampleTrails(snapshot, unsampledKeys); + const appendTicks = new Map(); + + for (const key of unsampledKeys) { + const tick = ticks.get(key); + if (tick === undefined) { trackedKeys.current.delete(key); + accessedAt.current.delete(key); + sampledRevisionByKey.current.delete(key); + buffer.current.delete(key); + continue; + } + + sampledRevisionByKey.current.set(key, snapshot.revision); + if (frozenRef.current && key === activeKey) { + const queued = buffer.current.get(key) ?? []; + queued.push(tick); + buffer.current.set(key, queued.slice(-HISTORY_CAPACITY)); + continue; } + + appendTicks.set(key, tick); } + + pruneCacheRecords( + accessedAt.current, + sampledRevisionByKey.current, + buffer.current, + trackedKeys.current, + ); + setTrailsByKey((previous) => { - const next = new Map(); - for (const key of trackedKeys.current) { - const tick = ticks.get(key); - if (tick === undefined) { - continue; - } - next.set(key, appendTick(previous.get(key), tick)); + const retained = pruneTrailCache(previous, trackedKeys.current); + const next = new Map(retained); + for (const [key, tick] of appendTicks) { + next.set(key, appendTick(retained.get(key), tick)); } return next; }); - }, [snapshot]); + }, [activeKey, snapshot]); return useCallback( (key: string, sort: SortMode): ProcessHistory => { - trackedKeys.current.add(key); const trails = trailsByKey.get(key); return { history: (sort === "cpu" ? trails?.cpu : trails?.memory) ?? [], From c67b284814fa35dbd614929830acc7e4aa45432b Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Thu, 18 Jun 2026 22:39:29 +0300 Subject: [PATCH 3/3] Collapse the duplicated prune logic --- .../processes/use-process-histories.ts | 53 ++++--------------- 1 file changed, 10 insertions(+), 43 deletions(-) diff --git a/src/renderer/components/processes/use-process-histories.ts b/src/renderer/components/processes/use-process-histories.ts index 05904cc..b0fe215 100644 --- a/src/renderer/components/processes/use-process-histories.ts +++ b/src/renderer/components/processes/use-process-histories.ts @@ -163,29 +163,23 @@ export function useProcessHistories( // Ticks that arrived while frozen, per key, oldest first - replayed on thaw. const buffer = useRef(new Map()); - useEffect(() => { - const now = Date.now(); + const reconcileTrackedKeys = useCallback((now: number) => { if (activeKey !== undefined) { accessedAt.current.set(activeKey, now); } - - const candidates = new Set(trackedKeys.current); - if (activeKey !== undefined) { - candidates.add(activeKey); - } - trackedKeys.current = pruneTrackedKeys(candidates, activeKey, accessedAt.current, now); + trackedKeys.current = pruneTrackedKeys(trackedKeys.current, activeKey, accessedAt.current, now); pruneCacheRecords( accessedAt.current, sampledRevisionByKey.current, buffer.current, trackedKeys.current, ); - - setTrailsByKey((previous) => { - return pruneTrailCache(previous, trackedKeys.current); - }); }, [activeKey]); + const commitPrunedTrails = useCallback(() => { + setTrailsByKey((previous) => pruneTrailCache(previous, trackedKeys.current)); + }, []); + useEffect(() => { const wasFrozen = frozenRef.current; frozenRef.current = frozen; @@ -213,28 +207,10 @@ export function useProcessHistories( }, [frozen]); useEffect(() => { - const now = Date.now(); - if (activeKey !== undefined) { - accessedAt.current.set(activeKey, now); - } - trackedKeys.current = pruneTrackedKeys( - trackedKeys.current, - activeKey, - accessedAt.current, - now, - ); - - pruneCacheRecords( - accessedAt.current, - sampledRevisionByKey.current, - buffer.current, - trackedKeys.current, - ); + reconcileTrackedKeys(Date.now()); if (trackedKeys.current.size === 0) { - setTrailsByKey((previous) => { - return pruneTrailCache(previous, trackedKeys.current); - }); + commitPrunedTrails(); return; } @@ -242,9 +218,7 @@ export function useProcessHistories( [...trackedKeys.current].filter((key) => sampledRevisionByKey.current.get(key) !== snapshot.revision), ); if (unsampledKeys.size === 0) { - setTrailsByKey((previous) => { - return pruneTrailCache(previous, trackedKeys.current); - }); + commitPrunedTrails(); return; } @@ -272,13 +246,6 @@ export function useProcessHistories( appendTicks.set(key, tick); } - pruneCacheRecords( - accessedAt.current, - sampledRevisionByKey.current, - buffer.current, - trackedKeys.current, - ); - setTrailsByKey((previous) => { const retained = pruneTrailCache(previous, trackedKeys.current); const next = new Map(retained); @@ -287,7 +254,7 @@ export function useProcessHistories( } return next; }); - }, [activeKey, snapshot]); + }, [activeKey, snapshot, reconcileTrackedKeys, commitPrunedTrails]); return useCallback( (key: string, sort: SortMode): ProcessHistory => {