@decentnetwork/lan 0.1.279 → 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.
- package/dist/cli/commands.js +26 -4
- package/dist/daemon/server.d.ts +8 -0
- package/dist/daemon/server.js +76 -84
- package/dist/ui/desktop/app.js +1 -1
- package/dist/utils/bounded-step.d.ts +14 -0
- package/dist/utils/bounded-step.js +33 -0
- package/dist/utils/exit-reason.d.ts +21 -0
- package/dist/utils/exit-reason.js +49 -0
- package/dist/utils/log-throttle.d.ts +6 -0
- package/dist/utils/log-throttle.js +29 -0
- package/package.json +2 -2
package/dist/cli/commands.js
CHANGED
|
@@ -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
|
-
|
|
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}`);
|
package/dist/daemon/server.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/daemon/server.js
CHANGED
|
@@ -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)
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
|
|
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.
|
|
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
|
|
@@ -885,7 +885,19 @@ export class DaemonServer {
|
|
|
885
885
|
const { spawn } = await import("child_process");
|
|
886
886
|
const argv = process.argv.slice();
|
|
887
887
|
const node = argv.shift();
|
|
888
|
-
|
|
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(" ")}`);
|
|
889
901
|
// Schedule shutdown for after the IPC response goes out. The
|
|
890
902
|
// 50ms timeout lets the response leave before cleanup closes IPC.
|
|
891
903
|
setTimeout(() => {
|
|
@@ -915,7 +927,7 @@ export class DaemonServer {
|
|
|
915
927
|
child.unref();
|
|
916
928
|
}
|
|
917
929
|
}
|
|
918
|
-
else {
|
|
930
|
+
else if (!underSystemd) {
|
|
919
931
|
// sh -c so we can shell-quote argv safely and use sleep.
|
|
920
932
|
// Node's detached flag reparents the relauncher to PID 1.
|
|
921
933
|
// At this point stop() has released IPC, TUN and the pidfile;
|
|
@@ -932,7 +944,10 @@ export class DaemonServer {
|
|
|
932
944
|
catch (err) {
|
|
933
945
|
this.logger.error(`Self-restart spawn failed: ${err instanceof Error ? err.message : err}`);
|
|
934
946
|
}
|
|
935
|
-
|
|
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);
|
|
936
951
|
})();
|
|
937
952
|
}, 50);
|
|
938
953
|
return { scheduledMs: 1500, argv: [node, ...argv] };
|
|
@@ -1498,9 +1513,19 @@ export class DaemonServer {
|
|
|
1498
1513
|
return;
|
|
1499
1514
|
this.logger.info("Stopping daemon");
|
|
1500
1515
|
this.isRunning = false;
|
|
1501
|
-
|
|
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();
|
|
1502
1523
|
this.logger.info("Daemon stopped");
|
|
1503
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;
|
|
1504
1529
|
/**
|
|
1505
1530
|
* Periodic one-line peer-connection summary at INFO. Between the
|
|
1506
1531
|
* startup block and the first friend flipping online the log is
|
|
@@ -1777,71 +1802,48 @@ export class DaemonServer {
|
|
|
1777
1802
|
getDoraIntegration() {
|
|
1778
1803
|
return this.doraIntegration;
|
|
1779
1804
|
}
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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) {
|
|
1820
1808
|
try {
|
|
1821
|
-
|
|
1822
|
-
await this.packetRouter.stop();
|
|
1823
|
-
}
|
|
1809
|
+
await boundedStep(run, budgetMs, () => this.logger.warn(`Shutdown step '${label}' exceeded ${budgetMs}ms — continuing without it`));
|
|
1824
1810
|
}
|
|
1825
1811
|
catch (e) {
|
|
1826
|
-
this.logger.warn(
|
|
1812
|
+
this.logger.warn(`Error in shutdown step '${label}':`, e);
|
|
1827
1813
|
}
|
|
1814
|
+
}
|
|
1815
|
+
removePidFile() {
|
|
1828
1816
|
try {
|
|
1829
|
-
if (this.
|
|
1830
|
-
|
|
1817
|
+
if (this.pidFile && existsSync(this.pidFile)) {
|
|
1818
|
+
unlinkSync(this.pidFile);
|
|
1831
1819
|
}
|
|
1832
1820
|
}
|
|
1833
|
-
catch
|
|
1834
|
-
|
|
1835
|
-
}
|
|
1836
|
-
try {
|
|
1837
|
-
if (this.tunDevice) {
|
|
1838
|
-
await this.tunDevice.close();
|
|
1839
|
-
}
|
|
1821
|
+
catch {
|
|
1822
|
+
// best-effort; a stale pidfile will be detected on next start
|
|
1840
1823
|
}
|
|
1841
|
-
|
|
1842
|
-
|
|
1824
|
+
}
|
|
1825
|
+
async cleanup() {
|
|
1826
|
+
if (this.statusTimer) {
|
|
1827
|
+
clearInterval(this.statusTimer);
|
|
1828
|
+
this.statusTimer = undefined;
|
|
1843
1829
|
}
|
|
1844
|
-
|
|
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 () => {
|
|
1845
1847
|
if (this.routeManager && !this.useMockTun) {
|
|
1846
1848
|
// Use the configured name on Linux (where we created the device),
|
|
1847
1849
|
// or the actual utun name on macOS (no-op cleanup since helper exit
|
|
@@ -1853,18 +1855,8 @@ export class DaemonServer {
|
|
|
1853
1855
|
this.config.network.ip;
|
|
1854
1856
|
await this.routeManager.cleanup(ifname, this.config.network.subnet, ip);
|
|
1855
1857
|
}
|
|
1856
|
-
}
|
|
1857
|
-
|
|
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
|
-
}
|
|
1858
|
+
});
|
|
1859
|
+
this.removePidFile();
|
|
1868
1860
|
}
|
|
1869
1861
|
}
|
|
1870
1862
|
function isProcessAlive(pid) {
|
package/dist/ui/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
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.
|
|
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.
|
|
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",
|