@torrent-tv/proxy 2.89.11 → 2.89.13

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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 2.89.13
2
+
3
+ - **Fix**: The proxy no longer stops answering everybody when one output is priced near zero. Asking how many encoders an output may run counted them one at a time until the machine was full, so the number of steps grew as the price shrank. On the stand on 2026-10-04 a soundtrack priced at 5.4e-14 s of machine per second of film held the main thread in that count for minutes, past 3.3 billion steps: no data channel was served, `/healthz` did not answer, and every viewer stopped. The count is now found by doubling and halving, in at most a few hundred steps whatever the price. The price itself is wrong and is not corrected here.
4
+
5
+ ## 2.89.12
6
+
7
+ - **Fix**: A connection that is gone is no longer probed. The delivery probe stopped only when a data channel reported `onClosed`, and a peer connection closed moments after its channels opened reports nothing: on the stand on 2026-10-04 a trial connection the page closed 70 ms after opening was still sent probes twice a second hours later, one timer left behind per such connection. When the transport watcher finds the connection gone, its probes now end with it.
8
+
1
9
  ## 2.89.11
2
10
 
3
11
  - **Fix**: A failed encoder run says what the source holds again. The line `this run asked for …, and the source holds N video, N audio, N subtitle` was meant to tell an output with no stream (the source's fault) from a track index past the end (ours), but every field failure printed `what the source holds was not recorded`: a session takes its media info from the playback planner's cache, and the planner left the stream counts out of it although it parses the same ffmpeg banner. They are cached with the rest now.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.89.11",
3
+ "version": "2.89.13",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -356,17 +356,37 @@ export class EncodeAdmission {
356
356
  return { runs: Number.POSITIVE_INFINITY, because: "the output is not priced" };
357
357
  }
358
358
  const others = this.#occupied(address);
359
+ const fits = (count) => this.#speedAt(others.costSec + EncodeAdmission.#added(load, others.files, count)) >= 1;
359
360
  let runs = 0;
360
- for (;;) {
361
- const next = runs + 1;
362
- if (this.#speedAt(others.costSec + EncodeAdmission.#added(load, others.files, next)) < 1) {
363
- break;
364
- }
365
- runs = next;
361
+ if (fits(1)) {
366
362
  if (!(load.costSec > 0)) {
367
363
  // Free to run: the per-output limit is the only bound.
368
364
  return { runs: Number.POSITIVE_INFINITY, because: "it costs nothing measurable" };
369
365
  }
366
+ // The largest count that still fits, found by doubling and halving: the
367
+ // speed only falls as encoders are added. Counting up one at a time took
368
+ // as many steps as the price is small — field 2026-10-04, a soundtrack
369
+ // priced at 5.4e-14 s per film second kept the main thread in this loop
370
+ // for minutes, past 3.3 billion steps, and every viewer of the proxy
371
+ // stopped receiving.
372
+ let low = 1;
373
+ let high = 2;
374
+ while (fits(high)) {
375
+ low = high;
376
+ high *= 2;
377
+ }
378
+ for (;;) {
379
+ const middle = Math.floor((low + high) / 2);
380
+ if (middle === low || middle === high) {
381
+ break;
382
+ }
383
+ if (fits(middle)) {
384
+ low = middle;
385
+ } else {
386
+ high = middle;
387
+ }
388
+ }
389
+ runs = low;
370
390
  }
371
391
  if (runs >= floor) {
372
392
  return { runs, because: "the machine" };
@@ -761,6 +761,7 @@ export function createDataChannelHandler({
761
761
  * @returns {void}
762
762
  */
763
763
  function connectionGone(sessionId, because) {
764
+ deliveryProbe.forget(sessionId);
764
765
  const viewers = viewersOnConnection.get(sessionId);
765
766
  viewersOnConnection.delete(sessionId);
766
767
  if (!viewers || viewers.size === 0) {
@@ -414,6 +414,7 @@ export function readProbeState(state) {
414
414
  * @returns {{
415
415
  * attach: (sessionId: string, tag: string, label: string, channel: import('node-datachannel').DataChannel) => void,
416
416
  * detach: (sessionId: string, channel: import('node-datachannel').DataChannel) => void,
417
+ * forget: (sessionId: string) => void,
417
418
  * noteEcho: (sessionId: string, echo: object) => void,
418
419
  * dispose: () => void
419
420
  * }}
@@ -699,6 +700,24 @@ export function createDeliveryProbe({
699
700
  }
700
701
  },
701
702
 
703
+ // The connection is gone, whether or not its channels said so. A peer
704
+ // connection closed moments after its channels opened gives no `onClosed`
705
+ // (field 2026-10-04: a trial connection closed 70 ms after opening was
706
+ // still probed hours later), so `detach` alone left a timer per such
707
+ // connection for the life of the process.
708
+ forget(sessionId) {
709
+ const connection = connections.get(sessionId);
710
+ if (!connection) {
711
+ return;
712
+ }
713
+ if (connection.timer !== null) {
714
+ clearInterval(connection.timer);
715
+ connection.timer = null;
716
+ }
717
+ connection.channels.clear();
718
+ connections.delete(sessionId);
719
+ },
720
+
702
721
  noteEcho(sessionId, echo) {
703
722
  const connection = connections.get(sessionId);
704
723
  if (!connection || !echo || typeof echo !== "object") {
@@ -160,3 +160,34 @@ test("a viewer whose newest channel closed is reached on the older connection",
160
160
  assert.equal(cuesOn(old.control), 1);
161
161
  assert.deepEqual(gone, [], "the trial connection still has its media channel");
162
162
  });
163
+
164
+ test("a connection the transport no longer knows is not probed any more, though no channel said it closed", (t) => {
165
+ t.mock.timers.enable({ apis: ["setInterval", "setTimeout", "Date"] });
166
+ let alive = true;
167
+ const handler = createDataChannelHandler({
168
+ proxyPort: 9090,
169
+ getTransportSnapshot: () => (alive ? { bytesSent: 0, bytesReceived: 0 } : null),
170
+ onViewerGone: () => {}
171
+ });
172
+ const channels = ["proxy", "proxy-control", "proxy-fast"].map((label) => new FakeDataChannel(label));
173
+ for (const channel of channels) {
174
+ handler.handleChannel("trial-peer", channel);
175
+ }
176
+ const probes = () =>
177
+ channels.reduce((sum, channel) => sum + channel.messages.filter((message) => message.includes('"probe"')).length, 0);
178
+ t.mock.timers.tick(2_000);
179
+ assert.ok(probes() > 0, "an open connection is probed");
180
+
181
+ // Field 2026-10-04: the peer connection closed 70 ms after its channels
182
+ // opened, and none of them reported `onClosed`.
183
+ alive = false;
184
+ const advance = (ms) => {
185
+ // Second by second: the watcher compares the clock between its own ticks,
186
+ // and one long jump lands every tick on the same instant.
187
+ for (let elapsed = 0; elapsed < ms; elapsed += 1_000) t.mock.timers.tick(1_000);
188
+ };
189
+ advance(30_000);
190
+ const afterGone = probes();
191
+ advance(30_000);
192
+ assert.equal(probes(), afterGone, "nothing is sent on a connection that is gone");
193
+ });
@@ -144,3 +144,27 @@ test("unpriced occupied work is neither spare pool headroom nor capacity for ano
144
144
  assert.equal(answer.admitted, false);
145
145
  assert.match(answer.reason, /capacity is unknown/);
146
146
  });
147
+
148
+ test("a near-free output is answered at once, whatever its price", () => {
149
+ // Field 2026-10-04: a soundtrack priced at 5.4e-14 s per film second kept
150
+ // the main thread counting encoders one at a time for minutes.
151
+ const { admission } = machine({ costs: { sound: 5.36680075607109e-14 }, share: 0.6 });
152
+ const started = Date.now();
153
+ const { runs } = admission.placesFor("sound", 0);
154
+ assert.ok(Date.now() - started < 1_000, "answered without counting one at a time");
155
+ assert.ok(runs > 1e12 && Number.isFinite(runs), `as many as fit: ${runs}`);
156
+ });
157
+
158
+ test("the count is the largest that keeps every output at realtime", () => {
159
+ for (const cost of [0.5, 0.1, 0.03, 0.007, 0.0011]) {
160
+ for (const share of [null, 1, 0.37]) {
161
+ const { admission } = machine({ costs: { a: cost, b: PICTURE_1080 / 4 }, running: { b: 1 }, share });
162
+ const usable = share ?? 1;
163
+ let expected = 0;
164
+ while (usable / (PICTURE_1080 / 4 + (expected + 1) * cost) >= 1) {
165
+ expected += 1;
166
+ }
167
+ assert.equal(admission.placesFor("a", 0).runs, Math.max(expected, 0), `cost ${cost}, share ${share}`);
168
+ }
169
+ }
170
+ });