@decentnetwork/lan 0.1.278 → 0.1.280

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.
@@ -95,8 +95,11 @@ export declare class PeerManager extends EventEmitter {
95
95
  sendAppPacket(userid: string, id: number, data: Uint8Array): Promise<void>;
96
96
  /** Send a file INLINE over the message channel (FileModel JSON envelope,
97
97
  * bulkmsg-split) — the only file path native iOS/C Carrier clients can
98
- * receive online. Use for native friends; JS friends keep sendFile(). */
99
- sendInlineFile(userid: string, data: Uint8Array, name: string): Promise<void>;
98
+ * receive online. Use for native friends; JS friends keep sendFile().
99
+ * Returns the SDK's delivery verdict: "acked" is the only outcome that
100
+ * justifies a "sent" checkmark. Older SDKs (< 0.1.140) resolve void —
101
+ * treat that as "accepted" (bytes out, delivery unproven). */
102
+ sendInlineFile(userid: string, data: Uint8Array, name: string): Promise<"acked" | "accepted" | "offline">;
100
103
  /**
101
104
  * Send a WebRTC call-signaling payload to a friend over the Carrier
102
105
  * friend-invite channel (the "carrier" extension), the exact transport the
@@ -186,11 +186,15 @@ export class PeerManager extends EventEmitter {
186
186
  }
187
187
  /** Send a file INLINE over the message channel (FileModel JSON envelope,
188
188
  * bulkmsg-split) — the only file path native iOS/C Carrier clients can
189
- * receive online. Use for native friends; JS friends keep sendFile(). */
189
+ * receive online. Use for native friends; JS friends keep sendFile().
190
+ * Returns the SDK's delivery verdict: "acked" is the only outcome that
191
+ * justifies a "sent" checkmark. Older SDKs (< 0.1.140) resolve void —
192
+ * treat that as "accepted" (bytes out, delivery unproven). */
190
193
  async sendInlineFile(userid, data, name) {
191
194
  if (!this.peer)
192
195
  throw new Error("Peer not created. Call create() first.");
193
- await this.peer.sendInlineFile(userid, { name, data });
196
+ const r = (await this.peer.sendInlineFile(userid, { name, data }));
197
+ return r?.delivery ?? "accepted";
194
198
  }
195
199
  /**
196
200
  * Send a WebRTC call-signaling payload to a friend over the Carrier
@@ -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,28 +564,49 @@ 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
- }
581
- await this.peerManager.sendInlineFile(userid, new Uint8Array(data), name);
582
- this.logger.info(`Sent inline file "${name}" (${data.length}B) to native peer ${userid.slice(0, 8)}`);
583
- this.messageStore?.appendFile(userid, "out", { name: sanitizeFileName(name), size: data.length, status: "sent", sent: data.length });
580
+ // safely under 16MB. Needs BOTH ends on the raised cap.
581
+ // Append the chip as "sending" FIRST, then let the SDK's delivery
582
+ // verdict decide what it becomes. Marking "sent" the moment
583
+ // sendInlineFile resolved was a lie — that only meant the bytes
584
+ // left this machine; a half-open session swallowed them silently
585
+ // while the sender stared at a checkmark (INBOX 2026-08-21 A).
586
+ const safeName = sanitizeFileName(name);
587
+ const chip = this.messageStore?.appendFile(userid, "out", { name: safeName, size: data.length, status: "sending", sent: 0 });
584
588
  this.friendMeta?.ensure(userid);
585
589
  this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
586
- return { inline: true, name, size: data.length };
590
+ let delivery;
591
+ try {
592
+ delivery = await this.peerManager.sendInlineFile(userid, new Uint8Array(data), name);
593
+ }
594
+ catch (e) {
595
+ if (chip)
596
+ this.messageStore?.patchFile(userid, chip.id, { status: "failed" });
597
+ 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 };
587
610
  }
588
611
  // Add the "out" chip immediately as STATUS=sending (not "sent" — the
589
612
  // transfer is reliable+acked, so it only flips to "sent" once the
@@ -862,7 +885,19 @@ export class DaemonServer {
862
885
  const { spawn } = await import("child_process");
863
886
  const argv = process.argv.slice();
864
887
  const node = argv.shift();
865
- this.logger.info(`Self-restart requested via IPC — relaunching ${node} ${argv.join(" ")}`);
888
+ // Under systemd the spawn-a-replacement dance is self-defeating:
889
+ // `detached` does not leave the cgroup, so KillMode=control-group
890
+ // takes the replacement down with us, and exit(0) does not trigger
891
+ // Restart=on-failure — the service ends up permanently down
892
+ // (INBOX 2026-08-22 #2). Detect systemd by the env it stamps on
893
+ // every unit (sudo's env_reset strips these, so an interactive
894
+ // `sudo agentnet up` still takes the spawn path).
895
+ const underSystemd = process.platform === "linux" &&
896
+ Boolean(process.env.INVOCATION_ID || process.env.JOURNAL_STREAM);
897
+ setExitReason("selfRestart via IPC");
898
+ this.logger.info(underSystemd
899
+ ? "Self-restart requested via IPC — running under systemd, will exit non-zero for the supervisor to relaunch"
900
+ : `Self-restart requested via IPC — relaunching ${node} ${argv.join(" ")}`);
866
901
  // Schedule shutdown for after the IPC response goes out. The
867
902
  // 50ms timeout lets the response leave before cleanup closes IPC.
868
903
  setTimeout(() => {
@@ -892,7 +927,7 @@ export class DaemonServer {
892
927
  child.unref();
893
928
  }
894
929
  }
895
- else {
930
+ else if (!underSystemd) {
896
931
  // sh -c so we can shell-quote argv safely and use sleep.
897
932
  // Node's detached flag reparents the relauncher to PID 1.
898
933
  // At this point stop() has released IPC, TUN and the pidfile;
@@ -909,7 +944,10 @@ export class DaemonServer {
909
944
  catch (err) {
910
945
  this.logger.error(`Self-restart spawn failed: ${err instanceof Error ? err.message : err}`);
911
946
  }
912
- setTimeout(() => process.exit(0), 200);
947
+ // Non-zero under systemd so Restart=on-failure (and =always)
948
+ // relaunches us; zero elsewhere — the detached child is the
949
+ // replacement and a supervisor must not also start one.
950
+ setTimeout(() => process.exit(underSystemd ? 70 : 0), 200);
913
951
  })();
914
952
  }, 50);
915
953
  return { scheduledMs: 1500, argv: [node, ...argv] };
@@ -1475,9 +1513,19 @@ export class DaemonServer {
1475
1513
  return;
1476
1514
  this.logger.info("Stopping daemon");
1477
1515
  this.isRunning = false;
1478
- await this.cleanup();
1516
+ // Overall teardown ceiling on top of the per-step bounds in cleanup():
1517
+ // even if every step runs to its own limit, the process must be gone
1518
+ // well before systemd's default TimeoutStopSec=90 SIGKILL.
1519
+ 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`));
1520
+ // The pidfile must go even when the cap cut cleanup() short — a stale
1521
+ // pidfile makes the next start think we are still running.
1522
+ this.removePidFile();
1479
1523
  this.logger.info("Daemon stopped");
1480
1524
  }
1525
+ /** Overall stop() ceiling. Per-step budgets in cleanup() sum to ~17.5s;
1526
+ * this cap and the CLI failsafe sit just above, and all of it stays under
1527
+ * even an aggressive systemd TimeoutStopSec=20 (default is 90). */
1528
+ static STOP_TOTAL_BUDGET_MS = 18_000;
1481
1529
  /**
1482
1530
  * Periodic one-line peer-connection summary at INFO. Between the
1483
1531
  * startup block and the first friend flipping online the log is
@@ -1754,71 +1802,48 @@ export class DaemonServer {
1754
1802
  getDoraIntegration() {
1755
1803
  return this.doraIntegration;
1756
1804
  }
1757
- async cleanup() {
1758
- if (this.statusTimer) {
1759
- clearInterval(this.statusTimer);
1760
- this.statusTimer = undefined;
1761
- }
1762
- // Persist any pending chat/meta writes before we exit.
1763
- this.messageStore?.flush();
1764
- this.friendMeta?.flush();
1805
+ /** One teardown step: bounded in time AND caught, so no component can
1806
+ * hang or break the rest of the teardown (INBOX 2026-08-22 #1). */
1807
+ async stopStep(label, budgetMs, run) {
1765
1808
  try {
1766
- if (this.dnsServer) {
1767
- await this.dnsServer.stop();
1768
- }
1809
+ await boundedStep(run, budgetMs, () => this.logger.warn(`Shutdown step '${label}' exceeded ${budgetMs}ms — continuing without it`));
1769
1810
  }
1770
1811
  catch (e) {
1771
- this.logger.warn("Error stopping DNS server:", e);
1772
- }
1773
- try {
1774
- if (this.ipcServer) {
1775
- await this.ipcServer.stop();
1776
- }
1777
- }
1778
- catch (e) {
1779
- this.logger.warn("Error stopping IPC server:", e);
1780
- }
1781
- try {
1782
- if (this.doraIntegration) {
1783
- this.doraIntegration.stop();
1784
- }
1785
- }
1786
- catch (e) {
1787
- this.logger.warn("Error stopping dora integration:", e);
1788
- }
1789
- try {
1790
- if (this.connectProxy) {
1791
- await this.connectProxy.stop();
1792
- }
1793
- }
1794
- catch (e) {
1795
- this.logger.warn("Error stopping proxy:", e);
1796
- }
1797
- try {
1798
- if (this.packetRouter) {
1799
- await this.packetRouter.stop();
1800
- }
1801
- }
1802
- catch (e) {
1803
- this.logger.warn("Error stopping router:", e);
1812
+ this.logger.warn(`Error in shutdown step '${label}':`, e);
1804
1813
  }
1814
+ }
1815
+ removePidFile() {
1805
1816
  try {
1806
- if (this.peerManager) {
1807
- await this.peerManager.stop();
1817
+ if (this.pidFile && existsSync(this.pidFile)) {
1818
+ unlinkSync(this.pidFile);
1808
1819
  }
1809
1820
  }
1810
- catch (e) {
1811
- this.logger.warn("Error stopping peer:", e);
1812
- }
1813
- try {
1814
- if (this.tunDevice) {
1815
- await this.tunDevice.close();
1816
- }
1821
+ catch {
1822
+ // best-effort; a stale pidfile will be detected on next start
1817
1823
  }
1818
- catch (e) {
1819
- this.logger.warn("Error closing TUN:", e);
1824
+ }
1825
+ async cleanup() {
1826
+ if (this.statusTimer) {
1827
+ clearInterval(this.statusTimer);
1828
+ this.statusTimer = undefined;
1820
1829
  }
1821
- try {
1830
+ // Persist any pending chat/meta writes before we exit.
1831
+ this.messageStore?.flush();
1832
+ this.friendMeta?.flush();
1833
+ // Per-step budgets sum to less than stop()'s overall cap so a single
1834
+ // wedged component costs its own budget, not the whole teardown.
1835
+ await this.stopStep("dns", 1_500, () => this.dnsServer?.stop());
1836
+ await this.stopStep("ipc", 1_500, () => this.ipcServer?.stop());
1837
+ await this.stopStep("dora", 1_000, () => this.doraIntegration?.stop());
1838
+ await this.stopStep("proxy", 1_500, () => this.connectProxy?.stop());
1839
+ await this.stopStep("router", 2_500, () => this.packetRouter?.stop());
1840
+ // Peer.stop() notifies every established friend that this net_crypto
1841
+ // session is going away — over dead relays that can stall indefinitely.
1842
+ // It gets the biggest budget (skipping the notifications entirely leaves
1843
+ // remote peers stuck on our stale session key), but never a blank check.
1844
+ await this.stopStep("peer", 5_000, () => this.peerManager?.stop());
1845
+ await this.stopStep("tun", 2_000, () => this.tunDevice?.close());
1846
+ await this.stopStep("routes", 2_500, async () => {
1822
1847
  if (this.routeManager && !this.useMockTun) {
1823
1848
  // Use the configured name on Linux (where we created the device),
1824
1849
  // or the actual utun name on macOS (no-op cleanup since helper exit
@@ -1830,18 +1855,8 @@ export class DaemonServer {
1830
1855
  this.config.network.ip;
1831
1856
  await this.routeManager.cleanup(ifname, this.config.network.subnet, ip);
1832
1857
  }
1833
- }
1834
- catch (e) {
1835
- this.logger.warn("Error cleaning routes:", e);
1836
- }
1837
- try {
1838
- if (this.pidFile && existsSync(this.pidFile)) {
1839
- unlinkSync(this.pidFile);
1840
- }
1841
- }
1842
- catch {
1843
- // best-effort; a stale pidfile will be detected on next start
1844
- }
1858
+ });
1859
+ this.removePidFile();
1845
1860
  }
1846
1861
  }
1847
1862
  function isProcessAlive(pid) {
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.278";
1
+ window.__DK_UI_VERSION="0.1.280";
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.278",
3
+ "version": "0.1.280",
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.139",
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",