@mark1russell7/service 0.5.11 → 0.5.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,780 @@
1
+ import { realClock } from "./clock.js";
2
+ import { heartbeat } from "./heartbeat.js";
3
+ import { i as heartbeatDetection } from "./timing-DGRi3XCI.js";
4
+ import { A as retrySchedule, B as zeroProblems, C as notSent, D as readyWait, F as timeProblems, G as immediate, H as NEVER, I as trackWithTimer, J as keepaliveSilent, K as keepaliveAnswered, L as wholeProblems, M as sendWithReissue, N as socketEnds, O as retryBackoff, P as stepChecks, R as withinBound, S as ms, U as WHAT, V as MANIFESTATIONS, W as firstDetector, X as unwatched, Y as kernelGiveUp, _ as inFlight, a as helloDial, b as message, c as ageProblems, d as clientProblems, f as clientRecorder, h as finiteMs, i as authority, j as roundTripCheck, k as retryProblems, l as attachClient, m as dialCheck, n as closeTimer, o as statusLineJudge, q as keepaliveReset, r as transition, s as STARTING_POLL, t as clientKnob, u as clientBounds, w as operationTable, x as missesProblems, y as localStateCheck, z as wrapperTimerProblems } from "./machine-BhTRu3u9.js";
5
+ //#region src/websocket/connection.ts
6
+ /** This function gives a message of ws as one buffer. At the default `binaryType` of ws, a message is a buffer already. */
7
+ const asBuffer = (data) => {
8
+ if (Buffer.isBuffer(data)) return data;
9
+ if (Array.isArray(data)) return Buffer.concat(data);
10
+ return Buffer.from(data);
11
+ };
12
+ /**
13
+ * This function adapts a `WebSocket` of ws to a connection. It listens to `error` at once, because
14
+ * in Node an error with no listener throws. It takes the socket from the event `upgrade`.
15
+ */
16
+ const adaptWs = (ws) => {
17
+ let socket;
18
+ let listeners = null;
19
+ const held = [];
20
+ const deliver = (event) => {
21
+ if (listeners === null) held.push(event);
22
+ else event(listeners);
23
+ };
24
+ ws.on("upgrade", (response) => {
25
+ socket = response.socket;
26
+ });
27
+ ws.on("open", () => deliver((given) => given.open()));
28
+ ws.on("message", (data, binary) => deliver((given) => given.message(asBuffer(data), binary)));
29
+ ws.on("pong", (data) => deliver((given) => given.pong(data)));
30
+ ws.on("close", (code, reason) => deliver((given) => given.close(code, reason.toString("utf8"))));
31
+ ws.on("error", (error) => deliver((given) => given.error(error)));
32
+ return {
33
+ listen(given) {
34
+ listeners = given;
35
+ for (const event of held.splice(0)) event(given);
36
+ },
37
+ send: (data, callback) => ws.send(data, (error) => callback(error ?? void 0)),
38
+ ping: (data) => ws.ping(data),
39
+ close: (code, reason) => ws.close(code, reason),
40
+ terminate: () => ws.terminate(),
41
+ get socket() {
42
+ return socket;
43
+ },
44
+ get protocol() {
45
+ return ws.protocol;
46
+ }
47
+ };
48
+ };
49
+ /**
50
+ * This factory makes a `WebSocket` of ws. It loads ws only when a client connects, thus a service
51
+ * that does not use WebSocket does not need it. The specifier is a value, thus the package needs no types of ws.
52
+ */
53
+ const nodeWs = async ({ url, protocols, options }) => {
54
+ const loaded = await import("ws");
55
+ const WebSocket = loaded.WebSocket ?? loaded.default;
56
+ if (WebSocket === void 0) throw new Error("ws gives no WebSocket class");
57
+ return adaptWs(new WebSocket(url, [...protocols], options));
58
+ };
59
+ //#endregion
60
+ //#region src/websocket/meets.ts
61
+ /**
62
+ * This function gives the manifestations that a WebSocket client can meet, and the detectors of
63
+ * each, from its knobs. The WS cells measured them on ws 8:
64
+ * - At a heartbeat of 2 s, a timeout of 1 s and 1 miss, the drop came 0.999 s to 1.0035 s after the
65
+ * ping that got no pong. The slowest of 495 runs took 1.003761 s.
66
+ * - `terminate()` closes the socket 0.3 ms to 1 ms after the drop. The close of the global WebSocket
67
+ * only writes a close frame, and its socket stayed ESTABLISHED.
68
+ * - The kernel of a frozen server acknowledges each ping, thus only the heartbeat finds it.
69
+ * - At `tcp_retries2 = 3`, the kernel gave up before a pong timeout of 10 s.
70
+ * - Without a heartbeat, the client waited for the keepalive of undici: 70 s. ws sets no keepalive.
71
+ */
72
+ const wsMeetings = (name, knobs, misses) => {
73
+ const interval = finiteMs(knobs.heartbeat_interval);
74
+ const detection = heartbeatDetection(knobs.heartbeat_interval, knobs.heartbeat_timeout, misses);
75
+ const heartbeat = (situation) => ({
76
+ by: "heartbeat_detection_time",
77
+ within: detection,
78
+ situation,
79
+ effect: `the heartbeat destroys the socket with terminate(), each call in flight fails as indeterminate, and ${name} connects again`
80
+ });
81
+ const call = {
82
+ by: "request_timeout",
83
+ within: knobs.request_timeout,
84
+ situation: "in flight",
85
+ effect: "the timer of the wrapper ends the call as indeterminate, and the connection stays"
86
+ };
87
+ const giveUp = kernelGiveUp(knobs.retrans_giveup_time, "each call in flight fails as indeterminate");
88
+ const silent = (manifestation) => ({
89
+ manifestation,
90
+ what: WHAT[manifestation],
91
+ detectors: [
92
+ call,
93
+ heartbeat("in flight"),
94
+ giveUp,
95
+ heartbeat("idle"),
96
+ keepaliveSilent(knobs.keepalive_idle)
97
+ ]
98
+ });
99
+ return [
100
+ {
101
+ manifestation: "rst",
102
+ what: WHAT.rst,
103
+ detectors: [
104
+ immediate("in flight", "ws closes with code 1006, the call fails as indeterminate, and the client connects again"),
105
+ {
106
+ by: "heartbeat_interval",
107
+ within: interval === void 0 ? NEVER : knobs.heartbeat_interval,
108
+ situation: "idle",
109
+ effect: "the next ping frame gets a reset, and the client connects again"
110
+ },
111
+ keepaliveReset(knobs.keepalive_idle)
112
+ ]
113
+ },
114
+ {
115
+ manifestation: "fin",
116
+ what: WHAT.fin,
117
+ detectors: [immediate("in flight", "the FIN arrives, ws closes with code 1006, and the call fails as indeterminate"), immediate("idle", "the FIN arrives, ws closes with code 1006, and the client connects again")]
118
+ },
119
+ silent("blackhole"),
120
+ silent("freeze-host"),
121
+ {
122
+ manifestation: "freeze-process",
123
+ what: WHAT["freeze-process"],
124
+ detectors: [
125
+ call,
126
+ heartbeat("in flight"),
127
+ heartbeat("idle"),
128
+ keepaliveAnswered()
129
+ ]
130
+ },
131
+ {
132
+ manifestation: "app-stall",
133
+ what: "the server holds its reply, for example in a slow handler, and its library still answers each ping",
134
+ detectors: [
135
+ call,
136
+ {
137
+ ...heartbeat("idle"),
138
+ within: NEVER,
139
+ effect: "the library of the server still answers each ping with a pong"
140
+ },
141
+ keepaliveAnswered()
142
+ ]
143
+ }
144
+ ];
145
+ };
146
+ //#endregion
147
+ //#region src/websocket/client.ts
148
+ /** These operations are in each table: a ping frame is idempotent (RFC 6455 §5.5.2). */
149
+ const WS_OPERATIONS = { ping: { idempotent: true } };
150
+ /** This function gives the problems of a spec: the knobs that the client cannot use. */
151
+ const specProblems = (name, spec) => {
152
+ const { knobs } = spec;
153
+ const problems = [
154
+ ...timeProblems(name, knobs),
155
+ ...wrapperTimerProblems(name, knobs, {
156
+ connect_bound: "an open",
157
+ request_timeout: "a call",
158
+ heartbeat_timeout: "a ping of the heartbeat"
159
+ }),
160
+ ...zeroProblems(name, "the client", knobs, { open: ["heartbeat_interval", "keepalive_idle"] }),
161
+ ...wholeProblems(name, knobs, { seconds: ["keepalive_idle"] }),
162
+ ...retryProblems(name, knobs),
163
+ ...missesProblems(name, spec.heartbeatMisses, "heartbeat"),
164
+ ...ageProblems(name, knobs, "the client does not replace its connection at an age")
165
+ ];
166
+ const interval = finiteMs(knobs.heartbeat_interval);
167
+ const timeout = finiteMs(knobs.heartbeat_timeout);
168
+ if (interval !== void 0 && interval > 0 && timeout !== void 0 && spec.heartbeatMisses > 1 && timeout >= interval) problems.push(`${name}'s heartbeat_timeout is not below its heartbeat_interval: with one ping at a time, a tick sends no ping while a ping waits`);
169
+ if (spec.path !== void 0 && !spec.path.startsWith("/")) problems.push(`${name}'s path does not start with "/"`);
170
+ if (spec.password !== void 0) problems.push(`${name} takes no password: give the credentials in headers, for example authorization`);
171
+ return problems;
172
+ };
173
+ /** This function gives the URL of a spec. The client speaks ws:// only. */
174
+ const wsUrl = (spec) => `ws://${authority(spec.host, spec.port)}${spec.path ?? "/"}`;
175
+ /** This function gives the arguments of `new WebSocket` from a spec. Each timer of ws comes from the declaration. */
176
+ const wsOptions = (spec) => {
177
+ const { knobs } = spec;
178
+ const keepalive = finiteMs(knobs.keepalive_idle);
179
+ return {
180
+ url: wsUrl(spec),
181
+ protocols: spec.protocols ?? [],
182
+ options: {
183
+ handshakeTimeout: ms(knobs.connect_bound),
184
+ autoPong: true,
185
+ closeTimeout: ms(knobs.close_bound),
186
+ keepAlive: keepalive !== void 0,
187
+ ...keepalive === void 0 ? {} : { keepAliveInitialDelay: keepalive },
188
+ ...spec.headers === void 0 ? {} : { headers: spec.headers }
189
+ }
190
+ };
191
+ };
192
+ /** The key of the dial. RFC 6455 §1.3 gives it as its example. The dial does not read the accept of the server. */
193
+ const DIAL_KEY = "dGhlIHNhbXBsZSBub25jZQ==";
194
+ /** This function gives the bytes of the dial: the upgrade request of RFC 6455 §4.1, with the subprotocols and the headers of the spec. */
195
+ const upgradeRequest = (spec) => {
196
+ return `${[
197
+ `GET ${spec.path ?? "/"} HTTP/1.1`,
198
+ `Host: ${authority(spec.host, spec.port)}`,
199
+ "Upgrade: websocket",
200
+ "Connection: Upgrade",
201
+ `Sec-WebSocket-Key: ${DIAL_KEY}`,
202
+ "Sec-WebSocket-Version: 13",
203
+ ...spec.protocols === void 0 || spec.protocols.length === 0 ? [] : [`Sec-WebSocket-Protocol: ${spec.protocols.join(", ")}`],
204
+ ...Object.entries(spec.headers ?? {}).map(([header, value]) => `${header}: ${value}`)
205
+ ].join("\r\n")}\r\n\r\n`;
206
+ };
207
+ /** This judge reads the status line of the answer to the upgrade. Only 101 passes: a WebSocket server takes the upgrade. */
208
+ const upgradeJudge = statusLineJudge((status, line) => status === 101 ? null : /* @__PURE__ */ new Error(`the server answered ${JSON.stringify(line)} to the upgrade, not 101`));
209
+ /** This function makes the dial of a spec: a new TCP connection, the upgrade request, and a wait for 101. The dial then destroys its socket. */
210
+ const wsDial = (spec) => (host, port, signal) => helloDial(upgradeRequest({
211
+ ...spec,
212
+ host,
213
+ port
214
+ }), upgradeJudge)(host, port, signal);
215
+ /** This function gives the payload of a ping: its sequence number as text. */
216
+ const payload = (sequence) => Buffer.from(String(sequence), "latin1");
217
+ /**
218
+ * This function makes a WebSocket client, a dependency of a service over ws 8.
219
+ * - It has one connection as one generation. The end of the connection ends the generation. The
220
+ * client then connects again by itself, after a backoff.
221
+ * - Its calls are a request until its reply, and a ping until its pong. Each call has a timer of
222
+ * the wrapper, because ws has none.
223
+ * - Its heartbeat sends a ping frame each interval, one at a time. A ping with no pong in its timeout
224
+ * is a miss. At the last miss, `terminate()` destroys the socket at once, and the client connects again.
225
+ * - It tells the outcome of a call in three values, and it settles each call in flight at the end of its generation (C8, C10).
226
+ * - It records the URL and the ends of the socket of each connection at its open (D1).
227
+ *
228
+ * These parts come from the core of the clients: the outcomes, the second try and the wait for a ready
229
+ * client. The backoff and its schedule, the calls in flight, the bounded close and the checks come from
230
+ * the core too. The heartbeat is the heartbeat of the package. This entry gives what ws does in its own way.
231
+ */
232
+ const wsClient = (name, spec, options = {}) => {
233
+ const problems = specProblems(name, spec);
234
+ if (problems.length > 0) throw new Error(problems.join("; "));
235
+ const { knobs } = spec;
236
+ const clock = options.clock ?? realClock;
237
+ const random = options.random ?? Math.random;
238
+ const create = options.create ?? nodeWs;
239
+ const dial = options.dial ?? wsDial(spec);
240
+ const turn = options.turn ?? (() => new Promise((resolve) => setImmediate(resolve)));
241
+ const operations = operationTable(WS_OPERATIONS, spec.operations);
242
+ const requestTimeout = ms(knobs.request_timeout);
243
+ const url = wsUrl(spec);
244
+ let status = "none";
245
+ let connection = null;
246
+ let token = 0;
247
+ let sequence = 0;
248
+ let beat = null;
249
+ let closed = null;
250
+ const retries = retrySchedule(clock, retryBackoff(knobs), random);
251
+ const joins = [];
252
+ const flight = inFlight();
253
+ const readied = readyWait(clock, () => status === "ready");
254
+ const replies = /* @__PURE__ */ new Map();
255
+ const pings = /* @__PURE__ */ new Map();
256
+ const listeners = /* @__PURE__ */ new Set();
257
+ const emit = clientRecorder(options.record, clock, name);
258
+ const record = (on, to = status, detail) => {
259
+ const from = status;
260
+ status = to;
261
+ emit(from, to, on, detail);
262
+ };
263
+ const closing = () => status === "closing" || status === "end";
264
+ /** This function waits for the backoff of the next try, then opens a new generation. */
265
+ const scheduleRetry = () => retries.schedule(() => {
266
+ if (closing()) return;
267
+ record("retry.connect", "connecting", `try ${retries.tries}, ${url}`);
268
+ open();
269
+ });
270
+ /** This function ends the generation that is open. It destroys the socket, because a close frame to a silent server gets no answer. */
271
+ const lose = (on, detail) => {
272
+ const c = connection;
273
+ if (c === null) return;
274
+ connection = null;
275
+ beat?.stop();
276
+ beat = null;
277
+ if (closing()) return;
278
+ record(on, "failing", detail);
279
+ flight.settleAll(`the generation ended with the call in flight (${detail})`);
280
+ c.terminate();
281
+ scheduleRetry();
282
+ };
283
+ /** This function gives a message of the server to the call that waits for it, or to the listeners. A late reply goes to nobody. */
284
+ const receive = (data, binary) => {
285
+ let matched = null;
286
+ try {
287
+ matched = spec.replies?.reply(data, binary) ?? null;
288
+ } catch {}
289
+ if (matched !== null) {
290
+ replies.get(matched.id)?.(matched.error === void 0 ? {
291
+ outcome: "ok",
292
+ value: {
293
+ data,
294
+ binary
295
+ }
296
+ } : {
297
+ outcome: "error",
298
+ error: matched.error,
299
+ reply: true
300
+ });
301
+ return;
302
+ }
303
+ for (const listener of [...listeners]) listener(data, binary);
304
+ };
305
+ /** This function counts a pong. Each pong answers the heartbeat. A pong for a ping answers each earlier ping too, as RFC 6455 §5.5.3 lets it. */
306
+ const pong = (data) => {
307
+ beat?.answer();
308
+ const text = data.toString("latin1");
309
+ if (!/^\d{1,15}$/.test(text)) return;
310
+ const upTo = Number(text);
311
+ for (const [at, settle] of [...pings]) if (at <= upTo) settle({
312
+ outcome: "ok",
313
+ value: void 0
314
+ });
315
+ };
316
+ /** This function starts the heartbeat of a generation: one ping at a time, while the client is ready. */
317
+ const startBeat = () => heartbeat({
318
+ interval: ms(knobs.heartbeat_interval),
319
+ timeout: ms(knobs.heartbeat_timeout),
320
+ misses: spec.heartbeatMisses,
321
+ clock,
322
+ turn,
323
+ due: () => status === "ready",
324
+ live: () => status === "ready",
325
+ ping: () => {
326
+ if (connection === null) throw new Error(`${name} has no connection`);
327
+ connection.ping(payload(sequence += 1));
328
+ },
329
+ onMiss: (misses) => record("heartbeat.miss", status, `${misses} of ${spec.heartbeatMisses}`),
330
+ onDead: () => lose("heartbeat.dead", `no pong in heartbeat_timeout, ${spec.heartbeatMisses} times in sequence`)
331
+ });
332
+ const join = (c) => {
333
+ connection = c;
334
+ retries.reset();
335
+ const generation = joins.length + 1;
336
+ joins.push({
337
+ generation,
338
+ at: clock.now(),
339
+ url,
340
+ protocol: c.protocol,
341
+ ...socketEnds(c.socket)
342
+ });
343
+ record("join", "ready", `generation ${generation}, ${url}`);
344
+ beat = startBeat();
345
+ readied.wake();
346
+ };
347
+ /**
348
+ * This function opens one generation in `connect_bound`. A failure goes to `failing`, and the
349
+ * backoff starts. A connection that opens after its bound or after the close is destroyed at once.
350
+ * The promise resolves when this try ends.
351
+ */
352
+ const open = () => {
353
+ const mine = token += 1;
354
+ let opened = null;
355
+ let joined = false;
356
+ let finish = () => {};
357
+ const ended = new Promise((resolve) => finish = resolve);
358
+ const stale = () => mine !== token || closing();
359
+ const fail = (detail) => {
360
+ bound();
361
+ finish();
362
+ if (stale()) return;
363
+ token += 1;
364
+ opened?.terminate();
365
+ record("connect.failed", "failing", detail);
366
+ scheduleRetry();
367
+ };
368
+ const bound = clock.after(ms(knobs.connect_bound), () => fail("no open connection in connect_bound"));
369
+ (async () => {
370
+ const c = await create(wsOptions(spec));
371
+ opened = c;
372
+ if (stale()) {
373
+ c.terminate();
374
+ return;
375
+ }
376
+ c.listen({
377
+ open: () => {
378
+ if (stale()) {
379
+ c.terminate();
380
+ return;
381
+ }
382
+ bound();
383
+ joined = true;
384
+ join(c);
385
+ finish();
386
+ },
387
+ message: (data, binary) => {
388
+ if (c === connection) receive(data, binary);
389
+ },
390
+ pong: (data) => {
391
+ if (c === connection) pong(data);
392
+ },
393
+ close: (code, reason) => {
394
+ if (c === connection) closed?.();
395
+ if (joined) {
396
+ if (c === connection) lose("connection.end", `code ${code}${reason === "" ? "" : `, ${reason}`}`);
397
+ } else if (!stale()) fail(`the connection closed before its open, with code ${code}`);
398
+ },
399
+ error: (error) => {
400
+ if (joined) {
401
+ if (c === connection) record("connection.error", status, message(error));
402
+ } else if (!stale()) fail(message(error));
403
+ }
404
+ });
405
+ })().catch((error) => fail(message(error)));
406
+ return ended;
407
+ };
408
+ /** This function gives the connection for a call, or the reason that the call does not leave the client. */
409
+ const usable = (signal) => {
410
+ if (status === "none") return notSent(`${name} has not connected`);
411
+ if (status !== "ready" || connection === null) return notSent(`${name} is ${status}, not ready: the call did not leave the client`);
412
+ if (signal?.aborted) return notSent("the caller aborted the call before it left the client");
413
+ return connection;
414
+ };
415
+ /** This function makes one try of a request: the write, then the reply, in `request_timeout`. */
416
+ const requestAttempt = (call, signal) => {
417
+ const c = usable(signal);
418
+ if ("outcome" in c) return Promise.resolve(c);
419
+ if (spec.replies === void 0) return Promise.resolve(notSent(`${name} has no replies in its spec: no message can answer a request`));
420
+ if (replies.has(call.id)) return Promise.resolve(notSent(`${name} has a request with the id ${call.id} in flight`));
421
+ return trackWithTimer(flight, clock, requestTimeout, signal, "the caller abandoned the request after it was written", "no reply in request_timeout: the server can have applied the request", (settle) => {
422
+ replies.set(call.id, settle);
423
+ try {
424
+ c.send(call.data, (error) => {
425
+ if (error !== void 0) settle({
426
+ outcome: "indeterminate",
427
+ error: `the write failed: ${message(error)}`
428
+ });
429
+ });
430
+ } catch (error) {
431
+ settle(notSent(message(error)));
432
+ }
433
+ }, () => {
434
+ replies.delete(call.id);
435
+ });
436
+ };
437
+ /** This function makes one try of a ping: the ping frame, then a pong, in `request_timeout`. */
438
+ const pingAttempt = (signal) => {
439
+ const c = usable(signal);
440
+ if ("outcome" in c) return Promise.resolve(c);
441
+ const at = sequence += 1;
442
+ return trackWithTimer(flight, clock, requestTimeout, signal, "the caller abandoned the ping after it was written", "no pong in request_timeout", (settle) => {
443
+ pings.set(at, settle);
444
+ try {
445
+ c.ping(payload(at));
446
+ } catch (error) {
447
+ settle(notSent(message(error)));
448
+ }
449
+ }, () => {
450
+ pings.delete(at);
451
+ });
452
+ };
453
+ const request = (operation, call, sendOptions = {}) => sendWithReissue({
454
+ clock,
455
+ bound: requestTimeout,
456
+ operation: operations[operation],
457
+ options: sendOptions,
458
+ attempt: (signal) => requestAttempt(call, signal),
459
+ beforeAgain: (left, signal) => readied.wait(left, signal),
460
+ onReissue: () => record("command.reissue", status, operation)
461
+ });
462
+ const ping = (sendOptions = {}) => sendWithReissue({
463
+ clock,
464
+ bound: requestTimeout,
465
+ operation: operations["ping"],
466
+ options: sendOptions,
467
+ attempt: (signal) => pingAttempt(signal),
468
+ beforeAgain: (left, signal) => readied.wait(left, signal),
469
+ onReissue: () => record("command.reissue", status, "ping")
470
+ });
471
+ const send = (data) => {
472
+ const c = usable(void 0);
473
+ if ("outcome" in c) return false;
474
+ try {
475
+ c.send(data, () => {});
476
+ return true;
477
+ } catch {
478
+ return false;
479
+ }
480
+ };
481
+ const handle = {
482
+ async connect(signal) {
483
+ if (signal.aborted) throw new Error(`${name}: the service stopped before the connect`);
484
+ record("status.connecting", "connecting");
485
+ await open();
486
+ },
487
+ async close() {
488
+ if (status === "none" || closing()) return;
489
+ record("close.request", "closing");
490
+ retries.cancel();
491
+ token += 1;
492
+ beat?.stop();
493
+ beat = null;
494
+ const c = connection;
495
+ if (c !== null) {
496
+ const handshake = flight.idle().then(() => new Promise((resolve) => {
497
+ closed = resolve;
498
+ c.close(1e3, "the client closed");
499
+ }));
500
+ if (!await withinBound(clock, ms(knobs.close_bound), handshake)) {
501
+ record("close.bound", "closing");
502
+ record("close.destroy", "closing");
503
+ c.terminate();
504
+ }
505
+ closed = null;
506
+ }
507
+ connection = null;
508
+ flight.settleAll("the client closed with the call in flight");
509
+ record("status.end", "end");
510
+ }
511
+ };
512
+ const interval = finiteMs(knobs.heartbeat_interval);
513
+ const declaration = {
514
+ system: spec.system,
515
+ knobs: {
516
+ connect_bound: knobs.connect_bound,
517
+ request_timeout: knobs.request_timeout,
518
+ heartbeat_interval: knobs.heartbeat_interval,
519
+ heartbeat_timeout: knobs.heartbeat_timeout,
520
+ heartbeat_detection_time: heartbeatDetection(knobs.heartbeat_interval, knobs.heartbeat_timeout, spec.heartbeatMisses),
521
+ retry_base: knobs.retry_base,
522
+ retry_cap: knobs.retry_cap,
523
+ keepalive_idle: knobs.keepalive_idle,
524
+ idle_refresh_interval: interval === void 0 ? knobs.keepalive_idle : knobs.heartbeat_interval,
525
+ max_connection_age: knobs.max_connection_age,
526
+ close_bound: knobs.close_bound,
527
+ retrans_giveup_time: knobs.retrans_giveup_time
528
+ },
529
+ operations
530
+ };
531
+ const checks = (checkOptions) => stepChecks(name, checkOptions, {
532
+ "local-state": localStateCheck(() => status === "ready"),
533
+ "round-trip": roundTripCheck((sendOptions) => ping(sendOptions)),
534
+ dial: dialCheck(dial, spec.host, spec.port)
535
+ });
536
+ return {
537
+ name,
538
+ declaration,
539
+ handle,
540
+ meets: wsMeetings(name, knobs, spec.heartbeatMisses),
541
+ get status() {
542
+ return status;
543
+ },
544
+ joins,
545
+ request,
546
+ ping,
547
+ send,
548
+ onMessage(listener) {
549
+ listeners.add(listener);
550
+ return () => {
551
+ listeners.delete(listener);
552
+ };
553
+ },
554
+ checks,
555
+ attach: (svc, checkOptions) => attachClient(svc, name, declaration, handle, checks(checkOptions)),
556
+ problems: (description) => clientProblems(name, description, clientBounds(knobs))
557
+ };
558
+ };
559
+ //#endregion
560
+ //#region src/websocket/machine.ts
561
+ /** The states where the client can close, and the states where a call can start its second try. */
562
+ const SERVING = [
563
+ "connecting",
564
+ "ready",
565
+ "failing"
566
+ ];
567
+ const LIVE = [...SERVING, "closing"];
568
+ /**
569
+ * The WebSocket client of the service as a `mark1russell7.machine/1` document. It is one generation
570
+ * at a time: one `WebSocket` of ws 8. The states:
571
+ * - `ready`: the upgrade ended with 101, and the client sends each call. Its heartbeat sends a ping frame each interval.
572
+ * - `failing`: no generation is open. The client connects again after its backoff.
573
+ * - `closing`: the calls in flight, then the close handshake, in `close_bound`. At the bound, `terminate()`.
574
+ *
575
+ * The end of the connection and the last miss of the heartbeat end the generation. A test records
576
+ * the transitions of the client and replays them against this document.
577
+ */
578
+ const WS_CLIENT_MACHINE = {
579
+ format: "mark1russell7.machine/1",
580
+ id: "service.machine.websocket-client",
581
+ title: "The WebSocket client of the service, over ws 8",
582
+ layer: "driver",
583
+ version: "0.1.0",
584
+ sources: [{
585
+ ref: "packages/service/src/websocket (the client implements this document)",
586
+ rung: "implemented"
587
+ }],
588
+ states: {
589
+ none: {
590
+ initial: true,
591
+ note: "the client exists, and its first connection does not"
592
+ },
593
+ connecting: { note: "the TCP connect and the upgrade, in connect_bound" },
594
+ ready: { note: "the connection is open. The heartbeat finds a silent server, and the timer of a call ends a call that waits" },
595
+ failing: { note: "no generation is open. The client connects again after its backoff" },
596
+ closing: { note: "the calls in flight, then the close handshake, in close_bound. At the bound, terminate() destroys the socket" },
597
+ end: {
598
+ final: true,
599
+ note: "the service closed the client"
600
+ }
601
+ },
602
+ events: {
603
+ "status.connecting": { kind: "local" },
604
+ join: {
605
+ kind: "rx",
606
+ note: "the upgrade ended with 101: a new generation (D1)"
607
+ },
608
+ "connect.failed": {
609
+ kind: "local",
610
+ note: "the connect or the upgrade failed, or did not end in connect_bound"
611
+ },
612
+ "retry.connect": {
613
+ kind: "timer",
614
+ note: "the backoff ended: a new try"
615
+ },
616
+ "connection.error": {
617
+ kind: "rx",
618
+ note: "an error event of the connection. Its close follows"
619
+ },
620
+ "connection.end": {
621
+ kind: "rx",
622
+ note: "the connection closed: a reset, a FIN, a close frame of the server, or the kernel"
623
+ },
624
+ "heartbeat.miss": {
625
+ kind: "timer",
626
+ note: "a ping frame got no pong in heartbeat_timeout"
627
+ },
628
+ "heartbeat.dead": {
629
+ kind: "local",
630
+ note: "the last miss in sequence: terminate() destroys the socket at once"
631
+ },
632
+ "command.reissue": { kind: "local" },
633
+ "close.request": { kind: "local" },
634
+ "close.bound": {
635
+ kind: "timer",
636
+ note: "the calls in flight or the close handshake did not end in close_bound"
637
+ },
638
+ "close.destroy": {
639
+ kind: "local",
640
+ note: "terminate() destroys a socket that a silent server does not close"
641
+ },
642
+ "status.end": { kind: "local" }
643
+ },
644
+ knobs: {
645
+ connect_bound: clientKnob("handshakeTimeout"),
646
+ heartbeat_interval: clientKnob("heartbeat.interval"),
647
+ heartbeat_timeout: clientKnob("heartbeat.timeout"),
648
+ retry_base: clientKnob("retry.base"),
649
+ retry_cap: clientKnob("retry.cap"),
650
+ close_bound: clientKnob("close.bound")
651
+ },
652
+ timers: {
653
+ connect_timeout: {
654
+ duration: {
655
+ knob: "connect_bound",
656
+ unit: "s"
657
+ },
658
+ arms: ["enter:connecting"],
659
+ cancels: ["join", "connect.failed"],
660
+ fires: "connect.failed",
661
+ note: "the bound of the wrapper on the open. ws bounds the connect and the upgrade with the same time"
662
+ },
663
+ heartbeat: {
664
+ duration: {
665
+ expr: "heartbeat_interval + heartbeat_timeout",
666
+ unit: "s"
667
+ },
668
+ arms: ["join", "heartbeat.miss"],
669
+ cancels: [
670
+ "connection.end",
671
+ "heartbeat.dead",
672
+ "close.request"
673
+ ],
674
+ fires: "heartbeat.miss",
675
+ note: "one ping at a time: a ping frame each interval, and a miss at its timeout with no pong. Each pong starts it again"
676
+ },
677
+ retry: {
678
+ duration: {
679
+ expr: "min(retry_cap, retry_base * pow(2, fired)) + retry_base",
680
+ unit: "s"
681
+ },
682
+ arms: [
683
+ "connect.failed",
684
+ "connection.end",
685
+ "heartbeat.dead"
686
+ ],
687
+ cancels: ["close.request"],
688
+ fires: "retry.connect",
689
+ backoff: { resets: ["join"] },
690
+ note: "the most delay: the backoff doubles from retry_base to retry_cap, with a jitter up to retry_base"
691
+ },
692
+ close: closeTimer()
693
+ },
694
+ transitions: [
695
+ transition("connect", "none", "status.connecting", "connecting"),
696
+ transition("join", "connecting", "join", "ready"),
697
+ transition("connect-failed", "connecting", "connect.failed", "failing", { timer: "connect_timeout" }),
698
+ transition("retry", "failing", "retry.connect", "connecting", { timer: "retry" }),
699
+ transition("connection-end", "ready", "connection.end", "failing"),
700
+ transition("heartbeat-miss", "ready", "heartbeat.miss", "ready", { timer: "heartbeat" }),
701
+ transition("heartbeat-dead", "ready", "heartbeat.dead", "failing"),
702
+ transition("connection-error-ready", "ready", "connection.error", "ready"),
703
+ transition("connection-error-closing", "closing", "connection.error", "closing"),
704
+ ...LIVE.map((state) => transition(`reissue-${state}`, state, "command.reissue", state)),
705
+ transition("close", [...SERVING], "close.request", "closing"),
706
+ transition("close-bound", "closing", "close.bound", "closing", { timer: "close" }),
707
+ transition("close-destroy", "closing", "close.destroy", "closing"),
708
+ transition("end", "closing", "status.end", "end")
709
+ ]
710
+ };
711
+ //#endregion
712
+ //#region src/websocket/starting.ts
713
+ /**
714
+ * This poll is the poll of the checks that `WS_CLIENT_STARTING_KNOBS` fits. It is `STARTING_POLL` of
715
+ * the core: each 5 s with a jitter of 20 %, and a timeout of 4 s (B6, O12). The poll does not depend on the driver.
716
+ */
717
+ const WS_CLIENT_STARTING_POLL = STARTING_POLL;
718
+ /** This count is the heartbeat misses to start from: 1, as in the WS cells. The drop came 0.999 s to 1.0035 s after the ping with no pong. */
719
+ const WS_CLIENT_STARTING_MISSES = 1;
720
+ /**
721
+ * These knobs are a declaration to start from, from what the WS cells measured on ws 8. The runtime
722
+ * has no defaults: a service gives these knobs, or its own. Each value holds the borders that the cells measured:
723
+ * - `connect_bound` 5 s: the connect timeout of DP44. ws sets no handshake timeout by default.
724
+ * - `request_timeout` 9.5 s: in the slowest poll of 10 s (O13).
725
+ * - `heartbeat_interval` 4 s: in the WST cells, a ping each 4 s kept a flow through a router that
726
+ * forgets it at 5 s. A ping each 6 s did not (O03).
727
+ * - `heartbeat_timeout` 3 s: a stall of 1 s with a pong timeout of 3 s dropped nothing. The detection is 4 s + 3 s = 7 s.
728
+ * - `retry_base` 0.5 s and `retry_cap` 4 s: the reconnect of the cells, from 500 ms, doubling to 4 s.
729
+ * The client adds a jitter up to the base. Thus the most delay is 4.5 s, in the slowest poll of 10 s.
730
+ * - `keepalive_idle` "infinite": ws sets no keepalive, and the heartbeat refreshes an idle flow.
731
+ * - `max_connection_age` "infinite": the client does not replace its connection at an age.
732
+ * - `close_bound` 2 s: a close frame to a silent server gets no answer. The `closeTimeout` of ws is 30 s.
733
+ * - `retrans_giveup_time` 924.6 s: the give-up of Linux at `tcp_retries2 = 15`.
734
+ */
735
+ const WS_CLIENT_STARTING_KNOBS = {
736
+ connect_bound: {
737
+ value: 5,
738
+ unit: "s"
739
+ },
740
+ request_timeout: {
741
+ value: 9.5,
742
+ unit: "s"
743
+ },
744
+ heartbeat_interval: {
745
+ value: 4,
746
+ unit: "s"
747
+ },
748
+ heartbeat_timeout: {
749
+ value: 3,
750
+ unit: "s"
751
+ },
752
+ retry_base: {
753
+ value: .5,
754
+ unit: "s"
755
+ },
756
+ retry_cap: {
757
+ value: 4,
758
+ unit: "s"
759
+ },
760
+ keepalive_idle: {
761
+ value: "infinite",
762
+ unit: "s"
763
+ },
764
+ max_connection_age: {
765
+ value: "infinite",
766
+ unit: "s"
767
+ },
768
+ close_bound: {
769
+ value: 2,
770
+ unit: "s"
771
+ },
772
+ retrans_giveup_time: {
773
+ value: 924.6,
774
+ unit: "s"
775
+ }
776
+ };
777
+ //#endregion
778
+ export { MANIFESTATIONS, STARTING_POLL, WS_CLIENT_MACHINE, WS_CLIENT_STARTING_KNOBS, WS_CLIENT_STARTING_MISSES, WS_CLIENT_STARTING_POLL, WS_OPERATIONS, adaptWs, firstDetector, nodeWs, readyWait, retryBackoff, retryProblems, retrySchedule, specProblems, statusLineJudge, unwatched, upgradeJudge, upgradeRequest, wsClient, wsDial, wsMeetings, wsOptions, wsUrl };
779
+
780
+ //# sourceMappingURL=websocket.js.map