@omercnet/paseo-top 0.0.0-stage → 0.0.1-next.250.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/LICENSE +21 -0
- package/OVERVIEW.md +15 -0
- package/README.md +31 -2
- package/client/dial.ts +29 -0
- package/client/footer.tsx +72 -0
- package/client/format.ts +56 -0
- package/client/gauge.tsx +189 -0
- package/client/screen.tsx +169 -0
- package/client/sessions.tsx +91 -0
- package/client/usage.ts +16 -0
- package/index.client.tsx +17 -0
- package/index.server.ts +18 -0
- package/package.json +60 -4
- package/paseo-plugin.json +8 -0
- package/server/names.ts +177 -0
- package/server/processes.ts +260 -0
- package/server/usage.ts +175 -0
- package/shared/usage.ts +56 -0
package/client/usage.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { getUsage } from "../shared/usage";
|
|
4
|
+
|
|
5
|
+
export const SCREEN_ID = "usage";
|
|
6
|
+
export const POLL_MS = 5000;
|
|
7
|
+
|
|
8
|
+
export function useUsage(hostId: string) {
|
|
9
|
+
const loadUsage = useRpc(getUsage);
|
|
10
|
+
// The host id keys the cache so a host switch never shows another host's numbers.
|
|
11
|
+
return useQuery({
|
|
12
|
+
queryKey: ["top", hostId, "usage"],
|
|
13
|
+
queryFn: () => loadUsage({}),
|
|
14
|
+
refetchInterval: POLL_MS,
|
|
15
|
+
});
|
|
16
|
+
}
|
package/index.client.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import { TopFooter } from "./client/footer";
|
|
3
|
+
import { TopScreen } from "./client/screen";
|
|
4
|
+
import { SCREEN_ID } from "./client/usage";
|
|
5
|
+
|
|
6
|
+
export default function contribute(client: PluginClientContext) {
|
|
7
|
+
const removeScreen = client.addScreen({ id: SCREEN_ID, title: "Top", Component: TopScreen });
|
|
8
|
+
const removeFooter = client.addSidebarFooterItem({
|
|
9
|
+
id: SCREEN_ID,
|
|
10
|
+
title: "Top",
|
|
11
|
+
Component: TopFooter,
|
|
12
|
+
});
|
|
13
|
+
return () => {
|
|
14
|
+
removeFooter();
|
|
15
|
+
removeScreen();
|
|
16
|
+
};
|
|
17
|
+
}
|
package/index.server.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import { createOwnership } from "./server/names";
|
|
3
|
+
import { startSampler } from "./server/usage";
|
|
4
|
+
import { getUsage } from "./shared/usage";
|
|
5
|
+
|
|
6
|
+
export default function contribute(server: PluginServerContext) {
|
|
7
|
+
const sampler = startSampler();
|
|
8
|
+
const ownSessions = createOwnership();
|
|
9
|
+
server.handle(getUsage, async (_input, { paseo }) => {
|
|
10
|
+
const usage = sampler.usage();
|
|
11
|
+
if (!usage.sessions) return usage;
|
|
12
|
+
return { ...usage, ...(await ownSessions(paseo, usage.sessions, usage.other)) };
|
|
13
|
+
});
|
|
14
|
+
return () => {
|
|
15
|
+
sampler.stop();
|
|
16
|
+
ownSessions.stop();
|
|
17
|
+
};
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omercnet/paseo-top",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1-next.250.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A Paseo plugin that shows host CPU, memory and disk usage in the sidebar footer.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/omercnet/paseo-plugins.git",
|
|
10
|
+
"directory": "top"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/omercnet/paseo-plugins/tree/main/top#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/omercnet/paseo-plugins/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"packageManager": "bun@1.4.2",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=24"
|
|
22
|
+
},
|
|
23
|
+
"author": "Omer Cohen",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"paseo",
|
|
26
|
+
"paseo-plugin",
|
|
27
|
+
"top",
|
|
28
|
+
"host-monitor"
|
|
29
|
+
],
|
|
30
|
+
"files": [
|
|
31
|
+
"index.client.tsx",
|
|
32
|
+
"index.server.ts",
|
|
33
|
+
"client",
|
|
34
|
+
"server",
|
|
35
|
+
"shared",
|
|
36
|
+
"paseo-plugin.json",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"OVERVIEW.md"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"check": "biome check .",
|
|
43
|
+
"check:write": "biome check --write .",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@biomejs/biome": "^2.5.10",
|
|
49
|
+
"@getpaseo/cli": "0.11.1",
|
|
50
|
+
"@getpaseo/client": "0.11.1",
|
|
51
|
+
"@getpaseo/plugin": "0.11.1",
|
|
52
|
+
"@getpaseo/protocol": "0.11.1",
|
|
53
|
+
"@tanstack/react-query": "^5.102.3",
|
|
54
|
+
"@types/node": "^24.10.1",
|
|
55
|
+
"@types/react": "^19.2.18",
|
|
56
|
+
"react": "19.1.0",
|
|
57
|
+
"react-native": "0.81.5",
|
|
58
|
+
"typescript": "^7.0.2",
|
|
59
|
+
"vitest": "^5.0.0",
|
|
60
|
+
"zod": "4.4.3"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/server/names.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { PaseoApi } from "@getpaseo/client";
|
|
2
|
+
import type { Session } from "../shared/usage";
|
|
3
|
+
|
|
4
|
+
/** Re-reading every agent, archived ones included, is not free, so it is done sparingly. */
|
|
5
|
+
const FULL_REFRESH_MS = 30_000;
|
|
6
|
+
/** A session whose agent is not yet known may be a brand new agent; look again, but not constantly. */
|
|
7
|
+
const EARLY_REFRESH_MS = 5000;
|
|
8
|
+
/** Only the first ever fetch is waited for, and not forever. */
|
|
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;
|
|
12
|
+
const PAGE_SIZE = 200;
|
|
13
|
+
export const MAX_PAGES = 25;
|
|
14
|
+
export const MAX_SESSIONS = 10;
|
|
15
|
+
|
|
16
|
+
interface Load {
|
|
17
|
+
cores: number;
|
|
18
|
+
rss: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Known {
|
|
22
|
+
agents: Map<string, { title: string | null; workspaceId: string | null }>;
|
|
23
|
+
workspaces: Map<string, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Page<T> {
|
|
27
|
+
entries: T[];
|
|
28
|
+
pageInfo: { hasMore: boolean; nextCursor: string | null };
|
|
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
|
+
});
|
|
44
|
+
const entries: T[] = [];
|
|
45
|
+
let cursor: string | undefined;
|
|
46
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
47
|
+
const result = await Promise.race([fetchPage(cursor), aborted]);
|
|
48
|
+
entries.push(...result.entries);
|
|
49
|
+
if (!result.pageInfo.hasMore || !result.pageInfo.nextCursor) break;
|
|
50
|
+
cursor = result.pageInfo.nextCursor;
|
|
51
|
+
}
|
|
52
|
+
return entries;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The process scan sees every readable process /proc lists, including agents that belong to other
|
|
57
|
+
* daemons on the same machine. This keeps only the sessions of agents this daemon knows, names them
|
|
58
|
+
* with the user's own agent and workspace names, and folds everything else into `other` so the
|
|
59
|
+
* numbers still add up to the processes that were scanned.
|
|
60
|
+
*
|
|
61
|
+
* The daemon's lists are read in the background and shared between callers, so a slow daemon never
|
|
62
|
+
* holds up the readings. Only the very first read is waited for, for a few seconds at most. Until
|
|
63
|
+
* then, and whenever the lists have never been available, no session can be shown as this host's and
|
|
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.
|
|
67
|
+
*/
|
|
68
|
+
export function createOwnership(now = Date.now, firstFetchTimeoutMs = FIRST_FETCH_TIMEOUT_MS) {
|
|
69
|
+
let known: Known | null = null;
|
|
70
|
+
/** Agents confirmed absent from the latest read, so they do not trigger another one. */
|
|
71
|
+
let foreign = new Set<string>();
|
|
72
|
+
let fetchedAt = Number.NEGATIVE_INFINITY;
|
|
73
|
+
let inflight: { done: Promise<void>; startedAt: number; abort: AbortController } | null = null;
|
|
74
|
+
|
|
75
|
+
const refresh = (paseo: PaseoApi, candidates: string[]) => {
|
|
76
|
+
if (inflight && now() - inflight.startedAt >= REFRESH_LIFETIME_MS) {
|
|
77
|
+
inflight.abort.abort();
|
|
78
|
+
inflight = null;
|
|
79
|
+
}
|
|
80
|
+
if (inflight) return inflight.done;
|
|
81
|
+
fetchedAt = now();
|
|
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 () => {
|
|
87
|
+
try {
|
|
88
|
+
const [agents, workspaces] = await Promise.all([
|
|
89
|
+
collect(signal, (cursor) =>
|
|
90
|
+
paseo.agents.list({
|
|
91
|
+
filter: { includeArchived: true },
|
|
92
|
+
// Oldest first: an agent's creation time never changes, so paging cannot skip one that
|
|
93
|
+
// is busy and being updated while the pages are read.
|
|
94
|
+
sort: [{ key: "created_at", direction: "asc" }],
|
|
95
|
+
page: { limit: PAGE_SIZE, ...(cursor ? { cursor } : {}) },
|
|
96
|
+
signal,
|
|
97
|
+
}),
|
|
98
|
+
),
|
|
99
|
+
// The SDK's workspace list takes no signal; the abort stops the paging between pages.
|
|
100
|
+
collect(signal, (cursor) =>
|
|
101
|
+
paseo.workspaces.list({ page: { limit: PAGE_SIZE, ...(cursor ? { cursor } : {}) } }),
|
|
102
|
+
),
|
|
103
|
+
]);
|
|
104
|
+
// An abandoned read must not overwrite what a newer one stored.
|
|
105
|
+
if (inflight !== run) return;
|
|
106
|
+
const next: Known = {
|
|
107
|
+
agents: new Map(
|
|
108
|
+
agents.map(({ agent }) => [
|
|
109
|
+
agent.id,
|
|
110
|
+
{ title: agent.title ?? null, workspaceId: agent.workspaceId ?? null },
|
|
111
|
+
]),
|
|
112
|
+
),
|
|
113
|
+
workspaces: new Map(
|
|
114
|
+
workspaces.map((workspace) => [workspace.id, workspace.title ?? workspace.name]),
|
|
115
|
+
),
|
|
116
|
+
};
|
|
117
|
+
known = next;
|
|
118
|
+
foreign = new Set(candidates.filter((id) => !next.agents.has(id)));
|
|
119
|
+
} catch {
|
|
120
|
+
// Keep the previous lists.
|
|
121
|
+
} finally {
|
|
122
|
+
if (inflight === run) inflight = null;
|
|
123
|
+
}
|
|
124
|
+
})();
|
|
125
|
+
return run.done;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const ownership = async (paseo: PaseoApi, sessions: Session[], other: Load) => {
|
|
129
|
+
if (sessions.length === 0) return { sessions, other };
|
|
130
|
+
|
|
131
|
+
const ids = sessions.map((session) => session.agentId);
|
|
132
|
+
const age = now() - fetchedAt;
|
|
133
|
+
const unseen = ids.some((id) => !known?.agents.has(id) && !foreign.has(id));
|
|
134
|
+
const pending =
|
|
135
|
+
age >= FULL_REFRESH_MS || (unseen && age >= EARLY_REFRESH_MS)
|
|
136
|
+
? refresh(paseo, ids)
|
|
137
|
+
: inflight?.done;
|
|
138
|
+
if (known === null && pending) {
|
|
139
|
+
await new Promise<void>((resolve) => {
|
|
140
|
+
const timer = setTimeout(resolve, firstFetchTimeoutMs);
|
|
141
|
+
pending.then(() => {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
resolve();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rest = { ...other };
|
|
149
|
+
const own: Session[] = [];
|
|
150
|
+
for (const session of sessions) {
|
|
151
|
+
const agent = known?.agents.get(session.agentId);
|
|
152
|
+
if (!agent) {
|
|
153
|
+
rest.cores += session.cores;
|
|
154
|
+
rest.rss += session.rss;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
own.push({
|
|
158
|
+
...session,
|
|
159
|
+
title: agent.title,
|
|
160
|
+
workspace: (agent.workspaceId && known?.workspaces.get(agent.workspaceId)) || null,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// Callers hand sessions over flagged first, then busiest, so the cap drops the least important.
|
|
164
|
+
for (const session of own.slice(MAX_SESSIONS)) {
|
|
165
|
+
rest.cores += session.cores;
|
|
166
|
+
rest.rss += session.rss;
|
|
167
|
+
}
|
|
168
|
+
return { sessions: own.slice(0, MAX_SESSIONS), other: rest };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return Object.assign(ownership, {
|
|
172
|
+
stop: () => {
|
|
173
|
+
inflight?.abort.abort();
|
|
174
|
+
inflight = null;
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import type { Session } from "../shared/usage";
|
|
4
|
+
|
|
5
|
+
const CLOCK_TICKS = 100;
|
|
6
|
+
const CPUS = Math.max(1, os.availableParallelism());
|
|
7
|
+
const MAX_DEPTH = 64;
|
|
8
|
+
const DEFAULT_PAGE_BYTES = 4096;
|
|
9
|
+
const AT_PAGESZ = 6;
|
|
10
|
+
|
|
11
|
+
/** The kernel's page size from the auxiliary vector; arm64 and ppc64 kernels often use 16K or 64K. */
|
|
12
|
+
export function readPageSize(auxvPath = "/proc/self/auxv"): number {
|
|
13
|
+
try {
|
|
14
|
+
const auxv = readFileSync(auxvPath);
|
|
15
|
+
const little = os.endianness() === "LE";
|
|
16
|
+
// Entries are (type, value) pairs of machine words; 64-bit hosts are the only ones in practice.
|
|
17
|
+
for (let at = 0; at + 16 <= auxv.length; at += 16) {
|
|
18
|
+
const type = Number(little ? auxv.readBigUInt64LE(at) : auxv.readBigUInt64BE(at));
|
|
19
|
+
if (type !== AT_PAGESZ) continue;
|
|
20
|
+
const value = Number(little ? auxv.readBigUInt64LE(at + 8) : auxv.readBigUInt64BE(at + 8));
|
|
21
|
+
const power = value > 0 && (value & (value - 1)) === 0;
|
|
22
|
+
return power && value >= DEFAULT_PAGE_BYTES && value <= 2 ** 20 ? value : DEFAULT_PAGE_BYTES;
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// Not Linux, or not readable.
|
|
26
|
+
}
|
|
27
|
+
return DEFAULT_PAGE_BYTES;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const PAGE_BYTES = readPageSize();
|
|
31
|
+
|
|
32
|
+
export interface ProcInfo {
|
|
33
|
+
/** `pid:starttime`, unique even after the kernel reuses a pid. */
|
|
34
|
+
key: string;
|
|
35
|
+
pid: number;
|
|
36
|
+
ppid: number;
|
|
37
|
+
/** User plus system CPU time so far, in ticks. */
|
|
38
|
+
ticks: number;
|
|
39
|
+
rss: number;
|
|
40
|
+
comm: string;
|
|
41
|
+
/** Agent that launched this process, from the environment Paseo gave it, if any. */
|
|
42
|
+
agentId: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Stat {
|
|
46
|
+
ppid: number;
|
|
47
|
+
ticks: number;
|
|
48
|
+
start: number;
|
|
49
|
+
rss: number;
|
|
50
|
+
comm: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Parses /proc/<pid>/stat. The command may contain spaces and parentheses, so split at the last `)`. */
|
|
54
|
+
export function parseStat(text: string): Stat | null {
|
|
55
|
+
const open = text.indexOf("(");
|
|
56
|
+
const close = text.lastIndexOf(")");
|
|
57
|
+
if (open < 0 || close < open) return null;
|
|
58
|
+
// Fields after the command start at number 3, so field n is at index n - 3.
|
|
59
|
+
const fields = text.slice(close + 2).split(" ");
|
|
60
|
+
const numbers = {
|
|
61
|
+
ppid: Number(fields[1]),
|
|
62
|
+
ticks: Number(fields[11]) + Number(fields[12]),
|
|
63
|
+
start: Number(fields[19]),
|
|
64
|
+
rss: Number(fields[21]) * PAGE_BYTES,
|
|
65
|
+
};
|
|
66
|
+
if (!Object.values(numbers).every(Number.isFinite)) return null;
|
|
67
|
+
// The kernel reports rss as a signed number; a negative one must not poison the whole reading.
|
|
68
|
+
return { comm: text.slice(open + 1, close), ...numbers, rss: Math.max(0, numbers.rss) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The whole environment is read, but only the agent id is kept; the rest can hold secrets and is dropped. */
|
|
72
|
+
export function parseAgentId(environ: string): string | null {
|
|
73
|
+
for (const entry of environ.split("\0")) {
|
|
74
|
+
if (entry.startsWith("PASEO_AGENT_ID=")) return entry.slice("PASEO_AGENT_ID=".length) || null;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
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.
|
|
84
|
+
*/
|
|
85
|
+
export function readProcs(root = "/proc"): ProcInfo[] {
|
|
86
|
+
const procs: ProcInfo[] = [];
|
|
87
|
+
for (const name of readdirSync(root)) {
|
|
88
|
+
if (!/^\d+$/.test(name)) continue;
|
|
89
|
+
try {
|
|
90
|
+
const stat = parseStat(readFileSync(`${root}/${name}/stat`, "utf8"));
|
|
91
|
+
if (!stat) continue;
|
|
92
|
+
let agentId: string | null;
|
|
93
|
+
try {
|
|
94
|
+
agentId = parseAgentId(readFileSync(`${root}/${name}/environ`, "utf8"));
|
|
95
|
+
} catch {
|
|
96
|
+
agentId = null;
|
|
97
|
+
}
|
|
98
|
+
procs.push({ key: `${name}:${stat.start}`, pid: Number(name), agentId, ...stat });
|
|
99
|
+
} catch {
|
|
100
|
+
// The process exited while it was being read.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return procs;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface SessionLoad {
|
|
107
|
+
agentId: string;
|
|
108
|
+
cores: number;
|
|
109
|
+
rss: number;
|
|
110
|
+
command: string;
|
|
111
|
+
processes: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface Attribution {
|
|
115
|
+
sessions: SessionLoad[];
|
|
116
|
+
other: { cores: number; rss: number };
|
|
117
|
+
/** CPU ticks per process, to diff against on the next scan. */
|
|
118
|
+
ticks: Map<string, number>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Charges each process to the agent that launched it. Tools often scrub or reset their
|
|
123
|
+
* environment, so an untagged process inherits from its nearest tagged ancestor.
|
|
124
|
+
*
|
|
125
|
+
* `previous` is the last scan's ticks, or null for the very first scan, which has nothing to diff
|
|
126
|
+
* against and so charges no CPU. After that, a process that is new since the last scan is charged
|
|
127
|
+
* all the CPU it has used, bounded by what every CPU could have given in the interval.
|
|
128
|
+
*/
|
|
129
|
+
export function attribute(
|
|
130
|
+
procs: readonly ProcInfo[],
|
|
131
|
+
previous: ReadonlyMap<string, number> | null,
|
|
132
|
+
elapsedSeconds: number,
|
|
133
|
+
): Attribution {
|
|
134
|
+
const byPid = new Map(procs.map((proc) => [proc.pid, proc]));
|
|
135
|
+
const ticks = new Map<string, number>();
|
|
136
|
+
const sessions = new Map<string, SessionLoad & { topCores: number; topRss: number }>();
|
|
137
|
+
const other = { cores: 0, rss: 0 };
|
|
138
|
+
const budget = elapsedSeconds * CLOCK_TICKS * CPUS;
|
|
139
|
+
|
|
140
|
+
for (const proc of procs) {
|
|
141
|
+
ticks.set(proc.key, proc.ticks);
|
|
142
|
+
let owner = proc;
|
|
143
|
+
for (let depth = 0; !owner.agentId && depth < MAX_DEPTH; depth++) {
|
|
144
|
+
const parent = byPid.get(owner.ppid);
|
|
145
|
+
if (!parent || parent === owner) break;
|
|
146
|
+
owner = parent;
|
|
147
|
+
}
|
|
148
|
+
let cores = 0;
|
|
149
|
+
if (previous && elapsedSeconds > 0) {
|
|
150
|
+
const earlier = previous.get(proc.key);
|
|
151
|
+
// A process seen before has a monotonic counter. One that is new since the last scan has used
|
|
152
|
+
// all its ticks within the interval; the bound only guards against a process missed earlier.
|
|
153
|
+
const used = earlier === undefined ? Math.min(proc.ticks, budget) : proc.ticks - earlier;
|
|
154
|
+
cores = Math.max(0, used) / CLOCK_TICKS / elapsedSeconds;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!owner.agentId) {
|
|
158
|
+
other.cores += cores;
|
|
159
|
+
other.rss += proc.rss;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
let session = sessions.get(owner.agentId);
|
|
163
|
+
if (!session) {
|
|
164
|
+
session = {
|
|
165
|
+
agentId: owner.agentId,
|
|
166
|
+
cores: 0,
|
|
167
|
+
rss: 0,
|
|
168
|
+
command: proc.comm,
|
|
169
|
+
processes: 0,
|
|
170
|
+
topCores: -1,
|
|
171
|
+
topRss: -1,
|
|
172
|
+
};
|
|
173
|
+
sessions.set(owner.agentId, session);
|
|
174
|
+
}
|
|
175
|
+
session.cores += cores;
|
|
176
|
+
session.rss += proc.rss;
|
|
177
|
+
session.processes++;
|
|
178
|
+
// The busiest process names the load; memory breaks ties between equally busy ones.
|
|
179
|
+
if (cores > session.topCores || (cores === session.topCores && proc.rss > session.topRss)) {
|
|
180
|
+
session.topCores = cores;
|
|
181
|
+
session.topRss = proc.rss;
|
|
182
|
+
session.command = proc.comm;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
sessions: [...sessions.values()].map(
|
|
188
|
+
({ topCores: _cores, topRss: _rss, ...session }) => session,
|
|
189
|
+
),
|
|
190
|
+
other,
|
|
191
|
+
ticks,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const SCAN_EVERY_MS = 10_000;
|
|
196
|
+
/** A session is heavy at one busy core held for three scans (30 seconds), or a quarter of memory. */
|
|
197
|
+
export const HEAVY_CORES = 1;
|
|
198
|
+
export const HEAVY_SCANS = 3;
|
|
199
|
+
export const HEAVY_MEMORY_SHARE = 0.25;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 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.
|
|
205
|
+
*/
|
|
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
|
+
};
|
|
214
|
+
let scannedAt = startedAt;
|
|
215
|
+
let failures = 0;
|
|
216
|
+
let recent = new Map<string, number[]>();
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
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
|
+
);
|
|
242
|
+
},
|
|
243
|
+
read(totalMemory: number): {
|
|
244
|
+
sessions: Session[] | null;
|
|
245
|
+
other: { cores: number; rss: number };
|
|
246
|
+
} {
|
|
247
|
+
if (!supported) return { sessions: null, other: { cores: 0, rss: 0 } };
|
|
248
|
+
const sessions = latest.sessions
|
|
249
|
+
.map((session): Session => {
|
|
250
|
+
const window = recent.get(session.agentId) ?? [];
|
|
251
|
+
// Held for the whole window, not averaged, so one burst does not flag a session.
|
|
252
|
+
const sustained = window.length === HEAVY_SCANS && window.every((c) => c >= HEAVY_CORES);
|
|
253
|
+
const heavy = sustained || session.rss >= HEAVY_MEMORY_SHARE * totalMemory;
|
|
254
|
+
return { ...session, title: null, workspace: null, heavy };
|
|
255
|
+
})
|
|
256
|
+
.sort((a, b) => Number(b.heavy) - Number(a.heavy) || b.cores - a.cores || b.rss - a.rss);
|
|
257
|
+
return { sessions, other: latest.other };
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|