@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.
@@ -129,7 +129,7 @@ export function buildUnit(execStart, nodeBinary = process.execPath) {
129
129
  * which is the only way to fix the servers that already have the bad numbers
130
130
  * baked in — and it never overwrites a unit the operator edited by hand.
131
131
  */
132
- export const LIMITS_VERSION = 5;
132
+ export const LIMITS_VERSION = 6;
133
133
  const LIMITS_MARKER = '# devbridge-limits-version:';
134
134
  /** `zz-` so it sorts last: an operator's own drop-in should still win. */
135
135
  const LIMITS_FILE = 'zz-devbridge-limits.conf';
@@ -277,6 +277,21 @@ const CEILING_FLOOR_BYTES = 2 * GIB;
277
277
  * - cap 85 % of total: on an idle dedicated box `MemAvailable` is nearly the whole
278
278
  * machine, and a ceiling of «everything» is the bug this function exists to fix.
279
279
  */
280
+ /**
281
+ * What the machine keeps for itself, whatever the agents are doing.
282
+ *
283
+ * Enough for sshd, journald, the kernel and some page cache to keep working
284
+ * while our cgroups sit at their ceiling. Proportional on a big machine,
285
+ * absolute on a small one, because 15 % of 4 GB is not enough to stay reachable.
286
+ *
287
+ * Hoisted out of {@link memoryPolicy} for #398 S3: the live allocator has to
288
+ * subtract exactly the same reserve when it decides how much of the machine is
289
+ * free right now, and a second copy of this number would drift from this one on
290
+ * the first machine where it mattered.
291
+ */
292
+ export function machineReserveBytes(totalBytes) {
293
+ return Math.max(1.5 * GIB, totalBytes * 0.15);
294
+ }
280
295
  export function memoryPolicy(facts, minCeilingBytes = managedUsageFloorBytes(facts)) {
281
296
  const { totalBytes, availableBytes, ownUsageBytes, sessionsUsageBytes, uptimeSec } = facts;
282
297
  const swapMaxBytes = Math.floor(Math.max(0, facts.swapTotalBytes ?? 0) * SESSIONS_SWAP_SHARE);
@@ -307,7 +322,7 @@ export function memoryPolicy(facts, minCeilingBytes = managedUsageFloorBytes(fac
307
322
  // Enough for sshd, journald, the kernel and some page cache to keep working
308
323
  // while the cgroup sits at its ceiling. Proportional on a big machine, absolute
309
324
  // on a small one, because 15 % of 4 GB is not enough to stay reachable.
310
- const reserve = Math.max(1.5 * GIB, totalBytes * 0.15);
325
+ const reserve = machineReserveBytes(totalBytes);
311
326
  // Both cgroups are added back, and for the same reason ours always was: what
312
327
  // they hold is already OUT of `MemAvailable`, so leaving the sessions out
313
328
  // makes a loaded machine look starved and walks the ceiling down under the
@@ -682,7 +697,7 @@ const MANAGED_HEADER = [
682
697
  * can measure one (the drift check treats a file with no
683
698
  * `MemoryMax` as outdated).
684
699
  */
685
- export function buildSessionsSliceOverride(facts = readMemoryFacts(), sessionsUsageBytes = readSessionsSliceUsageOrNull()) {
700
+ export function buildSessionsSliceOverride(facts = readMemoryFacts(), sessionsUsageBytes = readSessionsSliceUsageOrNull(), maxSessions = DEFAULT_MAX_SESSIONS) {
686
701
  const memory = facts ? memoryPolicy(facts) : null;
687
702
  return ([
688
703
  ...MANAGED_HEADER,
@@ -691,6 +706,41 @@ export function buildSessionsSliceOverride(facts = readMemoryFacts(), sessionsUs
691
706
  ? [
692
707
  `# ${memory.measured ? 'measured headroom' : 'still booting — conservative fraction of total'}`,
693
708
  `MemoryMax=${asMiB(memory.maxBytes)}`,
709
+ /**
710
+ * The COLLECTIVE brake, and the thing that makes #398 S3 safe.
711
+ *
712
+ * Since S3 each session's own brake is deliberately oversold — every
713
+ * session may grow into everything that is free — so the sum of the
714
+ * brakes is larger than this slice. That is only safe while THIS
715
+ * line exists: as the sessions together approach the ceiling the
716
+ * kernel slows all of them and reclaims first from whoever is above
717
+ * their `MemoryLow`, and the hard ceiling above, where the OOM
718
+ * killer picks a victim across the whole slice, is never reached.
719
+ *
720
+ * Measured on this host before it existed: the slice read
721
+ * `MemoryHigh=infinity`, overflowing it ended in a kill rather than
722
+ * throttling, and the one killed was the neighbour sitting under its
723
+ * own limit (`memory.events` of the victim: `high 0, max 0, oom 0,
724
+ * oom_kill 1`). The allocator reads this property back and falls
725
+ * back to a non-oversold formula when it is not in force.
726
+ */
727
+ `MemoryHigh=${asMiB(Math.floor(memory.maxBytes * SLICE_BRAKE_OF_CEILING))}`,
728
+ /**
729
+ * …and the protection the guarantees are built on.
730
+ *
731
+ * `memory.low` of a child is bounded by its ancestors' protection:
732
+ * without this line a session's own `MemoryLow` protects nothing at
733
+ * all. Deliberately a CONSTANT (seats × one guarantee), not the sum
734
+ * of the guarantees of the sessions that happen to be live: the sum
735
+ * changes on every start and finish, the slice is never rewritten
736
+ * live (a blocker QA found on 07.09 — a rewritten slice kills the
737
+ * sessions under it), so a number that depended on the live roster
738
+ * would be true for the hour after this file is written and a lie
739
+ * for the rest of it. What separates one session from another lives
740
+ * on their own scopes; this line only gives them the right to be
741
+ * protected at all.
742
+ */
743
+ `MemoryLow=${asMiB(Math.min(Math.max(1, maxSessions) * SESSION_GUARANTEE_BYTES, memory.maxBytes))}`,
694
744
  ]
695
745
  : unmeasuredSliceCeiling(sessionsUsageBytes)),
696
746
  // The line the cage is built on. See `session-cage.ts` and the note above.
@@ -701,6 +751,31 @@ export function buildSessionsSliceOverride(facts = readMemoryFacts(), sessionsUs
701
751
  'CPUAccounting=yes',
702
752
  ].join('\n') + '\n');
703
753
  }
754
+ /**
755
+ * Where the collective brake sits under the collective ceiling.
756
+ *
757
+ * Nine tenths: close enough that the sessions get almost the whole pot before
758
+ * anything is throttled, far enough that the kernel has a tenth of the pot in
759
+ * which to slow everyone down and reclaim, instead of arriving at the hard
760
+ * ceiling and choosing a victim.
761
+ */
762
+ export const SLICE_BRAKE_OF_CEILING = 0.9;
763
+ /**
764
+ * The guarantee one session gets, in the one place the slice needs to know it.
765
+ *
766
+ * Copied rather than imported from `session-allocator.ts`: that module imports
767
+ * `session-cage.ts`, which imports this one, and the cycle would be real. The
768
+ * two are compared by a test (`service-unit.test.ts`) so they cannot drift.
769
+ */
770
+ const SESSION_GUARANTEE_BYTES = 2048 * MIB;
771
+ /**
772
+ * Seats assumed when nobody has said otherwise.
773
+ *
774
+ * Three, which is what `DevServer.maxSessions` defaults to in the database. The
775
+ * runner's own config may say fewer; the slice's protection is sized for the
776
+ * seats the machine sells, not for the sessions that happen to be running.
777
+ */
778
+ export const DEFAULT_MAX_SESSIONS = 3;
704
779
  /**
705
780
  * The `[Slice]` lines for a machine whose memory the policy could not measure.
706
781
  * See {@link buildSessionsSliceOverride} for why each of the three answers is
@@ -853,7 +928,9 @@ function swapShareHasDrifted(contents, facts) {
853
928
  * arrive at the same number — otherwise a write whose floor was binding would be
854
929
  * seen as drifted on the very next call and rewritten forever.
855
930
  */
856
- export function writeLimitsOverride(force = false, home = systemdUserHome(), facts = readMemoryFacts(), sessionsUsageBytes = readSessionsSliceUsageOrNull()) {
931
+ export function writeLimitsOverride(force = false, home = systemdUserHome(), facts = readMemoryFacts(), sessionsUsageBytes = readSessionsSliceUsageOrNull(),
932
+ /** Seats this machine sells — sizes the slice's collective `MemoryLow` (#398 S3). */
933
+ maxSessions = DEFAULT_MAX_SESSIONS) {
857
934
  // Unknown usage can be a busy service whose systemd query timed out. Do not
858
935
  // replace an existing drop-in with a guessed percentage, or remove a limit
859
936
  // by rewriting the file without its MemoryMax line. Defer the whole policy.
@@ -866,7 +943,7 @@ export function writeLimitsOverride(force = false, home = systemdUserHome(), fac
866
943
  // them is a machine whose sessions are capped but whose daemon is not
867
944
  // prioritised — the regression described on `buildDevbridgeSliceOverride`.
868
945
  write(limitsOverridePath(home), buildLimitsOverride(undefined, facts));
869
- write(sessionsSliceOverridePath(home), buildSessionsSliceOverride(facts, sessionsUsageBytes));
946
+ write(sessionsSliceOverridePath(home), buildSessionsSliceOverride(facts, sessionsUsageBytes, maxSessions));
870
947
  write(devbridgeSliceOverridePath(home), buildDevbridgeSliceOverride());
871
948
  return true;
872
949
  }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * How much memory each live session may use, decided from what is actually free
3
+ * (#398 S3, ticket #398).
4
+ *
5
+ * The number this replaces was `max(pot / 3, 2 GiB)`, computed ONCE at daemon
6
+ * start and never again. The divisor came from `maxSessions` defaulting to three
7
+ * and from nothing else, so a lone session on an idle machine got a third of the
8
+ * pot and the other two thirds sat unused — the owner's complaint in #398, word
9
+ * for word: «ночью машина свободна ~9 ГБ, а сессии всё равно нельзя выйти за
10
+ * 2.5 ГБ».
11
+ *
12
+ * **The decisive idea is three levels, and it has to be understood before any of
13
+ * the arithmetic below.** Today a session has two numbers, the brake and the
14
+ * wall, and both of the jobs — «do not crowd your neighbour» and «do not kill
15
+ * the machine» — are being asked of them at once. That is unsatisfiable: for a
16
+ * lone session to take nearly the whole pot its brake must be nearly the whole
17
+ * pot, and then two such sessions overflow the pot between them without either
18
+ * being braked — and what overflows is the SLICE, where the victim is chosen by
19
+ * the kernel and the one that dies is not the one at fault (measured, #387).
20
+ *
21
+ * So there are three, the way every grown-up scheduler has three:
22
+ *
23
+ * - **the guarantee** (`MemoryLow`) — small and untouchable. The sum of the
24
+ * guarantees has to fit in the pot, and this is the ONLY place a sum is
25
+ * checked. It answers «how much will not be taken from me».
26
+ * - **the brake** (`MemoryHigh`) — large and DELIBERATELY oversold. Every
27
+ * session may grow into everything that is free right now. The sum of the
28
+ * brakes is greater than the pot, and that is not a mistake.
29
+ * - **the collective brake on the slice** (`MemoryHigh` on
30
+ * `devbridge-sessions.slice`, `service-unit.ts`) — the thing that makes the
31
+ * overselling safe. As the sessions together approach the pot the kernel
32
+ * slows all of them and reclaims first from whoever is above their guarantee,
33
+ * so the hard ceiling — where the OOM killer picks — is never reached.
34
+ * - **the wall** (`MemoryMax`) — still one guarantee below the pot, so a lone
35
+ * runaway meets ITS OWN wall before the shared pot overflows (rule of #387).
36
+ *
37
+ * Overselling the brakes WITHOUT the collective brake on the slice is a bug, not
38
+ * a solution. The two halves are one change, and {@link allocateSessionMemory}
39
+ * refuses to oversell when the caller reports that the slice has no live
40
+ * `MemoryHigh` — see {@link AllocatorMachine.collectiveBrake}.
41
+ */
42
+ /**
43
+ * What one session is guaranteed and will not have taken away.
44
+ *
45
+ * 2 GiB, sized over the 1571 MB peak measured for a workspace `pnpm typecheck`
46
+ * in this monorepo. A dev server that cannot run one of those is not a dev
47
+ * server, so this is the number below which a session is not worth starting.
48
+ */
49
+ export declare const SESSION_GUARANTEE_BYTES: number;
50
+ /**
51
+ * Growth allowance over what a session already holds.
52
+ *
53
+ * The same 1.25 `service-unit.ts` uses for the ceiling it writes over a cgroup's
54
+ * current usage, and for the same reason: a limit at exactly what is held is a
55
+ * limit that is already breached.
56
+ */
57
+ export declare const SESSION_HEADROOM_OVER_HOLD = 1.25;
58
+ /**
59
+ * The floor under the BRAKE, over what a session already holds — 1.05, and it
60
+ * is a different number from the one above on purpose (#398 §8, #403).
61
+ *
62
+ * The wall may sit a quarter above the hold: it is a hard limit, and headroom
63
+ * there costs nothing. The brake may not, because it is followed every tick: at
64
+ * 1.25 a session that merely holds its ground has its brake RAISED on every
65
+ * pass, `addMemoryTo` reads that as «the machine found it more memory», and the
66
+ * stall deadline resets for ever — measured in the suite, four tests waiting
67
+ * for a stop that could no longer come.
68
+ *
69
+ * 1.05 is enough for the one thing this floor is for: `memory.current` counts
70
+ * page cache, so a brake at exactly the anonymous hold is already breached.
71
+ */
72
+ export declare const SESSION_BRAKE_OVER_HOLD = 1.05;
73
+ /**
74
+ * The step the DIVIDED guarantee is rounded down to — 64 MiB (#403).
75
+ *
76
+ * `MemoryLow` is the one number here with no dead zone of its own, on purpose:
77
+ * a guarantee is what a person is promised, and a promise that lags a tick
78
+ * behind the machine is worse than one that follows it. But the divided
79
+ * guarantee is `pool / n`, and the pool follows `MemAvailable`, which breathes:
80
+ * measured on a hungry machine with two sessions, a pot of 3800 MiB and 11 MiB
81
+ * of breathing wrote `MemoryLow` on ten ticks out of ten where the code before
82
+ * these fixes wrote it on one.
83
+ *
84
+ * Rounding down rather than to nearest, because the sum of the guarantees is
85
+ * the one sum in this module that has to fit inside the pot.
86
+ */
87
+ export declare const GUARANTEE_STEP_BYTES: number;
88
+ /** Below this pot the machine is honestly single-session — see the formula. */
89
+ export declare function hungryPotBytes(baseBand: number): number;
90
+ /** The band this machine uses before any session-specific widening. */
91
+ export declare function baseBandBytes(oomContinue: boolean): number;
92
+ /** One live session, as the allocator needs to see it. */
93
+ export interface AllocatorSession {
94
+ id: string;
95
+ /**
96
+ * `memory.current − (file − shmem)`: what the session holds and cannot simply
97
+ * give back. Page cache is excluded because lowering a limit over clean file
98
+ * pages makes the kernel reclaim them, not kill anything — counting them would
99
+ * inflate every number by whatever the machine happened to have cached.
100
+ */
101
+ holdBytes: number;
102
+ /** `memory.current`, the whole reading — what `MemAvailable` cannot see. */
103
+ currentBytes: number;
104
+ }
105
+ /**
106
+ * What the machine's OWNER has said, in `config.toml` (#398 S6).
107
+ *
108
+ * Every field optional, and every absent one means «the shipped default». The
109
+ * knobs exist because this formula travels to dev servers nobody here has ever
110
+ * seen: the two previous memory rules were both reasonable on the author's
111
+ * machine and destructive somewhere else, and an owner who has to get work done
112
+ * tonight needs a way back that is not «downgrade the runner».
113
+ */
114
+ export interface AllocatorKnobs {
115
+ /** `false` — the pre-0.58.0 fixed third, computed once and never moved. */
116
+ adaptive?: boolean;
117
+ /** The guarantee, bytes. Default {@link SESSION_GUARANTEE_BYTES}. */
118
+ guaranteeBytes?: number;
119
+ /** A ceiling on the ceiling, bytes: no session grows past this whatever is free. */
120
+ sessionMaxBytes?: number;
121
+ }
122
+ /** What the machine says about itself right now. */
123
+ export interface AllocatorMachine {
124
+ /** `MemoryMax` systemd has in force on the sessions slice. Never rewritten live. */
125
+ potBytes: number | null;
126
+ /** `MemAvailable`. */
127
+ availableBytes: number;
128
+ /** `MemTotal`. */
129
+ totalBytes: number;
130
+ /** What the machine keeps for itself — `machineReserveBytes(totalBytes)`. */
131
+ reserveBytes: number;
132
+ /** Does a kill inside a scope cost one process or the whole session? */
133
+ oomContinue: boolean;
134
+ /**
135
+ * Is there a LIVE `MemoryHigh` on the sessions slice, read back off systemd?
136
+ *
137
+ * The hard invariant of this module. Overselling the brakes is safe only while
138
+ * the collective brake exists; without it, overflowing the slice ends in a
139
+ * kill rather than in throttling, and the one killed is the neighbour sitting
140
+ * under its own limit — measured on this host, victim `memory.events`:
141
+ * `high 0, max 0, oom 0, oom_kill 1`.
142
+ */
143
+ collectiveBrake: boolean;
144
+ /** Swap one session may push into, as `session-cage.ts` computes it. */
145
+ swapBytes: number;
146
+ }
147
+ /** The three numbers of one session, plus the band they were built with. */
148
+ export interface SessionAllocation {
149
+ id: string;
150
+ guaranteedBytes: number;
151
+ brakeBytes: number;
152
+ wallBytes: number;
153
+ swapBytes: number;
154
+ bandBytes: number;
155
+ /** This session's guarantee does not fit in the pot beside the others (D9). */
156
+ tight: boolean;
157
+ }
158
+ export interface Allocation {
159
+ sessions: SessionAllocation[];
160
+ /** The live pot — what is actually available to sessions right now. */
161
+ poolBytes: number;
162
+ /** What the sessions hold between them. */
163
+ heldBytes: number;
164
+ /** What nobody holds. */
165
+ freeBytes: number;
166
+ /** How many guarantees fit in the live pot. */
167
+ guaranteesFit: number;
168
+ /** The conservative formula was used because the slice has no collective brake. */
169
+ conservative: boolean;
170
+ /** The machine is honestly single-session: the pot is below `2G + band`. */
171
+ hungry: boolean;
172
+ }
173
+ /**
174
+ * The whole decision, as a pure function of the numbers.
175
+ *
176
+ * Kept pure and kept here, away from every timer and socket, because what it has
177
+ * to be is CORRECT on machines nobody has: the formula travels with the runner
178
+ * to every customer's dev server, and the last two times a memory rule shipped
179
+ * it was reasonable on the author's machine and destructive somewhere else
180
+ * (#387: the victim was the neighbour; `service-unit.ts:314`: a 4 GB machine got
181
+ * `MemoryHigh == MemoryMax`). So the fleet's profiles are fixtures, and the
182
+ * eight properties below are tests rather than comments.
183
+ */
184
+ export declare function allocateSessionMemory(machine: AllocatorMachine, sessions: AllocatorSession[], knobs?: AllocatorKnobs): Allocation;
185
+ /**
186
+ * What was last written to one session's scope, and how long it has been asking
187
+ * to shrink.
188
+ *
189
+ * The asymmetry below is not caution, it is a measurement: `MemoryMax` lowered
190
+ * below what a process holds ends it in seconds (verified on this host — a scope
191
+ * holding 200 MiB, `MemoryMax` set to 64 MiB, the process read «Killed» and the
192
+ * unit went `inactive`). An oversold limit is safe; a killed honest session is
193
+ * not recoverable.
194
+ */
195
+ export interface WrittenLimits {
196
+ brakeBytes: number;
197
+ wallBytes: number;
198
+ guaranteedBytes: number;
199
+ /**
200
+ * `MemorySwapMax`, which moves only when the MACHINE changes under us.
201
+ *
202
+ * It is here because of a live example: on 09.09.2026 a swap file appeared on
203
+ * vmi3219930 and every session on it went on being told `MemorySwapMax=0`,
204
+ * because the cage measures the machine once at daemon start. Without swap the
205
+ * brake stops instead of slowing, so that machine kept the exact failure this
206
+ * whole plan is about while the means to avoid it sat unused on its disk.
207
+ */
208
+ swapBytes: number;
209
+ /** Consecutive ticks on which the session's own numbers asked to come down. */
210
+ shrinkTicks: number;
211
+ /**
212
+ * What the session held when these numbers were written.
213
+ *
214
+ * The difference between «the session shrank» and «a neighbour arrived», and
215
+ * without it there was no difference at all: the wall came down whenever the
216
+ * formula wanted less, whatever the reason — which the design and the comment
217
+ * beside it both forbid. Found by the independent review of 10.09.2026.
218
+ */
219
+ holdBytes: number;
220
+ }
221
+ /** Ticks in a row a session must ask to shrink before its wall comes down. */
222
+ export declare const SHRINK_TICKS_TO_LOWER = 4;
223
+ /**
224
+ * Neither number moves for less than this — `max(256 MiB, 10 %)`.
225
+ *
226
+ * Every write is a `systemctl` call, and the bus was measured at 2.7 s under
227
+ * load. A dead zone is what keeps a pot that breathes by a few megabytes from
228
+ * costing a bus call per session per tick.
229
+ */
230
+ export declare function deadZoneBytes(value: number): number;
231
+ export interface LimitMove {
232
+ brakeBytes: number;
233
+ wallBytes: number;
234
+ guaranteedBytes: number;
235
+ swapBytes: number;
236
+ holdBytes: number;
237
+ shrinkTicks: number;
238
+ /** Properties to write, empty when nothing moved enough to be worth a call. */
239
+ properties: string[];
240
+ /** The brake came DOWN — the stall detector has to be muted for a while. */
241
+ lowered: boolean;
242
+ }
243
+ /**
244
+ * What to actually write, given what is already in force.
245
+ *
246
+ * Up is free and immediate: memory nobody is using costs nobody anything.
247
+ * Down is where every rule lives.
248
+ */
249
+ export declare function planLimitMove(written: WrittenLimits | null, wanted: SessionAllocation, holdBytes: number,
250
+ /**
251
+ * Does this systemd take `MemoryLow=` at all? (#398 S3)
252
+ *
253
+ * False costs the machine its guarantees and nothing else. It has to be
254
+ * honoured HERE and not only at spawn, because every property goes to systemd
255
+ * in ONE `set-property` call: a single directive this systemd refuses would
256
+ * take the brake and the wall down with it, every tick, for every session.
257
+ */
258
+ memoryLow?: boolean): LimitMove;
259
+ //# sourceMappingURL=session-allocator.d.ts.map