@omercnet/paseo-top 0.0.1-alpha → 0.0.1-next.252.1
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 +7 -2
- package/client/footer.tsx +25 -8
- package/client/gauge.tsx +4 -2
- package/client/screen.tsx +4 -0
- package/client/sessions.tsx +1 -1
- package/index.server.ts +4 -1
- package/package.json +2 -1
- package/paseo-plugin.json +3 -0
- package/server/names.ts +50 -16
- package/server/platform.ts +249 -0
- package/server/processes.ts +98 -50
- package/server/usage.ts +41 -25
- package/shared/usage.ts +2 -2
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. Top reads only that identifier from
|
|
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
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
A Paseo plugin that shows CPU, memory, disk and disk I/O of a host in the sidebar footer, and which agent sessions are responsible. See [OVERVIEW.md](OVERVIEW.md) for behavior.
|
|
4
4
|
|
|
5
|
+
## Screenshots
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
5
9
|
## Develop
|
|
6
10
|
|
|
7
11
|
```sh
|
|
@@ -21,8 +25,9 @@ Verify against an isolated daemon, never the default one:
|
|
|
21
25
|
|
|
22
26
|
- `shared/usage.ts`: the `top.usage` RPC contract and history constants.
|
|
23
27
|
- `server/usage.ts`: CPU, memory, disk and I/O readers, and the sampler with its history ring.
|
|
24
|
-
- `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.
|
|
25
30
|
- `server/names.ts`: joins agent and workspace names onto sessions.
|
|
26
31
|
- `index.server.ts`: wires the sampler to the RPC.
|
|
27
32
|
- `client/`, `index.client.tsx`: footer row, Top screen, dials and session list.
|
|
28
|
-
- `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/footer.tsx
CHANGED
|
@@ -2,19 +2,26 @@ import type { PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
|
|
2
2
|
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
3
|
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
|
4
4
|
import { Text, View } from "react-native";
|
|
5
|
-
import { formatPercent, formatRate, levelColor } from "./format";
|
|
5
|
+
import { formatPercent, formatRate, type Level, levelColor, loadLevel } from "./format";
|
|
6
6
|
import { SCREEN_ID, useUsage } from "./usage";
|
|
7
7
|
|
|
8
|
+
const LEVEL_NAME: Record<Level, string> = { ok: "", warn: ", high", hot: ", critical" };
|
|
9
|
+
|
|
10
|
+
/** What a screen reader says: the reading in words, with the level spelled out, not just coloured. */
|
|
11
|
+
function metricLabel(name: string, text: string, level: Level): string {
|
|
12
|
+
return text === "--" ? `${name} unavailable` : `${name} ${text}${LEVEL_NAME[level]}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
export function TopFooter({ theme, host, currentScreen, openScreen }: PluginSidebarItemProps) {
|
|
9
16
|
const usage = useUsage(host.id);
|
|
10
17
|
const heavy = usage.data?.sessions?.filter((session) => session.heavy).length ?? 0;
|
|
11
18
|
const metrics =
|
|
12
19
|
usage.data &&
|
|
13
20
|
([
|
|
14
|
-
["Cpu", formatPercent(usage.data.cpu), usage.data.cpu],
|
|
15
|
-
["MemoryStick", formatPercent(usage.data.memory), usage.data.memory],
|
|
16
|
-
["HardDrive", formatPercent(usage.data.disk), usage.data.disk],
|
|
17
|
-
["Gauge", formatRate(usage.data.io), null],
|
|
21
|
+
["Cpu", "CPU", formatPercent(usage.data.cpu), usage.data.cpu],
|
|
22
|
+
["MemoryStick", "Memory", formatPercent(usage.data.memory), usage.data.memory],
|
|
23
|
+
["HardDrive", "Disk", formatPercent(usage.data.disk), usage.data.disk],
|
|
24
|
+
["Gauge", "Disk I/O", formatRate(usage.data.io), null],
|
|
18
25
|
] as const);
|
|
19
26
|
|
|
20
27
|
return (
|
|
@@ -29,15 +36,25 @@ export function TopFooter({ theme, host, currentScreen, openScreen }: PluginSide
|
|
|
29
36
|
<Text style={{ color: theme.colors.statusDanger, fontSize: 11 }}>unavailable</Text>
|
|
30
37
|
)}
|
|
31
38
|
{heavy > 0 && (
|
|
32
|
-
<View
|
|
39
|
+
<View
|
|
40
|
+
accessible
|
|
41
|
+
accessibilityLabel={`${heavy} heavy ${heavy === 1 ? "session" : "sessions"}`}
|
|
42
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 2 }}
|
|
43
|
+
>
|
|
33
44
|
<Icon name="Flame" size={11} color={theme.colors.statusDanger} />
|
|
34
45
|
<Text style={{ color: theme.colors.statusDanger, fontSize: 11 }}>{heavy}</Text>
|
|
35
46
|
</View>
|
|
36
47
|
)}
|
|
37
|
-
{metrics?.map(([icon, text, percent]) => {
|
|
48
|
+
{metrics?.map(([icon, name, text, percent]) => {
|
|
38
49
|
const color = levelColor(theme, percent);
|
|
50
|
+
const level = loadLevel(percent);
|
|
39
51
|
return (
|
|
40
|
-
<View
|
|
52
|
+
<View
|
|
53
|
+
key={icon}
|
|
54
|
+
accessible
|
|
55
|
+
accessibilityLabel={metricLabel(name, text, level)}
|
|
56
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 2 }}
|
|
57
|
+
>
|
|
41
58
|
<Icon name={icon} size={11} color={color} />
|
|
42
59
|
<Text
|
|
43
60
|
numberOfLines={1}
|
package/client/gauge.tsx
CHANGED
|
@@ -31,12 +31,14 @@ export function Dial({ theme, size, icon, label, value, text }: DialProps) {
|
|
|
31
31
|
const needleLength = radius * 0.72;
|
|
32
32
|
const degrees = useRef(new Animated.Value(needleDegrees(value))).current;
|
|
33
33
|
useEffect(() => {
|
|
34
|
-
Animated.timing(degrees, {
|
|
34
|
+
const animation = Animated.timing(degrees, {
|
|
35
35
|
toValue: needleDegrees(value),
|
|
36
36
|
duration: 700,
|
|
37
37
|
easing: Easing.out(Easing.cubic),
|
|
38
38
|
useNativeDriver: false,
|
|
39
|
-
})
|
|
39
|
+
});
|
|
40
|
+
animation.start();
|
|
41
|
+
return () => animation.stop();
|
|
40
42
|
}, [degrees, value]);
|
|
41
43
|
const rotate = degrees.interpolate({
|
|
42
44
|
inputRange: [-SWEEP / 2, SWEEP / 2],
|
package/client/screen.tsx
CHANGED
|
@@ -68,6 +68,10 @@ export function TopScreen({ theme, layout, host, navigation }: PluginScreenProps
|
|
|
68
68
|
{host.label}
|
|
69
69
|
</Text>
|
|
70
70
|
<View
|
|
71
|
+
accessible
|
|
72
|
+
accessibilityLabel={
|
|
73
|
+
usage.isError ? "Usage unavailable" : usage.data ? "Usage live" : "Usage loading"
|
|
74
|
+
}
|
|
71
75
|
style={{
|
|
72
76
|
width: 8,
|
|
73
77
|
height: 8,
|
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/index.server.ts
CHANGED
|
@@ -11,5 +11,8 @@ export default function contribute(server: PluginServerContext) {
|
|
|
11
11
|
if (!usage.sessions) return usage;
|
|
12
12
|
return { ...usage, ...(await ownSessions(paseo, usage.sessions, usage.other)) };
|
|
13
13
|
});
|
|
14
|
-
return
|
|
14
|
+
return () => {
|
|
15
|
+
sampler.stop();
|
|
16
|
+
ownSessions.stop();
|
|
17
|
+
};
|
|
15
18
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omercnet/paseo-top",
|
|
3
|
-
"version": "0.0.1-
|
|
3
|
+
"version": "0.0.1-next.252.1",
|
|
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": {
|
package/paseo-plugin.json
CHANGED
package/server/names.ts
CHANGED
|
@@ -7,6 +7,8 @@ const FULL_REFRESH_MS = 30_000;
|
|
|
7
7
|
const EARLY_REFRESH_MS = 5000;
|
|
8
8
|
/** Only the first ever fetch is waited for, and not forever. */
|
|
9
9
|
const FIRST_FETCH_TIMEOUT_MS = 3000;
|
|
10
|
+
/** A read that has not settled by now is abandoned and cancelled, so a hung daemon cannot block later reads. */
|
|
11
|
+
const REFRESH_LIFETIME_MS = 20_000;
|
|
10
12
|
const PAGE_SIZE = 200;
|
|
11
13
|
export const MAX_PAGES = 25;
|
|
12
14
|
export const MAX_SESSIONS = 10;
|
|
@@ -26,12 +28,23 @@ interface Page<T> {
|
|
|
26
28
|
pageInfo: { hasMore: boolean; nextCursor: string | null };
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The daemon pages its lists, and an agent left off the first page must still be recognised. Aborting
|
|
33
|
+
* ends the read even when the daemon never answers the page in flight, which is then ignored.
|
|
34
|
+
*/
|
|
35
|
+
async function collect<T>(
|
|
36
|
+
signal: AbortSignal,
|
|
37
|
+
fetchPage: (cursor: string | undefined) => Promise<Page<T>>,
|
|
38
|
+
) {
|
|
39
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
40
|
+
const cancel = () => reject(new Error("Daemon read cancelled"));
|
|
41
|
+
if (signal.aborted) cancel();
|
|
42
|
+
else signal.addEventListener("abort", cancel, { once: true });
|
|
43
|
+
});
|
|
31
44
|
const entries: T[] = [];
|
|
32
45
|
let cursor: string | undefined;
|
|
33
46
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
34
|
-
const result = await fetchPage(cursor);
|
|
47
|
+
const result = await Promise.race([fetchPage(cursor), aborted]);
|
|
35
48
|
entries.push(...result.entries);
|
|
36
49
|
if (!result.pageInfo.hasMore || !result.pageInfo.nextCursor) break;
|
|
37
50
|
cursor = result.pageInfo.nextCursor;
|
|
@@ -40,7 +53,7 @@ async function collect<T>(fetchPage: (cursor: string | undefined) => Promise<Pag
|
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
/**
|
|
43
|
-
* The process scan sees every process
|
|
56
|
+
* The process scan sees every readable process /proc lists, including agents that belong to other
|
|
44
57
|
* daemons on the same machine. This keeps only the sessions of agents this daemon knows, names them
|
|
45
58
|
* with the user's own agent and workspace names, and folds everything else into `other` so the
|
|
46
59
|
* numbers still add up to the processes that were scanned.
|
|
@@ -48,34 +61,48 @@ async function collect<T>(fetchPage: (cursor: string | undefined) => Promise<Pag
|
|
|
48
61
|
* The daemon's lists are read in the background and shared between callers, so a slow daemon never
|
|
49
62
|
* holds up the readings. Only the very first read is waited for, for a few seconds at most. Until
|
|
50
63
|
* then, and whenever the lists have never been available, no session can be shown as this host's and
|
|
51
|
-
* all of its load is reported as other load. Once read, a failed refresh keeps the last lists.
|
|
64
|
+
* all of its load is reported as other load. Once read, a failed refresh keeps the last lists. A read
|
|
65
|
+
* that outlives `REFRESH_LIFETIME_MS` is cancelled and replaced by the next one, and whatever the
|
|
66
|
+
* abandoned read later returns is discarded. `stop` cancels the read in flight.
|
|
52
67
|
*/
|
|
53
68
|
export function createOwnership(now = Date.now, firstFetchTimeoutMs = FIRST_FETCH_TIMEOUT_MS) {
|
|
54
69
|
let known: Known | null = null;
|
|
55
70
|
/** Agents confirmed absent from the latest read, so they do not trigger another one. */
|
|
56
71
|
let foreign = new Set<string>();
|
|
57
72
|
let fetchedAt = Number.NEGATIVE_INFINITY;
|
|
58
|
-
let inflight: Promise<void
|
|
73
|
+
let inflight: { done: Promise<void>; startedAt: number; abort: AbortController } | null = null;
|
|
59
74
|
|
|
60
75
|
const refresh = (paseo: PaseoApi, candidates: string[]) => {
|
|
61
|
-
if (inflight)
|
|
76
|
+
if (inflight && now() - inflight.startedAt >= REFRESH_LIFETIME_MS) {
|
|
77
|
+
inflight.abort.abort();
|
|
78
|
+
inflight = null;
|
|
79
|
+
}
|
|
80
|
+
if (inflight) return inflight.done;
|
|
62
81
|
fetchedAt = now();
|
|
63
|
-
|
|
82
|
+
const abort = new AbortController();
|
|
83
|
+
const { signal } = abort;
|
|
84
|
+
const run = { done: Promise.resolve(), startedAt: fetchedAt, abort };
|
|
85
|
+
inflight = run;
|
|
86
|
+
run.done = (async () => {
|
|
64
87
|
try {
|
|
65
88
|
const [agents, workspaces] = await Promise.all([
|
|
66
|
-
collect((cursor) =>
|
|
89
|
+
collect(signal, (cursor) =>
|
|
67
90
|
paseo.agents.list({
|
|
68
91
|
filter: { includeArchived: true },
|
|
69
92
|
// Oldest first: an agent's creation time never changes, so paging cannot skip one that
|
|
70
93
|
// is busy and being updated while the pages are read.
|
|
71
94
|
sort: [{ key: "created_at", direction: "asc" }],
|
|
72
95
|
page: { limit: PAGE_SIZE, ...(cursor ? { cursor } : {}) },
|
|
96
|
+
signal,
|
|
73
97
|
}),
|
|
74
98
|
),
|
|
75
|
-
|
|
99
|
+
// The SDK's workspace list takes no signal; the abort stops the paging between pages.
|
|
100
|
+
collect(signal, (cursor) =>
|
|
76
101
|
paseo.workspaces.list({ page: { limit: PAGE_SIZE, ...(cursor ? { cursor } : {}) } }),
|
|
77
102
|
),
|
|
78
103
|
]);
|
|
104
|
+
// An abandoned read must not overwrite what a newer one stored.
|
|
105
|
+
if (inflight !== run) return;
|
|
79
106
|
const next: Known = {
|
|
80
107
|
agents: new Map(
|
|
81
108
|
agents.map(({ agent }) => [
|
|
@@ -91,14 +118,14 @@ export function createOwnership(now = Date.now, firstFetchTimeoutMs = FIRST_FETC
|
|
|
91
118
|
foreign = new Set(candidates.filter((id) => !next.agents.has(id)));
|
|
92
119
|
} catch {
|
|
93
120
|
// Keep the previous lists.
|
|
121
|
+
} finally {
|
|
122
|
+
if (inflight === run) inflight = null;
|
|
94
123
|
}
|
|
95
|
-
})()
|
|
96
|
-
|
|
97
|
-
});
|
|
98
|
-
return inflight;
|
|
124
|
+
})();
|
|
125
|
+
return run.done;
|
|
99
126
|
};
|
|
100
127
|
|
|
101
|
-
|
|
128
|
+
const ownership = async (paseo: PaseoApi, sessions: Session[], other: Load) => {
|
|
102
129
|
if (sessions.length === 0) return { sessions, other };
|
|
103
130
|
|
|
104
131
|
const ids = sessions.map((session) => session.agentId);
|
|
@@ -107,7 +134,7 @@ export function createOwnership(now = Date.now, firstFetchTimeoutMs = FIRST_FETC
|
|
|
107
134
|
const pending =
|
|
108
135
|
age >= FULL_REFRESH_MS || (unseen && age >= EARLY_REFRESH_MS)
|
|
109
136
|
? refresh(paseo, ids)
|
|
110
|
-
: inflight;
|
|
137
|
+
: inflight?.done;
|
|
111
138
|
if (known === null && pending) {
|
|
112
139
|
await new Promise<void>((resolve) => {
|
|
113
140
|
const timer = setTimeout(resolve, firstFetchTimeoutMs);
|
|
@@ -140,4 +167,11 @@ export function createOwnership(now = Date.now, firstFetchTimeoutMs = FIRST_FETC
|
|
|
140
167
|
}
|
|
141
168
|
return { sessions: own.slice(0, MAX_SESSIONS), other: rest };
|
|
142
169
|
};
|
|
170
|
+
|
|
171
|
+
return Object.assign(ownership, {
|
|
172
|
+
stop: () => {
|
|
173
|
+
inflight?.abort.abort();
|
|
174
|
+
inflight = null;
|
|
175
|
+
},
|
|
176
|
+
});
|
|
143
177
|
}
|
|
@@ -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
|
@@ -68,7 +68,7 @@ export function parseStat(text: string): Stat | null {
|
|
|
68
68
|
return { comm: text.slice(open + 1, close), ...numbers, rss: Math.max(0, numbers.rss) };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/**
|
|
71
|
+
/** The whole environment is read, but only the agent id is kept; the rest can hold secrets and is dropped. */
|
|
72
72
|
export function parseAgentId(environ: string): string | null {
|
|
73
73
|
for (const entry of environ.split("\0")) {
|
|
74
74
|
if (entry.startsWith("PASEO_AGENT_ID=")) return entry.slice("PASEO_AGENT_ID=".length) || null;
|
|
@@ -77,34 +77,29 @@ export function parseAgentId(environ: string): string | null {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
* Every process
|
|
81
|
-
*
|
|
80
|
+
* Every process listed under /proc, which includes other users' that are readable. The environment
|
|
81
|
+
* is read whole but only `PASEO_AGENT_ID` is kept, and it is read again on every scan, because a
|
|
82
|
+
* process can replace its environment when it execs without changing its pid or start time. A
|
|
83
|
+
* process whose environment cannot be read (another user's) has no agent.
|
|
82
84
|
*/
|
|
83
|
-
export function readProcs(
|
|
85
|
+
export function readProcs(root = "/proc"): ProcInfo[] {
|
|
84
86
|
const procs: ProcInfo[] = [];
|
|
85
|
-
const seen = new Set<string>();
|
|
86
87
|
for (const name of readdirSync(root)) {
|
|
87
88
|
if (!/^\d+$/.test(name)) continue;
|
|
88
89
|
try {
|
|
89
90
|
const stat = parseStat(readFileSync(`${root}/${name}/stat`, "utf8"));
|
|
90
91
|
if (!stat) continue;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
agentId = parseAgentId(readFileSync(`${root}/${name}/environ`, "utf8"));
|
|
97
|
-
} catch {
|
|
98
|
-
agentId = null; // another user's process
|
|
99
|
-
}
|
|
100
|
-
agentIds.set(key, agentId);
|
|
92
|
+
let agentId: string | null;
|
|
93
|
+
try {
|
|
94
|
+
agentId = parseAgentId(readFileSync(`${root}/${name}/environ`, "utf8"));
|
|
95
|
+
} catch {
|
|
96
|
+
agentId = null;
|
|
101
97
|
}
|
|
102
|
-
procs.push({ key
|
|
98
|
+
procs.push({ key: `${name}:${stat.start}`, pid: Number(name), agentId, ...stat });
|
|
103
99
|
} catch {
|
|
104
100
|
// The process exited while it was being read.
|
|
105
101
|
}
|
|
106
102
|
}
|
|
107
|
-
for (const key of agentIds.keys()) if (!seen.has(key)) agentIds.delete(key);
|
|
108
103
|
return procs;
|
|
109
104
|
}
|
|
110
105
|
|
|
@@ -203,53 +198,106 @@ export const HEAVY_CORES = 1;
|
|
|
203
198
|
export const HEAVY_SCANS = 3;
|
|
204
199
|
export const HEAVY_MEMORY_SHARE = 0.25;
|
|
205
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
|
+
|
|
206
217
|
/**
|
|
207
218
|
* Rescans every `SCAN_EVERY_MS` and remembers a short window of each session's CPU, so a one-scan
|
|
208
|
-
* spike does not flag it.
|
|
209
|
-
*
|
|
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.
|
|
210
225
|
*/
|
|
211
|
-
export function trackSessions(scan:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
let previous = first ? attribute(first, null, 0).ticks : new Map<string, number>();
|
|
215
|
-
let latest: Pick<Attribution, "sessions" | "other"> = {
|
|
216
|
-
sessions: [],
|
|
217
|
-
other: { cores: 0, rss: 0 },
|
|
218
|
-
};
|
|
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 };
|
|
219
229
|
let scannedAt = startedAt;
|
|
230
|
+
let triedAt = startedAt;
|
|
220
231
|
let failures = 0;
|
|
232
|
+
let pending = false;
|
|
233
|
+
let unavailable = false;
|
|
221
234
|
let recent = new Map<string, number[]>();
|
|
222
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
|
+
|
|
223
288
|
return {
|
|
224
289
|
update(at: number) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (++failures >= HEAVY_SCANS) {
|
|
231
|
-
latest = { sessions: [], other: { cores: 0, rss: 0 } };
|
|
232
|
-
recent = new Map();
|
|
233
|
-
}
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
failures = 0;
|
|
237
|
-
const next = attribute(procs, previous, (at - scannedAt) / 1000);
|
|
238
|
-
previous = next.ticks;
|
|
239
|
-
scannedAt = at;
|
|
240
|
-
latest = { sessions: next.sessions, other: next.other };
|
|
241
|
-
recent = new Map(
|
|
242
|
-
next.sessions.map(({ agentId, cores }) => [
|
|
243
|
-
agentId,
|
|
244
|
-
[...(recent.get(agentId) ?? []), cores].slice(-HEAVY_SCANS),
|
|
245
|
-
]),
|
|
246
|
-
);
|
|
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);
|
|
247
295
|
},
|
|
248
296
|
read(totalMemory: number): {
|
|
249
297
|
sessions: Session[] | null;
|
|
250
298
|
other: { cores: number; rss: number };
|
|
251
299
|
} {
|
|
252
|
-
if (
|
|
300
|
+
if (unavailable) return { sessions: null, other: NO_LOAD };
|
|
253
301
|
const sessions = latest.sessions
|
|
254
302
|
.map((session): Session => {
|
|
255
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;
|
|
@@ -18,10 +19,11 @@ export function readCpuTimes(cpus: ReadonlyArray<Pick<os.CpuInfo, "times">> = os
|
|
|
18
19
|
return { idle, total };
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
/** Busy share between two readings, or null when no time elapsed
|
|
22
|
+
/** Busy share between two readings, or null when no time elapsed, the counters went backwards or are not numbers. */
|
|
22
23
|
export function cpuPercent(previous: CpuTimes, current: CpuTimes): number | null {
|
|
23
24
|
const total = current.total - previous.total;
|
|
24
25
|
const idle = current.idle - previous.idle;
|
|
26
|
+
if (!Number.isFinite(total) || !Number.isFinite(idle)) return null;
|
|
25
27
|
if (total <= 0 || idle < 0 || idle > total) return null;
|
|
26
28
|
return (1 - idle / total) * 100;
|
|
27
29
|
}
|
|
@@ -35,13 +37,18 @@ export function diskPercent(
|
|
|
35
37
|
const { blocks, bfree, bavail } = stat(path);
|
|
36
38
|
const used = blocks - bfree;
|
|
37
39
|
const usable = used + bavail;
|
|
38
|
-
|
|
40
|
+
const percent = (used / usable) * 100;
|
|
41
|
+
return usable > 0 && Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : null;
|
|
39
42
|
} catch {
|
|
40
43
|
return null;
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Whole-disk bytes read plus written since boot (Linux only). Null where /proc is unavailable, and
|
|
49
|
+
* when any whole disk has a malformed counter: a partial total would show up as a throughput spike
|
|
50
|
+
* once the counter reads again.
|
|
51
|
+
*/
|
|
45
52
|
export function readDiskBytes(
|
|
46
53
|
sysBlock = "/sys/block",
|
|
47
54
|
diskstats = "/proc/diskstats",
|
|
@@ -52,34 +59,56 @@ export function readDiskBytes(
|
|
|
52
59
|
readdirSync(sysBlock).filter((name) => !/^(loop|ram|dm-|zram|md)/.test(name)),
|
|
53
60
|
);
|
|
54
61
|
let sectors = 0;
|
|
62
|
+
let matched = false;
|
|
55
63
|
for (const line of readFileSync(diskstats, "utf8").split("\n")) {
|
|
56
64
|
const fields = line.trim().split(/\s+/);
|
|
57
|
-
if (disks.has(fields[2] ?? ""))
|
|
65
|
+
if (!disks.has(fields[2] ?? "")) continue;
|
|
66
|
+
const read = Number(fields[5]);
|
|
67
|
+
const written = Number(fields[9]);
|
|
68
|
+
if (!(Number.isFinite(read) && read >= 0 && Number.isFinite(written) && written >= 0)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
sectors += read + written;
|
|
72
|
+
matched = true;
|
|
58
73
|
}
|
|
59
|
-
return sectors * 512;
|
|
74
|
+
return matched && Number.isFinite(sectors) ? sectors * 512 : null;
|
|
60
75
|
} catch {
|
|
61
76
|
return null;
|
|
62
77
|
}
|
|
63
78
|
}
|
|
64
79
|
|
|
65
|
-
/** Bytes per second between two readings, or null when
|
|
80
|
+
/** Bytes per second between two readings, or null when a counter is missing, not finite, or went backwards. */
|
|
66
81
|
export function bytesPerSecond(
|
|
67
82
|
previous: number | null,
|
|
68
83
|
current: number | null,
|
|
69
84
|
elapsedMs: number,
|
|
70
85
|
): number | null {
|
|
71
|
-
if (previous === null || current === null
|
|
72
|
-
|
|
86
|
+
if (previous === null || current === null) return null;
|
|
87
|
+
if (!(Number.isFinite(previous) && Number.isFinite(current) && Number.isFinite(elapsedMs))) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (current < previous || elapsedMs <= 0) return null;
|
|
91
|
+
const rate = ((current - previous) * 1000) / elapsedMs;
|
|
92
|
+
return Number.isFinite(rate) ? rate : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Used share of memory, clamped to 0-100; null when the host reports no usable total. Linux reports
|
|
97
|
+
* reclaimable cache as free, so this is memory programs cannot get back.
|
|
98
|
+
*/
|
|
99
|
+
export function memoryPercent(total = os.totalmem(), free = os.freemem()): number | null {
|
|
100
|
+
if (!Number.isFinite(total) || !Number.isFinite(free) || total <= 0) return null;
|
|
101
|
+
return Math.min(100, Math.max(0, (1 - free / total) * 100));
|
|
73
102
|
}
|
|
74
103
|
|
|
75
104
|
interface SamplerOptions {
|
|
76
105
|
intervalMs?: number;
|
|
77
106
|
readCpu?: () => CpuTimes;
|
|
78
107
|
readIo?: () => number | null;
|
|
79
|
-
readMemory?: () => number;
|
|
108
|
+
readMemory?: () => number | null;
|
|
80
109
|
readDisk?: () => number | null;
|
|
81
110
|
/** Lists processes, or returns null where that is not possible. */
|
|
82
|
-
scan?:
|
|
111
|
+
scan?: Scan;
|
|
83
112
|
totalMemory?: () => number;
|
|
84
113
|
now?: () => number;
|
|
85
114
|
}
|
|
@@ -92,8 +121,7 @@ export function startSampler({
|
|
|
92
121
|
intervalMs = 2000,
|
|
93
122
|
readCpu = readCpuTimes,
|
|
94
123
|
readIo = readDiskBytes,
|
|
95
|
-
|
|
96
|
-
readMemory = () => (1 - os.freemem() / os.totalmem()) * 100,
|
|
124
|
+
readMemory = memoryPercent,
|
|
97
125
|
readDisk = diskPercent,
|
|
98
126
|
scan = defaultScan(),
|
|
99
127
|
totalMemory = os.totalmem,
|
|
@@ -135,15 +163,3 @@ export function startSampler({
|
|
|
135
163
|
stop: () => clearInterval(timer),
|
|
136
164
|
};
|
|
137
165
|
}
|
|
138
|
-
|
|
139
|
-
/** Reads /proc; the environment cache lives as long as the sampler. */
|
|
140
|
-
function defaultScan(): () => ProcInfo[] | null {
|
|
141
|
-
const agentIds = new Map<string, string | null>();
|
|
142
|
-
return () => {
|
|
143
|
-
try {
|
|
144
|
-
return readProcs(agentIds);
|
|
145
|
-
} catch {
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
};
|
|
149
|
-
}
|
package/shared/usage.ts
CHANGED
|
@@ -10,7 +10,7 @@ const percent = z.number().min(0).max(100);
|
|
|
10
10
|
/** Usage of the daemon host. Percentages are 0-100; `null` means the host cannot report it yet. */
|
|
11
11
|
const sample = z.object({
|
|
12
12
|
cpu: percent.nullable(),
|
|
13
|
-
memory: percent,
|
|
13
|
+
memory: percent.nullable(),
|
|
14
14
|
disk: percent.nullable(),
|
|
15
15
|
/** Disk read plus write throughput in bytes per second. */
|
|
16
16
|
io: z.number().min(0).nullable(),
|
|
@@ -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'
|