@mark1russell7/service 0.0.0-stage → 0.2.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/build/index.js ADDED
@@ -0,0 +1,1901 @@
1
+ import { i as recoverSpan, n as asWindow, r as failSpan, t as asDebounce } from "./timing-DxZ4nkQg.js";
2
+ import { n as mayReissue, r as operationProblems, t as realClock } from "./clock-DlJFFZlX.js";
3
+ import { convertExact, convertible, quantity, rationals, valueIn } from "@mark1russell7/quantity";
4
+ import { arr, fromStates } from "@mark1russell7/algebra";
5
+ //#region src/description.ts
6
+ /**
7
+ * This is the format of a service as data: what `describe()` exports, and nothing starts. The
8
+ * timer algebra and the ordering law read it. Each timer has a unit. Each knob comes from the
9
+ * declaration, because the runtime has no defaults. A bound that nobody gives is "no bound", not
10
+ * zero.
11
+ */
12
+ const FORMAT = "mark1russell7.service/1";
13
+ //#endregion
14
+ //#region src/latch.ts
15
+ const ms = (amount) => valueIn(amount, "ms");
16
+ /**
17
+ * This function makes a latch. `tripped` is its state at the start: a readiness latch starts
18
+ * tripped, and a liveness latch starts released. The latch starts `changed` each time it trips or
19
+ * releases, and it gives each transition to `trace`. A debounce has the states `released` and
20
+ * `tripped`. A window also has the states `failing` and `recovering`, while one of its timers runs.
21
+ */
22
+ const latch = (timing, clock, tripped, changed, trace) => {
23
+ let state = tripped;
24
+ let since = clock.now();
25
+ let streak = 0;
26
+ let fails = 0;
27
+ let passes = 0;
28
+ let failingSince = null;
29
+ let passingSince = null;
30
+ let failTimer = null;
31
+ let passTimer = null;
32
+ /** This function gives the name of the state, as the machine documents name it. */
33
+ const named = () => state ? passingSince === null ? "tripped" : "recovering" : failingSince === null ? "released" : "failing";
34
+ /** This function does one step of the machine, and gives its transition to the trace. */
35
+ const step = (on, change) => {
36
+ const from = named();
37
+ change();
38
+ trace?.(from, named(), on);
39
+ };
40
+ const set = (next) => {
41
+ if (next === state) return;
42
+ state = next;
43
+ since = clock.now();
44
+ changed();
45
+ };
46
+ const stopFail = () => {
47
+ failTimer?.();
48
+ failTimer = null;
49
+ failingSince = null;
50
+ };
51
+ const stopPass = () => {
52
+ passTimer?.();
53
+ passTimer = null;
54
+ passingSince = null;
55
+ };
56
+ const debounce = (result) => {
57
+ if (timing.type !== "debounce") return;
58
+ if (result.outcome === "ok") {
59
+ passes += 1;
60
+ fails = 0;
61
+ if (state && passes >= timing.recoverAfter) set(false);
62
+ } else {
63
+ fails += 1;
64
+ passes = 0;
65
+ if (!state && fails >= timing.failAfter) set(true);
66
+ }
67
+ };
68
+ const windowed = (result) => {
69
+ if (timing.type !== "window") return;
70
+ if (result.outcome === "ok") {
71
+ stopFail();
72
+ if (!state) return;
73
+ if (timing.recoverAfter === void 0) {
74
+ set(false);
75
+ return;
76
+ }
77
+ if (passingSince !== null) return;
78
+ const wait = ms(timing.recoverAfter);
79
+ if (wait <= 0) {
80
+ stopPass();
81
+ set(false);
82
+ return;
83
+ }
84
+ passingSince = result.at;
85
+ passTimer = clock.after(wait, () => step("window.recovered", () => {
86
+ passTimer = null;
87
+ passingSince = null;
88
+ set(false);
89
+ }));
90
+ return;
91
+ }
92
+ stopPass();
93
+ if (state || failingSince !== null) return;
94
+ const wait = ms(timing.failAfter);
95
+ if (wait <= 0) {
96
+ stopFail();
97
+ set(true);
98
+ return;
99
+ }
100
+ failingSince = result.at;
101
+ failTimer = clock.after(wait, () => step("window.failed", () => {
102
+ failTimer = null;
103
+ failingSince = null;
104
+ set(true);
105
+ }));
106
+ };
107
+ return {
108
+ get tripped() {
109
+ return state;
110
+ },
111
+ get since() {
112
+ return since;
113
+ },
114
+ get streak() {
115
+ return streak;
116
+ },
117
+ record(result) {
118
+ if (result.outcome === "indeterminate") return;
119
+ const pass = result.outcome === "ok";
120
+ step(pass ? "poll.ok" : "poll.fail", () => {
121
+ streak = pass ? Math.max(0, streak) + 1 : Math.min(0, streak) - 1;
122
+ debounce(result);
123
+ windowed(result);
124
+ });
125
+ },
126
+ stop() {
127
+ stopFail();
128
+ stopPass();
129
+ }
130
+ };
131
+ };
132
+ //#endregion
133
+ //#region src/poller.ts
134
+ const message$1 = (error) => error instanceof Error ? error.message : String(error);
135
+ /**
136
+ * This function makes a poller. The loop is on a fixed delay from the start of each poll. Thus the
137
+ * next poll starts one interval after the current poll starts, with the jitter (±20 % is 0.2).
138
+ * The timing algebra of the declaration uses this model.
139
+ *
140
+ * Each poll makes a new measurement. While a use of the check is pending, the poller does not
141
+ * start the check again. Such a poll fails at once with the same cause, and the sequence of
142
+ * failures continues.
143
+ */
144
+ const poller = (options) => {
145
+ const { clock, poll, run } = options;
146
+ const interval = valueIn(poll.interval, "ms");
147
+ const timeout = valueIn(poll.timeout, "ms");
148
+ let last = null;
149
+ let next = null;
150
+ let stopped = false;
151
+ let looping = false;
152
+ let pending = null;
153
+ const active = /* @__PURE__ */ new Set();
154
+ /** This function gives the name of the state, as the machine document of the poller names it. */
155
+ const named = () => stopped ? "stopped" : pending !== null ? pending.late ? "hung" : "calling" : looping ? "waiting" : "idle";
156
+ const step = (on, change) => {
157
+ const from = named();
158
+ change();
159
+ options.trace?.(from, named(), on);
160
+ };
161
+ const result = (outcome, started, fields = {}) => ({
162
+ outcome,
163
+ at: clock.now(),
164
+ took: clock.now() - started,
165
+ cause: fields.cause ?? null,
166
+ timedOut: fields.timedOut ?? false,
167
+ thrown: fields.thrown ?? false,
168
+ forced: fields.forced ?? false
169
+ });
170
+ const once = () => {
171
+ const started = clock.now();
172
+ if (options.forced()) return Promise.resolve(result("error", started, {
173
+ cause: "the force-fail list names it",
174
+ forced: true
175
+ }));
176
+ if (pending !== null) {
177
+ const cause = `its call of ${Math.round(started - pending.since)} ms ago is still pending`;
178
+ return Promise.resolve(result("error", started, { cause }));
179
+ }
180
+ const controller = new AbortController();
181
+ return new Promise((resolve) => {
182
+ let done = false;
183
+ const finish = (value) => {
184
+ if (done) return;
185
+ done = true;
186
+ active.delete(abandon);
187
+ cancel();
188
+ resolve(value);
189
+ };
190
+ const abandon = () => {
191
+ controller.abort();
192
+ finish(result("error", started, { cause: "the runtime stopped" }));
193
+ };
194
+ active.add(abandon);
195
+ const call = {
196
+ since: started,
197
+ late: false
198
+ };
199
+ pending = call;
200
+ const cancel = clock.after(timeout, () => {
201
+ controller.abort();
202
+ step("timeout", () => {
203
+ if (pending === call) call.late = true;
204
+ });
205
+ finish(result("error", started, {
206
+ cause: `no answer in ${timeout} ms`,
207
+ timedOut: true
208
+ }));
209
+ });
210
+ const settled = () => {
211
+ if (pending !== call) return;
212
+ step(call.late ? "late-answer" : "answer", () => {
213
+ pending = null;
214
+ });
215
+ };
216
+ let answer;
217
+ try {
218
+ answer = Promise.resolve(run(controller.signal));
219
+ } catch (error) {
220
+ answer = Promise.reject(error);
221
+ }
222
+ answer.then((value) => {
223
+ settled();
224
+ const outcome = value ?? "ok";
225
+ finish(result(outcome, started, outcome === "ok" ? {} : { cause: `the check gave ${outcome}` }));
226
+ }, (error) => {
227
+ settled();
228
+ finish(result("error", started, {
229
+ cause: message$1(error),
230
+ thrown: true
231
+ }));
232
+ });
233
+ });
234
+ };
235
+ const handle = (value) => {
236
+ last = value;
237
+ if (!stopped) options.record(value);
238
+ return value;
239
+ };
240
+ const jittered = () => interval * (1 + poll.jitter * (2 * options.random() - 1));
241
+ const tick = () => {
242
+ next = null;
243
+ if (stopped) return;
244
+ let polled;
245
+ step("tick", () => {
246
+ next = clock.after(jittered(), tick);
247
+ polled = once();
248
+ });
249
+ polled.then(handle);
250
+ };
251
+ return {
252
+ get last() {
253
+ return last;
254
+ },
255
+ pollNow: () => {
256
+ let polled;
257
+ step("sweep", () => {
258
+ polled = once();
259
+ });
260
+ return polled.then(handle);
261
+ },
262
+ start(delay) {
263
+ if (stopped || next !== null) return;
264
+ step("start", () => {
265
+ looping = true;
266
+ next = clock.after(Math.max(0, delay), tick);
267
+ });
268
+ },
269
+ jittered,
270
+ stop() {
271
+ if (stopped) return;
272
+ step("stop", () => {
273
+ stopped = true;
274
+ next?.();
275
+ next = null;
276
+ });
277
+ for (const abandon of [...active]) abandon();
278
+ }
279
+ };
280
+ };
281
+ //#endregion
282
+ //#region src/health.ts
283
+ /**
284
+ * This function makes the runtime of a declaration. The service has no defaults of its own: each
285
+ * timing, poll and coupling comes from the description.
286
+ */
287
+ const healthRuntime = (description, runs, options = {}) => {
288
+ const clock = options.clock ?? realClock;
289
+ const random = options.random ?? Math.random;
290
+ const forced = new Set(options.forceFail ?? []);
291
+ const startedAt = clock.now();
292
+ const budget = valueIn(description.policies.startup, "ms");
293
+ const entries = /* @__PURE__ */ new Map();
294
+ const dependencies = new Set(description.dependencies.map((dependency) => dependency.name));
295
+ const lateDependencies = /* @__PURE__ */ new Set();
296
+ const listeners = /* @__PURE__ */ new Set();
297
+ let started = false;
298
+ let draining = false;
299
+ let stopped = false;
300
+ let gate = "closed";
301
+ const tracer = (machine, instance) => (from, to, on) => options.record?.({
302
+ at: clock.now(),
303
+ machine,
304
+ instance,
305
+ from,
306
+ to,
307
+ on
308
+ });
309
+ const gateTrace = tracer("gate", description.name);
310
+ const moveGate = (to, on, detail) => {
311
+ const from = gate;
312
+ gate = to;
313
+ options.record?.({
314
+ at: clock.now(),
315
+ machine: "gate",
316
+ instance: description.name,
317
+ from,
318
+ to,
319
+ on,
320
+ ...detail === void 0 ? {} : { detail }
321
+ });
322
+ };
323
+ let budgetTimer = clock.after(budget, () => {
324
+ budgetTimer = null;
325
+ if (gate !== "open" && gate !== "failed") gateTrace(gate, gate, "startup.over-budget");
326
+ });
327
+ const changed = () => {
328
+ if (listeners.size === 0) return;
329
+ const now = verdict();
330
+ for (const listener of listeners) listener(now);
331
+ };
332
+ const add = (check, given, late) => {
333
+ const run = options.wrapCheck?.(check, given) ?? given;
334
+ const timing = check.gates === null ? null : description.policies[check.gates];
335
+ const made = timing === null ? null : latch(timing, clock, check.gates === "readiness", changed, tracer(timing.type === "window" ? "window" : "latch", check.name));
336
+ const entry = {
337
+ check,
338
+ latch: made,
339
+ poller: poller({
340
+ name: check.name,
341
+ poll: check.poll,
342
+ run,
343
+ clock,
344
+ random,
345
+ forced: () => forced.has(check.name) || check.dependency !== null && forced.has(check.dependency),
346
+ record: (result) => {
347
+ made?.record(result);
348
+ if (result.outcome === "ok" && check.dependency !== null) {
349
+ if (lateDependencies.delete(check.dependency)) changed();
350
+ }
351
+ },
352
+ trace: tracer("poller", check.name)
353
+ })
354
+ };
355
+ entries.set(check.name, entry);
356
+ if (late && !stopped) {
357
+ entry.poller.pollNow();
358
+ entry.poller.start(entry.poller.jittered());
359
+ }
360
+ };
361
+ for (const check of description.checks) {
362
+ const run = runs.get(check.name);
363
+ if (run === void 0) throw new Error(`check ${check.name} has no function: the runtime has no defaults`);
364
+ add(check, run, false);
365
+ }
366
+ const latchesOf = (gateOf, keep = () => true) => [...entries.values()].flatMap((entry) => entry.check.gates === gateOf && entry.latch !== null && keep(entry.check) ? [entry.latch] : []);
367
+ const released = (latches) => latches.every((each) => !each.tripped);
368
+ /** This function tells if a policy fails through a declared coupling from another policy. */
369
+ const coupledOff = (to, base) => description.couplings.some((coupling) => coupling.to === to && !base[coupling.from]);
370
+ const verdict = () => {
371
+ const base = {
372
+ readiness: released(latchesOf("readiness")) && lateDependencies.size === 0,
373
+ liveness: released(latchesOf("liveness"))
374
+ };
375
+ const alive = base.liveness && !coupledOff("liveness", base);
376
+ const open = started && !draining && !coupledOff("readiness", base);
377
+ const ready = open && base.readiness;
378
+ const dependencyReady = (name) => !lateDependencies.has(name) && released(latchesOf("readiness", (check) => check.dependency === name));
379
+ const own = released(latchesOf("readiness", (check) => check.dependency === null));
380
+ const dependenciesOut = {};
381
+ for (const name of dependencies) dependenciesOut[name] = dependencyReady(name);
382
+ const controllers = {};
383
+ for (const controller of description.controllers) controllers[controller.name] = open && own && controller.requires.every(dependencyReady);
384
+ const checks = {};
385
+ for (const [name, entry] of entries) checks[name] = {
386
+ gates: entry.check.gates,
387
+ dependency: entry.check.dependency,
388
+ tripped: entry.latch?.tripped ?? null,
389
+ streak: entry.latch?.streak ?? 0,
390
+ since: entry.latch?.since ?? null,
391
+ last: entry.poller.last
392
+ };
393
+ const elapsed = clock.now() - startedAt;
394
+ return {
395
+ started,
396
+ draining,
397
+ ready,
398
+ alive,
399
+ controllers,
400
+ dependencies: dependenciesOut,
401
+ checks,
402
+ startup: {
403
+ budget,
404
+ elapsed,
405
+ overBudget: !started && elapsed > budget
406
+ }
407
+ };
408
+ };
409
+ const answer = (kind, pass, controller) => ({
410
+ status: pass || kind === "report" ? 200 : 503,
411
+ contentType: "application/health+json",
412
+ body: {
413
+ status: pass ? "pass" : "fail",
414
+ service: description.name,
415
+ probe: kind,
416
+ ...controller === void 0 ? {} : { controller },
417
+ ...kind === "report" ? { verdict: verdict() } : {}
418
+ }
419
+ });
420
+ return {
421
+ verdict,
422
+ probe(kind, controller) {
423
+ const now = verdict();
424
+ if (kind === "startup") return answer(kind, now.started);
425
+ if (kind === "liveness") return answer(kind, now.alive);
426
+ if (kind === "report") return answer(kind, now.ready && now.alive);
427
+ if (controller === void 0) return answer(kind, now.ready);
428
+ const known = now.controllers[controller];
429
+ if (known === void 0) throw new Error(`${description.name} has no controller ${controller}`);
430
+ return answer(kind, known, controller);
431
+ },
432
+ subscribe(listener) {
433
+ listeners.add(listener);
434
+ return () => listeners.delete(listener);
435
+ },
436
+ forceFail(name, on) {
437
+ if (on) forced.add(name);
438
+ else forced.delete(name);
439
+ },
440
+ drain() {
441
+ if (draining) return;
442
+ draining = true;
443
+ changed();
444
+ },
445
+ stop() {
446
+ stopped = true;
447
+ budgetTimer?.();
448
+ budgetTimer = null;
449
+ for (const entry of entries.values()) {
450
+ entry.poller.stop();
451
+ entry.latch?.stop();
452
+ }
453
+ },
454
+ beginStartup() {
455
+ if (gate === "closed") moveGate("starting", "startup.begin");
456
+ },
457
+ failStartup(cause) {
458
+ if (gate === "open" || gate === "failed") return;
459
+ moveGate("failed", "startup.fail", cause);
460
+ budgetTimer?.();
461
+ budgetTimer = null;
462
+ },
463
+ async warmUp() {
464
+ if (started || stopped || gate === "failed" || gate === "sweeping") return;
465
+ moveGate("sweeping", "mark-ready");
466
+ const sweep = clock.now();
467
+ await Promise.all([...entries.values()].map((entry) => entry.poller.pollNow()));
468
+ if (stopped || gate !== "sweeping") return;
469
+ for (const entry of entries.values()) entry.poller.start(entry.poller.jittered() - (clock.now() - sweep));
470
+ started = true;
471
+ budgetTimer?.();
472
+ budgetTimer = null;
473
+ moveGate("open", "sweep.done");
474
+ changed();
475
+ },
476
+ addCheck(check, run) {
477
+ if (entries.has(check.name)) throw new Error(`${description.name} already has a check named ${check.name}`);
478
+ add(check, run, true);
479
+ changed();
480
+ },
481
+ addDependency(name) {
482
+ if (dependencies.has(name)) return;
483
+ dependencies.add(name);
484
+ lateDependencies.add(name);
485
+ changed();
486
+ }
487
+ };
488
+ };
489
+ //#endregion
490
+ //#region src/process.ts
491
+ /** These are the signals that the runtime handles. */
492
+ const EXIT_SIGNALS = [
493
+ "SIGINT",
494
+ "SIGTERM",
495
+ "SIGQUIT"
496
+ ];
497
+ /** This port is the process of Node. */
498
+ const nodeProcess = {
499
+ on(signal, handler) {
500
+ const host = globalThis.process;
501
+ if (host === void 0) throw new Error("no process: give the runtime a process port");
502
+ host.on(signal, handler);
503
+ return () => {
504
+ host.off(signal, handler);
505
+ };
506
+ },
507
+ exit(code) {
508
+ const host = globalThis.process;
509
+ if (host === void 0) throw new Error("no process: give the runtime a process port");
510
+ host.exit(code);
511
+ }
512
+ };
513
+ //#endregion
514
+ //#region src/requests.ts
515
+ /**
516
+ * This function makes a tracker. `refuses` gives the reason that the service refuses a new request
517
+ * for a controller at this time, or null when it admits it.
518
+ */
519
+ const tracker = (controllers, clock, refuses) => {
520
+ const byName = new Map(controllers.map((controller) => [controller.name, controller]));
521
+ const flights = /* @__PURE__ */ new Set();
522
+ const idle = /* @__PURE__ */ new Set();
523
+ let intake = true;
524
+ const settle = (flight) => {
525
+ flights.delete(flight);
526
+ if (flights.size === 0) for (const done of [...idle]) done();
527
+ };
528
+ /** This function answers a request that the shutdown stopped, by the idempotency of its route. */
529
+ const cut = (flight, cause) => {
530
+ const operation = byName.get(flight.controller).routes[flight.route];
531
+ const since = quantity(Math.max(0, clock.now() - flight.started) / 1e3, "s");
532
+ const retryable = mayReissue(operation, since);
533
+ return {
534
+ kind: "cut",
535
+ status: retryable ? 503 : 500,
536
+ retryable,
537
+ cause
538
+ };
539
+ };
540
+ return {
541
+ request(controller, route, work) {
542
+ const held = byName.get(controller);
543
+ if (held === void 0) throw new Error(`there is no controller ${controller}`);
544
+ if (held.routes[route] === void 0) throw new Error(`controller ${controller} does not declare the route ${route}`);
545
+ const refusal = intake ? refuses(controller) : "the service stopped its intake";
546
+ if (refusal !== null) return Promise.resolve({
547
+ kind: "refused",
548
+ status: 503,
549
+ retryable: true,
550
+ cause: refusal
551
+ });
552
+ return new Promise((resolve) => {
553
+ let answered = false;
554
+ const flight = {
555
+ controller,
556
+ route,
557
+ started: clock.now(),
558
+ abort: new AbortController(),
559
+ answer: (outcome) => {
560
+ if (answered) return;
561
+ answered = true;
562
+ settle(flight);
563
+ resolve(outcome);
564
+ }
565
+ };
566
+ flights.add(flight);
567
+ let promise;
568
+ try {
569
+ promise = Promise.resolve(work(flight.abort.signal));
570
+ } catch (error) {
571
+ promise = Promise.reject(error);
572
+ }
573
+ promise.then((value) => {
574
+ if (answered) return;
575
+ answered = true;
576
+ settle(flight);
577
+ resolve({
578
+ kind: "done",
579
+ value
580
+ });
581
+ }, (error) => {
582
+ if (answered) return;
583
+ const outcome = flight.abort.signal.aborted ? cut(flight, "the shutdown aborted it, and it failed") : {
584
+ kind: "failed",
585
+ error
586
+ };
587
+ answered = true;
588
+ settle(flight);
589
+ resolve(outcome);
590
+ });
591
+ });
592
+ },
593
+ inFlight(controller) {
594
+ let count = 0;
595
+ for (const flight of flights) if (controller === void 0 || flight.controller === controller) count += 1;
596
+ return count;
597
+ },
598
+ stopIntake() {
599
+ intake = false;
600
+ },
601
+ cooperativeAbort() {
602
+ for (const flight of flights) flight.abort.abort(/* @__PURE__ */ new Error("the cooperative abort of the drain"));
603
+ },
604
+ drain(deadline) {
605
+ if (flights.size === 0) return Promise.resolve();
606
+ return new Promise((resolve) => {
607
+ let finished = false;
608
+ const done = () => {
609
+ if (finished) return;
610
+ finished = true;
611
+ idle.delete(done);
612
+ cancel();
613
+ resolve();
614
+ };
615
+ idle.add(done);
616
+ const cancel = clock.after(Math.max(0, deadline - clock.now()), () => {
617
+ for (const flight of [...flights]) {
618
+ flight.abort.abort(/* @__PURE__ */ new Error("the drain reached its deadline"));
619
+ flight.answer(cut(flight, "the drain reached its deadline"));
620
+ }
621
+ done();
622
+ });
623
+ });
624
+ }
625
+ };
626
+ };
627
+ //#endregion
628
+ //#region src/lifecycle.ts
629
+ /**
630
+ * These are the stages of the shutdown, in their sequence. A declared phase with one of these
631
+ * names gets the work of the runtime for its stage. A phase with another name gets only the work of
632
+ * its owners.
633
+ * - `fail readiness`: readiness fails at the start of the shutdown. This phase then holds for its
634
+ * cap, while the endpoints of the platform remove the service.
635
+ * - `stop intake`: each transport stops its intake, and new requests get a retryable 503.
636
+ * - `drain`: the transports and the requests in flight end, until the cap. The cooperative abort
637
+ * comes before the end of the cap, by the cooperative window.
638
+ * - `close transports`: each transport closes.
639
+ * - `stop the poller`: each check and watchdog stops. Then the signal of the service aborts.
640
+ * - `tear down dependencies`: each dependency closes, in the reverse sequence of its connection.
641
+ * - `close the health server`: the probes stop last.
642
+ */
643
+ const STAGES = [
644
+ "fail readiness",
645
+ "stop intake",
646
+ "drain",
647
+ "close transports",
648
+ "stop the poller",
649
+ "tear down dependencies",
650
+ "close the health server"
651
+ ];
652
+ /** This function gives the place of a stage in the sequence, or -1 for a phase of another name. */
653
+ const stageRank = (name) => STAGES.indexOf(name);
654
+ /** The signal of the service aborts after the poller stops, and before the dependencies close. */
655
+ const ABORT_AFTER = stageRank("stop the poller");
656
+ /**
657
+ * This function gives the time of the cooperative abort, in milliseconds after the drain starts. It
658
+ * is the cap less the cooperative window, and not less than half the cap. A window without a bound
659
+ * gives half the cap.
660
+ */
661
+ const cooperativeAbortAt = (cap, window) => {
662
+ const before = window.value === "infinite" ? Number.POSITIVE_INFINITY : valueIn(window, "ms");
663
+ return Math.max(cap - before, cap / 2);
664
+ };
665
+ const message = (error) => error instanceof Error ? error.message : String(error);
666
+ /**
667
+ * This function gives the problems of a declaration that the lifecycle cannot start: a stage with
668
+ * work and no declared phase. The runtime has no defaults, thus a transport without a drain phase
669
+ * has no time to drain.
670
+ */
671
+ const lifecycleProblems = (description, components) => {
672
+ const declared = new Set(description.shutdown.phases.map((phase) => phase.name));
673
+ const needs = (stage, why) => declared.has(stage) ? [] : [`${description.name} has ${why}, thus it declares the phase "${stage}"`];
674
+ const problems = [];
675
+ for (const transport of description.transports) if (!components.transports.has(transport.name)) problems.push(`transport ${transport.name} has no implementation: the runtime has no defaults`);
676
+ if (components.transports.size > 0 || description.controllers.length > 0) {
677
+ problems.push(...needs("stop intake", "transports or controllers"));
678
+ problems.push(...needs("drain", "transports or controllers"));
679
+ }
680
+ if (components.transports.size > 0) problems.push(...needs("close transports", "transports"));
681
+ if (description.checks.length > 0) problems.push(...needs("stop the poller", "checks"));
682
+ if (components.dependencies.size > 0) problems.push(...needs("tear down dependencies", "dependency handles"));
683
+ if (components.healthServer !== null) problems.push(...needs("close the health server", "a health server"));
684
+ return problems;
685
+ };
686
+ /**
687
+ * This function starts the lifecycle of a service. The startup has a sequence: the health server
688
+ * first, then each dependency, then each transport, and the startup gate last. A signal during the
689
+ * startup gets its shutdown after the startup ends. The shutdown does the declared phases in their
690
+ * sequence, each one capped. Only the lifecycle ends the process: 0 after a clean shutdown, and 1
691
+ * after an error.
692
+ */
693
+ const lifecycle = (description, runtime, components, options = {}) => {
694
+ const clock = options.clock ?? realClock;
695
+ const port = options.process ?? nodeProcess;
696
+ const record = (machine, from, to, on, detail) => options.record?.({
697
+ at: clock.now(),
698
+ machine,
699
+ instance: description.name,
700
+ from,
701
+ to,
702
+ on,
703
+ ...detail === void 0 ? {} : { detail }
704
+ });
705
+ const service = new AbortController();
706
+ const errors = [];
707
+ let state = "starting";
708
+ let exitCode = null;
709
+ let intakeStopped = false;
710
+ let shutdownStarted = false;
711
+ let failedStartup = false;
712
+ let exitState = "running";
713
+ const moveExit = (to, on, detail) => {
714
+ record("exit", exitState, to, on, detail);
715
+ exitState = to;
716
+ };
717
+ const fail = (cause) => {
718
+ errors.push(cause);
719
+ record("exit", exitState, exitState, "error", cause);
720
+ };
721
+ const requests = tracker(description.controllers, clock, (controller) => {
722
+ if (state === "starting") return "the service has not started";
723
+ if (state === "exited") return "the service has exited";
724
+ if (state === "draining") return null;
725
+ return runtime.verdict().controllers[controller] === true ? null : `controller ${controller} is not ready`;
726
+ });
727
+ let finish = () => {};
728
+ const done = new Promise((resolve) => {
729
+ finish = resolve;
730
+ });
731
+ let healthListening = false;
732
+ const connected = [];
733
+ const startedTransports = [];
734
+ const offs = EXIT_SIGNALS.map((signal) => port.on(signal, () => {
735
+ shutdown(signal);
736
+ }));
737
+ const startup = (async () => {
738
+ runtime.beginStartup();
739
+ const started = (what) => record("gate", "starting", "starting", "component.started", what);
740
+ try {
741
+ if (components.healthServer !== null) {
742
+ await components.healthServer.listen(runtime);
743
+ healthListening = true;
744
+ started("the health server");
745
+ }
746
+ for (const dependency of description.dependencies) {
747
+ const handle = components.dependencies.get(dependency.name);
748
+ if (handle === void 0) continue;
749
+ await handle.connect(service.signal);
750
+ connected.push([dependency.name, handle]);
751
+ started(`dependency ${dependency.name}`);
752
+ }
753
+ for (const transport of description.transports) {
754
+ const impl = components.transports.get(transport.name);
755
+ await impl.start(service.signal);
756
+ startedTransports.push([transport.name, impl]);
757
+ started(`${transport.kind} ${transport.name}`);
758
+ }
759
+ await runtime.warmUp();
760
+ if (state === "starting") state = "running";
761
+ } catch (error) {
762
+ const cause = `the startup failed: ${message(error)}`;
763
+ failedStartup = true;
764
+ runtime.failStartup(cause);
765
+ fail(cause);
766
+ if (exitState === "running") moveExit("stopping", "startup.fail", cause);
767
+ else record("exit", exitState, exitState, "startup.fail", cause);
768
+ }
769
+ })();
770
+ startup.then(() => {
771
+ if (failedStartup && !shutdownStarted) shutdown("the startup failed");
772
+ });
773
+ /** This function does the work of one phase, until its cap. It gives true when the work ended in time. */
774
+ const runPhase = async (phase, hold, work) => {
775
+ const cap = valueIn(phase.cap, "ms");
776
+ const deadline = clock.now() + cap;
777
+ const capped = new AbortController();
778
+ const context = {
779
+ deadline,
780
+ signal: capped.signal,
781
+ clock
782
+ };
783
+ let pending = work.length;
784
+ const all = Promise.all(work.map(async (each) => {
785
+ try {
786
+ await each(context);
787
+ } catch (error) {
788
+ fail(`the phase ${phase.name} failed: ${message(error)}`);
789
+ } finally {
790
+ pending -= 1;
791
+ }
792
+ }));
793
+ let cancelCap = () => {};
794
+ const end = new Promise((resolve) => {
795
+ cancelCap = clock.after(cap, () => resolve("cap"));
796
+ });
797
+ if (await Promise.race([all.then(() => "work"), end]) === "work" && hold) await end;
798
+ cancelCap();
799
+ capped.abort(/* @__PURE__ */ new Error(`the phase ${phase.name} reached its cap`));
800
+ return pending === 0;
801
+ };
802
+ async function shutdown(cause = "the service asked for it") {
803
+ if (shutdownStarted || exitState === "exited") {
804
+ if (exitState === "stopping") record("exit", exitState, exitState, "exit.repeat", cause);
805
+ return done;
806
+ }
807
+ shutdownStarted = true;
808
+ if (exitState === "running") moveExit("stopping", "exit.request", cause);
809
+ await startup;
810
+ state = "draining";
811
+ runtime.drain();
812
+ let drainState = "running";
813
+ const moveDrain = (to, on, detail) => {
814
+ record("drain", drainState, to, on, detail);
815
+ drainState = to;
816
+ };
817
+ const abortService = () => {
818
+ if (service.signal.aborted) return;
819
+ service.abort(/* @__PURE__ */ new Error("the service stopped"));
820
+ record("drain", drainState, drainState, "signal.abort");
821
+ };
822
+ const phases = description.shutdown.phases;
823
+ let on = "exit.request";
824
+ for (const phase of phases) {
825
+ const rank = stageRank(phase.name);
826
+ moveDrain(phase.name, on);
827
+ if (rank > ABORT_AFTER) abortService();
828
+ const work = [];
829
+ if (phase.name === "stop intake") {
830
+ work.push(() => {
831
+ intakeStopped = true;
832
+ requests.stopIntake();
833
+ });
834
+ for (const [, impl] of startedTransports) work.push(() => impl.stopIntake());
835
+ }
836
+ let cancelAbort = () => {};
837
+ if (phase.name === "drain") {
838
+ const cap = valueIn(phase.cap, "ms");
839
+ const cooperative = new AbortController();
840
+ const fireAt = cooperativeAbortAt(cap, description.shutdown.cooperativeWindow);
841
+ cancelAbort = clock.after(fireAt, () => {
842
+ cooperative.abort(/* @__PURE__ */ new Error("the cooperative abort of the drain"));
843
+ requests.cooperativeAbort();
844
+ record("drain", drainState, drainState, "drain.cooperative-abort");
845
+ });
846
+ work.push(({ deadline }) => requests.drain(deadline));
847
+ for (const [, impl] of startedTransports) work.push(({ deadline }) => impl.drain(deadline, cooperative.signal));
848
+ }
849
+ if (phase.name === "close transports") for (const [, impl] of startedTransports) work.push(() => impl.close());
850
+ if (phase.name === "stop the poller") work.push(() => {
851
+ runtime.stop();
852
+ components.stopPollers();
853
+ });
854
+ if (phase.name === "tear down dependencies") work.push(async () => {
855
+ for (const [, handle] of [...connected].reverse()) await handle.close();
856
+ });
857
+ if (phase.name === "close the health server" && healthListening && components.healthServer !== null) {
858
+ const server = components.healthServer;
859
+ work.push(() => server.close());
860
+ }
861
+ for (const owned of components.phaseWork.get(phase.name) ?? []) work.push(owned);
862
+ const inTime = await runPhase(phase, phase.name === "fail readiness", work);
863
+ cancelAbort();
864
+ if (!inTime) fail(`the phase ${phase.name} did not end in its cap of ${valueIn(phase.cap, "s")} s`);
865
+ on = inTime ? "phase.done" : "phase.cap";
866
+ }
867
+ moveDrain("done", on);
868
+ abortService();
869
+ runtime.stop();
870
+ components.stopPollers();
871
+ if (!intakeStopped) requests.stopIntake();
872
+ for (const off of offs) off();
873
+ state = "exited";
874
+ exitCode = errors.length > 0 ? 1 : 0;
875
+ moveExit("exited", "shutdown.done", `exit ${exitCode}`);
876
+ finish(exitCode);
877
+ port.exit(exitCode);
878
+ return exitCode;
879
+ }
880
+ return {
881
+ health: runtime,
882
+ get state() {
883
+ return state;
884
+ },
885
+ get exitCode() {
886
+ return exitCode;
887
+ },
888
+ get errors() {
889
+ return [...errors];
890
+ },
891
+ started: startup,
892
+ done,
893
+ signal: service.signal,
894
+ shutdown,
895
+ request: (controller, route, work) => requests.request(controller, route, work),
896
+ inFlight: (controller) => requests.inFlight(controller),
897
+ keepAlive: () => state === "running" && runtime.verdict().ready
898
+ };
899
+ };
900
+ //#endregion
901
+ //#region src/watchdog.ts
902
+ /** This function starts a watchdog on a clock. */
903
+ const watchdog = (clock, spec, trace) => {
904
+ const tick = valueIn(spec.tick, "ms");
905
+ const threshold = valueIn(spec.threshold, "ms");
906
+ if (!(tick > 0)) throw new RangeError(`watchdog ${spec.name} ticks every ${tick} ms, not above zero`);
907
+ let blocked = false;
908
+ let stopped = false;
909
+ let worst = 0;
910
+ let sincePoll = 0;
911
+ let next = null;
912
+ const named = () => stopped ? "stopped" : blocked ? "blocked" : "watching";
913
+ const arm = () => {
914
+ const due = clock.now() + tick;
915
+ next = clock.after(tick, () => {
916
+ next = null;
917
+ if (stopped) return;
918
+ const late = Math.max(0, clock.now() - due);
919
+ worst = Math.max(worst, late);
920
+ sincePoll = Math.max(sincePoll, late);
921
+ const from = named();
922
+ blocked = late > threshold;
923
+ trace?.(from, named(), "tick");
924
+ arm();
925
+ });
926
+ };
927
+ arm();
928
+ return {
929
+ check: () => {
930
+ const failed = blocked || sincePoll > threshold;
931
+ sincePoll = 0;
932
+ return failed ? "error" : "ok";
933
+ },
934
+ get worst() {
935
+ return worst;
936
+ },
937
+ get blocked() {
938
+ return blocked;
939
+ },
940
+ stop() {
941
+ if (stopped) return;
942
+ const from = named();
943
+ stopped = true;
944
+ next?.();
945
+ next = null;
946
+ trace?.(from, "stopped", "stop");
947
+ }
948
+ };
949
+ };
950
+ //#endregion
951
+ //#region src/builder.ts
952
+ const isTime = (amount) => convertible(amount.unit, "s");
953
+ const above = (amount) => amount.value === "infinite" || isTime(amount) && valueIn(amount, "s") > 0;
954
+ const whole = (count) => Number.isInteger(count) && count >= 1;
955
+ /** This function gives the problems of a timing: a zero interval or count fails decoding. */
956
+ const timingProblems = (policy, timing) => {
957
+ if (timing.type === "debounce") {
958
+ const problems = [];
959
+ if (!above(timing.interval)) problems.push(`the ${policy} debounce polls at an interval that is not above zero`);
960
+ if (!whole(timing.failAfter)) problems.push(`the ${policy} debounce fails after ${timing.failAfter} polls, not a whole number above zero`);
961
+ if (!whole(timing.recoverAfter)) problems.push(`the ${policy} debounce recovers after ${timing.recoverAfter} polls, not a whole number above zero`);
962
+ return problems;
963
+ }
964
+ const negative = (amount) => amount !== void 0 && (!isTime(amount) || valueIn(amount, "s") < 0);
965
+ return negative(timing.failAfter) || negative(timing.recoverAfter) ? [`the ${policy} window is not a time of zero or more`] : [];
966
+ };
967
+ /** This function starts the composition of a service. */
968
+ const service = (name) => {
969
+ if (name.trim().length === 0) throw new Error("a service needs a name");
970
+ const transports = [];
971
+ const dependencies = [];
972
+ const controllers = [];
973
+ const checks = [];
974
+ const runs = /* @__PURE__ */ new Map();
975
+ const couplings = [];
976
+ const phases = [];
977
+ let readiness;
978
+ let liveness;
979
+ let startup;
980
+ let cooperative;
981
+ let started = false;
982
+ let partial = false;
983
+ let runtime;
984
+ let live;
985
+ let running;
986
+ const transportImpls = /* @__PURE__ */ new Map();
987
+ const handles = /* @__PURE__ */ new Map();
988
+ const phaseWork = /* @__PURE__ */ new Map();
989
+ const watchdogs = [];
990
+ let probes = null;
991
+ /** This function stops a change to the shape of the service after the start. */
992
+ const composing = (what) => {
993
+ if (started) throw new Error(`${name} has started: ${what} is declared before svc.start()`);
994
+ };
995
+ const once = (list, entry, what) => {
996
+ if (list.some((held) => held.name === entry)) throw new Error(`${name} already has a ${what} named ${entry}`);
997
+ };
998
+ const transport = (kind) => (entry, knobs, impl) => {
999
+ composing(`a ${kind}`);
1000
+ once(transports, entry, "transport");
1001
+ transports.push({
1002
+ name: entry,
1003
+ kind,
1004
+ knobs: { ...knobs }
1005
+ });
1006
+ if (impl !== void 0) transportImpls.set(entry, impl);
1007
+ return builder;
1008
+ };
1009
+ /** This function makes the runtime of the declaration: its checks, and a watchdog for each spec. */
1010
+ const makeRuntime = (options) => {
1011
+ const description = builder.describe();
1012
+ const clock = options.clock ?? realClock;
1013
+ const dogs = [];
1014
+ const all = new Map(runs);
1015
+ for (const spec of watchdogs) {
1016
+ const dog = watchdog(clock, spec, (from, to, on) => options.record?.({
1017
+ at: clock.now(),
1018
+ machine: "watchdog",
1019
+ instance: spec.name,
1020
+ from,
1021
+ to,
1022
+ on
1023
+ }));
1024
+ dogs.push(dog);
1025
+ all.set(spec.name, () => dog.check());
1026
+ }
1027
+ const stopPollers = () => {
1028
+ for (const dog of dogs) dog.stop();
1029
+ };
1030
+ let made;
1031
+ try {
1032
+ made = healthRuntime(description, all, options);
1033
+ } catch (error) {
1034
+ stopPollers();
1035
+ throw error;
1036
+ }
1037
+ return {
1038
+ runtime: {
1039
+ ...made,
1040
+ stop: () => {
1041
+ made.stop();
1042
+ stopPollers();
1043
+ }
1044
+ },
1045
+ description,
1046
+ stopPollers
1047
+ };
1048
+ };
1049
+ const builder = {
1050
+ name,
1051
+ get health() {
1052
+ return live ?? null;
1053
+ },
1054
+ dependency(entry, spec, handle) {
1055
+ once(dependencies, entry, "dependency");
1056
+ if (handle !== void 0 && !started) handles.set(entry, handle);
1057
+ dependencies.push({
1058
+ name: entry,
1059
+ ...spec,
1060
+ knobs: { ...spec.knobs },
1061
+ operations: { ...spec.operations }
1062
+ });
1063
+ if (started) {
1064
+ partial = true;
1065
+ live?.addDependency(entry);
1066
+ }
1067
+ return builder;
1068
+ },
1069
+ server: transport("server"),
1070
+ consumer: transport("consumer"),
1071
+ worker: transport("worker"),
1072
+ controller(entry) {
1073
+ composing("a controller");
1074
+ once(controllers, entry, "controller");
1075
+ const held = {
1076
+ name: entry,
1077
+ requires: [],
1078
+ routes: {}
1079
+ };
1080
+ controllers.push(held);
1081
+ const handle = {
1082
+ requires(...needed) {
1083
+ composing(`what ${entry} requires`);
1084
+ held.requires.push(...needed);
1085
+ return handle;
1086
+ },
1087
+ route(served, operation) {
1088
+ composing(`${entry}'s routes`);
1089
+ if (held.routes[served] !== void 0) throw new Error(`${entry} already serves ${served}`);
1090
+ held.routes[served] = { ...operation };
1091
+ return handle;
1092
+ }
1093
+ };
1094
+ return handle;
1095
+ },
1096
+ check(check, run) {
1097
+ once(checks, check.name, "check");
1098
+ if (started && run === void 0) throw new Error(`check ${check.name} joins after the start, thus it needs its function`);
1099
+ checks.push({ ...check });
1100
+ if (run !== void 0) runs.set(check.name, run);
1101
+ if (started) {
1102
+ partial = true;
1103
+ live?.addCheck({
1104
+ ...check,
1105
+ poll: { ...check.poll }
1106
+ }, run);
1107
+ }
1108
+ return builder;
1109
+ },
1110
+ healthServer(server) {
1111
+ composing("the health server");
1112
+ if (probes !== null) throw new Error(`${name} already has a health server`);
1113
+ probes = server;
1114
+ return builder;
1115
+ },
1116
+ onPhase(phase, work) {
1117
+ composing(`work for the phase ${phase}`);
1118
+ phaseWork.set(phase, [...phaseWork.get(phase) ?? [], work]);
1119
+ return builder;
1120
+ },
1121
+ watchdog(spec) {
1122
+ composing(`the watchdog ${spec.name}`);
1123
+ once(checks, spec.name, "check");
1124
+ checks.push({
1125
+ name: spec.name,
1126
+ dependency: null,
1127
+ step: "local-state",
1128
+ ownConnection: true,
1129
+ gates: spec.gates,
1130
+ poll: { ...spec.poll }
1131
+ });
1132
+ watchdogs.push(spec);
1133
+ return builder;
1134
+ },
1135
+ readiness(timing) {
1136
+ composing("readiness");
1137
+ readiness = timing;
1138
+ return builder;
1139
+ },
1140
+ liveness(timing) {
1141
+ composing("liveness");
1142
+ liveness = timing;
1143
+ return builder;
1144
+ },
1145
+ startup(budget) {
1146
+ composing("the startup budget");
1147
+ startup = budget;
1148
+ return builder;
1149
+ },
1150
+ couple(coupling) {
1151
+ composing("a coupling");
1152
+ couplings.push({ ...coupling });
1153
+ return builder;
1154
+ },
1155
+ phase(entry, cap) {
1156
+ composing("a shutdown phase");
1157
+ once(phases, entry, "shutdown phase");
1158
+ phases.push({
1159
+ name: entry,
1160
+ cap
1161
+ });
1162
+ return builder;
1163
+ },
1164
+ cooperativeWindow(window) {
1165
+ composing("the cooperative window");
1166
+ cooperative = window;
1167
+ return builder;
1168
+ },
1169
+ describe() {
1170
+ const missing = [];
1171
+ if (readiness === void 0) missing.push("readiness (svc.readiness)");
1172
+ if (liveness === void 0) missing.push("liveness (svc.liveness)");
1173
+ if (startup === void 0) missing.push("a startup budget (svc.startup)");
1174
+ if (phases.length === 0) missing.push("its shutdown phases (svc.phase)");
1175
+ if (cooperative === void 0) missing.push("a cooperative window, or no bound (svc.cooperativeWindow)");
1176
+ if (missing.length > 0) throw new Error(`${name} does not declare ${missing.join("; ")}: the runtime has no defaults`);
1177
+ const known = new Set(dependencies.map((entry) => entry.name));
1178
+ const problems = [];
1179
+ const policies = {
1180
+ readiness,
1181
+ liveness
1182
+ };
1183
+ for (const gate of ["readiness", "liveness"]) problems.push(...timingProblems(gate, policies[gate]));
1184
+ for (const check of checks) {
1185
+ if (check.dependency !== null && !known.has(check.dependency)) problems.push(`check ${check.name} exercises ${check.dependency}, which is not a dependency`);
1186
+ if (!isTime(check.poll.interval) || !isTime(check.poll.timeout)) problems.push(`check ${check.name} polls in a unit that is not a time`);
1187
+ else if (!above(check.poll.interval) || !above(check.poll.timeout)) problems.push(`check ${check.name} polls with an interval or a timeout that is not above zero`);
1188
+ if (!(check.poll.jitter >= 0 && check.poll.jitter < 1)) problems.push(`check ${check.name} has a jitter of ${check.poll.jitter}, not a fraction below 1`);
1189
+ const timing = check.gates === null ? null : policies[check.gates];
1190
+ if (timing?.type === "debounce" && isTime(check.poll.interval) && above(timing.interval) && valueIn(check.poll.interval, "s") !== valueIn(timing.interval, "s")) problems.push(`check ${check.name} polls every ${valueIn(check.poll.interval, "s")} s, but the ${check.gates} debounce counts polls every ${valueIn(timing.interval, "s")} s`);
1191
+ }
1192
+ for (const controller of controllers) {
1193
+ for (const needed of controller.requires) if (!known.has(needed)) problems.push(`controller ${controller.name} requires ${needed}, which is not a dependency`);
1194
+ for (const [route, operation] of Object.entries(controller.routes)) problems.push(...operationProblems(`controller ${controller.name}`, route, operation));
1195
+ }
1196
+ for (const dependency of dependencies) for (const [name, operation] of Object.entries(dependency.operations)) problems.push(...operationProblems(`dependency ${dependency.name}`, name, operation));
1197
+ for (const phase of phases) if (!isTime(phase.cap)) problems.push(`phase ${phase.name}'s cap is not a time`);
1198
+ const ranked = phases.map((phase) => phase.name).filter((each) => stageRank(each) >= 0);
1199
+ for (let at = 1; at < ranked.length; at += 1) if (stageRank(ranked[at]) < stageRank(ranked[at - 1])) problems.push(`the phase "${ranked[at]}" comes after "${ranked[at - 1]}", but the stages go ${STAGES.join(", ")}`);
1200
+ if (problems.length > 0) throw new Error(`${name}: ${problems.join("; ")}`);
1201
+ const controllersOut = controllers.map((entry) => ({
1202
+ name: entry.name,
1203
+ requires: [...entry.requires],
1204
+ routes: { ...entry.routes }
1205
+ }));
1206
+ return {
1207
+ format: FORMAT,
1208
+ name,
1209
+ partial,
1210
+ transports: transports.map((entry) => ({
1211
+ ...entry,
1212
+ knobs: { ...entry.knobs }
1213
+ })),
1214
+ dependencies: dependencies.map((entry) => ({
1215
+ ...entry,
1216
+ knobs: { ...entry.knobs },
1217
+ operations: { ...entry.operations }
1218
+ })),
1219
+ controllers: controllersOut,
1220
+ checks: checks.map((entry) => ({
1221
+ ...entry,
1222
+ poll: { ...entry.poll }
1223
+ })),
1224
+ policies: {
1225
+ readiness,
1226
+ liveness,
1227
+ startup
1228
+ },
1229
+ couplings: couplings.map((entry) => ({ ...entry })),
1230
+ shutdown: {
1231
+ phases: phases.map((entry) => ({ ...entry })),
1232
+ cooperativeWindow: cooperative
1233
+ }
1234
+ };
1235
+ },
1236
+ start(options = {}) {
1237
+ if (runtime !== void 0) return runtime;
1238
+ if (running !== void 0) return Promise.reject(/* @__PURE__ */ new Error(`${name} runs its lifecycle: use its health`));
1239
+ let made;
1240
+ try {
1241
+ made = makeRuntime(options).runtime;
1242
+ } catch (error) {
1243
+ return Promise.reject(error);
1244
+ }
1245
+ started = true;
1246
+ live = made;
1247
+ runtime = made.warmUp().then(() => made);
1248
+ return runtime;
1249
+ },
1250
+ run(options = {}) {
1251
+ if (running !== void 0) return running;
1252
+ if (runtime !== void 0) return Promise.reject(/* @__PURE__ */ new Error(`${name} started its health only: run() starts the whole lifecycle`));
1253
+ let life;
1254
+ try {
1255
+ const description = builder.describe();
1256
+ const components = {
1257
+ transports: transportImpls,
1258
+ dependencies: handles,
1259
+ healthServer: probes,
1260
+ phaseWork,
1261
+ stopPollers: () => {}
1262
+ };
1263
+ const problems = lifecycleProblems(description, components);
1264
+ if (problems.length > 0) throw new Error(`${name}: ${problems.join("; ")}`);
1265
+ const made = makeRuntime(options);
1266
+ started = true;
1267
+ live = made.runtime;
1268
+ life = lifecycle(made.description, made.runtime, {
1269
+ ...components,
1270
+ stopPollers: made.stopPollers
1271
+ }, options);
1272
+ } catch (error) {
1273
+ return Promise.reject(error);
1274
+ }
1275
+ running = life.started.then(() => life);
1276
+ return running;
1277
+ }
1278
+ };
1279
+ return builder;
1280
+ };
1281
+ //#endregion
1282
+ //#region src/law.ts
1283
+ const value = (bound) => bound === "inf" || bound === "off" ? Infinity : Number(bound);
1284
+ const keyOf = (term) => term.at !== void 0 ? `${term.knob}@${term.at}` : term.knob;
1285
+ /** This function gives the bound of a symbol, or undefined when the symbol has no bound. */
1286
+ const boundOf$1 = (bind, key) => {
1287
+ const bound = bind[key];
1288
+ return typeof bound === "number" || bound === "inf" || bound === "off" ? bound : void 0;
1289
+ };
1290
+ /** This function gives the inequalities of a rule: its expr, then the others. */
1291
+ const inequalities = (rule) => [rule.expr, ...rule.expr_more ?? []];
1292
+ /** This function gives the symbols that a rule reads, as `symbol@hop`. */
1293
+ const symbolsOf = (rule) => {
1294
+ const out = [];
1295
+ for (const inequality of inequalities(rule)) for (const term of [...inequality.lhs.terms, ...inequality.rhs.terms]) {
1296
+ const key = keyOf(term);
1297
+ if (!out.includes(key)) out.push(key);
1298
+ }
1299
+ return out;
1300
+ };
1301
+ /** This function gives the value of a side, Σ coef × symbol + const, or null when a symbol has no bound. */
1302
+ const sideOf = (side, bind) => {
1303
+ let total = side.const ?? 0;
1304
+ for (const term of side.terms) {
1305
+ const bound = boundOf$1(bind, keyOf(term));
1306
+ if (bound === void 0) return null;
1307
+ total += term.coef * value(bound);
1308
+ }
1309
+ return total;
1310
+ };
1311
+ /** This function gives the margin of an inequality: from the binding (one for all, or one each), else from the rule, else 0. */
1312
+ const marginOf = (inequality, index, bind) => {
1313
+ if (Array.isArray(bind.margins)) return Number(bind.margins[index] ?? 0);
1314
+ if (typeof bind.margin === "number") return bind.margin;
1315
+ return typeof inequality.margin === "number" ? inequality.margin : 0;
1316
+ };
1317
+ /**
1318
+ * This function evaluates each inequality on one binding. "<" holds when lhs + margin < rhs, and
1319
+ * ">" holds when lhs > rhs + margin. Only the inequalities with all their symbols bound apply.
1320
+ * The others are null.
1321
+ */
1322
+ const evaluate = (rule, bind) => inequalities(rule).map((inequality, index) => {
1323
+ const lhs = sideOf(inequality.lhs, bind);
1324
+ const rhs = sideOf(inequality.rhs, bind);
1325
+ const margin = marginOf(inequality, index, bind);
1326
+ if (lhs === null || rhs === null) return {
1327
+ lhs,
1328
+ rhs,
1329
+ margin,
1330
+ holds: null
1331
+ };
1332
+ return {
1333
+ lhs,
1334
+ rhs,
1335
+ margin,
1336
+ holds: inequality.op === "<" ? lhs + margin < rhs : inequality.op === "<=" ? lhs + margin <= rhs : inequality.op === ">" ? lhs > rhs + margin : lhs >= rhs + margin
1337
+ };
1338
+ });
1339
+ /**
1340
+ * This function gives the verdict of an evaluation. When an applicable inequality fails, the
1341
+ * verdict is "violated". When all hold only because a timer is absent, the verdict is "unbounded",
1342
+ * because nothing then limits the window. Otherwise the verdict is "holds". When no inequality
1343
+ * applies, the arithmetic cannot decide, and the verdict is "depends".
1344
+ */
1345
+ const verdictOf = (rule, bind, evaluated) => {
1346
+ const applicable = evaluated.flatMap((entry, index) => entry.holds === null ? [] : [index]);
1347
+ if (applicable.length === 0) return "depends";
1348
+ if (applicable.some((index) => evaluated[index]?.holds === false)) return "violated";
1349
+ const all = inequalities(rule);
1350
+ return applicable.some((index) => {
1351
+ const inequality = all[index];
1352
+ return [...inequality.lhs.terms, ...inequality.rhs.terms].some((term) => {
1353
+ const bound = boundOf$1(bind, keyOf(term));
1354
+ return bound !== void 0 && value(bound) === Infinity;
1355
+ });
1356
+ }) ? "unbounded" : "holds";
1357
+ };
1358
+ //#endregion
1359
+ //#region src/lint.ts
1360
+ /** These are the identifiers of the rules that each declaration gets. */
1361
+ const SERVICE_RULES = [
1362
+ "O09",
1363
+ "O10",
1364
+ "O11",
1365
+ "O12",
1366
+ "O13",
1367
+ "O14",
1368
+ "O15",
1369
+ "O16",
1370
+ "O17",
1371
+ "O18",
1372
+ "O19",
1373
+ "O21",
1374
+ "O22",
1375
+ "O23",
1376
+ "O27"
1377
+ ];
1378
+ /** This function gives a finite amount in seconds. */
1379
+ const secondsOf = (amount) => rationals.toNumber(convertExact(rationals.fromNumber(amount.value), amount.unit, "s"));
1380
+ /** This function gives an amount, or no bound, as the law binds it. */
1381
+ const boundOf = (amount) => amount.value === "infinite" ? "inf" : secondsOf(amount);
1382
+ /** This function gives the largest of some numbers of seconds, or undefined when there are none. */
1383
+ const most = (values) => values.length === 0 ? void 0 : Math.max(...values);
1384
+ /** This function gives what the declaration tells about the service, as symbols of the law, and the source of each. */
1385
+ const serviceBindings = (svc, scenario) => {
1386
+ const bind = {};
1387
+ const from = {};
1388
+ const set = (symbol, bound, said) => {
1389
+ if (bound === void 0) return;
1390
+ bind[symbol] = bound;
1391
+ from[symbol] = said;
1392
+ };
1393
+ const gating = svc.checks.filter((check) => check.gates !== null);
1394
+ const readinessChecks = gating.filter((check) => check.gates === "readiness");
1395
+ const livenessChecks = gating.filter((check) => check.gates === "liveness");
1396
+ set("check_poll_interval@service", most(gating.map((check) => secondsOf(check.poll.interval))), "the slowest gating check's interval");
1397
+ set("check_timeout@service", most(gating.map((check) => secondsOf(check.poll.timeout))), "the longest gating check's timeout");
1398
+ set("slowest_poll@service", most(gating.map((check) => secondsOf(check.poll.timeout) + secondsOf(check.poll.interval) * (1 + check.poll.jitter))), "a check's timeout plus one jittered interval, at its slowest");
1399
+ const windowOf = (checks, policy) => most(checks.map((check) => secondsOf(failSpan(svc.policies[policy], check.poll, "timeout").max)));
1400
+ const readiness = windowOf(readinessChecks, "readiness");
1401
+ const liveness = windowOf(livenessChecks, "liveness");
1402
+ set("readiness_detection_window@service", readiness, "the readiness latch, failing by timeout");
1403
+ if (readiness !== void 0) set("readiness_detection_window@pod", readiness + secondsOf(scenario.probes.readiness), "the readiness latch, then the kubelet's probe");
1404
+ if (liveness !== void 0) set("liveness_detection_window@pod", liveness + secondsOf(scenario.probes.liveness), "the liveness window, then the kubelet's probe");
1405
+ set("startup_budget@pod", secondsOf(svc.policies.startup), "the declared startup budget");
1406
+ set("process_drain_time@process", svc.shutdown.phases.reduce((total, phase) => total + secondsOf(phase.cap), 0), `the shutdown phases' caps, summed (${svc.shutdown.phases.map((phase) => phase.name).join(", ")})`);
1407
+ set("cooperative_window@process", boundOf(svc.shutdown.cooperativeWindow), "the declared cooperative window");
1408
+ for (const [symbol, amount] of Object.entries(scenario.platform)) set(symbol, boundOf(amount), "the scenario");
1409
+ return {
1410
+ bind,
1411
+ from
1412
+ };
1413
+ };
1414
+ /** This function gives the client knobs of a dependency at @client, and the values of the scenario at its own hops. */
1415
+ const dependencyBindings = (dependency, scenario) => {
1416
+ const bind = {};
1417
+ for (const [knob, amount] of Object.entries(dependency.knobs)) bind[`${knob}@client`] = boundOf(amount);
1418
+ for (const [symbol, amount] of Object.entries(scenario.dependencies?.[dependency.name] ?? {})) bind[symbol.includes("@") ? symbol : `${symbol}@dependency`] = boundOf(amount);
1419
+ return bind;
1420
+ };
1421
+ const SCOPED = /@(client|dependency|server)$/;
1422
+ /** This function applies each service rule to a declaration and a scenario: one time for the service, and one time for each client. */
1423
+ const lint = (svc, scenario, rules) => {
1424
+ const service = serviceBindings(svc, scenario).bind;
1425
+ const findings = [];
1426
+ for (const rule of rules) {
1427
+ if (!SERVICE_RULES.includes(rule.id)) continue;
1428
+ const symbols = symbolsOf(rule);
1429
+ const scopes = symbols.some((symbol) => SCOPED.test(symbol)) ? svc.dependencies.map((dependency) => [dependency.name, {
1430
+ ...service,
1431
+ ...dependencyBindings(dependency, scenario)
1432
+ }]) : [[svc.name, service]];
1433
+ for (const [scope, bind] of scopes) {
1434
+ const evaluated = evaluate(rule, bind);
1435
+ findings.push({
1436
+ rule: rule.id,
1437
+ scope,
1438
+ verdict: verdictOf(rule, bind, evaluated),
1439
+ statement: rule.rule,
1440
+ ...rule.window !== void 0 ? { window: rule.window } : {},
1441
+ evaluated,
1442
+ unbound: symbols.filter((symbol) => bind[symbol] === void 0)
1443
+ });
1444
+ }
1445
+ }
1446
+ return findings;
1447
+ };
1448
+ //#endregion
1449
+ //#region src/machines.ts
1450
+ /**
1451
+ * The machines of the runtime as `mark1russell7.machine/1` documents. The documents are the
1452
+ * specification, and TypeScript implements them. A test records the transitions of the runtime
1453
+ * and replays them against each document, thus the code and the documents cannot disagree.
1454
+ */
1455
+ const SOURCE = [{
1456
+ ref: "packages/service/src (the runtime implements this document)",
1457
+ rung: "implemented"
1458
+ }];
1459
+ const base = (id, title) => ({
1460
+ format: "mark1russell7.machine/1",
1461
+ id: `service.machine.${id}`,
1462
+ title,
1463
+ layer: "self",
1464
+ version: "0.2.0",
1465
+ sources: SOURCE
1466
+ });
1467
+ const t = (id, from, on, to, extra = {}) => ({
1468
+ id,
1469
+ from,
1470
+ on,
1471
+ to,
1472
+ ...extra
1473
+ });
1474
+ /**
1475
+ * The startup gate opens one time. The lifecycle moves it from `closed` to `starting` while its
1476
+ * components start. Then one warm-up sweep of each check opens it. A component that does not start
1477
+ * fails it for good.
1478
+ */
1479
+ const GATE_MACHINE = {
1480
+ ...base("gate", "The startup gate"),
1481
+ states: {
1482
+ closed: { initial: true },
1483
+ starting: { note: "the health server, then the dependencies, then the transports" },
1484
+ sweeping: { note: "one bounded warm-up sweep of each check" },
1485
+ open: { final: true },
1486
+ failed: { final: true }
1487
+ },
1488
+ events: {
1489
+ "startup.begin": { kind: "local" },
1490
+ "component.started": { kind: "local" },
1491
+ "mark-ready": { kind: "local" },
1492
+ "sweep.done": { kind: "local" },
1493
+ "startup.fail": { kind: "local" },
1494
+ "startup.over-budget": { kind: "timer" }
1495
+ },
1496
+ knobs: { startup: {
1497
+ key: "policies.startup",
1498
+ scope: "service",
1499
+ unit: "s",
1500
+ default: null
1501
+ } },
1502
+ timers: { startup_budget: {
1503
+ duration: {
1504
+ knob: "startup",
1505
+ unit: "s"
1506
+ },
1507
+ arms: ["enter:closed"],
1508
+ cancels: ["sweep.done", "startup.fail"],
1509
+ fires: "startup.over-budget",
1510
+ note: "the gate says that it is over its budget, and the startup probe of the platform acts on it"
1511
+ } },
1512
+ transitions: [
1513
+ t("begin", "closed", "startup.begin", "starting"),
1514
+ t("component", "starting", "component.started", "starting"),
1515
+ t("mark-ready", ["closed", "starting"], "mark-ready", "sweeping"),
1516
+ t("open", "sweeping", "sweep.done", "open"),
1517
+ t("fail", [
1518
+ "closed",
1519
+ "starting",
1520
+ "sweeping"
1521
+ ], "startup.fail", "failed"),
1522
+ t("over-budget-closed", "closed", "startup.over-budget", "closed", { timer: "startup_budget" }),
1523
+ t("over-budget-starting", "starting", "startup.over-budget", "starting", { timer: "startup_budget" }),
1524
+ t("over-budget-sweeping", "sweeping", "startup.over-budget", "sweeping", { timer: "startup_budget" })
1525
+ ]
1526
+ };
1527
+ /** A debounce latch counts the polls of its check in sequence. A readiness latch starts tripped. */
1528
+ const LATCH_MACHINE = {
1529
+ ...base("latch", "The latch of a policy, as a debounce"),
1530
+ states: {
1531
+ tripped: {
1532
+ initial: true,
1533
+ note: "a readiness latch starts here; a liveness latch starts released"
1534
+ },
1535
+ released: {}
1536
+ },
1537
+ events: {
1538
+ "poll.ok": { kind: "local" },
1539
+ "poll.fail": { kind: "local" }
1540
+ },
1541
+ knobs: {
1542
+ fail_after: {
1543
+ key: "policies.<gate>.failAfter",
1544
+ scope: "check",
1545
+ unit: "count",
1546
+ default: null
1547
+ },
1548
+ recover_after: {
1549
+ key: "policies.<gate>.recoverAfter",
1550
+ scope: "check",
1551
+ unit: "count",
1552
+ default: null
1553
+ }
1554
+ },
1555
+ timers: {},
1556
+ transitions: [
1557
+ t("pass", "released", "poll.ok", "released"),
1558
+ t("fail-count", "released", "poll.fail", "released", { guard: "failures in sequence < fail_after" }),
1559
+ t("trip", "released", "poll.fail", "tripped", { guard: "failures in sequence >= fail_after" }),
1560
+ t("fail", "tripped", "poll.fail", "tripped"),
1561
+ t("pass-count", "tripped", "poll.ok", "tripped", { guard: "passes in sequence < recover_after" }),
1562
+ t("release", "tripped", "poll.ok", "released", { guard: "passes in sequence >= recover_after" })
1563
+ ]
1564
+ };
1565
+ /**
1566
+ * A window latch measures time on the monotonic clock from the first failure of a sequence, and
1567
+ * from the first pass after a trip.
1568
+ */
1569
+ const WINDOW_MACHINE = {
1570
+ ...base("window", "The latch of a policy, as a window"),
1571
+ states: {
1572
+ released: {
1573
+ initial: true,
1574
+ note: "a liveness latch starts here; a readiness latch starts tripped"
1575
+ },
1576
+ failing: { note: "the fail window runs" },
1577
+ tripped: {},
1578
+ recovering: { note: "the recover window runs" }
1579
+ },
1580
+ events: {
1581
+ "poll.ok": { kind: "local" },
1582
+ "poll.fail": { kind: "local" },
1583
+ "window.failed": { kind: "timer" },
1584
+ "window.recovered": { kind: "timer" }
1585
+ },
1586
+ knobs: {
1587
+ fail_after: {
1588
+ key: "policies.<gate>.failAfter",
1589
+ scope: "check",
1590
+ unit: "s",
1591
+ default: null
1592
+ },
1593
+ recover_after: {
1594
+ key: "policies.<gate>.recoverAfter",
1595
+ scope: "check",
1596
+ unit: "s",
1597
+ default: null
1598
+ }
1599
+ },
1600
+ timers: {
1601
+ fail_window: {
1602
+ duration: {
1603
+ knob: "fail_after",
1604
+ unit: "s"
1605
+ },
1606
+ arms: ["enter:failing"],
1607
+ cancels: ["poll.ok"],
1608
+ fires: "window.failed"
1609
+ },
1610
+ recover_window: {
1611
+ duration: {
1612
+ knob: "recover_after",
1613
+ unit: "s"
1614
+ },
1615
+ arms: ["enter:recovering"],
1616
+ cancels: ["poll.fail"],
1617
+ fires: "window.recovered"
1618
+ }
1619
+ },
1620
+ transitions: [
1621
+ t("pass", "released", "poll.ok", "released"),
1622
+ t("start-failing", "released", "poll.fail", "failing", { guard: "fail_after > 0" }),
1623
+ t("trip-at-once", "released", "poll.fail", "tripped", { guard: "fail_after = 0" }),
1624
+ t("still-failing", "failing", "poll.fail", "failing"),
1625
+ t("stop-failing", "failing", "poll.ok", "released"),
1626
+ t("trip", "failing", "window.failed", "tripped", { timer: "fail_window" }),
1627
+ t("fail", "tripped", "poll.fail", "tripped"),
1628
+ t("release-at-once", "tripped", "poll.ok", "released", { guard: "recover_after is absent or 0" }),
1629
+ t("start-recovering", "tripped", "poll.ok", "recovering", { guard: "recover_after > 0" }),
1630
+ t("still-recovering", "recovering", "poll.ok", "recovering"),
1631
+ t("stop-recovering", "recovering", "poll.fail", "tripped"),
1632
+ t("release", "recovering", "window.recovered", "released", { timer: "recover_window" })
1633
+ ]
1634
+ };
1635
+ /**
1636
+ * A poller starts one check on a jittered loop with a fixed delay. A call that outlives its timeout
1637
+ * stays pending (`hung`), and the poller does not start the check again until the call settles.
1638
+ */
1639
+ const POLLER_MACHINE = {
1640
+ ...base("poller", "The poller of a check"),
1641
+ states: {
1642
+ idle: {
1643
+ initial: true,
1644
+ note: "before the loop starts"
1645
+ },
1646
+ waiting: { note: "the loop timer runs, and no call is pending" },
1647
+ calling: { note: "a call runs, in its timeout" },
1648
+ hung: { note: "a call outlived its timeout, and it is still pending" },
1649
+ stopped: { final: true }
1650
+ },
1651
+ events: {
1652
+ start: { kind: "local" },
1653
+ sweep: { kind: "local" },
1654
+ tick: { kind: "timer" },
1655
+ answer: { kind: "local" },
1656
+ timeout: { kind: "timer" },
1657
+ "late-answer": { kind: "local" },
1658
+ stop: { kind: "local" }
1659
+ },
1660
+ knobs: {
1661
+ interval: {
1662
+ key: "checks.<name>.poll.interval",
1663
+ scope: "check",
1664
+ unit: "s",
1665
+ default: null
1666
+ },
1667
+ timeout: {
1668
+ key: "checks.<name>.poll.timeout",
1669
+ scope: "check",
1670
+ unit: "s",
1671
+ default: null
1672
+ }
1673
+ },
1674
+ timers: {
1675
+ loop: {
1676
+ duration: {
1677
+ knob: "interval",
1678
+ unit: "s"
1679
+ },
1680
+ arms: ["start", "tick"],
1681
+ cancels: ["stop"],
1682
+ fires: "tick",
1683
+ note: "the interval, with its jitter: a fixed delay from the start of each poll"
1684
+ },
1685
+ poll_timeout: {
1686
+ duration: {
1687
+ knob: "timeout",
1688
+ unit: "s"
1689
+ },
1690
+ arms: ["enter:calling"],
1691
+ cancels: ["answer", "stop"],
1692
+ fires: "timeout"
1693
+ }
1694
+ },
1695
+ transitions: [
1696
+ t("sweep", "idle", "sweep", "calling"),
1697
+ t("start", "idle", "start", "waiting"),
1698
+ t("start-while-calling", "calling", "start", "calling", { note: "a late check polls at once, then starts its loop" }),
1699
+ t("start-while-hung", "hung", "start", "hung"),
1700
+ t("tick", "waiting", "tick", "calling", { guard: "the force-fail list does not name the check" }),
1701
+ t("tick-forced", "waiting", "tick", "waiting", { guard: "the force-fail list names the check" }),
1702
+ t("tick-pending", "calling", "tick", "calling", { note: "the poll fails at once: its call is pending" }),
1703
+ t("tick-hung", "hung", "tick", "hung", { note: "the poll fails at once: its call is pending" }),
1704
+ t("answer-loop", "calling", "answer", "waiting", { guard: "the loop runs" }),
1705
+ t("answer-sweep", "calling", "answer", "idle", { guard: "the loop has not started" }),
1706
+ t("timeout", "calling", "timeout", "hung", { timer: "poll_timeout" }),
1707
+ t("late-answer-loop", "hung", "late-answer", "waiting", { guard: "the loop runs" }),
1708
+ t("late-answer-sweep", "hung", "late-answer", "idle", { guard: "the loop has not started" }),
1709
+ t("stop", [
1710
+ "idle",
1711
+ "waiting",
1712
+ "calling",
1713
+ "hung"
1714
+ ], "stop", "stopped"),
1715
+ t("settle-after-stop", "stopped", [
1716
+ "answer",
1717
+ "late-answer",
1718
+ "timeout"
1719
+ ], "stopped")
1720
+ ]
1721
+ };
1722
+ /**
1723
+ * The exit manager is the only part that ends the process. The first exit request starts the
1724
+ * shutdown, and a later one has no effect. An error makes the exit code 1.
1725
+ */
1726
+ const EXIT_MACHINE = {
1727
+ ...base("exit", "The exit manager"),
1728
+ states: {
1729
+ running: { initial: true },
1730
+ stopping: {},
1731
+ exited: { final: true }
1732
+ },
1733
+ events: {
1734
+ "exit.request": {
1735
+ kind: "app",
1736
+ note: "SIGINT, SIGTERM, SIGQUIT or shutdown()"
1737
+ },
1738
+ "exit.repeat": { kind: "app" },
1739
+ "startup.fail": { kind: "local" },
1740
+ error: { kind: "local" },
1741
+ "shutdown.done": { kind: "local" }
1742
+ },
1743
+ knobs: {},
1744
+ timers: {},
1745
+ transitions: [
1746
+ t("request", "running", "exit.request", "stopping"),
1747
+ t("startup-fail", "running", "startup.fail", "stopping"),
1748
+ t("error-running", "running", "error", "running"),
1749
+ t("repeat", "stopping", "exit.repeat", "stopping"),
1750
+ t("startup-fail-stopping", "stopping", "startup.fail", "stopping"),
1751
+ t("error", "stopping", "error", "stopping"),
1752
+ t("exit", "stopping", "shutdown.done", "exited", { note: "the exit code is 1 after an error, else 0" })
1753
+ ]
1754
+ };
1755
+ /** A watchdog measures how late its own tick comes, and a late tick shows a blocked event loop. */
1756
+ const WATCHDOG_MACHINE = {
1757
+ ...base("watchdog", "The watchdog of the event loop"),
1758
+ states: {
1759
+ watching: { initial: true },
1760
+ blocked: {},
1761
+ stopped: { final: true }
1762
+ },
1763
+ events: {
1764
+ tick: { kind: "timer" },
1765
+ stop: { kind: "local" }
1766
+ },
1767
+ knobs: {
1768
+ tick: {
1769
+ key: "watchdog.tick",
1770
+ scope: "process",
1771
+ unit: "s",
1772
+ default: null
1773
+ },
1774
+ threshold: {
1775
+ key: "watchdog.threshold",
1776
+ scope: "process",
1777
+ unit: "s",
1778
+ default: null
1779
+ }
1780
+ },
1781
+ timers: { tick: {
1782
+ duration: {
1783
+ knob: "tick",
1784
+ unit: "s"
1785
+ },
1786
+ arms: ["enter:watching", "tick"],
1787
+ cancels: ["stop"],
1788
+ fires: "tick"
1789
+ } },
1790
+ transitions: [
1791
+ t("on-time", "watching", "tick", "watching", { guard: "lateness <= threshold" }),
1792
+ t("block", "watching", "tick", "blocked", { guard: "lateness > threshold" }),
1793
+ t("still-blocked", "blocked", "tick", "blocked", { guard: "lateness > threshold" }),
1794
+ t("unblock", "blocked", "tick", "watching", { guard: "lateness <= threshold" }),
1795
+ t("stop", ["watching", "blocked"], "stop", "stopped")
1796
+ ]
1797
+ };
1798
+ /** This function gives the name of a knob for the cap of a phase: `drain` gives `drain_cap`. */
1799
+ const capKnob = (phase) => `${phase.replace(/[^A-Za-z0-9]+/g, "_")}_cap`;
1800
+ /**
1801
+ * This function gives the drain of a declaration as a machine. It has one state for each declared
1802
+ * phase, in its sequence, and each state has a timer at its cap. The drain also has the cooperative abort: the
1803
+ * cap of the drain less the cooperative window, and half that cap at the least.
1804
+ */
1805
+ const drainMachine = (description) => {
1806
+ const phases = description.shutdown.phases;
1807
+ const states = { running: { initial: true } };
1808
+ for (const phase of phases) states[phase.name] = {};
1809
+ states["done"] = { final: true };
1810
+ const knobs = Object.fromEntries([...phases.map((phase) => [capKnob(phase.name), {
1811
+ key: `shutdown.phases.${phase.name}.cap`,
1812
+ scope: "service",
1813
+ unit: "s",
1814
+ default: null
1815
+ }]), ["cooperative_window", {
1816
+ key: "shutdown.cooperativeWindow",
1817
+ scope: "service",
1818
+ unit: "s",
1819
+ default: null
1820
+ }]]);
1821
+ const timers = {};
1822
+ for (const phase of phases) timers[`${capKnob(phase.name)}_timer`] = {
1823
+ duration: {
1824
+ knob: capKnob(phase.name),
1825
+ unit: "s"
1826
+ },
1827
+ arms: [`enter:${phase.name}`],
1828
+ cancels: ["phase.done"],
1829
+ fires: "phase.cap"
1830
+ };
1831
+ if (phases.some((phase) => phase.name === "drain")) timers["cooperative_abort"] = {
1832
+ duration: {
1833
+ expr: "max(drain_cap - cooperative_window, drain_cap / 2)",
1834
+ unit: "s"
1835
+ },
1836
+ arms: ["enter:drain"],
1837
+ cancels: ["phase.done", "phase.cap"],
1838
+ fires: "drain.cooperative-abort"
1839
+ };
1840
+ const transitions = [];
1841
+ const order = [
1842
+ "running",
1843
+ ...phases.map((phase) => phase.name),
1844
+ "done"
1845
+ ];
1846
+ for (let at = 0; at + 1 < order.length; at += 1) {
1847
+ const from = order[at];
1848
+ const to = order[at + 1];
1849
+ transitions.push(at === 0 ? t(`begin`, from, "exit.request", to) : t(`after-${from}`, from, ["phase.done", "phase.cap"], to, {
1850
+ timer: `${capKnob(from)}_timer`,
1851
+ note: "phase.cap: the work of the phase did not end in its cap, and the exit code is 1"
1852
+ }));
1853
+ }
1854
+ if (phases.some((phase) => phase.name === "drain")) transitions.push(t("cooperative-abort", "drain", "drain.cooperative-abort", "drain", { timer: "cooperative_abort" }));
1855
+ for (const state of [...phases.map((phase) => phase.name), "done"]) transitions.push(t(`signal-abort-${state}`, state, "signal.abort", state));
1856
+ return {
1857
+ ...base("drain", `The drain of ${description.name}`),
1858
+ states,
1859
+ events: {
1860
+ "exit.request": { kind: "app" },
1861
+ "phase.done": { kind: "local" },
1862
+ "phase.cap": { kind: "timer" },
1863
+ "drain.cooperative-abort": { kind: "timer" },
1864
+ "signal.abort": { kind: "local" }
1865
+ },
1866
+ knobs,
1867
+ timers,
1868
+ transitions
1869
+ };
1870
+ };
1871
+ /** These are the static machines of the runtime, by the name that their events carry. */
1872
+ const MACHINES = {
1873
+ gate: GATE_MACHINE,
1874
+ latch: LATCH_MACHINE,
1875
+ window: WINDOW_MACHINE,
1876
+ poller: POLLER_MACHINE,
1877
+ exit: EXIT_MACHINE,
1878
+ watchdog: WATCHDOG_MACHINE
1879
+ };
1880
+ /**
1881
+ * This function replays the transitions of one machine of the runtime against its document. Each
1882
+ * instance starts in the state that its first transition leaves. Each transition must leave the
1883
+ * state where the replay is. The document must have a transition from that state, on that event,
1884
+ * to that state. The function gives each disagreement.
1885
+ */
1886
+ const replay = (doc, events) => {
1887
+ const problems = [];
1888
+ const at = /* @__PURE__ */ new Map();
1889
+ for (const event of events) {
1890
+ const where = at.get(event.instance) ?? event.from;
1891
+ if (doc.states[where] === void 0) problems.push(`${event.instance}: the state ${where} is not in ${doc.id}`);
1892
+ if (event.from !== where) problems.push(`${event.instance}: the runtime left ${event.from} on ${event.on}, but the replay is in ${where}`);
1893
+ if (!doc.transitions.some((each) => fromStates(doc, each.from).includes(event.from) && arr(each.on).includes(event.on) && each.to === event.to)) problems.push(`${event.instance}: ${doc.id} has no transition from ${event.from} on ${event.on} to ${event.to}`);
1894
+ at.set(event.instance, event.to);
1895
+ }
1896
+ return problems;
1897
+ };
1898
+ //#endregion
1899
+ export { EXIT_MACHINE, EXIT_SIGNALS, FORMAT, GATE_MACHINE, LATCH_MACHINE, MACHINES, POLLER_MACHINE, SERVICE_RULES, STAGES, WATCHDOG_MACHINE, WINDOW_MACHINE, asDebounce, asWindow, capKnob, cooperativeAbortAt, drainMachine, evaluate, failSpan, inequalities, lint, mayReissue, nodeProcess, realClock, recoverSpan, replay, service, serviceBindings, stageRank, symbolsOf, verdictOf, watchdog };
1900
+
1901
+ //# sourceMappingURL=index.js.map