@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.
- package/dist/carrier/peer-manager.d.ts +5 -2
- package/dist/carrier/peer-manager.js +6 -2
- package/dist/cli/commands.js +26 -4
- package/dist/daemon/server.d.ts +8 -0
- package/dist/daemon/server.js +103 -88
- 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
|
@@ -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
|
-
|
|
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
|
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,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)
|
|
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
|
-
//
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
1807
|
-
|
|
1817
|
+
if (this.pidFile && existsSync(this.pidFile)) {
|
|
1818
|
+
unlinkSync(this.pidFile);
|
|
1808
1819
|
}
|
|
1809
1820
|
}
|
|
1810
|
-
catch
|
|
1811
|
-
|
|
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
|
-
|
|
1819
|
-
|
|
1824
|
+
}
|
|
1825
|
+
async cleanup() {
|
|
1826
|
+
if (this.statusTimer) {
|
|
1827
|
+
clearInterval(this.statusTimer);
|
|
1828
|
+
this.statusTimer = undefined;
|
|
1820
1829
|
}
|
|
1821
|
-
|
|
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
|
-
|
|
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) {
|
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",
|