@hisoka-io/anon-rpc-worker 0.3.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.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The embedded TLS module (packages/nox-tls, rustls with ring compiled to
3
+ * WebAssembly). The worker runs TLS itself and hands the exit TLS records
4
+ * only. Errors thrown by the module carry a stable string `code`.
5
+ */
6
+
7
+ /** One TLS session (sans-IO): the tunnel moves its records. */
8
+ export interface TlsSessionLike {
9
+ takeOutgoing(): Uint8Array;
10
+ pushIncoming(data: Uint8Array): void;
11
+ pushTransportEof(): void;
12
+ isHandshaking(): boolean;
13
+ wantsWrite(): boolean;
14
+ closeNotifyReceived(): boolean;
15
+ isClosed(): boolean;
16
+ writePlaintext(data: Uint8Array): void;
17
+ takePlaintext(): Uint8Array;
18
+ close(): void;
19
+ protocol(): string;
20
+ free(): void;
21
+ }
22
+
23
+ export interface HttpReplyHead {
24
+ readonly status: number;
25
+ readonly headers: [string, string][];
26
+ readonly body: Uint8Array;
27
+ readonly keepAlive: boolean;
28
+ }
29
+
30
+ /** Incremental HTTP/1.1 response parser. */
31
+ export interface HttpParserLike {
32
+ /** True once the response is complete. */
33
+ push(data: Uint8Array): boolean;
34
+ finishOnClose(closeNotify: boolean): void;
35
+ response(): HttpReplyHead | null;
36
+ free(): void;
37
+ }
38
+
39
+ export interface TlsModuleInfo {
40
+ readonly crate: string;
41
+ /** Trust anchors compiled in (Mozilla roots via webpki-roots). */
42
+ readonly webpkiRoots: number;
43
+ }
44
+
45
+ /** What the worker uses of the module. */
46
+ export interface NoxTlsBindings {
47
+ newSession(serverName: string): TlsSessionLike;
48
+ newParser(headRequest: boolean, maxHeadBytes: number, maxBodyBytes: number): HttpParserLike;
49
+ /** `headers` alternates names and values. */
50
+ encodeRequest(
51
+ method: string,
52
+ authority: string,
53
+ target: string,
54
+ headers: string[],
55
+ body: Uint8Array,
56
+ keepAlive: boolean,
57
+ padJson: boolean,
58
+ ): Uint8Array;
59
+ readonly info: TlsModuleInfo;
60
+ }
61
+
62
+ /** The wasm-bindgen exports of packages/nox-tls (web target). */
63
+ export interface NoxTlsExports {
64
+ TlsClientConfig: new (extraRootDer: Uint8Array | undefined) => object;
65
+ TlsClientSession: new (config: object, serverName: string) => TlsSessionLike;
66
+ HttpResponseParser: new (headRequest: boolean, maxHeadBytes: number, maxBodyBytes: number) => HttpParserLike;
67
+ encodeHttpRequest(
68
+ method: string,
69
+ authority: string,
70
+ target: string,
71
+ headers: string[],
72
+ body: Uint8Array,
73
+ keepAlive: boolean,
74
+ padJson: boolean,
75
+ ): Uint8Array;
76
+ buildInfo(): TlsModuleInfo;
77
+ }
78
+
79
+ /**
80
+ * Bindings over an initialised module. `extraRootDer` is a test bed's CA,
81
+ * embedded by `build-test-worker.mjs --extra-root`; release bundles embed none.
82
+ */
83
+ export function tlsBindings(exports: NoxTlsExports, extraRootDer: Uint8Array | null): NoxTlsBindings {
84
+ const config = new exports.TlsClientConfig(extraRootDer ?? undefined);
85
+ return {
86
+ newSession: (serverName) => new exports.TlsClientSession(config, serverName),
87
+ newParser: (headRequest, maxHeadBytes, maxBodyBytes) =>
88
+ new exports.HttpResponseParser(headRequest, maxHeadBytes, maxBodyBytes),
89
+ encodeRequest: (...args) => exports.encodeHttpRequest(...args),
90
+ info: exports.buildInfo(),
91
+ };
92
+ }
93
+
94
+ /** A `code` the module set on an error, or `undefined`. */
95
+ export function tlsErrorCode(error: unknown): string | undefined {
96
+ const code = typeof error === "object" && error !== null ? (error as { code?: unknown }).code : undefined;
97
+ return typeof code === "string" && /^(TLS|HTTP)_[A-Z_]+$/u.test(code) ? code : undefined;
98
+ }
@@ -0,0 +1,581 @@
1
+ /**
2
+ * TLS sessions per host (E2E TLS design §4.2, §5): which tunnel exit opens a
3
+ * session, the handshaken spares of per-call mode, the shared sessions of
4
+ * keep-alive mode, and the exits skipped after a failed open.
5
+ *
6
+ * - Exit choice: uniformly random among exits advertising `tunnel_v1` that are
7
+ * not skipped. An exit that answers `Disabled`, or nothing within
8
+ * `tlsOpenTimeoutMs`, is skipped for `EXIT_SKIP_MS`; the open moves to
9
+ * another exit with a fresh tunnel id and ClientHello (only a ClientHello
10
+ * was sent, so that is always safe). `SessionLimit`, `RateLimited` and the
11
+ * other retryable refusals move the open at once.
12
+ * - Per-call spares: after a call to a host (once the first wallet call has
13
+ * settled), a replacement spare opens on a random exit after an
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 `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.
26
+ * - Keep-alive: up to `MAX_KEEP_ALIVE_SESSIONS` sessions per host, each
27
+ * reused for `tlsKeepAliveMs` from its open or `tlsMaxCallsPerSession`
28
+ * calls; a call beyond that runs on a session of its own.
29
+ */
30
+ import type { TopologyNode } from "@hisoka-io/nox-client";
31
+ import type { TlsSessionSetting } from "../config.js";
32
+ import type { WorkerLogger } from "../log.js";
33
+ import { TlsChannel, type ExchangeTiming } from "./channel.js";
34
+ import type { NoxTlsBindings } from "./module.js";
35
+ import {
36
+ TunnelOpenTimeoutError,
37
+ TunnelRejectedError,
38
+ type CopyPolicy,
39
+ type TunnelDeps,
40
+ type TunnelPort,
41
+ } from "./tunnel.js";
42
+
43
+ /** How long an exit stays skipped after `Disabled` or an open timeout. */
44
+ export const EXIT_SKIP_MS = 10 * 60_000;
45
+ /** Mean delay before a replacement spare opens (exponential). */
46
+ export const SPARE_DELAY_MEAN_MS = 2_000;
47
+ /** Keep-alive sessions per host. */
48
+ export const MAX_KEEP_ALIVE_SESSIONS = 4;
49
+ /** Hosts with spares kept at once; the least recently used host loses its spares. */
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;
65
+ /** Exits tried for one open. */
66
+ export const MAX_OPEN_ATTEMPTS = 3;
67
+ /** Handshake times kept for the copy timer's p95. */
68
+ export const LATENCY_SAMPLES = 64;
69
+ /** Samples needed before the p95 can raise the copy timer. */
70
+ export const LATENCY_MIN_SAMPLES = 5;
71
+
72
+ export interface PoolSettings {
73
+ readonly tlsSession: TlsSessionSetting;
74
+ readonly tlsSpares: number;
75
+ readonly tlsSpareTtlMs: number;
76
+ readonly tlsKeepAliveMs: number;
77
+ readonly tlsMaxCallsPerSession: number;
78
+ readonly tlsOpenTimeoutMs: number;
79
+ readonly tlsCopyAfterMs: number;
80
+ readonly tlsGapMs: number;
81
+ readonly tlsMaxCopies: number;
82
+ }
83
+
84
+ export interface PoolDeps extends TunnelDeps {
85
+ /** Uniform in [0, 1). */
86
+ readonly random: () => number;
87
+ readonly log: WorkerLogger;
88
+ }
89
+
90
+ /** No exit in the topology relays TLS tunnels, or every one is skipped. */
91
+ export class NoTunnelExitError extends Error {
92
+ constructor() {
93
+ super("No exit offers TLS tunnels");
94
+ this.name = "NoTunnelExitError";
95
+ }
96
+ }
97
+
98
+ interface Spare {
99
+ readonly channel: TlsChannel;
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;
129
+ }
130
+
131
+ interface Pooled {
132
+ readonly channel: TlsChannel;
133
+ idle: boolean;
134
+ }
135
+
136
+ export class TlsPool {
137
+ readonly policy: CopyPolicy;
138
+ private readonly skipped = new Map<string, number>();
139
+ private readonly spares = new Map<string, Spare[]>();
140
+ /** Spare opens per host, scheduled or in flight. */
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>();
147
+ private readonly sessions = new Map<string, Pooled[]>();
148
+ private readonly timers = new Set<ReturnType<typeof setTimeout>>();
149
+ private readonly samples: number[] = [];
150
+ /** Hosts called before background work was allowed: they get their spares then. */
151
+ private readonly deferredHosts = new Set<string>();
152
+ private background = false;
153
+ private closed = false;
154
+
155
+ constructor(
156
+ private readonly port: TunnelPort,
157
+ private readonly bindings: NoxTlsBindings,
158
+ private readonly settings: PoolSettings,
159
+ private readonly deps: PoolDeps,
160
+ ) {
161
+ const samples = this.samples;
162
+ this.policy = {
163
+ copyAfterMs: () => Math.max(settings.tlsCopyAfterMs, percentile95(samples)),
164
+ gapMs: settings.tlsGapMs,
165
+ maxCopies: settings.tlsMaxCopies,
166
+ };
167
+ }
168
+
169
+ /**
170
+ * True when at least one exit advertises `tunnel_v1`, skipped or not, so
171
+ * exits that time out or refuse opens cause a `network-error`, never plaintext.
172
+ */
173
+ offersTunnels(): boolean {
174
+ return this.port.tunnelExits().length > 0;
175
+ }
176
+
177
+ /** The first wallet call settled: spares may open from now on. */
178
+ enableBackground(): void {
179
+ if (this.background) return;
180
+ this.background = true;
181
+ for (const host of this.deferredHosts) this.replenish(host);
182
+ this.deferredHosts.clear();
183
+ }
184
+
185
+ /** A session ready for a request to `host`: a spare, an idle shared session, or a new one. */
186
+ async acquire(host: string, timing: Omit<ExchangeTiming, "background">): Promise<TlsChannel> {
187
+ const startedAt = this.deps.now();
188
+ if (this.settings.tlsSession === "per-call") {
189
+ this.noteCall(host, startedAt);
190
+ const spare = this.takeSpare(host);
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
+ });
204
+ this.replenish(host);
205
+ return channel;
206
+ }
207
+ const pooled = this.sessions.get(host) ?? [];
208
+ const now = this.deps.now();
209
+ const ready = pooled.find((entry) => entry.idle && this.keepAliveUsable(entry.channel, now));
210
+ if (ready !== undefined) {
211
+ ready.idle = false;
212
+ this.acquired.set(ready.channel, { path: "reused", openMs: 0, spareAgeMs: undefined });
213
+ return ready.channel;
214
+ }
215
+ const channel = await this.open(host, { ...timing, background: false });
216
+ this.acquired.set(channel, { path: "inline", openMs: this.deps.now() - startedAt, spareAgeMs: undefined });
217
+ const live = this.sessions.get(host) ?? [];
218
+ if (live.length < MAX_KEEP_ALIVE_SESSIONS) {
219
+ live.push({ channel, idle: false });
220
+ this.sessions.set(host, live);
221
+ }
222
+ return channel;
223
+ }
224
+
225
+ /**
226
+ * The call on `channel` finished; `ok` when it got a complete response. A
227
+ * per-call session is torn down at once, which also acknowledges the last
228
+ * bytes, so the exit releases the session's window.
229
+ */
230
+ release(channel: TlsChannel, ok: boolean): void {
231
+ if (this.settings.tlsSession === "per-call") {
232
+ this.teardown(channel);
233
+ return;
234
+ }
235
+ const pooled = this.sessions.get(channel.host);
236
+ const entry = pooled?.find((candidate) => candidate.channel === channel);
237
+ if (ok && entry !== undefined && this.keepAliveUsable(channel, this.deps.now())) {
238
+ entry.idle = true;
239
+ const left = channel.openedAt + this.settings.tlsKeepAliveMs - this.deps.now();
240
+ this.later(left, () => {
241
+ if (entry.idle) this.retire(channel);
242
+ });
243
+ return;
244
+ }
245
+ this.retire(channel);
246
+ }
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
+
253
+ /** Drop every session; spares and shared sessions are torn down. */
254
+ close(): void {
255
+ this.closed = true;
256
+ for (const timer of this.timers) clearTimeout(timer);
257
+ this.timers.clear();
258
+ for (const list of this.spares.values()) for (const spare of list) this.teardown(spare.channel);
259
+ this.spares.clear();
260
+ for (const list of this.sessions.values()) for (const entry of list) this.teardown(entry.channel);
261
+ this.sessions.clear();
262
+ }
263
+
264
+ private keepAliveUsable(channel: TlsChannel, now: number): boolean {
265
+ return channel.reusable &&
266
+ channel.calls < this.settings.tlsMaxCallsPerSession &&
267
+ now - channel.openedAt < this.settings.tlsKeepAliveMs;
268
+ }
269
+
270
+ private retire(channel: TlsChannel): void {
271
+ const pooled = this.sessions.get(channel.host);
272
+ if (pooled !== undefined) {
273
+ const left = pooled.filter((entry) => entry.channel !== channel);
274
+ if (left.length === 0) this.sessions.delete(channel.host);
275
+ else this.sessions.set(channel.host, left);
276
+ }
277
+ this.teardown(channel);
278
+ }
279
+
280
+ /** Open a session to `host`, moving to another exit when one fails as described above. */
281
+ private async open(host: string, timing: ExchangeTiming): Promise<TlsChannel> {
282
+ const tried = new Set<string>();
283
+ let lastError: unknown = new NoTunnelExitError();
284
+ for (let attempt = 0; attempt < MAX_OPEN_ATTEMPTS; attempt++) {
285
+ const exit = this.pickExit(tried);
286
+ if (exit === undefined) break;
287
+ tried.add(exit.id);
288
+ const channel = new TlsChannel(this.port, exit, host, this.policy, this.deps, this.bindings);
289
+ const startedAt = this.deps.now();
290
+ try {
291
+ await channel.handshake({ ...timing, openTimeoutMs: this.settings.tlsOpenTimeoutMs });
292
+ } catch (error) {
293
+ channel.free();
294
+ lastError = error;
295
+ if (error instanceof TunnelOpenTimeoutError) {
296
+ this.skip(exit, "open-timeout");
297
+ continue;
298
+ }
299
+ if (error instanceof TunnelRejectedError) {
300
+ this.deps.log.info("tls.reject", { code: error.code, seq: error.seq, retryable: error.retryable });
301
+ if (error.code === "Disabled") {
302
+ this.skip(exit, "disabled");
303
+ continue;
304
+ }
305
+ if (error.retryable) continue;
306
+ }
307
+ throw error;
308
+ }
309
+ this.sample(this.deps.now() - startedAt);
310
+ this.deps.log.debug("tls.open", {
311
+ tunnel: channel.tunnel.label,
312
+ ms: this.deps.now() - startedAt,
313
+ cpuMs: Math.round(channel.handshakeCpuMs * 10) / 10,
314
+ protocol: channel.protocol,
315
+ spare: timing.background,
316
+ });
317
+ return channel;
318
+ }
319
+ throw lastError;
320
+ }
321
+
322
+ private pickExit(tried: ReadonlySet<string>): TopologyNode | undefined {
323
+ const pool = this.candidates(tried);
324
+ if (pool.length === 0) return undefined;
325
+ return pool[Math.min(pool.length - 1, Math.floor(this.deps.random() * pool.length))];
326
+ }
327
+
328
+ private candidates(exclude: ReadonlySet<string>): TopologyNode[] {
329
+ const now = this.deps.now();
330
+ return this.port.tunnelExits().filter((node) => {
331
+ if (exclude.has(node.id)) return false;
332
+ const until = this.skipped.get(node.id);
333
+ if (until === undefined) return true;
334
+ if (until > now) return false;
335
+ this.skipped.delete(node.id);
336
+ return true;
337
+ });
338
+ }
339
+
340
+ private skip(exit: TopologyNode, reason: string): void {
341
+ this.skipped.set(exit.id, this.deps.now() + EXIT_SKIP_MS);
342
+ this.deps.log.info("tls.exit.skipped", { reason, minutes: EXIT_SKIP_MS / 60_000 });
343
+ }
344
+
345
+ private takeSpare(host: string): Spare | undefined {
346
+ const list = this.spares.get(host);
347
+ const spare = list?.shift();
348
+ if (spare === undefined) return undefined;
349
+ this.clearSpareTimers(spare);
350
+ // Most recently used host last (see MAX_SPARE_HOSTS).
351
+ this.spares.delete(host);
352
+ this.spares.set(host, list ?? []);
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;
400
+ }
401
+
402
+ /** Per-call mode: open a replacement spare for `host` after an exponential delay. */
403
+ private replenish(host: string): void {
404
+ if (this.settings.tlsSession !== "per-call" || this.settings.tlsSpares === 0 || this.closed) return;
405
+ if (!this.background) {
406
+ if (this.deferredHosts.size < MAX_SPARE_HOSTS) this.deferredHosts.add(host);
407
+ return;
408
+ }
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 {
421
+ this.opening.set(host, (this.opening.get(host) ?? 0) + 1);
422
+ const start = (): void => {
423
+ const settled = (entry: PendingOpen): void => {
424
+ const left = (this.opening.get(host) ?? 1) - 1;
425
+ if (left <= 0) this.opening.delete(host);
426
+ else this.opening.set(host, left);
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);
456
+ }
457
+
458
+ private keepSpare(host: string, channel: TlsChannel, readyAt: number): void {
459
+ if (this.closed) {
460
+ this.teardown(channel);
461
+ return;
462
+ }
463
+ const list = this.spares.get(host) ?? [];
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);
474
+ this.spares.delete(host);
475
+ this.spares.set(host, list);
476
+ while (this.spares.size > MAX_SPARE_HOSTS) {
477
+ const [oldest, spares] = this.spares.entries().next().value as [string, Spare[]];
478
+ this.spares.delete(oldest);
479
+ for (const evicted of spares) {
480
+ this.clearSpareTimers(evicted);
481
+ this.teardown(evicted.channel);
482
+ }
483
+ }
484
+ }
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
+
520
+ private teardown(channel: TlsChannel): void {
521
+ const deadlineAt = this.deps.now() + this.settings.tlsOpenTimeoutMs;
522
+ channel.teardown(deadlineAt).catch((error: unknown) =>
523
+ this.deps.log.debug("tls.teardown.failed", { reason: error instanceof Error ? error.name : "error" }),
524
+ );
525
+ }
526
+
527
+ private sample(ms: number): void {
528
+ this.samples.push(ms);
529
+ if (this.samples.length > LATENCY_SAMPLES) this.samples.shift();
530
+ }
531
+
532
+ private later(ms: number, run: () => void): ReturnType<typeof setTimeout> {
533
+ const timer = setTimeout(() => {
534
+ this.timers.delete(timer);
535
+ run();
536
+ }, Math.max(0, ms));
537
+ this.timers.add(timer);
538
+ return timer;
539
+ }
540
+ }
541
+
542
+ /** p95 of `samples`, or 0 below `LATENCY_MIN_SAMPLES`. */
543
+ function percentile95(samples: readonly number[]): number {
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;
550
+ const sorted = [...samples].sort((left, right) => left - right);
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
+ });
581
+ }