@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 +2 -2
- package/README.md +3 -2
- package/client/sessions.tsx +1 -1
- package/package.json +2 -1
- package/server/platform.ts +249 -0
- package/server/processes.ts +86 -33
- package/server/usage.ts +3 -13
- package/shared/usage.ts +1 -1
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
|
|
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
|
|
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
|
|
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.
|
package/client/sessions.tsx
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
+
}
|
package/server/processes.ts
CHANGED
|
@@ -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.
|
|
204
|
-
*
|
|
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:
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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 (
|
|
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 {
|
|
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?:
|
|
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 (
|
|
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'
|