@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.
- package/CHANGELOG.md +34 -0
- package/README.md +46 -22
- package/dist/anon-rpc-worker.js +226 -39
- package/dist/anon-rpc-worker.js.keccak256 +1 -1
- package/dist/anon-rpc-worker.provenance.json +7 -7
- package/package.json +3 -3
- package/src/config.ts +6 -2
- package/src/tls/pool.ts +258 -35
- package/src/tls/transport.ts +24 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
0xd9fa04b080a33062680a27cad828196bfb04615a0ebc76bcbffedb6dcbe838d4 anon-rpc-worker.js
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"arch": "x64",
|
|
4
4
|
"os": "linux"
|
|
5
5
|
},
|
|
6
|
-
"gitCommit": "
|
|
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": "
|
|
16
|
-
"wasmSha256": "
|
|
15
|
+
"wasmKeccak": "0x91060e59264ba44c3f16181caff1adcf16193a1e57d6ba4dd3531b192ac42de4",
|
|
16
|
+
"wasmSha256": "6db306a34ef50e9198f4ad6f31c6ffd9feafe459c24b8a29895824c7138241c8"
|
|
17
17
|
},
|
|
18
18
|
"output": {
|
|
19
|
-
"bytes":
|
|
20
|
-
"keccak256": "
|
|
21
|
-
"sha256": "
|
|
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.
|
|
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.
|
|
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.
|
|
29
|
-
"@hisoka-io/nox-wasm": "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
|
-
|
|
115
|
-
|
|
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
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
287
|
-
|
|
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
|
|
290
|
-
|
|
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
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
this.
|
|
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
|
-
|
|
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 *
|
|
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
|
}
|
package/src/tls/transport.ts
CHANGED
|
@@ -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))
|
|
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;
|