@decentnetwork/lan 0.1.297 → 0.1.299
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.
|
@@ -14,10 +14,19 @@ export interface ChatMessage {
|
|
|
14
14
|
ts: number;
|
|
15
15
|
/** Stable per-message id (ts + per-process sequence) for UI keys / dedup. */
|
|
16
16
|
id: string;
|
|
17
|
-
/** Outgoing-text delivery state. "
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
17
|
+
/** Outgoing-text delivery state. "sending" = the background delivery attempt
|
|
18
|
+
* is still running (chat-send returns before the network is consulted).
|
|
19
|
+
* "queued" = that attempt gave up, or the peer was offline when the user hit
|
|
20
|
+
* send; the daemon delivers it (in order) the moment the friend reconnects.
|
|
21
|
+
* Cleared once actually sent.
|
|
22
|
+
*
|
|
23
|
+
* The two states must stay distinct: chat-send used to store the message as
|
|
24
|
+
* "queued" while its own send was in flight, and the reconnect flush sends
|
|
25
|
+
* everything "queued". sendText brings the session up first when it has to,
|
|
26
|
+
* and the session coming up IS the reconnect event — so the flush sent the
|
|
27
|
+
* message, then chat-send sent it again, 10-20 ms apart. Every friend of a
|
|
28
|
+
* daemon saw those messages twice. */
|
|
29
|
+
status?: "sending" | "queued" | "sent" | "failed";
|
|
21
30
|
/** How this message traversed the network, for the UI to distinguish at a
|
|
22
31
|
* glance (different colors). "online" = live net_crypto session (direct/relay);
|
|
23
32
|
* "offline" = express store-and-forward (the friend or we were offline). Lets
|
|
@@ -52,11 +61,11 @@ export declare class MessageStore {
|
|
|
52
61
|
/** Append a message and schedule a flush. Returns the stored message.
|
|
53
62
|
* Pass `status: "queued"` for an outgoing text the peer wasn't online to
|
|
54
63
|
* receive — the daemon flushes it on reconnect. */
|
|
55
|
-
append(peer: string, dir: "in" | "out", text: string, ts?: number, status?: "queued" | "sent" | "failed", via?: "online" | "offline"): ChatMessage;
|
|
64
|
+
append(peer: string, dir: "in" | "out", text: string, ts?: number, status?: "sending" | "queued" | "sent" | "failed", via?: "online" | "offline"): ChatMessage;
|
|
56
65
|
/** Set (or, with undefined, clear) the delivery status on a text message.
|
|
57
66
|
* Used to flip a "queued" message to delivered once it's actually sent.
|
|
58
67
|
* No-op if the id isn't found or is a file entry. Returns true if patched. */
|
|
59
|
-
setStatus(peer: string, id: string, status?: "queued" | "sent" | "failed"): boolean;
|
|
68
|
+
setStatus(peer: string, id: string, status?: "sending" | "queued" | "sent" | "failed"): boolean;
|
|
60
69
|
/** Outgoing messages still awaiting delivery (peer was offline), oldest
|
|
61
70
|
* first — both queued text and queued file chips. The daemon drains this
|
|
62
71
|
* on a friend's reconnect. */
|
|
@@ -33,6 +33,11 @@ export class MessageStore {
|
|
|
33
33
|
let total = 0;
|
|
34
34
|
for (const [peer, msgs] of Object.entries(raw)) {
|
|
35
35
|
if (Array.isArray(msgs)) {
|
|
36
|
+
// A "sending" state cannot survive a restart — the attempt it
|
|
37
|
+
// described died with the process. Requeue so the flush owns it.
|
|
38
|
+
for (const m of msgs)
|
|
39
|
+
if (m.status === "sending")
|
|
40
|
+
m.status = "queued";
|
|
36
41
|
this.byPeer.set(peer, msgs);
|
|
37
42
|
total += msgs.length;
|
|
38
43
|
}
|
package/dist/daemon/server.js
CHANGED
|
@@ -458,14 +458,23 @@ export class DaemonServer {
|
|
|
458
458
|
//
|
|
459
459
|
// Same rule the embedded host and the browser client already follow:
|
|
460
460
|
// a delivery failure is a STATE on the message, never its absence.
|
|
461
|
-
|
|
461
|
+
//
|
|
462
|
+
// Stored as "sending", NOT "queued", while our own attempt runs. The
|
|
463
|
+
// reconnect flush re-sends everything "queued", and sendText brings
|
|
464
|
+
// the session up itself when it has to — which fires the very
|
|
465
|
+
// friend-connection event that triggers the flush. Stored as "queued"
|
|
466
|
+
// the message went out twice, 10-20 ms apart (seen on every daemon
|
|
467
|
+
// sender in ~/.agentnet/messages.json). "queued" now means exactly
|
|
468
|
+
// one thing: nobody is sending this, the flush must.
|
|
469
|
+
const online = !!this.peerManager?.isFriendOnline(userid);
|
|
470
|
+
const msg = this.messageStore?.append(userid, "out", text, Date.now(), online ? "sending" : "queued");
|
|
462
471
|
this.friendMeta?.ensure(userid);
|
|
463
472
|
this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
|
|
464
|
-
if (!msg || !
|
|
473
|
+
if (!msg || !online) {
|
|
465
474
|
this.logger.info(`Queued text for ${userid.slice(0, 8)} (delivers on reconnect)`);
|
|
466
475
|
return;
|
|
467
476
|
}
|
|
468
|
-
// Deliver in the background; the reconnect flush
|
|
477
|
+
// Deliver in the background; on failure hand it to the reconnect flush.
|
|
469
478
|
void (async () => {
|
|
470
479
|
try {
|
|
471
480
|
await this.peerManager.sendText(userid, text);
|
|
@@ -473,7 +482,8 @@ export class DaemonServer {
|
|
|
473
482
|
this.messageStore?.setStatus(userid, msg.id, undefined); // delivered
|
|
474
483
|
}
|
|
475
484
|
catch (e) {
|
|
476
|
-
this.logger.warn(`sendText to ${userid.slice(0, 8)} failed,
|
|
485
|
+
this.logger.warn(`sendText to ${userid.slice(0, 8)} failed, queued for reconnect: ${e.message}`);
|
|
486
|
+
this.messageStore?.setStatus(userid, msg.id, "queued");
|
|
477
487
|
}
|
|
478
488
|
this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
|
|
479
489
|
})();
|
|
@@ -10,6 +10,10 @@ export interface PacedForwarderOptions {
|
|
|
10
10
|
maxFlowBytes?: number;
|
|
11
11
|
/** Max queued bytes across a destination before shed (backstop). */
|
|
12
12
|
maxDestBytes?: number;
|
|
13
|
+
/** Longest a packet may sit queued before it is dropped instead of sent.
|
|
14
|
+
* Bounds LATENCY, which the byte caps cannot: a low-rate flow never fills
|
|
15
|
+
* 256 KB and so was never shed, however long the path had been stalled. */
|
|
16
|
+
maxSojournMs?: number;
|
|
13
17
|
/** Token-bucket burst ceiling as seconds-worth of rate. */
|
|
14
18
|
burstSeconds?: number;
|
|
15
19
|
now?: () => number;
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
const DEFAULT_RATE_BPS = 12_500_000; // 100 Mbps — effectively uncapped until measured
|
|
30
30
|
const DEFAULT_MAX_FLOW_BYTES = 256 * 1024;
|
|
31
31
|
const DEFAULT_MAX_DEST_BYTES = 4 * 1024 * 1024;
|
|
32
|
+
// Generous next to any real mesh RTT (relay paths run in the hundreds of ms),
|
|
33
|
+
// decisive against a stall measured in minutes. A packet older than this has
|
|
34
|
+
// outlived whatever asked for it.
|
|
35
|
+
const DEFAULT_MAX_SOJOURN_MS = 3_000;
|
|
32
36
|
const DEFAULT_BURST_SECONDS = 0.05; // 50ms of rate as burst — smooths without adding latency
|
|
33
37
|
const MIN_RATE_BPS = 32 * 1024; // never pace below 256 kbps
|
|
34
38
|
// LOSS-DRIVEN pacing. The old rule paced to `delivered × headroom`, but the
|
|
@@ -98,6 +102,7 @@ export class PacedForwarder {
|
|
|
98
102
|
send: opts.send,
|
|
99
103
|
defaultRateBps: opts.defaultRateBps ?? DEFAULT_RATE_BPS,
|
|
100
104
|
maxFlowBytes: opts.maxFlowBytes ?? DEFAULT_MAX_FLOW_BYTES,
|
|
105
|
+
maxSojournMs: opts.maxSojournMs ?? DEFAULT_MAX_SOJOURN_MS,
|
|
101
106
|
maxDestBytes: opts.maxDestBytes ?? DEFAULT_MAX_DEST_BYTES,
|
|
102
107
|
burstSeconds: opts.burstSeconds ?? DEFAULT_BURST_SECONDS,
|
|
103
108
|
now: opts.now ?? Date.now,
|
|
@@ -162,7 +167,7 @@ export class PacedForwarder {
|
|
|
162
167
|
}
|
|
163
168
|
let f = d.flows.get(flowKey);
|
|
164
169
|
if (!f) {
|
|
165
|
-
f = { key: flowKey, packets: [], bytes: 0 };
|
|
170
|
+
f = { key: flowKey, packets: [], enqueuedAt: [], bytes: 0 };
|
|
166
171
|
d.flows.set(flowKey, f);
|
|
167
172
|
d.order.push(flowKey);
|
|
168
173
|
}
|
|
@@ -170,6 +175,7 @@ export class PacedForwarder {
|
|
|
170
175
|
// congestion to just that flow's TCP without starving the others.
|
|
171
176
|
if (f.bytes + packet.length > this.#opts.maxFlowBytes) {
|
|
172
177
|
const oldest = f.packets.shift();
|
|
178
|
+
f.enqueuedAt.shift();
|
|
173
179
|
if (oldest) {
|
|
174
180
|
f.bytes -= oldest.length;
|
|
175
181
|
d.totalBytes -= oldest.length;
|
|
@@ -177,6 +183,7 @@ export class PacedForwarder {
|
|
|
177
183
|
}
|
|
178
184
|
}
|
|
179
185
|
f.packets.push(packet);
|
|
186
|
+
f.enqueuedAt.push(this.#opts.now());
|
|
180
187
|
f.bytes += packet.length;
|
|
181
188
|
d.totalBytes += packet.length;
|
|
182
189
|
this.#drain(dstIp);
|
|
@@ -205,6 +212,19 @@ export class PacedForwarder {
|
|
|
205
212
|
d.draining = true;
|
|
206
213
|
this.#pump(dstIp, d);
|
|
207
214
|
}
|
|
215
|
+
/** Discard packets that have waited past maxSojournMs. Returns true when the
|
|
216
|
+
* flow emptied, so the caller can skip it this round. */
|
|
217
|
+
#dropStale(d, f) {
|
|
218
|
+
const cutoff = this.#opts.now() - this.#opts.maxSojournMs;
|
|
219
|
+
while (f.packets.length > 0 && f.enqueuedAt[0] < cutoff) {
|
|
220
|
+
const stale = f.packets.shift();
|
|
221
|
+
f.enqueuedAt.shift();
|
|
222
|
+
f.bytes -= stale.length;
|
|
223
|
+
d.totalBytes -= stale.length;
|
|
224
|
+
this.#dropped++;
|
|
225
|
+
}
|
|
226
|
+
return f.packets.length === 0;
|
|
227
|
+
}
|
|
208
228
|
#pump(dstIp, d) {
|
|
209
229
|
d.draining = false;
|
|
210
230
|
const now = this.#opts.now();
|
|
@@ -237,8 +257,29 @@ export class PacedForwarder {
|
|
|
237
257
|
this.#opts.schedule(() => this.#pump(dstIp, d), ms);
|
|
238
258
|
return;
|
|
239
259
|
}
|
|
260
|
+
// Drop what has waited too long, BEFORE spending a token on it.
|
|
261
|
+
//
|
|
262
|
+
// The queue was bounded by bytes only — 256 KB a flow — which a
|
|
263
|
+
// high-rate flow trips in a moment and a low-rate one never trips at
|
|
264
|
+
// all. Measured 2026-08-31 against a peer on a Thai mobile link: ICMP
|
|
265
|
+
// echo replies arriving with RTTs of 180 to 250 SECONDS, each one
|
|
266
|
+
// exactly 1000ms lower than the last, which is the shape of a backlog
|
|
267
|
+
// flushing after the path came back. At 84 bytes a ping and one a
|
|
268
|
+
// second, filling 256 KB would take fifty minutes; the packets simply
|
|
269
|
+
// sat there. Delivering a four-minute-old packet is worse than dropping
|
|
270
|
+
// it — the sender gave up long ago, and TCP above reads it as a wildly
|
|
271
|
+
// inflated RTT.
|
|
272
|
+
//
|
|
273
|
+
// Bound the SOJOURN TIME, which is what CoDel is about and what a byte
|
|
274
|
+
// cap cannot express.
|
|
275
|
+
if (this.#dropStale(d, f)) {
|
|
276
|
+
d.order.splice(d.cursor % d.order.length, 1);
|
|
277
|
+
d.flows.delete(f.key);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
240
280
|
// Emit one packet from this flow, then advance the cursor (fairness).
|
|
241
281
|
const pkt = f.packets.shift();
|
|
282
|
+
f.enqueuedAt.shift();
|
|
242
283
|
f.bytes -= size;
|
|
243
284
|
d.totalBytes -= size;
|
|
244
285
|
d.tokens -= size;
|
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.299";
|
|
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"/>',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.299",
|
|
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",
|