@hisoka-io/anon-rpc-worker 0.3.0 → 0.4.0
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 +27 -0
- package/README.md +110 -22
- package/dist/anon-rpc-worker.js +1983 -234
- package/dist/anon-rpc-worker.js.keccak256 +1 -1
- package/dist/anon-rpc-worker.provenance.json +17 -6
- package/package.json +2 -2
- package/snapshot/capabilities.json +7 -4
- package/snapshot/nox-snapshot.json +6 -3
- package/snapshot/nox-snapshot.json.keccak256 +1 -1
- package/src/config.ts +69 -0
- package/src/core.ts +43 -4
- package/src/embed-modules.d.ts +16 -0
- package/src/errors.ts +7 -1
- package/src/fetch-map.ts +18 -1
- package/src/tls/channel.ts +183 -0
- package/src/tls/module.ts +98 -0
- package/src/tls/pool.ts +358 -0
- package/src/tls/transport.ts +257 -0
- package/src/tls/tunnel.ts +360 -0
- package/src/worker.ts +12 -4
|
@@ -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
|
+
}
|
package/src/tls/pool.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
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 after
|
|
16
|
+
* `tlsSpareTtlMs` and not replaced. Hosts live in memory only.
|
|
17
|
+
* - Keep-alive: up to `MAX_KEEP_ALIVE_SESSIONS` sessions per host, each
|
|
18
|
+
* reused for `tlsKeepAliveMs` from its open or `tlsMaxCallsPerSession`
|
|
19
|
+
* calls; a call beyond that runs on a session of its own.
|
|
20
|
+
*/
|
|
21
|
+
import type { TopologyNode } from "@hisoka-io/nox-client";
|
|
22
|
+
import type { TlsSessionSetting } from "../config.js";
|
|
23
|
+
import type { WorkerLogger } from "../log.js";
|
|
24
|
+
import { TlsChannel, type ExchangeTiming } from "./channel.js";
|
|
25
|
+
import type { NoxTlsBindings } from "./module.js";
|
|
26
|
+
import {
|
|
27
|
+
TunnelOpenTimeoutError,
|
|
28
|
+
TunnelRejectedError,
|
|
29
|
+
type CopyPolicy,
|
|
30
|
+
type TunnelDeps,
|
|
31
|
+
type TunnelPort,
|
|
32
|
+
} from "./tunnel.js";
|
|
33
|
+
|
|
34
|
+
/** How long an exit stays skipped after `Disabled` or an open timeout. */
|
|
35
|
+
export const EXIT_SKIP_MS = 10 * 60_000;
|
|
36
|
+
/** Mean delay before a replacement spare opens (exponential). */
|
|
37
|
+
export const SPARE_DELAY_MEAN_MS = 2_000;
|
|
38
|
+
/** Keep-alive sessions per host. */
|
|
39
|
+
export const MAX_KEEP_ALIVE_SESSIONS = 4;
|
|
40
|
+
/** Hosts with spares kept at once; the least recently used host loses its spares. */
|
|
41
|
+
export const MAX_SPARE_HOSTS = 8;
|
|
42
|
+
/** Exits tried for one open. */
|
|
43
|
+
export const MAX_OPEN_ATTEMPTS = 3;
|
|
44
|
+
/** Handshake times kept for the copy timer's p95. */
|
|
45
|
+
export const LATENCY_SAMPLES = 64;
|
|
46
|
+
/** Samples needed before the p95 can raise the copy timer. */
|
|
47
|
+
export const LATENCY_MIN_SAMPLES = 5;
|
|
48
|
+
|
|
49
|
+
export interface PoolSettings {
|
|
50
|
+
readonly tlsSession: TlsSessionSetting;
|
|
51
|
+
readonly tlsSpares: number;
|
|
52
|
+
readonly tlsSpareTtlMs: number;
|
|
53
|
+
readonly tlsKeepAliveMs: number;
|
|
54
|
+
readonly tlsMaxCallsPerSession: number;
|
|
55
|
+
readonly tlsOpenTimeoutMs: number;
|
|
56
|
+
readonly tlsCopyAfterMs: number;
|
|
57
|
+
readonly tlsGapMs: number;
|
|
58
|
+
readonly tlsMaxCopies: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PoolDeps extends TunnelDeps {
|
|
62
|
+
/** Uniform in [0, 1). */
|
|
63
|
+
readonly random: () => number;
|
|
64
|
+
readonly log: WorkerLogger;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** No exit in the topology relays TLS tunnels, or every one is skipped. */
|
|
68
|
+
export class NoTunnelExitError extends Error {
|
|
69
|
+
constructor() {
|
|
70
|
+
super("No exit offers TLS tunnels");
|
|
71
|
+
this.name = "NoTunnelExitError";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Spare {
|
|
76
|
+
readonly channel: TlsChannel;
|
|
77
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface Pooled {
|
|
81
|
+
readonly channel: TlsChannel;
|
|
82
|
+
idle: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class TlsPool {
|
|
86
|
+
readonly policy: CopyPolicy;
|
|
87
|
+
private readonly skipped = new Map<string, number>();
|
|
88
|
+
private readonly spares = new Map<string, Spare[]>();
|
|
89
|
+
private readonly opening = new Map<string, number>();
|
|
90
|
+
private readonly sessions = new Map<string, Pooled[]>();
|
|
91
|
+
private readonly timers = new Set<ReturnType<typeof setTimeout>>();
|
|
92
|
+
private readonly samples: number[] = [];
|
|
93
|
+
/** Hosts called before background work was allowed: they get their spares then. */
|
|
94
|
+
private readonly deferredHosts = new Set<string>();
|
|
95
|
+
private background = false;
|
|
96
|
+
private closed = false;
|
|
97
|
+
|
|
98
|
+
constructor(
|
|
99
|
+
private readonly port: TunnelPort,
|
|
100
|
+
private readonly bindings: NoxTlsBindings,
|
|
101
|
+
private readonly settings: PoolSettings,
|
|
102
|
+
private readonly deps: PoolDeps,
|
|
103
|
+
) {
|
|
104
|
+
const samples = this.samples;
|
|
105
|
+
this.policy = {
|
|
106
|
+
copyAfterMs: () => Math.max(settings.tlsCopyAfterMs, percentile95(samples)),
|
|
107
|
+
gapMs: settings.tlsGapMs,
|
|
108
|
+
maxCopies: settings.tlsMaxCopies,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* True when at least one exit advertises `tunnel_v1`, skipped or not, so
|
|
114
|
+
* exits that time out or refuse opens cause a `network-error`, never plaintext.
|
|
115
|
+
*/
|
|
116
|
+
offersTunnels(): boolean {
|
|
117
|
+
return this.port.tunnelExits().length > 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The first wallet call settled: spares may open from now on. */
|
|
121
|
+
enableBackground(): void {
|
|
122
|
+
if (this.background) return;
|
|
123
|
+
this.background = true;
|
|
124
|
+
for (const host of this.deferredHosts) this.replenish(host);
|
|
125
|
+
this.deferredHosts.clear();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A session ready for a request to `host`: a spare, an idle shared session, or a new one. */
|
|
129
|
+
async acquire(host: string, timing: Omit<ExchangeTiming, "background">): Promise<TlsChannel> {
|
|
130
|
+
if (this.settings.tlsSession === "per-call") {
|
|
131
|
+
const spare = this.takeSpare(host);
|
|
132
|
+
const channel = spare ?? (await this.open(host, { ...timing, background: false }));
|
|
133
|
+
this.replenish(host);
|
|
134
|
+
return channel;
|
|
135
|
+
}
|
|
136
|
+
const pooled = this.sessions.get(host) ?? [];
|
|
137
|
+
const now = this.deps.now();
|
|
138
|
+
const ready = pooled.find((entry) => entry.idle && this.keepAliveUsable(entry.channel, now));
|
|
139
|
+
if (ready !== undefined) {
|
|
140
|
+
ready.idle = false;
|
|
141
|
+
return ready.channel;
|
|
142
|
+
}
|
|
143
|
+
const channel = await this.open(host, { ...timing, background: false });
|
|
144
|
+
const live = this.sessions.get(host) ?? [];
|
|
145
|
+
if (live.length < MAX_KEEP_ALIVE_SESSIONS) {
|
|
146
|
+
live.push({ channel, idle: false });
|
|
147
|
+
this.sessions.set(host, live);
|
|
148
|
+
}
|
|
149
|
+
return channel;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The call on `channel` finished; `ok` when it got a complete response. A
|
|
154
|
+
* per-call session is torn down at once, which also acknowledges the last
|
|
155
|
+
* bytes, so the exit releases the session's window.
|
|
156
|
+
*/
|
|
157
|
+
release(channel: TlsChannel, ok: boolean): void {
|
|
158
|
+
if (this.settings.tlsSession === "per-call") {
|
|
159
|
+
this.teardown(channel);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const pooled = this.sessions.get(channel.host);
|
|
163
|
+
const entry = pooled?.find((candidate) => candidate.channel === channel);
|
|
164
|
+
if (ok && entry !== undefined && this.keepAliveUsable(channel, this.deps.now())) {
|
|
165
|
+
entry.idle = true;
|
|
166
|
+
const left = channel.openedAt + this.settings.tlsKeepAliveMs - this.deps.now();
|
|
167
|
+
this.later(left, () => {
|
|
168
|
+
if (entry.idle) this.retire(channel);
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.retire(channel);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Drop every session; spares and shared sessions are torn down. */
|
|
176
|
+
close(): void {
|
|
177
|
+
this.closed = true;
|
|
178
|
+
for (const timer of this.timers) clearTimeout(timer);
|
|
179
|
+
this.timers.clear();
|
|
180
|
+
for (const list of this.spares.values()) for (const spare of list) this.teardown(spare.channel);
|
|
181
|
+
this.spares.clear();
|
|
182
|
+
for (const list of this.sessions.values()) for (const entry of list) this.teardown(entry.channel);
|
|
183
|
+
this.sessions.clear();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private keepAliveUsable(channel: TlsChannel, now: number): boolean {
|
|
187
|
+
return channel.reusable &&
|
|
188
|
+
channel.calls < this.settings.tlsMaxCallsPerSession &&
|
|
189
|
+
now - channel.openedAt < this.settings.tlsKeepAliveMs;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private retire(channel: TlsChannel): void {
|
|
193
|
+
const pooled = this.sessions.get(channel.host);
|
|
194
|
+
if (pooled !== undefined) {
|
|
195
|
+
const left = pooled.filter((entry) => entry.channel !== channel);
|
|
196
|
+
if (left.length === 0) this.sessions.delete(channel.host);
|
|
197
|
+
else this.sessions.set(channel.host, left);
|
|
198
|
+
}
|
|
199
|
+
this.teardown(channel);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Open a session to `host`, moving to another exit when one fails as described above. */
|
|
203
|
+
private async open(host: string, timing: ExchangeTiming): Promise<TlsChannel> {
|
|
204
|
+
const tried = new Set<string>();
|
|
205
|
+
let lastError: unknown = new NoTunnelExitError();
|
|
206
|
+
for (let attempt = 0; attempt < MAX_OPEN_ATTEMPTS; attempt++) {
|
|
207
|
+
const exit = this.pickExit(tried);
|
|
208
|
+
if (exit === undefined) break;
|
|
209
|
+
tried.add(exit.id);
|
|
210
|
+
const channel = new TlsChannel(this.port, exit, host, this.policy, this.deps, this.bindings);
|
|
211
|
+
const startedAt = this.deps.now();
|
|
212
|
+
try {
|
|
213
|
+
await channel.handshake({ ...timing, openTimeoutMs: this.settings.tlsOpenTimeoutMs });
|
|
214
|
+
} catch (error) {
|
|
215
|
+
channel.free();
|
|
216
|
+
lastError = error;
|
|
217
|
+
if (error instanceof TunnelOpenTimeoutError) {
|
|
218
|
+
this.skip(exit, "open-timeout");
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (error instanceof TunnelRejectedError) {
|
|
222
|
+
this.deps.log.info("tls.reject", { code: error.code, seq: error.seq, retryable: error.retryable });
|
|
223
|
+
if (error.code === "Disabled") {
|
|
224
|
+
this.skip(exit, "disabled");
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (error.retryable) continue;
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
this.sample(this.deps.now() - startedAt);
|
|
232
|
+
this.deps.log.debug("tls.open", {
|
|
233
|
+
tunnel: channel.tunnel.label,
|
|
234
|
+
ms: this.deps.now() - startedAt,
|
|
235
|
+
cpuMs: Math.round(channel.handshakeCpuMs * 10) / 10,
|
|
236
|
+
protocol: channel.protocol,
|
|
237
|
+
spare: timing.background,
|
|
238
|
+
});
|
|
239
|
+
return channel;
|
|
240
|
+
}
|
|
241
|
+
throw lastError;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private pickExit(tried: ReadonlySet<string>): TopologyNode | undefined {
|
|
245
|
+
const pool = this.candidates(tried);
|
|
246
|
+
if (pool.length === 0) return undefined;
|
|
247
|
+
return pool[Math.min(pool.length - 1, Math.floor(this.deps.random() * pool.length))];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private candidates(exclude: ReadonlySet<string>): TopologyNode[] {
|
|
251
|
+
const now = this.deps.now();
|
|
252
|
+
return this.port.tunnelExits().filter((node) => {
|
|
253
|
+
if (exclude.has(node.id)) return false;
|
|
254
|
+
const until = this.skipped.get(node.id);
|
|
255
|
+
if (until === undefined) return true;
|
|
256
|
+
if (until > now) return false;
|
|
257
|
+
this.skipped.delete(node.id);
|
|
258
|
+
return true;
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private skip(exit: TopologyNode, reason: string): void {
|
|
263
|
+
this.skipped.set(exit.id, this.deps.now() + EXIT_SKIP_MS);
|
|
264
|
+
this.deps.log.info("tls.exit.skipped", { reason, minutes: EXIT_SKIP_MS / 60_000 });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private takeSpare(host: string): TlsChannel | undefined {
|
|
268
|
+
const list = this.spares.get(host);
|
|
269
|
+
const spare = list?.shift();
|
|
270
|
+
if (spare === undefined) return undefined;
|
|
271
|
+
clearTimeout(spare.timer);
|
|
272
|
+
this.timers.delete(spare.timer);
|
|
273
|
+
// Most recently used host last (see MAX_SPARE_HOSTS).
|
|
274
|
+
this.spares.delete(host);
|
|
275
|
+
this.spares.set(host, list ?? []);
|
|
276
|
+
return spare.channel;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Per-call mode: open a replacement spare for `host` after an exponential delay. */
|
|
280
|
+
private replenish(host: string): void {
|
|
281
|
+
if (this.settings.tlsSession !== "per-call" || this.settings.tlsSpares === 0 || this.closed) return;
|
|
282
|
+
if (!this.background) {
|
|
283
|
+
if (this.deferredHosts.size < MAX_SPARE_HOSTS) this.deferredHosts.add(host);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const have = (this.spares.get(host)?.length ?? 0) + (this.opening.get(host) ?? 0);
|
|
287
|
+
if (have >= this.settings.tlsSpares) return;
|
|
288
|
+
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(() => {
|
|
296
|
+
const left = (this.opening.get(host) ?? 1) - 1;
|
|
297
|
+
if (left <= 0) this.opening.delete(host);
|
|
298
|
+
else this.opening.set(host, left);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private keepSpare(host: string, channel: TlsChannel): void {
|
|
304
|
+
if (this.closed) {
|
|
305
|
+
this.teardown(channel);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
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 });
|
|
318
|
+
this.spares.delete(host);
|
|
319
|
+
this.spares.set(host, list);
|
|
320
|
+
while (this.spares.size > MAX_SPARE_HOSTS) {
|
|
321
|
+
const [oldest, spares] = this.spares.entries().next().value as [string, Spare[]];
|
|
322
|
+
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);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private teardown(channel: TlsChannel): void {
|
|
332
|
+
const deadlineAt = this.deps.now() + this.settings.tlsOpenTimeoutMs;
|
|
333
|
+
channel.teardown(deadlineAt).catch((error: unknown) =>
|
|
334
|
+
this.deps.log.debug("tls.teardown.failed", { reason: error instanceof Error ? error.name : "error" }),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private sample(ms: number): void {
|
|
339
|
+
this.samples.push(ms);
|
|
340
|
+
if (this.samples.length > LATENCY_SAMPLES) this.samples.shift();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private later(ms: number, run: () => void): ReturnType<typeof setTimeout> {
|
|
344
|
+
const timer = setTimeout(() => {
|
|
345
|
+
this.timers.delete(timer);
|
|
346
|
+
run();
|
|
347
|
+
}, Math.max(0, ms));
|
|
348
|
+
this.timers.add(timer);
|
|
349
|
+
return timer;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** p95 of `samples`, or 0 below `LATENCY_MIN_SAMPLES`. */
|
|
354
|
+
function percentile95(samples: readonly number[]): number {
|
|
355
|
+
if (samples.length < LATENCY_MIN_SAMPLES) return 0;
|
|
356
|
+
const sorted = [...samples].sort((left, right) => left - right);
|
|
357
|
+
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)] ?? 0;
|
|
358
|
+
}
|