@bridge4dev/runner 0.57.0 → 0.58.2
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 +23 -4
- package/dist/adapters/codex-protocol.js +6 -1
- package/dist/adapters/codex.js +11 -2
- package/dist/adapters/types.js +14 -0
- package/dist/cage-authority.d.ts +118 -0
- package/dist/cage-authority.js +241 -0
- package/dist/config.d.ts +83 -5
- package/dist/config.js +59 -1
- package/dist/daemon-lock.d.ts +43 -0
- package/dist/daemon-lock.js +107 -0
- package/dist/host-load.d.ts +9 -0
- package/dist/host-load.js +9 -0
- package/dist/index.js +222 -20
- package/dist/policy.d.ts +9 -0
- package/dist/policy.js +71 -1
- package/dist/protocol.d.ts +43 -27
- package/dist/recipe-schema.d.ts +12 -12
- package/dist/regex-guard.js +6 -6
- package/dist/self-update.js +22 -1
- package/dist/service-unit.d.ts +35 -3
- package/dist/service-unit.js +82 -5
- package/dist/session-allocator.d.ts +259 -0
- package/dist/session-allocator.js +492 -0
- package/dist/session-cage.d.ts +229 -2
- package/dist/session-cage.js +590 -40
- package/dist/session-limits.d.ts +71 -0
- package/dist/session-limits.js +93 -0
- package/dist/session-stall.d.ts +353 -0
- package/dist/session-stall.js +760 -0
- package/dist/supervisor.d.ts +223 -33
- package/dist/supervisor.js +845 -94
- package/dist/systemd-memory.js +2 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { killProcess, runSystemctl } from './cage-authority.js';
|
|
4
|
+
import { log } from './log.js';
|
|
5
|
+
import { scopeCgroupDir } from './session-cage.js';
|
|
6
|
+
const MIB = 1024 * 1024;
|
|
7
|
+
/**
|
|
8
|
+
* Telling «this session is standing still» from «this session is slow» — and
|
|
9
|
+
* doing something about it before the night is over (#398 S2, gotcha §480).
|
|
10
|
+
*
|
|
11
|
+
* Above `MemoryHigh` the kernel does not kill, it puts the allocating thread to
|
|
12
|
+
* sleep on the way back to userspace; with `MemorySwapMax=0` there is nothing to
|
|
13
|
+
* push out, so the penalty is up to 2 s per 256 KB and the process never reaches
|
|
14
|
+
* `MemoryMax` at all — it simply stops. On 09.09.2026 that cost one session an
|
|
15
|
+
* hour of silence on a build that printed nothing but its startup warnings.
|
|
16
|
+
* Ten of the fleet's fifteen caged machines have a session swap of zero, so this
|
|
17
|
+
* is the ordinary case and not the exotic one.
|
|
18
|
+
*
|
|
19
|
+
* The measure is ONE number and it is not the one three earlier drafts reached
|
|
20
|
+
* for.
|
|
21
|
+
*
|
|
22
|
+
* - **Not CPU.** A process wedged in direct reclaim burns system time in bursts:
|
|
23
|
+
* the same stuck process was measured at 3.6 %, 2.9 %, 6.9 %, 18.7 %, 98 %,
|
|
24
|
+
* 53 %, 22 %, 67 % of one core on consecutive seconds. Any threshold over it
|
|
25
|
+
* both misfires and misses.
|
|
26
|
+
* - **Not swap.** The first draft counted growth in `memory.swap.current` and
|
|
27
|
+
* `pgmajfault` as evidence of progress, out of a fear of killing honest work
|
|
28
|
+
* on a machine that swaps. The measurement refuted it: a live session with
|
|
29
|
+
* 232 MB of swap and 1.3 M major faults stood still for 2 h 15 min out of 18.
|
|
30
|
+
* Swap does not separate useful paging from thrashing.
|
|
31
|
+
* - **Pressure does.** `memory.pressure`, line `full`, field `total=`, is
|
|
32
|
+
* cumulative microseconds during which NO task in the cgroup could run for
|
|
33
|
+
* want of memory. Measured on this host: a wedged process, 27.9 s of stall in
|
|
34
|
+
* a 27.5 s window (~100 %); a healthy live session, 10.7 s in six hours
|
|
35
|
+
* (0.05 %); a session thrashing swap, 8093 s in 18 hours with the share close
|
|
36
|
+
* to 1 inside the bad stretches. Three orders of magnitude between honest
|
|
37
|
+
* work and a stall, in one number, on every machine that has PSI.
|
|
38
|
+
*
|
|
39
|
+
* The second condition is a cheap fuse and it is also a measurement: on a live
|
|
40
|
+
* scope of this host `anon` was 308 MB against a `memory.current` of 1033 MB
|
|
41
|
+
* with 9246 brake events — i.e. the brake very often catches page cache, which
|
|
42
|
+
* the kernel takes back with no stall at all. Those episodes must not reach the
|
|
43
|
+
* counter, let alone the deadline.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* One detector window.
|
|
47
|
+
*
|
|
48
|
+
* Five seconds, not the watch's thirty: the deadline below is three minutes, and
|
|
49
|
+
* a measure that needs two consecutive windows would spend a third of the
|
|
50
|
+
* deadline deciding at the 30 s cadence. Five seconds of `full` stall is also
|
|
51
|
+
* long enough that a single scheduling hiccup cannot fill it.
|
|
52
|
+
*/
|
|
53
|
+
export const STALL_WINDOW_MS = 5_000;
|
|
54
|
+
/**
|
|
55
|
+
* Share of the window spent with nothing in the cgroup able to run, above which
|
|
56
|
+
* the window counts as a standing one.
|
|
57
|
+
*
|
|
58
|
+
* **A fifth, not a half, and the difference was found by measurement.** The plan
|
|
59
|
+
* this module implements said 50 %, taken from a hard stall that read ~100 %.
|
|
60
|
+
* Reproducing the incident itself — a cage with a 64 MiB brake and no swap, a
|
|
61
|
+
* process touching what it allocates — gave four consecutive windows of
|
|
62
|
+
* **0.766, 0.484, 0.443, 0.542** (10.09.2026, this host). Two of the four sit
|
|
63
|
+
* BELOW a half: at that line the very shape this mechanism exists for would
|
|
64
|
+
* trip and clear in turn and never reach its deadline.
|
|
65
|
+
*
|
|
66
|
+
* There is room to move the line because the gap is enormous. Measured on the
|
|
67
|
+
* same host: a healthy live session, 0.0005; honest disk-bound work under the
|
|
68
|
+
* same 64 MiB brake writing 6.3 GB at 739 MB/s, exactly **0.0** with the brake
|
|
69
|
+
* never firing at all. A fifth is four hundred times what healthy work shows and
|
|
70
|
+
* half of what the mildest stalled window did.
|
|
71
|
+
*/
|
|
72
|
+
export const STALL_SHARE = 0.2;
|
|
73
|
+
/**
|
|
74
|
+
* …and the share it has to fall BELOW before a running deadline is cleared.
|
|
75
|
+
*
|
|
76
|
+
* Two lines, not one, for the reason the cage watch already had `calmTicks`: a
|
|
77
|
+
* genuine stall is not a flat number, it breathes. One line means one dip
|
|
78
|
+
* cancels three minutes of standing still. Half the trip line, and still two
|
|
79
|
+
* hundred times the 0.0005 healthy work shows.
|
|
80
|
+
*/
|
|
81
|
+
export const STALL_SHARE_CLEAR = 0.1;
|
|
82
|
+
/** Consecutive windows over the line before the session counts as standing still. */
|
|
83
|
+
export const STALL_WINDOWS_TO_TRIP = 2;
|
|
84
|
+
/**
|
|
85
|
+
* `(anon + shmem) / memory.current` above which the pressure is about memory the
|
|
86
|
+
* kernel cannot simply drop. Below it the brake is grinding page cache.
|
|
87
|
+
*/
|
|
88
|
+
export const STALL_ANON_SHARE = 0.9;
|
|
89
|
+
/**
|
|
90
|
+
* How long a standing session is given before its biggest command is stopped
|
|
91
|
+
* (decision D6 of the plan).
|
|
92
|
+
*
|
|
93
|
+
* Three minutes: long enough for a person who is watching to intervene, short
|
|
94
|
+
* enough that a session which stalls at 02:00 is not still standing at 08:00.
|
|
95
|
+
* Doubled on a machine without PSI, where the fallback measure below is weaker.
|
|
96
|
+
*/
|
|
97
|
+
export const STALL_GRACE_MS = 180_000;
|
|
98
|
+
/**
|
|
99
|
+
* A drop of this much in `memory.current` counts as real progress and clears the
|
|
100
|
+
* deadline.
|
|
101
|
+
*
|
|
102
|
+
* A new line of output does NOT: in the incident the build printed startup
|
|
103
|
+
* warnings for seventeen minutes while allocating nothing.
|
|
104
|
+
*/
|
|
105
|
+
export const STALL_PROGRESS_DROP_BYTES = 64 * MIB;
|
|
106
|
+
/**
|
|
107
|
+
* How long the detector stays quiet after WE lowered this session's brake.
|
|
108
|
+
*
|
|
109
|
+
* Measured on this host: a process honestly writing a file at 6.4 MB/s was
|
|
110
|
+
* stopped dead the instant its `MemoryHigh` was narrowed on the live scope. Any
|
|
111
|
+
* lowering therefore manufactures exactly the signal this module looks for, and
|
|
112
|
+
* without the mute the mechanism would catch its own edit and stop a command for
|
|
113
|
+
* it. One window plus the deadline, with room to spare.
|
|
114
|
+
*/
|
|
115
|
+
export const STALL_MUTE_AFTER_LOWER_MS = 240_000;
|
|
116
|
+
/**
|
|
117
|
+
* Between SIGTERM and SIGKILL for the subtree being stopped.
|
|
118
|
+
*
|
|
119
|
+
* Thirty seconds, not five: neither `pnpm install` nor `docker build` can put
|
|
120
|
+
* its own house in order in five, and half-written state costs the next turn
|
|
121
|
+
* more than the wait costs this one.
|
|
122
|
+
*/
|
|
123
|
+
export const STALL_SIGKILL_AFTER_MS = 30_000;
|
|
124
|
+
/**
|
|
125
|
+
* Growth under which the PSI-less fallback still calls a session stuck:
|
|
126
|
+
* `min(1 % of the brake, 16 MiB)` per window.
|
|
127
|
+
*/
|
|
128
|
+
export const STALL_FALLBACK_GROWTH_BYTES = 16 * MIB;
|
|
129
|
+
export function freshStallState(unit) {
|
|
130
|
+
return { unit, last: null, hotWindows: 0, stalledSince: null, markBytes: 0, mutedUntil: 0 };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The pure half: five files' text in, a sample out. Null when `memory.current`
|
|
134
|
+
* could not be read, which means the cgroup is gone.
|
|
135
|
+
*/
|
|
136
|
+
export function parseStallSample(files, at) {
|
|
137
|
+
const count = (raw) => {
|
|
138
|
+
const trimmed = raw.trim();
|
|
139
|
+
if (!/^\d+$/.test(trimmed))
|
|
140
|
+
return null;
|
|
141
|
+
const value = Number(trimmed);
|
|
142
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
143
|
+
};
|
|
144
|
+
const currentBytes = count(files.current);
|
|
145
|
+
if (currentBytes === null)
|
|
146
|
+
return null;
|
|
147
|
+
const statField = (name) => {
|
|
148
|
+
const match = new RegExp(`^${name} (\\d+)$`, 'm').exec(files.stat);
|
|
149
|
+
return match?.[1] ? Number(match[1]) : 0;
|
|
150
|
+
};
|
|
151
|
+
const event = (name) => {
|
|
152
|
+
const line = files.events.split('\n').find((l) => l.startsWith(`${name} `));
|
|
153
|
+
return line ? (count(line.slice(name.length + 1)) ?? 0) : 0;
|
|
154
|
+
};
|
|
155
|
+
// `full avg10=… avg60=… avg300=… total=12417907`. The averages are the kernel's
|
|
156
|
+
// own smoothing over windows we did not choose; `total` is the raw counter and
|
|
157
|
+
// the only one a window of our own length can be cut out of.
|
|
158
|
+
const full = files.pressure?.split('\n').find((l) => l.startsWith('full '));
|
|
159
|
+
const totalMatch = full ? /(?:^|\s)total=(\d+)/.exec(full) : null;
|
|
160
|
+
const pressureFullUs = totalMatch?.[1] ? Number(totalMatch[1]) : null;
|
|
161
|
+
return {
|
|
162
|
+
pressureFullUs: pressureFullUs !== null && Number.isSafeInteger(pressureFullUs) ? pressureFullUs : null,
|
|
163
|
+
highEvents: event('high'),
|
|
164
|
+
currentBytes,
|
|
165
|
+
anonBytes: statField('anon') + statField('shmem'),
|
|
166
|
+
brakeBytes: files.high.trim() === 'max' ? null : count(files.high),
|
|
167
|
+
at,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** Read one sample off a live scope. Null when the cgroup is not there. */
|
|
171
|
+
export function readStallSample(unit, at, readFile = (p) => fs.readFileSync(p, 'utf8')) {
|
|
172
|
+
const dir = scopeCgroupDir(unit);
|
|
173
|
+
if (dir === null)
|
|
174
|
+
return null;
|
|
175
|
+
try {
|
|
176
|
+
let pressure;
|
|
177
|
+
try {
|
|
178
|
+
pressure = readFile(path.join(dir, 'memory.pressure'));
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// A kernel without `CONFIG_PSI` has no such file. Not a failure — it
|
|
182
|
+
// decides which measure is used and doubles the deadline, nothing more.
|
|
183
|
+
pressure = undefined;
|
|
184
|
+
}
|
|
185
|
+
return parseStallSample({
|
|
186
|
+
current: readFile(path.join(dir, 'memory.current')),
|
|
187
|
+
high: readFile(path.join(dir, 'memory.high')),
|
|
188
|
+
events: readFile(path.join(dir, 'memory.events')),
|
|
189
|
+
stat: readFile(path.join(dir, 'memory.stat')),
|
|
190
|
+
...(pressure === undefined ? {} : { pressure }),
|
|
191
|
+
}, at);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* One window of the detector, as a pure function of the previous state.
|
|
199
|
+
*
|
|
200
|
+
* Everything the caller must not get wrong lives here: two windows before the
|
|
201
|
+
* counter trips, the anon fuse, the mute after our own edit, and a reset that
|
|
202
|
+
* only real progress can buy.
|
|
203
|
+
*/
|
|
204
|
+
export function stallStep(previous, sample, options = {}) {
|
|
205
|
+
const now = options.now ?? sample.at;
|
|
206
|
+
const graceMs = options.graceMs ?? STALL_GRACE_MS;
|
|
207
|
+
// The nominal window, so a suite can run the whole mechanism in a second
|
|
208
|
+
// without fake timers — which are not an option here: they fire the WS
|
|
209
|
+
// client's liveness watchdog and it terminates the socket as half-dead.
|
|
210
|
+
const nominalWindowMs = options.windowMs ?? STALL_WINDOW_MS;
|
|
211
|
+
const degraded = sample.pressureFullUs === null;
|
|
212
|
+
const clean = (mutedUntil) => ({
|
|
213
|
+
state: {
|
|
214
|
+
...previous,
|
|
215
|
+
last: sample,
|
|
216
|
+
hotWindows: 0,
|
|
217
|
+
stalledSince: null,
|
|
218
|
+
markBytes: 0,
|
|
219
|
+
mutedUntil,
|
|
220
|
+
},
|
|
221
|
+
stuck: false,
|
|
222
|
+
remainingMs: null,
|
|
223
|
+
expired: false,
|
|
224
|
+
degraded,
|
|
225
|
+
});
|
|
226
|
+
const before = previous.last;
|
|
227
|
+
// A cgroup this detector has not seen, or one whose counters went backwards —
|
|
228
|
+
// which can only be a NEW cgroup behind the same unit name. Start from zero
|
|
229
|
+
// rather than measure a window against a stranger's counter.
|
|
230
|
+
const sameCgroup = before !== null &&
|
|
231
|
+
sample.at > before.at &&
|
|
232
|
+
sample.highEvents >= before.highEvents &&
|
|
233
|
+
(sample.pressureFullUs === null ||
|
|
234
|
+
before.pressureFullUs === null ||
|
|
235
|
+
sample.pressureFullUs >= before.pressureFullUs);
|
|
236
|
+
if (!sameCgroup)
|
|
237
|
+
return clean(previous.mutedUntil);
|
|
238
|
+
if (now < previous.mutedUntil)
|
|
239
|
+
return clean(previous.mutedUntil);
|
|
240
|
+
const windowMs = sample.at - before.at;
|
|
241
|
+
const effectiveGraceEarly = (options.graceMs ?? STALL_GRACE_MS) * (degraded ? 2 : 1);
|
|
242
|
+
// A window shorter than half the nominal one is a tick that fired early; its
|
|
243
|
+
// share would be noise divided by a small number. The old sample is KEPT, so
|
|
244
|
+
// the next window is measured over a full span instead of two short ones —
|
|
245
|
+
// and the deadline, if one is running, keeps running.
|
|
246
|
+
if (windowMs < nominalWindowMs / 2) {
|
|
247
|
+
const running = previous.stalledSince !== null;
|
|
248
|
+
return {
|
|
249
|
+
state: { ...previous, last: before },
|
|
250
|
+
stuck: running,
|
|
251
|
+
remainingMs: running
|
|
252
|
+
? Math.max(0, effectiveGraceEarly - (now - (previous.stalledSince ?? now)))
|
|
253
|
+
: null,
|
|
254
|
+
expired: false,
|
|
255
|
+
degraded,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Three answers, not two, and the middle one is what the measurement bought.
|
|
260
|
+
*
|
|
261
|
+
* `hot` starts and continues a deadline. `clear` ends one. `warm` — between
|
|
262
|
+
* the two lines — does neither: a stall that dips for one window is still a
|
|
263
|
+
* stall, and a session that has not started standing still does not start now.
|
|
264
|
+
*/
|
|
265
|
+
const anonShare = sample.currentBytes > 0 ? sample.anonBytes / sample.currentBytes : 0;
|
|
266
|
+
const anonFuse = anonShare >= STALL_ANON_SHARE;
|
|
267
|
+
/**
|
|
268
|
+
* …and it has to be THIS session's own doing.
|
|
269
|
+
*
|
|
270
|
+
* `memory.pressure` counts every microsecond a task of this cgroup could not
|
|
271
|
+
* run for want of memory — INCLUDING when the reason is an ancestor. Since S3
|
|
272
|
+
* put a collective `MemoryHigh` on the sessions slice, that is no longer
|
|
273
|
+
* hypothetical: a greedy sibling filling the slice makes the kernel throttle
|
|
274
|
+
* everybody, and an innocent session sitting far under its own limits sees a
|
|
275
|
+
* high `full` share with nothing wrong on its side.
|
|
276
|
+
*
|
|
277
|
+
* The independent review of 10.09.2026 reproduced it with real cgroups: a
|
|
278
|
+
* scope holding a steady 430 MB, 1.5 GB under its own brake, below even its
|
|
279
|
+
* own `MemoryLow`, with `memory.events high = 0` for the whole run, showed
|
|
280
|
+
* shares of 0.22–0.42 for 45 consecutive windows — and the mechanism would
|
|
281
|
+
* have stopped its command at 214 s. That is precisely the failure #387 is
|
|
282
|
+
* about, the innocent neighbour, coming back through a new door.
|
|
283
|
+
*
|
|
284
|
+
* `memory.events` `high` is the discriminator, and it is exact: it counts
|
|
285
|
+
* times THIS cgroup's own brake was breached, and it does not move when an
|
|
286
|
+
* ancestor is the one throttling. Measured on this host, a session stalled by
|
|
287
|
+
* its OWN brake moved it by about a thousand per five-second window
|
|
288
|
+
* (4595 → 5785 → 6805 → 7655); the innocent neighbour above moved it not at
|
|
289
|
+
* all.
|
|
290
|
+
*/
|
|
291
|
+
/**
|
|
292
|
+
* «This session's own doing», by three signs and not one (#398 S7, B9).
|
|
293
|
+
*
|
|
294
|
+
* `memory.events high` is exact when it fires, and it has two blind spots
|
|
295
|
+
* that the rest of this stage created:
|
|
296
|
+
*
|
|
297
|
+
* 1. **The brake is not always above the hold.** On a tight machine the
|
|
298
|
+
* allocator floors the brake at `max(guarantee, hold × 1.05)`, so an
|
|
299
|
+
* INNOCENT session's own counter ticks on any growth at all. Hence the
|
|
300
|
+
* counter alone is not enough to convict — but it was never the only
|
|
301
|
+
* evidence: the deadline still needs pressure over the line for two
|
|
302
|
+
* windows.
|
|
303
|
+
* 2. **The slice's brake can bind first.** Above a pot of about 22.5 GiB a
|
|
304
|
+
* lone session's brake (`top − band`) sits ABOVE the collective brake on
|
|
305
|
+
* the slice (0.9 × pot), so the kernel throttles at the slice and this
|
|
306
|
+
* counter never moves — the detector switched off in precisely the
|
|
307
|
+
* headline case of the ticket. `nearOwnBrake` and `soleSession` cover it:
|
|
308
|
+
* a session sitting at its own ceiling, or alone in the slice, is not
|
|
309
|
+
* standing in anybody else's queue.
|
|
310
|
+
*/
|
|
311
|
+
const nearOwnBrake = sample.brakeBytes !== null && sample.currentBytes >= Math.floor(sample.brakeBytes * 0.95);
|
|
312
|
+
const ownBrakeFired = sample.highEvents > before.highEvents || nearOwnBrake || options.soleSession === true;
|
|
313
|
+
/**
|
|
314
|
+
* A frozen counter no longer cancels a deadline by itself, and it no longer
|
|
315
|
+
* needs a special case to avoid it either (#403).
|
|
316
|
+
*
|
|
317
|
+
* The first attempt at this bug added one: «a still counter means warm while
|
|
318
|
+
* a deadline runs», on the reasoning that a session wedged hard enough to
|
|
319
|
+
* stop allocating looks exactly like an innocent neighbour. It was the right
|
|
320
|
+
* observation and the wrong place — with a real neighbour in the slice the
|
|
321
|
+
* deadline then ran to its end anyway, and the innocent session lost its
|
|
322
|
+
* command. Reproduced in the suite, two sessions and one SIGTERM.
|
|
323
|
+
*
|
|
324
|
+
* `nearOwnBrake` above answers it properly: a session that has stopped
|
|
325
|
+
* allocating because its OWN limit is what stops it is sitting at that limit,
|
|
326
|
+
* which is a fact about the cgroup and not a guess about its intentions. So
|
|
327
|
+
* `ownBrakeFired` is true for it, and «somebody else's brake» keeps meaning
|
|
328
|
+
* exactly what it says.
|
|
329
|
+
*/
|
|
330
|
+
let heat;
|
|
331
|
+
if (sample.pressureFullUs !== null && before.pressureFullUs !== null) {
|
|
332
|
+
const share = (sample.pressureFullUs - before.pressureFullUs) / (windowMs * 1000);
|
|
333
|
+
if (!anonFuse || !ownBrakeFired || share < STALL_SHARE_CLEAR)
|
|
334
|
+
heat = 'clear';
|
|
335
|
+
else if (share >= STALL_SHARE)
|
|
336
|
+
heat = 'hot';
|
|
337
|
+
else
|
|
338
|
+
heat = 'warm';
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
// No PSI. The brake fired at least once, the cgroup barely grew, and what it
|
|
342
|
+
// holds is not page cache. Weaker than pressure, hence the doubled grace.
|
|
343
|
+
const growth = sample.currentBytes - before.currentBytes;
|
|
344
|
+
const ceiling = Math.min(sample.brakeBytes === null
|
|
345
|
+
? STALL_FALLBACK_GROWTH_BYTES
|
|
346
|
+
: Math.floor(sample.brakeBytes * 0.01), STALL_FALLBACK_GROWTH_BYTES);
|
|
347
|
+
if (!anonFuse || growth >= ceiling) {
|
|
348
|
+
// It is allocating, so it is moving. That IS the progress this measure has.
|
|
349
|
+
heat = 'clear';
|
|
350
|
+
}
|
|
351
|
+
else if (sample.highEvents > before.highEvents)
|
|
352
|
+
heat = 'hot';
|
|
353
|
+
else
|
|
354
|
+
heat = 'warm';
|
|
355
|
+
}
|
|
356
|
+
const effectiveGrace = degraded ? graceMs * 2 : graceMs;
|
|
357
|
+
if (heat === 'clear')
|
|
358
|
+
return clean(previous.mutedUntil);
|
|
359
|
+
// Real progress, even while the pressure stays up: the session gave memory back.
|
|
360
|
+
if (previous.stalledSince !== null &&
|
|
361
|
+
previous.markBytes - sample.currentBytes >= STALL_PROGRESS_DROP_BYTES) {
|
|
362
|
+
return clean(previous.mutedUntil);
|
|
363
|
+
}
|
|
364
|
+
// Warm and not already standing: nothing starts here.
|
|
365
|
+
if (heat === 'warm' && previous.stalledSince === null) {
|
|
366
|
+
return {
|
|
367
|
+
state: { ...previous, last: sample, stalledSince: null, markBytes: 0 },
|
|
368
|
+
stuck: false,
|
|
369
|
+
remainingMs: null,
|
|
370
|
+
expired: false,
|
|
371
|
+
degraded,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const hotWindows = heat === 'hot' ? previous.hotWindows + 1 : previous.hotWindows;
|
|
375
|
+
if (hotWindows < STALL_WINDOWS_TO_TRIP) {
|
|
376
|
+
return {
|
|
377
|
+
state: { ...previous, last: sample, hotWindows, stalledSince: null, markBytes: 0 },
|
|
378
|
+
stuck: false,
|
|
379
|
+
remainingMs: null,
|
|
380
|
+
expired: false,
|
|
381
|
+
degraded,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
const stalledSince = previous.stalledSince ?? now;
|
|
385
|
+
const markBytes = previous.stalledSince === null ? sample.currentBytes : previous.markBytes;
|
|
386
|
+
const elapsed = now - stalledSince;
|
|
387
|
+
return {
|
|
388
|
+
state: { ...previous, last: sample, hotWindows, stalledSince, markBytes },
|
|
389
|
+
stuck: true,
|
|
390
|
+
remainingMs: Math.max(0, effectiveGrace - elapsed),
|
|
391
|
+
expired: elapsed >= effectiveGrace,
|
|
392
|
+
degraded,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Anything whose command line says «MCP server» is the agent's own plumbing.
|
|
397
|
+
*
|
|
398
|
+
* Deliberately generous, because the direction of a mistake matters: a false
|
|
399
|
+
* positive costs us a candidate (we stop something smaller, or nothing), a false
|
|
400
|
+
* negative costs the session its tools mid-turn.
|
|
401
|
+
*
|
|
402
|
+
* **Widened after the independent review of 10.09.2026, which found the first
|
|
403
|
+
* version matched nothing real.** The command lines actually on this machine are
|
|
404
|
+
* `npm exec @playwright/mcp@latest …` and `node …/playwright-mcp --output-dir …`
|
|
405
|
+
* — in the first `mcp` is followed by `@`, in the second preceded by `-`, and
|
|
406
|
+
* the original character classes allowed neither. So the rule is now simply
|
|
407
|
+
* «`mcp` as a word», which does match both.
|
|
408
|
+
*
|
|
409
|
+
* The same review found the mirror-image defect, and it was the worse of the
|
|
410
|
+
* two: the AGENT's own command line contains `--mcp-config …`, so the agent
|
|
411
|
+
* matched, and protection used to be spread from anything that matched — which
|
|
412
|
+
* protected the agent's ENTIRE subtree and made every Claude session return no
|
|
413
|
+
* candidate at all. Protection is no longer spread from a tree root: a root is
|
|
414
|
+
* protected on its own account (it is the agent), and spreading from it would
|
|
415
|
+
* protect the very builds this mechanism exists to stop.
|
|
416
|
+
*/
|
|
417
|
+
export const MCP_MARKER = /(^|[^a-z0-9])mcp([^a-z0-9]|$)|modelcontextprotocol/i;
|
|
418
|
+
/**
|
|
419
|
+
* Bytes per page of `/proc/<pid>/statm`, worked out rather than assumed.
|
|
420
|
+
*
|
|
421
|
+
* 4096 is right on x86-64 and wrong on an arm64 kernel built with 16 KB or
|
|
422
|
+
* 64 KB pages — where every size this module reports would be off by a factor of
|
|
423
|
+
* four or sixteen. The ranking would survive (the error is uniform), but the
|
|
424
|
+
* number in the line a person reads would not. `/proc/self/status` gives `VmRSS`
|
|
425
|
+
* in kB and `/proc/self/statm` gives the same figure in pages, so dividing one
|
|
426
|
+
* by the other answers the question with no subprocess and no guess.
|
|
427
|
+
*
|
|
428
|
+
* Measured once per process: the page size cannot change under a running kernel.
|
|
429
|
+
*/
|
|
430
|
+
let cachedPageSize = null;
|
|
431
|
+
export function residentPageSize(readFile = (p) => fs.readFileSync(p, 'utf8'), procDir = '/proc') {
|
|
432
|
+
if (cachedPageSize !== null)
|
|
433
|
+
return cachedPageSize;
|
|
434
|
+
let size = 4096;
|
|
435
|
+
try {
|
|
436
|
+
const status = readFile(path.join(procDir, 'self', 'status'));
|
|
437
|
+
const rssKb = /^VmRSS:\s+(\d+) kB$/m.exec(status);
|
|
438
|
+
const pages = Number(readFile(path.join(procDir, 'self', 'statm'))
|
|
439
|
+
.trim()
|
|
440
|
+
.split(/\s+/)[1]);
|
|
441
|
+
if (rssKb?.[1] && Number.isSafeInteger(pages) && pages > 0) {
|
|
442
|
+
const derived = Math.round((Number(rssKb[1]) * 1024) / pages);
|
|
443
|
+
// Only a real page size: a power of two between 4 KiB and 64 KiB.
|
|
444
|
+
if (derived >= 4096 && derived <= 65536 && (derived & (derived - 1)) === 0)
|
|
445
|
+
size = derived;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// No `/proc/self`. 4096 is the right guess everywhere this runner runs.
|
|
450
|
+
}
|
|
451
|
+
cachedPageSize = size;
|
|
452
|
+
return size;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Read `/proc` for every process in one session's cgroup.
|
|
456
|
+
*
|
|
457
|
+
* **The exception to the rule in `host-load.ts`**, and the only one: that module
|
|
458
|
+
* states in full what this runner may read for telemetry, and says «no
|
|
459
|
+
* `/proc/<pid>` walk, no `ps`». This is not telemetry — nothing here leaves the
|
|
460
|
+
* machine except one command NAME — and choosing a victim cannot be done from
|
|
461
|
+
* two summary files. The rule and this exception are noted in both places.
|
|
462
|
+
*
|
|
463
|
+
* `ppid` is taken from after the LAST `)` of `/proc/<pid>/stat`, never by field
|
|
464
|
+
* number: a process name may contain spaces and parentheses (`(npm exec
|
|
465
|
+
* @playw)`), which moves every field after it (gotcha §441).
|
|
466
|
+
*/
|
|
467
|
+
export function readScopeProcesses(unit, readFile = (p) => fs.readFileSync(p, 'utf8'), procDir = '/proc') {
|
|
468
|
+
const dir = scopeCgroupDir(unit);
|
|
469
|
+
if (dir === null)
|
|
470
|
+
return [];
|
|
471
|
+
let listing;
|
|
472
|
+
try {
|
|
473
|
+
listing = readFile(path.join(dir, 'cgroup.procs'));
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return [];
|
|
477
|
+
}
|
|
478
|
+
const pageSize = residentPageSize(readFile, procDir);
|
|
479
|
+
const out = [];
|
|
480
|
+
for (const line of listing.split('\n')) {
|
|
481
|
+
const pid = Number(line.trim());
|
|
482
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
483
|
+
continue;
|
|
484
|
+
try {
|
|
485
|
+
const stat = readFile(path.join(procDir, String(pid), 'stat'));
|
|
486
|
+
const close = stat.lastIndexOf(')');
|
|
487
|
+
if (close < 0)
|
|
488
|
+
continue;
|
|
489
|
+
const after = stat
|
|
490
|
+
.slice(close + 1)
|
|
491
|
+
.trim()
|
|
492
|
+
.split(/\s+/);
|
|
493
|
+
// After `comm` come `state` then `ppid`; `starttime` is field 22 of the
|
|
494
|
+
// whole line, i.e. index 19 of what follows the closing parenthesis.
|
|
495
|
+
const ppid = Number(after[1]);
|
|
496
|
+
const startedAtTicks = Number(after[19]);
|
|
497
|
+
const statm = readFile(path.join(procDir, String(pid), 'statm'))
|
|
498
|
+
.trim()
|
|
499
|
+
.split(/\s+/);
|
|
500
|
+
const resident = Number(statm[1]);
|
|
501
|
+
const comm = readFile(path.join(procDir, String(pid), 'comm')).trim();
|
|
502
|
+
let cmdline = '';
|
|
503
|
+
try {
|
|
504
|
+
cmdline = readFile(path.join(procDir, String(pid), 'cmdline'));
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
// A process that ended between the listing and the read. Treated as
|
|
508
|
+
// ordinary work, which only ever makes it a candidate — and a candidate
|
|
509
|
+
// that no longer exists is skipped when the signal is sent.
|
|
510
|
+
}
|
|
511
|
+
if (!Number.isSafeInteger(ppid) || !Number.isSafeInteger(resident))
|
|
512
|
+
continue;
|
|
513
|
+
out.push({
|
|
514
|
+
pid,
|
|
515
|
+
ppid,
|
|
516
|
+
startedAtTicks: Number.isSafeInteger(startedAtTicks) ? startedAtTicks : -1,
|
|
517
|
+
comm,
|
|
518
|
+
rssBytes: resident * pageSize,
|
|
519
|
+
mcp: MCP_MARKER.test(cmdline.replace(/\0/g, ' ')),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
// Gone between the listing and the read.
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* The biggest command of this session, defined by the letter and not by «the
|
|
530
|
+
* fattest thing on the machine».
|
|
531
|
+
*
|
|
532
|
+
* Every exclusion below is a measurement or an incident:
|
|
533
|
+
*
|
|
534
|
+
* - **only pids of THIS cgroup.** By RSS the fattest processes on a dev server
|
|
535
|
+
* are `dockerd`, the production API and other people's agents; a candidate
|
|
536
|
+
* chosen machine-wide would stop one of those.
|
|
537
|
+
* - **never the agent.** The agent is the one process in the scope whose parent
|
|
538
|
+
* is OUTSIDE it — `systemd-run --scope` execs into it and the runner spawned
|
|
539
|
+
* it. That is a structural fact and needs no pid handed down through two
|
|
540
|
+
* adapters. Stopping the agent is what the top «Stop» button is for; this
|
|
541
|
+
* mechanism has no right to it (D6).
|
|
542
|
+
* - **never an MCP server**, nor anything under one: those are the agent's
|
|
543
|
+
* tools, not its work.
|
|
544
|
+
* - **never `git`.** It is never the fattest thing, and a killed `git` leaves
|
|
545
|
+
* `.git/index.lock` behind, which then costs the session its restore points
|
|
546
|
+
* and its next turns.
|
|
547
|
+
* - **the whole subtree**, not one pid: over a `tsc` there is a `pnpm`, and
|
|
548
|
+
* over that a `sh`, and killing the leaf leaves the parent to start another.
|
|
549
|
+
*/
|
|
550
|
+
export function pickKillCandidate(processes, agentPid = null) {
|
|
551
|
+
if (processes.length === 0)
|
|
552
|
+
return null;
|
|
553
|
+
const byPid = new Map(processes.map((p) => [p.pid, p]));
|
|
554
|
+
const children = new Map();
|
|
555
|
+
for (const p of processes) {
|
|
556
|
+
if (!byPid.has(p.ppid))
|
|
557
|
+
continue;
|
|
558
|
+
const list = children.get(p.ppid);
|
|
559
|
+
if (list)
|
|
560
|
+
list.push(p.pid);
|
|
561
|
+
else
|
|
562
|
+
children.set(p.ppid, [p.pid]);
|
|
563
|
+
}
|
|
564
|
+
// The agent: the root of the tree. On a scope there is exactly one, but a
|
|
565
|
+
// machine can surprise us, so every parentless process is protected.
|
|
566
|
+
const roots = processes.filter((p) => !byPid.has(p.ppid)).map((p) => p.pid);
|
|
567
|
+
const rootComms = new Set(roots.map((pid) => byPid.get(pid)?.comm ?? ''));
|
|
568
|
+
const protectedPids = new Set(roots);
|
|
569
|
+
const spread = (pid) => {
|
|
570
|
+
for (const child of children.get(pid) ?? []) {
|
|
571
|
+
if (protectedPids.has(child))
|
|
572
|
+
continue;
|
|
573
|
+
protectedPids.add(child);
|
|
574
|
+
spread(child);
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
for (const p of processes) {
|
|
578
|
+
if (!p.mcp)
|
|
579
|
+
continue;
|
|
580
|
+
protectedPids.add(p.pid);
|
|
581
|
+
/**
|
|
582
|
+
* …but never FROM THE AGENT. See the note on `MCP_MARKER`: the agent's own
|
|
583
|
+
* command line names the MCP config, so spreading from it protected every
|
|
584
|
+
* build the agent ever started.
|
|
585
|
+
*
|
|
586
|
+
* Which one is the agent is now KNOWN when the caller says so (#403).
|
|
587
|
+
* «Every root» was the old answer, and it was too wide by exactly one case:
|
|
588
|
+
* an MCP server whose launcher exited is a root too, and under that answer
|
|
589
|
+
* its own children — a browser, a language server — silently lost the
|
|
590
|
+
* protection they had before S7b. They are the agent's tools either way.
|
|
591
|
+
*/
|
|
592
|
+
const isAgent = agentPid === null ? roots.includes(p.pid) : p.pid === agentPid;
|
|
593
|
+
if (!isAgent)
|
|
594
|
+
spread(p.pid);
|
|
595
|
+
}
|
|
596
|
+
const subtree = (pid) => {
|
|
597
|
+
const acc = [pid];
|
|
598
|
+
for (const child of children.get(pid) ?? [])
|
|
599
|
+
acc.push(...subtree(child));
|
|
600
|
+
return acc;
|
|
601
|
+
};
|
|
602
|
+
let best = null;
|
|
603
|
+
for (const p of processes) {
|
|
604
|
+
if (protectedPids.has(p.pid))
|
|
605
|
+
continue;
|
|
606
|
+
// Only tree roots among what is left: a process whose parent is also a
|
|
607
|
+
// candidate is part of that parent's subtree, not a candidate of its own.
|
|
608
|
+
if (byPid.has(p.ppid) && !protectedPids.has(p.ppid))
|
|
609
|
+
continue;
|
|
610
|
+
if (p.comm === 'git')
|
|
611
|
+
continue;
|
|
612
|
+
// A second agent process (a background CLI) is not «a command» either.
|
|
613
|
+
if (rootComms.has(p.comm))
|
|
614
|
+
continue;
|
|
615
|
+
/**
|
|
616
|
+
* `git` is spared, and only `git` — not everything that happens to share a
|
|
617
|
+
* tree with it (#403).
|
|
618
|
+
*
|
|
619
|
+
* Two corrections, in this order. The review of 10.09.2026 found the first:
|
|
620
|
+
* the ordinary shape of a git command from an agent is `bash -lc "git …"`,
|
|
621
|
+
* so the root is `bash` and the `git` underneath it was killed with the
|
|
622
|
+
* tree, leaving `.git/index.lock` behind. The fix for it skipped any
|
|
623
|
+
* subtree CONTAINING a git — and that was too much, because in Claude Code
|
|
624
|
+
* every Bash tool call lives under ONE long-lived shell: a momentary
|
|
625
|
+
* `git status` made the four-gigabyte build in the same tree unkillable,
|
|
626
|
+
* and T2 quietly degraded into T3 «nothing to stop here».
|
|
627
|
+
*
|
|
628
|
+
* So the git processes and their children are removed from the signal list
|
|
629
|
+
* while the rest of the subtree stays a candidate. `comm` is truncated to
|
|
630
|
+
* 15 characters by the kernel, hence the prefix test — it also covers
|
|
631
|
+
* `git-remote-http` and `git-lfs`, which have the same lock to lose.
|
|
632
|
+
*/
|
|
633
|
+
const isGit = (pid) => (byPid.get(pid)?.comm ?? '').startsWith('git');
|
|
634
|
+
const spared = new Set();
|
|
635
|
+
const spareTree = (pid) => {
|
|
636
|
+
spared.add(pid);
|
|
637
|
+
for (const child of children.get(pid) ?? [])
|
|
638
|
+
spareTree(child);
|
|
639
|
+
};
|
|
640
|
+
for (const pid of subtree(p.pid))
|
|
641
|
+
if (isGit(pid))
|
|
642
|
+
spareTree(pid);
|
|
643
|
+
const pids = subtree(p.pid).filter((pid) => !spared.has(pid));
|
|
644
|
+
// Nothing but git under here: that is a git command, and it is spared whole.
|
|
645
|
+
if (pids.length === 0)
|
|
646
|
+
continue;
|
|
647
|
+
const rssBytes = pids.reduce((sum, pid) => sum + (byPid.get(pid)?.rssBytes ?? 0), 0);
|
|
648
|
+
// The NAME the person reads is the heaviest process of the subtree, not its
|
|
649
|
+
// root: over a `node` doing the work there is usually a `sh` or a `pnpm`,
|
|
650
|
+
// and «sh» tells nobody anything. Arguments are never taken — they carry
|
|
651
|
+
// keys and tokens.
|
|
652
|
+
const heaviest = pids
|
|
653
|
+
.map((pid) => byPid.get(pid))
|
|
654
|
+
.filter((x) => x !== undefined)
|
|
655
|
+
.sort((a, b) => b.rssBytes - a.rssBytes)[0];
|
|
656
|
+
if (best === null || rssBytes > best.rssBytes) {
|
|
657
|
+
best = {
|
|
658
|
+
rootPid: p.pid,
|
|
659
|
+
pids,
|
|
660
|
+
identities: pids.map((pid) => ({
|
|
661
|
+
pid,
|
|
662
|
+
startedAtTicks: byPid.get(pid)?.startedAtTicks ?? -1,
|
|
663
|
+
})),
|
|
664
|
+
name: heaviest?.comm ?? p.comm,
|
|
665
|
+
rssBytes,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return best;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Is this pid still the process it was when we decided to stop it?
|
|
673
|
+
*
|
|
674
|
+
* `starttime` never repeats for one pid, so comparing it is the cheap and exact
|
|
675
|
+
* answer. Anything unreadable is «not the same», which is the safe direction: a
|
|
676
|
+
* signal not sent costs a command another thirty seconds, a signal sent to the
|
|
677
|
+
* wrong process costs somebody something we know nothing about.
|
|
678
|
+
*/
|
|
679
|
+
export function isSameProcess(identity, readFile = (p) => fs.readFileSync(p, 'utf8'), procDir = '/proc') {
|
|
680
|
+
if (identity.startedAtTicks < 0)
|
|
681
|
+
return false;
|
|
682
|
+
try {
|
|
683
|
+
const stat = readFile(path.join(procDir, String(identity.pid), 'stat'));
|
|
684
|
+
const close = stat.lastIndexOf(')');
|
|
685
|
+
if (close < 0)
|
|
686
|
+
return false;
|
|
687
|
+
const after = stat
|
|
688
|
+
.slice(close + 1)
|
|
689
|
+
.trim()
|
|
690
|
+
.split(/\s+/);
|
|
691
|
+
return Number(after[19]) === identity.startedAtTicks;
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Send one signal to every pid of a subtree. Processes that ended are skipped.
|
|
699
|
+
*
|
|
700
|
+
* When identities are given, each pid is checked against the process it was
|
|
701
|
+
* when the subtree was chosen — see {@link isSameProcess}. The SIGKILL that
|
|
702
|
+
* follows a SIGTERM thirty seconds later is the call that needs it: on a busy
|
|
703
|
+
* machine a pid freed in between can belong to something else by then.
|
|
704
|
+
*/
|
|
705
|
+
export function signalSubtree(pids, signal, kill = killProcess, sameProcess = isSameProcess) {
|
|
706
|
+
let sent = 0;
|
|
707
|
+
// Deepest first, so a parent cannot notice a child dying and start another.
|
|
708
|
+
for (const entry of [...pids].reverse()) {
|
|
709
|
+
const pid = typeof entry === 'number' ? entry : entry.pid;
|
|
710
|
+
if (typeof entry !== 'number' && !sameProcess(entry))
|
|
711
|
+
continue;
|
|
712
|
+
try {
|
|
713
|
+
kill(pid, signal);
|
|
714
|
+
sent += 1;
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
// Already gone, or not ours. Either way there is nothing to stop.
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return sent;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Raise this session's brake on the live scope.
|
|
724
|
+
*
|
|
725
|
+
* `systemctl --user set-property --runtime`, never a write into the cgroup
|
|
726
|
+
* files: a direct write is undone by the next `daemon-reload`, and the runner
|
|
727
|
+
* calls one itself every hour. `--runtime` is not optional either — without it
|
|
728
|
+
* systemd persists the property into `~/.config/systemd/user.control/`, which
|
|
729
|
+
* OUTRANKS the drop-in this runner ships and which `doctor` reports as an
|
|
730
|
+
* emergency override (`index.ts`).
|
|
731
|
+
*
|
|
732
|
+
* Never throws. A failure here must not take down the tick, let alone the
|
|
733
|
+
* daemon: the daemon carries a fatal `unhandledRejection` handler, so one
|
|
734
|
+
* unhandled write error would end every live session on the machine. A
|
|
735
|
+
* `set-property` on a unit that ended between the measurement and the write
|
|
736
|
+
* exits 1 with «Unit … not found» — the ordinary outcome of a race, not an
|
|
737
|
+
* error worth a loud line.
|
|
738
|
+
*/
|
|
739
|
+
export async function setScopeProperties(unit, properties, run = (args) => runSystemctl(args)) {
|
|
740
|
+
if (properties.length === 0)
|
|
741
|
+
return true;
|
|
742
|
+
try {
|
|
743
|
+
await run(['set-property', '--runtime', unit, ...properties]);
|
|
744
|
+
return true;
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
const text = String(error instanceof Error ? error.message : error);
|
|
748
|
+
if (/not found|not loaded/i.test(text)) {
|
|
749
|
+
log.debug('session memory: the scope ended before its limits could be written', { unit });
|
|
750
|
+
}
|
|
751
|
+
else {
|
|
752
|
+
log.warn('session memory: could not write the limits of a live session', {
|
|
753
|
+
unit,
|
|
754
|
+
error: text,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
//# sourceMappingURL=session-stall.js.map
|