@omercnet/paseo-top 0.0.1-next.250.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/OVERVIEW.md CHANGED
@@ -4,12 +4,12 @@ Usage always describes one host. With several hosts connected, the footer follow
4
4
 
5
5
  **Load by session** adds up the CPU and memory of everything an agent started, not only the agent process. A build, test run or dev server launched by a tool counts toward the agent that launched it. Each row shows the agent and workspace names you gave them, the busiest command in its tree, cores and memory in use, and opens the agent when pressed. A session is flagged with a flame, and counted in the footer, when it holds one busy core for 30 seconds or a quarter of the host's memory. Everything not listed is shown on one "Everything else" line.
6
6
 
7
- How processes are matched to agents: Paseo gives every agent session an identifier in its environment, and everything the session starts inherits it. On every scan Top reads each process's environment into memory, keeps only that identifier and drops the rest at once; nothing else from it is stored, logged or sent anywhere. A process whose environment cannot be read, such as one owned by another user, cannot be tagged. A process with no identifier is charged to the nearest ancestor that has one, since tools often clear it. Only agents that belong to the host you are viewing are listed; processes of agents that belong to other daemons on the same machine are counted under "Everything else", together with untagged processes and sessions beyond the first ten. Per-session load needs Linux. The scan covers every process `/proc` lists, so other users' processes are counted too, under "Everything else". Containers run outside the agent's process tree and show as unattributed.
7
+ How processes are matched to agents: Paseo gives every agent session an identifier in its environment, and everything the session starts inherits it. On every scan Top reads each process's environment into memory, keeps only that identifier and drops the rest at once; nothing else from it is stored, logged or sent anywhere. A process whose environment cannot be read, such as one owned by another user, cannot be tagged. A process with no identifier is charged to the nearest ancestor that has one, since tools often clear it. Only agents that belong to the host you are viewing are listed; processes of agents that belong to other daemons on the same machine are counted under "Everything else", together with untagged processes and sessions beyond the first ten. Per-session load works on Linux, macOS and Windows. The scan covers every process the host lists, so other users' processes are counted too, under "Everything else". Containers, virtual machines and WSL run outside the agent's process tree and show as unattributed. On macOS and Windows the environment of another user's processes cannot be read, so they are never matched to an agent. Memory is the resident size on macOS and the working set on Windows, which includes pages shared with other processes (every DLL counts in every process), so a Windows session with many processes reads higher than what it really uses. If the process list cannot be read, the list says it is not available instead of showing an empty one, and the scan is retried more and more slowly.
8
8
 
9
9
  What the numbers are and are not: load is sampled every ten seconds, so a process that starts and exits between two scans is never seen, and one that is still running is charged for the CPU it used since it started. A session is flagged only when it has held a full core across three scans in a row, so a single burst does not flag it. Memory is each process's resident memory added up, so memory shared between processes of one agent is counted more than once, and the quarter-of-memory rule compares against the machine's memory, not a container's limit. Sessions plus "Everything else" cover the processes that were scanned, not the whole host: kernel time, finished processes and the page cache are outside them.
10
10
 
11
11
  CPU is the share of non-idle time over the last couple of seconds. Memory excludes reclaimable cache on Linux. Disk is the filesystem that holds the daemon user's home directory. Disk I/O is bytes read plus written per second across the physical disks, on Linux. History is kept by the daemon in memory, so it survives a page refresh and is shared by every client, but starts over when the plugin or daemon restarts. A reading the host cannot give shows `--`; if the plugin cannot be reached, the footer says unavailable.
12
12
 
13
- The plugin only reads usage counters and process tables. It has no settings and runs no commands.
13
+ The plugin only reads usage counters and process tables. It has no settings. On Linux it reads `/proc`. On macOS it runs `ps` twice every ten seconds, and on Windows it runs one PowerShell command, to list processes and read the Paseo identifier from each one's environment; it changes nothing and runs nothing else. On Windows that command declares native functions and reads other processes' memory, which endpoint-security tools on managed machines may flag. Set `PASEO_TOP_SESSIONS=off` in the daemon's environment to list no processes on any platform; the rest of Top is unaffected.
14
14
 
15
15
  Requires Paseo ^0.11.0.
package/README.md CHANGED
@@ -25,8 +25,9 @@ Verify against an isolated daemon, never the default one:
25
25
 
26
26
  - `shared/usage.ts`: the `top.usage` RPC contract and history constants.
27
27
  - `server/usage.ts`: CPU, memory, disk and I/O readers, and the sampler with its history ring.
28
- - `server/processes.ts`: `/proc` scan and attribution of processes to agent sessions.
28
+ - `server/processes.ts`: `/proc` scan, attribution of processes to agent sessions, and the session tracker.
29
+ - `server/platform.ts`: the macOS (`ps`) and Windows (PowerShell, PEB environment read) process listers.
29
30
  - `server/names.ts`: joins agent and workspace names onto sessions.
30
31
  - `index.server.ts`: wires the sampler to the RPC.
31
32
  - `client/`, `index.client.tsx`: footer row, Top screen, dials and session list.
32
- - `tests/`: unit tests; process tests run against a fake `/proc` tree.
33
+ - `tests/`: unit tests; Linux process tests run against a fake `/proc` tree. `npm run test:platform` also runs on macOS and Windows runners in CI, including a live process tree.
@@ -28,7 +28,7 @@ export function Sessions({ theme, sessions, other, onOpenAgent }: SessionsProps)
28
28
  if (sessions === null) {
29
29
  rows.push(
30
30
  <Text key="unsupported" style={muted}>
31
- Per-session load needs a Linux host.
31
+ Per-session load is not available on this host.
32
32
  </Text>,
33
33
  );
34
34
  } else if (sessions.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-top",
3
- "version": "0.0.1-next.250.1",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "description": "A Paseo plugin that shows host CPU, memory and disk usage in the sidebar footer.",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "check": "biome check .",
43
43
  "check:write": "biome check --write .",
44
44
  "test": "vitest run",
45
+ "test:platform": "vitest run",
45
46
  "typecheck": "tsc --noEmit"
46
47
  },
47
48
  "devDependencies": {
@@ -0,0 +1,249 @@
1
+ import { execFile } from "node:child_process";
2
+ import { type Listing, type ProcInfo, readProcs, type Scan } from "./processes";
3
+
4
+ const TIMEOUT_MS = 15_000;
5
+ const MAX_OUTPUT_BYTES = 64 * 1024 ** 2;
6
+ const CLOCK_TICKS = 100;
7
+ /** Windows counts CPU time in 100 ns units. */
8
+ const FILETIME_PER_TICK = 100_000;
9
+ const AGENT_ID_VARIABLE = "PASEO_AGENT_ID=";
10
+ /** First line of the Windows output: when the process table was read, in ms after the script started. */
11
+ const SNAPSHOT_MARK = "snapshot";
12
+
13
+ type Run = (file: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<string>;
14
+
15
+ const run: Run = (file, args, env) => {
16
+ const { promise, resolve, reject } = Promise.withResolvers<string>();
17
+ execFile(
18
+ file,
19
+ args,
20
+ {
21
+ timeout: TIMEOUT_MS,
22
+ maxBuffer: MAX_OUTPUT_BYTES,
23
+ windowsHide: true,
24
+ encoding: "utf8",
25
+ env: { ...process.env, ...env },
26
+ },
27
+ (error, stdout) => (error ? reject(error) : resolve(stdout)),
28
+ );
29
+ return promise;
30
+ };
31
+
32
+ /** `m:ss.cc` as `ps` prints CPU time (minutes are not capped at 59), in clock ticks. */
33
+ export function parseCpuTime(text: string): number | null {
34
+ const match = /^(\d+):(\d+(?:\.\d+)?)$/.exec(text);
35
+ return match ? Math.round((Number(match[1]) * 60 + Number(match[2])) * CLOCK_TICKS) : null;
36
+ }
37
+
38
+ // "Tue Oct 13 06:01:17 2026": `lstart`, whose day is space padded. Pinned to the C locale by the caller.
39
+ const LSTART = String.raw`(\S+\s+\S+\s+\d+\s+[\d:]+\s+\d+)`;
40
+ // pid ppid lstart cputime rss comm
41
+ const PS_ROW = new RegExp(String.raw`^\s*(\d+)\s+(\d+)\s+${LSTART}\s+(\S+)\s+(\d+)\s+(.+?)\s*$`);
42
+ // pid lstart command-and-environment
43
+ const PS_ENV_ROW = new RegExp(String.raw`^\s*(\d+)\s+${LSTART}\s(.*)$`);
44
+
45
+ /**
46
+ * The agent id in one `ps -E` line. The environment follows the arguments, so the last occurrence
47
+ * is the environment's, and arguments that only mention the variable do not count. The whole line
48
+ * is read but only the id is kept; the rest can hold secrets.
49
+ */
50
+ export function parsePsAgentId(line: string): string | null {
51
+ let id: string | null = null;
52
+ for (const match of line.matchAll(/(?:^|\s)PASEO_AGENT_ID=(\S+)/g)) id = match[1] ?? null;
53
+ return id;
54
+ }
55
+
56
+ const psKey = (pid: string, started: string) => `${pid}:${started.replace(/\s+/g, " ")}`;
57
+
58
+ /**
59
+ * macOS: `stats` is `ps -axww -o pid=,ppid=,lstart=,cputime=,rss=,comm=`, `environment` is
60
+ * `ps -axww -E -o pid=,lstart=,command=`. They are separate listings because `-E` appends the
61
+ * environment to the command column only, and a command may hold spaces. Rows are joined on pid
62
+ * and start time, so a pid reused between the two listings is not given the old process's agent.
63
+ */
64
+ export function parsePs(stats: string, environment: string): ProcInfo[] {
65
+ const agents = new Map<string, string>();
66
+ for (const line of environment.split("\n")) {
67
+ const match = PS_ENV_ROW.exec(line);
68
+ const id = match && parsePsAgentId(match[3] ?? "");
69
+ if (match && id) agents.set(psKey(match[1] ?? "", match[2] ?? ""), id);
70
+ }
71
+ const procs: ProcInfo[] = [];
72
+ for (const line of stats.split("\n")) {
73
+ const match = PS_ROW.exec(line);
74
+ if (!match) continue;
75
+ const [, pid = "", ppid = "", started = "", cpu = "", rssKib = "", path = ""] = match;
76
+ const ticks = parseCpuTime(cpu);
77
+ if (ticks === null) continue;
78
+ const key = psKey(pid, started);
79
+ procs.push({
80
+ key,
81
+ pid: Number(pid),
82
+ ppid: Number(ppid),
83
+ ticks,
84
+ rss: Number(rssKib) * 1024,
85
+ comm: path.slice(path.lastIndexOf("/") + 1),
86
+ agentId: agents.get(key) ?? null,
87
+ });
88
+ }
89
+ return procs;
90
+ }
91
+
92
+ /**
93
+ * The daemon is always in its own host's process list, so a listing without it is not one: empty
94
+ * or garbled output must fail the scan, not read as a host where nothing is running.
95
+ */
96
+ function ownListing<T extends { pid: number }>(procs: T[], self: number): T[] {
97
+ if (!procs.some((proc) => proc.pid === self))
98
+ throw new Error("process list does not include this process");
99
+ return procs;
100
+ }
101
+
102
+ export async function readPsProcs(exec: Run = run, self = process.pid): Promise<ProcInfo[]> {
103
+ // `lstart` follows the locale; the parser reads the C one.
104
+ const env = { LC_ALL: "C" };
105
+ const [stats, environment] = await Promise.all([
106
+ exec("/bin/ps", ["-axww", "-o", "pid=,ppid=,lstart=,cputime=,rss=,comm="], env),
107
+ exec("/bin/ps", ["-axww", "-E", "-o", "pid=,lstart=,command="], env),
108
+ ]);
109
+ return ownListing(parsePs(stats, environment), self);
110
+ }
111
+
112
+ /**
113
+ * Windows has no listing that includes another process's environment, so the script reads it from
114
+ * the process's memory: PEB -> RTL_USER_PROCESS_PARAMETERS -> Environment (64-bit layout; checked
115
+ * on x64 and ARM64). The native calls are declared with Reflection.Emit because compiling C#
116
+ * with Add-Type costs seconds on every start. Only the agent id leaves the script. A process that
117
+ * cannot be opened (another user's, protected) simply has none. Anything that stops the script
118
+ * from working at all, such as a locked-down language mode, exits non-zero.
119
+ */
120
+ export const WINDOWS_SCRIPT = `
121
+ $ErrorActionPreference = 'Stop'
122
+ $clock = [Diagnostics.Stopwatch]::StartNew()
123
+ if (-not [Environment]::Is64BitProcess) { exit 3 }
124
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
125
+ $type = [AppDomain]::CurrentDomain.DefineDynamicAssembly((New-Object System.Reflection.AssemblyName 'TopNative'), 'Run').DefineDynamicModule('m').DefineType('N', 'Public,Class')
126
+ function Import($name, $dll, $return, $params) {
127
+ $method = $type.DefinePInvokeMethod($name, $dll, 'Public,Static,PinvokeImpl', 'Standard', $return, $params, 'Winapi', 'Auto')
128
+ $method.SetImplementationFlags('PreserveSig')
129
+ }
130
+ Import 'OpenProcess' 'kernel32.dll' ([IntPtr]) @([uint32], [bool], [int])
131
+ Import 'CloseHandle' 'kernel32.dll' ([bool]) @([IntPtr])
132
+ Import 'ReadProcessMemory' 'kernel32.dll' ([bool]) @([IntPtr], [IntPtr], [byte[]], [IntPtr], [IntPtr])
133
+ Import 'NtQueryInformationProcess' 'ntdll.dll' ([int]) @([IntPtr], [int], [byte[]], [int], [IntPtr])
134
+ $native = $type.CreateType()
135
+ function ReadMemory($handle, [long]$address, [int]$size) {
136
+ $buffer = New-Object byte[] $size
137
+ if ($native::ReadProcessMemory($handle, [IntPtr]$address, $buffer, [IntPtr]$size, [IntPtr]::Zero)) { , $buffer }
138
+ }
139
+ function AgentId([int]$id) {
140
+ $handle = $native::OpenProcess(0x1010, $false, $id)
141
+ if ($handle -eq [IntPtr]::Zero) { return }
142
+ try {
143
+ $info = New-Object byte[] 48
144
+ if ($native::NtQueryInformationProcess($handle, 0, $info, 48, [IntPtr]::Zero) -ne 0) { return }
145
+ $peb = ReadMemory $handle ([BitConverter]::ToInt64($info, 8) + 0x20) 8
146
+ if (-not $peb) { return }
147
+ $parameters = [BitConverter]::ToInt64($peb, 0)
148
+ $start = ReadMemory $handle ($parameters + 0x80) 8
149
+ $length = ReadMemory $handle ($parameters + 0x3F0) 8
150
+ if (-not $start -or -not $length) { return }
151
+ $size = [int][Math]::Min([BitConverter]::ToInt64($length, 0), 1MB)
152
+ if ($size -le 0) { return }
153
+ $block = ReadMemory $handle ([BitConverter]::ToInt64($start, 0)) $size
154
+ if (-not $block) { return }
155
+ $found = [regex]::Match([Text.Encoding]::Unicode.GetString($block), '(?:^|\\0)${AGENT_ID_VARIABLE}([^\\0\\t\\r\\n]+)')
156
+ if ($found.Success) { $found.Groups[1].Value }
157
+ } catch { return } finally { [void]$native::CloseHandle($handle) }
158
+ }
159
+ $all = Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate,KernelModeTime,UserModeTime,WorkingSetSize,Name
160
+ $snapshotMs = $clock.ElapsedMilliseconds
161
+ $lines = foreach ($p in $all) {
162
+ if ($p.ProcessId -eq 0) { continue }
163
+ try {
164
+ @($p.ProcessId, $p.ParentProcessId, ([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds(), ([uint64]$p.KernelModeTime + [uint64]$p.UserModeTime), $p.WorkingSetSize, [string](AgentId $p.ProcessId), $p.Name) -join [char]9
165
+ } catch { continue }
166
+ }
167
+ [Console]::Out.Write((@('${SNAPSHOT_MARK}' + [char]9 + $snapshotMs) + $lines) -join [char]10)
168
+ `;
169
+
170
+ /**
171
+ * The listing also says how long after the script started the table was read, so a slower start
172
+ * of PowerShell on one scan than on the last does not skew the CPU rates. Windows keeps a dead parent's pid on its children, and the number may since belong to an
173
+ * unrelated process. A parent that started after its child cannot be its parent.
174
+ */
175
+ export function parseWindows(text: string): Listing {
176
+ const rows: Array<ProcInfo & { started: number }> = [];
177
+ let delayMs = 0;
178
+ for (const line of text.split("\n")) {
179
+ const [pid, ppid, started, cpu, workingSet, agentId, name] = line.trimEnd().split("\t");
180
+ if (pid === SNAPSHOT_MARK) {
181
+ delayMs = Number(ppid) || 0;
182
+ continue;
183
+ }
184
+ const numbers = [pid, ppid, started, cpu, workingSet].map(Number);
185
+ if (name === undefined || !numbers.every(Number.isFinite) || Number(pid) === 0) continue;
186
+ rows.push({
187
+ key: `${pid}:${started}`,
188
+ pid: numbers[0] as number,
189
+ ppid: numbers[1] as number,
190
+ started: numbers[2] as number,
191
+ ticks: Math.floor((numbers[3] as number) / FILETIME_PER_TICK),
192
+ rss: numbers[4] as number,
193
+ comm: name.replace(/\.exe$/i, ""),
194
+ agentId: agentId || null,
195
+ });
196
+ }
197
+ const started = new Map(rows.map((row) => [row.pid, row.started]));
198
+ const procs = rows.map(({ started: at, ...proc }) => {
199
+ const parent = started.get(proc.ppid);
200
+ return parent !== undefined && parent > at ? { ...proc, ppid: 0 } : proc;
201
+ });
202
+ return { procs, delayMs };
203
+ }
204
+
205
+ /**
206
+ * Windows PowerShell 5.1, which every Windows has. A 32-bit Node on a 64-bit Windows would get the
207
+ * 32-bit one from System32, which cannot read 64-bit processes; Sysnative reaches the real one.
208
+ */
209
+ export function powershellPath(env: NodeJS.ProcessEnv = process.env, arch = process.arch): string {
210
+ const root = env.SystemRoot ?? "C:\\Windows";
211
+ const folder = arch === "ia32" && env.PROCESSOR_ARCHITEW6432 ? "Sysnative" : "System32";
212
+ return `${root}\\${folder}\\WindowsPowerShell\\v1.0\\powershell.exe`;
213
+ }
214
+
215
+ export async function readWindowsProcs(
216
+ exec: Run = run,
217
+ self = process.pid,
218
+ powershell = powershellPath(),
219
+ ): Promise<Listing> {
220
+ const output = await exec(powershell, [
221
+ "-NoProfile",
222
+ "-NonInteractive",
223
+ "-EncodedCommand",
224
+ Buffer.from(WINDOWS_SCRIPT, "utf16le").toString("base64"),
225
+ ]);
226
+ const listing = parseWindows(output);
227
+ ownListing(listing.procs, self);
228
+ return listing;
229
+ }
230
+
231
+ /**
232
+ * The process lister for this platform. A scan that fails rejects, or returns null. Set
233
+ * `PASEO_TOP_SESSIONS=off` to not list processes at all, which turns per-session load off.
234
+ */
235
+ export function defaultScan(
236
+ platform: NodeJS.Platform = process.platform,
237
+ env: NodeJS.ProcessEnv = process.env,
238
+ ): Scan {
239
+ if (env.PASEO_TOP_SESSIONS === "off") return () => null;
240
+ if (platform === "darwin") return () => readPsProcs();
241
+ if (platform === "win32") return () => readWindowsProcs();
242
+ return () => {
243
+ try {
244
+ return readProcs();
245
+ } catch {
246
+ return null;
247
+ }
248
+ };
249
+ }
@@ -198,53 +198,106 @@ export const HEAVY_CORES = 1;
198
198
  export const HEAVY_SCANS = 3;
199
199
  export const HEAVY_MEMORY_SHARE = 0.25;
200
200
 
201
+ /** A process list read some time after the scan started, which a lister that knows says. */
202
+ export interface Listing {
203
+ procs: ProcInfo[];
204
+ delayMs: number;
205
+ }
206
+
207
+ /**
208
+ * Lists processes; null for a scan that failed, and a throw or rejection counts as one. macOS and
209
+ * Windows listers run a command, so they are async.
210
+ */
211
+ export type Scan = () => ProcInfo[] | Listing | null | Promise<ProcInfo[] | Listing | null>;
212
+
213
+ const NO_LOAD = { cores: 0, rss: 0 };
214
+ /** After this many failures in a row the retries slow down, doubling up to the cap. */
215
+ const MAX_RETRY_MS = 5 * 60_000;
216
+
201
217
  /**
202
218
  * Rescans every `SCAN_EVERY_MS` and remembers a short window of each session's CPU, so a one-scan
203
- * spike does not flag it. `scan` returns null where processes cannot be listed: for good on a host
204
- * that cannot do it at all (not Linux), and for one scan on a host that failed once.
219
+ * spike does not flag it. Until a scan has worked there is nothing to diff against, so sessions are
220
+ * unavailable (null) once a scan has failed; they are also unavailable after a lasting failure,
221
+ * never an empty list, which would claim that nothing is running. A failed scan after a good one
222
+ * keeps the last readings for a while. Failures are retried ever more slowly, since a lister that
223
+ * fails three times in a row is usually blocked, not unlucky. One scan runs at a time, timed from
224
+ * when it started plus the delay its lister reports.
205
225
  */
206
- export function trackSessions(scan: () => ProcInfo[] | null, startedAt: number) {
207
- const first = scan();
208
- const supported = first !== null;
209
- let previous = first ? attribute(first, null, 0).ticks : new Map<string, number>();
210
- let latest: Pick<Attribution, "sessions" | "other"> = {
211
- sessions: [],
212
- other: { cores: 0, rss: 0 },
213
- };
226
+ export function trackSessions(scan: Scan, startedAt: number) {
227
+ let previous: Map<string, number> | null = null;
228
+ let latest: Pick<Attribution, "sessions" | "other"> = { sessions: [], other: NO_LOAD };
214
229
  let scannedAt = startedAt;
230
+ let triedAt = startedAt;
215
231
  let failures = 0;
232
+ let pending = false;
233
+ let unavailable = false;
216
234
  let recent = new Map<string, number[]>();
217
235
 
236
+ const settle = (result: ProcInfo[] | Listing | null, at: number) => {
237
+ if (!result) {
238
+ // Keep the last readings through a hiccup, but not through a lasting failure. The next
239
+ // good scan diffs against the last good one, over the longer interval.
240
+ if (++failures >= HEAVY_SCANS) {
241
+ unavailable = true;
242
+ latest = { sessions: [], other: NO_LOAD };
243
+ recent = new Map();
244
+ } else if (!previous) unavailable = true;
245
+ return;
246
+ }
247
+ failures = 0;
248
+ unavailable = false;
249
+ const { procs, delayMs } = Array.isArray(result) ? { procs: result, delayMs: 0 } : result;
250
+ const readAt = at + delayMs;
251
+ const next = attribute(procs, previous, (readAt - scannedAt) / 1000);
252
+ scannedAt = readAt;
253
+ // The first good scan is only a baseline: it has no earlier counters to diff.
254
+ if (!previous) {
255
+ previous = next.ticks;
256
+ return;
257
+ }
258
+ previous = next.ticks;
259
+ latest = { sessions: next.sessions, other: next.other };
260
+ recent = new Map(
261
+ next.sessions.map(({ agentId, cores }) => [
262
+ agentId,
263
+ [...(recent.get(agentId) ?? []), cores].slice(-HEAVY_SCANS),
264
+ ]),
265
+ );
266
+ };
267
+
268
+ const start = (at: number) => {
269
+ triedAt = at;
270
+ let result: ReturnType<Scan>;
271
+ try {
272
+ result = scan();
273
+ if (!(result instanceof Promise)) return settle(result, at);
274
+ } catch {
275
+ return settle(null, at);
276
+ }
277
+ pending = true;
278
+ result
279
+ .catch(() => null)
280
+ .then((procs) => settle(procs, at))
281
+ .catch(() => {})
282
+ .finally(() => {
283
+ pending = false;
284
+ });
285
+ };
286
+ start(startedAt);
287
+
218
288
  return {
219
289
  update(at: number) {
220
- if (!supported || at - scannedAt < SCAN_EVERY_MS) return;
221
- const procs = scan();
222
- if (!procs) {
223
- // Keep the last readings through a hiccup, but not through a lasting failure. The next
224
- // good scan diffs against the last good one, over the longer interval.
225
- if (++failures >= HEAVY_SCANS) {
226
- latest = { sessions: [], other: { cores: 0, rss: 0 } };
227
- recent = new Map();
228
- }
229
- return;
230
- }
231
- failures = 0;
232
- const next = attribute(procs, previous, (at - scannedAt) / 1000);
233
- previous = next.ticks;
234
- scannedAt = at;
235
- latest = { sessions: next.sessions, other: next.other };
236
- recent = new Map(
237
- next.sessions.map(({ agentId, cores }) => [
238
- agentId,
239
- [...(recent.get(agentId) ?? []), cores].slice(-HEAVY_SCANS),
240
- ]),
241
- );
290
+ const wait =
291
+ failures < HEAVY_SCANS
292
+ ? SCAN_EVERY_MS
293
+ : Math.min(SCAN_EVERY_MS * 2 ** (failures - HEAVY_SCANS + 1), MAX_RETRY_MS);
294
+ if (!pending && at - triedAt >= wait) start(at);
242
295
  },
243
296
  read(totalMemory: number): {
244
297
  sessions: Session[] | null;
245
298
  other: { cores: number; rss: number };
246
299
  } {
247
- if (!supported) return { sessions: null, other: { cores: 0, rss: 0 } };
300
+ if (unavailable) return { sessions: null, other: NO_LOAD };
248
301
  const sessions = latest.sessions
249
302
  .map((session): Session => {
250
303
  const window = recent.get(session.agentId) ?? [];
package/server/usage.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { readdirSync, readFileSync, statfsSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import { HISTORY_EVERY_MS, HISTORY_LENGTH, type Sample } from "../shared/usage";
4
- import { type ProcInfo, readProcs, trackSessions } from "./processes";
4
+ import { defaultScan } from "./platform";
5
+ import { type Scan, trackSessions } from "./processes";
5
6
 
6
7
  export interface CpuTimes {
7
8
  idle: number;
@@ -107,7 +108,7 @@ interface SamplerOptions {
107
108
  readMemory?: () => number | null;
108
109
  readDisk?: () => number | null;
109
110
  /** Lists processes, or returns null where that is not possible. */
110
- scan?: () => ProcInfo[] | null;
111
+ scan?: Scan;
111
112
  totalMemory?: () => number;
112
113
  now?: () => number;
113
114
  }
@@ -162,14 +163,3 @@ export function startSampler({
162
163
  stop: () => clearInterval(timer),
163
164
  };
164
165
  }
165
-
166
- /** Reads /proc; returns null for a scan where it cannot be listed. */
167
- function defaultScan(): () => ProcInfo[] | null {
168
- return () => {
169
- try {
170
- return readProcs();
171
- } catch {
172
- return null;
173
- }
174
- };
175
- }
package/shared/usage.ts CHANGED
@@ -44,7 +44,7 @@ export const getUsage = defineRpc({
44
44
  /** Earlier samples, oldest first, `HISTORY_EVERY_MS` apart, so a fresh client has a timeline. */
45
45
  history: z.array(sample).max(HISTORY_LENGTH),
46
46
  totalMemory: z.number().positive(),
47
- /** Busiest first. `null` where the host cannot attribute processes (anything but Linux). */
47
+ /** Busiest first. `null` where the host cannot attribute processes (where the process list cannot be read). */
48
48
  sessions: z.array(session).nullable(),
49
49
  /**
50
50
  * Everything scanned that is not listed in `sessions`: processes of no agent, of other daemons'