@decentnetwork/lan 0.1.279 → 0.1.281

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.
@@ -2,7 +2,7 @@
2
2
  * CLI command handlers
3
3
  */
4
4
  import { resolve, dirname, join } from "path";
5
- import { existsSync, mkdirSync, readFileSync, copyFileSync } from "fs";
5
+ import { existsSync, mkdirSync, readFileSync, copyFileSync, writeSync } from "fs";
6
6
  import { createConnection } from "net";
7
7
  import { createRequire } from "module";
8
8
  import { fileURLToPath } from "url";
@@ -18,6 +18,7 @@ import { startMultiExitRouter } from "../proxy/multi-exit-router.js";
18
18
  import { resolveEgressBindIp } from "../proxy/egress.js";
19
19
  import { startFriendUi } from "../ui/server.js";
20
20
  import { installLogThrottle } from "../utils/log-throttle.js";
21
+ import { installExitLogger, setExitReason } from "../utils/exit-reason.js";
21
22
  /**
22
23
  * Refuse to open a second Carrier peer with this identity if the
23
24
  * daemon is already running with the same keypair. Two peers sharing
@@ -465,14 +466,35 @@ export async function cmdUp(args) {
465
466
  configDir: dir,
466
467
  useMockTun: !args.realTun,
467
468
  });
469
+ // Stamp a synchronous banner on every exit (code + which path claimed it).
470
+ // An exit whose banner says UNKNOWN is an unclaimed exit path — the
471
+ // 2026-08-20 incident on gpu-59 could not be attributed for lack of this.
472
+ installExitLogger();
468
473
  // Graceful shutdown
469
- const shutdown = async () => {
474
+ let shuttingDown = false;
475
+ const shutdown = async (signal) => {
476
+ if (shuttingDown)
477
+ return; // systemd can deliver SIGTERM more than once
478
+ shuttingDown = true;
479
+ setExitReason(`signal ${signal}`);
470
480
  console.log("\nShutting down...");
481
+ // Failsafe: if graceful stop wedges past its own internal caps, force the
482
+ // exit ourselves rather than idling until systemd's TimeoutStopSec
483
+ // SIGKILL (which used to make every `systemctl restart agentnet` take
484
+ // 90s). unref'd so the timer never holds an otherwise-finished process.
485
+ const failsafe = setTimeout(() => {
486
+ try {
487
+ writeSync(2, "[agentnet] graceful stop exceeded 20s failsafe — forcing exit\n");
488
+ }
489
+ catch { /* stderr gone */ }
490
+ process.exit(1);
491
+ }, 20_000);
492
+ failsafe.unref();
471
493
  await daemon.stop();
472
494
  process.exit(0);
473
495
  };
474
- process.on("SIGINT", shutdown);
475
- process.on("SIGTERM", shutdown);
496
+ process.on("SIGINT", () => void shutdown("SIGINT"));
497
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
476
498
  await daemon.start();
477
499
  const status = daemon.getStatus();
478
500
  console.log(`Daemon started. Identity: ${status.identity?.address}`);
@@ -102,6 +102,10 @@ export declare class DaemonServer {
102
102
  private isFileSenderWhitelisted;
103
103
  start(): Promise<void>;
104
104
  stop(): Promise<void>;
105
+ /** Overall stop() ceiling. Per-step budgets in cleanup() sum to ~17.5s;
106
+ * this cap and the CLI failsafe sit just above, and all of it stays under
107
+ * even an aggressive systemd TimeoutStopSec=20 (default is 90). */
108
+ static readonly STOP_TOTAL_BUDGET_MS = 18000;
105
109
  /**
106
110
  * Periodic one-line peer-connection summary at INFO. Between the
107
111
  * startup block and the first friend flipping online the log is
@@ -166,5 +170,9 @@ export declare class DaemonServer {
166
170
  getPacketRouter(): PacketRouter | undefined;
167
171
  getConnectProxy(): ConnectProxy | undefined;
168
172
  getDoraIntegration(): DoraIntegration | undefined;
173
+ /** One teardown step: bounded in time AND caught, so no component can
174
+ * hang or break the rest of the teardown (INBOX 2026-08-22 #1). */
175
+ private stopStep;
176
+ private removePidFile;
169
177
  private cleanup;
170
178
  }
@@ -23,6 +23,8 @@ import { PendingFriendsStore } from "./pending-friends.js";
23
23
  import { MessageStore } from "./message-store.js";
24
24
  import { FriendMetaStore } from "./friend-meta.js";
25
25
  import { Logger } from "../utils/logger.js";
26
+ import { boundedStep } from "../utils/bounded-step.js";
27
+ import { setExitReason } from "../utils/exit-reason.js";
26
28
  /**
27
29
  * Quote argv safely for `sh -c`. Wraps single-quotes by closing,
28
30
  * escaping the quote, and reopening — covers paths with spaces and
@@ -562,22 +564,20 @@ export class DaemonServer {
562
564
  this.logger.info(`Queued file "${name}" (${data.length}B) for offline ${userid.slice(0, 8)} (delivers on reconnect)`);
563
565
  return { queued: true, name: safe, size: data.length };
564
566
  }
565
- // NATIVE friends (iOS/Android/C Carrier) cannot see the toxcore
566
- // file-transfer protocol at all — the only online file path they
567
- // receive is the inline FileModel-JSON-over-bulkmsg envelope the
568
- // Beagle apps themselves use. Detected via the DHT-key signature.
569
- if (this.peerManager?.isNativeFriend(userid)) {
567
+ // NATIVE friends (iOS/Android/C Carrier): small files take the inline
568
+ // FileModel-JSON-over-bulkmsg envelope (single blob, the Beagle apps'
569
+ // own fast path); anything bigger falls through to the streaming
570
+ // sendFile below, which peer >= 0.1.141 carries to natives as DNFT1
571
+ // frames inside friend messages (offset acks, FEC, resume — verified
572
+ // 100MB JS→iPad). This gate used to REJECT >11MB outright, which
573
+ // blocked the very path built for large files.
574
+ const INLINE_MAX = 11 * 1024 * 1024;
575
+ if (this.peerManager?.isNativeFriend(userid) && data.length <= INLINE_MAX) {
570
576
  // The FileModel JSON envelope is base64 (inflates ~1.34x) + a little
571
577
  // JSON overhead, and the whole thing must fit the 16MB bulkmsg cap
572
578
  // (CARRIER_MAX_APP_BULKMSG_LEN, raised from 5MB alongside the receive
573
579
  // reorder buffer in peer >= 0.1.87). 11MB raw → ~14.7MB envelope,
574
- // safely under 16MB. Needs BOTH ends on the raised cap. (Very large /
575
- // streaming transfers should use toxcore file transfer, not inline.)
576
- const INLINE_MAX = 11 * 1024 * 1024;
577
- if (data.length > INLINE_MAX) {
578
- throw new Error(`This friend is a native (iOS/Android) client — files are sent inline and limited to ` +
579
- `${(INLINE_MAX / 1024 / 1024).toFixed(1)} MB (this is ${(data.length / 1024 / 1024).toFixed(1)} MB).`);
580
- }
580
+ // safely under 16MB. Needs BOTH ends on the raised cap.
581
581
  // Append the chip as "sending" FIRST, then let the SDK's delivery
582
582
  // verdict decide what it becomes. Marking "sent" the moment
583
583
  // sendInlineFile resolved was a lie — that only meant the bytes
@@ -587,26 +587,30 @@ export class DaemonServer {
587
587
  const chip = this.messageStore?.appendFile(userid, "out", { name: safeName, size: data.length, status: "sending", sent: 0 });
588
588
  this.friendMeta?.ensure(userid);
589
589
  this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
590
- let delivery;
591
- try {
592
- delivery = await this.peerManager.sendInlineFile(userid, new Uint8Array(data), name);
593
- }
594
- catch (e) {
590
+ // Sends are NON-BLOCKING (the 2026-08-05 rule): the delivery wait
591
+ // scales with size (up to 180s), and holding the HTTP request open
592
+ // that long froze the UI on an 8MB inline send. Return now; the
593
+ // verdict patches the chip and the event stream repaints it.
594
+ const bytes = new Uint8Array(data);
595
+ void (async () => {
596
+ let delivery;
597
+ try {
598
+ delivery = await this.peerManager.sendInlineFile(userid, bytes, name);
599
+ }
600
+ catch (e) {
601
+ if (chip)
602
+ this.messageStore?.patchFile(userid, chip.id, { status: "failed" });
603
+ this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
604
+ this.logger.warn(`Inline file "${name}" to native ${userid.slice(0, 8)} failed: ${e.message}`);
605
+ return;
606
+ }
607
+ const status = delivery === "acked" ? "sent" : delivery === "offline" ? "queued" : "failed";
595
608
  if (chip)
596
- this.messageStore?.patchFile(userid, chip.id, { status: "failed" });
609
+ this.messageStore?.patchFile(userid, chip.id, { status, sent: delivery === "acked" ? data.length : 0 });
597
610
  this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
598
- throw e;
599
- }
600
- const status = delivery === "acked" ? "sent" : delivery === "offline" ? "queued" : "failed";
601
- if (chip)
602
- this.messageStore?.patchFile(userid, chip.id, { status, sent: delivery === "acked" ? data.length : 0 });
603
- this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
604
- this.logger.info(`Inline file "${name}" (${data.length}B) to native ${userid.slice(0, 8)}: delivery=${delivery} → ${status}`);
605
- if (delivery === "accepted") {
606
- throw new Error(`The peer never confirmed receiving "${name}" — it likely did not arrive. ` +
607
- `Check that their app is in the foreground and try again.`);
608
- }
609
- return { inline: true, name, size: data.length, delivery };
611
+ this.logger.info(`Inline file "${name}" (${data.length}B) to native ${userid.slice(0, 8)}: delivery=${delivery} → ${status}`);
612
+ })();
613
+ return { inline: true, name, size: data.length, pending: true };
610
614
  }
611
615
  // Add the "out" chip immediately as STATUS=sending (not "sent" — the
612
616
  // transfer is reliable+acked, so it only flips to "sent" once the
@@ -885,7 +889,19 @@ export class DaemonServer {
885
889
  const { spawn } = await import("child_process");
886
890
  const argv = process.argv.slice();
887
891
  const node = argv.shift();
888
- this.logger.info(`Self-restart requested via IPC — relaunching ${node} ${argv.join(" ")}`);
892
+ // Under systemd the spawn-a-replacement dance is self-defeating:
893
+ // `detached` does not leave the cgroup, so KillMode=control-group
894
+ // takes the replacement down with us, and exit(0) does not trigger
895
+ // Restart=on-failure — the service ends up permanently down
896
+ // (INBOX 2026-08-22 #2). Detect systemd by the env it stamps on
897
+ // every unit (sudo's env_reset strips these, so an interactive
898
+ // `sudo agentnet up` still takes the spawn path).
899
+ const underSystemd = process.platform === "linux" &&
900
+ Boolean(process.env.INVOCATION_ID || process.env.JOURNAL_STREAM);
901
+ setExitReason("selfRestart via IPC");
902
+ this.logger.info(underSystemd
903
+ ? "Self-restart requested via IPC — running under systemd, will exit non-zero for the supervisor to relaunch"
904
+ : `Self-restart requested via IPC — relaunching ${node} ${argv.join(" ")}`);
889
905
  // Schedule shutdown for after the IPC response goes out. The
890
906
  // 50ms timeout lets the response leave before cleanup closes IPC.
891
907
  setTimeout(() => {
@@ -915,7 +931,7 @@ export class DaemonServer {
915
931
  child.unref();
916
932
  }
917
933
  }
918
- else {
934
+ else if (!underSystemd) {
919
935
  // sh -c so we can shell-quote argv safely and use sleep.
920
936
  // Node's detached flag reparents the relauncher to PID 1.
921
937
  // At this point stop() has released IPC, TUN and the pidfile;
@@ -932,7 +948,10 @@ export class DaemonServer {
932
948
  catch (err) {
933
949
  this.logger.error(`Self-restart spawn failed: ${err instanceof Error ? err.message : err}`);
934
950
  }
935
- setTimeout(() => process.exit(0), 200);
951
+ // Non-zero under systemd so Restart=on-failure (and =always)
952
+ // relaunches us; zero elsewhere — the detached child is the
953
+ // replacement and a supervisor must not also start one.
954
+ setTimeout(() => process.exit(underSystemd ? 70 : 0), 200);
936
955
  })();
937
956
  }, 50);
938
957
  return { scheduledMs: 1500, argv: [node, ...argv] };
@@ -1498,9 +1517,19 @@ export class DaemonServer {
1498
1517
  return;
1499
1518
  this.logger.info("Stopping daemon");
1500
1519
  this.isRunning = false;
1501
- await this.cleanup();
1520
+ // Overall teardown ceiling on top of the per-step bounds in cleanup():
1521
+ // even if every step runs to its own limit, the process must be gone
1522
+ // well before systemd's default TimeoutStopSec=90 SIGKILL.
1523
+ await boundedStep(() => this.cleanup(), DaemonServer.STOP_TOTAL_BUDGET_MS, () => this.logger.error(`Shutdown exceeded ${DaemonServer.STOP_TOTAL_BUDGET_MS}ms overall cap — exiting with cleanup incomplete`));
1524
+ // The pidfile must go even when the cap cut cleanup() short — a stale
1525
+ // pidfile makes the next start think we are still running.
1526
+ this.removePidFile();
1502
1527
  this.logger.info("Daemon stopped");
1503
1528
  }
1529
+ /** Overall stop() ceiling. Per-step budgets in cleanup() sum to ~17.5s;
1530
+ * this cap and the CLI failsafe sit just above, and all of it stays under
1531
+ * even an aggressive systemd TimeoutStopSec=20 (default is 90). */
1532
+ static STOP_TOTAL_BUDGET_MS = 18_000;
1504
1533
  /**
1505
1534
  * Periodic one-line peer-connection summary at INFO. Between the
1506
1535
  * startup block and the first friend flipping online the log is
@@ -1777,71 +1806,48 @@ export class DaemonServer {
1777
1806
  getDoraIntegration() {
1778
1807
  return this.doraIntegration;
1779
1808
  }
1780
- async cleanup() {
1781
- if (this.statusTimer) {
1782
- clearInterval(this.statusTimer);
1783
- this.statusTimer = undefined;
1784
- }
1785
- // Persist any pending chat/meta writes before we exit.
1786
- this.messageStore?.flush();
1787
- this.friendMeta?.flush();
1788
- try {
1789
- if (this.dnsServer) {
1790
- await this.dnsServer.stop();
1791
- }
1792
- }
1793
- catch (e) {
1794
- this.logger.warn("Error stopping DNS server:", e);
1795
- }
1796
- try {
1797
- if (this.ipcServer) {
1798
- await this.ipcServer.stop();
1799
- }
1800
- }
1801
- catch (e) {
1802
- this.logger.warn("Error stopping IPC server:", e);
1803
- }
1804
- try {
1805
- if (this.doraIntegration) {
1806
- this.doraIntegration.stop();
1807
- }
1808
- }
1809
- catch (e) {
1810
- this.logger.warn("Error stopping dora integration:", e);
1811
- }
1809
+ /** One teardown step: bounded in time AND caught, so no component can
1810
+ * hang or break the rest of the teardown (INBOX 2026-08-22 #1). */
1811
+ async stopStep(label, budgetMs, run) {
1812
1812
  try {
1813
- if (this.connectProxy) {
1814
- await this.connectProxy.stop();
1815
- }
1816
- }
1817
- catch (e) {
1818
- this.logger.warn("Error stopping proxy:", e);
1819
- }
1820
- try {
1821
- if (this.packetRouter) {
1822
- await this.packetRouter.stop();
1823
- }
1813
+ await boundedStep(run, budgetMs, () => this.logger.warn(`Shutdown step '${label}' exceeded ${budgetMs}ms — continuing without it`));
1824
1814
  }
1825
1815
  catch (e) {
1826
- this.logger.warn("Error stopping router:", e);
1816
+ this.logger.warn(`Error in shutdown step '${label}':`, e);
1827
1817
  }
1818
+ }
1819
+ removePidFile() {
1828
1820
  try {
1829
- if (this.peerManager) {
1830
- await this.peerManager.stop();
1821
+ if (this.pidFile && existsSync(this.pidFile)) {
1822
+ unlinkSync(this.pidFile);
1831
1823
  }
1832
1824
  }
1833
- catch (e) {
1834
- this.logger.warn("Error stopping peer:", e);
1835
- }
1836
- try {
1837
- if (this.tunDevice) {
1838
- await this.tunDevice.close();
1839
- }
1825
+ catch {
1826
+ // best-effort; a stale pidfile will be detected on next start
1840
1827
  }
1841
- catch (e) {
1842
- this.logger.warn("Error closing TUN:", e);
1828
+ }
1829
+ async cleanup() {
1830
+ if (this.statusTimer) {
1831
+ clearInterval(this.statusTimer);
1832
+ this.statusTimer = undefined;
1843
1833
  }
1844
- try {
1834
+ // Persist any pending chat/meta writes before we exit.
1835
+ this.messageStore?.flush();
1836
+ this.friendMeta?.flush();
1837
+ // Per-step budgets sum to less than stop()'s overall cap so a single
1838
+ // wedged component costs its own budget, not the whole teardown.
1839
+ await this.stopStep("dns", 1_500, () => this.dnsServer?.stop());
1840
+ await this.stopStep("ipc", 1_500, () => this.ipcServer?.stop());
1841
+ await this.stopStep("dora", 1_000, () => this.doraIntegration?.stop());
1842
+ await this.stopStep("proxy", 1_500, () => this.connectProxy?.stop());
1843
+ await this.stopStep("router", 2_500, () => this.packetRouter?.stop());
1844
+ // Peer.stop() notifies every established friend that this net_crypto
1845
+ // session is going away — over dead relays that can stall indefinitely.
1846
+ // It gets the biggest budget (skipping the notifications entirely leaves
1847
+ // remote peers stuck on our stale session key), but never a blank check.
1848
+ await this.stopStep("peer", 5_000, () => this.peerManager?.stop());
1849
+ await this.stopStep("tun", 2_000, () => this.tunDevice?.close());
1850
+ await this.stopStep("routes", 2_500, async () => {
1845
1851
  if (this.routeManager && !this.useMockTun) {
1846
1852
  // Use the configured name on Linux (where we created the device),
1847
1853
  // or the actual utun name on macOS (no-op cleanup since helper exit
@@ -1853,18 +1859,8 @@ export class DaemonServer {
1853
1859
  this.config.network.ip;
1854
1860
  await this.routeManager.cleanup(ifname, this.config.network.subnet, ip);
1855
1861
  }
1856
- }
1857
- catch (e) {
1858
- this.logger.warn("Error cleaning routes:", e);
1859
- }
1860
- try {
1861
- if (this.pidFile && existsSync(this.pidFile)) {
1862
- unlinkSync(this.pidFile);
1863
- }
1864
- }
1865
- catch {
1866
- // best-effort; a stale pidfile will be detected on next start
1867
- }
1862
+ });
1863
+ this.removePidFile();
1868
1864
  }
1869
1865
  }
1870
1866
  function isProcessAlive(pid) {
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.279";
1
+ window.__DK_UI_VERSION="0.1.281";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Run one shutdown step with an upper time bound.
3
+ *
4
+ * Teardown must never hang: a wedged component (Peer.stop() flushing KILL
5
+ * notifications to 21 friends over dead relays, `ip tuntap del` returning
6
+ * EBUSY in a retry loop) would otherwise hold the whole exit hostage until
7
+ * systemd's TimeoutStopSec SIGKILLs us at 90s (INBOX 2026-08-22 #1).
8
+ *
9
+ * On overrun the returned promise resolves and `onOverrun` fires; the step
10
+ * itself keeps running detached — we are exiting anyway, and cancelling
11
+ * arbitrary component stops is not safely possible. Errors from the step
12
+ * reject as usual (callers keep their per-step try/catch).
13
+ */
14
+ export declare function boundedStep(run: () => void | Promise<void>, ms: number, onOverrun: () => void): Promise<void>;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Run one shutdown step with an upper time bound.
3
+ *
4
+ * Teardown must never hang: a wedged component (Peer.stop() flushing KILL
5
+ * notifications to 21 friends over dead relays, `ip tuntap del` returning
6
+ * EBUSY in a retry loop) would otherwise hold the whole exit hostage until
7
+ * systemd's TimeoutStopSec SIGKILLs us at 90s (INBOX 2026-08-22 #1).
8
+ *
9
+ * On overrun the returned promise resolves and `onOverrun` fires; the step
10
+ * itself keeps running detached — we are exiting anyway, and cancelling
11
+ * arbitrary component stops is not safely possible. Errors from the step
12
+ * reject as usual (callers keep their per-step try/catch).
13
+ */
14
+ export async function boundedStep(run, ms, onOverrun) {
15
+ let timer;
16
+ try {
17
+ await Promise.race([
18
+ Promise.resolve().then(run),
19
+ new Promise((resolve) => {
20
+ timer = setTimeout(() => {
21
+ onOverrun();
22
+ resolve();
23
+ }, ms);
24
+ // Must not keep an otherwise-drained event loop alive.
25
+ timer.unref?.();
26
+ }),
27
+ ]);
28
+ }
29
+ finally {
30
+ if (timer)
31
+ clearTimeout(timer);
32
+ }
33
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Exit-path forensics for the daemon.
3
+ *
4
+ * Every legitimate shutdown path (signal handler, IPC selfRestart, failsafe)
5
+ * claims the exit by calling setExitReason() first. installExitLogger() then
6
+ * stamps a one-line banner on EVERY process exit — synchronously, via
7
+ * writeSync, so it cannot be lost to stream buffering the way async console
8
+ * writes can. An exit whose banner says "UNKNOWN" is the smoking gun for an
9
+ * unclaimed exit path (INBOX 2026-08-22 #3: a daemon died with no shutdown
10
+ * banner and we could not tell which path it took).
11
+ *
12
+ * beforeExit is also instrumented: it fires only when the event loop drains
13
+ * naturally — i.e. every handle (IPC socket, TUN fd, timers) has closed and
14
+ * Node is about to exit 0 on its own. For a daemon that should run forever,
15
+ * that is always a bug worth a log line.
16
+ */
17
+ /** Record why the process is about to exit. First caller wins — a failsafe
18
+ * firing after a signal handler must not overwrite the original cause. */
19
+ export declare function setExitReason(r: string): void;
20
+ export declare function getExitReason(): string | null;
21
+ export declare function installExitLogger(): void;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Exit-path forensics for the daemon.
3
+ *
4
+ * Every legitimate shutdown path (signal handler, IPC selfRestart, failsafe)
5
+ * claims the exit by calling setExitReason() first. installExitLogger() then
6
+ * stamps a one-line banner on EVERY process exit — synchronously, via
7
+ * writeSync, so it cannot be lost to stream buffering the way async console
8
+ * writes can. An exit whose banner says "UNKNOWN" is the smoking gun for an
9
+ * unclaimed exit path (INBOX 2026-08-22 #3: a daemon died with no shutdown
10
+ * banner and we could not tell which path it took).
11
+ *
12
+ * beforeExit is also instrumented: it fires only when the event loop drains
13
+ * naturally — i.e. every handle (IPC socket, TUN fd, timers) has closed and
14
+ * Node is about to exit 0 on its own. For a daemon that should run forever,
15
+ * that is always a bug worth a log line.
16
+ */
17
+ import { writeSync } from "node:fs";
18
+ let reason = null;
19
+ let installed = false;
20
+ /** Record why the process is about to exit. First caller wins — a failsafe
21
+ * firing after a signal handler must not overwrite the original cause. */
22
+ export function setExitReason(r) {
23
+ if (reason === null)
24
+ reason = r;
25
+ }
26
+ export function getExitReason() {
27
+ return reason;
28
+ }
29
+ export function installExitLogger() {
30
+ if (installed)
31
+ return;
32
+ installed = true;
33
+ process.on("beforeExit", (code) => {
34
+ try {
35
+ writeSync(2, `[agentnet] event loop drained (beforeExit, code=${code}) — no handle is keeping the daemon alive\n`);
36
+ }
37
+ catch {
38
+ /* stderr gone; nothing else to do */
39
+ }
40
+ });
41
+ process.on("exit", (code) => {
42
+ try {
43
+ writeSync(2, `[agentnet] process exit code=${code} reason=${reason ?? "UNKNOWN (no shutdown path claimed this exit)"}\n`);
44
+ }
45
+ catch {
46
+ /* stderr gone; nothing else to do */
47
+ }
48
+ });
49
+ }
@@ -24,6 +24,12 @@ export interface LogThrottleOptions {
24
24
  windowMs?: number;
25
25
  /** Distinct shapes tracked at once (guards the map itself from a leak). */
26
26
  maxShapes?: number;
27
+ /** Where the at-exit suppressed-count summary is written (default: a
28
+ * synchronous write to stdout, so it survives process.exit). Test seam. */
29
+ exitFlushSink?: (line: string) => void;
30
+ /** How the at-exit flush is registered (default: process.on("exit")).
31
+ * Test seam — lets tests trigger the hook without emitting real exit. */
32
+ registerExitHook?: (flush: () => void) => () => void;
27
33
  }
28
34
  /** Collapse a message to its shape: strip ids, IPs, numbers and durations so
29
35
  * the same event about different peers shares one bucket. */
@@ -17,6 +17,7 @@
17
17
  * go quiet; when the window closes, print a single "(… ×N suppressed)" line so
18
18
  * the volume is still visible. Nothing is dropped silently.
19
19
  */
20
+ import { writeSync } from "node:fs";
20
21
  /** Collapse a message to its shape: strip ids, IPs, numbers and durations so
21
22
  * the same event about different peers shares one bucket. */
22
23
  export function logShape(message) {
@@ -38,6 +39,20 @@ export function installLogThrottle(opts = {}) {
38
39
  const burst = opts.burst ?? 5;
39
40
  const windowMs = opts.windowMs ?? 60_000;
40
41
  const maxShapes = opts.maxShapes ?? 500;
42
+ const exitFlushSink = opts.exitFlushSink ??
43
+ ((line) => {
44
+ try {
45
+ writeSync(1, line);
46
+ }
47
+ catch {
48
+ /* stdout gone; nothing else to do */
49
+ }
50
+ });
51
+ const registerExitHook = opts.registerExitHook ??
52
+ ((flush) => {
53
+ process.on("exit", flush);
54
+ return () => process.removeListener("exit", flush);
55
+ });
41
56
  const shapes = new Map();
42
57
  const original = {
43
58
  log: console.log.bind(console),
@@ -95,7 +110,21 @@ export function installLogThrottle(opts = {}) {
95
110
  console.warn = wrap(original.warn);
96
111
  // Errors are rare and each one matters — never suppress them.
97
112
  console.error = original.error;
113
+ // Suppressed counts are normally reported when the shape's window rolls
114
+ // over — but a shape whose window never closes again (the process exits
115
+ // first) would vanish silently, breaking the "nothing is dropped silently"
116
+ // contract exactly when the log matters most (INBOX 2026-08-22 #3).
117
+ const flushAtExit = () => {
118
+ for (const st of shapes.values()) {
119
+ if (st.suppressed > 0) {
120
+ exitFlushSink(`… ${st.suppressed} more like this suppressed (flushed at exit): ${st.sample.slice(0, 160)}\n`);
121
+ st.suppressed = 0;
122
+ }
123
+ }
124
+ };
125
+ const unregisterExitHook = registerExitHook(flushAtExit);
98
126
  return () => {
127
+ unregisterExitHook();
99
128
  console.log = original.log;
100
129
  console.debug = original.debug;
101
130
  console.info = original.info;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.279",
3
+ "version": "0.1.281",
4
4
  "description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -84,7 +84,7 @@
84
84
  },
85
85
  "dependencies": {
86
86
  "@decentnetwork/dora": "^0.1.14",
87
- "@decentnetwork/peer": "^0.1.140",
87
+ "@decentnetwork/peer": "^0.1.141",
88
88
  "@decentnetwork/peer-webrtc": "^0.2.10",
89
89
  "ink": "^5.2.1",
90
90
  "js-yaml": "^4.1.0",