@decentnetwork/lan 0.1.189 → 0.1.191

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,18 @@ export interface MultiExitRouterOptions {
49
49
  log?: (msg: string) => void;
50
50
  }
51
51
  export declare const BUILTIN_REGION_DOMAINS: Record<string, string[]>;
52
+ interface HlsExitScore {
53
+ speedEwma: number | null;
54
+ flooredAtMs: number | null;
55
+ }
56
+ /** Choose exits safe for a foreground HLS fetch. Once any proven-fast exit is
57
+ * available, unproven/redeeming exits cannot borrow its score and displace it.
58
+ * With no proven exit, unproven exits remain the startup/failover path; if every
59
+ * exit is degraded, keep the original pool as a last resort. Exported for the
60
+ * policy regression tests. */
61
+ export declare function selectHlsPrimaryPool<T extends HlsExitScore>(pool: T[]): T[];
62
+ /** Dynamic prefetch concurrency follows only the foreground-safe pool. */
63
+ export declare function countHlsDeliveryExits<T extends HlsExitScore>(pool: T[]): number;
52
64
  /**
53
65
  * Start the multi-exit router. Returns a stop() that closes the listener.
54
66
  * Runs until stopped (the CLI command keeps the process alive).
@@ -56,3 +68,4 @@ export declare const BUILTIN_REGION_DOMAINS: Record<string, string[]>;
56
68
  export declare function startMultiExitRouter(opts: MultiExitRouterOptions): {
57
69
  stop: () => void;
58
70
  };
71
+ export {};
@@ -206,6 +206,10 @@ class RateMeter {
206
206
  * segments — but KEEP it in the pool (last-resort failover, small requests). */
207
207
  const FLOOR_BPS = 40_000;
208
208
  const REDEMPTION_MS = 90_000;
209
+ // Redemption probes are intentionally serialized and spaced out. A degraded
210
+ // exit is tested with a duplicate background segment while the player's normal
211
+ // fetch remains on proven exits, so recovery discovery cannot stall playback.
212
+ const REDEMPTION_PROBE_GAP_MS = 15_000;
209
213
  function floorExit(exit) {
210
214
  exit.speedEwma = exit.speedEwma === null ? FLOOR_BPS : Math.min(exit.speedEwma, FLOOR_BPS);
211
215
  exit.flooredAtMs = Date.now();
@@ -220,8 +224,38 @@ function sampleSpeed(exit, meter, durMs) {
220
224
  const bps = meter.peakBps(durMs);
221
225
  if (bps <= 0)
222
226
  return;
227
+ if (exit.flooredAtMs !== null) {
228
+ // FLOOR_BPS is a penalty, not a real historical sample. A qualifying probe
229
+ // replaces it with the fresh measurement instead of blending the floor into
230
+ // the result. Only a useful-speed transfer proves recovery; a completed but
231
+ // still-too-slow probe remains floored for another redemption interval.
232
+ if (bps >= NEAR_DEAD_EXIT_BPS) {
233
+ exit.speedEwma = bps;
234
+ exit.flooredAtMs = null;
235
+ }
236
+ else {
237
+ exit.speedEwma = FLOOR_BPS;
238
+ exit.flooredAtMs = Date.now();
239
+ }
240
+ return;
241
+ }
223
242
  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
243
+ }
244
+ /** Choose exits safe for a foreground HLS fetch. Once any proven-fast exit is
245
+ * available, unproven/redeeming exits cannot borrow its score and displace it.
246
+ * With no proven exit, unproven exits remain the startup/failover path; if every
247
+ * exit is degraded, keep the original pool as a last resort. Exported for the
248
+ * policy regression tests. */
249
+ export function selectHlsPrimaryPool(pool) {
250
+ const proven = pool.filter((e) => e.flooredAtMs === null && e.speedEwma !== null && e.speedEwma >= NEAR_DEAD_EXIT_BPS);
251
+ if (proven.length > 0)
252
+ return proven;
253
+ const unproven = pool.filter((e) => e.flooredAtMs === null && e.speedEwma === null);
254
+ return unproven.length > 0 ? unproven : pool;
255
+ }
256
+ /** Dynamic prefetch concurrency follows only the foreground-safe pool. */
257
+ export function countHlsDeliveryExits(pool) {
258
+ return Math.max(1, selectHlsPrimaryPool(pool).length);
225
259
  }
226
260
  function fmtMbps(bps) {
227
261
  return bps === null ? "?" : `${((bps * 8) / 1e6).toFixed(1)}Mbps`;
@@ -1042,7 +1076,41 @@ export function startMultiExitRouter(opts) {
1042
1076
  }
1043
1077
  // ---- HLS segment prefetch (plain-HTTP streams only) ------------------------
1044
1078
  // Fetch one URL through the region's exit pool, buffered, with failover.
1045
- function fetchViaRegionBuffered(u, extraHeaders, label, signal, exitHint) {
1079
+ let redemptionProbeInFlight = false;
1080
+ let lastRedemptionProbeMs = 0;
1081
+ function maybeStartRedemptionProbe(u, extraHeaders, region) {
1082
+ const now = Date.now();
1083
+ if (redemptionProbeInFlight || now - lastRedemptionProbeMs < REDEMPTION_PROBE_GAP_MS)
1084
+ return;
1085
+ const candidate = exits
1086
+ .filter((e) => e.region === region && e.flooredAtMs !== null &&
1087
+ now - e.flooredAtMs >= REDEMPTION_MS && e.trippedUntil <= now &&
1088
+ (e.healthy || (e.lastSuccessMs !== null && now - e.lastSuccessMs < RECENT_SUCCESS_MS)))
1089
+ .sort((a, b) => (a.flooredAtMs ?? now) - (b.flooredAtMs ?? now))[0];
1090
+ if (!candidate)
1091
+ return;
1092
+ redemptionProbeInFlight = true;
1093
+ lastRedemptionProbeMs = now;
1094
+ // Re-arm before dispatch so a failed/short probe cannot be started again by
1095
+ // every concurrent playlist refresh.
1096
+ candidate.flooredAtMs = now;
1097
+ log(` redemption-probe ${candidate.name}: isolated background segment test`);
1098
+ void fetchViaRegionBuffered(u, extraHeaders, "redemption-probe", undefined, undefined, candidate)
1099
+ .then(() => {
1100
+ if (candidate.flooredAtMs === null) {
1101
+ log(` redemption-probe ${candidate.name}: RECOVERED at ${fmtMbps(candidate.speedEwma)}`);
1102
+ }
1103
+ else {
1104
+ log(` redemption-probe ${candidate.name}: still degraded — keeping it off foreground HLS`);
1105
+ }
1106
+ })
1107
+ .catch((e) => {
1108
+ floorExit(candidate);
1109
+ log(` redemption-probe ${candidate.name}: failed (${e.message}) — keeping it off foreground HLS`);
1110
+ })
1111
+ .finally(() => { redemptionProbeInFlight = false; });
1112
+ }
1113
+ function fetchViaRegionBuffered(u, extraHeaders, label, signal, exitHint, forcedExit) {
1046
1114
  return new Promise((resolvep, rejectp) => {
1047
1115
  let settled = false;
1048
1116
  let curReq = null;
@@ -1077,7 +1145,9 @@ export function startMultiExitRouter(opts) {
1077
1145
  reject(new Error("no region for host"));
1078
1146
  return;
1079
1147
  }
1080
- const base0 = pickOrder(region);
1148
+ if (!forcedExit && label === "prefetch")
1149
+ maybeStartRedemptionProbe(u, extraHeaders, region);
1150
+ const base0 = forcedExit ? [forcedExit] : pickOrder(region);
1081
1151
  if (base0.length === 0) {
1082
1152
  reject(new Error("no healthy exit"));
1083
1153
  return;
@@ -1092,7 +1162,10 @@ export function startMultiExitRouter(opts) {
1092
1162
  // moderately-slow ones (0.4–0.6 Mbps) that we WANT for aggregation. A
1093
1163
  // single (non-striped) fetch instead skips near-dead exits and rotates.
1094
1164
  let order;
1095
- if (exitHint !== undefined) {
1165
+ if (forcedExit) {
1166
+ order = base0;
1167
+ }
1168
+ else if (exitHint !== undefined) {
1096
1169
  // Striped range: use pickOrder's live LOAD-AWARE order directly (base0 is
1097
1170
  // sorted by speed/(active+1), least-loaded-fastest first). Because each
1098
1171
  // buffered fetch now increments exit.active at connect time, concurrent
@@ -1110,8 +1183,7 @@ export function startMultiExitRouter(opts) {
1110
1183
  // splitting one exit's bandwidth N ways (observed: 3 segments all on
1111
1184
  // 1.15 at ~1Mbps each while 1.16 sat idle). A blind RR rotation kept
1112
1185
  // 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;
1186
+ order = selectHlsPrimaryPool(base0);
1115
1187
  }
1116
1188
  const path = (u.pathname || "/") + (u.search || "");
1117
1189
  const tail = (u.pathname || "/").split("/").pop() || path;
@@ -1123,7 +1195,8 @@ export function startMultiExitRouter(opts) {
1123
1195
  // bad luck; a fresh retry usually succeeds. Loop the whole exit order a
1124
1196
  // few rounds before giving up, so the player almost never sees a hard
1125
1197
  // failure (which trips its error watchdog → error.html → black screen).
1126
- if (round + 1 < MAX_FETCH_ROUNDS) {
1198
+ const maxRounds = forcedExit ? 1 : MAX_FETCH_ROUNDS;
1199
+ if (round + 1 < maxRounds) {
1127
1200
  const t = setTimeout(() => tryIdx(0, round + 1), 150);
1128
1201
  t.unref?.();
1129
1202
  return;
@@ -1268,10 +1341,9 @@ export function startMultiExitRouter(opts) {
1268
1341
  if (!fallbackRegion)
1269
1342
  return 1;
1270
1343
  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);
1344
+ const eligible = exits.filter((e) => e.region === fallbackRegion && e.trippedUntil <= now &&
1345
+ (e.healthy || (e.lastSuccessMs !== null && now - e.lastSuccessMs < RECENT_SUCCESS_MS)));
1346
+ return countHlsDeliveryExits(eligible);
1275
1347
  };
1276
1348
  const usableChinaExits = () => Math.min(AGG, deliveringChinaExits());
1277
1349
  // Prefetch parallelism = the number of exits ACTUALLY delivering right now
@@ -1565,17 +1637,6 @@ export function startMultiExitRouter(opts) {
1565
1637
  });
1566
1638
  if (mode === "loadbalance") {
1567
1639
  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
1640
  const parts = exits.filter((e) => e.healthy).map((e) => `${e.name}[${e.region}]: ${e.active} active / ${e.served} total / ${fmtMbps(e.speedEwma)}`);
1580
1641
  parts.push(`direct: ${directActive} active / ${directTotal} total`);
1581
1642
  log(`load — ${parts.join(" | ")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.189",
3
+ "version": "0.1.191",
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",
@@ -79,7 +79,7 @@
79
79
  },
80
80
  "dependencies": {
81
81
  "@decentnetwork/dora": "^0.1.13",
82
- "@decentnetwork/peer": "^0.1.93",
82
+ "@decentnetwork/peer": "^0.1.94",
83
83
  "@decentnetwork/peer-webrtc": "^0.2.10",
84
84
  "ink": "^5.2.1",
85
85
  "js-yaml": "^4.1.0",