From 78eee05e9004c9025d6acd6b7d76a0a5b99d93e1 Mon Sep 17 00:00:00 2001 From: Daniel Vernon Date: Tue, 4 Aug 2026 17:43:44 +0100 Subject: [PATCH 1/2] perf(telemetry): reduce idle resource monitoring CPU --- .../NativeTelemetryClient.test.ts | 5 +- .../NativeTelemetryClient.ts | 10 +-- .../components/settings/settingsLayout.tsx | 76 ++++++++++++++++-- .../connection/useDesktopLocalBootstraps.ts | 78 +++++++++++++++---- docs/internals/resource-telemetry.md | 11 ++- native/resource-monitor/src/main.rs | 68 ++++++++++++---- tasks/todo.md | 22 ++++++ 7 files changed, 223 insertions(+), 47 deletions(-) create mode 100644 tasks/todo.md diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts index 61a67d11606..d38e0019633 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts @@ -44,7 +44,7 @@ describe("resolveNativeSampleIntervalMs", () => { expect(resolveNativeSampleIntervalMs({ ...basePower, onBattery: "true" }, 1)).toBe(5_000); }); - it("keeps unknown background telemetry cheap but serves live diagnostics at 1Hz", () => { + it("uses a 5-second background cadence but serves live diagnostics at 1Hz", () => { const unknown: HostPowerSnapshot = { ...basePower, source: "unknown", @@ -58,7 +58,8 @@ describe("resolveNativeSampleIntervalMs", () => { 0, ), ).toBe(5_000); - expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(1_000); + expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(5_000); + expect(resolveNativeSampleIntervalMs(basePower, 1)).toBe(1_000); }); }); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index e8d81cc4c1c..07d1f990bc5 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -39,7 +39,7 @@ import { ServerConfig } from "../config.ts"; import { subscribeBeforeSnapshotWithoutMutex } from "../utils/subscribeBeforeSnapshot.ts"; const SAMPLE_INTERVAL_MS = 1_000; -const UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS = 5_000; +const BACKGROUND_SAMPLE_INTERVAL_MS = 5_000; const BATTERY_SAMPLE_INTERVAL_MS = 5_000; const CONSTRAINED_SAMPLE_INTERVAL_MS = 15_000; const HANDSHAKE_TIMEOUT = Duration.seconds(5); @@ -257,7 +257,7 @@ export function resolveNativeSampleIntervalMs( liveSubscriberCount: number, ): number { if (snapshot.stale || snapshot.source === "unknown") { - return liveSubscriberCount > 0 ? SAMPLE_INTERVAL_MS : UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS; + return liveSubscriberCount > 0 ? SAMPLE_INTERVAL_MS : BACKGROUND_SAMPLE_INTERVAL_MS; } if ( snapshot.suspended || @@ -268,7 +268,7 @@ export function resolveNativeSampleIntervalMs( return CONSTRAINED_SAMPLE_INTERVAL_MS; } if (snapshot.onBattery === "true") return BATTERY_SAMPLE_INTERVAL_MS; - return SAMPLE_INTERVAL_MS; + return liveSubscriberCount > 0 ? SAMPLE_INTERVAL_MS : BACKGROUND_SAMPLE_INTERVAL_MS; } export function commitCollectionControlUpdate( @@ -379,7 +379,7 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu updatedAt: initializedAt, }, liveSubscriberCount: 0, - sampleIntervalMs: UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS, + sampleIntervalMs: BACKGROUND_SAMPLE_INTERVAL_MS, }); const appliedCollectionControl = yield* Ref.make(yield* Ref.get(collectionControl)); const externalProcesses = yield* Ref.make>([]); @@ -980,7 +980,7 @@ export const layerTest = ( lastSampleAt: Option.none(), lastError: Option.some("Resource monitor test implementation is unavailable."), restartCount: 0, - sampleIntervalMs: UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS, + sampleIntervalMs: BACKGROUND_SAMPLE_INTERVAL_MS, }); return Layer.succeed( NativeTelemetryClient, diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 238fddbc48a..f7bcd397d22 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -6,9 +6,8 @@ import { type ReactNode, useCallback, useContext, - useEffect, useMemo, - useState, + useSyncExternalStore, } from "react"; import { cn } from "../../lib/utils"; @@ -78,14 +77,75 @@ function useSettingsSearchTarget(id: string | undefined) return targetRef; } +interface RelativeTimeTicker { + nowMs: number; + timerId: number | null; + readonly listeners: Set<() => void>; +} + +const relativeTimeTickers = new Map(); + +function getRelativeTimeTicker(intervalMs: number): RelativeTimeTicker { + const existing = relativeTimeTickers.get(intervalMs); + if (existing !== undefined) { + return existing; + } + const ticker: RelativeTimeTicker = { + nowMs: Date.now(), + timerId: null, + listeners: new Set(), + }; + relativeTimeTickers.set(intervalMs, ticker); + return ticker; +} + +function tickRelativeTime(ticker: RelativeTimeTicker): void { + const nextNowMs = Date.now(); + if (nextNowMs === ticker.nowMs) { + return; + } + ticker.nowMs = nextNowMs; + for (const listener of ticker.listeners) { + listener(); + } +} + +function subscribeToRelativeTime( + ticker: RelativeTimeTicker, + intervalMs: number, + listener: () => void, +): () => void { + ticker.listeners.add(listener); + if (ticker.listeners.size === 1) { + ticker.timerId = window.setInterval(() => tickRelativeTime(ticker), intervalMs); + } + + return () => { + ticker.listeners.delete(listener); + if (ticker.listeners.size === 0 && ticker.timerId !== null) { + window.clearInterval(ticker.timerId); + ticker.timerId = null; + relativeTimeTickers.delete(intervalMs); + } + }; +} + +function getRelativeTimeSnapshot(ticker: RelativeTimeTicker): number { + if (ticker.timerId === null) { + ticker.nowMs = Date.now(); + } + return ticker.nowMs; +} + /** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */ export function useRelativeTimeTick(intervalMs = 1_000) { - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - const id = setInterval(() => setNowMs(Date.now()), intervalMs); - return () => clearInterval(id); - }, [intervalMs]); - return nowMs; + const ticker = getRelativeTimeTicker(intervalMs); + const subscribe = useCallback( + (listener: () => void) => subscribeToRelativeTime(ticker, intervalMs, listener), + [intervalMs, ticker], + ); + const getSnapshot = useCallback(() => getRelativeTimeSnapshot(ticker), [ticker]); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } export function SettingsSection({ diff --git a/apps/web/src/connection/useDesktopLocalBootstraps.ts b/apps/web/src/connection/useDesktopLocalBootstraps.ts index 71642aecba4..314f6d3c711 100644 --- a/apps/web/src/connection/useDesktopLocalBootstraps.ts +++ b/apps/web/src/connection/useDesktopLocalBootstraps.ts @@ -1,9 +1,72 @@ import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; import { readDesktopSecondaryBootstraps } from "./desktopLocal"; const DESKTOP_LOCAL_BOOTSTRAP_POLL_MS = 2_000; +const EMPTY_BOOTSTRAPS: ReadonlyArray = []; + +let bootstraps: ReadonlyArray = EMPTY_BOOTSTRAPS; +let pollId: number | null = null; +const listeners = new Set<() => void>(); + +function sameBootstrap( + left: DesktopEnvironmentBootstrap, + right: DesktopEnvironmentBootstrap, +): boolean { + return ( + left.id === right.id && + left.label === right.label && + left.runningDistro === right.runningDistro && + left.httpBaseUrl === right.httpBaseUrl && + left.wsBaseUrl === right.wsBaseUrl && + left.bootstrapToken === right.bootstrapToken + ); +} + +function sameBootstraps( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((entry, index) => sameBootstrap(entry, right[index]!)) + ); +} + +function read(): void { + const next = readDesktopSecondaryBootstraps(); + if (sameBootstraps(bootstraps, next)) { + return; + } + bootstraps = next; + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + if (listeners.size === 1) { + read(); + pollId = window.setInterval(read, DESKTOP_LOCAL_BOOTSTRAP_POLL_MS); + } + + return () => { + listeners.delete(listener); + if (listeners.size === 0 && pollId !== null) { + window.clearInterval(pollId); + pollId = null; + } + }; +} + +function getSnapshot(): ReadonlyArray { + if (pollId === null && typeof window !== "undefined") { + read(); + } + return bootstraps; +} /** * Reactively track the desktop's secondary local backends (e.g. a parallel WSL @@ -13,16 +76,5 @@ const DESKTOP_LOCAL_BOOTSTRAP_POLL_MS = 2_000; * renderer consumer reads the same topology. */ export function useDesktopLocalBootstraps(): ReadonlyArray { - const [bootstraps, setBootstraps] = useState>( - readDesktopSecondaryBootstraps, - ); - - useEffect(() => { - const read = () => setBootstraps(readDesktopSecondaryBootstraps()); - read(); - const interval = setInterval(read, DESKTOP_LOCAL_BOOTSTRAP_POLL_MS); - return () => clearInterval(interval); - }, []); - - return bootstraps; + return useSyncExternalStore(subscribe, getSnapshot, () => EMPTY_BOOTSTRAPS); } diff --git a/docs/internals/resource-telemetry.md b/docs/internals/resource-telemetry.md index 0d07f31f8ac..f6c26f467a1 100644 --- a/docs/internals/resource-telemetry.md +++ b/docs/internals/resource-telemetry.md @@ -104,8 +104,12 @@ power-adaptive interval selected by the server. It collects: - cumulative process I/O counters. On Linux, task/thread enumeration is disabled. Command lines are loaded only -when first needed. This avoids the expensive default behavior of walking every -`/proc//task/` directory on each refresh. +when first needed. The process table is first refreshed for identity and CPU +data, then memory, I/O counters, and command lines are refreshed only for the +retained process tree. Live diagnostics and explicit `sampleNow` requests force +fresh command lines; background samples reuse already-loaded command lines. +This avoids repeating expensive detail work for unrelated processes while +preserving the full snapshot schema. ### Process-tree selection @@ -142,7 +146,8 @@ The server adjusts native sampling without restarting the sidecar: - suspended, locked, low-power, or serious/critical thermal state: 15 seconds; - battery: 5 seconds; -- normal AC: 1 second; +- normal AC: 5 seconds in the background and 1 second while live diagnostics + is open; - unknown or stale power: 5 seconds in the background and 1 second while live diagnostics is open. diff --git a/native/resource-monitor/src/main.rs b/native/resource-monitor/src/main.rs index 0e5dd66307b..e38d878384b 100644 --- a/native/resource-monitor/src/main.rs +++ b/native/resource-monitor/src/main.rs @@ -337,22 +337,30 @@ impl Collector { self.system.refresh_processes_specifics( ProcessesToUpdate::All, true, - process_refresh_kind(), + process_tracking_refresh_kind(), ); self.cpu_baseline_refreshed_at = Some(Instant::now()); } - fn sample(&mut self, config: &CollectorConfig, request_id: Option) -> SnapshotEvent { + fn sample( + &mut self, + config: &CollectorConfig, + request_id: Option, + refresh_commands: bool, + ) -> SnapshotEvent { if let Some(delay) = remaining_cpu_measurement_delay(self.cpu_baseline_refreshed_at.take(), Instant::now()) { thread::sleep(delay); } let collection_started = Instant::now(); + // Process identity and CPU are needed for the whole table so we can + // discover descendants and keep CPU deltas accurate. Expensive + // process details are refreshed separately for the retained subset. self.system.refresh_processes_specifics( ProcessesToUpdate::All, true, - process_refresh_kind(), + process_tracking_refresh_kind(), ); self.cpu_baseline_refreshed_at = Some(Instant::now()); @@ -388,6 +396,16 @@ impl Collector { roots.insert(config.root_pid); let tracked = select_tracked_pids(&rows, &roots); let tracked_process_count = tracked.len(); + let tracked_pids = tracked + .iter() + .copied() + .map(Pid::from_u32) + .collect::>(); + self.system.refresh_processes_specifics( + ProcessesToUpdate::Some(&tracked_pids), + true, + process_details_refresh_kind(refresh_commands), + ); let mut processes = tracked .into_iter() .filter_map(|pid| { @@ -450,12 +468,21 @@ impl Collector { } } -fn process_refresh_kind() -> ProcessRefreshKind { +fn process_tracking_refresh_kind() -> ProcessRefreshKind { + ProcessRefreshKind::nothing().with_cpu().without_tasks() +} + +fn process_details_refresh_kind(refresh_commands: bool) -> ProcessRefreshKind { + let command_update = if refresh_commands { + UpdateKind::Always + } else { + UpdateKind::OnlyIfNotSet + }; + ProcessRefreshKind::nothing() .with_memory() - .with_cpu() .with_disk_usage() - .with_cmd(UpdateKind::Always) + .with_cmd(command_update) .without_tasks() } @@ -696,7 +723,7 @@ fn main() -> io::Result<()> { if next_sample_at.is_some_and(|deadline| deadline <= Instant::now()) { if let Some(current) = config.as_ref() { if let Some(interval) = current.sample_interval { - let event = collector.sample(current, None); + let event = collector.sample(current, None, streaming_enabled); history.record(&event); if streaming_enabled { write_event(&mut writer, &event)?; @@ -789,7 +816,7 @@ fn main() -> io::Result<()> { } Command::SampleNow { request_id, .. } => { if let Some(current) = config.as_ref() { - let event = collector.sample(current, Some(request_id)); + let event = collector.sample(current, Some(request_id), true); history.record(&event); write_event(&mut writer, &event)?; next_sample_at = sample_now_deadline( @@ -1133,14 +1160,23 @@ mod tests { } #[test] - fn refreshes_commands_without_enumerating_linux_tasks() { - let refresh_kind = process_refresh_kind(); - - assert_eq!(refresh_kind.cmd(), UpdateKind::Always); - assert!(!refresh_kind.tasks()); - assert!(refresh_kind.cpu()); - assert!(refresh_kind.memory()); - assert!(refresh_kind.disk_usage()); + fn limits_expensive_refreshes_to_tracked_processes() { + let tracking_kind = process_tracking_refresh_kind(); + assert!(!tracking_kind.tasks()); + assert!(tracking_kind.cpu()); + assert!(!tracking_kind.memory()); + assert!(!tracking_kind.disk_usage()); + assert_eq!(tracking_kind.cmd(), UpdateKind::Never); + + let background_details = process_details_refresh_kind(false); + assert!(!background_details.tasks()); + assert!(!background_details.cpu()); + assert!(background_details.memory()); + assert!(background_details.disk_usage()); + assert_eq!(background_details.cmd(), UpdateKind::OnlyIfNotSet); + + let live_details = process_details_refresh_kind(true); + assert_eq!(live_details.cmd(), UpdateKind::Always); } #[test] diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 00000000000..a0a04daede1 --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,22 @@ +# CPU usage optimization + +- [x] Audit always-on server and client CPU-sensitive paths. +- [x] Scope native resource-monitor 1 Hz sampling to live diagnostics. +- [x] Limit native detail refreshes to tracked processes and reuse background command metadata. +- [x] Share the desktop-local bootstrap poll across renderer consumers. +- [x] Share the settings relative-time ticker across visible labels. +- [x] Update focused tests and resource telemetry documentation. +- [x] Re-run the Rust sidecar tests under the repository-supported Rust toolchain. + +## Review/results + +Background native process sampling now runs at 5 seconds on normal AC power and +returns to 1 second while diagnostics is open. This reduces idle process-table +scans and history copies without changing live diagnostics cadence. + +Native samples now scan the full process table only for identity and CPU data, +then refresh memory, I/O, and command details for the retained process tree. +The Rust sidecar test suite now passes under stable rustc 1.97.1. + +The web client now uses one active desktop-bootstrap poll and one active +relative-time ticker per interval instead of one timer per mounted consumer. From a94c104a7904d9e8d9c124099ec23815b6fed60e Mon Sep 17 00:00:00 2001 From: Daniel Vernon Date: Tue, 4 Aug 2026 18:04:46 +0100 Subject: [PATCH 2/2] fix(perf): guard shared ticker cleanup --- .../components/settings/settingsLayout.tsx | 4 +++- docs/internals/resource-telemetry.md | 11 +++------- native/resource-monitor/src/main.rs | 3 --- tasks/todo.md | 22 ------------------- 4 files changed, 6 insertions(+), 34 deletions(-) delete mode 100644 tasks/todo.md diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index f7bcd397d22..bdfe0a548d5 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -125,7 +125,9 @@ function subscribeToRelativeTime( if (ticker.listeners.size === 0 && ticker.timerId !== null) { window.clearInterval(ticker.timerId); ticker.timerId = null; - relativeTimeTickers.delete(intervalMs); + if (relativeTimeTickers.get(intervalMs) === ticker) { + relativeTimeTickers.delete(intervalMs); + } } }; } diff --git a/docs/internals/resource-telemetry.md b/docs/internals/resource-telemetry.md index f6c26f467a1..0d07f31f8ac 100644 --- a/docs/internals/resource-telemetry.md +++ b/docs/internals/resource-telemetry.md @@ -104,12 +104,8 @@ power-adaptive interval selected by the server. It collects: - cumulative process I/O counters. On Linux, task/thread enumeration is disabled. Command lines are loaded only -when first needed. The process table is first refreshed for identity and CPU -data, then memory, I/O counters, and command lines are refreshed only for the -retained process tree. Live diagnostics and explicit `sampleNow` requests force -fresh command lines; background samples reuse already-loaded command lines. -This avoids repeating expensive detail work for unrelated processes while -preserving the full snapshot schema. +when first needed. This avoids the expensive default behavior of walking every +`/proc//task/` directory on each refresh. ### Process-tree selection @@ -146,8 +142,7 @@ The server adjusts native sampling without restarting the sidecar: - suspended, locked, low-power, or serious/critical thermal state: 15 seconds; - battery: 5 seconds; -- normal AC: 5 seconds in the background and 1 second while live diagnostics - is open; +- normal AC: 1 second; - unknown or stale power: 5 seconds in the background and 1 second while live diagnostics is open. diff --git a/native/resource-monitor/src/main.rs b/native/resource-monitor/src/main.rs index e38d878384b..82ce9841413 100644 --- a/native/resource-monitor/src/main.rs +++ b/native/resource-monitor/src/main.rs @@ -354,9 +354,6 @@ impl Collector { thread::sleep(delay); } let collection_started = Instant::now(); - // Process identity and CPU are needed for the whole table so we can - // discover descendants and keep CPU deltas accurate. Expensive - // process details are refreshed separately for the retained subset. self.system.refresh_processes_specifics( ProcessesToUpdate::All, true, diff --git a/tasks/todo.md b/tasks/todo.md deleted file mode 100644 index a0a04daede1..00000000000 --- a/tasks/todo.md +++ /dev/null @@ -1,22 +0,0 @@ -# CPU usage optimization - -- [x] Audit always-on server and client CPU-sensitive paths. -- [x] Scope native resource-monitor 1 Hz sampling to live diagnostics. -- [x] Limit native detail refreshes to tracked processes and reuse background command metadata. -- [x] Share the desktop-local bootstrap poll across renderer consumers. -- [x] Share the settings relative-time ticker across visible labels. -- [x] Update focused tests and resource telemetry documentation. -- [x] Re-run the Rust sidecar tests under the repository-supported Rust toolchain. - -## Review/results - -Background native process sampling now runs at 5 seconds on normal AC power and -returns to 1 second while diagnostics is open. This reduces idle process-table -scans and history copies without changing live diagnostics cadence. - -Native samples now scan the full process table only for identity and CPU data, -then refresh memory, I/O, and command details for the retained process tree. -The Rust sidecar test suite now passes under stable rustc 1.97.1. - -The web client now uses one active desktop-bootstrap poll and one active -relative-time ticker per interval instead of one timer per mounted consumer.