@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.
@@ -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