@bridge4dev/runner 0.54.0 → 0.55.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/dist/adapters/claude.js +10 -2
- package/dist/adapters/codex.js +33 -0
- package/dist/index.js +25 -4
- package/dist/service-unit.d.ts +45 -9
- package/dist/service-unit.js +71 -21
- package/dist/session-cage.d.ts +223 -6
- package/dist/session-cage.js +473 -30
- package/dist/supervisor.d.ts +49 -0
- package/dist/supervisor.js +170 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/session-cage.js
CHANGED
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { runnerIdentity, systemdUserEnv } from './environment.js';
|
|
8
8
|
import { log } from './log.js';
|
|
9
|
-
import { DEVBRIDGE_SLICE, SESSION_CPU_WEIGHT, SESSIONS_SLICE } from './service-unit.js';
|
|
9
|
+
import { DEVBRIDGE_SLICE, memoryPolicy, readMemoryFacts, readSelfCgroup, readSwapTotalBytes, SESSION_CPU_WEIGHT, SESSIONS_SLICE, SESSIONS_SWAP_SHARE, sliceCgroupPath, } from './service-unit.js';
|
|
10
10
|
const execFileAsync = promisify(execFile);
|
|
11
11
|
/**
|
|
12
12
|
* A cgroup of its own for every agent session — the memory ceiling that
|
|
@@ -25,17 +25,28 @@ const execFileAsync = promisify(execFile);
|
|
|
25
25
|
* `agent-sessions-host-resources.md` §5.4.1), not a preference. The ones that
|
|
26
26
|
* cost the most to learn:
|
|
27
27
|
*
|
|
28
|
-
* - `MemorySwapMax
|
|
29
|
-
* process under `MemoryMax=200M` allocated 2 GB and drained the host's
|
|
28
|
+
* - `MemorySwapMax` is BOUNDED, never unset. Without a bound there is no cage:
|
|
29
|
+
* a process under `MemoryMax=200M` allocated 2 GB and drained the host's
|
|
30
30
|
* entire swap (`memory.events` read `max 0, oom_kill 0`, swap 2047/2047 MB)
|
|
31
31
|
* — the exact incident this whole plan exists to prevent, reproduced by the
|
|
32
32
|
* supposed fix. `MemoryMax` bounds RESIDENT memory; the rest goes to swap,
|
|
33
|
-
* and swap belongs to the machine, not to the cgroup.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
33
|
+
* and swap belongs to the machine, not to the cgroup. 0.54.0 wrote `0`;
|
|
34
|
+
* since #387 it is a SHARE of the machine's swap (see
|
|
35
|
+
* {@link sessionSwapMaxBytes}), because without any swap the brake below
|
|
36
|
+
* has nothing to push pages into and stalls instead of slowing.
|
|
37
|
+
* - `MemoryHigh` UNDER `MemoryMax`, and far under it (#387). 0.54.0 shipped
|
|
38
|
+
* the hard line alone, at 2.5 GiB, with systemd's default `OOMPolicy=stop`:
|
|
39
|
+
* an honest `pnpm typecheck` (seven `tsc` at ~400 MB each) went over it, the
|
|
40
|
+
* kernel killed ONE `tsc`, and systemd then stopped the WHOLE scope — the
|
|
41
|
+
* session died with «exited with code 143», twice in an hour, on correct
|
|
42
|
+
* work. The owner's rule is that honest work never reaches a kill: the
|
|
43
|
+
* honest share is where the brake starts, the wall is the machine's measured
|
|
44
|
+
* headroom, and only a real runaway ever gets that far — and then
|
|
45
|
+
* `OOMPolicy=continue` costs it the one process, not the session.
|
|
46
|
+
* The spike's «60 s in, still alive at 100 MB» under `MemoryHigh` was a
|
|
47
|
+
* runaway with NO swap to be pushed into — that is the kernel's penalty
|
|
48
|
+
* (up to 2 s per 256 KB when nothing can be reclaimed), and it is why the
|
|
49
|
+
* swap share above is part of the same change, not a separate one.
|
|
39
50
|
* - no `--collect`. A scope killed by the OOM killer stays behind in `failed`,
|
|
40
51
|
* and `systemctl --user show <unit> -p Result` is the ONLY place the cause
|
|
41
52
|
* can be read: `Result=oom-kill`. `--collect` deletes the unit the instant it
|
|
@@ -74,10 +85,14 @@ const execFileAsync = promisify(execFile);
|
|
|
74
85
|
const MIB = 1024 * 1024;
|
|
75
86
|
const GIB = 1024 * MIB;
|
|
76
87
|
/**
|
|
77
|
-
*
|
|
88
|
+
* The honest share of one session — where the BRAKE starts, not where it dies.
|
|
89
|
+
*
|
|
90
|
+
* Until #387 this was `MemoryMax`, the kill line. It is the same number and the
|
|
91
|
+
* same reasoning, moved one step down the ladder: above it the kernel reclaims
|
|
92
|
+
* and throttles (`MemoryHigh`), and the kill line is {@link sessionMemoryMaxBytes}.
|
|
78
93
|
*
|
|
79
94
|
* `min(2.5 GiB, service ceiling / 2)` — §3 of
|
|
80
|
-
* `docs/plans/
|
|
95
|
+
* `docs/plans/shipped/agent-sessions-host-resources.md`. Both halves matter:
|
|
81
96
|
*
|
|
82
97
|
* - the absolute number is sized against the measurement, not against a round
|
|
83
98
|
* figure. The heaviest ordinary thing a session runs is a workspace
|
|
@@ -94,7 +109,7 @@ const GIB = 1024 * MIB;
|
|
|
94
109
|
* protects the machine is what systemd has in force, not what the drop-in on
|
|
95
110
|
* disk says — those two have already drifted apart once (§5.5 of the plan).
|
|
96
111
|
*/
|
|
97
|
-
export const
|
|
112
|
+
export const SESSION_MEMORY_HIGH_ABSOLUTE_BYTES = Math.round(2.5 * GIB);
|
|
98
113
|
/**
|
|
99
114
|
* Processes and threads per session.
|
|
100
115
|
*
|
|
@@ -128,7 +143,30 @@ export const SESSION_SCOPE_PREFIX = 'devbridge-session-';
|
|
|
128
143
|
* cost the one thing it does buy: the runaway dying instead of its neighbours
|
|
129
144
|
* (QA-2026-09-07 MINOR-6, where the earlier wording promised the opposite).
|
|
130
145
|
*/
|
|
131
|
-
export const
|
|
146
|
+
export const SESSION_MEMORY_HIGH_MIN_BYTES = 2 * GIB;
|
|
147
|
+
/**
|
|
148
|
+
* The wall, when nothing on the machine can say where it should be.
|
|
149
|
+
*
|
|
150
|
+
* Every real path reads the wall off systemd (the slice, else the service) or
|
|
151
|
+
* measures it (`memoryPolicy`). This is the last resort for a machine that gave
|
|
152
|
+
* neither — twice the honest share, so that even blind the wall sits above the
|
|
153
|
+
* brake by a margin an honest build fits into, and a runaway still meets one.
|
|
154
|
+
*/
|
|
155
|
+
export const SESSION_MEMORY_WALL_FALLBACK_FACTOR = 2;
|
|
156
|
+
/**
|
|
157
|
+
* The pool is cut into this many honest shares. Three, because that is what
|
|
158
|
+
* `maxSessions` defaults to on a dev server, so three sessions can sit at their
|
|
159
|
+
* brakes without the pool itself overflowing — and one runaway then meets its
|
|
160
|
+
* own wall (`ceiling − share`) while a neighbour on its share still fits.
|
|
161
|
+
*/
|
|
162
|
+
export const SESSION_SHARE_DIVISOR = 3;
|
|
163
|
+
/**
|
|
164
|
+
* On a machine too small to leave a share of room, the brake is this fraction
|
|
165
|
+
* of the wall. The same 0.8 `memoryPolicy` puts between the service's own
|
|
166
|
+
* `MemoryHigh` and `MemoryMax`, for the same reason: a band, however narrow,
|
|
167
|
+
* beats a bare kill line.
|
|
168
|
+
*/
|
|
169
|
+
export const SESSION_BRAKE_OF_WALL = 0.8;
|
|
132
170
|
/**
|
|
133
171
|
* `clamp(ceiling / 2, 2 GiB, 2.5 GiB)`, never above the ceiling itself.
|
|
134
172
|
*
|
|
@@ -147,14 +185,111 @@ export const SESSION_MEMORY_MIN_BYTES = 2 * GIB;
|
|
|
147
185
|
* hand, and then «never more than the slice» has to still be true
|
|
148
186
|
* (QA-2026-09-07 MINOR-5).
|
|
149
187
|
*/
|
|
150
|
-
export function
|
|
151
|
-
|
|
152
|
-
|
|
188
|
+
export function sessionMemoryHighBytes(containingMemoryMaxBytes) {
|
|
189
|
+
return sessionMemoryLadder(containingMemoryMaxBytes).highBytes;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The two numbers of the cage, computed in ONE place because each is wrong
|
|
193
|
+
* without the other (QA of #387 found both halves broken separately).
|
|
194
|
+
*
|
|
195
|
+
* ```
|
|
196
|
+
* brake = max(containing / 3, 2 GiB) capped by the containing ceiling
|
|
197
|
+
* wall = containing − brake never at or below the brake
|
|
198
|
+
* ```
|
|
199
|
+
*
|
|
200
|
+
* **Why a third, and no upper cap.** The owner's second note on #387: «ночью
|
|
201
|
+
* машина свободна (~9 ГБ), а сессии всё равно нельзя выйти за 2.5 ГБ… граница
|
|
202
|
+
* должна смотреть на то, сколько реально свободно». A fixed 2.5 GiB was right
|
|
203
|
+
* as a KILL line and is wrong as a brake: on a big machine it throttles honest
|
|
204
|
+
* work that the machine could have absorbed. A third of the pool is the largest
|
|
205
|
+
* share that still lets three sessions sit at their brakes inside the ceiling,
|
|
206
|
+
* which is what `maxSessions` defaults to. The 2 GiB floor stays — it is sized
|
|
207
|
+
* over the 1571 MB measured peak of a workspace `pnpm typecheck`, and a dev
|
|
208
|
+
* server that cannot run one of those is not a dev server.
|
|
209
|
+
*
|
|
210
|
+
* **Why the wall is `containing − brake` and not the ceiling itself.** 0.55.0's
|
|
211
|
+
* first shape put the wall AT the slice ceiling, and that is not a per-session
|
|
212
|
+
* wall at all: the kernel charges a leaf and every ancestor, so with any
|
|
213
|
+
* neighbour the SLICE overflows first, and then the victim is chosen across the
|
|
214
|
+
* whole slice. Measured on this host (a 300 MB slice, two 250 MB scopes): the
|
|
215
|
+
* scope that crept up survived to the end, and the **neighbour holding 200 MB
|
|
216
|
+
* under its own wall was killed** — slice `max 19, oom 1, oom_kill 1`, victim
|
|
217
|
+
* `max 0, oom 0, oom_kill 1`. Leaving one honest share of room under the
|
|
218
|
+
* ceiling is what makes the runaway meet its OWN wall first while a neighbour
|
|
219
|
+
* on its share still fits.
|
|
220
|
+
*
|
|
221
|
+
* **The tiny machine.** When the ceiling is so low that `containing − brake`
|
|
222
|
+
* would land at or under the brake, there is no room for isolation at all: the
|
|
223
|
+
* wall becomes the ceiling and the BRAKE is lowered to 80 % of it, so that
|
|
224
|
+
* there is still a braking band (`memoryPolicy` uses the same 0.8 for the
|
|
225
|
+
* service). Without this, a 4 GB machine got `MemoryHigh == MemoryMax` — the
|
|
226
|
+
* 0.54.0 kill line back in place, with the card claiming a brake.
|
|
227
|
+
*/
|
|
228
|
+
export function sessionMemoryLadder(containingMemoryMaxBytes, measuredCeilingBytes = null) {
|
|
229
|
+
const containing = [containingMemoryMaxBytes, measuredCeilingBytes].find((value) => value !== null && Number.isFinite(value) && value > 0);
|
|
230
|
+
if (containing === undefined) {
|
|
231
|
+
// Nothing on this machine could say what contains the session. The
|
|
232
|
+
// reference share, and a wall well above it, so the ladder still exists.
|
|
233
|
+
return {
|
|
234
|
+
highBytes: SESSION_MEMORY_HIGH_ABSOLUTE_BYTES,
|
|
235
|
+
maxBytes: SESSION_MEMORY_HIGH_ABSOLUTE_BYTES * SESSION_MEMORY_WALL_FALLBACK_FACTOR,
|
|
236
|
+
};
|
|
153
237
|
}
|
|
154
|
-
const
|
|
155
|
-
const
|
|
238
|
+
const ceiling = Math.floor(containing);
|
|
239
|
+
const share = Math.max(Math.floor(ceiling / SESSION_SHARE_DIVISOR), SESSION_MEMORY_HIGH_MIN_BYTES);
|
|
156
240
|
// Never promise a session more than the cgroup that contains every session.
|
|
157
|
-
|
|
241
|
+
const brake = Math.max(1, Math.min(share, ceiling));
|
|
242
|
+
const wall = ceiling - brake;
|
|
243
|
+
if (wall <= brake) {
|
|
244
|
+
return {
|
|
245
|
+
highBytes: Math.max(1, Math.floor(ceiling * SESSION_BRAKE_OF_WALL)),
|
|
246
|
+
maxBytes: ceiling,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return { highBytes: brake, maxBytes: wall };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The wall — `MemoryMax` on the scope — and why it is the WHOLE containing
|
|
253
|
+
* ceiling and not a fraction of it (#387).
|
|
254
|
+
*
|
|
255
|
+
* The owner's rule: the wall is protection against a runaway, not the norm of
|
|
256
|
+
* work. Where the norm lives is {@link sessionMemoryHighBytes}; the wall has to
|
|
257
|
+
* be far enough above it that honest work never touches it, and «far» on a dev
|
|
258
|
+
* server means «what the machine can actually spare» — which is exactly the
|
|
259
|
+
* number the slice ceiling already is (`memoryPolicy`: `MemAvailable` plus what
|
|
260
|
+
* we hold, minus a reserve). Giving a session less than that is the 2.5 GiB
|
|
261
|
+
* mistake with a different number.
|
|
262
|
+
*
|
|
263
|
+
* With `OOMPolicy=continue` on the scope, reaching the wall costs a runaway its
|
|
264
|
+
* fattest process and nothing else; the neighbours are protected by the slice,
|
|
265
|
+
* which holds the same number over all of them together.
|
|
266
|
+
*
|
|
267
|
+
* `containing` is what systemd has in force on the slice (else the service);
|
|
268
|
+
* `measured` is what the policy would write there; the factor is the last
|
|
269
|
+
* resort. Never below the brake — a wall under the brake is a kill line again.
|
|
270
|
+
*/
|
|
271
|
+
export function sessionMemoryMaxBytes(containingMemoryMaxBytes, measuredCeilingBytes = null) {
|
|
272
|
+
return sessionMemoryLadder(containingMemoryMaxBytes, measuredCeilingBytes).maxBytes;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Swap one session may push into — the reason the brake slows instead of stalls.
|
|
276
|
+
*
|
|
277
|
+
* Half of what the slice may use, the same «no single session takes the
|
|
278
|
+
* collective allowance» rule as the memory share. When systemd reports no
|
|
279
|
+
* bound on the slice (the drop-in never landed, MAJOR-3 shape) the share is cut
|
|
280
|
+
* from the machine's `SwapTotal` directly, with the same fraction the slice
|
|
281
|
+
* would have had, so the per-scope line is a real bound on its own. A machine
|
|
282
|
+
* with no swap gets 0, which is exactly 0.54.0's line — and exactly right:
|
|
283
|
+
* there is nothing to share.
|
|
284
|
+
*/
|
|
285
|
+
export function sessionSwapMaxBytes(sliceSwapMaxBytes, hostSwapTotalBytes) {
|
|
286
|
+
if (sliceSwapMaxBytes !== null && Number.isFinite(sliceSwapMaxBytes)) {
|
|
287
|
+
return Math.max(0, Math.floor(sliceSwapMaxBytes / 2));
|
|
288
|
+
}
|
|
289
|
+
if (hostSwapTotalBytes !== null && Number.isFinite(hostSwapTotalBytes)) {
|
|
290
|
+
return Math.max(0, Math.floor((hostSwapTotalBytes * SESSIONS_SWAP_SHARE) / 2));
|
|
291
|
+
}
|
|
292
|
+
return 0;
|
|
158
293
|
}
|
|
159
294
|
// ─── unit names ──────────────────────────────────────────────────────
|
|
160
295
|
/**
|
|
@@ -297,9 +432,14 @@ async function runLiveProbe(options) {
|
|
|
297
432
|
// that tests a different command line proves nothing about the real one.
|
|
298
433
|
...(options.expandEnvironmentFlag ? ['--expand-environment=no'] : []),
|
|
299
434
|
'-p',
|
|
435
|
+
`MemoryHigh=${PROBE_MEMORY_MAX / 2}`,
|
|
436
|
+
'-p',
|
|
300
437
|
`MemoryMax=${PROBE_MEMORY_MAX}`,
|
|
301
438
|
'-p',
|
|
302
439
|
'MemorySwapMax=0',
|
|
440
|
+
// The one property an older systemd refuses for a scope — the probe is
|
|
441
|
+
// where that refusal has to surface, so the real spawn never meets it.
|
|
442
|
+
...(options.oomPolicyFlag ? ['-p', OOM_POLICY_CONTINUE] : []),
|
|
303
443
|
'-p',
|
|
304
444
|
'TasksMax=16',
|
|
305
445
|
'-p',
|
|
@@ -355,15 +495,31 @@ async function readSystemdRunVersion() {
|
|
|
355
495
|
}
|
|
356
496
|
}
|
|
357
497
|
async function showMemoryMax(unit) {
|
|
498
|
+
const value = await showByteCount(unit, 'MemoryMax');
|
|
499
|
+
return value !== null && value > 0 ? value : null;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* A byte-count property as systemd has it in force. `infinity` and «no answer»
|
|
503
|
+
* are both null; 0 is kept, because for `MemorySwapMax` zero is the whole point.
|
|
504
|
+
*/
|
|
505
|
+
async function showByteCount(unit, property) {
|
|
358
506
|
try {
|
|
359
|
-
const { stdout } = await execFileAsync('systemctl', ['--user', 'show', unit, '-p',
|
|
360
|
-
const
|
|
361
|
-
|
|
507
|
+
const { stdout } = await execFileAsync('systemctl', ['--user', 'show', unit, '-p', property, '--value'], { timeout: 10_000, env: systemdUserEnv() });
|
|
508
|
+
const raw = stdout.trim();
|
|
509
|
+
if (!/^\d+$/.test(raw))
|
|
510
|
+
return null;
|
|
511
|
+
const value = Number(raw);
|
|
512
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
362
513
|
}
|
|
363
514
|
catch {
|
|
364
515
|
return null;
|
|
365
516
|
}
|
|
366
517
|
}
|
|
518
|
+
/** The ceiling the policy would write to the slice today — the measured wall. */
|
|
519
|
+
function measuredMachineCeiling() {
|
|
520
|
+
const facts = readMemoryFacts();
|
|
521
|
+
return facts ? memoryPolicy(facts).maxBytes : null;
|
|
522
|
+
}
|
|
367
523
|
export const defaultCageProbe = {
|
|
368
524
|
cgroupFsType: () => statfsType('/sys/fs/cgroup'),
|
|
369
525
|
systemdRunOnPath: () => onPath('systemd-run'),
|
|
@@ -378,6 +534,9 @@ export const defaultCageProbe = {
|
|
|
378
534
|
probeMemoryMax: runLiveProbe,
|
|
379
535
|
serviceMemoryMax: () => showMemoryMax('devbridge-runner'),
|
|
380
536
|
sessionsSliceMemoryMax: () => showMemoryMax(SESSIONS_SLICE),
|
|
537
|
+
sessionsSliceSwapMax: () => showByteCount(SESSIONS_SLICE, 'MemorySwapMax'),
|
|
538
|
+
hostSwapTotalBytes: readSwapTotalBytes,
|
|
539
|
+
machineCeilingBytes: measuredMachineCeiling,
|
|
381
540
|
canRenice: () => {
|
|
382
541
|
try {
|
|
383
542
|
// A no-op that still asks the kernel the question: setting our own
|
|
@@ -396,7 +555,10 @@ export async function detectSessionCage(probe = defaultCageProbe) {
|
|
|
396
555
|
const fallback = (reason) => ({
|
|
397
556
|
mode: probe.canRenice() ? 'nice-only' : 'none',
|
|
398
557
|
reason,
|
|
558
|
+
memoryHighBytes: null,
|
|
399
559
|
memoryMaxBytes: null,
|
|
560
|
+
swapMaxBytes: null,
|
|
561
|
+
oomContinue: false,
|
|
400
562
|
serviceMemoryMaxBytes: null,
|
|
401
563
|
sessionsSliceMemoryMaxBytes: null,
|
|
402
564
|
expandEnvironmentFlag: false,
|
|
@@ -426,7 +588,18 @@ export async function detectSessionCage(probe = defaultCageProbe) {
|
|
|
426
588
|
// work is not the thing that runs.
|
|
427
589
|
const systemdVersion = await probe.systemdRunVersion();
|
|
428
590
|
const expandEnvironmentFlag = systemdVersion !== null && systemdVersion >= EXPAND_ENVIRONMENT_MIN_SYSTEMD;
|
|
429
|
-
|
|
591
|
+
// `OOMPolicy=` on a SCOPE is newer than the cage's other switches, and which
|
|
592
|
+
// release added it is not documented well enough to gate on a number (this
|
|
593
|
+
// host, systemd 255, takes it; an older systemd answers «Unknown assignment»
|
|
594
|
+
// and spawns NOTHING at all). So the probe decides: try with it, and on a
|
|
595
|
+
// refusal that names the property try once more without — that machine keeps
|
|
596
|
+
// its cage and loses only the «one process, not the session» half (#387).
|
|
597
|
+
let oomPolicyFlag = true;
|
|
598
|
+
let probed = await probe.probeMemoryMax({ expandEnvironmentFlag, oomPolicyFlag });
|
|
599
|
+
if (probed.error !== null && refusesOomPolicy(probed.error)) {
|
|
600
|
+
oomPolicyFlag = false;
|
|
601
|
+
probed = await probe.probeMemoryMax({ expandEnvironmentFlag, oomPolicyFlag });
|
|
602
|
+
}
|
|
430
603
|
if (probed.error !== null) {
|
|
431
604
|
// Not «the cage did not hold» — nothing was ever caged. Blaming `memory.max`
|
|
432
605
|
// for a refused command line sent people to fix delegation that was fine.
|
|
@@ -437,17 +610,36 @@ export async function detectSessionCage(probe = defaultCageProbe) {
|
|
|
437
610
|
}
|
|
438
611
|
const serviceMemoryMaxBytes = await probe.serviceMemoryMax();
|
|
439
612
|
const sessionsSliceMemoryMaxBytes = await probe.sessionsSliceMemoryMax();
|
|
613
|
+
const sliceSwapMaxBytes = await probe.sessionsSliceSwapMax();
|
|
614
|
+
// The slice is what actually contains the session; the service is the
|
|
615
|
+
// fallback for a systemd that would not answer about the slice at all.
|
|
616
|
+
const containing = sessionsSliceMemoryMaxBytes ?? serviceMemoryMaxBytes;
|
|
617
|
+
const ladder = sessionMemoryLadder(containing, probe.machineCeilingBytes());
|
|
440
618
|
return {
|
|
441
619
|
mode: 'scope',
|
|
442
620
|
reason: '',
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
621
|
+
memoryHighBytes: ladder.highBytes,
|
|
622
|
+
memoryMaxBytes: ladder.maxBytes,
|
|
623
|
+
swapMaxBytes: sessionSwapMaxBytes(sliceSwapMaxBytes, probe.hostSwapTotalBytes()),
|
|
624
|
+
oomContinue: oomPolicyFlag,
|
|
446
625
|
serviceMemoryMaxBytes,
|
|
447
626
|
sessionsSliceMemoryMaxBytes,
|
|
448
627
|
expandEnvironmentFlag,
|
|
449
628
|
};
|
|
450
629
|
}
|
|
630
|
+
/** The `-p` assignment itself, in one place: the probe and the spawn must agree. */
|
|
631
|
+
const OOM_POLICY_CONTINUE = 'OOMPolicy=continue';
|
|
632
|
+
/**
|
|
633
|
+
* Did `systemd-run` refuse the command line BECAUSE of `OOMPolicy=`?
|
|
634
|
+
*
|
|
635
|
+
* systemd's wording for a property a unit type does not take is «Unknown
|
|
636
|
+
* assignment: OOMPolicy=continue» (`bus_append_unit_property_assignment`); a
|
|
637
|
+
* refusal that does not name the property is some other machine's problem and
|
|
638
|
+
* must not be retried into a cage with a weaker policy.
|
|
639
|
+
*/
|
|
640
|
+
export function refusesOomPolicy(error) {
|
|
641
|
+
return /OOMPolicy/i.test(error);
|
|
642
|
+
}
|
|
451
643
|
/**
|
|
452
644
|
* Before the detector has run, nothing is wrapped.
|
|
453
645
|
*
|
|
@@ -459,7 +651,10 @@ export async function detectSessionCage(probe = defaultCageProbe) {
|
|
|
459
651
|
const UNPROBED = {
|
|
460
652
|
mode: 'nice-only',
|
|
461
653
|
reason: 'not probed yet',
|
|
654
|
+
memoryHighBytes: null,
|
|
462
655
|
memoryMaxBytes: null,
|
|
656
|
+
swapMaxBytes: null,
|
|
657
|
+
oomContinue: false,
|
|
463
658
|
serviceMemoryMaxBytes: null,
|
|
464
659
|
sessionsSliceMemoryMaxBytes: null,
|
|
465
660
|
expandEnvironmentFlag: false,
|
|
@@ -477,7 +672,10 @@ export async function initSessionCage(probe = defaultCageProbe) {
|
|
|
477
672
|
if (detected.mode === 'scope') {
|
|
478
673
|
log.info('session cage: each session gets its own cgroup', {
|
|
479
674
|
slice: SESSIONS_SLICE,
|
|
675
|
+
memoryHighMB: Math.round((detected.memoryHighBytes ?? 0) / MIB),
|
|
480
676
|
memoryMaxMB: Math.round((detected.memoryMaxBytes ?? 0) / MIB),
|
|
677
|
+
swapMaxMB: Math.round((detected.swapMaxBytes ?? 0) / MIB),
|
|
678
|
+
oomPolicy: detected.oomContinue ? 'continue' : 'stop (this systemd takes none on a scope)',
|
|
481
679
|
serviceMemoryMaxMB: detected.serviceMemoryMaxBytes
|
|
482
680
|
? Math.round(detected.serviceMemoryMaxBytes / MIB)
|
|
483
681
|
: null,
|
|
@@ -517,6 +715,16 @@ export function sessionCage() {
|
|
|
517
715
|
* is provably gone — see {@link sessionScopeUnit}.
|
|
518
716
|
*/
|
|
519
717
|
const attempts = new Map();
|
|
718
|
+
/**
|
|
719
|
+
* The scope each caged id is CURRENTLY in, so the supervisor's memory watch can
|
|
720
|
+
* find the cgroup of a live session without the adapter having to hand it up.
|
|
721
|
+
* Set in {@link cageSpawn}, cleared in {@link releaseSessionScope}.
|
|
722
|
+
*/
|
|
723
|
+
const liveUnits = new Map();
|
|
724
|
+
/** The scope a live caged id runs in, or null when it is not caged (or gone). */
|
|
725
|
+
export function sessionScopeUnitOf(id) {
|
|
726
|
+
return liveUnits.get(id) ?? null;
|
|
727
|
+
}
|
|
520
728
|
/**
|
|
521
729
|
* Wrap a command in its session's cage, or hand it back untouched.
|
|
522
730
|
*
|
|
@@ -532,12 +740,21 @@ const attempts = new Map();
|
|
|
532
740
|
*/
|
|
533
741
|
export function cageSpawn(input) {
|
|
534
742
|
const facts = sessionCage();
|
|
535
|
-
if (facts.mode !== 'scope' ||
|
|
743
|
+
if (facts.mode !== 'scope' ||
|
|
744
|
+
facts.memoryMaxBytes === null ||
|
|
745
|
+
facts.memoryHighBytes === null ||
|
|
746
|
+
facts.swapMaxBytes === null) {
|
|
536
747
|
return { command: input.command, args: input.args, env: {}, unit: null };
|
|
537
748
|
}
|
|
538
749
|
const attempt = (attempts.get(input.id) ?? 0) + 1;
|
|
539
750
|
attempts.set(input.id, attempt);
|
|
540
751
|
const unit = sessionScopeUnit(input.id, attempt);
|
|
752
|
+
liveUnits.set(input.id, unit);
|
|
753
|
+
// A verdict nobody read belongs to the process that just ended, not to the
|
|
754
|
+
// one starting now: without this, a kill that was never surfaced would be
|
|
755
|
+
// pinned hours later on an unrelated crash of the next process (#387 QA).
|
|
756
|
+
deaths.delete(input.id);
|
|
757
|
+
oomKillsSeen.delete(input.id);
|
|
541
758
|
return {
|
|
542
759
|
command: 'systemd-run',
|
|
543
760
|
args: [
|
|
@@ -550,14 +767,26 @@ export function cageSpawn(input) {
|
|
|
550
767
|
// Only where systemd knows the switch — below 254 it is a hard error and
|
|
551
768
|
// `--scope` does no expansion anyway (module header, MAJOR-2).
|
|
552
769
|
...(facts.expandEnvironmentFlag ? ['--expand-environment=no'] : []),
|
|
770
|
+
// The ladder of #387, bottom to top. The brake: past the honest share the
|
|
771
|
+
// kernel reclaims and slows this session down, and nothing dies.
|
|
772
|
+
'-p',
|
|
773
|
+
`MemoryHigh=${facts.memoryHighBytes}`,
|
|
774
|
+
// The wall: the machine's measured headroom, far above the brake. Only a
|
|
775
|
+
// runaway gets here, and with `OOMPolicy=continue` it costs one process.
|
|
553
776
|
'-p',
|
|
554
777
|
`MemoryMax=${facts.memoryMaxBytes}`,
|
|
555
|
-
// THE line. Without
|
|
778
|
+
// THE line. Without a bound there is no cage at all: `MemoryMax` bounds
|
|
556
779
|
// resident memory and lets the rest fall into the host's swap, which is
|
|
557
780
|
// how a 200 MB cage allocated 2 GB and took the machine's swap with it.
|
|
558
|
-
//
|
|
781
|
+
// Bounded, never unset — and since #387 a share rather than 0, because
|
|
782
|
+
// the brake above has to have somewhere to push pages (module header).
|
|
559
783
|
'-p',
|
|
560
|
-
|
|
784
|
+
`MemorySwapMax=${facts.swapMaxBytes}`,
|
|
785
|
+
// Only where the probe proved this systemd takes it on a scope. Without
|
|
786
|
+
// it the kernel killing ONE process stops the WHOLE scope (`OOMPolicy`
|
|
787
|
+
// defaults to `stop`) — the exact mechanism that killed honest sessions
|
|
788
|
+
// in 0.54.0, and the same lesson `service-unit.ts` learned in QA-112.
|
|
789
|
+
...(facts.oomContinue ? ['-p', OOM_POLICY_CONTINUE] : []),
|
|
561
790
|
'-p',
|
|
562
791
|
`TasksMax=${SESSION_TASKS_MAX}`,
|
|
563
792
|
'-p',
|
|
@@ -583,6 +812,167 @@ export function cageSpawn(input) {
|
|
|
583
812
|
export function killedBeforeExec(info) {
|
|
584
813
|
return info.caged && !info.sawOutput && info.code === null && info.signal === 'SIGTERM';
|
|
585
814
|
}
|
|
815
|
+
/**
|
|
816
|
+
* The pure half of {@link readScopeMemoryStatus}: the four files' text in, the
|
|
817
|
+
* status out. `memory.swap.current` is optional (no swap controller, cgroup v2
|
|
818
|
+
* without swap accounting); the rest are not.
|
|
819
|
+
*/
|
|
820
|
+
export function parseScopeMemoryStatus(files) {
|
|
821
|
+
const count = (raw) => {
|
|
822
|
+
const trimmed = raw.trim();
|
|
823
|
+
if (!/^\d+$/.test(trimmed))
|
|
824
|
+
return null;
|
|
825
|
+
const value = Number(trimmed);
|
|
826
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
827
|
+
};
|
|
828
|
+
const limit = (raw) => (raw.trim() === 'max' ? null : count(raw));
|
|
829
|
+
const event = (name) => {
|
|
830
|
+
const line = files.events.split('\n').find((l) => l.startsWith(`${name} `));
|
|
831
|
+
return line ? (count(line.slice(name.length + 1)) ?? 0) : 0;
|
|
832
|
+
};
|
|
833
|
+
const currentBytes = count(files.current);
|
|
834
|
+
if (currentBytes === null)
|
|
835
|
+
return null;
|
|
836
|
+
return {
|
|
837
|
+
currentBytes,
|
|
838
|
+
highBytes: limit(files.high),
|
|
839
|
+
maxBytes: limit(files.max),
|
|
840
|
+
swapCurrentBytes: files.swapCurrent === undefined ? 0 : (count(files.swapCurrent) ?? 0),
|
|
841
|
+
highEvents: event('high'),
|
|
842
|
+
oomKills: event('oom_kill'),
|
|
843
|
+
ownLimitOom: event('oom'),
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
/** Where a session scope's cgroup lives, or null off a user manager. */
|
|
847
|
+
function scopeCgroupDir(unit) {
|
|
848
|
+
const self = readSelfCgroup();
|
|
849
|
+
if (self === null)
|
|
850
|
+
return null;
|
|
851
|
+
const slice = sliceCgroupPath(self, SESSIONS_SLICE);
|
|
852
|
+
return slice === null ? null : path.join(slice, unit);
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* What one live session's cgroup holds and has been through. Null when the
|
|
856
|
+
* cgroup is not there (the scope ended, or this machine has no cage).
|
|
857
|
+
*
|
|
858
|
+
* Filesystem, not `systemctl show`: this runs on the supervisor's 30 s tick for
|
|
859
|
+
* every live session, and the bus is the thing that took 2.7 s under load.
|
|
860
|
+
*/
|
|
861
|
+
export function readScopeMemoryStatus(unit, readFile = (p) => fs.readFileSync(p, 'utf8')) {
|
|
862
|
+
const dir = scopeCgroupDir(unit);
|
|
863
|
+
if (dir === null)
|
|
864
|
+
return null;
|
|
865
|
+
try {
|
|
866
|
+
let swapCurrent;
|
|
867
|
+
try {
|
|
868
|
+
swapCurrent = readFile(path.join(dir, 'memory.swap.current'));
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
swapCurrent = undefined;
|
|
872
|
+
}
|
|
873
|
+
return parseScopeMemoryStatus({
|
|
874
|
+
current: readFile(path.join(dir, 'memory.current')),
|
|
875
|
+
high: readFile(path.join(dir, 'memory.high')),
|
|
876
|
+
max: readFile(path.join(dir, 'memory.max')),
|
|
877
|
+
events: readFile(path.join(dir, 'memory.events')),
|
|
878
|
+
...(swapCurrent === undefined ? {} : { swapCurrent }),
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
catch {
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* `oom_kill` as the watch last saw it, per id — so a kill that was already
|
|
887
|
+
* announced in the feed while the session lived is not blamed for a death that
|
|
888
|
+
* came later for another reason.
|
|
889
|
+
*/
|
|
890
|
+
const oomKillsSeen = new Map();
|
|
891
|
+
export function markScopeOomKillsSeen(id, oomKills) {
|
|
892
|
+
oomKillsSeen.set(id, oomKills);
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* «a process in it was stopped» / «3 processes in it were stopped», so the verb
|
|
896
|
+
* agrees. Shared with the supervisor's feed notices, which count the same
|
|
897
|
+
* thing and must not word it differently.
|
|
898
|
+
*/
|
|
899
|
+
export function stoppedProcesses(count, where = 'it') {
|
|
900
|
+
return count === 1
|
|
901
|
+
? `a process in ${where} was stopped`
|
|
902
|
+
: `${count} processes in ${where} were stopped`;
|
|
903
|
+
}
|
|
904
|
+
const deaths = new Map();
|
|
905
|
+
/**
|
|
906
|
+
* Snapshot the cgroup's verdict before systemd can take it away.
|
|
907
|
+
*
|
|
908
|
+
* Exported for the test seam only (`readStatus`): the real caller passes
|
|
909
|
+
* nothing and reads the live cgroup.
|
|
910
|
+
*/
|
|
911
|
+
export function rememberDeath(id, unit, readStatus = readScopeMemoryStatus) {
|
|
912
|
+
const status = readStatus(unit);
|
|
913
|
+
const seen = oomKillsSeen.get(id) ?? 0;
|
|
914
|
+
oomKillsSeen.delete(id);
|
|
915
|
+
if (status === null || status.oomKills <= seen)
|
|
916
|
+
return;
|
|
917
|
+
deaths.set(id, {
|
|
918
|
+
oomKills: status.oomKills - seen,
|
|
919
|
+
maxBytes: status.maxBytes,
|
|
920
|
+
// `oom` moves only where the limit that was hit belongs. A kill with our
|
|
921
|
+
// own counter still at zero came from an ancestor — the pool.
|
|
922
|
+
reached: status.ownLimitOom > 0 ? 'own-ceiling' : 'shared-pool',
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* The other source of the same verdict, for the machine where the first one is
|
|
927
|
+
* already gone (#387 QA).
|
|
928
|
+
*
|
|
929
|
+
* On a systemd that takes no `OOMPolicy` on a scope, one kill stops the whole
|
|
930
|
+
* scope; systemd then removes the cgroup as soon as it is empty, and the
|
|
931
|
+
* snapshot above can find nothing at all. `Result=oom-kill` survives that —
|
|
932
|
+
* it is read a few milliseconds later from the unit, which is why
|
|
933
|
+
* {@link releaseSessionScope} is now awaited before a death is explained.
|
|
934
|
+
*/
|
|
935
|
+
function rememberDeathFromResult(id) {
|
|
936
|
+
if (deaths.has(id))
|
|
937
|
+
return;
|
|
938
|
+
deaths.set(id, {
|
|
939
|
+
oomKills: 1,
|
|
940
|
+
maxBytes: sessionCage().memoryMaxBytes,
|
|
941
|
+
// systemd stopped the unit for an OOM inside it; which limit was reached is
|
|
942
|
+
// not in `Result`, and neither claim may be guessed.
|
|
943
|
+
reached: 'unknown',
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* One sentence for the error the person reads, when the kernel had a hand in
|
|
948
|
+
* this death — or null when it had not. Consumed: a session restarted after an
|
|
949
|
+
* OOM must not carry the old sentence into its next, unrelated failure.
|
|
950
|
+
*
|
|
951
|
+
* The text that reached people in 0.54.0 was «exited with code 143» once and
|
|
952
|
+
* «terminated by signal SIGKILL» the next time, for the same cause, and neither
|
|
953
|
+
* said the word memory. This is that word.
|
|
954
|
+
*/
|
|
955
|
+
export function explainMemoryDeath(id) {
|
|
956
|
+
const death = deaths.get(id);
|
|
957
|
+
if (!death)
|
|
958
|
+
return null;
|
|
959
|
+
deaths.delete(id);
|
|
960
|
+
const stopped = stoppedProcesses(death.oomKills);
|
|
961
|
+
const ceiling = death.maxBytes === null ? '' : ` of ${Math.round(death.maxBytes / MIB)} MB`;
|
|
962
|
+
if (death.reached === 'own-ceiling') {
|
|
963
|
+
return (`This session reached its memory ceiling${ceiling} and ${stopped} — ` +
|
|
964
|
+
'send a message to carry on; if it keeps happening, the machine needs more memory.');
|
|
965
|
+
}
|
|
966
|
+
if (death.reached === 'shared-pool') {
|
|
967
|
+
return (`The machine ran out of memory for agent sessions and ${stoppedProcesses(death.oomKills, 'this one')} — ` +
|
|
968
|
+
'send a message to carry on; if it keeps happening, run fewer sessions at once or give the machine more memory.');
|
|
969
|
+
}
|
|
970
|
+
// Which ceiling was reached is unknowable here, so the sentence names no
|
|
971
|
+
// culprit: it has to be true whether this session was the greedy one or a
|
|
972
|
+
// bystander.
|
|
973
|
+
return (`There was not enough memory for this session and ${stopped} — ` +
|
|
974
|
+
'send a message to carry on; if it keeps happening, run fewer sessions at once or give the machine more memory.');
|
|
975
|
+
}
|
|
586
976
|
const realSystemctl = async (args) => {
|
|
587
977
|
const { stdout, stderr } = await execFileAsync('systemctl', ['--user', ...args], {
|
|
588
978
|
timeout: 15_000,
|
|
@@ -609,8 +999,57 @@ function showValue(stdout, property) {
|
|
|
609
999
|
* startable in the meantime.
|
|
610
1000
|
*/
|
|
611
1001
|
export async function releaseSessionScope(unit, id, systemctl = realSystemctl) {
|
|
1002
|
+
const running = releaseSessionScopeInner(unit, id, systemctl);
|
|
1003
|
+
if (id !== undefined) {
|
|
1004
|
+
releasing.set(id, running);
|
|
1005
|
+
void running.finally(() => {
|
|
1006
|
+
if (releasing.get(id) === running)
|
|
1007
|
+
releasing.delete(id);
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
return await running;
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Releases in flight, per cage id, so a death can be explained with BOTH
|
|
1014
|
+
* sources: the cgroup snapshot (synchronous, gone the moment systemd prunes the
|
|
1015
|
+
* group) and `Result=oom-kill` (authoritative, and a few milliseconds late).
|
|
1016
|
+
* Without the wait, the machine that needs the sentence most — the one whose
|
|
1017
|
+
* systemd stops the whole scope — was the one that never got it (#387 QA).
|
|
1018
|
+
*/
|
|
1019
|
+
const releasing = new Map();
|
|
1020
|
+
/**
|
|
1021
|
+
* The sentence for a death, once the verdict is in. Null when the kernel had no
|
|
1022
|
+
* hand in it. Waits for the release of this session's scope, but never longer
|
|
1023
|
+
* than `capMs`: an answer that arrives after the person has read the error is
|
|
1024
|
+
* worth nothing, and a hung `systemctl` must not hold a failing session open.
|
|
1025
|
+
*/
|
|
1026
|
+
export async function memoryDeathSentence(id, capMs = 3_000) {
|
|
1027
|
+
const pending = releasing.get(id);
|
|
1028
|
+
if (pending) {
|
|
1029
|
+
let timer;
|
|
1030
|
+
await Promise.race([
|
|
1031
|
+
pending.catch(() => null),
|
|
1032
|
+
new Promise((resolve) => {
|
|
1033
|
+
timer = setTimeout(resolve, capMs);
|
|
1034
|
+
timer.unref?.();
|
|
1035
|
+
}),
|
|
1036
|
+
]);
|
|
1037
|
+
if (timer)
|
|
1038
|
+
clearTimeout(timer);
|
|
1039
|
+
}
|
|
1040
|
+
return explainMemoryDeath(id);
|
|
1041
|
+
}
|
|
1042
|
+
async function releaseSessionScopeInner(unit, id, systemctl) {
|
|
612
1043
|
if (!unit)
|
|
613
1044
|
return null;
|
|
1045
|
+
// Synchronously and FIRST: the cgroup's own counters are the one record of an
|
|
1046
|
+
// OOM kill that survives `OOMPolicy=continue` (the unit does not fail, so
|
|
1047
|
+
// `Result` says nothing), and the directory goes away the moment the scope is
|
|
1048
|
+
// empty. This is what the adapter's error text is built from (#387).
|
|
1049
|
+
if (id !== undefined)
|
|
1050
|
+
rememberDeath(id, unit);
|
|
1051
|
+
if (id !== undefined)
|
|
1052
|
+
liveUnits.delete(id);
|
|
614
1053
|
let result = null;
|
|
615
1054
|
try {
|
|
616
1055
|
const { stdout } = await systemctl(['show', unit, '-p', 'Result']);
|
|
@@ -624,6 +1063,10 @@ export async function releaseSessionScope(unit, id, systemctl = realSystemctl) {
|
|
|
624
1063
|
unit,
|
|
625
1064
|
memoryMaxMB: Math.round((sessionCage().memoryMaxBytes ?? 0) / MIB),
|
|
626
1065
|
});
|
|
1066
|
+
// The cgroup snapshot above may have found nothing: on a systemd that stops
|
|
1067
|
+
// the whole scope, the group is gone by the time the process's `exit` fires.
|
|
1068
|
+
if (id !== undefined)
|
|
1069
|
+
rememberDeathFromResult(id);
|
|
627
1070
|
}
|
|
628
1071
|
let forgotten = false;
|
|
629
1072
|
try {
|