@decentnetwork/lan 0.1.190 → 0.1.192

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.
Binary file
Binary file
Binary file
Binary file
@@ -49,6 +49,19 @@ export interface MultiExitRouterOptions {
49
49
  log?: (msg: string) => void;
50
50
  }
51
51
  export declare const BUILTIN_REGION_DOMAINS: Record<string, string[]>;
52
+ export declare function isUsefulRecoverySample(peakBps: number, averageBps: number, minBps?: number): boolean;
53
+ interface HlsExitScore {
54
+ speedEwma: number | null;
55
+ flooredAtMs: number | null;
56
+ }
57
+ /** Choose exits safe for a foreground HLS fetch. Once any proven-fast exit is
58
+ * available, unproven/redeeming exits cannot borrow its score and displace it.
59
+ * With no proven exit, unproven exits remain the startup/failover path; if every
60
+ * exit is degraded, keep the original pool as a last resort. Exported for the
61
+ * policy regression tests. */
62
+ export declare function selectHlsPrimaryPool<T extends HlsExitScore>(pool: T[]): T[];
63
+ /** Dynamic prefetch concurrency follows only the foreground-safe pool. */
64
+ export declare function countHlsDeliveryExits<T extends HlsExitScore>(pool: T[]): number;
52
65
  /**
53
66
  * Start the multi-exit router. Returns a stop() that closes the listener.
54
67
  * Runs until stopped (the CLI command keeps the process alive).
@@ -56,3 +69,4 @@ export declare const BUILTIN_REGION_DOMAINS: Record<string, string[]>;
56
69
  export declare function startMultiExitRouter(opts: MultiExitRouterOptions): {
57
70
  stop: () => void;
58
71
  };
72
+ export {};
@@ -149,6 +149,16 @@ const NEVER_LEARN = /(^|\.)(google|gstatic|googleapis|googleusercontent|ggpht|gv
149
149
  // (≈0.5 Mbit/s) keeps every usable exit in and sits safely ABOVE the 40 KB/s
150
150
  // deprioritize floor so a floored exit still drops out.
151
151
  const NEAR_DEAD_EXIT_BPS = 60_000;
152
+ // Foreground HLS needs a materially stronger exit than ordinary browsing.
153
+ // CCTV's 720p variant is 1.8 Mbit/s; an exit below ~1.2 Mbit/s cannot deliver
154
+ // one four-second segment quickly enough to be useful even with a small live
155
+ // buffer. Keep such exits available for last-resort failover, but do not count
156
+ // them as HLS delivery capacity or let a slow redemption probe re-enable them.
157
+ // This is bytes/sec (150 KB/s = 1.2 Mbit/s).
158
+ const HLS_DELIVERY_BPS = 150_000;
159
+ export function isUsefulRecoverySample(peakBps, averageBps, minBps = HLS_DELIVERY_BPS) {
160
+ return peakBps >= minBps && averageBps >= minBps;
161
+ }
152
162
  // A buffered segment fetch is given a deadline sized to deliver its body at
153
163
  // roughly this bitrate; an exit that can't keep up is abandoned and the next is
154
164
  // tried. Live HLS needs each ~4s segment in a few seconds, so this is set near
@@ -206,6 +216,10 @@ class RateMeter {
206
216
  * segments — but KEEP it in the pool (last-resort failover, small requests). */
207
217
  const FLOOR_BPS = 40_000;
208
218
  const REDEMPTION_MS = 90_000;
219
+ // Redemption probes are intentionally serialized and spaced out. A degraded
220
+ // exit is tested with a duplicate background segment while the player's normal
221
+ // fetch remains on proven exits, so recovery discovery cannot stall playback.
222
+ const REDEMPTION_PROBE_GAP_MS = 15_000;
209
223
  function floorExit(exit) {
210
224
  exit.speedEwma = exit.speedEwma === null ? FLOOR_BPS : Math.min(exit.speedEwma, FLOOR_BPS);
211
225
  exit.flooredAtMs = Date.now();
@@ -213,15 +227,53 @@ function floorExit(exit) {
213
227
  /** Feed one tunnel's rate meter into the exit's speed EWMA (peak-window based).
214
228
  * Only transfers past SPEED_MIN_BYTES count — smaller ones never reveal path
215
229
  * capacity on a high-RTT link. */
216
- function sampleSpeed(exit, meter, durMs) {
230
+ function sampleSpeed(exit, meter, durMs, recoveryMinBps = NEAR_DEAD_EXIT_BPS) {
217
231
  exit.bytesDown += meter.total;
218
232
  if (meter.total < SPEED_MIN_BYTES)
219
233
  return;
220
234
  const bps = meter.peakBps(durMs);
221
235
  if (bps <= 0)
222
236
  return;
237
+ if (exit.flooredAtMs !== null) {
238
+ // FLOOR_BPS is a penalty, not a real historical sample. A qualifying probe
239
+ // replaces it with the fresh measurement instead of blending the floor into
240
+ // the result. Only a useful-speed transfer proves recovery; a completed but
241
+ // still-too-slow probe remains floored for another redemption interval.
242
+ // Peak-window speed is useful for ranking paths, but HLS redemption must
243
+ // also prove whole-segment average throughput. A probe observed at a 0.9
244
+ // Mbit/s peak but only 0.6 Mbit/s average caused six-way prefetch/hedge
245
+ // congestion when it rejoined a 1.8 Mbit/s stream.
246
+ const averageBps = durMs > 0 ? (meter.total / durMs) * 1000 : 0;
247
+ if (isUsefulRecoverySample(bps, averageBps, recoveryMinBps)) {
248
+ exit.speedEwma = bps;
249
+ exit.flooredAtMs = null;
250
+ }
251
+ else {
252
+ exit.speedEwma = FLOOR_BPS;
253
+ exit.flooredAtMs = Date.now();
254
+ }
255
+ return;
256
+ }
223
257
  exit.speedEwma = exit.speedEwma === null ? bps : SPEED_EWMA_KEEP * exit.speedEwma + (1 - SPEED_EWMA_KEEP) * bps;
224
- exit.flooredAtMs = null; // a real qualifying transfer is proof of recovery
258
+ }
259
+ /** Choose exits safe for a foreground HLS fetch. Once any proven-fast exit is
260
+ * available, unproven/redeeming exits cannot borrow its score and displace it.
261
+ * With no proven exit, unproven exits remain the startup/failover path; if every
262
+ * exit is degraded, keep the original pool as a last resort. Exported for the
263
+ * policy regression tests. */
264
+ export function selectHlsPrimaryPool(pool) {
265
+ const proven = pool.filter((e) => e.flooredAtMs === null && e.speedEwma !== null && e.speedEwma >= HLS_DELIVERY_BPS);
266
+ if (proven.length > 0)
267
+ return proven;
268
+ const unproven = pool.filter((e) => e.flooredAtMs === null && e.speedEwma === null);
269
+ return unproven.length > 0 ? unproven : pool;
270
+ }
271
+ /** Dynamic prefetch concurrency follows only the foreground-safe pool. */
272
+ export function countHlsDeliveryExits(pool) {
273
+ // Two adjacent live segments in flight keep the buffer moving without the
274
+ // runaway 6-8 request fan-out seen when every marginal exit increased both
275
+ // prefetch concurrency and whole-segment hedging.
276
+ return Math.min(2, Math.max(1, selectHlsPrimaryPool(pool).length));
225
277
  }
226
278
  function fmtMbps(bps) {
227
279
  return bps === null ? "?" : `${((bps * 8) / 1e6).toFixed(1)}Mbps`;
@@ -1042,7 +1094,41 @@ export function startMultiExitRouter(opts) {
1042
1094
  }
1043
1095
  // ---- HLS segment prefetch (plain-HTTP streams only) ------------------------
1044
1096
  // Fetch one URL through the region's exit pool, buffered, with failover.
1045
- function fetchViaRegionBuffered(u, extraHeaders, label, signal, exitHint) {
1097
+ let redemptionProbeInFlight = false;
1098
+ let lastRedemptionProbeMs = 0;
1099
+ function maybeStartRedemptionProbe(u, extraHeaders, region) {
1100
+ const now = Date.now();
1101
+ if (redemptionProbeInFlight || now - lastRedemptionProbeMs < REDEMPTION_PROBE_GAP_MS)
1102
+ return;
1103
+ const candidate = exits
1104
+ .filter((e) => e.region === region && e.flooredAtMs !== null &&
1105
+ now - e.flooredAtMs >= REDEMPTION_MS && e.trippedUntil <= now &&
1106
+ (e.healthy || (e.lastSuccessMs !== null && now - e.lastSuccessMs < RECENT_SUCCESS_MS)))
1107
+ .sort((a, b) => (a.flooredAtMs ?? now) - (b.flooredAtMs ?? now))[0];
1108
+ if (!candidate)
1109
+ return;
1110
+ redemptionProbeInFlight = true;
1111
+ lastRedemptionProbeMs = now;
1112
+ // Re-arm before dispatch so a failed/short probe cannot be started again by
1113
+ // every concurrent playlist refresh.
1114
+ candidate.flooredAtMs = now;
1115
+ log(` redemption-probe ${candidate.name}: isolated background segment test`);
1116
+ void fetchViaRegionBuffered(u, extraHeaders, "redemption-probe", undefined, undefined, candidate)
1117
+ .then(() => {
1118
+ if (candidate.flooredAtMs === null) {
1119
+ log(` redemption-probe ${candidate.name}: RECOVERED at ${fmtMbps(candidate.speedEwma)}`);
1120
+ }
1121
+ else {
1122
+ log(` redemption-probe ${candidate.name}: still degraded — keeping it off foreground HLS`);
1123
+ }
1124
+ })
1125
+ .catch((e) => {
1126
+ floorExit(candidate);
1127
+ log(` redemption-probe ${candidate.name}: failed (${e.message}) — keeping it off foreground HLS`);
1128
+ })
1129
+ .finally(() => { redemptionProbeInFlight = false; });
1130
+ }
1131
+ function fetchViaRegionBuffered(u, extraHeaders, label, signal, exitHint, forcedExit) {
1046
1132
  return new Promise((resolvep, rejectp) => {
1047
1133
  let settled = false;
1048
1134
  let curReq = null;
@@ -1077,7 +1163,9 @@ export function startMultiExitRouter(opts) {
1077
1163
  reject(new Error("no region for host"));
1078
1164
  return;
1079
1165
  }
1080
- const base0 = pickOrder(region);
1166
+ if (!forcedExit && label === "prefetch")
1167
+ maybeStartRedemptionProbe(u, extraHeaders, region);
1168
+ const base0 = forcedExit ? [forcedExit] : pickOrder(region);
1081
1169
  if (base0.length === 0) {
1082
1170
  reject(new Error("no healthy exit"));
1083
1171
  return;
@@ -1092,7 +1180,10 @@ export function startMultiExitRouter(opts) {
1092
1180
  // moderately-slow ones (0.4–0.6 Mbps) that we WANT for aggregation. A
1093
1181
  // single (non-striped) fetch instead skips near-dead exits and rotates.
1094
1182
  let order;
1095
- if (exitHint !== undefined) {
1183
+ if (forcedExit) {
1184
+ order = base0;
1185
+ }
1186
+ else if (exitHint !== undefined) {
1096
1187
  // Striped range: use pickOrder's live LOAD-AWARE order directly (base0 is
1097
1188
  // sorted by speed/(active+1), least-loaded-fastest first). Because each
1098
1189
  // buffered fetch now increments exit.active at connect time, concurrent
@@ -1110,8 +1201,7 @@ export function startMultiExitRouter(opts) {
1110
1201
  // splitting one exit's bandwidth N ways (observed: 3 segments all on
1111
1202
  // 1.15 at ~1Mbps each while 1.16 sat idle). A blind RR rotation kept
1112
1203
  // colliding because it ignored who was actually busy.
1113
- const fast = base0.filter((e) => e.speedEwma === null || e.speedEwma >= NEAR_DEAD_EXIT_BPS);
1114
- order = fast.length > 0 ? fast : base0;
1204
+ order = selectHlsPrimaryPool(base0);
1115
1205
  }
1116
1206
  const path = (u.pathname || "/") + (u.search || "");
1117
1207
  const tail = (u.pathname || "/").split("/").pop() || path;
@@ -1123,7 +1213,8 @@ export function startMultiExitRouter(opts) {
1123
1213
  // bad luck; a fresh retry usually succeeds. Loop the whole exit order a
1124
1214
  // few rounds before giving up, so the player almost never sees a hard
1125
1215
  // failure (which trips its error watchdog → error.html → black screen).
1126
- if (round + 1 < MAX_FETCH_ROUNDS) {
1216
+ const maxRounds = forcedExit ? 1 : MAX_FETCH_ROUNDS;
1217
+ if (round + 1 < maxRounds) {
1127
1218
  const t = setTimeout(() => tryIdx(0, round + 1), 150);
1128
1219
  t.unref?.();
1129
1220
  return;
@@ -1201,7 +1292,7 @@ export function startMultiExitRouter(opts) {
1201
1292
  nextExit(`empty ${status}`);
1202
1293
  return;
1203
1294
  }
1204
- sampleSpeed(exit, meter, durMs);
1295
+ sampleSpeed(exit, meter, durMs, HLS_DELIVERY_BPS);
1205
1296
  if (label) {
1206
1297
  const kb = (body.length / 1024).toFixed(0);
1207
1298
  const mbps = durMs > 0 ? ((body.length * 8) / durMs / 1000).toFixed(1) : "?";
@@ -1268,10 +1359,9 @@ export function startMultiExitRouter(opts) {
1268
1359
  if (!fallbackRegion)
1269
1360
  return 1;
1270
1361
  const now = Date.now();
1271
- const n = exits.filter((e) => e.region === fallbackRegion && e.trippedUntil <= now &&
1272
- (e.healthy || (e.lastSuccessMs !== null && now - e.lastSuccessMs < RECENT_SUCCESS_MS)) &&
1273
- (e.speedEwma === null || e.speedEwma >= NEAR_DEAD_EXIT_BPS)).length;
1274
- return Math.max(1, n);
1362
+ const eligible = exits.filter((e) => e.region === fallbackRegion && e.trippedUntil <= now &&
1363
+ (e.healthy || (e.lastSuccessMs !== null && now - e.lastSuccessMs < RECENT_SUCCESS_MS)));
1364
+ return countHlsDeliveryExits(eligible);
1275
1365
  };
1276
1366
  const usableChinaExits = () => Math.min(AGG, deliveringChinaExits());
1277
1367
  // Prefetch parallelism = the number of exits ACTUALLY delivering right now
@@ -1565,17 +1655,6 @@ export function startMultiExitRouter(opts) {
1565
1655
  });
1566
1656
  if (mode === "loadbalance") {
1567
1657
  const reporter = setInterval(() => {
1568
- // Redemption: lift an expired speed-floor back to "unproven" so the exit
1569
- // gets a fresh segment chance (only segments can re-prove it; without
1570
- // this a floor is a permanent exclusion, which we never want).
1571
- const rnow = Date.now();
1572
- for (const e of exits) {
1573
- if (e.flooredAtMs !== null && rnow - e.flooredAtMs > REDEMPTION_MS) {
1574
- e.flooredAtMs = null;
1575
- e.speedEwma = null;
1576
- log(`${e.name}: floor lifted after ${Math.round(REDEMPTION_MS / 1000)}s — giving it another chance`);
1577
- }
1578
- }
1579
1658
  const parts = exits.filter((e) => e.healthy).map((e) => `${e.name}[${e.region}]: ${e.active} active / ${e.served} total / ${fmtMbps(e.speedEwma)}`);
1580
1659
  parts.push(`direct: ${directActive} active / ${directTotal} total`);
1581
1660
  log(`load — ${parts.join(" | ")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.190",
3
+ "version": "0.1.192",
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",