tosijs-ui 1.6.21 → 1.6.23

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,401 @@
1
+ /*
2
+ Machine-health preflight for the doc-site build and dev server.
3
+
4
+ The memory watchdog in `dev-server.ts` bounds what THIS process does to the
5
+ machine. It is blind to what the machine is already doing — and that is where the
6
+ real damage came from: three dev servers left running from before a leak fix
7
+ landed (a running process keeps the code it loaded at launch, so updating the
8
+ package does nothing for it) grew to 103GB, 57GB and 49GB of RSS on a 32GB box.
9
+ Demand hit ~210GB against 32GB of RAM, the compressor swelled to 18GB, free
10
+ memory fell to 14MB, and the page-out scanner reclaimed zero pages. macOS's
11
+ jetsam never stepped in — it just let the box thrash for twenty minutes until it
12
+ was power-cycled.
13
+
14
+ Nothing in the build noticed, because nothing in the build ever looked. So look:
15
+ sample the process table at build start and at dev-server launch, and refuse to
16
+ add load to a machine that is already dying. Say which processes, how big, how
17
+ old, and the exact command to kill them.
18
+
19
+ Build-time only (Bun/Node APIs). Never import this from browser code.
20
+ */
21
+ import * as os from 'os';
22
+ import { $ } from 'bun';
23
+ /**
24
+ * Parse `ps -eo pid=,rss=,etime=,args=` output. Bytes are KB in `ps`; the command
25
+ * is everything after the third field, so it may contain spaces.
26
+ */
27
+ export function parsePs(output) {
28
+ const procs = [];
29
+ for (const line of output.split('\n')) {
30
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
31
+ if (!m)
32
+ continue;
33
+ procs.push({
34
+ pid: Number(m[1]),
35
+ rssMb: Math.round(Number(m[2]) / 1024),
36
+ etime: m[3],
37
+ command: m[4],
38
+ });
39
+ }
40
+ return procs;
41
+ }
42
+ /** `ps` etime — `[[dd-]hh:]mm:ss` — as hours. */
43
+ export function etimeHours(etime) {
44
+ const [days, rest] = etime.includes('-')
45
+ ? [Number(etime.split('-')[0]), etime.split('-')[1]]
46
+ : [0, etime];
47
+ const parts = rest.split(':').map(Number);
48
+ const [h, m, s] = parts.length === 3 ? parts : [0, parts[0] ?? 0, parts[1] ?? 0];
49
+ return days * 24 + h + m / 60 + s / 3600;
50
+ }
51
+ /**
52
+ * A local dev server / build script — the thing we know should never be sitting on
53
+ * gigabytes for hours.
54
+ *
55
+ * Deliberately `bun` (and `deno`) only, NOT `node`. Plenty of legitimate,
56
+ * long-lived node processes routinely exceed the ceiling — Claude Code, VS Code's
57
+ * extension host, a TypeScript server on a large project — and hard-failing every
58
+ * build because the editor is fat would be a guard nobody keeps. A runaway node
59
+ * process is still caught, by the >50%-of-RAM trigger, which is not a judgement
60
+ * call about what the process is for.
61
+ *
62
+ * `bun install`/`test`/`x` and friends are one-shot commands, not servers.
63
+ */
64
+ const isDevProcess = (command) => {
65
+ // Match the EXECUTABLE, not any occurrence of "bun" in the command line. `/\b(bun|deno)\b/`
66
+ // matched `node ~/.bun/install/global/…/cli.js` — a real line from this machine's process
67
+ // table — because the word boundary happily lands inside a path.
68
+ const argv0 = command.trim().split(/\s+/)[0] ?? '';
69
+ const exe = argv0.split('/').pop() ?? '';
70
+ if (!/^(bun|deno)$/.test(exe))
71
+ return false;
72
+ // One-shot subcommands are not servers. `build` is here because WE spawn it: the
73
+ // bundler child is a `bun build`, and a build peaking mid-run must never look like a
74
+ // runaway dev server — least of all to its own parent.
75
+ const sub = command.trim().split(/\s+/)[1] ?? '';
76
+ return !/^(test|install|add|remove|update|pm|x|create|init|link|outdated|publish|upgrade|build|run)$/.test(sub);
77
+ };
78
+ /**
79
+ * Processes whose enormous RSS is the POINT of the machine, not a symptom.
80
+ *
81
+ * A local LLM holding a 96GB model resident is not a runaway — it is what the box is
82
+ * for. Counting it as a hog would fail every build on a machine working exactly as
83
+ * intended, and the predictable response to that is `DEV_SKIP_PREFLIGHT=1` in a shell
84
+ * profile, which throws the whole guard away. A guard that cries wolf on the
85
+ * machine's purpose is a guard that gets disabled.
86
+ *
87
+ * Exempt processes are never offenders, and their memory is subtracted from the
88
+ * budget instead (see `memoryBudgetMb`) — so they shrink the headroom we police
89
+ * rather than triggering on it.
90
+ */
91
+ const isExemptProcess = (command) => /\b(ollama|llama[-_.]?(server|cli|cpp)|lms|LM Studio|mlx[-_]lm|vllm|koboldcpp|text-generation-webui)\b/i.test(command);
92
+ /**
93
+ * How much RAM is actually available to *dev work* — the denominator every
94
+ * percentage below is taken against.
95
+ *
96
+ * This is the whole trick, and it is worth being explicit about why it is not
97
+ * `totalmem()`. **Percentages of physical RAM scale with the hardware; headroom does
98
+ * not.** Buy 96GB more memory and spend all of it on a resident model, and the slack
99
+ * available to a runaway build is exactly what it was before — but every
100
+ * percentage-of-RAM threshold has quadrupled, so the guard gets four times laxer on
101
+ * the machine where it needed to be just as strict. On a 128GB box with a 96GB model
102
+ * up, a 50%-of-RAM "catastrophic" bar is 64GB: double the headroom that exists, so it
103
+ * can never fire in time.
104
+ *
105
+ * Budget = RAM − (what exempt processes hold) − (`reservedMb`), floored at
106
+ * `minBudgetPct` of RAM so a machine that is genuinely fully committed still gets
107
+ * sane thresholds rather than absurd ones. On the 128GB/96GB box that yields a ~32GB
108
+ * budget — the same absolute thresholds that are right on a 32GB laptop, because the
109
+ * slack is the same. Unload the model and the budget floats back up on its own.
110
+ *
111
+ * `devLimitMb` is deliberately NOT a percentage: it survives a hardware change
112
+ * precisely because it is absolute. 4GB is too big for a dev server on any machine.
113
+ */
114
+ export function memoryBudgetMb(procs, opts) {
115
+ const { totalRamMb, reservedMb = 0, minBudgetPct = 25 } = opts;
116
+ const exemptMb = procs
117
+ .filter((p) => isExemptProcess(p.command))
118
+ .reduce((sum, p) => sum + p.rssMb, 0);
119
+ const floor = Math.round((totalRamMb * minBudgetPct) / 100);
120
+ return Math.max(totalRamMb - exemptMb - reservedMb, floor);
121
+ }
122
+ /**
123
+ * Judge the process table against physical RAM.
124
+ *
125
+ * THREE per-process triggers. Note what is deliberately NOT here: a rule that SUMS
126
+ * RSS across processes. It is the obvious idea and it is wrong on macOS — at the
127
+ * moment of the fatal crash, total RSS across 629 processes was ~225GB against 32GB
128
+ * of RAM, and the box was still limping. Compression and swap mean summed footprint
129
+ * routinely exceeds physical RAM on a perfectly healthy machine, so an aggregate-RSS
130
+ * rule would fire during ordinary work and get switched off within a week. A SINGLE
131
+ * process at half the machine is genuinely anomalous; the sum is not. (For a real
132
+ * machine-level signal, see `assessMemoryPressure` below — VM pressure, not RSS
133
+ * arithmetic.)
134
+ *
135
+ * Every percentage is taken against the **dev memory budget** (`memoryBudgetMb`), NOT
136
+ * against physical RAM — see that function for why. Exempt processes (a resident LLM,
137
+ * say) can never be offenders; they shrink the budget instead.
138
+ *
139
+ * - **Any process over `catastrophicPct` of the budget** (default 50%). Nothing
140
+ * healthy is half the available machine. Not limited to dev processes — if
141
+ * something else is eating the box, adding a build to it is still the wrong move.
142
+ * - **A dev process over `hugeDevPct` of the budget** (default 25%), *regardless of
143
+ * age*. The age gate below is a real blind spot: the runaway that took the machine
144
+ * down went 0→100GB in about twenty minutes, so it spent its entire climb through
145
+ * the 4–16GB band both too young to be "stale" and too small to be "catastrophic" —
146
+ * i.e. invisible for the whole ascent. No legitimate dev process is a quarter of
147
+ * the available machine, young or not.
148
+ * - **A long-lived dev process over `devLimitMb`** (default: the dev server's own RSS
149
+ * ceiling). Absolute, not a percentage — 4GB is too big for a dev server on any
150
+ * machine, and an absolute limit survives a hardware upgrade. Age-gated so a build
151
+ * legitimately peaking mid-run isn't flagged.
152
+ *
153
+ * `selfPid` is excluded throughout — this process's own growth is the memory
154
+ * watchdog's job, and it is bounded there.
155
+ */
156
+ export function assessProcesses(procs, opts) {
157
+ const { totalRamMb, selfPid, devLimitMb = 4096, catastrophicPct = 50, hugeDevPct = 25, minAgeHours = 1, reservedMb = 0, } = opts;
158
+ const budgetMb = memoryBudgetMb(procs, { totalRamMb, reservedMb });
159
+ const pct = (p) => Math.round((budgetMb * p) / 100);
160
+ const catastrophicMb = pct(catastrophicPct);
161
+ const hugeDevMb = pct(hugeDevPct);
162
+ // A resident model is the machine's purpose, not a hog: never an offender.
163
+ const others = procs.filter((p) => p.pid !== selfPid && !isExemptProcess(p.command));
164
+ const bySize = (a, b) => b.rssMb - a.rssMb;
165
+ const budgetNote = budgetMb < totalRamMb
166
+ ? ` (${gb(budgetMb)} of ${gb(totalRamMb)} RAM is available to dev work; ` +
167
+ `the rest is held by a resident model or reserved)`
168
+ : '';
169
+ const catastrophic = others.filter((p) => p.rssMb >= catastrophicMb);
170
+ const devProcs = others.filter((p) => isDevProcess(p.command));
171
+ const hugeDev = devProcs.filter((p) => p.rssMb >= hugeDevMb && !catastrophic.includes(p));
172
+ const staleDevServers = devProcs.filter((p) => !catastrophic.includes(p) &&
173
+ !hugeDev.includes(p) &&
174
+ p.rssMb >= devLimitMb &&
175
+ etimeHours(p.etime) >= minAgeHours);
176
+ // A DEV process this big is a runaway, and we refuse. A NON-dev process this big is
177
+ // very often the machine doing its actual job — Docker Desktop's VM, a `java -Xmx8g`,
178
+ // an Xcode Simulator, a Parallels guest, a database. Failing someone's build and
179
+ // telling them to `kill` their database is how a guard gets switched off forever, so
180
+ // those only ever WARN. The honest "this machine is dying" signal is VM pressure,
181
+ // which stays fail-closed precisely because it doesn't care what the memory is for.
182
+ const catastrophicDev = catastrophic.filter((p) => isDevProcess(p.command));
183
+ const catastrophicOther = catastrophic.filter((p) => !isDevProcess(p.command));
184
+ const devOffenders = [
185
+ ...catastrophicDev,
186
+ ...hugeDev,
187
+ ...staleDevServers,
188
+ ].sort(bySize);
189
+ if (devOffenders.length) {
190
+ const worst = devOffenders[0];
191
+ return {
192
+ level: 'fail',
193
+ offenders: devOffenders,
194
+ reason: `${devOffenders.length} dev process${devOffenders.length === 1 ? ' is' : 'es are'} too big to be healthy — the largest is ${gb(worst.rssMb)}, against ` +
195
+ `${gb(budgetMb)} of memory available for dev work${budgetNote}`,
196
+ };
197
+ }
198
+ if (catastrophicOther.length) {
199
+ return {
200
+ level: 'warn',
201
+ offenders: catastrophicOther.sort(bySize),
202
+ reason: `${catastrophicOther.length} process${catastrophicOther.length === 1 ? ' is' : 'es are'} using more than ${catastrophicPct}% of the memory available for dev work — ` +
203
+ `which may be entirely deliberate (a VM, a simulator, a database), so this is a ` +
204
+ `warning, not a refusal${budgetNote}`,
205
+ };
206
+ }
207
+ return { level: 'ok', offenders: [], reason: '' };
208
+ }
209
+ const gb = (mb) => mb >= 1024 ? `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)}GB` : `${mb}MB`;
210
+ /** Parse `vm_stat`. Page size is in its header ("page size of 16384 bytes"). */
211
+ export function parseVmStat(output) {
212
+ const pageSize = Number(output.match(/page size of (\d+) bytes/)?.[1] ?? 4096);
213
+ const pages = (label) => {
214
+ const m = output.match(new RegExp(`${label}:\\s+(\\d+)`));
215
+ return m ? Number(m[1]) : 0;
216
+ };
217
+ const toMb = (p) => Math.round((p * pageSize) / 1024 / 1024);
218
+ return {
219
+ freeMb: toMb(pages('Pages free')),
220
+ compressorMb: toMb(pages('Pages occupied by compressor')),
221
+ };
222
+ }
223
+ /** Parse `sysctl vm.swapusage` → used MB. */
224
+ export function parseSwapUsage(output) {
225
+ const m = output.match(/used\s*=\s*([\d.]+)([MGK])/i);
226
+ if (!m)
227
+ return 0;
228
+ const n = Number(m[1]);
229
+ const unit = m[2].toUpperCase();
230
+ return Math.round(unit === 'G' ? n * 1024 : unit === 'K' ? n / 1024 : n);
231
+ }
232
+ /**
233
+ * Judge VM pressure.
234
+ *
235
+ * Requires BOTH a swollen compressor AND almost no free memory. Either alone is
236
+ * normal: macOS keeps free memory low on purpose (it is caching, not idling), and a
237
+ * few GB of compressor on a busy machine is routine. It is the combination — the
238
+ * compressor has eaten a large slice of RAM *and* there is nothing left to hand out
239
+ * — that means the VM system is about to stall. Swap is reported for context and as
240
+ * a corroborating trigger, not on its own: a Mac can sit on GBs of swap indefinitely
241
+ * while perfectly healthy.
242
+ */
243
+ export function assessMemoryPressure(vm, opts) {
244
+ const { totalRamMb, compressorPct = 40, freeFloorMb = 256, swapFloorMb = 8192, } = opts;
245
+ const compressorMb = Math.round((totalRamMb * compressorPct) / 100);
246
+ const compressorSwollen = vm.compressorMb >= compressorMb;
247
+ const starved = vm.freeMb <= freeFloorMb;
248
+ const swapping = vm.swapUsedMb >= swapFloorMb;
249
+ if (compressorSwollen && (starved || swapping)) {
250
+ return {
251
+ level: 'fail',
252
+ reason: `the VM system is stalling — compressor at ${gb(vm.compressorMb)} ` +
253
+ `(over ${compressorPct}% of ${gb(totalRamMb)} RAM), ` +
254
+ `${gb(vm.freeMb)} free, ${gb(vm.swapUsedMb)} swapped`,
255
+ };
256
+ }
257
+ return { level: 'ok', reason: '' };
258
+ }
259
+ async function readVmPressure() {
260
+ if (process.platform !== 'darwin')
261
+ return null;
262
+ try {
263
+ const [vmstat, swap] = await Promise.all([
264
+ $ `vm_stat`.quiet().text(),
265
+ $ `sysctl vm.swapusage`.quiet().text(),
266
+ ]);
267
+ return {
268
+ ...parseVmStat(vmstat),
269
+ swapUsedMb: parseSwapUsage(swap),
270
+ };
271
+ }
272
+ catch {
273
+ return null;
274
+ }
275
+ }
276
+ /** Best-effort cwd for a pid, so a flagged process can be traced to its project. */
277
+ async function cwdOf(pid) {
278
+ try {
279
+ const out = await $ `lsof -a -d cwd -p ${pid} -Fn`.quiet().text();
280
+ const line = out.split('\n').find((l) => l.startsWith('n'));
281
+ return line ? line.slice(1) : '';
282
+ }
283
+ catch {
284
+ return '';
285
+ }
286
+ }
287
+ async function snapshot() {
288
+ const out = await $ `ps -eo pid=,rss=,etime=,args=`.quiet().text();
289
+ return parsePs(out);
290
+ }
291
+ /** pids descended from `pid` — our own build children, which we must never judge. */
292
+ async function descendantsOf(pid) {
293
+ const out = await $ `pgrep -P ${pid}`
294
+ .quiet()
295
+ .text()
296
+ .catch(() => '');
297
+ const kids = out
298
+ .trim()
299
+ .split('\n')
300
+ .filter(Boolean)
301
+ .map(Number)
302
+ .filter((n) => Number.isInteger(n) && n > 0);
303
+ const all = [...kids];
304
+ for (const kid of kids)
305
+ all.push(...(await descendantsOf(kid)));
306
+ return all;
307
+ }
308
+ /**
309
+ * Is a hard failure appropriate here, or only a warning?
310
+ *
311
+ * **Warn in CI, and ONLY in CI.** A runner is a fresh box thrown away in four minutes:
312
+ * there is no walked-away-from dev server on it, nobody to read the advice, and nothing
313
+ * to kill — but a busy runner would turn a green suite into a mystery "webServer
314
+ * exited", and a guard that flakes the test lane is a guard someone disables globally.
315
+ *
316
+ * It is tempting to also downgrade when stdout is not a TTY ("nobody is watching"), and
317
+ * that is a trap: **a piped dev server is not an unattended one, it is an AGENT'S one**
318
+ * — `bun start > dev.log` is exactly how a coding agent launches it, and
319
+ * agent-launched servers are the precise population that left three runaways on this
320
+ * machine and took it down. Disabling the guard where there is no terminal would
321
+ * disable it exactly where it has already failed once.
322
+ */
323
+ const hardFailAppropriate = () => !process.env.CI && !process.env.GITHUB_ACTIONS;
324
+ export async function preflight(opts = {}) {
325
+ const configured = opts.mode === false ? 'off' : opts.mode ?? 'fail';
326
+ const mode = process.env.DEV_SKIP_PREFLIGHT === '1' ? 'off' : configured;
327
+ if (mode === 'off' || process.platform === 'win32')
328
+ return true;
329
+ // A `fail` that nobody is watching is a `warn` that breaks the build.
330
+ const canFail = mode === 'fail' && hardFailAppropriate();
331
+ const totalRamMb = opts.totalRamMb ?? Math.round(os.totalmem() / 1024 / 1024);
332
+ // VM pressure first: it is the only check that speaks to whether the MACHINE is
333
+ // dying, as opposed to whether some process looks wrong. A box in a full VM stall
334
+ // is unsafe to build on no matter how innocent the process table looks.
335
+ const vm = opts.vm !== undefined ? opts.vm : await readVmPressure();
336
+ if (vm) {
337
+ const pressure = assessMemoryPressure(vm, { totalRamMb });
338
+ if (pressure.level === 'fail') {
339
+ console.error(`\n🛑 ${opts.label ?? 'Build'}: this machine is out of memory.\n\n` +
340
+ ` ${pressure.reason}.\n\n` +
341
+ ` The page-out scanner has nothing left to reclaim, so the machine is about\n` +
342
+ ` to spend its time swapping instead of working — and macOS will thrash\n` +
343
+ ` rather than kill anything. Free some memory (quit apps, kill stray dev\n` +
344
+ ` servers — \`ps -eo pid,rss,args | sort -k2 -rn | head\`) and try again.\n\n` +
345
+ ` Override with DEV_SKIP_PREFLIGHT=1.\n`);
346
+ // VM pressure is fail-closed even in CI: it is not a heuristic about someone
347
+ // else's process, it is the machine reporting that it has nothing left to give.
348
+ if (mode === 'fail')
349
+ return false;
350
+ }
351
+ }
352
+ let assessment;
353
+ try {
354
+ // Never judge our OWN children. buildSite spawns `bun build`, the example-check,
355
+ // tsc, the ePub child and a gzip child — and the dev server's health tick fires
356
+ // every 60s, so it can easily land mid-rebuild and see the bundler we ourselves
357
+ // started. A build peaking mid-run must not look like a runaway to its own parent.
358
+ const mine = new Set([process.pid, ...(await descendantsOf(process.pid))]);
359
+ const procs = (opts.procs ?? (await snapshot())).filter((p) => !mine.has(p.pid));
360
+ assessment = assessProcesses(procs, {
361
+ totalRamMb,
362
+ selfPid: process.pid,
363
+ devLimitMb: opts.devLimitMb,
364
+ // Hold back RAM for known-heavy non-dev work the exempt list can't name.
365
+ reservedMb: Number(process.env.DEV_RESERVED_MB || 0) || 0,
366
+ });
367
+ }
368
+ catch {
369
+ return true; // couldn't look — never block the build on the check itself
370
+ }
371
+ if (assessment.level === 'ok')
372
+ return true;
373
+ const lines = [];
374
+ for (const p of assessment.offenders) {
375
+ const cwd = await cwdOf(p.pid);
376
+ lines.push(` ${String(p.pid).padStart(6)} ${gb(p.rssMb).padStart(7)} ` +
377
+ `${p.etime.padStart(9)} old ${p.command.slice(0, 60)}` +
378
+ (cwd ? `\n${' '.repeat(10)}in ${cwd}` : ''));
379
+ }
380
+ // A warn-level assessment (a big NON-dev process — a VM, a simulator, a database)
381
+ // never refuses. Say it once and get out of the way.
382
+ if (assessment.level === 'warn' || !canFail) {
383
+ console.warn(`\n⚠️ ${opts.label ?? 'Build'}: ${assessment.reason}.\n\n` +
384
+ ` PID RSS AGE COMMAND\n${lines.join('\n')}\n` +
385
+ (canFail
386
+ ? ''
387
+ : `\n (Proceeding anyway: CI. Nothing here to kill, nobody here to read this.)\n`));
388
+ return true;
389
+ }
390
+ console.error(`\n🛑 ${opts.label ?? 'Build'} stopped: this machine is in trouble.\n\n` +
391
+ ` ${assessment.reason}.\n\n` +
392
+ ` PID RSS AGE COMMAND\n${lines.join('\n')}\n\n` +
393
+ ` A dev server left running keeps executing the code it loaded at launch —\n` +
394
+ ` updating the package does nothing for it. Left for days, a per-rebuild leak\n` +
395
+ ` compounds until the machine swaps itself to death, and macOS will thrash\n` +
396
+ ` rather than kill it. Adding another build to that is how you lose the box.\n\n` +
397
+ ` Kill them, then try again:\n\n` +
398
+ ` kill ${assessment.offenders.map((p) => p.pid).join(' ')}\n\n` +
399
+ ` Override with DEV_SKIP_PREFLIGHT=1 if you know these are fine.\n`);
400
+ return false;
401
+ }
@@ -2,6 +2,7 @@ import type { ProjectLinks, LinkItem } from '../../doc-browser';
2
2
  import type { DocSystemTheme } from '../doc-system-styles';
3
3
  import type { Doc } from './docs';
4
4
  import type { BookManifest } from '../book-manifest';
5
+ import type { PreflightMode } from './preflight';
5
6
  export type SiteHost = 'github-pages' | 'firebase' | 'static';
6
7
  /** Resolved paths handed to a `libraryBuild` override (see SiteConfig). */
7
8
  export interface LibraryBuildContext {
@@ -186,6 +187,39 @@ export interface SiteConfig {
186
187
  port?: number;
187
188
  /** extra dev-server watch paths (added to docPaths + bundleEntry dir). */
188
189
  watchPaths?: string[];
190
+ /**
191
+ * RSS ceiling (MB) for the dev server, default 4096. A watch process lives for
192
+ * days across thousands of rebuilds, so anything the build strands per rebuild
193
+ * compounds until the machine swaps itself to death. Past this, the server
194
+ * prints the growth-per-rebuild and exits rather than take the machine with it.
195
+ * Overridden by the DEV_MEMORY_LIMIT_MB env var. Raise it if a genuinely large
196
+ * build needs the headroom — but sustained growth per rebuild is a leak, not a
197
+ * ceiling that's too low.
198
+ */
199
+ memoryLimitMb?: number;
200
+ /**
201
+ * Hours of idleness (no request served, no rebuild) after which the dev server
202
+ * exits, default 8. Zero or negative disables it. Overridden by the
203
+ * DEV_IDLE_TIMEOUT_HOURS env var.
204
+ *
205
+ * The memory ceiling above bounds how bad ONE server gets; this bounds how many
206
+ * there are. A dev server is trivially forgotten — the failure that motivated
207
+ * both guards was three servers left running for days, still executing the code
208
+ * they loaded at launch (updating the package does nothing for a process that is
209
+ * already running). An idle server has no value to trade against that, so it goes.
210
+ */
211
+ idleTimeoutHours?: number;
212
+ /**
213
+ * Machine-health preflight before each build and at dev-server launch: refuse to add
214
+ * load to a machine that is already dying (a runaway dev server, a VM stall).
215
+ *
216
+ * `'fail'` (default) refuses; `'warn'` prints and proceeds; `false`/`'off'` skips it.
217
+ * Also `DEV_SKIP_PREFLIGHT=1`. A hard failure is **automatically downgraded to a
218
+ * warning in CI** — the guard is there to stop a human from making a bad situation
219
+ * worse, and on a throwaway runner there is no human, no stale dev server, and
220
+ * nothing to kill.
221
+ */
222
+ preflight?: PreflightMode | false;
189
223
  /**
190
224
  * Enable the dev-server source read/write endpoints (`/__docstore/source`) that
191
225
  * back in-browser "edit page source". Local dev only — the dev server runs on