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.
@@ -12,13 +12,142 @@ import { statSync } from 'fs';
12
12
  import { watch } from 'chokidar';
13
13
  import { $, spawn } from 'bun';
14
14
  import { buildSite } from './orchestrator';
15
+ import { preflight } from './preflight';
15
16
  const TEST_RESULTS_FILE = '.browser-tests.json';
17
+ const DEFAULT_IDLE_HOURS = 8;
18
+ /**
19
+ * The haltija the dev server spawns (`bunx <this>`).
20
+ *
21
+ * Pinned to a **range with a floor**, not `@latest`. This is library code: every
22
+ * adopter's dev server runs whatever this string resolves to, so `@latest` means a
23
+ * floating executable fetch — the tool can change under every consumer overnight,
24
+ * with haltija in nobody's lockfile and no version contract. A caret range still
25
+ * picks up fixes without letting a major land unannounced.
26
+ *
27
+ * Override with `HALTIJA_VERSION` (e.g. `HALTIJA_VERSION=haltija@beta` to test a
28
+ * pre-release, or a local path/tarball) — you should not have to edit library code
29
+ * to try a different haltija.
30
+ *
31
+ * Floor is **1.4.0** — the first release whose behavior this integration can rely on:
32
+ * - `hj` routes to the server owning the current directory, so `hj` inside a project
33
+ * drives THAT project's browser instead of falling back to a shared port and
34
+ * silently driving whoever was focused there.
35
+ * - a haltija server no longer overwrites the machine-wide `hj` binary with an older
36
+ * copy on startup — so OUR spawning one can't downgrade the CLI for an unrelated,
37
+ * up-to-date project (the exact "unpinned executable fetch from library code"
38
+ * hazard this pin exists to bound).
39
+ * - HTTPS-only servers stopped advertising an HTTP port they weren't listening on.
40
+ * - `hj` action commands (`navigate`, `click`, …) exit NON-ZERO on failure, so the
41
+ * test lane below can trust an exit code instead of racing to a timeout.
42
+ */
43
+ const HALTIJA_PKG = process.env.HALTIJA_VERSION ?? 'haltija@^1.4.0';
44
+ /**
45
+ * Resolve the idle-exit timeout to milliseconds (0 = disabled).
46
+ *
47
+ * Env wins over config, config over the default. An unparseable value falls back
48
+ * to the default rather than to 0: a typo'd `DEV_IDLE_TIMEOUT_HOURS=8h` must not
49
+ * silently turn the guard OFF — that is the exact failure it exists to prevent.
50
+ * Only an explicit non-positive number disables it.
51
+ */
52
+ export function resolveIdleMs(configHours, envHours) {
53
+ const env = envHours?.trim();
54
+ const raw = env ? Number(env) : configHours ?? DEFAULT_IDLE_HOURS;
55
+ const hours = Number.isFinite(raw) ? raw : DEFAULT_IDLE_HOURS;
56
+ return hours > 0 ? hours * 3600_000 : 0;
57
+ }
58
+ const DEFAULT_LIMIT_MB = 4096;
59
+ /**
60
+ * Resolve the RSS ceiling in MB (0 = disabled).
61
+ *
62
+ * Same rule as `resolveIdleMs`, and it was NOT being applied: the ceiling was read as
63
+ * `Number(env ?? config ?? 4096)`, which fails in both directions at once —
64
+ *
65
+ * DEV_MEMORY_LIMIT_MB='' → `??` passes '' through → Number('') === 0 → the ceiling
66
+ * is ZERO, so `rss >= limit` is true on the first sample
67
+ * and the dev server kills itself on every rebuild.
68
+ * DEV_MEMORY_LIMIT_MB=4gb → NaN → every `>=` comparison is false → the guard is
69
+ * silently OFF, on the machine of someone who was
70
+ * explicitly trying to configure it.
71
+ *
72
+ * An empty env var is *unset*, not zero. Garbage falls back to the default, never to
73
+ * off. Only an explicit non-positive number disables the ceiling.
74
+ */
75
+ export function resolveLimitMb(configMb, envMb) {
76
+ const env = envMb?.trim();
77
+ const raw = env ? Number(env) : configMb ?? DEFAULT_LIMIT_MB;
78
+ const mb = Number.isFinite(raw) ? raw : DEFAULT_LIMIT_MB;
79
+ return mb > 0 ? mb : 0;
80
+ }
81
+ /**
82
+ * Reclaim the port we are about to bind, from the process LISTENING on it.
83
+ *
84
+ * That sentence is the predicate, and the old code did not implement it. It ran
85
+ * `lsof -ti:${port} | xargs kill -9`, and **`lsof -i:PORT` matches sockets whose
86
+ * LOCAL *or REMOTE* port is PORT** — so it returned every process merely *connected*
87
+ * to that port and SIGKILLed them all. Not theoretical: on this machine
88
+ * `lsof -ti:443` returns GitHub Desktop, Proton Bridge and two `claude` processes,
89
+ * none of which listen on 443. Aimed at our own dev port it would take out the
90
+ * browser reading the page, Playwright's browsers, the haltija Electron — anything
91
+ * with an open connection.
92
+ *
93
+ * We shipped that reasoning in this very file for `pkill -f haltija` ("a test lane
94
+ * that reaches outside the repo and kills the developer's tools presents as 'my tools
95
+ * got weird', never as a red test") and then failed to apply it here.
96
+ *
97
+ * So: listeners only; confirm each pid is actually a JS runtime before signalling
98
+ * (if something else owns our port, that is the user's business — say so, don't shoot
99
+ * it); SIGTERM first and give it a moment; SIGKILL only what refuses to die.
100
+ */
16
101
  async function killStrayServer(port) {
17
- try {
18
- await $ `lsof -ti:${port} | xargs kill -9 2>/dev/null`.quiet();
19
- }
20
- catch {
21
- // No process on port, that's fine
102
+ // Number('') is 0, and `lsof -ti:0` matches sockets with an UNBOUND port — on this
103
+ // machine, system daemons. Never let a bad port become a kill list.
104
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
105
+ return;
106
+ const pids = await $ `lsof -ti:${port} -sTCP:LISTEN`
107
+ .quiet()
108
+ .text()
109
+ .then((out) => out
110
+ .trim()
111
+ .split('\n')
112
+ .filter(Boolean)
113
+ .map(Number)
114
+ .filter((p) => Number.isInteger(p) && p > 0 && p !== process.pid))
115
+ .catch(() => []); // nothing listening — the normal case
116
+ for (const pid of pids) {
117
+ const comm = await $ `ps -p ${pid} -o comm=`
118
+ .quiet()
119
+ .text()
120
+ .then((s) => s.trim())
121
+ .catch(() => '');
122
+ if (!/\b(bun|node|deno)\b/.test(comm)) {
123
+ console.warn(`⚠️ port ${port} is held by pid ${pid} (${comm || 'unknown'}), which is not a\n` +
124
+ ` dev server — leaving it alone. Free the port, or set PORT to another one.`);
125
+ continue;
126
+ }
127
+ try {
128
+ process.kill(pid, 'SIGTERM');
129
+ }
130
+ catch {
131
+ continue; // already gone
132
+ }
133
+ // Give it a moment to close the listener, then insist.
134
+ for (let i = 0; i < 20; i++) {
135
+ await Bun.sleep(50);
136
+ try {
137
+ process.kill(pid, 0);
138
+ }
139
+ catch {
140
+ break; // exited
141
+ }
142
+ if (i === 19) {
143
+ try {
144
+ process.kill(pid, 'SIGKILL');
145
+ }
146
+ catch {
147
+ // gone between the check and the signal — fine
148
+ }
149
+ }
150
+ }
22
151
  }
23
152
  }
24
153
  /**
@@ -48,10 +177,8 @@ async function ensureHaltijaChannel(port) {
48
177
  // HTTPS dev page has no mixed-content), while the `hj` CLI drives over its
49
178
  // default HTTP 8700 — one server, both transports, shared state. HTTPS cert
50
179
  // is mkcert-trusted. Output quiet so it doesn't drown the dev log.
51
- // `@latest` (1.3.2+): the widget-mode WebRTC screen capture (which makes the
52
- // Electron app CI-only) is on the stable channel now, along with the fix for
53
- // an accidental Electron launch. (Was pinned to `@beta` before 1.3.2.)
54
- spawn(['bunx', 'haltija@latest', '--server', '--both'], {
180
+ // Version comes from HALTIJA_PKG — a pinned range, not `@latest`; see above.
181
+ spawn(['bunx', HALTIJA_PKG, '--server', '--both'], {
55
182
  stdout: 'ignore',
56
183
  stderr: 'ignore',
57
184
  });
@@ -80,17 +207,51 @@ async function ensureDevCerts() {
80
207
  process.exit(1);
81
208
  }
82
209
  export async function devServer(config, opts = {}) {
83
- const PORT = config.port ?? 8787;
210
+ // PORT env wins over the config, so a test harness can bring up its own instance on
211
+ // its own port instead of adopting (or killing — killStrayServer takes the port)
212
+ // the dev server you already have running.
213
+ // `||`, not `??`: an EMPTY `PORT=` is unset, not "port zero". `??` only catches
214
+ // null/undefined, so `PORT=''` yielded `Number('') === 0` — and port 0 fed
215
+ // killStrayServer, whose `lsof -ti:0` matches sockets with an unbound port (system
216
+ // daemons, on this machine). An env var set to empty is the most ordinary shell
217
+ // accident there is; it must not become a kill list.
218
+ const PORT = Number(process.env.PORT || config.port || 8787);
84
219
  const PUBLIC = path.resolve('./', config.outputDir ?? 'docs');
85
220
  const isSPA = true;
86
221
  const testMode = !!opts.test;
222
+ // Don't add a days-long watch process to a machine that is already drowning — and
223
+ // don't quietly become the fourth stale dev server. `buildSite` preflights too, but a
224
+ // consumer's custom `build` need not go through it, and "on launch" is the moment a
225
+ // human is actually looking at the terminal.
226
+ //
227
+ // THROWS; does not `process.exit`. `devServer` is a public export, and library code
228
+ // killing the host process is not ours to do — the caller catches and decides. (The
229
+ // health tick below is different: by then we own a running server on a dying machine,
230
+ // and stopping it is the entire point of the guard.)
231
+ if (!(await preflight({
232
+ label: 'Dev server',
233
+ devLimitMb: config.memoryLimitMb,
234
+ mode: config.preflight,
235
+ }))) {
236
+ throw new Error('dev server: refusing to start — see the machine-health report above. ' +
237
+ 'Override with DEV_SKIP_PREFLIGHT=1, or set `preflight: "warn"` in your site config.');
238
+ }
87
239
  // Haltija dev-channel — give a coding agent eyes on the running page. Opt-in
88
240
  // (config.haltijaDev or HALTIJA_DEV=1), never in test mode. The loader is a
89
241
  // localhost-gated runtime import() of the local channel's dev.js, so haltija
90
242
  // is never bundled and self-disables off-localhost; it's injected only at
91
243
  // serve time (below), so it never lands in the built output.
244
+ //
245
+ // Explicitly OFF under `HALTIJA_DEV=0` and in CI, which both override a config
246
+ // `haltijaDev: true`: the E2E lane starts this server, and there is no agent on
247
+ // the other end of the channel there — spawning it would only download an Electron
248
+ // app into the runner for nobody to look at.
92
249
  const HALTIJA_HTTPS_PORT = 8701;
250
+ const haltijaOff = process.env.HALTIJA_DEV === '0' ||
251
+ process.env.HALTIJA_DEV === 'false' ||
252
+ process.env.CI === 'true';
93
253
  const haltijaDev = !testMode &&
254
+ !haltijaOff &&
94
255
  (config.haltijaDev === true ||
95
256
  process.env.HALTIJA_DEV === '1' ||
96
257
  process.env.HALTIJA_DEV === 'true');
@@ -213,6 +374,85 @@ export async function devServer(config, opts = {}) {
213
374
  });
214
375
  }
215
376
  }
377
+ // Idle self-exit.
378
+ //
379
+ // The memory ceiling below bounds how bad one server gets; this bounds how many
380
+ // there are. A dev server is trivially forgotten — and a forgotten one is not
381
+ // inert, it is a days-old process still running the code it loaded at launch,
382
+ // leaking whatever that code leaked (updating the package does nothing for a
383
+ // process that is already running). Three of those, left over from before a leak
384
+ // fix landed, wedged a 32GB machine at ~210GB of demand. An idle server has no
385
+ // value to weigh against that, so it exits and lets you start a current one.
386
+ //
387
+ // Activity is a request served or a rebuild — i.e. someone is actually reading or
388
+ // editing. Off in test mode, which has its own timeout and exits on its own.
389
+ const idleMs = resolveIdleMs(config.idleTimeoutHours, process.env.DEV_IDLE_TIMEOUT_HOURS);
390
+ let lastActivity = Date.now();
391
+ const touch = () => {
392
+ lastActivity = Date.now();
393
+ };
394
+ // Memory watchdog.
395
+ //
396
+ // The build hands work to native code (the bundler, the HTML parser, the SVG
397
+ // rasterizer) which can strand memory the JS heap never sees — so nothing
398
+ // here can GC it away, and `heapUsed` stays flat while RSS climbs. A watch
399
+ // process lives for DAYS across thousands of rebuilds, so a per-rebuild leak
400
+ // compounds until the machine swaps itself to death. That is not
401
+ // hypothetical: a 2-day session reached 136GB RSS and took the machine down
402
+ // (Bun.build's native arena — see orchestrator.ts and oven-sh/bun#34053).
403
+ // Dying loudly beats being the reason a laptop overheats: the dev server is
404
+ // one keystroke to restart, the machine is not.
405
+ //
406
+ // Sampled after every rebuild AND on the periodic health tick below. The tick
407
+ // matters: a rebuild-only check is blind to a process that is already over the
408
+ // ceiling but has stopped rebuilding — which is exactly the shape of a server
409
+ // you have walked away from, i.e. the one that kills the machine.
410
+ const rssMb = () => Math.round(process.memoryUsage().rss / 1e6);
411
+ const limitMb = resolveLimitMb(config.memoryLimitMb, process.env.DEV_MEMORY_LIMIT_MB);
412
+ let baselineMb = 0;
413
+ let rebuilds = 0;
414
+ let warned = false;
415
+ const checkMemory = (fromRebuild = true) => {
416
+ if (limitMb <= 0)
417
+ return; // explicitly disabled
418
+ const mb = rssMb();
419
+ if (fromRebuild) {
420
+ rebuilds += 1;
421
+ if (!baselineMb)
422
+ baselineMb = mb;
423
+ }
424
+ if (!baselineMb)
425
+ baselineMb = mb;
426
+ const growth = mb - baselineMb;
427
+ const each = rebuilds > 1 ? growth / (rebuilds - 1) : 0;
428
+ if (mb >= limitMb) {
429
+ // Distinguish the two ways to get here, because the advice is opposite:
430
+ // memory that GREW across rebuilds is a leak (report it); a build that was
431
+ // simply born bigger than the ceiling just needs a bigger ceiling.
432
+ const leaking = rebuilds > 1 && each >= 1;
433
+ const diagnosis = leaking
434
+ ? ` ${rebuilds} rebuilds, +${growth}MB since the first ` +
435
+ `(~${each.toFixed(1)}MB per rebuild).\n\n` +
436
+ ` Growth per rebuild should be ~0, so this is a leak, not your project\n` +
437
+ ` getting bigger. Restarting reclaims it — please report the numbers\n` +
438
+ ` above. If this build genuinely needs the headroom, raise the ceiling\n` +
439
+ ` with DEV_MEMORY_LIMIT_MB=<mb> or memoryLimitMb in your site config.\n`
440
+ : ` ${rebuilds} rebuild${rebuilds === 1 ? '' : 's'}, ` +
441
+ `+${growth}MB since the first — i.e. it is not growing.\n\n` +
442
+ ` This build's baseline footprint is simply above the ceiling, which is\n` +
443
+ ` not a leak. Raise it with DEV_MEMORY_LIMIT_MB=<mb> or memoryLimitMb in\n` +
444
+ ` your site config.\n`;
445
+ console.error(`\n🛑 dev server stopping: ${mb}MB RSS, over the ${limitMb}MB limit.\n\n` +
446
+ diagnosis);
447
+ process.exit(1);
448
+ }
449
+ if (!warned && mb > limitMb * 0.6) {
450
+ warned = true;
451
+ console.warn(`⚠️ dev server at ${mb}MB RSS after ${rebuilds} rebuilds (+${growth}MB, ` +
452
+ `~${each.toFixed(1)}MB each; limit ${limitMb}MB). Growth per rebuild ` +
453
+ `should be ~0 — if this keeps climbing, restart and report it.`);
454
+ }
455
+ };
216
456
  if (!testMode) {
217
457
  // Rebuild on any source change. By default that's just buildSite(), but a
218
458
  // consumer whose full build has steps BEYOND buildSite — e.g. a custom IIFE
@@ -223,6 +463,40 @@ export async function devServer(config, opts = {}) {
223
463
  // never regenerated — leaving the page's /iife.js to 404 into the SPA
224
464
  // fallback (it "loads as html"). Serialize builds and coalesce bursts.
225
465
  const runBuild = opts.build ?? (() => buildSite(config));
466
+ // Rebuild-storm detector.
467
+ //
468
+ // The other way this process eats the machine is not a leak but a LOOP: if the
469
+ // build writes a file that the watcher watches, every rebuild triggers the next
470
+ // one, forever. Each iteration spawns children (bundler, css, ePub), pegs the
471
+ // CPU, and adds the per-rebuild residual — so a loop is a leak with a throttle
472
+ // removed. The known self-writes (`version.ts`, `icon-data.ts`) are in `ignored`
473
+ // below, but `config.prebuild` is arbitrary consumer code: anything it writes
474
+ // into a watched path loops, and the failure looks like "my fan is on" rather
475
+ // than like a bug.
476
+ //
477
+ // RATE is the wrong signal, and it is worth saying why, because it is the obvious
478
+ // thing to reach for and it does not work: this project's build takes ~3s, so a
479
+ // real loop can only manage ~19 rebuilds a minute — under any "20 a minute" limit
480
+ // you would think to set. Meanwhile a 200ms build loops at 300 a minute. No single
481
+ // rate threshold is both blind to human editing and sensitive to a slow-build loop.
482
+ //
483
+ // The signal that actually separates them is that a loop is SELF-SUSTAINING: the
484
+ // next rebuild is always already queued the instant the current one ends. There is
485
+ // never a human-scale pause, because no human is involved. So count *consecutive
486
+ // immediate* rebuilds, and let any ordinary gap — someone pausing to think, even
487
+ // for two seconds — reset the count.
488
+ const IMMEDIATE_MS = 1500;
489
+ const LOOP_STREAK = 30; // fatal: 30 rebuilds with no human-scale gap between any
490
+ const LOOP_WARN = 15;
491
+ let lastBuildEnd = 0;
492
+ let streak = 0;
493
+ let stormWarned = false;
494
+ const triggers = new Map();
495
+ const topTriggers = () => [...triggers.entries()]
496
+ .sort((a, b) => b[1] - a[1])
497
+ .slice(0, 5)
498
+ .map(([p, n]) => ` ${String(n).padStart(4)}x ${p}`)
499
+ .join('\n');
226
500
  let building = false;
227
501
  let pending = false;
228
502
  const rebuild = async () => {
@@ -231,6 +505,11 @@ export async function devServer(config, opts = {}) {
231
505
  return;
232
506
  }
233
507
  building = true;
508
+ // "Immediate" covers both shapes a loop takes: for a SLOW build the self-write
509
+ // lands mid-build and `pending` is already set when it ends (so the next rebuild
510
+ // starts at once); for a FAST build the watcher event arrives just after the end.
511
+ // Both look like: started again with no gap.
512
+ const immediate = lastBuildEnd > 0 && Date.now() - lastBuildEnd < IMMEDIATE_MS;
234
513
  try {
235
514
  await runBuild();
236
515
  }
@@ -239,6 +518,26 @@ export async function devServer(config, opts = {}) {
239
518
  }
240
519
  finally {
241
520
  building = false;
521
+ lastBuildEnd = Date.now();
522
+ streak = immediate ? streak + 1 : 0;
523
+ touch();
524
+ checkMemory();
525
+ if (streak >= LOOP_STREAK) {
526
+ console.error(`\n🛑 dev server stopping: rebuild loop — ${streak} rebuilds back to back, ` +
527
+ `with no pause between any of them.\n\n` +
528
+ ` The files that keep triggering it:\n\n${topTriggers()}\n\n` +
529
+ ` A build that writes a file it also watches rebuilds forever, spawning a\n` +
530
+ ` bundler every time until the machine gives up — a loop is a leak with the\n` +
531
+ ` throttle removed. If a file above is generated by your build, stop writing\n` +
532
+ ` it into a watched path, or add it to the watcher's ignore list (\`ignored\`\n` +
533
+ ` in dev-server.ts; see the \`prebuild\` notes in the doc-site-system docs).\n`);
534
+ process.exit(1);
535
+ }
536
+ if (streak >= LOOP_WARN && !stormWarned) {
537
+ stormWarned = true;
538
+ console.warn(`⚠️ ${streak} rebuilds back to back with no pause — this looks like a rebuild\n` +
539
+ ` loop (a build writing a file it also watches). Top triggers:\n${topTriggers()}`);
540
+ }
242
541
  if (pending) {
243
542
  pending = false;
244
543
  void rebuild();
@@ -254,7 +553,12 @@ export async function devServer(config, opts = {}) {
254
553
  './icons',
255
554
  ...(config.watchPaths ?? []),
256
555
  ];
257
- watch(watchPaths, { ignored, ignoreInitial: true }).on('all', () => void rebuild());
556
+ watch(watchPaths, { ignored, ignoreInitial: true }).on('all', (_event, changedPath) => {
557
+ if (changedPath) {
558
+ triggers.set(changedPath, (triggers.get(changedPath) ?? 0) + 1);
559
+ }
560
+ void rebuild();
561
+ });
258
562
  }
259
563
  await ensureDevCerts();
260
564
  const server = Bun.serve({
@@ -264,6 +568,7 @@ export async function devServer(config, opts = {}) {
264
568
  cert: Bun.file('./tls/certificate.pem'),
265
569
  },
266
570
  async fetch(request) {
571
+ touch();
267
572
  let reqPath = new URL(request.url).pathname;
268
573
  console.log(request.method, reqPath);
269
574
  if (request.method === 'POST' && reqPath === '/report') {
@@ -292,6 +597,60 @@ export async function devServer(config, opts = {}) {
292
597
  },
293
598
  });
294
599
  console.log(`Listening on https://localhost:${PORT}`);
600
+ // ── health tick ───────────────────────────────────────────────────────────
601
+ //
602
+ // Everything else in this file is edge-triggered: the RSS check fires after a
603
+ // rebuild, the preflight fires at launch. Both are blind to the state that
604
+ // actually kills machines — a server nobody is touching any more, sitting on
605
+ // gigabytes, on a box that is quietly filling up around it. Nothing rebuilds,
606
+ // so nothing looks. So look on a timer, not only on an event.
607
+ //
608
+ // Three checks, cheapest first:
609
+ // 1. RSS ceiling — catches a process already over the line that has stopped
610
+ // rebuilding (the walked-away-from server).
611
+ // 2. Idle exit — bounds how MANY servers exist, not just how big one gets.
612
+ // 3. Machine preflight (every tick) — catches the box going bad around us,
613
+ // including other projects' runaways. If the machine is dying we exit too, and
614
+ // print the PIDs and the kill command on the way out. Exiting IS the guard here,
615
+ // unlike at launch where we throw: by now we own a running server on a dying
616
+ // machine, and the whole point is to stop being part of the problem.
617
+ if (!testMode) {
618
+ if (idleMs > 0) {
619
+ console.log(`Exits after ${idleMs / 3600_000}h idle (DEV_IDLE_TIMEOUT_HOURS=0 to disable).`);
620
+ }
621
+ // Every minute, not every five. The runaway that killed the machine went 0→100GB
622
+ // in about twenty minutes; on a box whose dev budget is a fraction of its RAM
623
+ // (most of it held by a resident model), the trip from healthy to unrecoverable
624
+ // fits comfortably inside a five-minute window — and with swap on a fast, large
625
+ // disk, macOS will thrash for a very long time before it gives up, which is more
626
+ // rope to hang the machine with, not less. A `ps` + `vm_stat` per minute is free.
627
+ const TICK_MS = 60_000;
628
+ const timer = setInterval(async () => {
629
+ checkMemory(false); // exits if we are over the ceiling
630
+ const idleFor = Date.now() - lastActivity;
631
+ if (idleMs > 0 && idleFor >= idleMs) {
632
+ console.log(`\n💤 dev server exiting: idle for ${Math.round(idleFor / 3600_000)}h — no requests, no rebuilds.\n\n` +
633
+ ` Restart it when you need it; a fresh one also picks up any dependency\n` +
634
+ ` updates, which a long-running process never does. Disable with\n` +
635
+ ` DEV_IDLE_TIMEOUT_HOURS=0 or idleTimeoutHours in your site config.\n`);
636
+ clearInterval(timer);
637
+ server.stop();
638
+ process.exit(0);
639
+ }
640
+ const ok = await preflight({
641
+ label: 'Dev server',
642
+ devLimitMb: config.memoryLimitMb,
643
+ mode: config.preflight,
644
+ });
645
+ if (!ok) {
646
+ clearInterval(timer);
647
+ server.stop();
648
+ process.exit(1);
649
+ }
650
+ }, TICK_MS);
651
+ // Never let the health check itself be the thing keeping the process alive.
652
+ timer.unref?.();
653
+ }
295
654
  if (haltijaDev) {
296
655
  await ensureHaltijaChannel(HALTIJA_HTTPS_PORT);
297
656
  }
@@ -313,9 +672,13 @@ export async function devServer(config, opts = {}) {
313
672
  }
314
673
  if (!hjAvailable) {
315
674
  console.log('Starting haltija...');
316
- haltija = spawn(['bunx', 'haltija@latest', '-f'], {
317
- stdout: 'inherit',
318
- stderr: 'inherit',
675
+ // stdout/stderr are NOT inherited: `bunx haltija` launches an Electron app as a
676
+ // grandchild, and killing the bunx wrapper does not kill it. An orphaned Electron
677
+ // holding our inherited stdout keeps the pipe open forever, so a CI job or an
678
+ // agent capturing this command's output sees it hang long after it exited (0).
679
+ haltija = spawn(['bunx', HALTIJA_PKG, '-f'], {
680
+ stdout: 'ignore',
681
+ stderr: 'ignore',
319
682
  });
320
683
  console.log('Waiting for browser...');
321
684
  for (let i = 0; i < 20; i++) {
@@ -341,20 +704,82 @@ export async function devServer(config, opts = {}) {
341
704
  else {
342
705
  console.log('Using existing haltija browser');
343
706
  }
707
+ /**
708
+ * Tear down the haltija WE started — including the Electron grandchild.
709
+ *
710
+ * `haltija.kill()` only signals the `bunx` wrapper; the Electron app it launched
711
+ * survives. A surviving instance leaves stale windows behind, and the next
712
+ * `--test` run reuses any haltija reporting `windows.length > 0` — so the run
713
+ * after this one navigates a dead window, never POSTs /report, and fails with
714
+ * "Browser tests timed out" on a perfectly good codebase.
715
+ *
716
+ * The predicate is **descendants of the process we spawned** — NOT `pkill -f
717
+ * haltija/apps/desktop`, which is what this used to do while claiming in this very
718
+ * comment to leave your own haltija alone. `pkill -f` matches every process on the
719
+ * machine whose command line contains that string, so it killed the haltija YOU
720
+ * were running too. And it is the *test suite* that runs this: we spawn our own
721
+ * whenever yours reports zero windows, so "haltija open, no windows, run the doc
722
+ * tests" silently killed your browser. A test lane that reaches outside the repo
723
+ * and kills the developer's tools presents as "my tools got weird", never as a red
724
+ * test.
725
+ *
726
+ * The tree is collected BEFORE the wrapper dies: once it is gone, Electron is
727
+ * reparented to init and `pgrep -P` can no longer find it.
728
+ */
729
+ const descendantsOf = async (pid) => {
730
+ const out = await $ `pgrep -P ${pid}`
731
+ .quiet()
732
+ .text()
733
+ .catch(() => '');
734
+ const kids = out
735
+ .trim()
736
+ .split('\n')
737
+ .filter(Boolean)
738
+ .map(Number)
739
+ .filter((n) => Number.isInteger(n) && n > 0);
740
+ const all = [...kids];
741
+ for (const kid of kids)
742
+ all.push(...(await descendantsOf(kid)));
743
+ return all;
744
+ };
745
+ const stopHaltija = async () => {
746
+ if (!haltija?.pid)
747
+ return;
748
+ const tree = await descendantsOf(haltija.pid);
749
+ haltija.kill();
750
+ for (const pid of tree) {
751
+ try {
752
+ process.kill(pid, 'SIGTERM');
753
+ }
754
+ catch {
755
+ // already gone — fine
756
+ }
757
+ }
758
+ };
344
759
  console.log('Opening demo site...');
345
- await $ `hj navigate https://localhost:${PORT}`;
760
+ // haltija 1.4: `hj navigate` exits NON-ZERO on failure (no browser reachable, a
761
+ // window that didn't take the URL, …). Before 1.4 it exited 0 regardless, so a
762
+ // failed navigate silently sailed on and the run died 120s later at the test
763
+ // timeout with a misleading "Browser tests timed out". Now we can read the exit
764
+ // code: fail immediately, say why, and tear down the haltija we spawned so the
765
+ // next run isn't inheriting a half-navigated browser.
766
+ const nav = await $ `hj navigate https://localhost:${PORT}`.nothrow().quiet();
767
+ if (nav.exitCode !== 0) {
768
+ console.error(`hj navigate failed (exit ${nav.exitCode}): ${nav.stderr.toString().trim() || 'no browser reachable'}`);
769
+ await stopHaltija();
770
+ server.stop();
771
+ process.exit(1);
772
+ }
346
773
  try {
347
774
  const results = await testResults;
348
775
  const exitCode = results.failed > 0 ? 1 : 0;
349
- if (haltija)
350
- haltija.kill();
776
+ await stopHaltija();
351
777
  server.stop();
352
778
  process.exit(exitCode);
353
779
  }
354
780
  catch (e) {
355
781
  console.error(e.message);
356
- if (haltija)
357
- haltija.kill();
782
+ await stopHaltija();
358
783
  server.stop();
359
784
  process.exit(1);
360
785
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ /*
2
+ Standalone ePub build — the subprocess entry point for buildEpub().
3
+
4
+ buildEpub() drives happy-dom (HTML→XHTML for 50+ chapters) and @resvg/resvg-js
5
+ (cover rasterization). Both hold native/retained memory that the JS heap can't
6
+ see, and the ePub is rebuilt on EVERY dev-server rebuild — so in-process it
7
+ strands memory in a watch process that lives for days. Running it in a child
8
+ hands all of it back to the OS on exit. Same reasoning as the bundle step; see
9
+ the Bun.build note in orchestrator.ts.
10
+
11
+ bun epub-cli.ts <payload.json> # payload = { config, opts }
12
+
13
+ Build-time only. Never import this from browser code.
14
+ */
15
+ import { buildEpub } from './epub';
16
+ const payloadPath = process.argv[2];
17
+ if (!payloadPath) {
18
+ console.error('epub-cli: expected a payload file path');
19
+ process.exit(1);
20
+ }
21
+ const { config, opts } = JSON.parse(await Bun.file(payloadPath).text());
22
+ await buildEpub(config, opts ?? {});
@@ -535,6 +535,12 @@ export async function buildEpub(config, opts = {}) {
535
535
  const output = path.resolve(opts.output ?? path.join(outDir, `${slugify(meta.title)}.epub`));
536
536
  await zipEpub(buildDir, output);
537
537
  fs.rmSync(buildDir, { recursive: true, force: true });
538
+ // Release the parser window: an unclosed happy-dom Window holds its whole
539
+ // document (every chapter parsed) plus its timers. Hygiene, not a proven win —
540
+ // buildEpub still grows ~1.5MB per call with this in place, so something here
541
+ // is still retained. buildEpub now runs in a child process (see orchestrator),
542
+ // which is what actually bounds it.
543
+ win?.close?.();
538
544
  console.log(`epub: ${output} (${chapters.length} chapters)`);
539
545
  return output;
540
546
  }
@@ -10,6 +10,7 @@ the IIFE bundle loads.
10
10
  Build-time only (uses Bun.write). Shares slug + markdown rendering with the runtime
11
11
  component (src/doc-system/*) so static and hydrated output agree.
12
12
  */
13
+ import { pageTitle } from '../doc-title';
13
14
  import { buildSlugMap, pathForSlug, rewriteDocLinks } from '../routing';
14
15
  import { buildNavTree, navOpenPath } from '../nav-tree';
15
16
  import { renderDocMarkdown, docDescription } from '../render';
@@ -80,12 +81,9 @@ function pageHtml(doc, config, slugMap, configAttr) {
80
81
  const localizedAttr = config.localizedStrings
81
82
  ? ` localized="${escapeAttr(withBase(basePath, localizedUrl))}"`
82
83
  : '';
83
- // `headTitle` is the verbatim <title>; otherwise suffix the project name (unless
84
- // the doc title already includes it).
85
- const title = doc.headTitle ||
86
- (projectName && !doc.title.includes(projectName)
87
- ? `${doc.title} — ${projectName}`
88
- : doc.title);
84
+ // ONE rule, shared with the doc-browser's hydration path — see doc-title.ts. Writing
85
+ // it twice is what made the title change on hydration (issue #6).
86
+ const title = pageTitle(doc, projectName);
89
87
  // Per-page metadata (from the doc's JSON block) wins; else derive, else site default.
90
88
  const description = doc.description || docDescription(doc.text) || config.description || '';
91
89
  const keywords = Array.isArray(doc.keywords)
@@ -102,21 +102,6 @@ export function generateLlmsTxt(outputPath, meta = {}, corpus) {
102
102
  console.warn('llms.txt: no readable package.json — using config values only');
103
103
  }
104
104
  const entries = corpus ? entriesFromCorpus(corpus, meta) : entriesFromSrcScan();
105
- // Only orient agents about the live-example execution model when the corpus
106
- // actually has live examples (a book / pure-docs site has none).
107
- const exampleFence = /```(js|tjs|ts|html|css)\b/;
108
- const hasLiveExamples = !!corpus && corpus.some((d) => exampleFence.test(d.text ?? ''));
109
- const liveExampleNote = hasLiveExamples
110
- ? [
111
- 'Live examples run inline on the shared page by default (not sandboxed):',
112
- 'each has its own `preview` element, but they share one tosijs module, so',
113
- '`tosi()` state singletons are shared across every example on a page. This is',
114
- 'deliberate (it shows tosi isolation and lets you drive demos via the global',
115
- 'singleton). Namespace example state with unique top-level keys so examples',
116
- "don't clobber each other; the `iframe` attribute isolates DOM/CSS, not state.",
117
- '',
118
- ]
119
- : [];
120
105
  const name = meta.name ?? pkg.name ?? '';
121
106
  const description = meta.description ?? pkg.description ?? '';
122
107
  const links = [];
@@ -140,7 +125,6 @@ export function generateLlmsTxt(outputPath, meta = {}, corpus) {
140
125
  '',
141
126
  'Full documentation, with live code examples, is at the links below.',
142
127
  '',
143
- ...liveExampleNote,
144
128
  '## Pages',
145
129
  '',
146
130
  ];