moshcode 0.38.0 → 0.40.0

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.
@@ -274,7 +274,8 @@ export function requiredPort(platform, preferred = 5354) {
274
274
  /* ------------------------------------------------------- running the plan */
275
275
 
276
276
  import { spawn } from "node:child_process";
277
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
277
+ import dgram from "node:dgram";
278
+ import { mkdir, open, readFile, rm, writeFile } from "node:fs/promises";
278
279
  import { existsSync } from "node:fs";
279
280
  import { dirname, join } from "node:path";
280
281
  import { homedir, tmpdir } from "node:os";
@@ -367,17 +368,132 @@ export async function daemonStatus(path = pidfilePath()) {
367
368
  }
368
369
 
369
370
  /**
370
- * Start the bridge detached, so the shell that launched it can exit.
371
+ * Where a daemon that died on startup left its reason.
371
372
  *
372
- * Not a systemd unit / launchd job / Windows service yet, which means it does
373
+ * Next to the pidfile, because the two answer halves of the same question and
374
+ * a person debugging one wants the other in the same directory.
375
+ */
376
+ export function daemonLogPath(path = pidfilePath()) {
377
+ return join(dirname(path), "moshpit-dns.log");
378
+ }
379
+
380
+ /**
381
+ * How long to wait for the bridge to answer before reporting it unproven.
382
+ *
383
+ * Generous on purpose, and it costs nothing in the case that matters: a daemon
384
+ * that dies resolves the race on its `exit` event immediately, so this bounds
385
+ * only the "alive but has not answered yet" case. The bridge binds *after* it
386
+ * fetches the ending list, which against the live registry is ~3s on a fast
387
+ * link — a tighter deadline would print a warning about healthy bridges on
388
+ * every slow connection.
389
+ */
390
+ export const READY_TIMEOUT_MS = 8000;
391
+ const POLL_MS = 150;
392
+ const LOG_TAIL_LINES = 20;
393
+
394
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
395
+
396
+ /** A minimal A query. Only the reply matters here, never what it says. */
397
+ function encodeQuery(name, id) {
398
+ const labels = String(name).split(".").filter(Boolean);
399
+ const head = Buffer.alloc(12);
400
+ head.writeUInt16BE(id, 0);
401
+ head.writeUInt16BE(0x0100, 2); // standard query, recursion desired
402
+ head.writeUInt16BE(1, 4); // one question
403
+ const tail = Buffer.alloc(4);
404
+ tail.writeUInt16BE(1, 0); // A
405
+ tail.writeUInt16BE(1, 2); // IN
406
+ return Buffer.concat([
407
+ head,
408
+ ...labels.map((label) => {
409
+ const bytes = Buffer.from(label, "ascii");
410
+ return Buffer.concat([Buffer.from([bytes.length]), bytes]);
411
+ }),
412
+ Buffer.from([0]),
413
+ tail,
414
+ ]);
415
+ }
416
+
417
+ /**
418
+ * Is something serving DNS on this port?
419
+ *
420
+ * Any well-formed reply counts, including NXDOMAIN and SERVFAIL. The question
421
+ * is whether the resolver is up, and a bridge whose upstreams are unreachable
422
+ * is still a bridge that started — conflating the two would turn a bad network
423
+ * into a failed start.
424
+ */
425
+ export function probeResolver({ host = "127.0.0.1", port, name = "a.eggs", timeoutMs = 500 } = {}) {
426
+ return new Promise((resolve) => {
427
+ const socket = dgram.createSocket("udp4");
428
+ const id = Math.floor(Math.random() * 65536);
429
+ let done = false;
430
+ const finish = (answered) => {
431
+ if (done) return;
432
+ done = true;
433
+ clearTimeout(timer);
434
+ try {
435
+ socket.close();
436
+ } catch {
437
+ // Already closed by the error that brought us here.
438
+ }
439
+ resolve(answered);
440
+ };
441
+ const timer = setTimeout(() => finish(false), timeoutMs);
442
+ socket.once("error", () => finish(false));
443
+ socket.on("message", (msg) => finish(msg.length >= 2 && msg.readUInt16BE(0) === id));
444
+ socket.send(encodeQuery(name, id), port, host, (err) => {
445
+ if (err) finish(false);
446
+ });
447
+ });
448
+ }
449
+
450
+ async function readLogTail(path, lines = LOG_TAIL_LINES) {
451
+ const text = await readFile(path, "utf8").catch(() => "");
452
+ const trimmed = text.trimEnd();
453
+ return trimmed ? trimmed.split("\n").slice(-lines).join("\n") : "";
454
+ }
455
+
456
+ /**
457
+ * Start the bridge detached, so the shell that launched it can exit — and do
458
+ * not claim it started until it has proved it is there.
459
+ *
460
+ * The old version spawned with `stdio: "ignore"`, wrote the pidfile from
461
+ * `child.pid`, and returned `started: true` in the same tick. Both halves of
462
+ * that were wrong on any machine where the daemon dies on startup. `enable`
463
+ * printed `ok bridge started (pid N)` for a process that was already gone, then
464
+ * installed catch-all routing — `Domains=~.` — pointing every lookup on the box
465
+ * at a port with nothing behind it. The failure took the machine's whole
466
+ * resolver down and left no way to find out why, because the one stream the
467
+ * daemon wrote its reason to had been routed to /dev/null. A node that is not
468
+ * on root's PATH, a port it cannot bind, a half-written install: all of them
469
+ * arrived as the same confident success line.
470
+ *
471
+ * So: stdout and stderr go to a file, an early exit is a failed start that
472
+ * reports what the daemon said, and the pidfile is written only once the
473
+ * process is still there — never for one that is not, which is what made
474
+ * `daemonStatus` report a stale pid as a crash that had never happened.
475
+ *
476
+ * Still not a systemd unit / launchd job / Windows service, which means it does
373
477
  * not survive a reboot. `moshcode dns status` says so plainly rather than
374
478
  * letting someone discover it when their names stop resolving.
375
479
  */
376
- export async function startDaemon({ port, registryBase, path = pidfilePath(), entry, proxy = null }) {
480
+ export async function startDaemon({
481
+ port,
482
+ registryBase,
483
+ path = pidfilePath(),
484
+ entry,
485
+ proxy = null,
486
+ host = "127.0.0.1",
487
+ logPath = null,
488
+ readyTimeoutMs = READY_TIMEOUT_MS,
489
+ probe = probeResolver,
490
+ sleep = defaultSleep,
491
+ }) {
377
492
  const existing = await daemonStatus(path);
378
493
  if (existing.running) return { started: false, pid: existing.pid, alreadyRunning: true };
379
494
 
380
495
  await mkdir(dirname(path), { recursive: true });
496
+ const log = logPath || daemonLogPath(path);
381
497
  const args = [entry, "dns", "start", "--port", String(port)];
382
498
  if (registryBase) args.push("--registry", registryBase);
383
499
  // Passed at spawn time because it is what the resolver answers with, not
@@ -385,10 +501,64 @@ export async function startDaemon({ port, registryBase, path = pidfilePath(), en
385
501
  // short of restarting it, which is why `enable` decides this before starting.
386
502
  if (proxy) args.push("--proxy", proxy);
387
503
 
388
- const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
504
+ // Truncated rather than appended: the only question this file ever answers is
505
+ // "why did the run I just did fail", and a previous crash above this run's
506
+ // output is how that question gets answered wrong.
507
+ await writeFile(log, "");
508
+ const handle = await open(log, "a");
509
+ let child;
510
+ try {
511
+ child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", handle.fd, handle.fd] });
512
+ } finally {
513
+ // The child holds its own duplicate of the descriptor from spawn onward.
514
+ await handle.close();
515
+ }
516
+
517
+ // `error` covers the spawn itself failing — execPath gone, not executable —
518
+ // which never reaches `exit` at all.
519
+ const died = new Promise((resolve) => {
520
+ child.once("error", (error) => resolve({ reason: error.message }));
521
+ child.once("exit", (code, signal) => resolve({
522
+ reason: signal ? `killed by ${signal}` : `exited ${code} before it could serve`,
523
+ code,
524
+ signal,
525
+ }));
526
+ });
527
+
528
+ let gone = null;
529
+ let verified = false;
530
+ const deadline = Date.now() + readyTimeoutMs;
531
+ while (Date.now() < deadline) {
532
+ gone = await Promise.race([died, sleep(POLL_MS).then(() => null)]);
533
+ if (gone) break;
534
+ if (await probe({ host, port })) {
535
+ verified = true;
536
+ break;
537
+ }
538
+ }
539
+
389
540
  child.unref();
541
+
542
+ if (gone) {
543
+ // No pidfile for a process that is not there. Writing one anyway is what
544
+ // made the next `enable` believe a bridge was running and skip starting one.
545
+ await rm(path, { force: true });
546
+ return {
547
+ started: false,
548
+ alreadyRunning: false,
549
+ pid: null,
550
+ error: gone.reason,
551
+ log: await readLogTail(log),
552
+ logPath: log,
553
+ };
554
+ }
555
+
390
556
  await writeFile(path, `${child.pid}\n`);
391
- return { started: true, pid: child.pid, alreadyRunning: false };
557
+ // `verified: false` is a process that is alive but had not answered by the
558
+ // deadline — a slow registry fetch on a slow link, most often. Reported as
559
+ // what it is rather than rounded up to success or down to failure: killing a
560
+ // bridge that was merely still waking up would be the worse mistake.
561
+ return { started: true, pid: child.pid, alreadyRunning: false, verified, logPath: log };
392
562
  }
393
563
 
394
564
  export async function stopDaemon(path = pidfilePath()) {
package/src/dns.mjs CHANGED
@@ -2892,11 +2892,43 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2892
2892
  // too rather than pinning the answer to one family.
2893
2893
  proxy: proxyAddress ? (proxyAddress.v4 || proxyAddress.v6) : null,
2894
2894
  });
2895
+ // The routing this is about to install is catch-all — every lookup on the
2896
+ // machine, not just Moshpit ones — so a bridge that did not come up is not
2897
+ // a degraded feature, it is the machine's resolver pointed at nothing.
2898
+ // Refused here, before the drop-in is written, because the alternative was
2899
+ // discovering it from a box that could no longer resolve its own package
2900
+ // mirror. Nothing has been changed at this point except the restore point,
2901
+ // which is removed on the way out.
2902
+ if (!started.reused && !started.alreadyRunning && !started.started) {
2903
+ out(` FAIL bridge did not start on ${DEFAULT_HOST}:${wanted} — ${started.error}`);
2904
+ if (started.log) {
2905
+ out("");
2906
+ for (const line of started.log.split("\n")) out(` ${line}`);
2907
+ }
2908
+ out("");
2909
+ out("Refusing to route this machine's DNS at a bridge that is not running.");
2910
+ out("Nothing has been changed.");
2911
+ if (started.logPath) out(` the daemon's output is at ${started.logPath}`);
2912
+ out(` to watch it start in the foreground: moshcode dns start --port ${wanted}`);
2913
+ if (recorded2.ok) await applyPlan({ steps: [{ kind: "remove", path: manifestFile, why: "the switch never happened" }] });
2914
+ return 1;
2915
+ }
2895
2916
  out(started.reused
2896
2917
  ? ` ok using the bridge already on ${DEFAULT_HOST}:${wanted} (pid ${reusing.pid || "?"}) — not starting a second one`
2897
2918
  : started.alreadyRunning
2898
2919
  ? ` ok bridge already running (pid ${started.pid})`
2899
- : ` ok bridge started on ${DEFAULT_HOST}:${wanted} (pid ${started.pid})`);
2920
+ : started.verified === true
2921
+ ? ` ok bridge started on ${DEFAULT_HOST}:${wanted} (pid ${started.pid}) — answering`
2922
+ : ` ok bridge started on ${DEFAULT_HOST}:${wanted} (pid ${started.pid})`);
2923
+ // Alive, but it had not answered a query by the deadline. Said out loud
2924
+ // rather than swallowed: if the routing below fails to verify, this line is
2925
+ // the reason, and it is cheaper to read it here than to derive it later.
2926
+ // Strictly `false`, never merely absent: a starter that does not report on
2927
+ // verification has not failed it, and rounding the two together would print
2928
+ // a warning about every bridge that was started by something else.
2929
+ if (started.started && started.verified === false) {
2930
+ out(` -- it has not answered a query yet — still starting, or it will not serve`);
2931
+ }
2900
2932
 
2901
2933
  const outcome = await applyWith(plan, {
2902
2934
  verify: () => verify({ moshpit: moshpitProbe }),
package/src/help.mjs CHANGED
@@ -449,8 +449,21 @@ export function renderPitCommand(name) {
449
449
  }
450
450
  }
451
451
  const out = [`/${entry.name} — ${entry.description}`];
452
- if (entry.args) out.push("", "usage:", row(`/${entry.name} ${entry.args}`, "", 44));
452
+ // A pit-only verb may write its own synopsis/examples/note, the same shapes
453
+ // renderCommand reads. Without them the args string is the whole usage, which
454
+ // is enough for `/quit` and not enough for anything with sub-verbs.
455
+ const synopsis = entry.synopsis || (entry.args ? [[`/${entry.name} ${entry.args}`, ""]] : []);
456
+ if (synopsis.length) {
457
+ out.push("", "usage:");
458
+ for (const [line, note] of synopsis) out.push(row(line, note, 44));
459
+ }
453
460
  if (entry.aliases?.length) out.push("", `aliases: ${entry.aliases.map((a) => `/${a}`).join(", ")}`);
461
+ const examples = entry.examples || [];
462
+ if (examples.length) {
463
+ out.push("", "examples:");
464
+ for (const [line, note] of examples) out.push(row(line, note ? `# ${note}` : "", 44));
465
+ }
466
+ if (entry.note) out.push("", wrap(entry.note, 0));
454
467
  return out.join("\n");
455
468
  }
456
469