@hienlh/ppm 0.23.19 → 0.23.20
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/CHANGELOG.md +7 -0
- package/CLAUDE.md +2 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/docs/lessons-learned.md +30 -0
- package/package.json +1 -1
- package/src/server/index.ts +8 -0
- package/src/services/autostart-generator.ts +9 -1
- package/src/services/event-loop-lag.ts +44 -11
- package/src/services/machine-cpu.ts +40 -0
- package/src/services/process-priority.ts +153 -0
- package/src/services/process-thread-snapshot.ts +193 -0
- package/src/services/supervisor.ts +44 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.23.20] - 2026-10-26
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **On Windows the server no longer freezes for up to a minute and gets restarted**: PPM started from autostart now runs at normal memory and disk priority.
|
|
8
|
+
- **Stall logs now say what the server was waiting on**, so the cause of a freeze is on record after a restart.
|
|
9
|
+
|
|
3
10
|
## [0.23.19] - 2026-10-26
|
|
4
11
|
|
|
5
12
|
### Fixed
|
package/CLAUDE.md
CHANGED
|
@@ -246,6 +246,8 @@ Related: `configService.save()`/`set()` throw when `load()` has not run, because
|
|
|
246
246
|
- **Stripping ANSI codes from a terminal buffer leaks what the screen never showed**: a line editor redraws in place, so zsh-autosuggestions draws the rest of a history entry in grey and the next key erases it with cursor moves — a regex that drops escape sequences keeps that text and hands the AI the user's shell history as if it were output. `read_terminal` replays the buffer into `@xterm/headless` at the terminal's own size instead (`terminal-text.ts`), which also keeps a progress bar's last state and a full-screen program's screen; any new reader of terminal output should go through it.
|
|
247
247
|
- **Playwright's headless shell ignores `--unsafely-treat-insecure-origin-as-secure`**: the default `headless: true` launches `chromium-headless-shell`, where a page on a mapped name (`--host-resolver-rules=MAP ppm-remote.test 127.0.0.1`) stays an insecure origin and loses every API a browser keeps for secure contexts. That is how PPM's chat send was found broken on plain HTTP: it called `crypto.randomUUID`, so a turn never started and the test read as hung. Full Chromium (`channel: "chromium"`, also headless) honours the flag; `ai-tab-tools-e2e.mjs` launches it only for its device-that-is-not-the-host step.
|
|
248
248
|
|
|
249
|
+
- **A process with little CPU during a stall was not necessarily starved**: the Windows autostart task once ran at the Task Scheduler's default priority 7, which also means memory priority 2 and I/O priority Low for the supervisor and every child it spawns. The server's main thread then sat in `PageIn` waits for 20-60s on an idle machine (13% CPU) until the health check killed it, while the lag monitor logged `cause=starved`. The task is now priority 5, both processes raise themselves at start (`process-priority.ts`, logged as a `WARN` when it fires), the lag monitor says `cause=blocked` when cores were free, and a failed health probe logs the server's main-thread wait from the supervisor (`process-thread-snapshot.ts`). See `docs/lessons-learned.md`.
|
|
250
|
+
|
|
249
251
|
## UI Rules
|
|
250
252
|
|
|
251
253
|
When creating or modifying any UI component, you MUST read and follow `docs/design-guidelines.md`, especially the **Mobile-First UI Rules** section. Key rules:
|
|
@@ -73,4 +73,4 @@ This skill covers the `ppm` CLI, its HTTP API, and its config DB. It does **not*
|
|
|
73
73
|
- Third-party extensions (inspect via `ppm ext list`).
|
|
74
74
|
- The Claude Agent SDK internals (separate skill).
|
|
75
75
|
|
|
76
|
-
<!-- Generated for PPM v0.23.
|
|
76
|
+
<!-- Generated for PPM v0.23.20 at build time. Re-run `ppm export skill --install` to refresh. -->
|
|
@@ -490,4 +490,4 @@ _Base URL: `http://localhost:8080` (default; override via `ppm config set port <
|
|
|
490
490
|
- `ws://<host>/ws/terminal` — PTY terminal multiplexer
|
|
491
491
|
- `ws://<host>/ws/extensions` — extension host channel
|
|
492
492
|
|
|
493
|
-
<!-- Generated from src/server/routes/ for PPM v0.23.
|
|
493
|
+
<!-- Generated from src/server/routes/ for PPM v0.23.20 -->
|
package/docs/lessons-learned.md
CHANGED
|
@@ -373,3 +373,33 @@ append-mode descriptor it hands the server. `tests/unit/cli/windows-launch-log-r
|
|
|
373
373
|
fails if either call site points the redirect back at `ppm.log`. `ppm.err.log` is still
|
|
374
374
|
overwritten on each start. It only duplicates the supervisor's own lines plus a crash trace from
|
|
375
375
|
the last run.
|
|
376
|
+
|
|
377
|
+
## A Task Scheduler task at priority 7 demotes memory and I/O, not just CPU (2026-10-26)
|
|
378
|
+
|
|
379
|
+
PPM's autostart task was registered with the scheduler's default `<Priority>7</Priority>`. That
|
|
380
|
+
starts the supervisor below normal on CPU **and** at memory priority 2 (normal is 5) and I/O
|
|
381
|
+
priority Low (normal is Normal), and every child inherits all three: the server, every
|
|
382
|
+
`claude`/`codex` process, every terminal. Windows takes pages from a low-memory-priority process
|
|
383
|
+
first and reads them back at that process's I/O priority, so the server's event loop sat in
|
|
384
|
+
page-in waits for 20-60s at a time, several times an hour, until the supervisor's health check
|
|
385
|
+
killed it (three restarts in 40 minutes on 26/10).
|
|
386
|
+
|
|
387
|
+
It was invisible to every log PPM kept. The lag monitor called each one `cause=starved` because
|
|
388
|
+
the process had used little CPU, and "starved" means the CPU went to someone else — but the
|
|
389
|
+
machine was at 13%. An outside watcher reading the main thread once a second found it in
|
|
390
|
+
`PageIn` for 23s on an idle NVMe disk. Three things changed:
|
|
391
|
+
|
|
392
|
+
- The task is registered at priority 5, and the supervisor and server raise themselves to normal
|
|
393
|
+
at start (`process-priority.ts`), which covers tasks registered before. Only raising: a
|
|
394
|
+
priority set higher on purpose is left alone. A demoted start is logged as a `WARN`.
|
|
395
|
+
- The lag monitor measures the machine's CPU across a stall and says `cause=blocked` when there
|
|
396
|
+
were free cores — the thread was waiting on a page-in, a disk read, a lock or a synchronous
|
|
397
|
+
child, not starved.
|
|
398
|
+
- A failed health probe logs, from the supervisor, what the server's main thread is waiting on
|
|
399
|
+
(`process-thread-snapshot.ts`: one `NtQuerySystemInformation` call on Windows, `/proc` on
|
|
400
|
+
Linux), its hard page faults and CPU since the last probe, and the machine's CPU. That line is
|
|
401
|
+
written while the server is still stalled, which is the only moment the cause can be read.
|
|
402
|
+
|
|
403
|
+
To check a running PPM: `GetProcessInformation(ProcessMemoryPriority)` and
|
|
404
|
+
`NtQueryInformationProcess(ProcessIoPriority = 33)` on its pid; Task Manager shows only the CPU
|
|
405
|
+
class.
|
package/package.json
CHANGED
package/src/server/index.ts
CHANGED
|
@@ -877,6 +877,14 @@ if (process.argv.includes("__serve__")) {
|
|
|
877
877
|
}
|
|
878
878
|
|
|
879
879
|
await setupLogFile();
|
|
880
|
+
// Before any child is spawned (they inherit it). The supervisor raises its own first,
|
|
881
|
+
// so this only fires for a server started some other way.
|
|
882
|
+
{
|
|
883
|
+
const { logPriorityRaise } = await import("../services/process-priority.ts");
|
|
884
|
+
const { createLogger } = await import("../services/logger.ts");
|
|
885
|
+
const priorityLog = createLogger("priority");
|
|
886
|
+
await logPriorityRaise("server", (m) => priorityLog.warn(m));
|
|
887
|
+
}
|
|
880
888
|
configService.load();
|
|
881
889
|
await followLogLevelConfig();
|
|
882
890
|
(await import("../services/memory-diagnostics.ts")).startMemoryDiagnostics();
|
|
@@ -243,6 +243,14 @@ export function getTaskXmlPath(): string {
|
|
|
243
243
|
*
|
|
244
244
|
* ExecutionTimeLimit must be PT0S (unlimited): schtasks would have defaulted to
|
|
245
245
|
* PT72H and killed the supervisor after three days of uptime.
|
|
246
|
+
*
|
|
247
|
+
* Priority must be 5 (normal), not the Task Scheduler's default 7. Priority 7 starts the
|
|
248
|
+
* process below normal on CPU *and* at memory priority 2 and I/O priority Low, and every
|
|
249
|
+
* child inherits that. Windows then trims the server's pages first and reads them back
|
|
250
|
+
* at the lowest I/O priority, so the event loop sat in a page-in for 20-60s at a time on
|
|
251
|
+
* an idle machine — long enough for the supervisor's health check to kill the server.
|
|
252
|
+
* `process-priority.ts` also raises a running process, which covers tasks registered
|
|
253
|
+
* before this changed.
|
|
246
254
|
*/
|
|
247
255
|
export function generateTaskXml(vbsPath: string, userId = getCurrentWindowsUserId()): string {
|
|
248
256
|
const user = escapeXml(userId);
|
|
@@ -282,7 +290,7 @@ export function generateTaskXml(vbsPath: string, userId = getCurrentWindowsUserI
|
|
|
282
290
|
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
283
291
|
<WakeToRun>false</WakeToRun>
|
|
284
292
|
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
285
|
-
<Priority>
|
|
293
|
+
<Priority>5</Priority>
|
|
286
294
|
</Settings>
|
|
287
295
|
<Actions Context="Author">
|
|
288
296
|
<Exec>
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* a busy instance against 0.12-0.65ms on an idle one of the same binary), but
|
|
10
10
|
* from outside it names no culprit.
|
|
11
11
|
*
|
|
12
|
-
* A late tick means one of
|
|
12
|
+
* A late tick means one of three things, and they are fixed in different places:
|
|
13
13
|
*
|
|
14
14
|
* - **self** — the loop was busy with our own synchronous work, or with GC.
|
|
15
15
|
* Worth hunting: a blocking call, a whole-file read, a synchronous DB write.
|
|
@@ -18,10 +18,17 @@
|
|
|
18
18
|
* and one `claude` process per session. Nothing in this repository fixes
|
|
19
19
|
* that; moving work to another thread least of all, since the CPU was never
|
|
20
20
|
* ours to move it to.
|
|
21
|
+
* - **blocked** — we got little CPU while the machine had plenty to spare, so
|
|
22
|
+
* the thread was *waiting*: on a page-in, a disk read, a lock, a synchronous
|
|
23
|
+
* child process. This is what every 20-60s stall on 26/10/2026 turned out to
|
|
24
|
+
* be — the main thread in `PageIn` waits with the machine at 13% CPU, because
|
|
25
|
+
* the process ran at memory priority 2 and I/O priority Low (see
|
|
26
|
+
* `process-priority.ts`). They were logged as "starved" until this cause existed,
|
|
27
|
+
* which pointed at the one explanation that was wrong.
|
|
21
28
|
*
|
|
22
|
-
* Sampling `process.cpuUsage()` across the same gap
|
|
23
|
-
*
|
|
24
|
-
* stalled" is a fact with
|
|
29
|
+
* Sampling `process.cpuUsage()` across the same gap separates our work from not
|
|
30
|
+
* ours; the machine's CPU across it separates starved from blocked. Without both,
|
|
31
|
+
* "the server stalled" is a fact with opposite conclusions.
|
|
25
32
|
*
|
|
26
33
|
* The monitor costs one timer and two counter reads per tick. It holds its
|
|
27
34
|
* samples in memory, and only a stall of `LOG_STALL_MS` or more also reaches
|
|
@@ -31,6 +38,7 @@
|
|
|
31
38
|
* left nothing behind once the process restarted.
|
|
32
39
|
*/
|
|
33
40
|
import { createLogger } from "./logger.ts";
|
|
41
|
+
import { machineBusyFraction, readMachineCpuTimes, type MachineCpuTimes } from "./machine-cpu.ts";
|
|
34
42
|
|
|
35
43
|
const log = createLogger("event-loop");
|
|
36
44
|
|
|
@@ -55,7 +63,13 @@ export const SELF_RATIO = 0.7;
|
|
|
55
63
|
/** CPU/wall at or below this: the process was not running for most of the gap. */
|
|
56
64
|
export const STARVED_RATIO = 0.25;
|
|
57
65
|
|
|
58
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Machine-wide busy share at or above which a process not running counts as starved.
|
|
68
|
+
* Below it there were free cores, so not running means the thread was waiting.
|
|
69
|
+
*/
|
|
70
|
+
export const MACHINE_BUSY_FRACTION = 0.8;
|
|
71
|
+
|
|
72
|
+
export type LagCause = "self" | "starved" | "blocked" | "mixed";
|
|
59
73
|
|
|
60
74
|
export interface LagSample {
|
|
61
75
|
/** Epoch ms at which the late tick finally ran. */
|
|
@@ -66,6 +80,8 @@ export interface LagSample {
|
|
|
66
80
|
cpuMs: number;
|
|
67
81
|
/** Resident set at that moment, to spot a stall that is really GC pressure. */
|
|
68
82
|
rssMb: number;
|
|
83
|
+
/** Share of every core on the machine that was busy across the gap, 0-100; absent when unknown. */
|
|
84
|
+
machineCpuPct?: number;
|
|
69
85
|
cause: LagCause;
|
|
70
86
|
}
|
|
71
87
|
|
|
@@ -96,12 +112,17 @@ export interface LagReport {
|
|
|
96
112
|
* The ratio can exceed 1: `cpuUsage` counts every thread, and Bun runs file
|
|
97
113
|
* system work on a pool, so a gap spent in parallel IO bills more CPU than wall
|
|
98
114
|
* time passed. That is still our own work, which is what the answer says.
|
|
115
|
+
*
|
|
116
|
+
* Without a machine reading a gap we spent off the CPU stays "starved", the only
|
|
117
|
+
* answer there was before the machine was measured.
|
|
99
118
|
*/
|
|
100
|
-
export function classifyLag(lagMs: number, cpuMs: number): LagCause {
|
|
119
|
+
export function classifyLag(lagMs: number, cpuMs: number, machineBusy?: number | null): LagCause {
|
|
101
120
|
if (lagMs <= 0) return "self";
|
|
102
121
|
const ratio = cpuMs / lagMs;
|
|
103
122
|
if (ratio >= SELF_RATIO) return "self";
|
|
104
|
-
if (ratio <= STARVED_RATIO)
|
|
123
|
+
if (ratio <= STARVED_RATIO) {
|
|
124
|
+
return machineBusy != null && machineBusy < MACHINE_BUSY_FRACTION ? "blocked" : "starved";
|
|
125
|
+
}
|
|
105
126
|
return "mixed";
|
|
106
127
|
}
|
|
107
128
|
|
|
@@ -116,18 +137,22 @@ export function sampleFromTick(args: {
|
|
|
116
137
|
elapsedMs: number;
|
|
117
138
|
cpuMicros: number;
|
|
118
139
|
rssBytes: number;
|
|
140
|
+
/** Machine-wide busy share across the same gap, 0-1, when it was measured. */
|
|
141
|
+
machineBusy?: number | null;
|
|
119
142
|
thresholdMs?: number;
|
|
120
143
|
}): LagSample | null {
|
|
121
144
|
const lagMs = args.elapsedMs - TICK_MS;
|
|
122
145
|
if (lagMs < (args.thresholdMs ?? REPORT_THRESHOLD_MS)) return null;
|
|
123
146
|
const cpuMs = args.cpuMicros / 1000;
|
|
124
|
-
|
|
147
|
+
const sample: LagSample = {
|
|
125
148
|
at: args.now,
|
|
126
149
|
lagMs: Math.round(lagMs),
|
|
127
150
|
cpuMs: Math.round(cpuMs),
|
|
128
151
|
rssMb: Math.round(args.rssBytes / 1048576),
|
|
129
|
-
cause: classifyLag(lagMs, cpuMs),
|
|
152
|
+
cause: classifyLag(lagMs, cpuMs, args.machineBusy),
|
|
130
153
|
};
|
|
154
|
+
if (args.machineBusy != null) sample.machineCpuPct = Math.round(args.machineBusy * 100);
|
|
155
|
+
return sample;
|
|
131
156
|
}
|
|
132
157
|
|
|
133
158
|
// ─── Monitor ───────────────────────────────────────────────────────────
|
|
@@ -137,6 +162,7 @@ let samples: LagSample[] = [];
|
|
|
137
162
|
let startedAt = 0;
|
|
138
163
|
let lastAt = 0;
|
|
139
164
|
let lastCpu: { user: number; system: number } | null = null;
|
|
165
|
+
let lastMachine: MachineCpuTimes | null = null;
|
|
140
166
|
let ticks = 0;
|
|
141
167
|
let stalls = 0;
|
|
142
168
|
let blockedMs = 0;
|
|
@@ -156,7 +182,8 @@ export function logLongStall(sample: LagSample): void {
|
|
|
156
182
|
return;
|
|
157
183
|
}
|
|
158
184
|
const more = longStallsNotLogged ? ` (+${longStallsNotLogged} more of ${LOG_STALL_MS}ms+ since the last one logged)` : "";
|
|
159
|
-
|
|
185
|
+
const machine = sample.machineCpuPct === undefined ? "" : `, machineCpu=${sample.machineCpuPct}%`;
|
|
186
|
+
log.warn(`Event loop stalled ${sample.lagMs}ms (cause=${sample.cause}, cpu=${sample.cpuMs}ms${machine}, rss=${sample.rssMb}MB)${more}`);
|
|
160
187
|
lastStallLoggedAt = sample.at;
|
|
161
188
|
longStallsNotLogged = 0;
|
|
162
189
|
}
|
|
@@ -168,8 +195,11 @@ function tick(): void {
|
|
|
168
195
|
const cpuMicros = lastCpu
|
|
169
196
|
? cpu.user - lastCpu.user + (cpu.system - lastCpu.system)
|
|
170
197
|
: 0;
|
|
198
|
+
const machine = readMachineCpuTimes();
|
|
199
|
+
const machineBusy = machineBusyFraction(lastMachine, machine);
|
|
171
200
|
lastAt = now;
|
|
172
201
|
lastCpu = cpu;
|
|
202
|
+
lastMachine = machine;
|
|
173
203
|
ticks++;
|
|
174
204
|
|
|
175
205
|
const sample = sampleFromTick({
|
|
@@ -177,6 +207,7 @@ function tick(): void {
|
|
|
177
207
|
elapsedMs,
|
|
178
208
|
cpuMicros,
|
|
179
209
|
rssBytes: process.memoryUsage.rss(),
|
|
210
|
+
machineBusy,
|
|
180
211
|
});
|
|
181
212
|
if (!sample) return;
|
|
182
213
|
|
|
@@ -194,6 +225,7 @@ export function startLagMonitor(): void {
|
|
|
194
225
|
startedAt = Date.now();
|
|
195
226
|
lastAt = startedAt;
|
|
196
227
|
lastCpu = process.cpuUsage();
|
|
228
|
+
lastMachine = readMachineCpuTimes();
|
|
197
229
|
ticks = 0;
|
|
198
230
|
stalls = 0;
|
|
199
231
|
blockedMs = 0;
|
|
@@ -211,11 +243,12 @@ export function stopLagMonitor(): void {
|
|
|
211
243
|
clearInterval(timer);
|
|
212
244
|
timer = null;
|
|
213
245
|
lastCpu = null;
|
|
246
|
+
lastMachine = null;
|
|
214
247
|
}
|
|
215
248
|
|
|
216
249
|
export function lagReport(): LagReport {
|
|
217
250
|
const uptimeMs = startedAt ? Date.now() - startedAt : 0;
|
|
218
|
-
const byCause: Record<LagCause, number> = { self: 0, starved: 0, mixed: 0 };
|
|
251
|
+
const byCause: Record<LagCause, number> = { self: 0, starved: 0, blocked: 0, mixed: 0 };
|
|
219
252
|
for (const s of samples) byCause[s.cause]++;
|
|
220
253
|
return {
|
|
221
254
|
running: timer !== null,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How busy the whole machine's CPUs were between two readings.
|
|
3
|
+
*
|
|
4
|
+
* A process that got little CPU during a stall was either *starved* (every core was
|
|
5
|
+
* busy with someone else) or *blocked* (cores were free, and it was waiting on a disk
|
|
6
|
+
* read, a page-in, a lock or a child process). Its own CPU time cannot tell the two
|
|
7
|
+
* apart; the machine's can.
|
|
8
|
+
*/
|
|
9
|
+
import { cpus } from "node:os";
|
|
10
|
+
|
|
11
|
+
export interface MachineCpuTimes {
|
|
12
|
+
/** Sum over every core of time spent not idle, in ms. */
|
|
13
|
+
busy: number;
|
|
14
|
+
/** Sum over every core of all time, in ms. */
|
|
15
|
+
total: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readMachineCpuTimes(): MachineCpuTimes | null {
|
|
19
|
+
try {
|
|
20
|
+
let busy = 0;
|
|
21
|
+
let total = 0;
|
|
22
|
+
for (const c of cpus()) {
|
|
23
|
+
const t = c.times;
|
|
24
|
+
const all = t.user + t.nice + t.sys + t.idle + t.irq;
|
|
25
|
+
total += all;
|
|
26
|
+
busy += all - t.idle;
|
|
27
|
+
}
|
|
28
|
+
return total > 0 ? { busy, total } : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Share of all cores that were busy between `a` and `b`, 0-1; null when it cannot be told. */
|
|
35
|
+
export function machineBusyFraction(a: MachineCpuTimes | null, b: MachineCpuTimes | null): number | null {
|
|
36
|
+
if (!a || !b) return null;
|
|
37
|
+
const total = b.total - a.total;
|
|
38
|
+
if (total <= 0) return null;
|
|
39
|
+
return Math.min(1, Math.max(0, (b.busy - a.busy) / total));
|
|
40
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This process's CPU, memory and I/O priority on Windows, and raising them back to normal.
|
|
3
|
+
*
|
|
4
|
+
* A process started by a Task Scheduler task at the default priority 7 — which is how
|
|
5
|
+
* PPM's autostart task was registered until it was changed to 5 — runs below normal on
|
|
6
|
+
* CPU, at memory priority 2 instead of 5, and at I/O priority Low instead of Normal.
|
|
7
|
+
* Every child inherits that, the server included. Windows takes pages from a
|
|
8
|
+
* low-memory-priority process first, and reading them back happens at that process's
|
|
9
|
+
* I/O priority. Measured on 26/10/2026: the server's main thread sat in `PageIn` waits
|
|
10
|
+
* for 23s on an idle NVMe disk with the machine at 13% CPU, and stalls like it, 20-60s
|
|
11
|
+
* long and several an hour, were what the supervisor's health check kept killing it for.
|
|
12
|
+
* Raising the three priorities to normal on the running process needs no privilege.
|
|
13
|
+
*
|
|
14
|
+
* Only ever raises: a priority someone set higher on purpose is left alone.
|
|
15
|
+
* Every read is `undefined` when it fails, and nothing here throws.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface ProcessPriority {
|
|
19
|
+
/** Priority class name, e.g. "normal", "below-normal". */
|
|
20
|
+
cpu?: string;
|
|
21
|
+
/** Memory priority, 1 (very low) to 5 (normal). */
|
|
22
|
+
memory?: number;
|
|
23
|
+
/** I/O priority, 0 (very low) to 2 (normal); 3 is high. */
|
|
24
|
+
io?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CLASS_NAMES: Record<number, string> = {
|
|
28
|
+
0x40: "idle",
|
|
29
|
+
0x4000: "below-normal",
|
|
30
|
+
0x20: "normal",
|
|
31
|
+
0x8000: "above-normal",
|
|
32
|
+
0x80: "high",
|
|
33
|
+
0x100: "realtime",
|
|
34
|
+
};
|
|
35
|
+
const NORMAL_PRIORITY_CLASS = 0x20;
|
|
36
|
+
/** Classes below normal, the only ones this raises. */
|
|
37
|
+
const LOW_CLASSES = new Set(["idle", "below-normal"]);
|
|
38
|
+
export const MEMORY_PRIORITY_NORMAL = 5;
|
|
39
|
+
export const IO_PRIORITY_NORMAL = 2;
|
|
40
|
+
|
|
41
|
+
/** `GetProcessInformation` / `SetProcessInformation` class. */
|
|
42
|
+
const PROCESS_MEMORY_PRIORITY = 0;
|
|
43
|
+
/** `NtQueryInformationProcess` / `NtSetInformationProcess` class `ProcessIoPriority`. */
|
|
44
|
+
const PROCESS_IO_PRIORITY = 33;
|
|
45
|
+
/** `(HANDLE)-1`, the pseudo handle for the calling process. */
|
|
46
|
+
const CURRENT_PROCESS = -1n;
|
|
47
|
+
|
|
48
|
+
/** What to change to bring `p` up to normal; an empty object when nothing is below it. Exported for tests. */
|
|
49
|
+
export function priorityRaisePlan(p: ProcessPriority): { cpu?: true; memory?: true; io?: true } {
|
|
50
|
+
const plan: { cpu?: true; memory?: true; io?: true } = {};
|
|
51
|
+
if (p.cpu !== undefined && LOW_CLASSES.has(p.cpu)) plan.cpu = true;
|
|
52
|
+
if (p.memory !== undefined && p.memory < MEMORY_PRIORITY_NORMAL) plan.memory = true;
|
|
53
|
+
if (p.io !== undefined && p.io < IO_PRIORITY_NORMAL) plan.io = true;
|
|
54
|
+
return plan;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** "cpu=below-normal memory=2/5 io=1/2", for one log line. */
|
|
58
|
+
export function describePriority(p: ProcessPriority): string {
|
|
59
|
+
const v = (x: unknown) => (x === undefined ? "?" : String(x));
|
|
60
|
+
return `cpu=${v(p.cpu)} memory=${v(p.memory)}/${MEMORY_PRIORITY_NORMAL} io=${v(p.io)}/${IO_PRIORITY_NORMAL}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type Kernel32 = {
|
|
64
|
+
GetPriorityClass: (process: bigint) => number;
|
|
65
|
+
SetPriorityClass: (process: bigint, cls: number) => number;
|
|
66
|
+
GetProcessInformation: (process: bigint, cls: number, info: number, size: number) => number;
|
|
67
|
+
SetProcessInformation: (process: bigint, cls: number, info: number, size: number) => number;
|
|
68
|
+
};
|
|
69
|
+
type Ntdll = {
|
|
70
|
+
NtQueryInformationProcess: (process: bigint, cls: number, info: number, size: number, ret: number | null) => number;
|
|
71
|
+
NtSetInformationProcess: (process: bigint, cls: number, info: number, size: number) => number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let libs: { k32: Kernel32; nt: Ntdll } | null | undefined;
|
|
75
|
+
|
|
76
|
+
async function load(): Promise<{ k32: Kernel32; nt: Ntdll } | null> {
|
|
77
|
+
if (libs !== undefined) return libs;
|
|
78
|
+
try {
|
|
79
|
+
const { dlopen, FFIType } = await import("bun:ffi");
|
|
80
|
+
const k32 = dlopen("kernel32.dll", {
|
|
81
|
+
GetPriorityClass: { args: [FFIType.i64], returns: FFIType.u32 },
|
|
82
|
+
SetPriorityClass: { args: [FFIType.i64, FFIType.u32], returns: FFIType.i32 },
|
|
83
|
+
GetProcessInformation: { args: [FFIType.i64, FFIType.i32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
|
84
|
+
SetProcessInformation: { args: [FFIType.i64, FFIType.i32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
|
85
|
+
}).symbols as unknown as Kernel32;
|
|
86
|
+
const nt = dlopen("ntdll.dll", {
|
|
87
|
+
NtQueryInformationProcess: { args: [FFIType.i64, FFIType.i32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
|
88
|
+
NtSetInformationProcess: { args: [FFIType.i64, FFIType.i32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
|
89
|
+
}).symbols as unknown as Ntdll;
|
|
90
|
+
libs = { k32, nt };
|
|
91
|
+
} catch {
|
|
92
|
+
libs = null;
|
|
93
|
+
}
|
|
94
|
+
return libs;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** This process's priorities. Empty off Windows or when the calls are unavailable. */
|
|
98
|
+
export async function readProcessPriority(platform: NodeJS.Platform = process.platform): Promise<ProcessPriority> {
|
|
99
|
+
if (platform !== "win32") return {};
|
|
100
|
+
try {
|
|
101
|
+
const l = await load();
|
|
102
|
+
if (!l) return {};
|
|
103
|
+
const { ptr } = await import("bun:ffi");
|
|
104
|
+
const out: ProcessPriority = {};
|
|
105
|
+
const cls = l.k32.GetPriorityClass(CURRENT_PROCESS);
|
|
106
|
+
if (cls) out.cpu = CLASS_NAMES[cls] ?? `0x${cls.toString(16)}`;
|
|
107
|
+
const mem = new Uint32Array(1);
|
|
108
|
+
if (l.k32.GetProcessInformation(CURRENT_PROCESS, PROCESS_MEMORY_PRIORITY, ptr(mem), 4) !== 0) out.memory = mem[0];
|
|
109
|
+
const io = new Uint32Array(1);
|
|
110
|
+
if (l.nt.NtQueryInformationProcess(CURRENT_PROCESS, PROCESS_IO_PRIORITY, ptr(io), 4, null) === 0) out.io = io[0];
|
|
111
|
+
return out;
|
|
112
|
+
} catch {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Raise whatever is below normal to normal. Returns the reading before and after, so the
|
|
119
|
+
* caller can log what it found — a below-normal reading is the evidence, whether or not
|
|
120
|
+
* the raise worked.
|
|
121
|
+
*/
|
|
122
|
+
export async function raiseToNormalPriority(
|
|
123
|
+
platform: NodeJS.Platform = process.platform,
|
|
124
|
+
): Promise<{ before: ProcessPriority; after: ProcessPriority; changed: boolean }> {
|
|
125
|
+
const before = await readProcessPriority(platform);
|
|
126
|
+
const plan = priorityRaisePlan(before);
|
|
127
|
+
if (!plan.cpu && !plan.memory && !plan.io) return { before, after: before, changed: false };
|
|
128
|
+
try {
|
|
129
|
+
const l = await load();
|
|
130
|
+
if (!l) return { before, after: before, changed: false };
|
|
131
|
+
const { ptr } = await import("bun:ffi");
|
|
132
|
+
if (plan.cpu) l.k32.SetPriorityClass(CURRENT_PROCESS, NORMAL_PRIORITY_CLASS);
|
|
133
|
+
if (plan.memory) {
|
|
134
|
+
const mem = new Uint32Array([MEMORY_PRIORITY_NORMAL]);
|
|
135
|
+
l.k32.SetProcessInformation(CURRENT_PROCESS, PROCESS_MEMORY_PRIORITY, ptr(mem), 4);
|
|
136
|
+
}
|
|
137
|
+
if (plan.io) {
|
|
138
|
+
const io = new Uint32Array([IO_PRIORITY_NORMAL]);
|
|
139
|
+
l.nt.NtSetInformationProcess(CURRENT_PROCESS, PROCESS_IO_PRIORITY, ptr(io), 4);
|
|
140
|
+
}
|
|
141
|
+
} catch { /* the reading after says what took */ }
|
|
142
|
+
return { before, after: await readProcessPriority(platform), changed: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Raise to normal and log it — only when something was below normal, since that reading
|
|
147
|
+
* is the one worth keeping: it says the process ran demoted until this moment.
|
|
148
|
+
*/
|
|
149
|
+
export async function logPriorityRaise(role: string, warn: (message: string) => void): Promise<void> {
|
|
150
|
+
const { before, after, changed } = await raiseToNormalPriority();
|
|
151
|
+
if (!changed) return;
|
|
152
|
+
warn(`${role} started below normal priority (${describePriority(before)}), raised to ${describePriority(after)}`);
|
|
153
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What another process's main thread is doing right now, read from outside it.
|
|
3
|
+
*
|
|
4
|
+
* When the server stops answering, nothing inside it can say why: its event loop is
|
|
5
|
+
* the thing that stopped. The supervisor can still look, and one reading taken while
|
|
6
|
+
* the health probe is timing out names the wait — `WrPageIn` (memory being read back
|
|
7
|
+
* from disk), `Executive` (a kernel wait such as a synchronous file read), `WrQueue`
|
|
8
|
+
* (idle, so the loop is fine and the probe was slow elsewhere), or running (our own
|
|
9
|
+
* synchronous work). That one word is what located the 20-60s stalls of 26/10/2026,
|
|
10
|
+
* which were invisible to every log PPM kept: an outside watcher found the main
|
|
11
|
+
* thread in `PageIn` for 23s with the machine at 13% CPU.
|
|
12
|
+
*
|
|
13
|
+
* Also returned: the process's hard page faults and CPU time, so two readings give a
|
|
14
|
+
* rate. Windows reads all of it with one `NtQuerySystemInformation` call; Linux reads
|
|
15
|
+
* `/proc/<pid>/stat` and the main task's `wchan`. Every field is `undefined` when it
|
|
16
|
+
* cannot be read, and nothing here throws.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
|
|
20
|
+
export interface ThreadSnapshot {
|
|
21
|
+
/** Main thread scheduler state: "running", "waiting", "ready", … (Linux: R, S, D, … spelled out). */
|
|
22
|
+
state?: string;
|
|
23
|
+
/** Windows `KWAIT_REASON` name while waiting; Linux `wchan` (the kernel function it sleeps in). */
|
|
24
|
+
waitReason?: string;
|
|
25
|
+
threads?: number;
|
|
26
|
+
/** Page faults that went to disk, since the process started. */
|
|
27
|
+
hardFaults?: number;
|
|
28
|
+
/** User + kernel CPU of the whole process since it started, in ms. */
|
|
29
|
+
cpuMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const THREAD_STATES = [
|
|
33
|
+
"initialized", "ready", "running", "standby", "terminated", "waiting", "transition",
|
|
34
|
+
"deferred-ready", "gate-wait", "waiting-for-outswap",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** `KWAIT_REASON`, in order. */
|
|
38
|
+
const WAIT_REASONS = [
|
|
39
|
+
"Executive", "FreePage", "PageIn", "PoolAllocation", "DelayExecution", "Suspended", "UserRequest",
|
|
40
|
+
"WrExecutive", "WrFreePage", "WrPageIn", "WrPoolAllocation", "WrDelayExecution", "WrSuspended",
|
|
41
|
+
"WrUserRequest", "WrSpare0", "WrQueue", "WrLpcReceive", "WrLpcReply", "WrVirtualMemory", "WrPageOut",
|
|
42
|
+
"WrRendezvous", "WrKeyedEvent", "WrTerminated", "WrProcessInSwap", "WrCpuRateControl",
|
|
43
|
+
"WrCalloutStack", "WrKernel", "WrResource", "WrPushLock", "WrMutex", "WrQuantumEnd",
|
|
44
|
+
"WrDispatchInt", "WrPreempted", "WrYieldExecution", "WrFastMutex", "WrGuardedMutex", "WrRundown",
|
|
45
|
+
"WrAlertByThreadId", "WrDeferredPreempt", "WrPhysicalFault",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// SYSTEM_PROCESS_INFORMATION on 64-bit Windows.
|
|
49
|
+
const P_NEXT = 0x00;
|
|
50
|
+
const P_THREADS = 0x04;
|
|
51
|
+
const P_HARD_FAULTS = 0x10;
|
|
52
|
+
const P_USER_TIME = 0x28;
|
|
53
|
+
const P_KERNEL_TIME = 0x30;
|
|
54
|
+
const P_PID = 0x50;
|
|
55
|
+
const P_SIZE = 0x100;
|
|
56
|
+
// SYSTEM_THREAD_INFORMATION, which follow it one after another.
|
|
57
|
+
const T_CREATE_TIME = 0x10;
|
|
58
|
+
const T_STATE = 0x44;
|
|
59
|
+
const T_WAIT_REASON = 0x48;
|
|
60
|
+
const T_SIZE = 0x50;
|
|
61
|
+
/** Process and thread times are in 100 ns units. */
|
|
62
|
+
const TICKS_PER_MS = 10_000n;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Find `pid` in a `SystemProcessInformation` buffer and describe its main thread — the
|
|
66
|
+
* one created first. Exported for tests.
|
|
67
|
+
*/
|
|
68
|
+
export function parseWindowsProcessInformation(view: DataView, pid: number): ThreadSnapshot | null {
|
|
69
|
+
let at = 0;
|
|
70
|
+
for (;;) {
|
|
71
|
+
if (at + P_SIZE > view.byteLength) return null;
|
|
72
|
+
if (Number(view.getBigUint64(at + P_PID, true)) === pid) {
|
|
73
|
+
const threads = view.getUint32(at + P_THREADS, true);
|
|
74
|
+
const cpu = view.getBigInt64(at + P_USER_TIME, true) + view.getBigInt64(at + P_KERNEL_TIME, true);
|
|
75
|
+
const out: ThreadSnapshot = {
|
|
76
|
+
threads,
|
|
77
|
+
hardFaults: view.getUint32(at + P_HARD_FAULTS, true),
|
|
78
|
+
cpuMs: Number(cpu / TICKS_PER_MS),
|
|
79
|
+
};
|
|
80
|
+
let main = -1;
|
|
81
|
+
let mainCreated = 0n;
|
|
82
|
+
for (let i = 0; i < threads; i++) {
|
|
83
|
+
const t = at + P_SIZE + i * T_SIZE;
|
|
84
|
+
if (t + T_SIZE > view.byteLength) break;
|
|
85
|
+
const created = view.getBigInt64(t + T_CREATE_TIME, true);
|
|
86
|
+
if (main === -1 || created < mainCreated) { main = t; mainCreated = created; }
|
|
87
|
+
}
|
|
88
|
+
if (main !== -1) {
|
|
89
|
+
const state = view.getUint32(main + T_STATE, true);
|
|
90
|
+
out.state = THREAD_STATES[state] ?? `state-${state}`;
|
|
91
|
+
if (out.state === "waiting") {
|
|
92
|
+
const reason = view.getUint32(main + T_WAIT_REASON, true);
|
|
93
|
+
out.waitReason = WAIT_REASONS[reason] ?? `reason-${reason}`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
const next = view.getUint32(at + P_NEXT, true);
|
|
99
|
+
if (next === 0) return null;
|
|
100
|
+
at += next;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type Ntdll = { NtQuerySystemInformation: (cls: number, buf: number, len: number, ret: number) => number };
|
|
105
|
+
let ntdll: Ntdll | null | undefined;
|
|
106
|
+
|
|
107
|
+
const SYSTEM_PROCESS_INFORMATION = 5;
|
|
108
|
+
const STATUS_INFO_LENGTH_MISMATCH = 0xc0000004 | 0;
|
|
109
|
+
/** Grows on a length mismatch; a busy machine needs ~1-2 MB. */
|
|
110
|
+
let bufferSize = 2 * 1024 * 1024;
|
|
111
|
+
|
|
112
|
+
async function readWindows(pid: number): Promise<ThreadSnapshot | null> {
|
|
113
|
+
if (ntdll === undefined) {
|
|
114
|
+
try {
|
|
115
|
+
const { dlopen, FFIType } = await import("bun:ffi");
|
|
116
|
+
ntdll = dlopen("ntdll.dll", {
|
|
117
|
+
NtQuerySystemInformation: { args: [FFIType.i32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
|
118
|
+
}).symbols as unknown as Ntdll;
|
|
119
|
+
} catch {
|
|
120
|
+
ntdll = null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!ntdll) return null;
|
|
124
|
+
const { ptr } = await import("bun:ffi");
|
|
125
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
126
|
+
const buf = new Uint8Array(bufferSize);
|
|
127
|
+
const needed = new Uint32Array(1);
|
|
128
|
+
const status = ntdll.NtQuerySystemInformation(SYSTEM_PROCESS_INFORMATION, ptr(buf), buf.byteLength, ptr(needed));
|
|
129
|
+
if (status === STATUS_INFO_LENGTH_MISMATCH) {
|
|
130
|
+
bufferSize = Math.max(bufferSize * 2, (needed[0] ?? 0) + 256 * 1024);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (status !== 0) return null;
|
|
134
|
+
return parseWindowsProcessInformation(new DataView(buf.buffer), pid);
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const LINUX_STATES: Record<string, string> = {
|
|
140
|
+
R: "running", S: "sleeping", D: "disk-wait", T: "stopped", t: "traced", Z: "zombie", X: "dead",
|
|
141
|
+
I: "idle", W: "paging", P: "parked",
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** `/proc/<pid>/stat`: state, threads, major faults, CPU. Exported for tests. */
|
|
145
|
+
export function parseLinuxStat(text: string, clockTicksPerSecond = 100): ThreadSnapshot | null {
|
|
146
|
+
// The command name is in parentheses and may itself hold spaces or parentheses.
|
|
147
|
+
const close = text.lastIndexOf(")");
|
|
148
|
+
if (close === -1) return null;
|
|
149
|
+
const f = text.slice(close + 2).trim().split(/\s+/);
|
|
150
|
+
// f[0] is field 3 (state); field N is f[N - 3].
|
|
151
|
+
const state = f[0];
|
|
152
|
+
const majflt = Number(f[12 - 3]);
|
|
153
|
+
const utime = Number(f[14 - 3]);
|
|
154
|
+
const stime = Number(f[15 - 3]);
|
|
155
|
+
const threads = Number(f[20 - 3]);
|
|
156
|
+
const out: ThreadSnapshot = {};
|
|
157
|
+
if (state) out.state = LINUX_STATES[state] ?? state;
|
|
158
|
+
if (Number.isFinite(majflt)) out.hardFaults = majflt;
|
|
159
|
+
if (Number.isFinite(utime) && Number.isFinite(stime)) out.cpuMs = Math.round(((utime + stime) * 1000) / clockTicksPerSecond);
|
|
160
|
+
if (Number.isFinite(threads)) out.threads = threads;
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readLinux(pid: number): ThreadSnapshot | null {
|
|
165
|
+
const out = parseLinuxStat(readFileSync(`/proc/${pid}/stat`, "utf8"));
|
|
166
|
+
if (!out) return null;
|
|
167
|
+
// The process's own stat reports the main task's state; wchan says where it sleeps.
|
|
168
|
+
try {
|
|
169
|
+
const wchan = readFileSync(`/proc/${pid}/task/${pid}/wchan`, "utf8").trim();
|
|
170
|
+
if (wchan && wchan !== "0") out.waitReason = wchan;
|
|
171
|
+
} catch { /* wchan is optional, and hidden on some kernels */ }
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One reading of `pid`. Null when the platform cannot be read or the process is gone. */
|
|
176
|
+
export async function readThreadSnapshot(pid: number, platform: NodeJS.Platform = process.platform): Promise<ThreadSnapshot | null> {
|
|
177
|
+
try {
|
|
178
|
+
if (platform === "win32") return await readWindows(pid);
|
|
179
|
+
if (platform === "linux") return readLinux(pid);
|
|
180
|
+
} catch { /* fall through */ }
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** "main thread waiting/WrPageIn, hard faults +38, cpu +120ms" — relative to `previous` when given. */
|
|
185
|
+
export function describeThreadSnapshot(now: ThreadSnapshot, previous?: ThreadSnapshot | null): string {
|
|
186
|
+
const parts: string[] = [];
|
|
187
|
+
if (now.state) parts.push(`main thread ${now.state}${now.waitReason ? `/${now.waitReason}` : ""}`);
|
|
188
|
+
if (now.hardFaults !== undefined) {
|
|
189
|
+
parts.push(previous?.hardFaults !== undefined ? `hard faults +${now.hardFaults - previous.hardFaults}` : `hard faults ${now.hardFaults}`);
|
|
190
|
+
}
|
|
191
|
+
if (now.cpuMs !== undefined && previous?.cpuMs !== undefined) parts.push(`server cpu +${now.cpuMs - previous.cpuMs}ms`);
|
|
192
|
+
return parts.join(", ");
|
|
193
|
+
}
|
|
@@ -41,6 +41,9 @@ import {
|
|
|
41
41
|
} from "./edge-target-resolver.ts";
|
|
42
42
|
import { EDGE_PRIOR_DEATHS_ENV } from "./edge-forwarder.ts";
|
|
43
43
|
import { PLIST_LABEL } from "./autostart-generator.ts";
|
|
44
|
+
import { logPriorityRaise } from "./process-priority.ts";
|
|
45
|
+
import { readThreadSnapshot, describeThreadSnapshot, type ThreadSnapshot } from "./process-thread-snapshot.ts";
|
|
46
|
+
import { readMachineCpuTimes, machineBusyFraction, type MachineCpuTimes } from "./machine-cpu.ts";
|
|
44
47
|
|
|
45
48
|
// ─── Constants ─────────────────────────────────────────────────────────
|
|
46
49
|
const MAX_RESTARTS = 10;
|
|
@@ -1193,6 +1196,41 @@ async function runDbBackupTick(reason: "hourly" | "start"): Promise<void> {
|
|
|
1193
1196
|
}
|
|
1194
1197
|
|
|
1195
1198
|
// ─── Health checks ─────────────────────────────────────────────────────
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* The server and the machine as of the last probe, so a failed probe can report rates
|
|
1202
|
+
* over the 30s before it rather than totals since boot.
|
|
1203
|
+
*/
|
|
1204
|
+
let serverBaseline: { pid: number; thread: ThreadSnapshot | null; machine: MachineCpuTimes | null } | null = null;
|
|
1205
|
+
|
|
1206
|
+
async function recordServerBaseline(pid: number | undefined): Promise<void> {
|
|
1207
|
+
if (!pid) return;
|
|
1208
|
+
serverBaseline = { pid, thread: await readThreadSnapshot(pid), machine: readMachineCpuTimes() };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* What the server is doing while it does not answer: its main thread's wait, the
|
|
1213
|
+
* page faults that went to disk and its CPU since the last probe, and how busy the
|
|
1214
|
+
* machine was. A failed probe used to say only "timeout", which left a 60s stall with
|
|
1215
|
+
* no cause on record once the server was restarted.
|
|
1216
|
+
*/
|
|
1217
|
+
async function describeUnresponsiveServer(pid: number | undefined): Promise<string> {
|
|
1218
|
+
if (!pid) return "";
|
|
1219
|
+
try {
|
|
1220
|
+
const thread = await readThreadSnapshot(pid);
|
|
1221
|
+
const machine = readMachineCpuTimes();
|
|
1222
|
+
const before = serverBaseline?.pid === pid ? serverBaseline : null;
|
|
1223
|
+
const parts: string[] = [];
|
|
1224
|
+
if (thread) parts.push(describeThreadSnapshot(thread, before?.thread));
|
|
1225
|
+
const busy = machineBusyFraction(before?.machine ?? null, machine);
|
|
1226
|
+
if (busy !== null) parts.push(`machine cpu ${Math.round(busy * 100)}%`);
|
|
1227
|
+
serverBaseline = { pid, thread, machine };
|
|
1228
|
+
return parts.filter(Boolean).join(", ");
|
|
1229
|
+
} catch {
|
|
1230
|
+
return "";
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1196
1234
|
function startServerHealthCheck() {
|
|
1197
1235
|
healthTimer = setInterval(async () => {
|
|
1198
1236
|
if (shuttingDown || getState() === "stopped") return;
|
|
@@ -1232,14 +1270,16 @@ function startServerHealthCheck() {
|
|
|
1232
1270
|
const res = await fetch(`http://127.0.0.1:${checkPort}/api/health`, {
|
|
1233
1271
|
signal: AbortSignal.timeout(5000),
|
|
1234
1272
|
});
|
|
1235
|
-
if (res.ok) { healthFailCount = 0; return; }
|
|
1273
|
+
if (res.ok) { healthFailCount = 0; await recordServerBaseline(serverChild?.pid); return; }
|
|
1236
1274
|
lastProbe = `HTTP ${res.status}`;
|
|
1237
1275
|
} catch (e) {
|
|
1238
1276
|
const err = e as { name?: string; code?: string; message?: string } | null;
|
|
1239
1277
|
lastProbe = err?.name === "TimeoutError" ? "timeout after 5s" : err?.code ?? err?.message ?? String(e);
|
|
1240
1278
|
}
|
|
1241
1279
|
healthFailCount++;
|
|
1242
|
-
|
|
1280
|
+
// Read while the server is still not answering: the main thread's wait is the cause.
|
|
1281
|
+
const waitingOn = await describeUnresponsiveServer(serverChild?.pid);
|
|
1282
|
+
log("WARN", `Server health probe on port ${checkPort} failed (${healthFailCount}/${SERVER_HEALTH_FAIL_THRESHOLD}): ${lastProbe}${waitingOn ? ` — ${waitingOn}` : ""}`);
|
|
1243
1283
|
if (healthFailCount >= SERVER_HEALTH_FAIL_THRESHOLD && serverChild) {
|
|
1244
1284
|
log("ERROR", `Server unresponsive for ${healthFailCount}×${SERVER_HEALTH_INTERVAL_MS / 1000}s (last probe: ${lastProbe}) — killing PID ${serverChild.pid}`);
|
|
1245
1285
|
const pid = serverChild.pid;
|
|
@@ -2020,6 +2060,8 @@ export async function runSupervisor(opts: {
|
|
|
2020
2060
|
const logFd = openSync(logFile(), "a");
|
|
2021
2061
|
startLogRotation();
|
|
2022
2062
|
log("INFO", `Supervisor started (PID: ${process.pid}, port: ${opts.port}, share: ${opts.share})`);
|
|
2063
|
+
// Before any child is spawned: every child inherits these, the server included.
|
|
2064
|
+
await logPriorityRaise("supervisor", (m) => log("WARN", m));
|
|
2023
2065
|
|
|
2024
2066
|
// ── Systemd self-heal: if the unit file is stale (e.g. still has
|
|
2025
2067
|
// Restart=on-failure), we were likely spawned by the old Bun.spawn()
|