@bridge4dev/runner 0.56.0 → 0.58.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude.js +23 -4
- package/dist/adapters/codex-protocol.js +6 -1
- package/dist/adapters/codex.js +11 -2
- package/dist/adapters/questions.d.ts +15 -0
- package/dist/adapters/questions.js +32 -0
- 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 +68 -0
- package/dist/protocol.d.ts +43 -27
- package/dist/recipe-schema.d.ts +12 -12
- 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 +235 -33
- package/dist/supervisor.js +1178 -265
- 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,492 @@
|
|
|
1
|
+
import { SESSION_MEMORY_BAND_BYTES, sessionMemoryLadder } from './session-cage.js';
|
|
2
|
+
const MIB = 1024 * 1024;
|
|
3
|
+
/**
|
|
4
|
+
* How much memory each live session may use, decided from what is actually free
|
|
5
|
+
* (#398 S3, ticket #398).
|
|
6
|
+
*
|
|
7
|
+
* The number this replaces was `max(pot / 3, 2 GiB)`, computed ONCE at daemon
|
|
8
|
+
* start and never again. The divisor came from `maxSessions` defaulting to three
|
|
9
|
+
* and from nothing else, so a lone session on an idle machine got a third of the
|
|
10
|
+
* pot and the other two thirds sat unused — the owner's complaint in #398, word
|
|
11
|
+
* for word: «ночью машина свободна ~9 ГБ, а сессии всё равно нельзя выйти за
|
|
12
|
+
* 2.5 ГБ».
|
|
13
|
+
*
|
|
14
|
+
* **The decisive idea is three levels, and it has to be understood before any of
|
|
15
|
+
* the arithmetic below.** Today a session has two numbers, the brake and the
|
|
16
|
+
* wall, and both of the jobs — «do not crowd your neighbour» and «do not kill
|
|
17
|
+
* the machine» — are being asked of them at once. That is unsatisfiable: for a
|
|
18
|
+
* lone session to take nearly the whole pot its brake must be nearly the whole
|
|
19
|
+
* pot, and then two such sessions overflow the pot between them without either
|
|
20
|
+
* being braked — and what overflows is the SLICE, where the victim is chosen by
|
|
21
|
+
* the kernel and the one that dies is not the one at fault (measured, #387).
|
|
22
|
+
*
|
|
23
|
+
* So there are three, the way every grown-up scheduler has three:
|
|
24
|
+
*
|
|
25
|
+
* - **the guarantee** (`MemoryLow`) — small and untouchable. The sum of the
|
|
26
|
+
* guarantees has to fit in the pot, and this is the ONLY place a sum is
|
|
27
|
+
* checked. It answers «how much will not be taken from me».
|
|
28
|
+
* - **the brake** (`MemoryHigh`) — large and DELIBERATELY oversold. Every
|
|
29
|
+
* session may grow into everything that is free right now. The sum of the
|
|
30
|
+
* brakes is greater than the pot, and that is not a mistake.
|
|
31
|
+
* - **the collective brake on the slice** (`MemoryHigh` on
|
|
32
|
+
* `devbridge-sessions.slice`, `service-unit.ts`) — the thing that makes the
|
|
33
|
+
* overselling safe. As the sessions together approach the pot the kernel
|
|
34
|
+
* slows all of them and reclaims first from whoever is above their guarantee,
|
|
35
|
+
* so the hard ceiling — where the OOM killer picks — is never reached.
|
|
36
|
+
* - **the wall** (`MemoryMax`) — still one guarantee below the pot, so a lone
|
|
37
|
+
* runaway meets ITS OWN wall before the shared pot overflows (rule of #387).
|
|
38
|
+
*
|
|
39
|
+
* Overselling the brakes WITHOUT the collective brake on the slice is a bug, not
|
|
40
|
+
* a solution. The two halves are one change, and {@link allocateSessionMemory}
|
|
41
|
+
* refuses to oversell when the caller reports that the slice has no live
|
|
42
|
+
* `MemoryHigh` — see {@link AllocatorMachine.collectiveBrake}.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* What one session is guaranteed and will not have taken away.
|
|
46
|
+
*
|
|
47
|
+
* 2 GiB, sized over the 1571 MB peak measured for a workspace `pnpm typecheck`
|
|
48
|
+
* in this monorepo. A dev server that cannot run one of those is not a dev
|
|
49
|
+
* server, so this is the number below which a session is not worth starting.
|
|
50
|
+
*/
|
|
51
|
+
export const SESSION_GUARANTEE_BYTES = 2048 * MIB;
|
|
52
|
+
/**
|
|
53
|
+
* Growth allowance over what a session already holds.
|
|
54
|
+
*
|
|
55
|
+
* The same 1.25 `service-unit.ts` uses for the ceiling it writes over a cgroup's
|
|
56
|
+
* current usage, and for the same reason: a limit at exactly what is held is a
|
|
57
|
+
* limit that is already breached.
|
|
58
|
+
*/
|
|
59
|
+
export const SESSION_HEADROOM_OVER_HOLD = 1.25;
|
|
60
|
+
/**
|
|
61
|
+
* The floor under the BRAKE, over what a session already holds — 1.05, and it
|
|
62
|
+
* is a different number from the one above on purpose (#398 §8, #403).
|
|
63
|
+
*
|
|
64
|
+
* The wall may sit a quarter above the hold: it is a hard limit, and headroom
|
|
65
|
+
* there costs nothing. The brake may not, because it is followed every tick: at
|
|
66
|
+
* 1.25 a session that merely holds its ground has its brake RAISED on every
|
|
67
|
+
* pass, `addMemoryTo` reads that as «the machine found it more memory», and the
|
|
68
|
+
* stall deadline resets for ever — measured in the suite, four tests waiting
|
|
69
|
+
* for a stop that could no longer come.
|
|
70
|
+
*
|
|
71
|
+
* 1.05 is enough for the one thing this floor is for: `memory.current` counts
|
|
72
|
+
* page cache, so a brake at exactly the anonymous hold is already breached.
|
|
73
|
+
*/
|
|
74
|
+
export const SESSION_BRAKE_OVER_HOLD = 1.05;
|
|
75
|
+
/**
|
|
76
|
+
* The step the DIVIDED guarantee is rounded down to — 64 MiB (#403).
|
|
77
|
+
*
|
|
78
|
+
* `MemoryLow` is the one number here with no dead zone of its own, on purpose:
|
|
79
|
+
* a guarantee is what a person is promised, and a promise that lags a tick
|
|
80
|
+
* behind the machine is worse than one that follows it. But the divided
|
|
81
|
+
* guarantee is `pool / n`, and the pool follows `MemAvailable`, which breathes:
|
|
82
|
+
* measured on a hungry machine with two sessions, a pot of 3800 MiB and 11 MiB
|
|
83
|
+
* of breathing wrote `MemoryLow` on ten ticks out of ten where the code before
|
|
84
|
+
* these fixes wrote it on one.
|
|
85
|
+
*
|
|
86
|
+
* Rounding down rather than to nearest, because the sum of the guarantees is
|
|
87
|
+
* the one sum in this module that has to fit inside the pot.
|
|
88
|
+
*/
|
|
89
|
+
export const GUARANTEE_STEP_BYTES = 64 * 1024 * 1024;
|
|
90
|
+
/** The divided guarantee, quantised so it does not breathe with `MemAvailable`. */
|
|
91
|
+
function quantiseGuarantee(bytes) {
|
|
92
|
+
return Math.max(1, Math.floor(bytes / GUARANTEE_STEP_BYTES) * GUARANTEE_STEP_BYTES);
|
|
93
|
+
}
|
|
94
|
+
/** Below this pot the machine is honestly single-session — see the formula. */
|
|
95
|
+
export function hungryPotBytes(baseBand) {
|
|
96
|
+
return 2 * SESSION_GUARANTEE_BYTES + baseBand;
|
|
97
|
+
}
|
|
98
|
+
/** The band this machine uses before any session-specific widening. */
|
|
99
|
+
export function baseBandBytes(oomContinue) {
|
|
100
|
+
return oomContinue ? SESSION_MEMORY_BAND_BYTES : 512 * MIB;
|
|
101
|
+
}
|
|
102
|
+
function clamp(value, low, high) {
|
|
103
|
+
return Math.min(Math.max(value, low), high);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The whole decision, as a pure function of the numbers.
|
|
107
|
+
*
|
|
108
|
+
* Kept pure and kept here, away from every timer and socket, because what it has
|
|
109
|
+
* to be is CORRECT on machines nobody has: the formula travels with the runner
|
|
110
|
+
* to every customer's dev server, and the last two times a memory rule shipped
|
|
111
|
+
* it was reasonable on the author's machine and destructive somewhere else
|
|
112
|
+
* (#387: the victim was the neighbour; `service-unit.ts:314`: a 4 GB machine got
|
|
113
|
+
* `MemoryHigh == MemoryMax`). So the fleet's profiles are fixtures, and the
|
|
114
|
+
* eight properties below are tests rather than comments.
|
|
115
|
+
*/
|
|
116
|
+
export function allocateSessionMemory(machine, sessions, knobs = {}) {
|
|
117
|
+
const G = Math.max(1, knobs.guaranteeBytes ?? SESSION_GUARANTEE_BYTES);
|
|
118
|
+
const baseBand = baseBandBytes(machine.oomContinue);
|
|
119
|
+
const held = sessions.reduce((sum, s) => sum + Math.max(0, s.holdBytes), 0);
|
|
120
|
+
const current = sessions.reduce((sum, s) => sum + Math.max(0, s.currentBytes), 0);
|
|
121
|
+
// The live pot. `MemAvailable` cannot see what our own cgroups hold, so it is
|
|
122
|
+
// added back; the machine's reserve comes off; and the slice's own ceiling is
|
|
123
|
+
// the hard cap, because that is what systemd is actually enforcing over all
|
|
124
|
+
// sessions together. Never rewritten live — a slice rewritten under live
|
|
125
|
+
// sessions kills them (blocker found by QA on 07.09).
|
|
126
|
+
const pot = machine.potBytes ?? Number.POSITIVE_INFINITY;
|
|
127
|
+
const measured = machine.availableBytes + current - machine.reserveBytes;
|
|
128
|
+
// The floor is what the sessions already hold: a pot below that is not a pot,
|
|
129
|
+
// it is a number that would have to kill somebody to become true.
|
|
130
|
+
/**
|
|
131
|
+
* FLOORED, and this is not tidiness.
|
|
132
|
+
*
|
|
133
|
+
* `machineReserveBytes` is `max(1.5 GiB, total × 0.15)`, and on any machine
|
|
134
|
+
* with more than 10 GiB of RAM the second term wins and is FRACTIONAL
|
|
135
|
+
* (12541489152 × 0.15 = 1881223372.8). The fraction travelled through the pot
|
|
136
|
+
* into the brake and the wall, and systemd was handed
|
|
137
|
+
* `MemoryHigh=6350946099.200001`.
|
|
138
|
+
*
|
|
139
|
+
* **Корректировка 10.09.2026.** Проверка, ради которой это писалось, сначала
|
|
140
|
+
* назвала это блокером — «systemd такое отвергнет, на парке не записалось бы
|
|
141
|
+
* ничего». Замер показал обратное: systemd десятичное значение принимает.
|
|
142
|
+
* Пол всё равно нужен, но по другой причине: то, что systemd принял, он
|
|
143
|
+
* округляет по-своему, и обратно читается уже другое число — а согласование
|
|
144
|
+
* лимитов сравнивает записанное с прочитанным точным равенством. Дробь
|
|
145
|
+
* означала бы «не совпало» на каждом тике и запись свойств по кругу.
|
|
146
|
+
*
|
|
147
|
+
* Оставлено здесь дословно, потому что в этом проекте комментарий-замер
|
|
148
|
+
* читают как факт: неверное обоснование в комментарии живёт дольше, чем
|
|
149
|
+
* неверный код.
|
|
150
|
+
*/
|
|
151
|
+
const poolBytes = Math.floor(Math.max(Math.min(pot, measured), held, G));
|
|
152
|
+
const freeBytes = Math.max(0, poolBytes - held);
|
|
153
|
+
const guaranteesFit = Math.max(0, Math.floor(poolBytes / G));
|
|
154
|
+
const n = Math.max(1, sessions.length);
|
|
155
|
+
const hungry = poolBytes < hungryPotBytes(baseBand);
|
|
156
|
+
// The guarantee. The ONE sum that is checked — and when it does not fit, the
|
|
157
|
+
// session still starts (D9) and is told it is in a crowd.
|
|
158
|
+
const guaranteeFits = n * G <= poolBytes;
|
|
159
|
+
const guarantee = guaranteeFits ? G : quantiseGuarantee(Math.floor(poolBytes / n));
|
|
160
|
+
/**
|
|
161
|
+
* The conservative fallback, and why it exists at all.
|
|
162
|
+
*
|
|
163
|
+
* Overselling the brakes is safe ONLY while the slice carries a live
|
|
164
|
+
* `MemoryHigh`. On a systemd where the drop-in never applied, the slice reads
|
|
165
|
+
* `MemoryMax=infinity` and overflowing it ends in a kill, not in throttling —
|
|
166
|
+
* and the kernel picks the victim across the whole slice. So a machine that
|
|
167
|
+
* cannot prove it has the collective brake gets roughly today's behaviour
|
|
168
|
+
* instead: the pot divided by the number of sessions, never fewer than three.
|
|
169
|
+
*/
|
|
170
|
+
const share = Math.floor(poolBytes / Math.max(3, n));
|
|
171
|
+
const out = sessions.map((session) => {
|
|
172
|
+
const hold = Math.max(0, session.holdBytes);
|
|
173
|
+
/**
|
|
174
|
+
* The owner's emergency switch, and it gives back the OLD numbers — both of
|
|
175
|
+
* them, computed the old way (#398 S7, B1).
|
|
176
|
+
*
|
|
177
|
+
* `adaptive_memory = false` is documented as «back to what 0.55.0 did», and
|
|
178
|
+
* that is the whole value of it: a switch out of a bad night has to land
|
|
179
|
+
* somewhere known. What it did instead was route only the BRAKE through the
|
|
180
|
+
* old formula, leave the wall to the new one, and compute both from the
|
|
181
|
+
* LIVE pool rather than from the pot. Measured on the DEVbridge profile:
|
|
182
|
+
* brake 2840 as in 0.55.0, wall 3096 where 0.55.0 gave 5681 — a switch that
|
|
183
|
+
* halves your ceiling is not a way back.
|
|
184
|
+
*
|
|
185
|
+
* `sessionMemoryLadder` IS the 0.55.0 pair, and it is the same function the
|
|
186
|
+
* daemon-start snapshot uses, so there is one definition of «the old
|
|
187
|
+
* behaviour» rather than a re-derivation of it.
|
|
188
|
+
*/
|
|
189
|
+
if (knobs.adaptive === false) {
|
|
190
|
+
const ladder = sessionMemoryLadder(machine.potBytes);
|
|
191
|
+
// The owner's ceiling still applies — a switch back to the old formula is
|
|
192
|
+
// not a switch out of the owner's own limit (#398 S7, B2). Never under
|
|
193
|
+
// what the session holds, for the reason every floor here exists.
|
|
194
|
+
const capped = Math.min(ladder.highBytes, knobs.sessionMaxBytes ?? Number.POSITIVE_INFINITY);
|
|
195
|
+
const brake = Math.floor(Math.max(capped, hold * SESSION_BRAKE_OVER_HOLD));
|
|
196
|
+
const wall = Math.floor(Math.max(ladder.maxBytes, brake + baseBand));
|
|
197
|
+
return {
|
|
198
|
+
id: session.id,
|
|
199
|
+
guaranteedBytes: Math.min(guarantee, brake),
|
|
200
|
+
brakeBytes: brake,
|
|
201
|
+
wallBytes: wall,
|
|
202
|
+
swapBytes: machine.swapBytes,
|
|
203
|
+
bandBytes: baseBand,
|
|
204
|
+
tight: !guaranteeFits,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (hungry) {
|
|
208
|
+
// Honestly one session at a time. No `G` floor under the brake here: on a
|
|
209
|
+
// 2 GiB pot a brake of 2048 would sit ABOVE a wall of 1792 and the ladder
|
|
210
|
+
// would be upside down. The band stays even here — a wall equal to the pot
|
|
211
|
+
// would hand the choice of victim back to the kernel (D4).
|
|
212
|
+
const band = machine.oomContinue
|
|
213
|
+
? baseBand
|
|
214
|
+
: Math.max(baseBand, Math.floor(poolBytes * 0.25));
|
|
215
|
+
/**
|
|
216
|
+
* …but NEVER below what this session already holds, and that omission was
|
|
217
|
+
* a blocker found by the independent review of 10.09.2026.
|
|
218
|
+
*
|
|
219
|
+
* A session holding 2900 MiB on a 2900 MiB pot was handed a wall of
|
|
220
|
+
* 2644 MiB — and lowering `MemoryMax` under what a process holds ends it
|
|
221
|
+
* in seconds (measured on this host: a scope holding 200 MiB, `MemoryMax`
|
|
222
|
+
* set to 64 MiB, the process read «Killed», the unit went `inactive`).
|
|
223
|
+
* The tight machine is exactly where this bites, because there the pot IS
|
|
224
|
+
* roughly what the session holds.
|
|
225
|
+
*/
|
|
226
|
+
/**
|
|
227
|
+
* The floor is `hold × 1.05`, not a bare `hold` — the constant exists for
|
|
228
|
+
* exactly this and the plan says so (§8): «a limit at exactly what is held
|
|
229
|
+
* is a limit that is already breached».
|
|
230
|
+
*
|
|
231
|
+
* `memory.current` counts page cache too, so a brake set to the anonymous
|
|
232
|
+
* `hold` is a scope that starts above its own brake: constant reclaim,
|
|
233
|
+
* PSI climbing, and the stall detector of S2 seeing a session it cannot
|
|
234
|
+
* help. Found reviewing these very fixes (#403).
|
|
235
|
+
*/
|
|
236
|
+
const floorOfHold = Math.floor(hold * SESSION_BRAKE_OVER_HOLD);
|
|
237
|
+
/**
|
|
238
|
+
* The owner's ceiling applies here too (#398 S7, B2).
|
|
239
|
+
*
|
|
240
|
+
* The hungry branch returns before the rest of the function, so both
|
|
241
|
+
* knobs used to be simply skipped on a tight machine — and a tight
|
|
242
|
+
* machine is exactly where an owner reaches for one: «leave room for the
|
|
243
|
+
* database, whatever the agents want». The floor under it stays what it
|
|
244
|
+
* is everywhere else, because a ceiling under what a session already
|
|
245
|
+
* holds kills it rather than limits it.
|
|
246
|
+
*/
|
|
247
|
+
const ownerCapHere = knobs.sessionMaxBytes ?? Number.POSITIVE_INFINITY;
|
|
248
|
+
const brake = Math.floor(Math.max(1, Math.min(Math.max(poolBytes - 2 * band, 1), ownerCapHere), floorOfHold));
|
|
249
|
+
/**
|
|
250
|
+
* The pot is the cap, EXCEPT where honouring it would put the wall under
|
|
251
|
+
* what the session holds — and then the wall wins.
|
|
252
|
+
*
|
|
253
|
+
* The trade is the one this module makes everywhere: an oversold limit is
|
|
254
|
+
* safe, a killed honest session is not recoverable. On a hungry machine it
|
|
255
|
+
* costs nothing at all, because there is by definition at most one session
|
|
256
|
+
* in the slice: if the slice's own ceiling binds first, the victim the
|
|
257
|
+
* kernel picks IS this session, so there is no neighbour to harm.
|
|
258
|
+
*/
|
|
259
|
+
const potCap = pot === Number.POSITIVE_INFINITY ? poolBytes + band : Math.floor(pot);
|
|
260
|
+
const wall = Math.floor(Math.max(brake + band, Math.min(Math.max(poolBytes - band, floorOfHold + band), potCap)));
|
|
261
|
+
return {
|
|
262
|
+
id: session.id,
|
|
263
|
+
/**
|
|
264
|
+
* The share, not the constant — the same review found three sessions on
|
|
265
|
+
* a 2048 MiB pot each being promised 2048 MiB. 4608 MiB of promises in
|
|
266
|
+
* a pot of 2048, and the sum of the guarantees is the ONE sum in this
|
|
267
|
+
* module that has to fit.
|
|
268
|
+
*/
|
|
269
|
+
guaranteedBytes: Math.max(1, Math.min(G, brake, quantiseGuarantee(Math.floor(poolBytes / n)))),
|
|
270
|
+
brakeBytes: brake,
|
|
271
|
+
wallBytes: wall,
|
|
272
|
+
swapBytes: machine.swapBytes,
|
|
273
|
+
bandBytes: band,
|
|
274
|
+
tight: !guaranteeFits,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// The brake this session WANTS: everything it holds plus everything nobody
|
|
278
|
+
// holds. This is the whole point of the stage — a lone session on an idle
|
|
279
|
+
// machine asks for nearly the whole pot, and gets it.
|
|
280
|
+
//
|
|
281
|
+
// …unless the owner has switched the whole thing off (#398 S6), and then it
|
|
282
|
+
// is exactly the number 0.55.0 shipped: `max(pot / 3, 2 GiB)`. The switch
|
|
283
|
+
// has to give back the OLD behaviour and not an approximation of it, or it
|
|
284
|
+
// is not a way out of a bad night.
|
|
285
|
+
// The `adaptive === false` case never reaches here — it returns above, with
|
|
286
|
+
// the 0.55.0 pair whole.
|
|
287
|
+
const desired = machine.collectiveBrake ? hold + freeBytes : share;
|
|
288
|
+
const band = machine.oomContinue
|
|
289
|
+
? baseBand
|
|
290
|
+
: Math.max(baseBand, Math.floor(Math.max(desired, G) * 0.25));
|
|
291
|
+
/**
|
|
292
|
+
* The ceiling of the brake, PER SESSION.
|
|
293
|
+
*
|
|
294
|
+
* A common `poolBytes − G` for everyone was a trap the first draft fell
|
|
295
|
+
* into: the pool is floored by what the sessions hold, so on a crowded
|
|
296
|
+
* machine `pool − G` comes out BELOW what one session already has. Worked
|
|
297
|
+
* example: a session holding 5000 MiB with nothing free, pool 5000, common
|
|
298
|
+
* ceiling 2952 — the formula would have handed a live session with five
|
|
299
|
+
* gigabytes in hand a brake of 2696 and a wall of 2952, which kills it in
|
|
300
|
+
* seconds (measured: `MemoryMax` lowered under what a process holds ends it).
|
|
301
|
+
*/
|
|
302
|
+
const top = Math.floor(Math.min(Math.max(Math.floor(hold * SESSION_HEADROOM_OVER_HOLD) + band, poolBytes - G), pot === Number.POSITIVE_INFINITY ? poolBytes : pot));
|
|
303
|
+
/**
|
|
304
|
+
* `top − band`, and the `− band` is not decoration.
|
|
305
|
+
*
|
|
306
|
+
* With both the brake and the wall capped at `pool − G`, the headline case
|
|
307
|
+
* of the whole plan — one session on a free machine — produced
|
|
308
|
+
* `MemoryHigh == MemoryMax`: the 0.54.0 kill line the entire stage exists to
|
|
309
|
+
* remove.
|
|
310
|
+
*/
|
|
311
|
+
const ownerCap = knobs.sessionMaxBytes ?? Number.POSITIVE_INFINITY;
|
|
312
|
+
/**
|
|
313
|
+
* The floor under the brake is the guarantee OR what the session already
|
|
314
|
+
* holds, whichever is larger.
|
|
315
|
+
*
|
|
316
|
+
* The independent review of 10.09.2026 found the second half missing: on a
|
|
317
|
+
* machine that answers `sessionOomContinue: false` the band is a quarter of
|
|
318
|
+
* the brake, and `top − band` came out below a session's own `hold` — which
|
|
319
|
+
* puts a live session under its brake the moment the number is written, i.e.
|
|
320
|
+
* throttles it for having done nothing.
|
|
321
|
+
*/
|
|
322
|
+
const floorOfBrake = Math.max(guarantee, Math.floor(hold * SESSION_BRAKE_OVER_HOLD));
|
|
323
|
+
const brake = Math.floor(clamp(Math.min(desired, ownerCap), floorOfBrake, Math.max(floorOfBrake, Math.min(top - band, ownerCap))));
|
|
324
|
+
/**
|
|
325
|
+
* The band between the brake and the wall is never given up, even when the
|
|
326
|
+
* pot is the thing in the way (decision D5 of `runner-cage-authority.md`,
|
|
327
|
+
* 10.09.2026).
|
|
328
|
+
*
|
|
329
|
+
* Measured on the profile of vmi2502773: a pot of 6835 MiB under a session
|
|
330
|
+
* holding 6000 gave `MemoryHigh=6000` and `MemoryMax=6835` — a band of 835
|
|
331
|
+
* where this machine's band is 1708. And the next tick made it worse:
|
|
332
|
+
* `planLimitMove` refuses to leave a wall under `brake + band` and would
|
|
333
|
+
* raise the wall to 7708, ABOVE the pot, which then travelled into the
|
|
334
|
+
* frame and onto the machine card.
|
|
335
|
+
*
|
|
336
|
+
* So the choice is made here and made once: an oversold wall is safe (the
|
|
337
|
+
* slice's own ceiling still binds, and the victim the kernel picks inside
|
|
338
|
+
* this scope is this session), while a brake equal to the wall means no
|
|
339
|
+
* braking at all — the session hits the hard limit with nothing in between,
|
|
340
|
+
* which is the 0.54.0 behaviour #387 was written to end.
|
|
341
|
+
*/
|
|
342
|
+
const wallCeiling = pot === Number.POSITIVE_INFINITY ? poolBytes + band : Math.floor(pot);
|
|
343
|
+
const wallWanted = Math.max(brake + band, Math.floor(hold * SESSION_HEADROOM_OVER_HOLD) + band);
|
|
344
|
+
const wall = Math.floor(Math.max(brake + band, Math.min(wallWanted, wallCeiling)));
|
|
345
|
+
return {
|
|
346
|
+
id: session.id,
|
|
347
|
+
guaranteedBytes: guarantee,
|
|
348
|
+
brakeBytes: brake,
|
|
349
|
+
wallBytes: wall,
|
|
350
|
+
swapBytes: machine.swapBytes,
|
|
351
|
+
bandBytes: band,
|
|
352
|
+
tight: !guaranteeFits,
|
|
353
|
+
};
|
|
354
|
+
});
|
|
355
|
+
return {
|
|
356
|
+
sessions: out,
|
|
357
|
+
poolBytes,
|
|
358
|
+
heldBytes: held,
|
|
359
|
+
freeBytes,
|
|
360
|
+
guaranteesFit,
|
|
361
|
+
conservative: !machine.collectiveBrake,
|
|
362
|
+
hungry,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/** Ticks in a row a session must ask to shrink before its wall comes down. */
|
|
366
|
+
export const SHRINK_TICKS_TO_LOWER = 4;
|
|
367
|
+
/**
|
|
368
|
+
* Neither number moves for less than this — `max(256 MiB, 10 %)`.
|
|
369
|
+
*
|
|
370
|
+
* Every write is a `systemctl` call, and the bus was measured at 2.7 s under
|
|
371
|
+
* load. A dead zone is what keeps a pot that breathes by a few megabytes from
|
|
372
|
+
* costing a bus call per session per tick.
|
|
373
|
+
*/
|
|
374
|
+
export function deadZoneBytes(value) {
|
|
375
|
+
return Math.max(256 * MIB, Math.floor(value * 0.1));
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* What to actually write, given what is already in force.
|
|
379
|
+
*
|
|
380
|
+
* Up is free and immediate: memory nobody is using costs nobody anything.
|
|
381
|
+
* Down is where every rule lives.
|
|
382
|
+
*/
|
|
383
|
+
export function planLimitMove(written, wanted, holdBytes,
|
|
384
|
+
/**
|
|
385
|
+
* Does this systemd take `MemoryLow=` at all? (#398 S3)
|
|
386
|
+
*
|
|
387
|
+
* False costs the machine its guarantees and nothing else. It has to be
|
|
388
|
+
* honoured HERE and not only at spawn, because every property goes to systemd
|
|
389
|
+
* in ONE `set-property` call: a single directive this systemd refuses would
|
|
390
|
+
* take the brake and the wall down with it, every tick, for every session.
|
|
391
|
+
*/
|
|
392
|
+
memoryLow = true) {
|
|
393
|
+
const hold = Math.max(0, holdBytes);
|
|
394
|
+
if (written === null) {
|
|
395
|
+
return {
|
|
396
|
+
brakeBytes: wanted.brakeBytes,
|
|
397
|
+
wallBytes: wanted.wallBytes,
|
|
398
|
+
// Zero when nothing writes `MemoryLow` on this machine (#403): the field
|
|
399
|
+
// means «what is in force», and a guarantee nobody wrote is not in force.
|
|
400
|
+
// The card and the agent's prompt both read this, and both were naming a
|
|
401
|
+
// guarantee on machines whose systemd never took one.
|
|
402
|
+
guaranteedBytes: memoryLow ? wanted.guaranteedBytes : 0,
|
|
403
|
+
swapBytes: wanted.swapBytes,
|
|
404
|
+
holdBytes: hold,
|
|
405
|
+
shrinkTicks: 0,
|
|
406
|
+
properties: [
|
|
407
|
+
...(memoryLow ? [`MemoryLow=${wanted.guaranteedBytes}`] : []),
|
|
408
|
+
`MemoryHigh=${wanted.brakeBytes}`,
|
|
409
|
+
`MemoryMax=${wanted.wallBytes}`,
|
|
410
|
+
`MemorySwapMax=${wanted.swapBytes}`,
|
|
411
|
+
],
|
|
412
|
+
lowered: false,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
// The brake. Down is allowed on the same tick, but only ever down to what is
|
|
416
|
+
// unused: never under the guarantee, and never onto a session that is sitting
|
|
417
|
+
// on its memory right now.
|
|
418
|
+
const brakeFloor = Math.max(wanted.guaranteedBytes, Math.floor(hold * 1.05));
|
|
419
|
+
const nextBrake = wanted.brakeBytes >= written.brakeBytes
|
|
420
|
+
? wanted.brakeBytes
|
|
421
|
+
: Math.max(wanted.brakeBytes, brakeFloor);
|
|
422
|
+
/**
|
|
423
|
+
* The wall. Down ONLY from the session's own shrinking, never because a
|
|
424
|
+
* neighbour arrived — that is a separate rule and not a consequence of the
|
|
425
|
+
* one above. Without it a session honestly holding 500 MB and about to grow
|
|
426
|
+
* would be given a wall sized by its past.
|
|
427
|
+
*/
|
|
428
|
+
const wallFloor = Math.floor(hold * 1.5);
|
|
429
|
+
/**
|
|
430
|
+
* …and «asked to shrink» means THIS SESSION shrank, not «the formula wants
|
|
431
|
+
* less». A neighbour arriving makes the formula want less for everybody, and
|
|
432
|
+
* a session that is honestly holding 500 MB and about to grow would then be
|
|
433
|
+
* given a wall sized by its past.
|
|
434
|
+
*/
|
|
435
|
+
const shrankItself = hold < written.holdBytes;
|
|
436
|
+
// …and `written.holdBytes` is what the session held WHEN THE WALL WAS WRITTEN,
|
|
437
|
+
// not on the previous tick — see the assignment at the end of this function.
|
|
438
|
+
const wantsLowerWall = wanted.wallBytes < written.wallBytes && shrankItself;
|
|
439
|
+
const shrinkTicks = wantsLowerWall ? written.shrinkTicks + 1 : 0;
|
|
440
|
+
let nextWall = written.wallBytes;
|
|
441
|
+
if (wanted.wallBytes >= written.wallBytes) {
|
|
442
|
+
nextWall = wanted.wallBytes;
|
|
443
|
+
}
|
|
444
|
+
else if (shrinkTicks >= SHRINK_TICKS_TO_LOWER && wanted.wallBytes >= wallFloor) {
|
|
445
|
+
nextWall = wanted.wallBytes;
|
|
446
|
+
}
|
|
447
|
+
// A wall under the brake is a kill line again.
|
|
448
|
+
if (nextWall < nextBrake + wanted.bandBytes)
|
|
449
|
+
nextWall = nextBrake + wanted.bandBytes;
|
|
450
|
+
const properties = [];
|
|
451
|
+
if (Math.abs(nextBrake - written.brakeBytes) >= deadZoneBytes(written.brakeBytes)) {
|
|
452
|
+
properties.push(`MemoryHigh=${nextBrake}`);
|
|
453
|
+
}
|
|
454
|
+
if (Math.abs(nextWall - written.wallBytes) >= deadZoneBytes(written.wallBytes)) {
|
|
455
|
+
properties.push(`MemoryMax=${nextWall}`);
|
|
456
|
+
}
|
|
457
|
+
if (memoryLow && wanted.guaranteedBytes !== written.guaranteedBytes) {
|
|
458
|
+
properties.push(`MemoryLow=${wanted.guaranteedBytes}`);
|
|
459
|
+
}
|
|
460
|
+
// No dead zone on swap: it changes only when the machine itself does — a swap
|
|
461
|
+
// file appearing or going away — and every such change matters (see
|
|
462
|
+
// `WrittenLimits.swapBytes`).
|
|
463
|
+
if (wanted.swapBytes !== written.swapBytes) {
|
|
464
|
+
properties.push(`MemorySwapMax=${wanted.swapBytes}`);
|
|
465
|
+
}
|
|
466
|
+
const brakeOut = properties.some((p) => p.startsWith('MemoryHigh='))
|
|
467
|
+
? nextBrake
|
|
468
|
+
: written.brakeBytes;
|
|
469
|
+
const wallOut = properties.some((p) => p.startsWith('MemoryMax=')) ? nextWall : written.wallBytes;
|
|
470
|
+
return {
|
|
471
|
+
brakeBytes: brakeOut,
|
|
472
|
+
wallBytes: wallOut,
|
|
473
|
+
guaranteedBytes: memoryLow ? wanted.guaranteedBytes : 0,
|
|
474
|
+
swapBytes: wanted.swapBytes,
|
|
475
|
+
/**
|
|
476
|
+
* Carried, not refreshed, while the wall stands still (#403).
|
|
477
|
+
*
|
|
478
|
+
* The field means «what this session held when these numbers were written»,
|
|
479
|
+
* and its own comment says so — but returning `hold` on every tick turned
|
|
480
|
+
* `shrankItself` into «less than on the previous tick», which is a
|
|
481
|
+
* different and much rarer thing. On a realistic trace after a build
|
|
482
|
+
* (4000 → 600, then a plateau of ±10 MiB) the counter never once reached
|
|
483
|
+
* four in fourteen ticks: every plateau tick with a hold one megabyte above
|
|
484
|
+
* the last reset it. The wall could therefore never come down at all.
|
|
485
|
+
*/
|
|
486
|
+
holdBytes: properties.some((p) => p.startsWith('MemoryMax=')) ? hold : written.holdBytes,
|
|
487
|
+
shrinkTicks,
|
|
488
|
+
properties,
|
|
489
|
+
lowered: brakeOut < written.brakeBytes,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
//# sourceMappingURL=session-allocator.js.map
|