@bridge4dev/runner 0.54.0 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +217 -6
- package/dist/session-cage.js +458 -30
- package/dist/supervisor.d.ts +49 -0
- package/dist/supervisor.js +172 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -6,7 +6,7 @@ import { AsyncQueue } from '../async-queue.js';
|
|
|
6
6
|
import { log } from '../log.js';
|
|
7
7
|
import { mcpConfigPath } from '../paths.js';
|
|
8
8
|
import { lowerPriority } from '../process-priority.js';
|
|
9
|
-
import { cageSpawn, releaseSessionScope } from '../session-cage.js';
|
|
9
|
+
import { cageSpawn, memoryDeathSentence, releaseSessionScope } from '../session-cage.js';
|
|
10
10
|
import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
|
|
11
11
|
import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
|
|
12
12
|
import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
|
|
@@ -2444,9 +2444,17 @@ class ClaudeSession {
|
|
|
2444
2444
|
// appended it to as well.
|
|
2445
2445
|
const message = maskString(String(error instanceof Error ? error.message : error) + this.stderrSuffix());
|
|
2446
2446
|
const code = errorCode(message);
|
|
2447
|
+
// #387: «exited with code 143» and «terminated by signal SIGKILL» were the
|
|
2448
|
+
// two faces of one cause — the kernel's OOM killer inside this session's
|
|
2449
|
+
// cgroup — and neither said so. The cage remembers the kill counter at
|
|
2450
|
+
// exit; when it moved, the sentence about memory rides on the error.
|
|
2451
|
+
// Awaited, not read: on a systemd that stops the whole scope the cgroup
|
|
2452
|
+
// is already gone when the process's `exit` fires, and the only surviving
|
|
2453
|
+
// record is `Result=oom-kill`, which the release is still fetching.
|
|
2454
|
+
const memory = await memoryDeathSentence(this.spec.sessionId);
|
|
2447
2455
|
this.emit({
|
|
2448
2456
|
type: 'error',
|
|
2449
|
-
message: classifyRunError(message),
|
|
2457
|
+
message: memory ? `${classifyRunError(message)} ${memory}` : classifyRunError(message),
|
|
2450
2458
|
...(code ? { code } : {}),
|
|
2451
2459
|
// #373: this catch is the CLI process falling over under the read loop —
|
|
2452
2460
|
// the only place in this adapter where that is what happened. Our own
|
package/dist/adapters/codex.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AsyncQueue } from '../async-queue.js';
|
|
2
2
|
import { log } from '../log.js';
|
|
3
3
|
import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
|
|
4
|
+
import { memoryDeathSentence } from '../session-cage.js';
|
|
4
5
|
import { RUNNER_VERSION } from '../version.js';
|
|
5
6
|
import { repairCodexAuth } from './codex-home.js';
|
|
6
7
|
import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
|
|
@@ -1866,8 +1867,36 @@ class CodexSession {
|
|
|
1866
1867
|
processGone: true,
|
|
1867
1868
|
});
|
|
1868
1869
|
}
|
|
1870
|
+
if (!this.stopped)
|
|
1871
|
+
this.sayIfMemoryKilledIt();
|
|
1869
1872
|
this.finish();
|
|
1870
1873
|
}
|
|
1874
|
+
/**
|
|
1875
|
+
* «codex app-server exited (code null)» is the same sentence whether the CLI
|
|
1876
|
+
* crashed or the kernel took it for memory, and #387 is the ticket about
|
|
1877
|
+
* exactly that silence. The cage knows which it was; here it reaches the
|
|
1878
|
+
* person.
|
|
1879
|
+
*
|
|
1880
|
+
* A follow-up `notice` rather than a longer error message, because both death
|
|
1881
|
+
* paths in this adapter are synchronous by design (one is a notification
|
|
1882
|
+
* handler, the other a catch that closes the output queue on the next line),
|
|
1883
|
+
* and the verdict needs an await — `Result=oom-kill` is read after the exit.
|
|
1884
|
+
*/
|
|
1885
|
+
sayIfMemoryKilledIt() {
|
|
1886
|
+
if (this.memoryVerdictAsked)
|
|
1887
|
+
return;
|
|
1888
|
+
this.memoryVerdictAsked = true;
|
|
1889
|
+
void memoryDeathSentence(this.spec.sessionId)
|
|
1890
|
+
.then((sentence) => {
|
|
1891
|
+
if (sentence)
|
|
1892
|
+
this.emit({ type: 'notice', level: 'warn', text: sentence });
|
|
1893
|
+
})
|
|
1894
|
+
.catch(() => {
|
|
1895
|
+
// A verdict we could not fetch is no verdict; the error text stands.
|
|
1896
|
+
});
|
|
1897
|
+
}
|
|
1898
|
+
/** One verdict per process — both death paths can fire for the same exit. */
|
|
1899
|
+
memoryVerdictAsked = false;
|
|
1871
1900
|
// ─── Capabilities ──────────────────────────────────────────────────
|
|
1872
1901
|
refreshCapabilities() {
|
|
1873
1902
|
if (this.capabilitiesInFlight || this.stopped)
|
|
@@ -2120,6 +2149,10 @@ class CodexSession {
|
|
|
2120
2149
|
return code;
|
|
2121
2150
|
}
|
|
2122
2151
|
this.emit({ type: 'error', message: `Codex session error: ${message.slice(0, 1_000)}` });
|
|
2152
|
+
// The generic door: a pending request rejected because the app-server died
|
|
2153
|
+
// (`failAll`). If the kernel is why it died, say so (#387).
|
|
2154
|
+
if (/app-server exited|SIGKILL/i.test(message))
|
|
2155
|
+
this.sayIfMemoryKilledIt();
|
|
2123
2156
|
return null;
|
|
2124
2157
|
}
|
|
2125
2158
|
/**
|
package/dist/index.js
CHANGED
|
@@ -423,9 +423,27 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
423
423
|
// Why not `scope`, in one sentence, for the card to show under it.
|
|
424
424
|
// Empty string when the cage is on.
|
|
425
425
|
sessionCageReason: sessionCage().reason,
|
|
426
|
-
|
|
427
|
-
|
|
426
|
+
/**
|
|
427
|
+
* The ladder one session lives under (#387), in bytes. Null when there is
|
|
428
|
+
* no cage — then the only ceiling is the service's, over all of them.
|
|
429
|
+
*
|
|
430
|
+
* `High` is the brake: past it the kernel slows the session down instead
|
|
431
|
+
* of killing. `Max` is the wall, far above — the machine's measured
|
|
432
|
+
* headroom, where only a runaway ever arrives. Runners up to 0.54.0 sent
|
|
433
|
+
* `Max` alone, and it was the kill line at 2.5 GiB; a card that sees no
|
|
434
|
+
* `High` is looking at one of those.
|
|
435
|
+
*/
|
|
436
|
+
sessionMemoryHighBytes: sessionCage().memoryHighBytes,
|
|
428
437
|
sessionMemoryMaxBytes: sessionCage().memoryMaxBytes,
|
|
438
|
+
/** Swap one session may push into while braked. 0 on a machine without swap. */
|
|
439
|
+
sessionSwapMaxBytes: sessionCage().swapMaxBytes,
|
|
440
|
+
/**
|
|
441
|
+
* Whether reaching the wall costs the session ONE process (`OOMPolicy=
|
|
442
|
+
* continue` on the scope) or is left to that systemd's own default —
|
|
443
|
+
* older systemds do not take the setting on a scope at all. The card
|
|
444
|
+
* words the wall by it.
|
|
445
|
+
*/
|
|
446
|
+
sessionOomContinue: sessionCage().oomContinue,
|
|
429
447
|
/**
|
|
430
448
|
* The ceiling over ALL sessions, as systemd has it in force — not as the
|
|
431
449
|
* drop-in on disk says.
|
|
@@ -1425,7 +1443,10 @@ async function cmdDoctor(args) {
|
|
|
1425
1443
|
print(` ${devbridgeSliceOverridePath()}`);
|
|
1426
1444
|
if (cage.mode === 'scope') {
|
|
1427
1445
|
const mb = (bytes) => bytes === null ? 'infinity' : `${Math.round(bytes / 1024 / 1024)} MB`;
|
|
1428
|
-
print(` per session MemoryMax ${mb(cage.memoryMaxBytes)}, MemorySwapMax
|
|
1446
|
+
print(` per session MemoryHigh ${mb(cage.memoryHighBytes)} (brake: slows, never kills), MemoryMax ${mb(cage.memoryMaxBytes)} (wall), MemorySwapMax ${mb(cage.swapMaxBytes)}, TasksMax ${SESSION_TASKS_MAX}`);
|
|
1447
|
+
print(` at the wall ${cage.oomContinue
|
|
1448
|
+
? 'OOMPolicy=continue — the kernel kills the hungriest process, the session stays up'
|
|
1449
|
+
: 'this systemd does not take OOMPolicy on a scope (it is newer than the rest of the cage), so what happens at the wall is its own default — on systemd 253+ that is «stop the whole session»'}`);
|
|
1429
1450
|
// What systemd has IN FORCE on the slice, beside the paths of the files
|
|
1430
1451
|
// that were supposed to put it there. «В файле 7680M, действует 6.0G» is the
|
|
1431
1452
|
// lesson of §5.5, and the drop-in of a slice can fail to land in exactly the
|
|
@@ -1436,7 +1457,7 @@ async function cmdDoctor(args) {
|
|
|
1436
1457
|
print(' NOT CAPPED — the drop-in above has not been applied.');
|
|
1437
1458
|
print(` Apply with: ${systemctlHint('daemon-reload')}`);
|
|
1438
1459
|
}
|
|
1439
|
-
print(` service ceiling ${mb(cage.serviceMemoryMaxBytes)} (the per-session
|
|
1460
|
+
print(` service ceiling ${mb(cage.serviceMemoryMaxBytes)} (the per-session brake is half of the containing ceiling, capped at 2.5 GiB; the wall IS the containing ceiling)`);
|
|
1440
1461
|
print(` expand-env flag ${cage.expandEnvironmentFlag ? 'passed (systemd ≥ 254)' : 'not passed — this systemd does not know it, and --scope does not expand anyway'}`);
|
|
1441
1462
|
}
|
|
1442
1463
|
else {
|
package/dist/service-unit.d.ts
CHANGED
|
@@ -57,7 +57,7 @@ export declare function buildUnit(execStart?: string, nodeBinary?: string): stri
|
|
|
57
57
|
* which is the only way to fix the servers that already have the bad numbers
|
|
58
58
|
* baked in — and it never overwrites a unit the operator edited by hand.
|
|
59
59
|
*/
|
|
60
|
-
export declare const LIMITS_VERSION =
|
|
60
|
+
export declare const LIMITS_VERSION = 5;
|
|
61
61
|
/**
|
|
62
62
|
* Where the agent sessions live once they have a cage of their own.
|
|
63
63
|
*
|
|
@@ -152,10 +152,29 @@ export interface MemoryFacts {
|
|
|
152
152
|
sessionsFloorBytes?: number;
|
|
153
153
|
/** Seconds since boot. Below `BOOT_SETTLE_SEC` the measurement is a lie. */
|
|
154
154
|
uptimeSec: number;
|
|
155
|
+
/**
|
|
156
|
+
* `SwapTotal` — what the sessions' swap share is cut from (#387). Absent or 0
|
|
157
|
+
* means a machine without swap, and then the share is 0: the brake on such a
|
|
158
|
+
* machine can only drop page cache, and the wall is what stops a runaway.
|
|
159
|
+
*/
|
|
160
|
+
swapTotalBytes?: number;
|
|
155
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* How much of the machine's swap ALL sessions together may use (#387).
|
|
164
|
+
*
|
|
165
|
+
* Half, not all: swap belongs to the machine (the spike's 200 MB cage drained
|
|
166
|
+
* the host's 2 GB and the box hung), so the other half stays with the neighbours
|
|
167
|
+
* and the kernel. Each session gets half of this again (`sessionSwapMaxBytes`).
|
|
168
|
+
* On the 2 GB swap this machine has today that is 512 MB per session — enough
|
|
169
|
+
* for a build's overshoot, not for a runaway; the owner's note on #387 is that
|
|
170
|
+
* the swap itself has to grow for the brake to have real room.
|
|
171
|
+
*/
|
|
172
|
+
export declare const SESSIONS_SWAP_SHARE = 0.5;
|
|
156
173
|
export interface MemoryPolicy {
|
|
157
174
|
maxBytes: number;
|
|
158
175
|
highBytes: number;
|
|
176
|
+
/** `MemorySwapMax` for the sessions slice: `SESSIONS_SWAP_SHARE` of `SwapTotal`. */
|
|
177
|
+
swapMaxBytes: number;
|
|
159
178
|
/** false = the conservative static pair, because the machine was still booting. */
|
|
160
179
|
measured: boolean;
|
|
161
180
|
/**
|
|
@@ -221,6 +240,19 @@ export declare function managedUsageFloorBytes(facts: MemoryFacts): number;
|
|
|
221
240
|
* machine, and a ceiling of «everything» is the bug this function exists to fix.
|
|
222
241
|
*/
|
|
223
242
|
export declare function memoryPolicy(facts: MemoryFacts, minCeilingBytes?: number): MemoryPolicy;
|
|
243
|
+
/** `SwapTotal` of this machine, or null where `/proc/meminfo` will not say. */
|
|
244
|
+
export declare function readSwapTotalBytes(): number | null;
|
|
245
|
+
/**
|
|
246
|
+
* What our own cgroup currently holds.
|
|
247
|
+
*
|
|
248
|
+
* Read through `/proc/self/cgroup` rather than assembling the path from the
|
|
249
|
+
* service name: the runner runs as root and as a dedicated user, under
|
|
250
|
+
* `user@0.service` and under `user@1001.service`, and guessing that path wrong
|
|
251
|
+
* silently returns 0 — which would quietly shrink the ceiling by whatever we are
|
|
252
|
+
* already using. Returns 0 on cgroup v1 or in a container without the file, which
|
|
253
|
+
* is the safe direction: a slightly lower ceiling, never a higher one.
|
|
254
|
+
*/
|
|
255
|
+
export declare function readSelfCgroup(): string | null;
|
|
224
256
|
export declare function readOwnCgroupMemory(): CgroupMemory | null;
|
|
225
257
|
/**
|
|
226
258
|
* What the cgroup holds that `MemAvailable` has NOT already counted.
|
|
@@ -349,14 +381,18 @@ export declare function buildLimitsOverride(cpuCount?: number, facts?: MemoryFac
|
|
|
349
381
|
* not the guarantee `memoryPolicy` computes, and it is written down here rather
|
|
350
382
|
* than glossed over (QA-2026-09-07 MINOR-4).
|
|
351
383
|
*
|
|
352
|
-
* `MemorySwapMax
|
|
353
|
-
* resident memory alone is not a ceiling, it is a swap pump
|
|
354
|
-
* allocated 2 GB and drained the host's swap during the spike).
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
384
|
+
* `MemorySwapMax` is a BOUND on the slice for the same reason it is on every
|
|
385
|
+
* scope: a ceiling on resident memory alone is not a ceiling, it is a swap pump
|
|
386
|
+
* (a 200 MB cage allocated 2 GB and drained the host's swap during the spike).
|
|
387
|
+
* Since #387 the bound is a share of the machine's swap rather than 0
|
|
388
|
+
* (`SESSIONS_SWAP_SHARE`), because each session now carries a `MemoryHigh`
|
|
389
|
+
* brake, and a brake with no swap under it stalls the session instead of
|
|
390
|
+
* slowing it. The unmeasured branch still writes 0: no measurement, no swap to
|
|
391
|
+
* hand out.
|
|
392
|
+
*
|
|
393
|
+
* No `MemoryHigh` here. The brake belongs on each SCOPE (`session-cage.ts`):
|
|
394
|
+
* soft pressure on the slice would throttle every session on the machine to
|
|
395
|
+
* keep one runaway alive a little longer.
|
|
360
396
|
*
|
|
361
397
|
* `sessionsUsageBytes` is the door that used to lead around all of the above.
|
|
362
398
|
* `facts` is null whenever the SERVICE's `MemoryCurrent` is unreadable — a
|
package/dist/service-unit.js
CHANGED
|
@@ -129,7 +129,7 @@ export function buildUnit(execStart, nodeBinary = process.execPath) {
|
|
|
129
129
|
* which is the only way to fix the servers that already have the bad numbers
|
|
130
130
|
* baked in — and it never overwrites a unit the operator edited by hand.
|
|
131
131
|
*/
|
|
132
|
-
export const LIMITS_VERSION =
|
|
132
|
+
export const LIMITS_VERSION = 5;
|
|
133
133
|
const LIMITS_MARKER = '# devbridge-limits-version:';
|
|
134
134
|
/** `zz-` so it sorts last: an operator's own drop-in should still win. */
|
|
135
135
|
const LIMITS_FILE = 'zz-devbridge-limits.conf';
|
|
@@ -181,6 +181,17 @@ export function devbridgeSliceOverridePath(home = systemdUserHome()) {
|
|
|
181
181
|
}
|
|
182
182
|
const MIB = 1024 * 1024;
|
|
183
183
|
const GIB = 1024 * MIB;
|
|
184
|
+
/**
|
|
185
|
+
* How much of the machine's swap ALL sessions together may use (#387).
|
|
186
|
+
*
|
|
187
|
+
* Half, not all: swap belongs to the machine (the spike's 200 MB cage drained
|
|
188
|
+
* the host's 2 GB and the box hung), so the other half stays with the neighbours
|
|
189
|
+
* and the kernel. Each session gets half of this again (`sessionSwapMaxBytes`).
|
|
190
|
+
* On the 2 GB swap this machine has today that is 512 MB per session — enough
|
|
191
|
+
* for a build's overshoot, not for a runaway; the owner's note on #387 is that
|
|
192
|
+
* the swap itself has to grow for the brake to have real room.
|
|
193
|
+
*/
|
|
194
|
+
export const SESSIONS_SWAP_SHARE = 0.5;
|
|
184
195
|
/**
|
|
185
196
|
* How long after boot `MemAvailable` starts telling the truth.
|
|
186
197
|
*
|
|
@@ -268,9 +279,16 @@ const CEILING_FLOOR_BYTES = 2 * GIB;
|
|
|
268
279
|
*/
|
|
269
280
|
export function memoryPolicy(facts, minCeilingBytes = managedUsageFloorBytes(facts)) {
|
|
270
281
|
const { totalBytes, availableBytes, ownUsageBytes, sessionsUsageBytes, uptimeSec } = facts;
|
|
282
|
+
const swapMaxBytes = Math.floor(Math.max(0, facts.swapTotalBytes ?? 0) * SESSIONS_SWAP_SHARE);
|
|
271
283
|
const withFloor = (maxBytes, measured, starved = false) => {
|
|
272
284
|
const ceiling = Math.floor(Math.max(maxBytes, minCeilingBytes));
|
|
273
|
-
return {
|
|
285
|
+
return {
|
|
286
|
+
maxBytes: ceiling,
|
|
287
|
+
highBytes: Math.floor(ceiling * 0.8),
|
|
288
|
+
swapMaxBytes,
|
|
289
|
+
measured,
|
|
290
|
+
starved,
|
|
291
|
+
};
|
|
274
292
|
};
|
|
275
293
|
// Still booting: the neighbours have not claimed their memory yet, so measuring
|
|
276
294
|
// now would hand back almost the whole machine. Take a fraction of TOTAL
|
|
@@ -311,8 +329,8 @@ function clamp(value, low, high) {
|
|
|
311
329
|
function asMiB(bytes) {
|
|
312
330
|
return `${Math.max(1, Math.floor(bytes / MIB))}M`;
|
|
313
331
|
}
|
|
314
|
-
/** `
|
|
315
|
-
function
|
|
332
|
+
/** One `kB` field of `/proc/meminfo`, in bytes; null when absent or unreadable. */
|
|
333
|
+
function memInfoField(name) {
|
|
316
334
|
let text;
|
|
317
335
|
try {
|
|
318
336
|
text = fs.readFileSync('/proc/meminfo', 'utf8');
|
|
@@ -320,13 +338,20 @@ function readMemInfo() {
|
|
|
320
338
|
catch {
|
|
321
339
|
return null;
|
|
322
340
|
}
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
341
|
+
const match = new RegExp(`^${name}:\\s+(\\d+) kB$`, 'm').exec(text);
|
|
342
|
+
return match?.[1] ? Number(match[1]) * 1024 : null;
|
|
343
|
+
}
|
|
344
|
+
/** `MemTotal`/`MemAvailable`/`SwapTotal` in bytes, or null where there is no `/proc`. */
|
|
345
|
+
function readMemInfo() {
|
|
346
|
+
const totalBytes = memInfoField('MemTotal');
|
|
347
|
+
const availableBytes = memInfoField('MemAvailable');
|
|
348
|
+
// 0 is a real answer here (no swap), unlike the two above.
|
|
349
|
+
const swapTotalBytes = memInfoField('SwapTotal') ?? 0;
|
|
350
|
+
return totalBytes && availableBytes ? { totalBytes, availableBytes, swapTotalBytes } : null;
|
|
351
|
+
}
|
|
352
|
+
/** `SwapTotal` of this machine, or null where `/proc/meminfo` will not say. */
|
|
353
|
+
export function readSwapTotalBytes() {
|
|
354
|
+
return memInfoField('SwapTotal');
|
|
330
355
|
}
|
|
331
356
|
/**
|
|
332
357
|
* What our own cgroup currently holds.
|
|
@@ -338,7 +363,7 @@ function readMemInfo() {
|
|
|
338
363
|
* already using. Returns 0 on cgroup v1 or in a container without the file, which
|
|
339
364
|
* is the safe direction: a slightly lower ceiling, never a higher one.
|
|
340
365
|
*/
|
|
341
|
-
function readSelfCgroup() {
|
|
366
|
+
export function readSelfCgroup() {
|
|
342
367
|
try {
|
|
343
368
|
const line = fs
|
|
344
369
|
.readFileSync('/proc/self/cgroup', 'utf8')
|
|
@@ -620,14 +645,18 @@ const MANAGED_HEADER = [
|
|
|
620
645
|
* not the guarantee `memoryPolicy` computes, and it is written down here rather
|
|
621
646
|
* than glossed over (QA-2026-09-07 MINOR-4).
|
|
622
647
|
*
|
|
623
|
-
* `MemorySwapMax
|
|
624
|
-
* resident memory alone is not a ceiling, it is a swap pump
|
|
625
|
-
* allocated 2 GB and drained the host's swap during the spike).
|
|
648
|
+
* `MemorySwapMax` is a BOUND on the slice for the same reason it is on every
|
|
649
|
+
* scope: a ceiling on resident memory alone is not a ceiling, it is a swap pump
|
|
650
|
+
* (a 200 MB cage allocated 2 GB and drained the host's swap during the spike).
|
|
651
|
+
* Since #387 the bound is a share of the machine's swap rather than 0
|
|
652
|
+
* (`SESSIONS_SWAP_SHARE`), because each session now carries a `MemoryHigh`
|
|
653
|
+
* brake, and a brake with no swap under it stalls the session instead of
|
|
654
|
+
* slowing it. The unmeasured branch still writes 0: no measurement, no swap to
|
|
655
|
+
* hand out.
|
|
626
656
|
*
|
|
627
|
-
* No `MemoryHigh` here
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
* 380 ms honest death.
|
|
657
|
+
* No `MemoryHigh` here. The brake belongs on each SCOPE (`session-cage.ts`):
|
|
658
|
+
* soft pressure on the slice would throttle every session on the machine to
|
|
659
|
+
* keep one runaway alive a little longer.
|
|
631
660
|
*
|
|
632
661
|
* `sessionsUsageBytes` is the door that used to lead around all of the above.
|
|
633
662
|
* `facts` is null whenever the SERVICE's `MemoryCurrent` is unreadable — a
|
|
@@ -664,8 +693,8 @@ export function buildSessionsSliceOverride(facts = readMemoryFacts(), sessionsUs
|
|
|
664
693
|
`MemoryMax=${asMiB(memory.maxBytes)}`,
|
|
665
694
|
]
|
|
666
695
|
: unmeasuredSliceCeiling(sessionsUsageBytes)),
|
|
667
|
-
// The line the cage is built on. See `session-cage.ts
|
|
668
|
-
|
|
696
|
+
// The line the cage is built on. See `session-cage.ts` and the note above.
|
|
697
|
+
`MemorySwapMax=${memory && memory.swapMaxBytes > 0 ? asMiB(memory.swapMaxBytes) : '0'}`,
|
|
669
698
|
'TasksMax=8192',
|
|
670
699
|
'MemoryAccounting=yes',
|
|
671
700
|
'TasksAccounting=yes',
|
|
@@ -773,6 +802,14 @@ function fileIsOutdated(readFile, target, facts) {
|
|
|
773
802
|
*/
|
|
774
803
|
const CEILING_DRIFT_TOLERANCE = 0.15;
|
|
775
804
|
function memoryCeilingHasDrifted(contents, facts) {
|
|
805
|
+
// The swap share drifts for its own reason, and on its own schedule: an
|
|
806
|
+
// operator who enlarges the machine's swapfile (the one lever that makes the
|
|
807
|
+
// brake a brake rather than a freeze) would otherwise keep the old, smaller
|
|
808
|
+
// share until the next `LIMITS_VERSION` — and each session would keep getting
|
|
809
|
+
// half of it (#387 QA). Zero to non-zero and back is always drift; above that
|
|
810
|
+
// the same tolerance as the ceiling.
|
|
811
|
+
if (swapShareHasDrifted(contents, facts))
|
|
812
|
+
return true;
|
|
776
813
|
// Never re-measure a machine that is still booting: the static pair it is
|
|
777
814
|
// holding is deliberate, and «drift» against a lie is not drift.
|
|
778
815
|
if (!memoryPolicy(facts).measured)
|
|
@@ -795,6 +832,19 @@ function memoryCeilingHasDrifted(contents, facts) {
|
|
|
795
832
|
const wanted = memoryPolicy(facts).maxBytes;
|
|
796
833
|
return Math.abs(writtenBytes - wanted) / wanted > CEILING_DRIFT_TOLERANCE;
|
|
797
834
|
}
|
|
835
|
+
function swapShareHasDrifted(contents, facts) {
|
|
836
|
+
const written = [...contents.matchAll(/^MemorySwapMax=(\d+)([KMG]?)$/gm)].at(-1);
|
|
837
|
+
// Not written at all: only the slice file carries this line, and the service
|
|
838
|
+
// file legitimately has none. Silence is not drift.
|
|
839
|
+
if (!written?.[1])
|
|
840
|
+
return false;
|
|
841
|
+
const scale = { '': 1, K: 1024, M: MIB, G: 1024 * MIB }[written[2] ?? ''] ?? MIB;
|
|
842
|
+
const writtenBytes = Number(written[1]) * scale;
|
|
843
|
+
const wanted = memoryPolicy(facts).swapMaxBytes;
|
|
844
|
+
if (writtenBytes === 0 || wanted === 0)
|
|
845
|
+
return writtenBytes !== wanted;
|
|
846
|
+
return Math.abs(writtenBytes - wanted) / wanted > CEILING_DRIFT_TOLERANCE;
|
|
847
|
+
}
|
|
798
848
|
/**
|
|
799
849
|
* Write the drop-in. Returns false when nothing needed doing.
|
|
800
850
|
*
|