@decentnetwork/lan 0.1.191 → 0.1.193

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
@@ -59,6 +59,9 @@ export interface HlsPrefetcherOptions {
59
59
  * exit just serializes and stalls); as exits recover it rises and aggregation
60
60
  * resumes. Overrides `stripes` when provided. */
61
61
  stripesFn?: () => number;
62
+ /** Delay before duplicating a slow whole-segment fetch. Primarily exposed for
63
+ * deterministic tests; default 3.5s is just below CCTV's 4s live segment. */
64
+ wholeHedgeMs?: number;
62
65
  /** Stripe chunk size — also the first probe chunk (default 256 KB). Segments
63
66
  * smaller than this aren't striped. */
64
67
  stripeChunkBytes?: number;
@@ -27,7 +27,7 @@ const STRIPE_TRIES = 3;
27
27
  * landed in this long is late (a healthy exit delivers in 1-3.5s), so a
28
28
  * parallel duplicate on another exit is launched. Rarely fires in steady
29
29
  * state; mainly absorbs redemption probes trapped by a still-bad exit. */
30
- const WHOLE_HEDGE_MS = 5000;
30
+ const WHOLE_HEDGE_MS = 3500;
31
31
  /** Total size from a `Content-Range: bytes 0-255/900000` header, or null. */
32
32
  function contentRangeTotal(h) {
33
33
  const v = Array.isArray(h) ? h[0] : h;
@@ -114,6 +114,7 @@ export class HlsPrefetcher {
114
114
  #stripes;
115
115
  #stripesFn;
116
116
  #stripeChunkBytes;
117
+ #wholeHedgeMs;
117
118
  #hits = 0;
118
119
  #misses = 0;
119
120
  constructor(fetcher, opts = {}) {
@@ -127,6 +128,7 @@ export class HlsPrefetcher {
127
128
  this.#stripes = Math.max(1, opts.stripes ?? 4);
128
129
  this.#stripesFn = opts.stripesFn;
129
130
  this.#stripeChunkBytes = opts.stripeChunkBytes ?? 256 * 1024;
131
+ this.#wholeHedgeMs = opts.wholeHedgeMs ?? WHOLE_HEDGE_MS;
130
132
  }
131
133
  /** Effective stripe count right now (dynamic if a stripesFn was provided). */
132
134
  #stripeCount() {
@@ -179,41 +181,61 @@ export class HlsPrefetcher {
179
181
  * the origin ignores Range or a stripe can't be completed. No deadline: if
180
182
  * every exit is slow, a slow whole body still beats a corrupt/empty one.
181
183
  *
182
- * HEDGED when ≥2 exits are delivering: if the body hasn't landed within
183
- * WHOLE_HEDGE_MS, launch a PARALLEL duplicate (the router's load-aware pick
184
- * sends it to the best other exit) and take whichever finishes first. This
185
- * absorbs the redemption probe: a just-redeemed exit that traps its probe
186
- * segment for 13-15s used to stall playback every REDEMPTION cycle now
187
- * the hedge covers the segment while the probe runs to its deadline and
188
- * floors the bad exit properly (the loser is NOT aborted, on purpose: an
189
- * aborted probe never gets floored and would be re-probed forever). */
184
+ * HEDGED even when only one exit is currently qualified: if the body hasn't
185
+ * landed within 3.5s, launch a second connection and take whichever finishes
186
+ * first. A single proven exit still showed occasional 7-10s per-connection
187
+ * tails; waiting for it alone drained CCTV's four-second live buffer. The
188
+ * losing request is aborted immediately so the rescue does not keep splitting
189
+ * bandwidth after a winner is available. Redemption probes are separate,
190
+ * forced-exit requests and are never aborted by this path. */
190
191
  #whole(u, extra) {
191
- const one = () => this.#fetcher(u, { ...extra, host: u.host }, "prefetch");
192
- const canHedge = (this.#concurrencyFn ? this.#concurrencyFn() : this.#concurrency) >= 2;
193
- if (!canHedge)
194
- return one(); // single exit: a duplicate would just split its bandwidth
192
+ const one = (signal, label) => this.#fetcher(u, { ...extra, host: u.host }, label, signal);
195
193
  return new Promise((resolve, reject) => {
196
194
  let settled = false;
197
- let fails = 0;
198
- let hedged = false;
195
+ let pending = 1;
196
+ let hedgeStarted = false;
199
197
  let timer = null;
200
- const win = (r) => { if (settled)
201
- return; settled = true; if (timer)
202
- clearTimeout(timer); resolve(r); };
203
- const lose = (e) => { fails++; if (!settled && fails >= (hedged ? 2 : 1)) {
198
+ const first = new AbortController();
199
+ let hedge = null;
200
+ const win = (winner) => (r) => {
201
+ if (settled)
202
+ return;
204
203
  settled = true;
205
204
  if (timer)
206
205
  clearTimeout(timer);
207
- reject(e);
208
- } };
209
- one().then(win, lose);
210
- timer = setTimeout(() => {
211
- timer = null;
206
+ (winner === first ? hedge : first)?.abort();
207
+ // Keep the winner alive through resolution; aborting a fully consumed
208
+ // response would only discard its reusable keep-alive socket.
209
+ void winner;
210
+ resolve(r);
211
+ };
212
+ const lose = (e) => {
212
213
  if (settled)
213
214
  return;
214
- hedged = true;
215
- this.#fetcher(u, { ...extra, host: u.host }, "prefetch-hedge").then(win, lose);
216
- }, WHOLE_HEDGE_MS);
215
+ pending--;
216
+ if (!hedgeStarted) {
217
+ if (timer)
218
+ clearTimeout(timer);
219
+ startHedge();
220
+ }
221
+ else if (pending === 0) {
222
+ settled = true;
223
+ reject(e);
224
+ }
225
+ };
226
+ const startHedge = () => {
227
+ if (settled || hedgeStarted)
228
+ return;
229
+ hedgeStarted = true;
230
+ hedge = new AbortController();
231
+ pending++;
232
+ one(hedge.signal, "prefetch-hedge").then(win(hedge), lose);
233
+ };
234
+ one(first.signal, "prefetch").then(win(first), lose);
235
+ timer = setTimeout(() => {
236
+ timer = null;
237
+ startHedge();
238
+ }, this.#wholeHedgeMs);
217
239
  timer.unref?.();
218
240
  });
219
241
  }
@@ -49,6 +49,7 @@ 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;
52
53
  interface HlsExitScore {
53
54
  speedEwma: number | null;
54
55
  flooredAtMs: number | null;
@@ -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
@@ -217,7 +227,7 @@ function floorExit(exit) {
217
227
  /** Feed one tunnel's rate meter into the exit's speed EWMA (peak-window based).
218
228
  * Only transfers past SPEED_MIN_BYTES count — smaller ones never reveal path
219
229
  * capacity on a high-RTT link. */
220
- function sampleSpeed(exit, meter, durMs) {
230
+ function sampleSpeed(exit, meter, durMs, recoveryMinBps = NEAR_DEAD_EXIT_BPS) {
221
231
  exit.bytesDown += meter.total;
222
232
  if (meter.total < SPEED_MIN_BYTES)
223
233
  return;
@@ -229,7 +239,12 @@ function sampleSpeed(exit, meter, durMs) {
229
239
  // replaces it with the fresh measurement instead of blending the floor into
230
240
  // the result. Only a useful-speed transfer proves recovery; a completed but
231
241
  // still-too-slow probe remains floored for another redemption interval.
232
- if (bps >= NEAR_DEAD_EXIT_BPS) {
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)) {
233
248
  exit.speedEwma = bps;
234
249
  exit.flooredAtMs = null;
235
250
  }
@@ -247,7 +262,7 @@ function sampleSpeed(exit, meter, durMs) {
247
262
  * exit is degraded, keep the original pool as a last resort. Exported for the
248
263
  * policy regression tests. */
249
264
  export function selectHlsPrimaryPool(pool) {
250
- const proven = pool.filter((e) => e.flooredAtMs === null && e.speedEwma !== null && e.speedEwma >= NEAR_DEAD_EXIT_BPS);
265
+ const proven = pool.filter((e) => e.flooredAtMs === null && e.speedEwma !== null && e.speedEwma >= HLS_DELIVERY_BPS);
251
266
  if (proven.length > 0)
252
267
  return proven;
253
268
  const unproven = pool.filter((e) => e.flooredAtMs === null && e.speedEwma === null);
@@ -255,7 +270,10 @@ export function selectHlsPrimaryPool(pool) {
255
270
  }
256
271
  /** Dynamic prefetch concurrency follows only the foreground-safe pool. */
257
272
  export function countHlsDeliveryExits(pool) {
258
- return Math.max(1, selectHlsPrimaryPool(pool).length);
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));
259
277
  }
260
278
  function fmtMbps(bps) {
261
279
  return bps === null ? "?" : `${((bps * 8) / 1e6).toFixed(1)}Mbps`;
@@ -1274,7 +1292,7 @@ export function startMultiExitRouter(opts) {
1274
1292
  nextExit(`empty ${status}`);
1275
1293
  return;
1276
1294
  }
1277
- sampleSpeed(exit, meter, durMs);
1295
+ sampleSpeed(exit, meter, durMs, HLS_DELIVERY_BPS);
1278
1296
  if (label) {
1279
1297
  const kb = (body.length / 1024).toFixed(0);
1280
1298
  const mbps = durMs > 0 ? ((body.length * 8) / durMs / 1000).toFixed(1) : "?";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.191",
3
+ "version": "0.1.193",
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",