@bridge4dev/runner 0.57.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.
@@ -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