From aa92950c2664e4a2d46a6a6c70daf8dbba46bad3 Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Wed, 13 May 2026 01:11:55 -0400 Subject: [PATCH 1/3] fix: show simulator network throughput --- README.md | 4 + client/src/api/simulators.ts | 47 + client/src/api/types.ts | 81 ++ .../accessibility/AccessibilityInspector.tsx | 36 +- .../accessibility/PerformancePanel.tsx | 474 +++++++ client/src/styles/components.css | 346 ++++++ docs/api/rest.md | 19 + docs/cli/commands.md | 13 + docs/cli/flags.md | 2 + docs/cli/index.md | 2 + server/src/api/routes.rs | 235 ++++ server/src/main.rs | 217 +++- server/src/performance.rs | 1088 +++++++++++++++++ skills/simdeck/SKILL.md | 6 +- 14 files changed, 2564 insertions(+), 6 deletions(-) create mode 100644 client/src/features/accessibility/PerformancePanel.tsx create mode 100644 server/src/performance.rs diff --git a/README.md b/README.md index d57e01f7..14bdf040 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ view inside the editor. - Android emulator frames are sourced from emulator gRPC; loopback browsers use raw RGBA over WebRTC, and non-loopback browsers use VideoToolbox-encoded H.264 - Full simulator control & inspection using private iOS accessibility APIs and Android UIAutomator - available using `simdeck` CLI - Real-time screen `describe` command using accessibility view tree - available in token-efficient format for agents +- Simulator app performance gauges for CPU, memory, disk writes, network throughput, hang signals, and stack sampling - CoreSimulator chrome asset rendering for device bezels - NativeScript, React Native, Flutter, UIKit and SwiftUI runtime inspector plugins to debug app's view hierarchy live - `simdeck/test` for fast JS-based app tests that can query accessibility state and drive simulator controls @@ -159,6 +160,9 @@ simdeck rotate-left simdeck rotate-right simdeck chrome-profile simdeck logs --seconds 30 --limit 200 +simdeck processes +simdeck stats --watch +simdeck sample --seconds 3 ``` `boot` uses SimDeck's private CoreSimulator boot path so it can start devices diff --git a/client/src/api/simulators.ts b/client/src/api/simulators.ts index 404d0ac8..07cf829c 100644 --- a/client/src/api/simulators.ts +++ b/client/src/api/simulators.ts @@ -5,10 +5,13 @@ import type { ChromeDevToolsTargetDiscovery, ChromeProfile, InspectorRequestResponse, + SimulatorPerformanceResponse, SimulatorLogsResponse, SimulatorMetadata, + SimulatorProcessListResponse, SimulatorStateResponse, SimulatorsResponse, + StackSampleResponse, WebKitTargetDiscovery, } from "./types"; @@ -87,6 +90,50 @@ export async function fetchSimulatorLogs( ); } +export async function fetchSimulatorProcesses( + udid: string, + options: RequestInit = {}, +): Promise { + return apiRequest( + `/api/simulators/${encodeURIComponent(udid)}/processes`, + options, + ); +} + +export async function fetchSimulatorPerformance( + udid: string, + options: { + pid?: number | null; + windowMs?: number; + request?: RequestInit; + } = {}, +): Promise { + const params = new URLSearchParams(); + if (options.pid != null) { + params.set("pid", String(options.pid)); + } + if (options.windowMs != null) { + params.set("windowMs", String(options.windowMs)); + } + const query = params.size > 0 ? `?${params}` : ""; + return apiRequest( + `/api/simulators/${encodeURIComponent(udid)}/performance${query}`, + options.request ?? {}, + ); +} + +export async function sampleSimulatorProcess( + udid: string, + pid: number, + seconds = 3, +): Promise { + const params = new URLSearchParams({ seconds: String(seconds) }); + return apiRequest( + `/api/simulators/${encodeURIComponent(udid)}/processes/${pid}/sample?${params}`, + { method: "POST" }, + ); +} + export async function fetchWebKitTargets( udid: string, options: RequestInit = {}, diff --git a/client/src/api/types.ts b/client/src/api/types.ts index 46127939..f4ac12af 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -293,6 +293,87 @@ export interface SimulatorLogsResponse { entries: SimulatorLogEntry[]; } +export interface PerformanceProcess { + pid: number; + parentPid: number; + process: string; + role: string; + state: string; + appName?: string | null; + bundleIdentifier?: string | null; + command: string; + isForeground: boolean; +} + +export interface PerformanceHangStatus { + state: string; + staleMs?: number | null; + reason: string; +} + +export interface PerformanceSample { + pid: number; + timestampMs: number; + cpuPercent: number; + memoryResidentBytes?: number | null; + memoryFootprintBytes?: number | null; + memoryPeakFootprintBytes?: number | null; + diskReadBytes?: number | null; + diskWriteBytes?: number | null; + diskReadBytesPerSecond?: number | null; + diskWriteBytesPerSecond?: number | null; + networkReceivedBytes?: number | null; + networkSentBytes?: number | null; + networkReceivedBytesPerSecond?: number | null; + networkSentBytesPerSecond?: number | null; + networkConnectionCount?: number | null; + networkEstablishedConnectionCount?: number | null; + networkEndpoints: string[]; + hang: PerformanceHangStatus; +} + +export interface PerformanceEvent { + category: string; + level: string; + message: string; + pid: number | null; + process: string; + subsystem: string; + timestamp: string; +} + +export interface SimulatorPerformanceResponse { + udid: string; + sampledAt: number; + selectedPid?: number | null; + foregroundProcess?: SimulatorForegroundApp | null; + processes: PerformanceProcess[]; + current?: PerformanceSample | null; + history: PerformanceSample[]; + events: PerformanceEvent[]; + warnings: string[]; +} + +export interface SimulatorProcessListResponse { + udid: string; + foregroundProcess?: SimulatorForegroundApp | null; + processes: PerformanceProcess[]; +} + +export interface StackSampleReport { + pid: number; + seconds: number; + sampledAt: number; + report: string; + stderr: string; + truncated: boolean; +} + +export interface StackSampleResponse { + udid: string; + sample: StackSampleReport; +} + export interface InspectorRequestResponse { inspector?: Record; result: T; diff --git a/client/src/features/accessibility/AccessibilityInspector.tsx b/client/src/features/accessibility/AccessibilityInspector.tsx index 42a866c3..9205b499 100644 --- a/client/src/features/accessibility/AccessibilityInspector.tsx +++ b/client/src/features/accessibility/AccessibilityInspector.tsx @@ -10,6 +10,7 @@ import type { UIKitScriptResult, } from "../../api/types"; import { ConsolePanel } from "./ConsolePanel"; +import { PerformancePanel } from "./PerformancePanel"; import { ancestorAccessibilityIds, accessibilityIdentifier, @@ -39,7 +40,7 @@ interface AccessibilityInspectorProps { visible: boolean; } -type InspectorTab = "console" | "inspector"; +type InspectorTab = "console" | "inspector" | "performance"; export function AccessibilityInspector({ availableSources, @@ -245,6 +246,15 @@ export function AccessibilityInspector({ > + {activeTab === "console" ? ( + ) : activeTab === "performance" ? ( + ) : (
{sourceOptions.length > 0 ? ( @@ -457,6 +472,19 @@ function ConsoleIcon() { ); } +function PerformanceIcon() { + return ( + + + + ); +} + function NodeDetails({ node, selectedSimulator, @@ -523,8 +551,8 @@ function NodeDetails({ function isAndroidSimulator(simulator: SimulatorMetadata | null): boolean { return Boolean( simulator?.platform === "android-emulator" || - simulator?.deviceTypeIdentifier === "android-emulator" || - simulator?.udid.startsWith("android:"), + simulator?.deviceTypeIdentifier === "android-emulator" || + simulator?.udid.startsWith("android:"), ); } @@ -941,5 +969,5 @@ function readStoredTab(): InspectorTab { return "inspector"; } const tab = window.localStorage.getItem("xcw-hierarchy-active-tab"); - return tab === "console" ? "console" : "inspector"; + return tab === "console" || tab === "performance" ? tab : "inspector"; } diff --git a/client/src/features/accessibility/PerformancePanel.tsx b/client/src/features/accessibility/PerformancePanel.tsx new file mode 100644 index 00000000..4525ea3d --- /dev/null +++ b/client/src/features/accessibility/PerformancePanel.tsx @@ -0,0 +1,474 @@ +import { useEffect, useMemo, useState } from "react"; +import type { CSSProperties, PointerEvent } from "react"; + +import { + fetchSimulatorPerformance, + sampleSimulatorProcess, +} from "../../api/simulators"; +import type { + PerformanceProcess, + PerformanceSample, + SimulatorMetadata, + SimulatorPerformanceResponse, + StackSampleReport, +} from "../../api/types"; + +const PERFORMANCE_REFRESH_MS = 1500; +const PERFORMANCE_WINDOW_MS = 120_000; + +interface PerformancePanelProps { + selectedSimulator: SimulatorMetadata | null; + visible: boolean; +} + +export function PerformancePanel({ + selectedSimulator, + visible, +}: PerformancePanelProps) { + const udid = selectedSimulator?.udid ?? ""; + const [selectedPid, setSelectedPid] = useState(null); + const [performance, setPerformance] = + useState(null); + const [error, setError] = useState(""); + const [sample, setSample] = useState(null); + const [sampling, setSampling] = useState(false); + + useEffect(() => { + setSelectedPid(null); + setPerformance(null); + setSample(null); + setError(""); + }, [udid]); + + useEffect(() => { + if (!visible || !udid || !selectedSimulator?.isBooted) { + return; + } + + let cancelled = false; + let timer: number | undefined; + async function refresh() { + try { + const next = await fetchSimulatorPerformance(udid, { + pid: selectedPid, + windowMs: PERFORMANCE_WINDOW_MS, + }); + if (cancelled) { + return; + } + setPerformance(next); + setError(""); + if (selectedPid == null && next.selectedPid != null) { + setSelectedPid(next.selectedPid); + } + } catch (refreshError) { + if (!cancelled) { + setError(errorMessage(refreshError)); + } + } finally { + if (!cancelled) { + timer = window.setTimeout(refresh, PERFORMANCE_REFRESH_MS); + } + } + } + + void refresh(); + return () => { + cancelled = true; + if (timer != null) { + window.clearTimeout(timer); + } + }; + }, [selectedPid, selectedSimulator?.isBooted, udid, visible]); + + const current = performance?.current ?? null; + const selectedProcess = useMemo( + () => + performance?.processes.find( + (process) => process.pid === (selectedPid ?? performance.selectedPid), + ) ?? null, + [performance, selectedPid], + ); + + async function runSample() { + const pid = selectedPid ?? performance?.selectedPid ?? null; + if (!udid || pid == null) { + return; + } + setSampling(true); + setSample(null); + setError(""); + try { + const response = await sampleSimulatorProcess(udid, pid, 3); + setSample(response.sample); + } catch (sampleError) { + setError(errorMessage(sampleError)); + } finally { + setSampling(false); + } + } + + if (!selectedSimulator) { + return
Select a simulator.
; + } + if (!selectedSimulator.isBooted) { + return
Boot the simulator.
; + } + + return ( +
+
+ {performance?.processes.length ? ( + performance.processes.map((process) => ( + { + setSelectedPid(process.pid); + setSample(null); + }} + process={process} + selected={ + process.pid === (selectedPid ?? performance.selectedPid) + } + /> + )) + ) : ( +
+ {error || "Waiting for an app process."} +
+ )} +
+ + {error && performance?.processes.length ? ( +
{error}
+ ) : null} + + {current ? ( + <> +
+ + + + + + +
+ +
+ {hangLabel(current.hang.state)} + {current.hang.reason} +
+ + memoryDisplayBytes(sample) ?? 0} + valueLabel={formatBytes} + /> + sample.cpuPercent} + valueLabel={formatPercent} + /> + sample.diskWriteBytesPerSecond ?? 0} + valueLabel={formatRate} + /> + sample.networkReceivedBytesPerSecond ?? 0} + valueLabel={formatRate} + /> + sample.networkSentBytesPerSecond ?? 0} + valueLabel={formatRate} + /> + +
+
Network
+
+ + {formatRate(current.networkReceivedBytesPerSecond)} down /{" "} + {formatRate(current.networkSentBytesPerSecond)} up + + + {formatBytes(current.networkReceivedBytes)} received /{" "} + {formatBytes(current.networkSentBytes)} sent + + + {current.networkConnectionCount == null + ? "Connection details unavailable" + : `${current.networkConnectionCount} connections, ${current.networkEstablishedConnectionCount ?? 0} established`} + +
+ {current.networkEndpoints.length ? ( +
+ {current.networkEndpoints.map((endpoint) => ( +
{endpoint}
+ ))} +
+ ) : null} +
+ +
+
+
+
CPU Sample
+
+ {selectedProcess + ? `${selectedProcess.process} (${selectedProcess.pid})` + : `PID ${current.pid}`} +
+
+ +
+ {sample ? ( +
+                {sample.report || sample.stderr}
+              
+ ) : null} +
+ +
+
Crashes
+ {performance?.events.length ? ( +
+ {performance.events.map((event, index) => ( +
+ {event.level} + {event.message} +
+ ))} +
+ ) : ( +
+ No recent crash or termination signals. +
+ )} +
+ + ) : null} +
+ ); +} + +function ProcessButton({ + onSelect, + process, + selected, +}: { + onSelect: () => void; + process: PerformanceProcess; + selected: boolean; +}) { + return ( + + ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function Timeline({ + label, + samples, + value, + valueLabel, +}: { + label: string; + samples: PerformanceSample[]; + value: (sample: PerformanceSample) => number; + valueLabel: (value: number | null | undefined) => string; +}) { + const [hoverIndex, setHoverIndex] = useState(null); + const values = samples.map(value); + const latest = values.at(-1) ?? 0; + const max = Math.max(...values, 1); + const coordinates = values.map((item, index) => ({ + x: values.length <= 1 ? 0 : (index / (values.length - 1)) * 100, + y: 42 - (Math.max(0, item) / max) * 36, + })); + const points = coordinates + .map((point) => `${round(point.x)},${round(point.y)}`) + .join(" "); + const activeIndex = + hoverIndex == null || hoverIndex >= samples.length ? null : hoverIndex; + const activePoint = activeIndex == null ? null : coordinates[activeIndex]; + const activeSample = activeIndex == null ? null : samples[activeIndex]; + + function handlePointerMove(event: PointerEvent) { + if (samples.length === 0) { + setHoverIndex(null); + return; + } + const rect = event.currentTarget.getBoundingClientRect(); + const position = clamp((event.clientX - rect.left) / rect.width, 0, 1); + setHoverIndex(Math.round(position * (samples.length - 1))); + } + + return ( +
+
+ {label} + {valueLabel(latest)} +
+ setHoverIndex(null)} + onPointerMove={handlePointerMove} + preserveAspectRatio="none" + viewBox="0 0 100 44" + > + + {points ? : null} + {activePoint ? ( + <> + + + + ) : null} + + {activePoint && activeSample ? ( +
+ {formatSampleTime(activeSample.timestampMs)} + {valueLabel(value(activeSample))} +
+ ) : null} +
+ ); +} + +function memoryDisplayBytes(sample: PerformanceSample): number | null { + return sample.memoryFootprintBytes ?? sample.memoryResidentBytes ?? null; +} + +function formatPercent(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) { + return "--"; + } + return `${Math.round(value * 10) / 10}%`; +} + +function formatBytes(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) { + return "--"; + } + const units = ["B", "KB", "MB", "GB"]; + let next = value; + let unit = 0; + while (next >= 1024 && unit < units.length - 1) { + next /= 1024; + unit += 1; + } + return `${unit === 0 ? next : Math.round(next * 10) / 10} ${units[unit]}`; +} + +function formatRate(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) { + return "--"; + } + return `${formatBytes(value)}/s`; +} + +function hangLabel(state: string): string { + if (state === "busy") { + return "Busy"; + } + if (state === "quiet") { + return "Quiet"; + } + if (state === "responsive") { + return "Responsive"; + } + return "Unknown"; +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function formatSampleTime(timestampMs: number): string { + if (!Number.isFinite(timestampMs) || timestampMs <= 0) { + return ""; + } + return new Date(timestampMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/client/src/styles/components.css b/client/src/styles/components.css index 7644eeb6..1600d740 100644 --- a/client/src/styles/components.css +++ b/client/src/styles/components.css @@ -1404,6 +1404,352 @@ color: var(--text); } +.performance-panel { + flex: 1; + min-height: 0; + overflow: auto; + padding: 8px; + background: color-mix(in srgb, var(--surface) 92%, var(--bg)); + color: var(--text); + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.performance-empty { + display: grid; + min-height: 120px; + place-items: center; + padding: 16px; + color: var(--text-muted); + font-size: 12px; + text-align: center; +} + +.performance-empty.compact { + min-height: 48px; + border: 1px solid var(--border-subtle); + border-radius: 8px; +} + +.performance-error { + margin: 8px 0; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--error) 50%, var(--border)); + border-radius: 8px; + color: var(--error); + font-size: 11px; + line-height: 1.35; +} + +.performance-process-list { + display: grid; + gap: 6px; + margin-bottom: 8px; +} + +.performance-process { + display: grid; + gap: 2px; + width: 100%; + padding: 8px 9px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font: inherit; + text-align: left; +} + +.performance-process:hover { + background: var(--surface-hover); +} + +.performance-process.selected { + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + background: color-mix(in srgb, var(--accent) 12%, var(--surface)); +} + +.performance-process-name, +.performance-process-meta { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.performance-process-name { + font-size: 12px; + font-weight: 700; +} + +.performance-process-meta { + color: var(--text-muted); + font-size: 10px; +} + +.performance-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin-bottom: 8px; +} + +.performance-metric { + display: grid; + gap: 3px; + min-width: 0; + padding: 8px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: var(--bg); +} + +.performance-metric span { + color: var(--text-muted); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; +} + +.performance-metric strong { + overflow: hidden; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.performance-hang { + display: grid; + gap: 3px; + margin-bottom: 8px; + padding: 8px 9px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: var(--bg); + color: var(--text-secondary); + font-size: 11px; + line-height: 1.35; +} + +.performance-hang span:first-child { + color: var(--text); + font-weight: 750; +} + +.performance-hang.state-busy { + border-color: color-mix(in srgb, var(--error) 50%, var(--border)); +} + +.performance-hang.state-busy span:first-child { + color: var(--error); +} + +.performance-hang.state-responsive span:first-child { + color: var(--success); +} + +.performance-chart { + position: relative; + display: grid; + gap: 6px; + margin-bottom: 8px; + padding: 8px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: var(--bg); +} + +.performance-chart-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 11px; +} + +.performance-chart-head span { + color: var(--text-muted); + font-weight: 700; + text-transform: uppercase; +} + +.performance-chart-head strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.performance-chart-svg { + display: block; + width: 100%; + height: 64px; + cursor: crosshair; + touch-action: none; +} + +.performance-chart-svg line { + stroke: var(--border-subtle); + stroke-width: 1; +} + +.performance-chart-svg polyline { + fill: none; + stroke: var(--accent); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; + vector-effect: non-scaling-stroke; +} + +.performance-chart-marker { + stroke: color-mix(in srgb, var(--text-muted) 70%, transparent); + stroke-dasharray: 3 3; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.performance-chart-point { + fill: var(--accent); + stroke: var(--bg); + stroke-width: 1.4; + vector-effect: non-scaling-stroke; +} + +.performance-chart-tooltip { + position: absolute; + left: clamp(44px, var(--performance-tooltip-x), calc(100% - 44px)); + bottom: 10px; + z-index: 2; + display: grid; + gap: 1px; + min-width: 76px; + padding: 5px 7px; + border: 1px solid var(--border); + border-radius: 6px; + background: color-mix(in srgb, var(--surface) 94%, #000000); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25); + font-size: 10px; + line-height: 1.25; + pointer-events: none; + transform: translateX(-50%); +} + +.performance-chart-tooltip span { + color: var(--text-muted); +} + +.performance-chart-tooltip strong { + color: var(--text); +} + +.performance-section { + display: grid; + gap: 7px; + margin-bottom: 8px; + padding: 8px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: var(--bg); +} + +.performance-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.performance-section-title { + color: var(--text); + font-size: 11px; + font-weight: 750; +} + +.performance-section-subtitle, +.performance-network-line, +.performance-muted { + color: var(--text-muted); + font-size: 11px; + line-height: 1.35; +} + +.performance-network-line { + display: grid; + gap: 2px; +} + +.performance-sample-button { + flex: 0 0 auto; + height: 26px; + padding: 0 9px; + border: 1px solid var(--border-subtle); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font: inherit; + font-size: 11px; + font-weight: 700; +} + +.performance-sample-button:hover:not(:disabled) { + background: var(--surface-hover); +} + +.performance-sample-button:disabled { + color: var(--text-muted); +} + +.performance-endpoints, +.performance-events { + display: grid; + gap: 4px; +} + +.performance-endpoints div { + overflow: hidden; + color: var(--text-secondary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.performance-event { + display: grid; + gap: 2px; + padding: 6px; + border-radius: 6px; + background: color-mix(in srgb, var(--surface) 70%, var(--bg)); + font-size: 10px; + line-height: 1.35; +} + +.performance-event span:first-child { + color: var(--error); + font-weight: 750; +} + +.performance-event span:last-child { + overflow-wrap: anywhere; + color: var(--text-secondary); +} + +.performance-sample-report { + max-height: 260px; + overflow: auto; + margin: 0; + padding: 8px; + border: 1px solid var(--border-subtle); + border-radius: 6px; + background: color-mix(in srgb, var(--surface) 80%, #000000); + color: var(--text-secondary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 10px; + line-height: 1.35; + white-space: pre-wrap; +} + .hierarchy-resize-x { position: absolute; top: 0; diff --git a/docs/api/rest.md b/docs/api/rest.md index b1164d11..aae6daee 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -69,6 +69,25 @@ Device IDs come from `/api/simulators`. Android IDs use the `android:` prefix. | `POST` | `/api/simulators/{udid}/launch` | `{ "bundleId": "com.example.App" }` | | `POST` | `/api/simulators/{udid}/open-url` | `{ "url": "https://example.com" }` | +## Performance + +iOS simulator app processes run as host macOS processes. These endpoints expose host-process telemetry for matching simulator app PIDs. + +| Method | Path | Purpose | +| ------ | ---------------------------------------------------- | --------------------------------------------------- | +| `GET` | `/api/simulators/{udid}/processes` | List app, extension, helper, and web-content PIDs | +| `GET` | `/api/simulators/{udid}/performance` | Current sample plus rolling CPU/memory/disk/network history | +| `GET` | `/api/simulators/{udid}/processes/{pid}/performance` | Performance data for one simulator app process | +| `POST` | `/api/simulators/{udid}/processes/{pid}/sample` | Capture a short CPU stack sample with `sample` | + +Performance query parameters: + +| Parameter | Notes | +| -------------- | --------------------------------------------------------- | +| `pid=123` | Select a process; defaults to the foreground app | +| `windowMs=...` | History window, clamped between 10 seconds and 10 minutes | +| `seconds=3` | Stack sample duration for `POST .../sample` | + ## Live Video | Method | Path | Purpose | diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 984b56d7..4fa63541 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -63,6 +63,19 @@ simdeck describe --point 120,240 Default source selection prefers a connected framework inspector, then the Swift in-app agent, then native accessibility. +## Performance + +```sh +simdeck processes +simdeck stats +simdeck stats --pid 12345 +simdeck stats --watch +simdeck sample +simdeck sample --pid 12345 --seconds 3 +``` + +Performance data is simulator-only and uses host-process telemetry for matching app, extension, helper, and web-content PIDs. `stats` reports CPU, memory, disk write rate, network receive/send rates, connection count, hang state, and recent crash or termination signals. `sample` captures a short macOS `sample` report for the selected or foreground app process. + ## Input Coordinates are screen points unless `--normalized` is present. diff --git a/docs/cli/flags.md b/docs/cli/flags.md index c7574066..efe6339a 100644 --- a/docs/cli/flags.md +++ b/docs/cli/flags.md @@ -63,6 +63,8 @@ Used by `simdeck ui`, `daemon start`, `daemon restart`, `service on`, and `servi | ---------------- | ---------------------------------------------------- | | `screenshot` | `--output `, `--stdout` | | `logs` | `--seconds `, `--limit ` | +| `stats` | `--pid `, `--watch`, `--interval ` | +| `sample` | `--pid `, `--seconds ` | | `pasteboard set` | `--stdin`, `--file` | | `batch` | `--step`, `--file`, `--stdin`, `--continue-on-error` | diff --git a/docs/cli/index.md b/docs/cli/index.md index e021b742..5c81671f 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -39,6 +39,8 @@ simdeck tap --label "Continue" --wait-timeout-ms 5000 simdeck describe --format agent --max-depth 3 simdeck screenshot --output screen.png simdeck logs --seconds 30 --limit 200 +simdeck stats +simdeck sample --seconds 3 ``` Most successful commands print JSON so they can be piped into tools such as `jq`. diff --git a/server/src/api/routes.rs b/server/src/api/routes.rs index 5fd4ab47..5f4a5ca6 100644 --- a/server/src/api/routes.rs +++ b/server/src/api/routes.rs @@ -8,6 +8,9 @@ use crate::inspector::{InspectorHub, PublishedInspector}; use crate::logs::LogRegistry; use crate::metrics::counters::{ClientStreamStats, Metrics}; use crate::native::bridge::{LogFilters, NativeBridge}; +use crate::performance::{ + sample_stack, DisplaySignal, ForegroundProcess, PerformanceQuery, PerformanceRegistry, +}; use crate::simulators::registry::SessionRegistry; use crate::simulators::session::SimulatorSession; use crate::static_files; @@ -59,6 +62,7 @@ pub struct AppState { pub logs: LogRegistry, pub inspectors: InspectorHub, pub metrics: Arc, + pub performance: PerformanceRegistry, pub stream_clients: StreamClientForegroundRegistry, pub simulator_inventory: SimulatorInventoryCache, pub android: AndroidBridge, @@ -555,6 +559,19 @@ struct LogsQuery { q: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PerformanceRequestQuery { + pid: Option, + window_ms: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct StackSampleRequestQuery { + seconds: Option, +} + #[derive(Deserialize)] struct InspectorRequestPayload { method: String, @@ -625,6 +642,19 @@ pub fn router(state: AppState) -> Router { ) .route("/api/simulators", get(list_simulators)) .route("/api/simulators/{udid}/state", get(simulator_state)) + .route("/api/simulators/{udid}/processes", get(simulator_processes)) + .route( + "/api/simulators/{udid}/performance", + get(simulator_performance), + ) + .route( + "/api/simulators/{udid}/processes/{pid}/performance", + get(simulator_process_performance), + ) + .route( + "/api/simulators/{udid}/processes/{pid}/sample", + post(sample_process_stack), + ) .route("/api/simulators/{udid}/boot", post(boot_simulator)) .route("/api/simulators/{udid}/shutdown", post(shutdown_simulator)) .route("/api/simulators/{udid}/erase", post(erase_simulator)) @@ -1487,6 +1517,211 @@ async fn simulator_state( }))) } +async fn simulator_processes( + State(state): State, + Path(udid): Path, +) -> Result, AppError> { + if android::is_android_id(&udid) { + return Err(AppError::bad_request( + "Performance gauges are only supported for iOS simulators.", + )); + } + let foreground = performance_foreground_process(&state, &udid).await; + let processes = state + .performance + .list_processes(&udid, foreground.clone()) + .await?; + Ok(json(json_value!({ + "udid": udid, + "foregroundProcess": foreground, + "processes": processes, + }))) +} + +async fn simulator_performance( + State(state): State, + Path(udid): Path, + Query(query): Query, +) -> Result, AppError> { + simulator_performance_payload(state, udid, query.pid, query.window_ms).await +} + +async fn simulator_process_performance( + State(state): State, + Path((udid, pid)): Path<(String, i32)>, + Query(query): Query, +) -> Result, AppError> { + simulator_performance_payload(state, udid, Some(pid), query.window_ms).await +} + +async fn simulator_performance_payload( + state: AppState, + udid: String, + pid: Option, + window_ms: Option, +) -> Result, AppError> { + if android::is_android_id(&udid) { + return Err(AppError::bad_request( + "Performance gauges are only supported for iOS simulators.", + )); + } + let foreground = performance_foreground_process(&state, &udid).await; + let display_signal = simulator_display_signal(state.clone(), &udid).await; + let snapshot = state + .performance + .snapshot( + &udid, + PerformanceQuery { + pid, + history_window_ms: window_ms.unwrap_or(120_000).clamp(10_000, 10 * 60 * 1000), + }, + foreground, + display_signal, + ) + .await?; + let events = performance_log_events(&state, &udid, &snapshot).await; + let mut value = serde_json::to_value(snapshot).map_err(|error| { + AppError::internal(format!("Unable to encode performance data: {error}")) + })?; + if let Some(object) = value.as_object_mut() { + object.insert("events".to_owned(), Value::Array(events)); + } + Ok(json(value)) +} + +async fn sample_process_stack( + State(state): State, + Path((udid, pid)): Path<(String, i32)>, + Query(query): Query, +) -> Result, AppError> { + if android::is_android_id(&udid) { + return Err(AppError::bad_request( + "Performance sampling is only supported for iOS simulators.", + )); + } + let foreground = performance_foreground_process(&state, &udid).await; + let processes = state.performance.list_processes(&udid, foreground).await?; + if !processes.iter().any(|process| process.pid == pid) { + return Err(AppError::bad_request(format!( + "Process {pid} does not belong to simulator {udid}." + ))); + } + let report = sample_stack(pid, query.seconds.unwrap_or(3)).await?; + Ok(json(json_value!({ + "udid": udid, + "sample": report, + }))) +} + +async fn performance_foreground_process(state: &AppState, udid: &str) -> Option { + foreground_app_metadata(state, udid) + .await + .ok() + .flatten() + .map(|foreground| ForegroundProcess { + process_identifier: foreground.process_identifier, + bundle_identifier: foreground.bundle_identifier, + app_name: foreground.app_name, + }) +} + +async fn simulator_display_signal(state: AppState, udid: &str) -> DisplaySignal { + all_device_values(state, false) + .await + .ok() + .and_then(|simulators| { + simulators + .into_iter() + .find(|entry| entry.get("udid").and_then(Value::as_str) == Some(udid)) + }) + .and_then(|simulator| { + let display = simulator.get("privateDisplay")?; + Some(DisplaySignal { + frame_sequence: display + .get("frameSequence") + .and_then(Value::as_u64) + .unwrap_or(0), + last_frame_at_ms: display + .get("lastFrameAt") + .and_then(Value::as_u64) + .unwrap_or(0), + }) + }) + .unwrap_or_default() +} + +async fn performance_log_events( + state: &AppState, + udid: &str, + snapshot: &crate::performance::SimulatorPerformanceSnapshot, +) -> Vec { + let Some(current) = snapshot.current.as_ref() else { + return Vec::new(); + }; + let process_name = snapshot + .processes + .iter() + .find(|process| process.pid == current.pid) + .map(|process| process.process.as_str()) + .unwrap_or(""); + let filters = LogFilters::new(Vec::new(), Vec::new(), String::new()); + if state.logs.ensure_started(udid).await.is_err() { + return Vec::new(); + } + state + .logs + .snapshot(udid, &filters, 800) + .await + .into_iter() + .rev() + .filter(|entry| performance_log_entry_matches(entry, current.pid, process_name)) + .take(12) + .map(|entry| { + json_value!({ + "timestamp": entry.timestamp, + "level": entry.level, + "process": entry.process, + "pid": entry.pid, + "subsystem": entry.subsystem, + "category": entry.category, + "message": entry.message, + }) + }) + .collect::>() + .into_iter() + .rev() + .collect() +} + +fn performance_log_entry_matches( + entry: &crate::native::bridge::LogEntry, + pid: i32, + process_name: &str, +) -> bool { + let pid_matches = entry.pid.as_i64() == Some(pid as i64); + let process_matches = !process_name.is_empty() && entry.process == process_name; + if !pid_matches && !process_matches { + return false; + } + let haystack = format!( + "{} {} {} {}", + entry.level, entry.subsystem, entry.category, entry.message + ) + .to_lowercase(); + [ + "abort", + "crash", + "exception", + "exited", + "jetsam", + "killed", + "signal", + "terminat", + ] + .iter() + .any(|needle| haystack.contains(needle)) +} + async fn boot_simulator( State(state): State, Path(udid): Path, diff --git a/server/src/main.rs b/server/src/main.rs index 453c73c2..693f5551 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -10,6 +10,7 @@ mod logging; mod logs; mod metrics; mod native; +mod performance; mod service; mod simulators; mod static_files; @@ -26,6 +27,7 @@ use logs::LogRegistry; use metrics::counters::Metrics; use native::bridge::{NativeBridge, NativeInputSession}; use native::ffi; +use performance::PerformanceRegistry; use serde::{Deserialize, Serialize}; use serde_json::Value; use simulators::registry::SessionRegistry; @@ -42,7 +44,7 @@ use std::process::{Command as ProcessCommand, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime}; use tracing::info; const RECOVERABLE_RESTART_EXIT_CODE: i32 = 75; @@ -180,6 +182,25 @@ enum Command { #[arg(long, default_value_t = 200)] limit: usize, }, + Processes { + udid: String, + }, + Stats { + udid: String, + #[arg(long)] + pid: Option, + #[arg(long)] + watch: bool, + #[arg(long, default_value_t = 1.5)] + interval: f64, + }, + Sample { + udid: String, + #[arg(long)] + pid: Option, + #[arg(long, default_value_t = 3)] + seconds: u64, + }, Screenshot { udid: String, #[arg(short, long)] @@ -1346,6 +1367,9 @@ fn is_known_command(value: &str) -> bool { | "uninstall" | "pasteboard" | "logs" + | "processes" + | "stats" + | "sample" | "screenshot" | "describe" | "touch" @@ -2259,6 +2283,53 @@ fn main() -> anyhow::Result<()> { println_json(&serde_json::json!({ "entries": entries }))?; Ok(()) } + Command::Processes { udid } => { + let service_url = command_service_url(explicit_server_url.as_deref())?; + let processes = service_get_json( + &service_url, + &format!("/api/simulators/{}/processes", url_path_component(&udid)), + )?; + println_json(&processes)?; + Ok(()) + } + Command::Stats { + udid, + pid, + watch, + interval, + } => { + let service_url = command_service_url(explicit_server_url.as_deref())?; + if watch { + run_stats_watch(&service_url, &udid, pid, interval)?; + } else { + let stats = service_performance_json(&service_url, &udid, pid)?; + println_json(&stats)?; + } + Ok(()) + } + Command::Sample { udid, pid, seconds } => { + let service_url = command_service_url(explicit_server_url.as_deref())?; + let pid = match pid { + Some(pid) => pid, + None => service_performance_json(&service_url, &udid, None)? + .get("selectedPid") + .and_then(Value::as_i64) + .ok_or_else(|| { + anyhow::anyhow!("No foreground simulator app process is available.") + })? as i32, + }; + let report = service_post_sample(&service_url, &udid, pid, seconds)?; + let sample = report.get("sample").unwrap_or(&Value::Null); + if let Some(text) = sample.get("report").and_then(Value::as_str) { + print!("{text}"); + if !text.ends_with('\n') { + println!(); + } + } else { + println_json(&report)?; + } + Ok(()) + } Command::Screenshot { udid, output, @@ -3419,6 +3490,149 @@ fn service_launch(server_url: &str, udid: &str, bundle_id: &str) -> anyhow::Resu ) } +fn service_performance_json( + server_url: &str, + udid: &str, + pid: Option, +) -> anyhow::Result { + let mut path = format!( + "/api/simulators/{}/performance?windowMs=120000", + url_path_component(udid) + ); + if let Some(pid) = pid { + path.push_str(&format!("&pid={pid}")); + } + service_get_json(server_url, &path) +} + +fn service_post_sample( + server_url: &str, + udid: &str, + pid: i32, + seconds: u64, +) -> anyhow::Result { + http_request_json( + server_url, + "POST", + &format!( + "/api/simulators/{}/processes/{pid}/sample?seconds={}", + url_path_component(udid), + seconds.clamp(1, 30) + ), + None, + ) +} + +fn run_stats_watch( + server_url: &str, + udid: &str, + pid: Option, + interval: f64, +) -> anyhow::Result<()> { + let interval = Duration::from_secs_f64(interval.clamp(0.25, 60.0)); + loop { + let stats = service_performance_json(server_url, udid, pid)?; + print_performance_line(&stats)?; + std::thread::sleep(interval); + } +} + +fn print_performance_line(stats: &Value) -> anyhow::Result<()> { + let current = stats + .get("current") + .and_then(Value::as_object) + .ok_or_else(|| anyhow::anyhow!("No current performance sample is available."))?; + let pid = current.get("pid").and_then(Value::as_i64).unwrap_or(0); + let process = stats + .get("processes") + .and_then(Value::as_array) + .and_then(|processes| { + processes + .iter() + .find(|process| process.get("pid").and_then(Value::as_i64) == Some(pid)) + }) + .and_then(|process| process.get("process")) + .and_then(Value::as_str) + .unwrap_or("unknown"); + let cpu = current + .get("cpuPercent") + .and_then(Value::as_f64) + .map(|value| format!("{value:.1}%")) + .unwrap_or_else(|| "--".to_owned()); + let memory = current + .get("memoryFootprintBytes") + .or_else(|| current.get("memoryResidentBytes")) + .and_then(Value::as_u64) + .map(format_bytes_cli) + .unwrap_or_else(|| "--".to_owned()); + let disk = current + .get("diskWriteBytesPerSecond") + .and_then(Value::as_f64) + .map(|value| format!("{}/s", format_bytes_cli(value.max(0.0) as u64))) + .unwrap_or_else(|| "--".to_owned()); + let network_in = current + .get("networkReceivedBytesPerSecond") + .and_then(Value::as_f64) + .map(|value| format!("{}/s", format_bytes_cli(value.max(0.0) as u64))) + .unwrap_or_else(|| "--".to_owned()); + let network_out = current + .get("networkSentBytesPerSecond") + .and_then(Value::as_f64) + .map(|value| format!("{}/s", format_bytes_cli(value.max(0.0) as u64))) + .unwrap_or_else(|| "--".to_owned()); + let connections = current + .get("networkConnectionCount") + .and_then(Value::as_u64) + .map(|value| value.to_string()) + .unwrap_or_else(|| "--".to_owned()); + let hang = current + .get("hang") + .and_then(|hang| hang.get("state")) + .and_then(Value::as_str) + .unwrap_or("unknown"); + println!( + "{} pid={} process={} cpu={} memory={} diskWrite={} netIn={} netOut={} connections={} hang={}", + chrono_like_time_label(), + pid, + process, + cpu, + memory, + disk, + network_in, + network_out, + connections, + hang + ); + io::stdout().flush()?; + Ok(()) +} + +fn format_bytes_cli(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit + 1 < UNITS.len() { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{} {}", bytes, UNITS[unit]) + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +fn chrono_like_time_label() -> String { + let now = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let seconds = now % 60; + let minutes = (now / 60) % 60; + let hours = (now / 3600) % 24; + format!("{hours:02}:{minutes:02}:{seconds:02}") +} + fn service_touch(server_url: &str, udid: &str, x: f64, y: f64, phase: &str) -> anyhow::Result<()> { service_post_ok( server_url, @@ -5420,6 +5634,7 @@ async fn serve( logs, inspectors, metrics, + performance: PerformanceRegistry::default(), stream_clients: Default::default(), simulator_inventory: Default::default(), android: Default::default(), diff --git a/server/src/performance.rs b/server/src/performance.rs new file mode 100644 index 00000000..ec361d0e --- /dev/null +++ b/server/src/performance.rs @@ -0,0 +1,1088 @@ +use crate::error::AppError; +use serde::Serialize; +use std::collections::{HashMap, VecDeque}; +use std::ffi::c_void; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::process::Command; +use tokio::time::timeout; + +const HISTORY_RETENTION_MS: u64 = 10 * 60 * 1000; +const HISTORY_MAX_SAMPLES: usize = 720; +const PROCESS_LIST_TIMEOUT: Duration = Duration::from_secs(2); +const PROCESS_SAMPLE_TIMEOUT: Duration = Duration::from_secs(2); +const NETWORK_SAMPLE_TIMEOUT: Duration = Duration::from_millis(650); +const NETWORK_TOTALS_TIMEOUT: Duration = Duration::from_millis(1_800); +const STACK_SAMPLE_MAX_BYTES: usize = 256 * 1024; + +#[derive(Clone, Default)] +pub struct PerformanceRegistry { + inner: Arc>, +} + +#[derive(Default)] +struct PerformanceState { + last_raw: HashMap, + history: HashMap>, + hang: HashMap, +} + +#[derive(Clone, Debug)] +struct RawCounterSnapshot { + sampled_at: Instant, + timestamp_ms: u64, + cpu_time_ns: Option, + disk_read_bytes: Option, + disk_write_bytes: Option, + network_received_bytes: Option, + network_sent_bytes: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ForegroundProcess { + pub process_identifier: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub bundle_identifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_name: Option, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct DisplaySignal { + pub frame_sequence: u64, + pub last_frame_at_ms: u64, +} + +#[derive(Clone, Copy, Debug)] +pub struct PerformanceQuery { + pub pid: Option, + pub history_window_ms: u64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SimulatorPerformanceSnapshot { + pub udid: String, + pub sampled_at: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_pid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub foreground_process: Option, + pub processes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub current: Option, + pub history: Vec, + pub warnings: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PerformanceProcess { + pub pid: i32, + pub parent_pid: i32, + pub process: String, + pub role: String, + pub state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bundle_identifier: Option, + pub command: String, + pub is_foreground: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PerformanceSample { + pub pid: i32, + pub timestamp_ms: u64, + pub cpu_percent: f64, + pub memory_resident_bytes: Option, + pub memory_footprint_bytes: Option, + pub memory_peak_footprint_bytes: Option, + pub disk_read_bytes: Option, + pub disk_write_bytes: Option, + pub disk_read_bytes_per_second: Option, + pub disk_write_bytes_per_second: Option, + pub network_received_bytes: Option, + pub network_sent_bytes: Option, + pub network_received_bytes_per_second: Option, + pub network_sent_bytes_per_second: Option, + pub network_connection_count: Option, + pub network_established_connection_count: Option, + pub network_endpoints: Vec, + pub hang: HangStatus, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HangStatus { + pub state: String, + pub stale_ms: Option, + pub reason: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StackSampleReport { + pub pid: i32, + pub seconds: u64, + pub sampled_at: u64, + pub report: String, + pub stderr: String, + pub truncated: bool, +} + +#[derive(Clone, Debug)] +struct PsProcess { + pid: i32, + parent_pid: i32, + state: String, + cpu_percent: f64, + rss_kb: Option, + command: String, +} + +#[derive(Clone, Debug)] +struct RawPerformanceSample { + process: PerformanceProcess, + ps_cpu_percent: f64, + ps_memory_resident_bytes: Option, + rusage: Option, + network: Option, +} + +#[derive(Clone, Copy, Debug)] +struct ProcessRUsage { + user_time_ns: u64, + system_time_ns: u64, + resident_size: u64, + phys_footprint: u64, + lifetime_max_phys_footprint: u64, + disk_read_bytes: u64, + disk_write_bytes: u64, +} + +#[derive(Clone, Debug)] +struct NetworkSnapshot { + connection_count: usize, + established_connection_count: usize, + received_bytes: Option, + sent_bytes: Option, + endpoints: Vec, +} + +#[derive(Clone, Debug, Default)] +struct HangTracker { + last_frame_sequence: u64, + last_frame_change_ms: u64, +} + +impl PerformanceRegistry { + pub async fn list_processes( + &self, + udid: &str, + foreground: Option, + ) -> Result, AppError> { + let ps = list_ps_processes().await?; + let mut processes = app_processes_from_ps(udid, foreground.as_ref(), ps); + if let Some(foreground) = foreground.as_ref() { + ensure_foreground_process(&mut processes, foreground).await; + } + processes.sort_by_key(|process| (!process.is_foreground, process.process.clone())); + Ok(processes) + } + + pub async fn snapshot( + &self, + udid: &str, + query: PerformanceQuery, + foreground: Option, + display_signal: DisplaySignal, + ) -> Result { + let sampled_at = now_ms(); + let mut warnings = Vec::new(); + let ps = list_ps_processes().await?; + let mut processes = app_processes_from_ps(udid, foreground.as_ref(), ps); + if let Some(foreground) = foreground.as_ref() { + ensure_foreground_process(&mut processes, foreground).await; + } + processes.sort_by_key(|process| (!process.is_foreground, process.process.clone())); + + let selected_pid = query + .pid + .or_else(|| { + foreground + .as_ref() + .map(|process| process.process_identifier as i32) + }) + .or_else(|| processes.first().map(|process| process.pid)); + + if processes.is_empty() { + warnings.push("No simulator app process matched this UDID yet.".to_owned()); + } + + let mut raw_samples = Vec::new(); + for process in &processes { + raw_samples + .push(sample_process(process.clone(), selected_pid == Some(process.pid)).await); + } + + let selected_pid = + selected_pid.filter(|pid| raw_samples.iter().any(|sample| sample.process.pid == *pid)); + let (current, history) = self.merge_samples( + raw_samples, + selected_pid, + display_signal, + query.history_window_ms, + ); + + Ok(SimulatorPerformanceSnapshot { + udid: udid.to_owned(), + sampled_at, + selected_pid, + foreground_process: foreground, + processes, + current, + history, + warnings, + }) + } + + fn merge_samples( + &self, + raw_samples: Vec, + selected_pid: Option, + display_signal: DisplaySignal, + history_window_ms: u64, + ) -> (Option, Vec) { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut current = None; + let now = Instant::now(); + let now_ms = now_ms(); + + for raw in raw_samples { + let pid = raw.process.pid; + let counter = RawCounterSnapshot { + sampled_at: now, + timestamp_ms: now_ms, + cpu_time_ns: raw + .rusage + .map(|rusage| rusage.user_time_ns.saturating_add(rusage.system_time_ns)), + disk_read_bytes: raw.rusage.map(|rusage| rusage.disk_read_bytes), + disk_write_bytes: raw.rusage.map(|rusage| rusage.disk_write_bytes), + network_received_bytes: raw + .network + .as_ref() + .and_then(|network| network.received_bytes), + network_sent_bytes: raw.network.as_ref().and_then(|network| network.sent_bytes), + }; + let previous = inner.last_raw.insert(pid, counter.clone()); + let cpu_percent = cpu_percent(&raw, previous.as_ref(), &counter); + let disk_read_bytes_per_second = rate_per_second( + previous + .as_ref() + .and_then(|previous| previous.disk_read_bytes), + counter.disk_read_bytes, + previous.as_ref(), + &counter, + ); + let disk_write_bytes_per_second = rate_per_second( + previous + .as_ref() + .and_then(|previous| previous.disk_write_bytes), + counter.disk_write_bytes, + previous.as_ref(), + &counter, + ); + let network_received_bytes_per_second = rate_per_second( + previous + .as_ref() + .and_then(|previous| previous.network_received_bytes), + counter.network_received_bytes, + previous.as_ref(), + &counter, + ); + let network_sent_bytes_per_second = rate_per_second( + previous + .as_ref() + .and_then(|previous| previous.network_sent_bytes), + counter.network_sent_bytes, + previous.as_ref(), + &counter, + ); + let hang = if selected_pid == Some(pid) { + hang_status( + inner.hang.entry(pid).or_default(), + display_signal, + now_ms, + cpu_percent, + ) + } else { + HangStatus { + state: "not-selected".to_owned(), + stale_ms: None, + reason: "Hang signal is tracked for the selected app process.".to_owned(), + } + }; + let sample = PerformanceSample { + pid, + timestamp_ms: now_ms, + cpu_percent, + memory_resident_bytes: raw + .rusage + .map(|rusage| rusage.resident_size) + .or(raw.ps_memory_resident_bytes), + memory_footprint_bytes: raw.rusage.map(|rusage| rusage.phys_footprint), + memory_peak_footprint_bytes: raw.rusage.map(|rusage| { + rusage + .lifetime_max_phys_footprint + .max(rusage.phys_footprint) + }), + disk_read_bytes: raw.rusage.map(|rusage| rusage.disk_read_bytes), + disk_write_bytes: raw.rusage.map(|rusage| rusage.disk_write_bytes), + disk_read_bytes_per_second, + disk_write_bytes_per_second, + network_received_bytes: raw + .network + .as_ref() + .and_then(|network| network.received_bytes), + network_sent_bytes: raw.network.as_ref().and_then(|network| network.sent_bytes), + network_received_bytes_per_second, + network_sent_bytes_per_second, + network_connection_count: raw + .network + .as_ref() + .map(|network| network.connection_count), + network_established_connection_count: raw + .network + .as_ref() + .map(|network| network.established_connection_count), + network_endpoints: raw + .network + .map(|network| network.endpoints) + .unwrap_or_default(), + hang, + }; + + let history = inner.history.entry(pid).or_default(); + history.push_back(sample.clone()); + prune_history(history, now_ms); + if selected_pid == Some(pid) { + current = Some(sample); + } + } + + let history = selected_pid + .and_then(|pid| inner.history.get(&pid)) + .map(|history| { + let window_start = now_ms.saturating_sub(history_window_ms); + history + .iter() + .filter(|sample| sample.timestamp_ms >= window_start) + .cloned() + .collect::>() + }) + .unwrap_or_default(); + + (current, history) + } +} + +pub async fn sample_stack(pid: i32, seconds: u64) -> Result { + if pid <= 0 { + return Err(AppError::bad_request("Process id must be positive.")); + } + let seconds = seconds.clamp(1, 30); + let sampled_at = now_ms(); + let report_path = std::env::temp_dir().join(format!( + "simdeck-sample-{pid}-{}-{}.txt", + std::process::id(), + sampled_at + )); + let result = timeout( + Duration::from_secs(seconds + 20), + Command::new("sample") + .arg(pid.to_string()) + .arg(seconds.to_string()) + .arg("-file") + .arg(&report_path) + .output(), + ) + .await + .map_err(|_| AppError::native("Timed out sampling process stack."))? + .map_err(|error| AppError::native(format!("Unable to run sample: {error}")))?; + + let stderr = String::from_utf8_lossy(&result.stderr).trim().to_owned(); + let mut report = tokio::fs::read(&report_path).await.unwrap_or_default(); + let _ = tokio::fs::remove_file(&report_path).await; + let truncated = report.len() > STACK_SAMPLE_MAX_BYTES; + if truncated { + report.truncate(STACK_SAMPLE_MAX_BYTES); + } + + if !result.status.success() { + return Err(AppError::native(if stderr.is_empty() { + format!("sample exited with status {}", result.status) + } else { + stderr + })); + } + + Ok(StackSampleReport { + pid, + seconds, + sampled_at, + report: String::from_utf8_lossy(&report).into_owned(), + stderr, + truncated, + }) +} + +async fn sample_process( + process: PerformanceProcess, + include_network: bool, +) -> RawPerformanceSample { + let pid = process.pid; + let ps = ps_process_for_pid(pid).await; + let rusage = read_process_rusage(pid); + let network = if include_network { + sample_network(pid).await.ok() + } else { + None + }; + RawPerformanceSample { + ps_cpu_percent: ps.as_ref().map_or(0.0, |process| process.cpu_percent), + ps_memory_resident_bytes: ps + .as_ref() + .and_then(|process| process.rss_kb) + .map(|rss_kb| rss_kb.saturating_mul(1024)), + process, + rusage, + network, + } +} + +async fn list_ps_processes() -> Result, AppError> { + let output = timeout( + PROCESS_LIST_TIMEOUT, + Command::new("ps") + .args(["-axo", "pid=,ppid=,state=,%cpu=,rss=,command="]) + .output(), + ) + .await + .map_err(|_| AppError::native("Timed out listing host processes."))? + .map_err(|error| AppError::native(format!("Unable to list host processes: {error}")))?; + if !output.status.success() { + return Err(AppError::native("Unable to list host processes.")); + } + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(parse_ps_line) + .collect()) +} + +async fn ps_process_for_pid(pid: i32) -> Option { + let output = timeout( + PROCESS_SAMPLE_TIMEOUT, + Command::new("ps") + .args([ + "-p", + &pid.to_string(), + "-o", + "pid=,ppid=,state=,%cpu=,rss=,command=", + ]) + .output(), + ) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .lines() + .find_map(parse_ps_line) +} + +fn parse_ps_line(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let mut parts = trimmed.split_whitespace(); + let pid = parts.next()?.parse::().ok()?; + let parent_pid = parts.next()?.parse::().ok()?; + let state = parts.next()?.to_owned(); + let cpu_percent = parts.next()?.parse::().unwrap_or(0.0); + let rss_kb = parts.next()?.parse::().ok(); + let command = parts.collect::>().join(" "); + Some(PsProcess { + pid, + parent_pid, + state, + cpu_percent, + rss_kb, + command, + }) +} + +fn app_processes_from_ps( + udid: &str, + foreground: Option<&ForegroundProcess>, + processes: Vec, +) -> Vec { + let foreground_pid = foreground.map(|process| process.process_identifier as i32); + processes + .into_iter() + .filter(|process| { + process.command.contains(udid) + && (is_relevant_app_process(&process.command) + || foreground_pid == Some(process.pid)) + }) + .map(|process| performance_process(process, foreground)) + .collect() +} + +async fn ensure_foreground_process( + processes: &mut Vec, + foreground: &ForegroundProcess, +) { + let pid = foreground.process_identifier as i32; + if processes.iter().any(|process| process.pid == pid) { + return; + } + let Some(ps) = ps_process_for_pid(pid).await else { + return; + }; + processes.push(performance_process(ps, Some(foreground))); +} + +fn performance_process( + ps: PsProcess, + foreground: Option<&ForegroundProcess>, +) -> PerformanceProcess { + let app_path = app_bundle_path_from_command(&ps.command); + let metadata = app_path.as_deref().and_then(app_metadata); + let fallback_name = process_name_from_command(&ps.command); + let is_foreground = + foreground.is_some_and(|process| process.process_identifier as i32 == ps.pid); + PerformanceProcess { + pid: ps.pid, + parent_pid: ps.parent_pid, + process: metadata + .as_ref() + .and_then(|metadata| metadata.app_name.clone()) + .or_else(|| { + foreground + .filter(|foreground| foreground.process_identifier as i32 == ps.pid) + .and_then(|foreground| foreground.app_name.clone()) + }) + .unwrap_or(fallback_name), + role: process_role(&ps.command), + state: ps.state, + app_name: metadata + .as_ref() + .and_then(|metadata| metadata.app_name.clone()) + .or_else(|| { + foreground + .filter(|foreground| foreground.process_identifier as i32 == ps.pid) + .and_then(|foreground| foreground.app_name.clone()) + }), + bundle_identifier: metadata + .as_ref() + .and_then(|metadata| metadata.bundle_identifier.clone()) + .or_else(|| { + foreground + .filter(|foreground| foreground.process_identifier as i32 == ps.pid) + .and_then(|foreground| foreground.bundle_identifier.clone()) + }), + command: ps.command, + is_foreground, + } +} + +fn is_relevant_app_process(command: &str) -> bool { + command.contains(".app/") + || command.contains(".appex/") + || command.contains("WebContent") + || command.contains("UIKitApplication") +} + +fn process_role(command: &str) -> String { + if command.contains(".appex/") { + "extension".to_owned() + } else if command.contains("WebContent") { + "web-content".to_owned() + } else if command.contains(".app/") { + "app".to_owned() + } else { + "helper".to_owned() + } +} + +fn process_name_from_command(command: &str) -> String { + let executable = command + .split_whitespace() + .next() + .unwrap_or(command) + .trim_matches('"'); + Path::new(executable) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("unknown") + .to_owned() +} + +fn app_bundle_path_from_command(command: &str) -> Option { + let marker = if command.contains(".app/") { + ".app/" + } else { + ".appex/" + }; + let end = command.find(marker)? + marker.trim_end_matches('/').len(); + let start = command[..end].rfind(' ').map_or(0, |index| index + 1); + Some(command[start..end].trim_matches('"').to_owned()) +} + +struct AppMetadata { + app_name: Option, + bundle_identifier: Option, +} + +fn app_metadata(app_path: &str) -> Option { + let plist = plist::Value::from_file(Path::new(app_path).join("Info.plist")).ok()?; + let dictionary = plist.as_dictionary()?; + let app_name = string_plist_value(dictionary.get("CFBundleDisplayName")) + .or_else(|| string_plist_value(dictionary.get("CFBundleName"))) + .or_else(|| { + Path::new(app_path) + .file_stem() + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned) + }); + let bundle_identifier = string_plist_value(dictionary.get("CFBundleIdentifier")); + Some(AppMetadata { + app_name, + bundle_identifier, + }) +} + +fn string_plist_value(value: Option<&plist::Value>) -> Option { + value + .and_then(plist::Value::as_string) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn cpu_percent( + raw: &RawPerformanceSample, + previous: Option<&RawCounterSnapshot>, + current: &RawCounterSnapshot, +) -> f64 { + let Some(previous) = previous else { + return raw.ps_cpu_percent.max(0.0); + }; + let (Some(previous_cpu), Some(current_cpu)) = (previous.cpu_time_ns, current.cpu_time_ns) + else { + return raw.ps_cpu_percent.max(0.0); + }; + let wall_ns = current + .sampled_at + .saturating_duration_since(previous.sampled_at) + .as_nanos() as f64; + if wall_ns <= 0.0 { + return raw.ps_cpu_percent.max(0.0); + } + let cpu_ns = current_cpu.saturating_sub(previous_cpu) as f64; + round_one_decimal((cpu_ns / wall_ns) * 100.0) +} + +fn rate_per_second( + previous_value: Option, + current_value: Option, + previous: Option<&RawCounterSnapshot>, + current: &RawCounterSnapshot, +) -> Option { + let previous = previous?; + let previous_value = previous_value?; + let current_value = current_value?; + let elapsed = current + .timestamp_ms + .saturating_sub(previous.timestamp_ms) + .max(1) as f64 + / 1000.0; + Some((current_value.saturating_sub(previous_value) as f64) / elapsed) +} + +fn hang_status( + tracker: &mut HangTracker, + display_signal: DisplaySignal, + now_ms: u64, + cpu_percent: f64, +) -> HangStatus { + if display_signal.frame_sequence == 0 { + return HangStatus { + state: "unknown".to_owned(), + stale_ms: None, + reason: "No display frame signal is available yet.".to_owned(), + }; + } + + if tracker.last_frame_sequence != display_signal.frame_sequence { + tracker.last_frame_sequence = display_signal.frame_sequence; + tracker.last_frame_change_ms = now_ms; + } + if tracker.last_frame_change_ms == 0 { + tracker.last_frame_change_ms = display_signal.last_frame_at_ms.max(now_ms); + } + + let stale_ms = now_ms.saturating_sub(tracker.last_frame_change_ms); + if stale_ms >= 5_000 && cpu_percent >= 30.0 { + HangStatus { + state: "busy".to_owned(), + stale_ms: Some(stale_ms), + reason: "Display frames have not changed while the process is using CPU.".to_owned(), + } + } else if stale_ms >= 8_000 && cpu_percent < 5.0 { + HangStatus { + state: "quiet".to_owned(), + stale_ms: Some(stale_ms), + reason: "Display frames have not changed and the process is mostly idle.".to_owned(), + } + } else { + HangStatus { + state: "responsive".to_owned(), + stale_ms: Some(stale_ms), + reason: "Display frame progress is recent.".to_owned(), + } + } +} + +fn prune_history(history: &mut VecDeque, now_ms: u64) { + let retention_start = now_ms.saturating_sub(HISTORY_RETENTION_MS); + while history + .front() + .is_some_and(|sample| sample.timestamp_ms < retention_start) + { + history.pop_front(); + } + while history.len() > HISTORY_MAX_SAMPLES { + history.pop_front(); + } +} + +async fn sample_network(pid: i32) -> Result { + let (received_bytes, sent_bytes) = sample_network_totals(pid).await.unwrap_or((None, None)); + let output = timeout( + NETWORK_SAMPLE_TIMEOUT, + Command::new("lsof") + .args(["-nP", "-a", "-p", &pid.to_string(), "-iTCP", "-iUDP"]) + .output(), + ) + .await + .map_err(|_| AppError::native("Timed out reading process network connections."))? + .map_err(|error| { + AppError::native(format!( + "Unable to read process network connections: {error}" + )) + })?; + if !output.status.success() { + return Ok(NetworkSnapshot { + connection_count: 0, + established_connection_count: 0, + received_bytes, + sent_bytes, + endpoints: Vec::new(), + }); + } + let mut connection_count = 0; + let mut established_connection_count = 0; + let mut endpoints = Vec::new(); + for line in String::from_utf8_lossy(&output.stdout).lines().skip(1) { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + connection_count += 1; + if trimmed.contains("ESTABLISHED") { + established_connection_count += 1; + } + if endpoints.len() < 8 { + endpoints.push(compact_lsof_endpoint(trimmed)); + } + } + Ok(NetworkSnapshot { + connection_count, + established_connection_count, + received_bytes, + sent_bytes, + endpoints, + }) +} + +async fn sample_network_totals(pid: i32) -> Result<(Option, Option), AppError> { + let output = timeout( + NETWORK_TOTALS_TIMEOUT, + Command::new("nettop") + .args([ + "-P", + "-L", + "1", + "-x", + "-J", + "bytes_in,bytes_out", + "-p", + &pid.to_string(), + ]) + .output(), + ) + .await + .map_err(|_| AppError::native("Timed out reading process network totals."))? + .map_err(|error| AppError::native(format!("Unable to read process network totals: {error}")))?; + + if !output.status.success() { + return Ok((None, None)); + } + + Ok(parse_nettop_network_totals(&String::from_utf8_lossy( + &output.stdout, + ))) +} + +fn parse_nettop_network_totals(output: &str) -> (Option, Option) { + let mut lines = output.lines().filter(|line| !line.trim().is_empty()); + let Some(header) = lines.next() else { + return (None, None); + }; + let columns = split_nettop_csv_line(header); + let Some(received_index) = columns.iter().position(|column| column == "bytes_in") else { + return (None, None); + }; + let Some(sent_index) = columns.iter().position(|column| column == "bytes_out") else { + return (None, None); + }; + + let mut received_total = 0_u64; + let mut sent_total = 0_u64; + let mut saw_received = false; + let mut saw_sent = false; + + for line in lines { + let fields = split_nettop_csv_line(line); + if let Some(value) = fields + .get(received_index) + .and_then(|field| parse_nettop_byte_value(field)) + { + received_total = received_total.saturating_add(value); + saw_received = true; + } + if let Some(value) = fields + .get(sent_index) + .and_then(|field| parse_nettop_byte_value(field)) + { + sent_total = sent_total.saturating_add(value); + saw_sent = true; + } + } + + ( + saw_received.then_some(received_total), + saw_sent.then_some(sent_total), + ) +} + +fn split_nettop_csv_line(line: &str) -> Vec { + line.split(',') + .map(|field| field.trim().trim_matches('"').to_owned()) + .collect() +} + +fn parse_nettop_byte_value(value: &str) -> Option { + let value = value.trim().trim_matches('"').replace('_', ""); + if value.is_empty() || value == "-" { + return None; + } + if let Ok(bytes) = value.parse::() { + return Some(bytes); + } + + let number_end = value + .char_indices() + .find_map(|(index, character)| { + (!character.is_ascii_digit() && character != '.').then_some(index) + }) + .unwrap_or(value.len()); + if number_end == 0 { + return None; + } + + let number = value[..number_end].parse::().ok()?; + if !number.is_finite() || number < 0.0 { + return None; + } + let suffix = value[number_end..] + .trim() + .trim_end_matches("/s") + .to_ascii_lowercase(); + let multiplier = match suffix.as_str() { + "" | "b" | "byte" | "bytes" => 1.0, + "k" | "kb" | "kib" => 1024.0, + "m" | "mb" | "mib" => 1024.0 * 1024.0, + "g" | "gb" | "gib" => 1024.0 * 1024.0 * 1024.0, + "t" | "tb" | "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0, + _ => return None, + }; + Some((number * multiplier).round() as u64) +} + +fn compact_lsof_endpoint(line: &str) -> String { + line.split_whitespace() + .skip_while(|part| *part != "TCP" && *part != "UDP") + .collect::>() + .join(" ") +} + +fn round_one_decimal(value: f64) -> f64 { + (value * 10.0).round() / 10.0 +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +#[cfg(target_os = "macos")] +fn read_process_rusage(pid: i32) -> Option { + let mut info = RUsageInfoV6::default(); + let result = unsafe { + proc_pid_rusage( + pid, + RUSAGE_INFO_V6, + &mut info as *mut RUsageInfoV6 as *mut c_void, + ) + }; + (result == 0).then_some(ProcessRUsage { + user_time_ns: info.ri_user_time, + system_time_ns: info.ri_system_time, + resident_size: info.ri_resident_size, + phys_footprint: info.ri_phys_footprint, + lifetime_max_phys_footprint: info.ri_lifetime_max_phys_footprint, + disk_read_bytes: info.ri_diskio_bytesread, + disk_write_bytes: info.ri_diskio_byteswritten, + }) +} + +#[cfg(not(target_os = "macos"))] +fn read_process_rusage(_pid: i32) -> Option { + None +} + +#[cfg(target_os = "macos")] +const RUSAGE_INFO_V6: i32 = 6; + +#[cfg(target_os = "macos")] +#[repr(C)] +#[derive(Default)] +struct RUsageInfoV6 { + ri_uuid: [u8; 16], + ri_user_time: u64, + ri_system_time: u64, + ri_pkg_idle_wkups: u64, + ri_interrupt_wkups: u64, + ri_pageins: u64, + ri_wired_size: u64, + ri_resident_size: u64, + ri_phys_footprint: u64, + ri_proc_start_abstime: u64, + ri_proc_exit_abstime: u64, + ri_child_user_time: u64, + ri_child_system_time: u64, + ri_child_pkg_idle_wkups: u64, + ri_child_interrupt_wkups: u64, + ri_child_pageins: u64, + ri_child_elapsed_abstime: u64, + ri_diskio_bytesread: u64, + ri_diskio_byteswritten: u64, + ri_cpu_time_qos_default: u64, + ri_cpu_time_qos_maintenance: u64, + ri_cpu_time_qos_background: u64, + ri_cpu_time_qos_utility: u64, + ri_cpu_time_qos_legacy: u64, + ri_cpu_time_qos_user_initiated: u64, + ri_cpu_time_qos_user_interactive: u64, + ri_billed_system_time: u64, + ri_serviced_system_time: u64, + ri_logical_writes: u64, + ri_lifetime_max_phys_footprint: u64, + ri_instructions: u64, + ri_cycles: u64, + ri_billed_energy: u64, + ri_serviced_energy: u64, + ri_interval_max_phys_footprint: u64, + ri_runnable_time: u64, + ri_flags: u64, + ri_user_ptime: u64, + ri_system_ptime: u64, + ri_pinstructions: u64, + ri_pcycles: u64, + ri_energy_nj: u64, + ri_penergy_nj: u64, + ri_secure_time_in_system: u64, + ri_secure_ptime_in_system: u64, + ri_neural_footprint: u64, + ri_lifetime_max_neural_footprint: u64, + ri_interval_max_neural_footprint: u64, + ri_reserved: [u64; 9], +} + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut c_void) -> i32; +} + +#[cfg(test)] +mod tests { + use super::{app_bundle_path_from_command, parse_nettop_network_totals, parse_ps_line}; + + #[test] + fn parse_ps_line_keeps_command_tail() { + let row = parse_ps_line("123 1 S 4.2 8192 /tmp/My App.app/My App --flag value").unwrap(); + assert_eq!(row.pid, 123); + assert_eq!(row.parent_pid, 1); + assert_eq!(row.cpu_percent, 4.2); + assert_eq!(row.rss_kb, Some(8192)); + assert_eq!(row.command, "/tmp/My App.app/My App --flag value"); + } + + #[test] + fn app_bundle_path_from_command_extracts_app_bundle() { + assert_eq!( + app_bundle_path_from_command("/tmp/Fixture.app/Fixture --args"), + Some("/tmp/Fixture.app".to_owned()) + ); + } + + #[test] + fn parse_nettop_network_totals_reads_process_csv() { + let output = ",bytes_in,bytes_out,\nFixture.123,100,240,\nHelper.456,2 KB,1.5 KB,\n"; + assert_eq!( + parse_nettop_network_totals(output), + (Some(2148), Some(1776)) + ); + } + + #[test] + fn parse_nettop_network_totals_reads_extended_csv() { + let output = "time,,interface,state,bytes_in,bytes_out,\n12:00:00,Fixture.123,,,10,20,\n"; + assert_eq!(parse_nettop_network_totals(output), (Some(10), Some(20))); + } +} diff --git a/skills/simdeck/SKILL.md b/skills/simdeck/SKILL.md index 877a3b19..4ee963e2 100644 --- a/skills/simdeck/SKILL.md +++ b/skills/simdeck/SKILL.md @@ -206,9 +206,13 @@ simdeck screenshot --output screen.png simdeck screenshot --stdout > screen.png simdeck logs --seconds 30 --limit 200 simdeck chrome-profile +simdeck processes +simdeck stats +simdeck stats --watch +simdeck sample --seconds 3 ``` -Use screenshots for still evidence. Prefer describe for token-efficient state dumps, if they have enough context. +Use screenshots for still evidence. Use `stats` for simulator app CPU, memory, disk write, network receive/send rates, connections, hang, and crash/termination signals. Use `sample` only when a short CPU stack capture is worth the extra pause. Prefer describe for token-efficient state dumps, if they have enough context. ## Default Loop From 642d2b88a99199eef1d3fa71781b30ccd7b93f05 Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Wed, 13 May 2026 10:33:05 -0400 Subject: [PATCH 2/3] fix: follow foreground simulator in performance panel --- .../accessibility/PerformancePanel.tsx | 19 +- server/src/api/routes.rs | 225 ++++++++++++++++-- server/src/performance.rs | 18 +- 3 files changed, 241 insertions(+), 21 deletions(-) diff --git a/client/src/features/accessibility/PerformancePanel.tsx b/client/src/features/accessibility/PerformancePanel.tsx index 4525ea3d..eb7a2bbc 100644 --- a/client/src/features/accessibility/PerformancePanel.tsx +++ b/client/src/features/accessibility/PerformancePanel.tsx @@ -27,6 +27,7 @@ export function PerformancePanel({ }: PerformancePanelProps) { const udid = selectedSimulator?.udid ?? ""; const [selectedPid, setSelectedPid] = useState(null); + const [followForeground, setFollowForeground] = useState(true); const [performance, setPerformance] = useState(null); const [error, setError] = useState(""); @@ -35,6 +36,7 @@ export function PerformancePanel({ useEffect(() => { setSelectedPid(null); + setFollowForeground(true); setPerformance(null); setSample(null); setError(""); @@ -50,7 +52,7 @@ export function PerformancePanel({ async function refresh() { try { const next = await fetchSimulatorPerformance(udid, { - pid: selectedPid, + pid: followForeground ? null : selectedPid, windowMs: PERFORMANCE_WINDOW_MS, }); if (cancelled) { @@ -58,7 +60,15 @@ export function PerformancePanel({ } setPerformance(next); setError(""); - if (selectedPid == null && next.selectedPid != null) { + if (followForeground) { + setSelectedPid(next.selectedPid ?? null); + } else if ( + selectedPid != null && + !next.processes.some((process) => process.pid === selectedPid) + ) { + setFollowForeground(true); + setSelectedPid(next.selectedPid ?? null); + } else if (selectedPid == null && next.selectedPid != null) { setSelectedPid(next.selectedPid); } } catch (refreshError) { @@ -79,7 +89,7 @@ export function PerformancePanel({ window.clearTimeout(timer); } }; - }, [selectedPid, selectedSimulator?.isBooted, udid, visible]); + }, [followForeground, selectedPid, selectedSimulator?.isBooted, udid, visible]); const current = performance?.current ?? null; const selectedProcess = useMemo( @@ -124,6 +134,7 @@ export function PerformancePanel({ key={process.pid} onSelect={() => { setSelectedPid(process.pid); + setFollowForeground(process.isForeground); setSample(null); }} process={process} @@ -427,7 +438,7 @@ function formatBytes(value: number | null | undefined): string { next /= 1024; unit += 1; } - return `${unit === 0 ? next : Math.round(next * 10) / 10} ${units[unit]}`; + return `${unit === 0 ? Math.round(next) : Math.round(next * 10) / 10} ${units[unit]}`; } function formatRate(value: number | null | undefined): string { diff --git a/server/src/api/routes.rs b/server/src/api/routes.rs index 5f4a5ca6..14859c3b 100644 --- a/server/src/api/routes.rs +++ b/server/src/api/routes.rs @@ -1614,15 +1614,15 @@ async fn sample_process_stack( } async fn performance_foreground_process(state: &AppState, udid: &str) -> Option { - foreground_app_metadata(state, udid) - .await - .ok() - .flatten() - .map(|foreground| ForegroundProcess { - process_identifier: foreground.process_identifier, - bundle_identifier: foreground.bundle_identifier, - app_name: foreground.app_name, - }) + let foreground = match foreground_app_metadata(state, udid).await { + Ok(Some(foreground)) => Some(foreground), + _ => foreground_app_from_launchctl(udid).await.ok().flatten(), + }; + foreground.map(|foreground| ForegroundProcess { + process_identifier: foreground.process_identifier, + bundle_identifier: foreground.bundle_identifier, + app_name: foreground.app_name, + }) } async fn simulator_display_signal(state: AppState, udid: &str) -> DisplaySignal { @@ -4908,6 +4908,164 @@ async fn foreground_app_metadata( })) } +#[derive(Clone, Debug)] +struct UIKitApplicationService { + pid: i64, + service_name: String, +} + +#[derive(Clone, Debug)] +struct UIKitApplicationServiceDetails { + active_count: u64, + app_name: Option, + bundle_identifier: Option, + process_identifier: i64, + spawn_role: String, +} + +async fn foreground_app_from_launchctl( + udid: &str, +) -> Result, String> { + let services = simulator_ui_application_services(udid).await?; + let mut best: Option = None; + for service in services { + let Some(details) = ui_application_service_details(udid, &service).await? else { + continue; + }; + let details_score = ui_application_foreground_score(&details); + let best_score = best + .as_ref() + .map(ui_application_foreground_score) + .unwrap_or((0, 0)); + if details_score > best_score { + best = Some(details); + } + } + + Ok(best.map(|details| devtools::ForegroundApp { + process_identifier: details.process_identifier, + bundle_identifier: details.bundle_identifier, + app_name: details.app_name, + })) +} + +async fn simulator_ui_application_services( + udid: &str, +) -> Result, String> { + let output = timeout( + Duration::from_secs(2), + Command::new("xcrun") + .args(["simctl", "spawn", udid, "launchctl", "print", "user/501"]) + .output(), + ) + .await + .map_err(|_| "Timed out listing simulator UIKit applications.".to_owned())? + .map_err(|error| format!("Unable to list simulator UIKit applications: {error}"))?; + if !output.status.success() { + return Ok(Vec::new()); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(parse_ui_application_service_line) + .collect()) +} + +fn parse_ui_application_service_line(line: &str) -> Option { + let trimmed = line.trim(); + if !trimmed.contains("UIKitApplication:") { + return None; + } + let mut parts = trimmed.split_whitespace(); + let pid = parts.next()?.parse::().ok()?; + let separator = parts.next()?; + let service_name = parts.next()?.to_owned(); + if separator != "-" || pid <= 0 || !service_name.starts_with("UIKitApplication:") { + return None; + } + Some(UIKitApplicationService { pid, service_name }) +} + +async fn ui_application_service_details( + udid: &str, + service: &UIKitApplicationService, +) -> Result, String> { + let output = timeout( + Duration::from_secs(1), + Command::new("xcrun") + .args([ + "simctl", + "spawn", + udid, + "launchctl", + "print", + &format!("user/501/{}", service.service_name), + ]) + .output(), + ) + .await + .map_err(|_| "Timed out reading simulator UIKit application state.".to_owned())? + .map_err(|error| format!("Unable to read simulator UIKit application state: {error}"))?; + if !output.status.success() { + return Ok(None); + } + + let text = String::from_utf8_lossy(&output.stdout); + let active_count = launchctl_numeric_value(&text, "active count").unwrap_or(0); + let process_identifier = launchctl_numeric_value(&text, "pid") + .map(|pid| pid as i64) + .unwrap_or(service.pid); + if process_identifier <= 0 + || !launchctl_value(&text, "state").is_some_and(|value| value == "running") + { + return Ok(None); + } + let spawn_role = launchctl_value(&text, "spawn role").unwrap_or_default(); + let program = launchctl_value(&text, "program"); + let bundle_identifier = launchctl_value(&text, "bundle id"); + let app_name = program + .as_deref() + .and_then(app_bundle_path_from_command) + .and_then(|path| { + std::path::Path::new(&path) + .file_stem() + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned) + }) + .or_else(|| bundle_identifier.clone()); + + Ok(Some(UIKitApplicationServiceDetails { + active_count, + app_name, + bundle_identifier, + process_identifier, + spawn_role, + })) +} + +fn ui_application_foreground_score(details: &UIKitApplicationServiceDetails) -> (u8, u64) { + let role_score = if details.spawn_role.contains("ui focal") { + 2 + } else if details.spawn_role.contains("ui") { + 1 + } else { + 0 + }; + (role_score, details.active_count) +} + +fn launchctl_value(output: &str, key: &str) -> Option { + let prefix = format!("{key} = "); + output.lines().find_map(|line| { + let value = line.trim().strip_prefix(&prefix)?.trim(); + (!value.is_empty()).then_some(value.to_owned()) + }) +} + +fn launchctl_numeric_value(output: &str, key: &str) -> Option { + launchctl_value(output, key)?.parse::().ok() +} + async fn process_command(pid: i64) -> Result { let output = timeout( Duration::from_secs(1), @@ -4929,9 +5087,10 @@ async fn process_command(pid: i64) -> Result { } fn app_bundle_path_from_command(command: &str) -> Option { + let command = command.trim(); let app_marker = ".app/"; let end = command.find(app_marker)? + ".app".len(); - let start = command[..end].rfind(' ').map_or(0, |index| index + 1); + let start = command[..end].find('/').unwrap_or(0); Some(command[start..end].to_owned()) } @@ -5879,11 +6038,13 @@ mod tests { inspector_session_from_published, inspector_session_score, is_inspector_agent_transport_path, normalize_inspector_node, normalize_screen_point_from_snapshot, normalized_gesture_coordinates, - parse_lsof_tcp_listener, resolved_stream_quality_limits, split_filter_values, - stream_quality_profile, suppress_native_ax_translation_error, tap_point_from_snapshot, - trim_tree_depth, AccessibilityHierarchySource, ElementSelectorPayload, InspectorSession, - InspectorSessionTransport, StreamQualityLimits, StreamQualityPayload, SOURCE_FLUTTER, - SOURCE_NATIVE_AX, SOURCE_NATIVE_SCRIPT, SOURCE_REACT_NATIVE, SOURCE_SWIFTUI, SOURCE_UIKIT, + parse_lsof_tcp_listener, parse_ui_application_service_line, resolved_stream_quality_limits, + split_filter_values, stream_quality_profile, suppress_native_ax_translation_error, + tap_point_from_snapshot, trim_tree_depth, ui_application_foreground_score, + AccessibilityHierarchySource, ElementSelectorPayload, InspectorSession, + InspectorSessionTransport, StreamQualityLimits, StreamQualityPayload, + UIKitApplicationServiceDetails, SOURCE_FLUTTER, SOURCE_NATIVE_AX, SOURCE_NATIVE_SCRIPT, + SOURCE_REACT_NATIVE, SOURCE_SWIFTUI, SOURCE_UIKIT, }; use crate::inspector::PublishedInspector; use crate::transport::packet::FramePacket; @@ -6190,6 +6351,40 @@ mod tests { ); } + #[test] + fn parse_ui_application_service_line_extracts_pid_and_service() { + let service = parse_ui_application_service_line( + " 41210 - \tUIKitApplication:com.apple.mobilesafari[2777][rb-legacy]", + ) + .unwrap(); + assert_eq!(service.pid, 41210); + assert_eq!( + service.service_name, + "UIKitApplication:com.apple.mobilesafari[2777][rb-legacy]" + ); + } + + #[test] + fn ui_application_foreground_score_prefers_focal_then_active_count() { + let focal = UIKitApplicationServiceDetails { + active_count: 1, + app_name: None, + bundle_identifier: None, + process_identifier: 1, + spawn_role: "ui focal (1)".to_owned(), + }; + let background = UIKitApplicationServiceDetails { + active_count: 10, + app_name: None, + bundle_identifier: None, + process_identifier: 2, + spawn_role: "non-ui (3)".to_owned(), + }; + assert!( + ui_application_foreground_score(&focal) > ui_application_foreground_score(&background) + ); + } + #[test] fn normalize_inspector_node_maps_runtime_metadata_to_accessibility_fields() { let normalized = normalize_inspector_node( diff --git a/server/src/performance.rs b/server/src/performance.rs index ec361d0e..088143b2 100644 --- a/server/src/performance.rs +++ b/server/src/performance.rs @@ -13,7 +13,7 @@ const HISTORY_MAX_SAMPLES: usize = 720; const PROCESS_LIST_TIMEOUT: Duration = Duration::from_secs(2); const PROCESS_SAMPLE_TIMEOUT: Duration = Duration::from_secs(2); const NETWORK_SAMPLE_TIMEOUT: Duration = Duration::from_millis(650); -const NETWORK_TOTALS_TIMEOUT: Duration = Duration::from_millis(1_800); +const NETWORK_TOTALS_TIMEOUT: Duration = Duration::from_secs(6); const STACK_SAMPLE_MAX_BYTES: usize = 256 * 1024; #[derive(Clone, Default)] @@ -646,8 +646,9 @@ fn app_bundle_path_from_command(command: &str) -> Option { } else { ".appex/" }; + let command = command.trim(); let end = command.find(marker)? + marker.trim_end_matches('/').len(); - let start = command[..end].rfind(' ').map_or(0, |index| index + 1); + let start = command[..end].find('/').unwrap_or(0); Some(command[start..end].trim_matches('"').to_owned()) } @@ -1071,6 +1072,19 @@ mod tests { ); } + #[test] + fn app_bundle_path_from_command_keeps_spaces_in_bundle_path() { + assert_eq!( + app_bundle_path_from_command( + "/Library/Developer/CoreSimulator/Volumes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/Applications/MobileSafari.app/MobileSafari" + ), + Some( + "/Library/Developer/CoreSimulator/Volumes/iOS 26.4.simruntime/Contents/Resources/RuntimeRoot/Applications/MobileSafari.app" + .to_owned() + ) + ); + } + #[test] fn parse_nettop_network_totals_reads_process_csv() { let output = ",bytes_in,bytes_out,\nFixture.123,100,240,\nHelper.456,2 KB,1.5 KB,\n"; From faf4770e5c885caf3271c726e8356cfbd2a7861f Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Wed, 13 May 2026 13:05:39 -0400 Subject: [PATCH 3/3] chore: update commit guidance --- .../features/accessibility/AccessibilityInspector.tsx | 4 ++-- client/src/features/accessibility/PerformancePanel.tsx | 8 +++++++- docs/api/rest.md | 10 +++++----- server/src/api/routes.rs | 2 +- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/client/src/features/accessibility/AccessibilityInspector.tsx b/client/src/features/accessibility/AccessibilityInspector.tsx index 9205b499..54fa8561 100644 --- a/client/src/features/accessibility/AccessibilityInspector.tsx +++ b/client/src/features/accessibility/AccessibilityInspector.tsx @@ -551,8 +551,8 @@ function NodeDetails({ function isAndroidSimulator(simulator: SimulatorMetadata | null): boolean { return Boolean( simulator?.platform === "android-emulator" || - simulator?.deviceTypeIdentifier === "android-emulator" || - simulator?.udid.startsWith("android:"), + simulator?.deviceTypeIdentifier === "android-emulator" || + simulator?.udid.startsWith("android:"), ); } diff --git a/client/src/features/accessibility/PerformancePanel.tsx b/client/src/features/accessibility/PerformancePanel.tsx index eb7a2bbc..c782029d 100644 --- a/client/src/features/accessibility/PerformancePanel.tsx +++ b/client/src/features/accessibility/PerformancePanel.tsx @@ -89,7 +89,13 @@ export function PerformancePanel({ window.clearTimeout(timer); } }; - }, [followForeground, selectedPid, selectedSimulator?.isBooted, udid, visible]); + }, [ + followForeground, + selectedPid, + selectedSimulator?.isBooted, + udid, + visible, + ]); const current = performance?.current ?? null; const selectedProcess = useMemo( diff --git a/docs/api/rest.md b/docs/api/rest.md index aae6daee..64dde52d 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -73,12 +73,12 @@ Device IDs come from `/api/simulators`. Android IDs use the `android:` prefix. iOS simulator app processes run as host macOS processes. These endpoints expose host-process telemetry for matching simulator app PIDs. -| Method | Path | Purpose | -| ------ | ---------------------------------------------------- | --------------------------------------------------- | -| `GET` | `/api/simulators/{udid}/processes` | List app, extension, helper, and web-content PIDs | +| Method | Path | Purpose | +| ------ | ---------------------------------------------------- | ----------------------------------------------------------- | +| `GET` | `/api/simulators/{udid}/processes` | List app, extension, helper, and web-content PIDs | | `GET` | `/api/simulators/{udid}/performance` | Current sample plus rolling CPU/memory/disk/network history | -| `GET` | `/api/simulators/{udid}/processes/{pid}/performance` | Performance data for one simulator app process | -| `POST` | `/api/simulators/{udid}/processes/{pid}/sample` | Capture a short CPU stack sample with `sample` | +| `GET` | `/api/simulators/{udid}/processes/{pid}/performance` | Performance data for one simulator app process | +| `POST` | `/api/simulators/{udid}/processes/{pid}/sample` | Capture a short CPU stack sample with `sample` | Performance query parameters: diff --git a/server/src/api/routes.rs b/server/src/api/routes.rs index 14859c3b..e0979941 100644 --- a/server/src/api/routes.rs +++ b/server/src/api/routes.rs @@ -5016,7 +5016,7 @@ async fn ui_application_service_details( .map(|pid| pid as i64) .unwrap_or(service.pid); if process_identifier <= 0 - || !launchctl_value(&text, "state").is_some_and(|value| value == "running") + || launchctl_value(&text, "state").is_none_or(|value| value != "running") { return Ok(None); }