@bridge4dev/runner 0.53.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 +105 -2
- package/dist/adapters/codex-protocol.d.ts +11 -0
- package/dist/adapters/codex-protocol.js +41 -3
- package/dist/adapters/codex.js +37 -0
- package/dist/host-load.d.ts +156 -0
- package/dist/host-load.js +223 -0
- package/dist/index.js +211 -40
- package/dist/process-priority.d.ts +55 -0
- package/dist/process-priority.js +99 -0
- package/dist/protocol.d.ts +25 -3
- package/dist/recipe-schema.d.ts +6 -6
- package/dist/self-update.js +43 -2
- package/dist/service-unit.d.ts +268 -10
- package/dist/service-unit.js +432 -53
- package/dist/session-cage.d.ts +508 -0
- package/dist/session-cage.js +1183 -0
- package/dist/supervisor.d.ts +106 -0
- package/dist/supervisor.js +243 -0
- package/dist/systemd-memory.d.ts +35 -0
- package/dist/systemd-memory.js +115 -0
- package/dist/verify.js +28 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { DEVBRIDGE_SLICE, SESSION_CPU_WEIGHT, SESSIONS_SLICE } from './service-unit.js';
|
|
2
|
+
/**
|
|
3
|
+
* The honest share of one session — where the BRAKE starts, not where it dies.
|
|
4
|
+
*
|
|
5
|
+
* Until #387 this was `MemoryMax`, the kill line. It is the same number and the
|
|
6
|
+
* same reasoning, moved one step down the ladder: above it the kernel reclaims
|
|
7
|
+
* and throttles (`MemoryHigh`), and the kill line is {@link sessionMemoryMaxBytes}.
|
|
8
|
+
*
|
|
9
|
+
* `min(2.5 GiB, service ceiling / 2)` — §3 of
|
|
10
|
+
* `docs/plans/shipped/agent-sessions-host-resources.md`. Both halves matter:
|
|
11
|
+
*
|
|
12
|
+
* - the absolute number is sized against the measurement, not against a round
|
|
13
|
+
* figure. The heaviest ordinary thing a session runs is a workspace
|
|
14
|
+
* `pnpm typecheck`, measured at a 1571 MB peak; three live `claude`
|
|
15
|
+
* processes are 512/577/646 MB. 2.5 GiB leaves ~60 % headroom over the
|
|
16
|
+
* worst measured case, which is the margin between "kills a runaway" and
|
|
17
|
+
* "kills the work";
|
|
18
|
+
* - the half-of-the-service half is what keeps ONE session from being able to
|
|
19
|
+
* fill the ceiling that covers all of them. On this machine the service
|
|
20
|
+
* ceiling is 7680M, so half is 3.75 GiB and the absolute number wins.
|
|
21
|
+
*
|
|
22
|
+
* The service ceiling is read off systemd (`systemctl --user show
|
|
23
|
+
* devbridge-runner -p MemoryMax`) rather than recomputed here, because what
|
|
24
|
+
* protects the machine is what systemd has in force, not what the drop-in on
|
|
25
|
+
* disk says — those two have already drifted apart once (§5.5 of the plan).
|
|
26
|
+
*/
|
|
27
|
+
export declare const SESSION_MEMORY_HIGH_ABSOLUTE_BYTES: number;
|
|
28
|
+
/**
|
|
29
|
+
* Processes and threads per session.
|
|
30
|
+
*
|
|
31
|
+
* An agent forks a lot (a monorepo build is hundreds of short-lived processes),
|
|
32
|
+
* so this is not a memory limit in disguise — it is the fork-bomb stop. 512 is
|
|
33
|
+
* far above anything measured on a real session and far below the 8192 the
|
|
34
|
+
* whole service gets.
|
|
35
|
+
*/
|
|
36
|
+
export declare const SESSION_TASKS_MAX = 512;
|
|
37
|
+
/** Prefix of every scope this module creates. The sweeper matches on it. */
|
|
38
|
+
export declare const SESSION_SCOPE_PREFIX = "devbridge-session-";
|
|
39
|
+
/**
|
|
40
|
+
* How the sessions on this machine are contained.
|
|
41
|
+
*
|
|
42
|
+
* `scope` — a cgroup per session with a memory ceiling (this module).
|
|
43
|
+
* `nice-only` — cgroup v2 is not usable here; stage 1a's `nice(2)` is all there
|
|
44
|
+
* is. Correct on cgroup v1/hybrid (Ubuntu 20.04, CentOS 7/8) and
|
|
45
|
+
* under a foreign supervisor.
|
|
46
|
+
* `none` — not even that: the kernel refuses `setpriority` as well.
|
|
47
|
+
*
|
|
48
|
+
* Travels in `hello` as `machine.sessionCage` so the dashboard can SAY that the
|
|
49
|
+
* containment did not take on a given machine, instead of leaving it to be
|
|
50
|
+
* guessed (plan §5.4.3).
|
|
51
|
+
*/
|
|
52
|
+
export type SessionCageMode = 'scope' | 'nice-only' | 'none';
|
|
53
|
+
export interface SessionCageFacts {
|
|
54
|
+
mode: SessionCageMode;
|
|
55
|
+
/** Why not `scope`. Empty string when it is. */
|
|
56
|
+
reason: string;
|
|
57
|
+
/**
|
|
58
|
+
* The brake — `MemoryHigh` on the scope: past it the kernel reclaims and slows
|
|
59
|
+
* the session down instead of killing anything. Null unless `mode === 'scope'`.
|
|
60
|
+
*/
|
|
61
|
+
memoryHighBytes: number | null;
|
|
62
|
+
/**
|
|
63
|
+
* The wall — `MemoryMax` on the scope, far above the brake. Reaching it costs
|
|
64
|
+
* the session its hungriest process (and, without `oomContinue`, the session).
|
|
65
|
+
* Null unless `mode === 'scope'`.
|
|
66
|
+
*/
|
|
67
|
+
memoryMaxBytes: number | null;
|
|
68
|
+
/** Swap one session may use, bytes. 0 = none. Null unless `mode === 'scope'`. */
|
|
69
|
+
swapMaxBytes: number | null;
|
|
70
|
+
/**
|
|
71
|
+
* `OOMPolicy=continue` is on the scope: a kill inside it takes one process,
|
|
72
|
+
* not the session. False on a systemd whose scopes do not take the setting
|
|
73
|
+
* (Ubuntu 22.04 is 249, and there the whole scope still stops on any kill —
|
|
74
|
+
* which is why the wall has to be far away on every machine, not only here).
|
|
75
|
+
*/
|
|
76
|
+
oomContinue: boolean;
|
|
77
|
+
/** The service ceiling systemd has in force, as measured. Null = infinity. */
|
|
78
|
+
serviceMemoryMaxBytes: number | null;
|
|
79
|
+
/**
|
|
80
|
+
* The ceiling over ALL sessions together, as systemd has it in force on
|
|
81
|
+
* `devbridge-sessions.slice`. Null means there is none.
|
|
82
|
+
*
|
|
83
|
+
* Read rather than assumed, because the live probe proves only the PERSONAL
|
|
84
|
+
* ceiling: a `MemoryMax` on a scope applies whatever the parent slice says. A
|
|
85
|
+
* machine whose `daemon-reload` never happened therefore passed the probe,
|
|
86
|
+
* announced `sessionCage: 'scope'` and `limitsCurrent: true`, and had three
|
|
87
|
+
* sessions of 2.5 GB each over a slice at `MemoryMax=infinity` — with the card
|
|
88
|
+
* saying «Sessions capped» (QA-2026-09-07 MAJOR-3).
|
|
89
|
+
*/
|
|
90
|
+
sessionsSliceMemoryMaxBytes: number | null;
|
|
91
|
+
/**
|
|
92
|
+
* Is `--expand-environment=no` on the command line? systemd ≥ 254 only — see
|
|
93
|
+
* the module header. False is not a downgrade below 254: expansion in
|
|
94
|
+
* `--scope` was off by default there anyway.
|
|
95
|
+
*/
|
|
96
|
+
expandEnvironmentFlag: boolean;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The FLOOR the plan's formula does not have, and the reason it needs one.
|
|
100
|
+
*
|
|
101
|
+
* `min(2.5 GiB, ceiling / 2)` is right on a big machine and wrong on a small
|
|
102
|
+
* one: at the 2 GiB ceiling `memoryPolicy` gives the smallest supported box,
|
|
103
|
+
* half is 1 GiB — BELOW the 1571 MB a workspace `pnpm typecheck` was measured
|
|
104
|
+
* at. The cage would then kill honest work on exactly the machines least able
|
|
105
|
+
* to afford a failed run, and it would look like a flaky agent rather than a
|
|
106
|
+
* limit. A containment that fires on correct work is not containment.
|
|
107
|
+
*
|
|
108
|
+
* So the floor is set above the worst measured ordinary peak, not at a round
|
|
109
|
+
* number.
|
|
110
|
+
*
|
|
111
|
+
* It is a floor and not a switch: below ~2.4 GB of RAM the containing ceiling
|
|
112
|
+
* itself drops under this number, and there the last clamp in
|
|
113
|
+
* {@link sessionMemoryMaxBytes} wins and a session is capped below the floor.
|
|
114
|
+
* That is not the cage killing honest work — the slice would have killed the
|
|
115
|
+
* same `pnpm typecheck` a moment later anyway, because it holds the same
|
|
116
|
+
* number — so removing `MemoryMax` from the scope there would buy nothing and
|
|
117
|
+
* cost the one thing it does buy: the runaway dying instead of its neighbours
|
|
118
|
+
* (QA-2026-09-07 MINOR-6, where the earlier wording promised the opposite).
|
|
119
|
+
*/
|
|
120
|
+
export declare const SESSION_MEMORY_HIGH_MIN_BYTES: number;
|
|
121
|
+
/**
|
|
122
|
+
* The wall, when nothing on the machine can say where it should be.
|
|
123
|
+
*
|
|
124
|
+
* Every real path reads the wall off systemd (the slice, else the service) or
|
|
125
|
+
* measures it (`memoryPolicy`). This is the last resort for a machine that gave
|
|
126
|
+
* neither — twice the honest share, so that even blind the wall sits above the
|
|
127
|
+
* brake by a margin an honest build fits into, and a runaway still meets one.
|
|
128
|
+
*/
|
|
129
|
+
export declare const SESSION_MEMORY_WALL_FALLBACK_FACTOR = 2;
|
|
130
|
+
/**
|
|
131
|
+
* The pool is cut into this many honest shares. Three, because that is what
|
|
132
|
+
* `maxSessions` defaults to on a dev server, so three sessions can sit at their
|
|
133
|
+
* brakes without the pool itself overflowing — and one runaway then meets its
|
|
134
|
+
* own wall (`ceiling − share`) while a neighbour on its share still fits.
|
|
135
|
+
*/
|
|
136
|
+
export declare const SESSION_SHARE_DIVISOR = 3;
|
|
137
|
+
/**
|
|
138
|
+
* On a machine too small to leave a share of room, the brake is this fraction
|
|
139
|
+
* of the wall. The same 0.8 `memoryPolicy` puts between the service's own
|
|
140
|
+
* `MemoryHigh` and `MemoryMax`, for the same reason: a band, however narrow,
|
|
141
|
+
* beats a bare kill line.
|
|
142
|
+
*/
|
|
143
|
+
export declare const SESSION_BRAKE_OF_WALL = 0.8;
|
|
144
|
+
/**
|
|
145
|
+
* `clamp(ceiling / 2, 2 GiB, 2.5 GiB)`, never above the ceiling itself.
|
|
146
|
+
*
|
|
147
|
+
* Three bounds, each for its own reason — see
|
|
148
|
+
* {@link SESSION_MEMORY_MAX_ABSOLUTE_BYTES} for the upper two and
|
|
149
|
+
* {@link SESSION_MEMORY_MIN_BYTES} for the lower one. The last clamp matters
|
|
150
|
+
* on a tiny machine: a per-session number ABOVE the slice's own ceiling is not
|
|
151
|
+
* a limit at all, it is a bigger number that never applies, and printing it in
|
|
152
|
+
* `doctor` would be a straight lie about what is in force.
|
|
153
|
+
*
|
|
154
|
+
* The argument is the ceiling of the cgroup that CONTAINS the session, and
|
|
155
|
+
* since 0.54.0 that is `devbridge-sessions.slice` and not the service — the
|
|
156
|
+
* caller reads the slice and falls back to the service only when systemd has
|
|
157
|
+
* nothing to say about the slice. The two are the same number by construction
|
|
158
|
+
* today; they come apart the moment a drop-in fails to apply or is edited by
|
|
159
|
+
* hand, and then «never more than the slice» has to still be true
|
|
160
|
+
* (QA-2026-09-07 MINOR-5).
|
|
161
|
+
*/
|
|
162
|
+
export declare function sessionMemoryHighBytes(containingMemoryMaxBytes: number | null): number;
|
|
163
|
+
/** The brake and the wall, decided together — see {@link sessionMemoryLadder}. */
|
|
164
|
+
export interface SessionMemoryLadder {
|
|
165
|
+
highBytes: number;
|
|
166
|
+
maxBytes: number;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The two numbers of the cage, computed in ONE place because each is wrong
|
|
170
|
+
* without the other (QA of #387 found both halves broken separately).
|
|
171
|
+
*
|
|
172
|
+
* ```
|
|
173
|
+
* brake = max(containing / 3, 2 GiB) capped by the containing ceiling
|
|
174
|
+
* wall = containing − brake never at or below the brake
|
|
175
|
+
* ```
|
|
176
|
+
*
|
|
177
|
+
* **Why a third, and no upper cap.** The owner's second note on #387: «ночью
|
|
178
|
+
* машина свободна (~9 ГБ), а сессии всё равно нельзя выйти за 2.5 ГБ… граница
|
|
179
|
+
* должна смотреть на то, сколько реально свободно». A fixed 2.5 GiB was right
|
|
180
|
+
* as a KILL line and is wrong as a brake: on a big machine it throttles honest
|
|
181
|
+
* work that the machine could have absorbed. A third of the pool is the largest
|
|
182
|
+
* share that still lets three sessions sit at their brakes inside the ceiling,
|
|
183
|
+
* which is what `maxSessions` defaults to. The 2 GiB floor stays — it is sized
|
|
184
|
+
* over the 1571 MB measured peak of a workspace `pnpm typecheck`, and a dev
|
|
185
|
+
* server that cannot run one of those is not a dev server.
|
|
186
|
+
*
|
|
187
|
+
* **Why the wall is `containing − brake` and not the ceiling itself.** 0.55.0's
|
|
188
|
+
* first shape put the wall AT the slice ceiling, and that is not a per-session
|
|
189
|
+
* wall at all: the kernel charges a leaf and every ancestor, so with any
|
|
190
|
+
* neighbour the SLICE overflows first, and then the victim is chosen across the
|
|
191
|
+
* whole slice. Measured on this host (a 300 MB slice, two 250 MB scopes): the
|
|
192
|
+
* scope that crept up survived to the end, and the **neighbour holding 200 MB
|
|
193
|
+
* under its own wall was killed** — slice `max 19, oom 1, oom_kill 1`, victim
|
|
194
|
+
* `max 0, oom 0, oom_kill 1`. Leaving one honest share of room under the
|
|
195
|
+
* ceiling is what makes the runaway meet its OWN wall first while a neighbour
|
|
196
|
+
* on its share still fits.
|
|
197
|
+
*
|
|
198
|
+
* **The tiny machine.** When the ceiling is so low that `containing − brake`
|
|
199
|
+
* would land at or under the brake, there is no room for isolation at all: the
|
|
200
|
+
* wall becomes the ceiling and the BRAKE is lowered to 80 % of it, so that
|
|
201
|
+
* there is still a braking band (`memoryPolicy` uses the same 0.8 for the
|
|
202
|
+
* service). Without this, a 4 GB machine got `MemoryHigh == MemoryMax` — the
|
|
203
|
+
* 0.54.0 kill line back in place, with the card claiming a brake.
|
|
204
|
+
*/
|
|
205
|
+
export declare function sessionMemoryLadder(containingMemoryMaxBytes: number | null, measuredCeilingBytes?: number | null): SessionMemoryLadder;
|
|
206
|
+
/**
|
|
207
|
+
* The wall — `MemoryMax` on the scope — and why it is the WHOLE containing
|
|
208
|
+
* ceiling and not a fraction of it (#387).
|
|
209
|
+
*
|
|
210
|
+
* The owner's rule: the wall is protection against a runaway, not the norm of
|
|
211
|
+
* work. Where the norm lives is {@link sessionMemoryHighBytes}; the wall has to
|
|
212
|
+
* be far enough above it that honest work never touches it, and «far» on a dev
|
|
213
|
+
* server means «what the machine can actually spare» — which is exactly the
|
|
214
|
+
* number the slice ceiling already is (`memoryPolicy`: `MemAvailable` plus what
|
|
215
|
+
* we hold, minus a reserve). Giving a session less than that is the 2.5 GiB
|
|
216
|
+
* mistake with a different number.
|
|
217
|
+
*
|
|
218
|
+
* With `OOMPolicy=continue` on the scope, reaching the wall costs a runaway its
|
|
219
|
+
* fattest process and nothing else; the neighbours are protected by the slice,
|
|
220
|
+
* which holds the same number over all of them together.
|
|
221
|
+
*
|
|
222
|
+
* `containing` is what systemd has in force on the slice (else the service);
|
|
223
|
+
* `measured` is what the policy would write there; the factor is the last
|
|
224
|
+
* resort. Never below the brake — a wall under the brake is a kill line again.
|
|
225
|
+
*/
|
|
226
|
+
export declare function sessionMemoryMaxBytes(containingMemoryMaxBytes: number | null, measuredCeilingBytes?: number | null): number;
|
|
227
|
+
/**
|
|
228
|
+
* Swap one session may push into — the reason the brake slows instead of stalls.
|
|
229
|
+
*
|
|
230
|
+
* Half of what the slice may use, the same «no single session takes the
|
|
231
|
+
* collective allowance» rule as the memory share. When systemd reports no
|
|
232
|
+
* bound on the slice (the drop-in never landed, MAJOR-3 shape) the share is cut
|
|
233
|
+
* from the machine's `SwapTotal` directly, with the same fraction the slice
|
|
234
|
+
* would have had, so the per-scope line is a real bound on its own. A machine
|
|
235
|
+
* with no swap gets 0, which is exactly 0.54.0's line — and exactly right:
|
|
236
|
+
* there is nothing to share.
|
|
237
|
+
*/
|
|
238
|
+
export declare function sessionSwapMaxBytes(sliceSwapMaxBytes: number | null, hostSwapTotalBytes: number | null): number;
|
|
239
|
+
/**
|
|
240
|
+
* The session id as systemd will accept it.
|
|
241
|
+
*
|
|
242
|
+
* Ids are UUIDs today and arbitrary text tomorrow — they arrive from the API,
|
|
243
|
+
* and `verify` keys its cage by a run id. systemd unit names take only
|
|
244
|
+
* `[A-Za-z0-9:_.-]`, so everything else is folded to `-`. Folding is lossy, and
|
|
245
|
+
* lossy is how two different sessions end up fighting over one scope, so
|
|
246
|
+
* anything that had to be changed or cut also gets eight hex digits of the
|
|
247
|
+
* original — deterministic, so the same session always names the same unit and
|
|
248
|
+
* the sweeper can still recognise it.
|
|
249
|
+
*/
|
|
250
|
+
export declare function sanitizeCageId(id: string): string;
|
|
251
|
+
/**
|
|
252
|
+
* The scope unit for one START of one session.
|
|
253
|
+
*
|
|
254
|
+
* `attempt` is not decoration. A scope the OOM killer took stays loaded in
|
|
255
|
+
* `failed` until something resets it, and `systemd-run --unit=` on a name that
|
|
256
|
+
* is still loaded fails outright — measured: «Unit devbridge-….scope was
|
|
257
|
+
* already loaded or has a fragment file», exit 1, nothing spawned. So a session
|
|
258
|
+
* that is restarted after an OOM (which is exactly when it IS restarted) would
|
|
259
|
+
* be unable to start at all if the name never changed.
|
|
260
|
+
* {@link releaseSessionScope} clears the failed unit and only then lets the
|
|
261
|
+
* counter fall back to 1, so the plain `devbridge-session-<id>.scope` is the
|
|
262
|
+
* normal case and the marker appears only while the old scope is still there.
|
|
263
|
+
*/
|
|
264
|
+
export declare function sessionScopeUnit(id: string, attempt?: number): string;
|
|
265
|
+
/**
|
|
266
|
+
* The systemd release that added `--expand-environment=`.
|
|
267
|
+
*
|
|
268
|
+
* Below it `systemd-run` answers `unrecognized option` and exits 1 — the probe
|
|
269
|
+
* then reads as «the cage did not hold» on a machine whose cgroups are perfect
|
|
270
|
+
* (Ubuntu 22.04 is 249, Debian 12 and RHEL 9 are 252). See the module header for
|
|
271
|
+
* why the flag is not needed there either.
|
|
272
|
+
*/
|
|
273
|
+
export declare const EXPAND_ENVIRONMENT_MIN_SYSTEMD = 254;
|
|
274
|
+
/** What one run of the throwaway scope answered, and why it did not run. */
|
|
275
|
+
export interface CageProbeResult {
|
|
276
|
+
/** `memory.max` as the scope read it back. Null when the scope never ran. */
|
|
277
|
+
memoryMax: string | null;
|
|
278
|
+
/** `systemd-run`'s own first line of complaint. Null when it ran. */
|
|
279
|
+
error: string | null;
|
|
280
|
+
}
|
|
281
|
+
/** Everything about this machine the detector needs, so a test can lie about all of it. */
|
|
282
|
+
export interface CageProbe {
|
|
283
|
+
/** `cgroup2fs`, `tmpfs`, or whatever else is mounted at `/sys/fs/cgroup`. */
|
|
284
|
+
cgroupFsType: () => string | null;
|
|
285
|
+
systemdRunOnPath: () => boolean;
|
|
286
|
+
/** Major version of `systemd-run`, or null when it would not say. */
|
|
287
|
+
systemdRunVersion: () => Promise<number | null>;
|
|
288
|
+
/** `$XDG_RUNTIME_DIR` when `$XDG_RUNTIME_DIR/bus` exists, else null. */
|
|
289
|
+
userBusPath: () => string | null;
|
|
290
|
+
/** Controllers delegated to this user's manager, or null when unreadable. */
|
|
291
|
+
delegatedControllers: () => string[] | null;
|
|
292
|
+
/** The one-shot scope that reads its own `memory.max`, built like a real one. */
|
|
293
|
+
probeMemoryMax: (options: {
|
|
294
|
+
expandEnvironmentFlag: boolean;
|
|
295
|
+
oomPolicyFlag: boolean;
|
|
296
|
+
}) => Promise<CageProbeResult>;
|
|
297
|
+
/** `MemoryMax` of the runner service in bytes; null for `infinity`. */
|
|
298
|
+
serviceMemoryMax: () => Promise<number | null>;
|
|
299
|
+
/** `MemoryMax` in force on `devbridge-sessions.slice`; null for `infinity`. */
|
|
300
|
+
sessionsSliceMemoryMax: () => Promise<number | null>;
|
|
301
|
+
/** `MemorySwapMax` in force on the slice; 0 is a real answer, null = `infinity`/unknown. */
|
|
302
|
+
sessionsSliceSwapMax: () => Promise<number | null>;
|
|
303
|
+
/** `SwapTotal` of the machine, or null where `/proc/meminfo` will not say. */
|
|
304
|
+
hostSwapTotalBytes: () => number | null;
|
|
305
|
+
/** The ceiling `memoryPolicy` would write today, or null on an unmeasurable machine. */
|
|
306
|
+
machineCeilingBytes: () => number | null;
|
|
307
|
+
/** Can this kernel renice at all — the fallback's own precondition. */
|
|
308
|
+
canRenice: () => boolean;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Variables the child needs to reach the user's systemd, and nothing else.
|
|
312
|
+
*
|
|
313
|
+
* `systemd-run --user` finds the bus through `XDG_RUNTIME_DIR` (sd-bus falls
|
|
314
|
+
* back to `$XDG_RUNTIME_DIR/bus` when `DBUS_SESSION_BUS_ADDRESS` is unset), and
|
|
315
|
+
* that variable is already on both spawn allowlists — so merging this into a
|
|
316
|
+
* session's environment widens nothing. `DBUS_SESSION_BUS_ADDRESS` is passed on
|
|
317
|
+
* only when the daemon really has one of its own, never synthesised.
|
|
318
|
+
*/
|
|
319
|
+
export declare function cageEnv(): Record<string, string>;
|
|
320
|
+
export declare const defaultCageProbe: CageProbe;
|
|
321
|
+
export declare function detectSessionCage(probe?: CageProbe): Promise<SessionCageFacts>;
|
|
322
|
+
/**
|
|
323
|
+
* Did `systemd-run` refuse the command line BECAUSE of `OOMPolicy=`?
|
|
324
|
+
*
|
|
325
|
+
* systemd's wording for a property a unit type does not take is «Unknown
|
|
326
|
+
* assignment: OOMPolicy=continue» (`bus_append_unit_property_assignment`); a
|
|
327
|
+
* refusal that does not name the property is some other machine's problem and
|
|
328
|
+
* must not be retried into a cage with a weaker policy.
|
|
329
|
+
*/
|
|
330
|
+
export declare function refusesOomPolicy(error: string): boolean;
|
|
331
|
+
/**
|
|
332
|
+
* Probe once, at daemon start, and remember the answer.
|
|
333
|
+
*
|
|
334
|
+
* The probe costs a process, so it is not something a spawn can afford to do:
|
|
335
|
+
* three sessions starting at once would mean three throwaway scopes before the
|
|
336
|
+
* first agent got a word out.
|
|
337
|
+
*/
|
|
338
|
+
export declare function initSessionCage(probe?: CageProbe): Promise<SessionCageFacts>;
|
|
339
|
+
/** What the last {@link initSessionCage} found; the safe default before it ran. */
|
|
340
|
+
export declare function sessionCage(): SessionCageFacts;
|
|
341
|
+
export interface CagedSpawn {
|
|
342
|
+
command: string;
|
|
343
|
+
args: string[];
|
|
344
|
+
/**
|
|
345
|
+
* Variables to MERGE into the child's environment (`{...yours, ...cage.env}`).
|
|
346
|
+
* Empty when nothing was wrapped.
|
|
347
|
+
*/
|
|
348
|
+
env: Record<string, string>;
|
|
349
|
+
/** The scope unit this start will live in, or null when nothing was wrapped. */
|
|
350
|
+
unit: string | null;
|
|
351
|
+
}
|
|
352
|
+
/** The scope a live caged id runs in, or null when it is not caged (or gone). */
|
|
353
|
+
export declare function sessionScopeUnitOf(id: string): string | null;
|
|
354
|
+
/**
|
|
355
|
+
* Wrap a command in its session's cage, or hand it back untouched.
|
|
356
|
+
*
|
|
357
|
+
* Untouched is the honest answer on every machine where the cage was not proved
|
|
358
|
+
* to work: `systemd-run` would accept the flags there and apply nothing, and a
|
|
359
|
+
* session that believes it is contained when it is not is worse than one that
|
|
360
|
+
* knows it is not.
|
|
361
|
+
*
|
|
362
|
+
* Pipes, exit codes, signals and stdin EOF behave exactly as with a direct
|
|
363
|
+
* spawn, because `systemd-run --scope` execs into the SAME pid — verified in the
|
|
364
|
+
* spike, including `detached: true` + `process.kill(-pid)` in `verify.ts`
|
|
365
|
+
* (`pgid === child.pid` still holds).
|
|
366
|
+
*/
|
|
367
|
+
export declare function cageSpawn(input: {
|
|
368
|
+
id: string;
|
|
369
|
+
command: string;
|
|
370
|
+
args: string[];
|
|
371
|
+
}): CagedSpawn;
|
|
372
|
+
/**
|
|
373
|
+
* Did this process die in the window before `systemd-run` handed over?
|
|
374
|
+
*
|
|
375
|
+
* Starting a scope took 0.07–2.5 s in the spike, and 2741 ms on a loaded
|
|
376
|
+
* machine. A stop inside that window signals `systemd-run` itself, before the
|
|
377
|
+
* `exec`, and the parent sees `{code: null, signal: 'SIGTERM'}` with nothing on
|
|
378
|
+
* stdout at all. That is «the session never started», not «the agent died
|
|
379
|
+
* silently» — told apart here so no supervisor has to infer it from an empty
|
|
380
|
+
* buffer.
|
|
381
|
+
*/
|
|
382
|
+
export declare function killedBeforeExec(info: {
|
|
383
|
+
code: number | null;
|
|
384
|
+
signal: string | null;
|
|
385
|
+
sawOutput: boolean;
|
|
386
|
+
caged: boolean;
|
|
387
|
+
}): boolean;
|
|
388
|
+
/** The counters of one session's cgroup, read straight off the filesystem. */
|
|
389
|
+
export interface ScopeMemoryStatus {
|
|
390
|
+
/** `memory.current`. */
|
|
391
|
+
currentBytes: number;
|
|
392
|
+
/** `memory.high` — null for `max`. */
|
|
393
|
+
highBytes: number | null;
|
|
394
|
+
/** `memory.max` — null for `max`. */
|
|
395
|
+
maxBytes: number | null;
|
|
396
|
+
/** `memory.swap.current`, 0 where the file is missing. */
|
|
397
|
+
swapCurrentBytes: number;
|
|
398
|
+
/** `memory.events` `high`: how many times the brake engaged. Cumulative. */
|
|
399
|
+
highEvents: number;
|
|
400
|
+
/** `memory.events` `oom_kill`: processes the kernel killed in here. Cumulative. */
|
|
401
|
+
oomKills: number;
|
|
402
|
+
/**
|
|
403
|
+
* `memory.events` `oom`: how many times THIS cgroup's own limit was reached
|
|
404
|
+
* and an allocation was about to fail.
|
|
405
|
+
*
|
|
406
|
+
* The discriminator between «this session hit its wall» and «the pool over
|
|
407
|
+
* all sessions ran out and the kernel picked a victim anywhere in it».
|
|
408
|
+
* Measured on this host: the innocent neighbour that was killed read
|
|
409
|
+
* `max 0, oom 0, oom_kill 1`, while the slice read `max 19, oom 1,
|
|
410
|
+
* oom_kill 1`. Without it every kill was reported to the session it landed
|
|
411
|
+
* on as «you went over your ceiling», which for a neighbour is false.
|
|
412
|
+
*/
|
|
413
|
+
ownLimitOom: number;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* The pure half of {@link readScopeMemoryStatus}: the four files' text in, the
|
|
417
|
+
* status out. `memory.swap.current` is optional (no swap controller, cgroup v2
|
|
418
|
+
* without swap accounting); the rest are not.
|
|
419
|
+
*/
|
|
420
|
+
export declare function parseScopeMemoryStatus(files: {
|
|
421
|
+
current: string;
|
|
422
|
+
high: string;
|
|
423
|
+
max: string;
|
|
424
|
+
events: string;
|
|
425
|
+
swapCurrent?: string;
|
|
426
|
+
}): ScopeMemoryStatus | null;
|
|
427
|
+
/**
|
|
428
|
+
* What one live session's cgroup holds and has been through. Null when the
|
|
429
|
+
* cgroup is not there (the scope ended, or this machine has no cage).
|
|
430
|
+
*
|
|
431
|
+
* Filesystem, not `systemctl show`: this runs on the supervisor's 30 s tick for
|
|
432
|
+
* every live session, and the bus is the thing that took 2.7 s under load.
|
|
433
|
+
*/
|
|
434
|
+
export declare function readScopeMemoryStatus(unit: string, readFile?: (p: string) => string): ScopeMemoryStatus | null;
|
|
435
|
+
export declare function markScopeOomKillsSeen(id: string, oomKills: number): void;
|
|
436
|
+
/**
|
|
437
|
+
* Snapshot the cgroup's verdict before systemd can take it away.
|
|
438
|
+
*
|
|
439
|
+
* Exported for the test seam only (`readStatus`): the real caller passes
|
|
440
|
+
* nothing and reads the live cgroup.
|
|
441
|
+
*/
|
|
442
|
+
export declare function rememberDeath(id: string, unit: string, readStatus?: (unit: string) => ScopeMemoryStatus | null): void;
|
|
443
|
+
/**
|
|
444
|
+
* One sentence for the error the person reads, when the kernel had a hand in
|
|
445
|
+
* this death — or null when it had not. Consumed: a session restarted after an
|
|
446
|
+
* OOM must not carry the old sentence into its next, unrelated failure.
|
|
447
|
+
*
|
|
448
|
+
* The text that reached people in 0.54.0 was «exited with code 143» once and
|
|
449
|
+
* «terminated by signal SIGKILL» the next time, for the same cause, and neither
|
|
450
|
+
* said the word memory. This is that word.
|
|
451
|
+
*/
|
|
452
|
+
export declare function explainMemoryDeath(id: string): string | null;
|
|
453
|
+
/** So the sweeper and the reader can be tested without a machine under them. */
|
|
454
|
+
export type Systemctl = (args: string[]) => Promise<{
|
|
455
|
+
stdout: string;
|
|
456
|
+
stderr: string;
|
|
457
|
+
}>;
|
|
458
|
+
/**
|
|
459
|
+
* Read why the scope ended, say so, and then let systemd forget it.
|
|
460
|
+
*
|
|
461
|
+
* This is the whole reason `--collect` is not passed. The order is fixed: the
|
|
462
|
+
* process has already exited, `Result` is read, the reason is logged, and only
|
|
463
|
+
* then is the unit reset — because `reset-failed` is what deletes the answer.
|
|
464
|
+
*
|
|
465
|
+
* Failure here is never fatal: a unit that could not be reset is swept at the
|
|
466
|
+
* next daemon start, and the counter in {@link cageSpawn} keeps the session
|
|
467
|
+
* startable in the meantime.
|
|
468
|
+
*/
|
|
469
|
+
export declare function releaseSessionScope(unit: string | null, id?: string, systemctl?: Systemctl): Promise<string | null>;
|
|
470
|
+
/**
|
|
471
|
+
* The sentence for a death, once the verdict is in. Null when the kernel had no
|
|
472
|
+
* hand in it. Waits for the release of this session's scope, but never longer
|
|
473
|
+
* than `capMs`: an answer that arrives after the person has read the error is
|
|
474
|
+
* worth nothing, and a hung `systemctl` must not hold a failing session open.
|
|
475
|
+
*/
|
|
476
|
+
export declare function memoryDeathSentence(id: string, capMs?: number): Promise<string | null>;
|
|
477
|
+
/** Unit names of every `devbridge-session-*.scope` systemd still knows about. */
|
|
478
|
+
export declare function listSessionScopeUnits(systemctl?: Systemctl): Promise<string[]>;
|
|
479
|
+
export interface SessionScopeInfo {
|
|
480
|
+
unit: string;
|
|
481
|
+
memoryMaxBytes: number | null;
|
|
482
|
+
memoryCurrentBytes: number | null;
|
|
483
|
+
tasksCurrent: number | null;
|
|
484
|
+
result: string | null;
|
|
485
|
+
activeState: string | null;
|
|
486
|
+
}
|
|
487
|
+
/** What `doctor` prints for each caged session. */
|
|
488
|
+
export declare function listSessionScopes(systemctl?: Systemctl): Promise<SessionScopeInfo[]>;
|
|
489
|
+
/**
|
|
490
|
+
* Stop and forget every session scope that has no session behind it.
|
|
491
|
+
*
|
|
492
|
+
* At daemon start that is all of them by definition, and it is the point: a
|
|
493
|
+
* scope outlives a killed daemon carrying the whole process tree with it, which
|
|
494
|
+
* is the shape of the 10 h 51 min `ugrep` of 16.08. Stopping the scope takes the
|
|
495
|
+
* tree, not just the process we happened to know about.
|
|
496
|
+
*
|
|
497
|
+
* `liveIds` exists so the same sweep can run later without killing work in
|
|
498
|
+
* progress; only the CURRENT scope of a live session is spared, because an
|
|
499
|
+
* earlier attempt of the same session is exactly the leftover we are here for.
|
|
500
|
+
*/
|
|
501
|
+
export declare function sweepOrphanSessionScopes(liveIds?: Iterable<string>, systemctl?: Systemctl): Promise<string[]>;
|
|
502
|
+
/**
|
|
503
|
+
* The slice names and the CPU share — re-exported so a caller that reasons about
|
|
504
|
+
* the cage needs one import, while the values themselves stay next to the
|
|
505
|
+
* drop-in that writes them (`service-unit.ts`).
|
|
506
|
+
*/
|
|
507
|
+
export { SESSIONS_SLICE, DEVBRIDGE_SLICE, SESSION_CPU_WEIGHT };
|
|
508
|
+
//# sourceMappingURL=session-cage.d.ts.map
|