@hisoka-io/anon-rpc-worker 0.4.0 → 0.4.1

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.
@@ -1 +1 @@
1
- 0xd8bef626ec8511d7a468bca5137880680ca2da20d2ea4d9c981a2a3d0c86ba1e anon-rpc-worker.js
1
+ 0xd9fa04b080a33062680a27cad828196bfb04615a0ebc76bcbffedb6dcbe838d4 anon-rpc-worker.js
@@ -3,7 +3,7 @@
3
3
  "arch": "x64",
4
4
  "os": "linux"
5
5
  },
6
- "gitCommit": "fee8d69861a112c65f0cbb0b848a7726ec12f01a",
6
+ "gitCommit": "66bce4af274b4be457ef11336ba9cb6d9200efd1",
7
7
  "gitTreeClean": true,
8
8
  "inputs": {
9
9
  "bootstrapKeccak": "0x6567e3ad5e1b1ac81529391c890d2652695d3ec139d56625170384794085fb74",
@@ -12,13 +12,13 @@
12
12
  "snapshotKeccak": "0x1090f071896b6ef41ddfd40d4ebcd20fae85a66468f82497e19a635488014681",
13
13
  "tlsWasmKeccak": "0x60b29cd4fca7ee2f31fbcff2dca25e64939e4b71a4629ffcfea1b89546f501ce",
14
14
  "tlsWasmSha256": "f74e8cfbed7be33ef1a9158b7b66e454b4ca0849bcb8956487fd13da47e4f4d2",
15
- "wasmKeccak": "0x6ff418a78fc022d34dff00a867e173269413fb6887c9d6bf8e6505d4dcfffe2d",
16
- "wasmSha256": "5171416972ea4499fd4b4ea8bcadf002f4c10f10ed42cafb0d5a426121da9c4f"
15
+ "wasmKeccak": "0x91060e59264ba44c3f16181caff1adcf16193a1e57d6ba4dd3531b192ac42de4",
16
+ "wasmSha256": "6db306a34ef50e9198f4ad6f31c6ffd9feafe459c24b8a29895824c7138241c8"
17
17
  },
18
18
  "output": {
19
- "bytes": 1820668,
20
- "keccak256": "0xd8bef626ec8511d7a468bca5137880680ca2da20d2ea4d9c981a2a3d0c86ba1e",
21
- "sha256": "4b7b69cd6f45c364cd87ff75f5f8cacdebea2cd852d5384c47215bb88d56f35a"
19
+ "bytes": 1831246,
20
+ "keccak256": "0xd9fa04b080a33062680a27cad828196bfb04615a0ebc76bcbffedb6dcbe838d4",
21
+ "sha256": "9544fb10390eb135d4f6b9a32de674ea859e4977514afed4772cb4642d62ed19"
22
22
  },
23
23
  "package": "@hisoka-io/anon-rpc-worker",
24
24
  "tls": {
@@ -55,5 +55,5 @@
55
55
  "wasmPack": "0.13.1",
56
56
  "wasmPinsChecked": true
57
57
  },
58
- "version": "0.4.0"
58
+ "version": "0.4.1"
59
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hisoka-io/anon-rpc-worker",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Nox mixnet worker for the anon-rpc standard, built as one reproducible classic-script bundle with a pinned NoxRegistry snapshot",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -25,8 +25,8 @@
25
25
  "ethers": "6.16.0",
26
26
  "typescript": "5.9.3",
27
27
  "vitest": "1.6.1",
28
- "@hisoka-io/nox-client": "0.8.0",
29
- "@hisoka-io/nox-wasm": "0.2.0"
28
+ "@hisoka-io/nox-client": "0.9.0",
29
+ "@hisoka-io/nox-wasm": "0.3.0"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"
package/src/config.ts CHANGED
@@ -111,8 +111,12 @@ export const CONFIG_DEFAULTS: NoxWorkerConfig = Object.freeze({
111
111
  // Founder decisions D1/D2 (E2E TLS design §8): the most private settings until decided.
112
112
  tls: "required",
113
113
  tlsSession: "per-call",
114
- tlsSpares: 1,
115
- tlsSpareTtlMs: 20_000,
114
+ // Spare policy measured from India on 2026-10-15: the upstream RPC hosts close an idle TLS connection 15.0 s
115
+ // after the handshake, and spares used 14.2 s or more after their handshake had been closed (one wasted exchange,
116
+ // then a retry on a new session). A 12 s TTL kept every spare live (0 retries in 70 calls); a second spare per
117
+ // host raised spare use at a 1 s gap from 16/30 to 23/30 calls.
118
+ tlsSpares: 2,
119
+ tlsSpareTtlMs: 12_000,
116
120
  tlsKeepAliveMs: 30_000,
117
121
  tlsMaxCallsPerSession: 100,
118
122
  tlsOpenTimeoutMs: 5_000,
package/src/tls/pool.ts CHANGED
@@ -12,8 +12,17 @@
12
12
  * - Per-call spares: after a call to a host (once the first wallet call has
13
13
  * settled), a replacement spare opens on a random exit after an
14
14
  * exponential delay with mean `SPARE_DELAY_MEAN_MS`, up to `tlsSpares` per
15
- * host. A spare serves one call; unused, it is torn down after
16
- * `tlsSpareTtlMs` and not replaced. Hosts live in memory only.
15
+ * host. A spare serves one call; unused, it is torn down `tlsSpareTtlMs`
16
+ * after its handshake. While the host was called within
17
+ * `SPARE_REFILL_WINDOW_MS`, a replacement opens at once
18
+ * `SPARE_REFILL_LEAD_MS` plus up to `SPARE_REFILL_JITTER_MS` before that
19
+ * teardown (one replacement per host at a time, so a host holds at most
20
+ * `tlsSpares` spares plus the one being replaced); after the window an
21
+ * expired spare is not replaced. Hosts live in memory only.
22
+ * - A call that finds no spare while a spare's open for its host has run for
23
+ * at least half the handshake median (and at most its p95) waits for that
24
+ * open instead of starting another, and opens its own session when that
25
+ * open fails.
17
26
  * - Keep-alive: up to `MAX_KEEP_ALIVE_SESSIONS` sessions per host, each
18
27
  * reused for `tlsKeepAliveMs` from its open or `tlsMaxCallsPerSession`
19
28
  * calls; a call beyond that runs on a session of its own.
@@ -39,6 +48,20 @@ export const SPARE_DELAY_MEAN_MS = 2_000;
39
48
  export const MAX_KEEP_ALIVE_SESSIONS = 4;
40
49
  /** Hosts with spares kept at once; the least recently used host loses its spares. */
41
50
  export const MAX_SPARE_HOSTS = 8;
51
+ /**
52
+ * A spare is refreshed before its TTL only while its host was called within
53
+ * this long. Bounds how far apart the refresh chain can link a wallet's calls
54
+ * to one host at low traffic.
55
+ */
56
+ export const SPARE_REFILL_WINDOW_MS = 60_000;
57
+ /**
58
+ * How long before a spare's TTL its replacement opens: about the handshake
59
+ * p95 measured from India on 2026-10-15 (1.45 s), so the replacement is ready
60
+ * before the old spare goes.
61
+ */
62
+ export const SPARE_REFILL_LEAD_MS = 1_500;
63
+ /** Uniform jitter added to `SPARE_REFILL_LEAD_MS`, so refreshes do not tick at a fixed period. */
64
+ export const SPARE_REFILL_JITTER_MS = 500;
42
65
  /** Exits tried for one open. */
43
66
  export const MAX_OPEN_ATTEMPTS = 3;
44
67
  /** Handshake times kept for the copy timer's p95. */
@@ -74,7 +97,35 @@ export class NoTunnelExitError extends Error {
74
97
 
75
98
  interface Spare {
76
99
  readonly channel: TlsChannel;
77
- readonly timer: ReturnType<typeof setTimeout>;
100
+ /** When the handshake finished: the TTL and the logged age count from here. */
101
+ readonly readyAt: number;
102
+ readonly timers: ReturnType<typeof setTimeout>[];
103
+ /** A replacement is opening; this spare still serves a call until its TTL. */
104
+ superseded: boolean;
105
+ /** The TTL leaves room for a refresh (longer than LEAD plus jitter). */
106
+ refreshable: boolean;
107
+ }
108
+
109
+ /** A background spare open in flight, which a call may wait for. */
110
+ interface PendingOpen {
111
+ readonly startedAt: number;
112
+ /** A call waits for this open: it is handed to the call, not kept as a spare. */
113
+ claimed: boolean;
114
+ /** Set once the open succeeded. */
115
+ channel: TlsChannel | undefined;
116
+ readyAt: number;
117
+ readonly promise: Promise<TlsChannel>;
118
+ }
119
+
120
+ /** How `acquire` obtained a session, for the `tls.call` log line. */
121
+ export type AcquirePath = "spare" | "inline" | "waited" | "reused";
122
+
123
+ export interface AcquireInfo {
124
+ readonly path: AcquirePath;
125
+ /** Time `acquire` spent waiting for a handshake (0 for a spare or a reused session). */
126
+ readonly openMs: number;
127
+ /** Age of the spare since its handshake, for `path: "spare"`. */
128
+ readonly spareAgeMs: number | undefined;
78
129
  }
79
130
 
80
131
  interface Pooled {
@@ -86,7 +137,13 @@ export class TlsPool {
86
137
  readonly policy: CopyPolicy;
87
138
  private readonly skipped = new Map<string, number>();
88
139
  private readonly spares = new Map<string, Spare[]>();
140
+ /** Spare opens per host, scheduled or in flight. */
89
141
  private readonly opening = new Map<string, number>();
142
+ /** Spare opens per host that have started their handshake. */
143
+ private readonly pending = new Map<string, PendingOpen[]>();
144
+ /** When each host was last called (per-call mode), least recent first. */
145
+ private readonly lastCallAt = new Map<string, number>();
146
+ private readonly acquired = new WeakMap<TlsChannel, AcquireInfo>();
90
147
  private readonly sessions = new Map<string, Pooled[]>();
91
148
  private readonly timers = new Set<ReturnType<typeof setTimeout>>();
92
149
  private readonly samples: number[] = [];
@@ -127,9 +184,23 @@ export class TlsPool {
127
184
 
128
185
  /** A session ready for a request to `host`: a spare, an idle shared session, or a new one. */
129
186
  async acquire(host: string, timing: Omit<ExchangeTiming, "background">): Promise<TlsChannel> {
187
+ const startedAt = this.deps.now();
130
188
  if (this.settings.tlsSession === "per-call") {
189
+ this.noteCall(host, startedAt);
131
190
  const spare = this.takeSpare(host);
132
- const channel = spare ?? (await this.open(host, { ...timing, background: false }));
191
+ if (spare !== undefined) {
192
+ this.acquired.set(spare.channel, { path: "spare", openMs: 0, spareAgeMs: startedAt - spare.readyAt });
193
+ this.replenish(host);
194
+ return spare.channel;
195
+ }
196
+ const inFlight = this.waitableOpen(host, startedAt);
197
+ const waited = inFlight === undefined ? undefined : await this.waitFor(host, inFlight, timing);
198
+ const channel = waited ?? (await this.open(host, { ...timing, background: false }));
199
+ this.acquired.set(channel, {
200
+ path: waited === undefined ? "inline" : "waited",
201
+ openMs: this.deps.now() - startedAt,
202
+ spareAgeMs: undefined,
203
+ });
133
204
  this.replenish(host);
134
205
  return channel;
135
206
  }
@@ -138,9 +209,11 @@ export class TlsPool {
138
209
  const ready = pooled.find((entry) => entry.idle && this.keepAliveUsable(entry.channel, now));
139
210
  if (ready !== undefined) {
140
211
  ready.idle = false;
212
+ this.acquired.set(ready.channel, { path: "reused", openMs: 0, spareAgeMs: undefined });
141
213
  return ready.channel;
142
214
  }
143
215
  const channel = await this.open(host, { ...timing, background: false });
216
+ this.acquired.set(channel, { path: "inline", openMs: this.deps.now() - startedAt, spareAgeMs: undefined });
144
217
  const live = this.sessions.get(host) ?? [];
145
218
  if (live.length < MAX_KEEP_ALIVE_SESSIONS) {
146
219
  live.push({ channel, idle: false });
@@ -172,6 +245,11 @@ export class TlsPool {
172
245
  this.retire(channel);
173
246
  }
174
247
 
248
+ /** How the last `acquire` that returned `channel` obtained it. */
249
+ acquireInfo(channel: TlsChannel): AcquireInfo | undefined {
250
+ return this.acquired.get(channel);
251
+ }
252
+
175
253
  /** Drop every session; spares and shared sessions are torn down. */
176
254
  close(): void {
177
255
  this.closed = true;
@@ -264,16 +342,61 @@ export class TlsPool {
264
342
  this.deps.log.info("tls.exit.skipped", { reason, minutes: EXIT_SKIP_MS / 60_000 });
265
343
  }
266
344
 
267
- private takeSpare(host: string): TlsChannel | undefined {
345
+ private takeSpare(host: string): Spare | undefined {
268
346
  const list = this.spares.get(host);
269
347
  const spare = list?.shift();
270
348
  if (spare === undefined) return undefined;
271
- clearTimeout(spare.timer);
272
- this.timers.delete(spare.timer);
349
+ this.clearSpareTimers(spare);
273
350
  // Most recently used host last (see MAX_SPARE_HOSTS).
274
351
  this.spares.delete(host);
275
352
  this.spares.set(host, list ?? []);
276
- return spare.channel;
353
+ return spare;
354
+ }
355
+
356
+ private noteCall(host: string, at: number): void {
357
+ this.lastCallAt.delete(host);
358
+ this.lastCallAt.set(host, at);
359
+ while (this.lastCallAt.size > MAX_SPARE_HOSTS) {
360
+ const oldest = this.lastCallAt.keys().next().value as string;
361
+ this.lastCallAt.delete(oldest);
362
+ }
363
+ }
364
+
365
+ /** `host` was called within `SPARE_REFILL_WINDOW_MS`. */
366
+ private recentlyCalled(host: string): boolean {
367
+ const at = this.lastCallAt.get(host);
368
+ return at !== undefined && this.deps.now() - at < SPARE_REFILL_WINDOW_MS;
369
+ }
370
+
371
+ /**
372
+ * The oldest unclaimed spare open for `host` that started at least half the
373
+ * handshake median ago and no longer ago than the handshake p95 (once there
374
+ * are `LATENCY_MIN_SAMPLES`): an open past the p95 is likely stuck on a slow
375
+ * exit, so the call opens its own and that open stays a spare if it lands.
376
+ */
377
+ private waitableOpen(host: string, now: number): PendingOpen | undefined {
378
+ const minAgeMs = percentile(this.samples, 0.5, 1) / 2;
379
+ const maxAgeMs = percentile95(this.samples) || Number.POSITIVE_INFINITY;
380
+ return this.pending.get(host)?.find((entry) => {
381
+ const ageMs = now - entry.startedAt;
382
+ return !entry.claimed && ageMs >= minAgeMs && ageMs <= maxAgeMs;
383
+ });
384
+ }
385
+
386
+ /**
387
+ * Wait for the spare open `entry` and take its session. Undefined when that
388
+ * open fails or the call's deadline passes first (the call then opens its
389
+ * own); the call's abort rejects. A session the call gave up on stays a spare.
390
+ */
391
+ private async waitFor(host: string, entry: PendingOpen, timing: Omit<ExchangeTiming, "background">): Promise<TlsChannel | undefined> {
392
+ entry.claimed = true;
393
+ const outcome = await settleFirst(entry.promise, timing.signal, timing.deadlineAt - this.deps.now());
394
+ if (outcome.kind === "opened") return outcome.channel;
395
+ if (outcome.kind === "failed") return undefined;
396
+ entry.claimed = false;
397
+ if (entry.channel !== undefined) this.keepSpare(host, entry.channel, entry.readyAt);
398
+ if (outcome.kind === "aborted") throw outcome.reason;
399
+ return undefined;
277
400
  }
278
401
 
279
402
  /** Per-call mode: open a replacement spare for `host` after an exponential delay. */
@@ -283,51 +406,117 @@ export class TlsPool {
283
406
  if (this.deferredHosts.size < MAX_SPARE_HOSTS) this.deferredHosts.add(host);
284
407
  return;
285
408
  }
286
- const have = (this.spares.get(host)?.length ?? 0) + (this.opening.get(host) ?? 0);
287
- if (have >= this.settings.tlsSpares) return;
409
+ if (this.spareCount(host) >= this.settings.tlsSpares) return;
410
+ this.openSpare(host, Math.round(-Math.log(1 - this.deps.random()) * SPARE_DELAY_MEAN_MS));
411
+ }
412
+
413
+ /** Spares of `host` that count against `tlsSpares`: those not being replaced, and opens scheduled or in flight. */
414
+ private spareCount(host: string): number {
415
+ const live = (this.spares.get(host) ?? []).filter((spare) => !spare.superseded).length;
416
+ return live + (this.opening.get(host) ?? 0);
417
+ }
418
+
419
+ /** Open a spare for `host` after `delayMs` (at once for 0); it becomes a spare unless a call claims it first. */
420
+ private openSpare(host: string, delayMs: number): void {
288
421
  this.opening.set(host, (this.opening.get(host) ?? 0) + 1);
289
- const delayMs = Math.round(-Math.log(1 - this.deps.random()) * SPARE_DELAY_MEAN_MS);
290
- this.later(delayMs, () => {
291
- const deadlineAt = this.deps.now() + this.settings.tlsOpenTimeoutMs * MAX_OPEN_ATTEMPTS;
292
- this.open(host, { deadlineAt, background: true }).then(
293
- (channel) => this.keepSpare(host, channel),
294
- (error: unknown) => this.deps.log.debug("tls.spare.failed", { reason: error instanceof Error ? error.name : "error" }),
295
- ).finally(() => {
422
+ const start = (): void => {
423
+ const settled = (entry: PendingOpen): void => {
296
424
  const left = (this.opening.get(host) ?? 1) - 1;
297
425
  if (left <= 0) this.opening.delete(host);
298
426
  else this.opening.set(host, left);
299
- });
300
- });
427
+ const rest = (this.pending.get(host) ?? []).filter((candidate) => candidate !== entry);
428
+ if (rest.length === 0) this.pending.delete(host);
429
+ else this.pending.set(host, rest);
430
+ };
431
+ const startedAt = this.deps.now();
432
+ const deadlineAt = startedAt + this.settings.tlsOpenTimeoutMs * MAX_OPEN_ATTEMPTS;
433
+ // The handlers run after `entry` below is assigned: `open` settles asynchronously.
434
+ const promise: Promise<TlsChannel> = this.open(host, { deadlineAt, background: true }).then(
435
+ (channel) => {
436
+ settled(entry);
437
+ entry.channel = channel;
438
+ entry.readyAt = this.deps.now();
439
+ if (!entry.claimed) this.keepSpare(host, channel, entry.readyAt);
440
+ return channel;
441
+ },
442
+ (error: unknown) => {
443
+ settled(entry);
444
+ this.deps.log.debug("tls.spare.failed", { reason: error instanceof Error ? error.name : "error" });
445
+ throw error;
446
+ },
447
+ );
448
+ promise.catch(() => undefined);
449
+ const entry: PendingOpen = { startedAt, claimed: false, channel: undefined, readyAt: 0, promise };
450
+ const list = this.pending.get(host) ?? [];
451
+ list.push(entry);
452
+ this.pending.set(host, list);
453
+ };
454
+ if (delayMs <= 0) start();
455
+ else this.later(delayMs, start);
301
456
  }
302
457
 
303
- private keepSpare(host: string, channel: TlsChannel): void {
458
+ private keepSpare(host: string, channel: TlsChannel, readyAt: number): void {
304
459
  if (this.closed) {
305
460
  this.teardown(channel);
306
461
  return;
307
462
  }
308
463
  const list = this.spares.get(host) ?? [];
309
- const timer = this.later(this.settings.tlsSpareTtlMs, () => {
310
- const current = this.spares.get(host);
311
- const index = current?.findIndex((spare) => spare.channel === channel) ?? -1;
312
- if (current === undefined || index < 0) return;
313
- current.splice(index, 1);
314
- if (current.length === 0) this.spares.delete(host);
315
- this.teardown(channel);
316
- });
317
- list.push({ channel, timer });
464
+ const spare: Spare = { channel, readyAt, timers: [], superseded: false, refreshable: false };
465
+ const ttlMs = this.settings.tlsSpareTtlMs;
466
+ const ageMs = this.deps.now() - readyAt;
467
+ spare.timers.push(this.later(ttlMs - ageMs, () => this.expire(host, spare)));
468
+ const refreshMs = ttlMs - SPARE_REFILL_LEAD_MS - this.deps.random() * SPARE_REFILL_JITTER_MS - ageMs;
469
+ if (refreshMs > 0) {
470
+ spare.refreshable = true;
471
+ spare.timers.push(this.later(refreshMs, () => this.refresh(host, spare)));
472
+ }
473
+ list.push(spare);
318
474
  this.spares.delete(host);
319
475
  this.spares.set(host, list);
320
476
  while (this.spares.size > MAX_SPARE_HOSTS) {
321
477
  const [oldest, spares] = this.spares.entries().next().value as [string, Spare[]];
322
478
  this.spares.delete(oldest);
323
- for (const spare of spares) {
324
- clearTimeout(spare.timer);
325
- this.timers.delete(spare.timer);
326
- this.teardown(spare.channel);
479
+ for (const evicted of spares) {
480
+ this.clearSpareTimers(evicted);
481
+ this.teardown(evicted.channel);
327
482
  }
328
483
  }
329
484
  }
330
485
 
486
+ /** Before a spare's TTL: open its replacement at once while the host is recent. */
487
+ private refresh(host: string, spare: Spare): void {
488
+ if (this.closed || !this.background || !this.recentlyCalled(host)) return;
489
+ const list = this.spares.get(host);
490
+ if (list === undefined || !list.includes(spare)) return;
491
+ if (list.some((other) => other.superseded)) return;
492
+ spare.superseded = true;
493
+ this.openSpare(host, 0);
494
+ }
495
+
496
+ /**
497
+ * A spare's TTL: tear it down. A spare whose refresh could not run (another
498
+ * spare of the host was being replaced, or the replacement failed) is
499
+ * replaced at once while the host is recent, up to `tlsSpares`.
500
+ */
501
+ private expire(host: string, spare: Spare): void {
502
+ const current = this.spares.get(host);
503
+ const index = current?.indexOf(spare) ?? -1;
504
+ if (current === undefined || index < 0) return;
505
+ current.splice(index, 1);
506
+ if (current.length === 0) this.spares.delete(host);
507
+ this.clearSpareTimers(spare);
508
+ this.teardown(spare.channel);
509
+ if (!spare.refreshable || this.closed || !this.background || !this.recentlyCalled(host)) return;
510
+ if (this.spareCount(host) < this.settings.tlsSpares) this.openSpare(host, 0);
511
+ }
512
+
513
+ private clearSpareTimers(spare: Spare): void {
514
+ for (const timer of spare.timers) {
515
+ clearTimeout(timer);
516
+ this.timers.delete(timer);
517
+ }
518
+ }
519
+
331
520
  private teardown(channel: TlsChannel): void {
332
521
  const deadlineAt = this.deps.now() + this.settings.tlsOpenTimeoutMs;
333
522
  channel.teardown(deadlineAt).catch((error: unknown) =>
@@ -352,7 +541,41 @@ export class TlsPool {
352
541
 
353
542
  /** p95 of `samples`, or 0 below `LATENCY_MIN_SAMPLES`. */
354
543
  function percentile95(samples: readonly number[]): number {
355
- if (samples.length < LATENCY_MIN_SAMPLES) return 0;
544
+ return percentile(samples, 0.95, LATENCY_MIN_SAMPLES);
545
+ }
546
+
547
+ /** The `q` quantile (nearest rank) of `samples`, or 0 below `minSamples`. */
548
+ function percentile(samples: readonly number[], q: number, minSamples: number): number {
549
+ if (samples.length < Math.max(1, minSamples)) return 0;
356
550
  const sorted = [...samples].sort((left, right) => left - right);
357
- return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)] ?? 0;
551
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * q) - 1))] ?? 0;
552
+ }
553
+
554
+ type Settled =
555
+ | { readonly kind: "opened"; readonly channel: TlsChannel }
556
+ | { readonly kind: "failed" }
557
+ | { readonly kind: "aborted"; readonly reason: unknown }
558
+ | { readonly kind: "deadline" };
559
+
560
+ /** Whichever comes first: `open` settling, `signal` aborting, or `withinMs` passing. */
561
+ function settleFirst(open: Promise<TlsChannel>, signal: AbortSignal | undefined, withinMs: number): Promise<Settled> {
562
+ return new Promise<Settled>((resolve) => {
563
+ if (signal?.aborted === true) {
564
+ resolve({ kind: "aborted", reason: signal.reason });
565
+ return;
566
+ }
567
+ let timer: ReturnType<typeof setTimeout> | undefined;
568
+ const onAbort = (): void => finish({ kind: "aborted", reason: signal?.reason });
569
+ const finish = (outcome: Settled): void => {
570
+ if (timer !== undefined) clearTimeout(timer);
571
+ signal?.removeEventListener("abort", onAbort);
572
+ resolve(outcome);
573
+ };
574
+ signal?.addEventListener("abort", onAbort, { once: true });
575
+ timer = setTimeout(() => finish({ kind: "deadline" }), Math.max(0, withinMs));
576
+ open.then(
577
+ (channel) => finish({ kind: "opened", channel }),
578
+ () => finish({ kind: "failed" }),
579
+ );
580
+ });
358
581
  }
@@ -18,7 +18,7 @@ import {
18
18
  type TunnelHop,
19
19
  } from "../fetch-map.js";
20
20
  import { describeError, type WorkerLogger } from "../log.js";
21
- import { TLS_PORT } from "./channel.js";
21
+ import { TLS_PORT, type TlsChannel } from "./channel.js";
22
22
  import { tlsErrorCode, type NoxTlsBindings } from "./module.js";
23
23
  import { NoTunnelExitError, type TlsPool } from "./pool.js";
24
24
  import {
@@ -184,6 +184,7 @@ export class TlsTransport implements TunnelHop {
184
184
  if (budget.signal.aborted) throw budget.signal.reason;
185
185
  throw mapTlsError(error);
186
186
  }
187
+ const sentAt = this.now();
187
188
  try {
188
189
  const reply = await channel.request({
189
190
  http,
@@ -196,6 +197,7 @@ export class TlsTransport implements TunnelHop {
196
197
  background: false,
197
198
  });
198
199
  this.pool.release(channel, true);
200
+ this.logCall(channel, attempt, sentAt, true);
199
201
  return {
200
202
  status: reply.status,
201
203
  headers: reply.headers.filter(([name]) => !CONNECTION_HEADERS.has(name.toLowerCase())),
@@ -205,12 +207,32 @@ export class TlsTransport implements TunnelHop {
205
207
  } catch (error) {
206
208
  this.pool.release(channel, false);
207
209
  if (budget.signal.aborted) throw budget.signal.reason;
208
- if (attempt > 0 || budget.remainingMs() <= 0 || !retriable(error, resendable)) throw mapTlsError(error);
210
+ if (attempt > 0 || budget.remainingMs() <= 0 || !retriable(error, resendable)) {
211
+ this.logCall(channel, attempt, sentAt, false);
212
+ throw mapTlsError(error);
213
+ }
209
214
  this.log.info("tls.retry", { reason: error instanceof Error ? error.name : "error" });
210
215
  }
211
216
  }
212
217
  }
213
218
 
219
+ /**
220
+ * `tls.call`: how the last attempt of a tunnel hop got its session (`path`,
221
+ * `"retry"` after a retry), the handshake wait, the spare's age, and the
222
+ * request exchange time.
223
+ */
224
+ private logCall(channel: TlsChannel, retries: number, sentAt: number, ok: boolean): void {
225
+ const info = this.pool.acquireInfo(channel);
226
+ this.log.info("tls.call", {
227
+ path: retries > 0 ? "retry" : info?.path ?? "inline",
228
+ openMs: info?.openMs ?? 0,
229
+ ...(info?.spareAgeMs === undefined ? {} : { spareAgeMs: info.spareAgeMs }),
230
+ exchangeMs: this.now() - sentAt,
231
+ retries,
232
+ ok,
233
+ });
234
+ }
235
+
214
236
  private encode(request: PreparedRequest, target: TunnelTarget): Uint8Array {
215
237
  const headers: string[] = [];
216
238
  let hasAuthorization = false;