@mark1russell7/service 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -3,6 +3,7 @@ import { detectionTime, heartbeat } from "./heartbeat.js";
3
3
  import { a as recoverSpan, i as heartbeatDetection, n as asWindow, r as failSpan, t as asDebounce } from "./timing-DGRi3XCI.js";
4
4
  import { n as operationProblems, t as mayReissue } from "./operation-DL5RxFds.js";
5
5
  import { backoffBand, backoffDelay, backoffProblems } from "./backoff.js";
6
+ import { n as countOf, r as millisecondsOf, t as boundedMillisecondsOf } from "./knobs-BxG-c7Zb.js";
6
7
  import { convertExact, convertible, quantity, rationals, valueIn } from "@mark1russell7/quantity";
7
8
  import { replay } from "@mark1russell7/algebra/replay";
8
9
  //#region src/description.ts
@@ -497,101 +498,441 @@ const EXIT_SIGNALS = [
497
498
  "SIGTERM",
498
499
  "SIGQUIT"
499
500
  ];
500
- /** This port is the process of Node. */
501
- const nodeProcess = {
502
- on(signal, handler) {
503
- const host = globalThis.process;
504
- if (host === void 0) throw new Error("no process: give the runtime a process port");
505
- host.on(signal, handler);
506
- return () => {
507
- host.off(signal, handler);
508
- };
509
- },
510
- exit(code) {
511
- const host = globalThis.process;
501
+ /** These are the process triggers that a declaration can opt in to. */
502
+ const PROCESS_TRIGGERS = [
503
+ "stdin-close",
504
+ "ipc-close",
505
+ "ipc-message"
506
+ ];
507
+ /** This function tells if a number is an exit code of a process: a whole number from 0 to 255. */
508
+ const isExitCode = (code) => Number.isInteger(code) && code >= 0 && code <= 255;
509
+ /** This function tells if a trigger is an exit signal. A repeat signal obeys `onRepeatSignal`. */
510
+ const isExitSignal = (trigger) => EXIT_SIGNALS.includes(trigger);
511
+ /** This function tells if a value is a message shape: a value of JSON, with no function and no undefined. */
512
+ const isMessageShape = (value) => {
513
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
514
+ if (typeof value === "number") return Number.isFinite(value);
515
+ if (Array.isArray(value)) return value.every(isMessageShape);
516
+ if (typeof value !== "object") return false;
517
+ const prototype = Object.getPrototypeOf(value);
518
+ return (prototype === Object.prototype || prototype === null) && Object.values(value).every(isMessageShape);
519
+ };
520
+ /** This function tells if a message of the IPC channel matches a shape. */
521
+ const matchesMessage = (shape, message) => {
522
+ if (shape === null || typeof shape !== "object") return Object.is(shape, message);
523
+ if (Array.isArray(shape)) {
524
+ const items = shape;
525
+ return Array.isArray(message) && message.length === items.length && items.every((item, at) => matchesMessage(item, message[at]));
526
+ }
527
+ if (message === null || typeof message !== "object" || Array.isArray(message)) return false;
528
+ const fields = message;
529
+ return Object.entries(shape).every(([field, value]) => Object.hasOwn(fields, field) && matchesMessage(value, fields[field]));
530
+ };
531
+ /** This time is the interval of the timer of a hold, the most that a timer of Node accepts. */
532
+ const HOLD_INTERVAL = 2147483647;
533
+ /** This function adds a handler to some events of an emitter. The handler operates one time at most. */
534
+ const once = (emitter, events, handler) => {
535
+ let called = false;
536
+ const fire = () => {
537
+ if (called) return;
538
+ called = true;
539
+ handler();
540
+ };
541
+ for (const event of events) emitter.on(event, fire);
542
+ return () => {
543
+ for (const event of events) emitter.off(event, fire);
544
+ };
545
+ };
546
+ /** This function gives a promise that ends when the data before it in a stream is written. */
547
+ const written = (stream) => new Promise((resolve) => {
548
+ if (stream === void 0 || stream.destroyed === true || stream.writable === false) {
549
+ resolve();
550
+ return;
551
+ }
552
+ try {
553
+ stream.write("", () => resolve());
554
+ } catch {
555
+ resolve();
556
+ }
557
+ });
558
+ /**
559
+ * This function makes a process port on a host. `find` gives the host at each use, or undefined
560
+ * when the platform has no process. Then the use throws.
561
+ */
562
+ const processPortOf = (find) => {
563
+ const hostProcess = () => {
564
+ const host = find();
512
565
  if (host === void 0) throw new Error("no process: give the runtime a process port");
513
- host.exit(code);
566
+ return host;
567
+ };
568
+ return {
569
+ on(trigger, handler) {
570
+ const host = hostProcess();
571
+ if (trigger === "stdin-close") {
572
+ const input = host.stdin;
573
+ if (input === void 0) return () => {};
574
+ if (input.readableEnded === true) {
575
+ let live = true;
576
+ queueMicrotask(() => {
577
+ if (live) handler();
578
+ });
579
+ return () => {
580
+ live = false;
581
+ };
582
+ }
583
+ return once(input, ["end", "close"], handler);
584
+ }
585
+ if (trigger === "ipc-close") return once(host, ["disconnect"], handler);
586
+ if (trigger === "ipc-message") {
587
+ const receive = (message) => handler(message);
588
+ host.on("message", receive);
589
+ return () => {
590
+ host.off("message", receive);
591
+ };
592
+ }
593
+ host.on(trigger, handler);
594
+ return () => {
595
+ host.off(trigger, handler);
596
+ };
597
+ },
598
+ async flush() {
599
+ const host = hostProcess();
600
+ await Promise.all([written(host.stdout), written(host.stderr)]);
601
+ },
602
+ hold() {
603
+ hostProcess();
604
+ const timer = setInterval(() => {}, HOLD_INTERVAL);
605
+ let held = true;
606
+ return () => {
607
+ if (!held) return;
608
+ held = false;
609
+ clearInterval(timer);
610
+ };
611
+ },
612
+ exit(code) {
613
+ hostProcess().exit(code);
614
+ }
615
+ };
616
+ };
617
+ /** This port is the process of Node. */
618
+ const nodeProcess = processPortOf(() => globalThis.process);
619
+ //#endregion
620
+ //#region src/routes.ts
621
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*/;
622
+ /** This function gives the problems of the syntax of a pattern: an empty pattern, a part without a name, or a name two times. */
623
+ const patternProblems = (owner, pattern) => {
624
+ const problems = [];
625
+ if (pattern.length === 0) problems.push(`${owner} has an empty route pattern`);
626
+ const names = /* @__PURE__ */ new Set();
627
+ for (let at = 0; at < pattern.length; at += 1) {
628
+ if (pattern[at] !== ":") continue;
629
+ const name = NAME.exec(pattern.slice(at + 1))?.[0];
630
+ if (name === void 0) {
631
+ problems.push(`${owner}'s pattern ${pattern} has a ":" without a name at ${at}`);
632
+ continue;
633
+ }
634
+ if (names.has(name)) problems.push(`${owner}'s pattern ${pattern} names :${name} two times`);
635
+ names.add(name);
636
+ at += name.length;
637
+ }
638
+ return problems;
639
+ };
640
+ /** This function gives the problems of a pattern and of each of its operations, which `describe()` refuses. */
641
+ const routePatternProblems = (owner, declared) => {
642
+ const problems = patternProblems(owner, declared.pattern);
643
+ const names = Object.keys(declared.operations);
644
+ if (names.length === 0) problems.push(`${owner}'s pattern ${declared.pattern} declares no operation`);
645
+ for (const name of names) problems.push(...operationProblems(owner, `${declared.pattern} (${name})`, declared.operations[name]));
646
+ return problems;
647
+ };
648
+ /** This function compiles a pattern to a matcher. The match must cover the whole route. */
649
+ const compilePattern = (pattern) => {
650
+ const kinds = [];
651
+ let source = "^";
652
+ for (let at = 0; at < pattern.length; at += 1) {
653
+ const char = pattern[at];
654
+ if (char === "*") {
655
+ source += "([\\s\\S]*)";
656
+ kinds.push({ kind: "wildcard" });
657
+ continue;
658
+ }
659
+ if (char === ":") {
660
+ const name = NAME.exec(pattern.slice(at + 1))?.[0];
661
+ if (name !== void 0) {
662
+ source += "([^/]+)";
663
+ kinds.push({
664
+ kind: "param",
665
+ name
666
+ });
667
+ at += name.length;
668
+ continue;
669
+ }
670
+ }
671
+ source += char.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
514
672
  }
673
+ const matcher = new RegExp(`${source}$`);
674
+ return {
675
+ pattern,
676
+ match(route) {
677
+ const found = matcher.exec(route);
678
+ if (found === null) return null;
679
+ const params = {};
680
+ const wildcards = [];
681
+ kinds.forEach((part, index) => {
682
+ const text = found[index + 1] ?? "";
683
+ if (part.kind === "param") params[part.name] = text;
684
+ else wildcards.push(text);
685
+ });
686
+ return {
687
+ params,
688
+ wildcards
689
+ };
690
+ }
691
+ };
515
692
  };
516
693
  //#endregion
517
694
  //#region src/requests.ts
695
+ const isAsyncIterable$1 = (value) => value !== null && (typeof value === "object" || typeof value === "function") && typeof value[Symbol.asyncIterator] === "function";
696
+ const isPromiseLike = (value) => value !== null && (typeof value === "object" || typeof value === "function") && typeof value.then === "function";
697
+ /** This function gives a stream of events from a function that gives the next event. */
698
+ const streamOf = (next, stop) => {
699
+ let chain = Promise.resolve();
700
+ const iterator = {
701
+ next() {
702
+ const step = chain.then(next).then((event) => event === null ? {
703
+ value: void 0,
704
+ done: true
705
+ } : {
706
+ value: event,
707
+ done: false
708
+ });
709
+ chain = step.catch(() => {});
710
+ return step;
711
+ },
712
+ return() {
713
+ stop();
714
+ return Promise.resolve({
715
+ value: void 0,
716
+ done: true
717
+ });
718
+ }
719
+ };
720
+ return {
721
+ [Symbol.asyncIterator]: () => iterator,
722
+ async collect() {
723
+ const items = [];
724
+ for (;;) {
725
+ const step = await iterator.next();
726
+ if (step.done === true) throw new Error("the stream ended without its end");
727
+ if (step.value.kind === "item") items.push(step.value.value);
728
+ else return {
729
+ items,
730
+ end: step.value
731
+ };
732
+ }
733
+ }
734
+ };
735
+ };
518
736
  /**
519
737
  * This function makes a tracker. `refuses` gives the reason that the service refuses a new request
520
- * for a controller at this time, or null when it admits it.
738
+ * for a controller at this time, or null when it admits it. `choices` gives the function of each
739
+ * route pattern, by controller and by pattern.
521
740
  */
522
- const tracker = (controllers, clock, refuses) => {
523
- const byName = new Map(controllers.map((controller) => [controller.name, controller]));
741
+ const tracker = (controllers, clock, refuses, choices = /* @__PURE__ */ new Map()) => {
742
+ const byName = new Map(controllers.map((controller) => [controller.name, {
743
+ controller,
744
+ patterns: (controller.patterns ?? []).map((declared) => ({
745
+ declared,
746
+ compiled: compilePattern(declared.pattern)
747
+ }))
748
+ }]));
524
749
  const flights = /* @__PURE__ */ new Set();
525
750
  const idle = /* @__PURE__ */ new Set();
526
751
  let intake = true;
752
+ /** This function gives the operation of a request: its exact route, else the first pattern that matches it. */
753
+ const operationOf = (controller, route, detail) => {
754
+ const held = byName.get(controller);
755
+ if (held === void 0) throw new Error(`there is no controller ${controller}`);
756
+ const exact = held.controller.routes[route];
757
+ if (exact !== void 0) return exact;
758
+ for (const { declared, compiled } of held.patterns) {
759
+ const found = compiled.match(route);
760
+ if (found === null) continue;
761
+ const names = Object.keys(declared.operations);
762
+ const choose = choices.get(controller)?.get(declared.pattern);
763
+ const name = choose === void 0 ? names[0] : choose({
764
+ controller,
765
+ route,
766
+ ...found,
767
+ detail
768
+ });
769
+ const operation = declared.operations[name];
770
+ if (operation === void 0) throw new Error(`the pattern ${declared.pattern} of ${controller} declares no operation ${String(name)} for ${route}`);
771
+ return operation;
772
+ }
773
+ throw new Error(`controller ${controller} does not declare the route ${route}`);
774
+ };
527
775
  const settle = (flight) => {
528
- flights.delete(flight);
776
+ if (!flights.delete(flight)) return;
529
777
  if (flights.size === 0) for (const done of [...idle]) done();
530
778
  };
531
- /** This function answers a request that the shutdown stopped, by the idempotency of its route. */
532
- const cut = (flight, cause) => {
533
- const operation = byName.get(flight.controller).routes[flight.route];
534
- const since = quantity(Math.max(0, clock.now() - flight.started) / 1e3, "s");
535
- const retryable = mayReissue(operation, since);
536
- return {
537
- kind: "cut",
538
- status: retryable ? 503 : 500,
539
- retryable,
540
- cause
779
+ /** This function starts the work of an admitted request, and gives its stream. */
780
+ const open = (controller, operation, work) => {
781
+ const started = clock.now();
782
+ const abort = new AbortController();
783
+ let delivered = 0;
784
+ let end = null;
785
+ let ended = false;
786
+ let returned = false;
787
+ let source = null;
788
+ let unary = null;
789
+ let interrupt = () => {};
790
+ const interrupted = new Promise((resolve) => {
791
+ interrupt = () => resolve("interrupted");
792
+ });
793
+ const cutEnd = (cause) => {
794
+ const since = quantity(Math.max(0, clock.now() - started) / 1e3, "s");
795
+ const retryable = mayReissue(operation, since);
796
+ return {
797
+ kind: "cut",
798
+ status: delivered > 0 ? null : retryable ? 503 : 500,
799
+ retryable,
800
+ cause,
801
+ delivered
802
+ };
541
803
  };
542
- };
543
- return {
544
- request(controller, route, work) {
545
- const held = byName.get(controller);
546
- if (held === void 0) throw new Error(`there is no controller ${controller}`);
547
- if (held.routes[route] === void 0) throw new Error(`controller ${controller} does not declare the route ${route}`);
548
- const refusal = intake ? refuses(controller) : "the service stopped its intake";
549
- if (refusal !== null) return Promise.resolve({
550
- kind: "refused",
551
- status: 503,
552
- retryable: true,
553
- cause: refusal
554
- });
555
- return new Promise((resolve) => {
556
- let answered = false;
557
- const flight = {
558
- controller,
559
- route,
560
- started: clock.now(),
561
- abort: new AbortController(),
562
- answer: (outcome) => {
563
- if (answered) return;
564
- answered = true;
565
- settle(flight);
566
- resolve(outcome);
567
- }
804
+ /** This function ends the work of the request: the end is known, and the request leaves the flights. */
805
+ const finish = (given) => {
806
+ if (end !== null) return;
807
+ end = given;
808
+ settle(flight);
809
+ interrupt();
810
+ };
811
+ /** This function ends the work after a throw: a failure after the shutdown aborted it is a cut. */
812
+ const failed = (error) => finish(abort.signal.aborted ? cutEnd("the shutdown aborted it, and it failed") : {
813
+ kind: "failed",
814
+ error,
815
+ delivered
816
+ });
817
+ const closeSource = () => {
818
+ if (source === null || returned) return;
819
+ returned = true;
820
+ try {
821
+ Promise.resolve(source.return?.()).catch(() => {});
822
+ } catch {}
823
+ };
824
+ const flight = {
825
+ controller,
826
+ cooperative: () => {
827
+ abort.abort(/* @__PURE__ */ new Error("the cooperative abort of the drain"));
828
+ closeSource();
829
+ },
830
+ cut: (cause) => {
831
+ abort.abort(new Error(cause));
832
+ finish(cutEnd(cause));
833
+ closeSource();
834
+ }
835
+ };
836
+ flights.add(flight);
837
+ /** This function takes the result of the work: a stream, or one value. */
838
+ const take = (result) => {
839
+ if (end !== null) return;
840
+ if (isAsyncIterable$1(result)) {
841
+ source = result[Symbol.asyncIterator]();
842
+ if (abort.signal.aborted) closeSource();
843
+ } else {
844
+ unary = { value: result };
845
+ settle(flight);
846
+ }
847
+ };
848
+ let ready;
849
+ try {
850
+ const result = work(abort.signal);
851
+ if (isAsyncIterable$1(result) || !isPromiseLike(result)) {
852
+ take(result);
853
+ ready = Promise.resolve();
854
+ } else ready = Promise.resolve(result).then(take, failed);
855
+ } catch (error) {
856
+ failed(error);
857
+ ready = Promise.resolve();
858
+ }
859
+ const next = async () => {
860
+ if (ended) return null;
861
+ if (end === null) await Promise.race([ready, interrupted]);
862
+ if (end === null && unary !== null) {
863
+ const { value } = unary;
864
+ unary = null;
865
+ delivered = 1;
866
+ end = {
867
+ kind: "done",
868
+ delivered
869
+ };
870
+ return {
871
+ kind: "item",
872
+ value
568
873
  };
569
- flights.add(flight);
570
- let promise;
874
+ }
875
+ if (end === null && source !== null) {
876
+ const pending = source.next();
877
+ let step;
571
878
  try {
572
- promise = Promise.resolve(work(flight.abort.signal));
879
+ step = await Promise.race([pending, interrupted]);
573
880
  } catch (error) {
574
- promise = Promise.reject(error);
881
+ failed(error);
882
+ step = "interrupted";
575
883
  }
576
- promise.then((value) => {
577
- if (answered) return;
578
- answered = true;
579
- settle(flight);
580
- resolve({
884
+ if (step !== "interrupted") {
885
+ if (step.done !== true) {
886
+ delivered += 1;
887
+ return {
888
+ kind: "item",
889
+ value: step.value
890
+ };
891
+ }
892
+ finish(abort.signal.aborted ? cutEnd("the shutdown ended the stream") : {
581
893
  kind: "done",
582
- value
894
+ delivered
583
895
  });
584
- }, (error) => {
585
- if (answered) return;
586
- const outcome = flight.abort.signal.aborted ? cut(flight, "the shutdown aborted it, and it failed") : {
587
- kind: "failed",
588
- error
589
- };
590
- answered = true;
591
- settle(flight);
592
- resolve(outcome);
896
+ }
897
+ }
898
+ ended = true;
899
+ return end;
900
+ };
901
+ const stop = () => {
902
+ if (end === null) {
903
+ abort.abort(/* @__PURE__ */ new Error("the reader stopped the stream"));
904
+ finish({
905
+ kind: "done",
906
+ delivered
593
907
  });
594
- });
908
+ closeSource();
909
+ }
910
+ ended = true;
911
+ };
912
+ return streamOf(next, stop);
913
+ };
914
+ return {
915
+ request(controller, route, work, options = {}) {
916
+ const operation = operationOf(controller, route, options.detail);
917
+ const refusal = intake ? refuses(controller) : "the service stopped its intake";
918
+ if (refusal !== null) {
919
+ let given = false;
920
+ const refused = {
921
+ kind: "refused",
922
+ status: 503,
923
+ retryable: true,
924
+ cause: refusal,
925
+ delivered: 0
926
+ };
927
+ return streamOf(() => {
928
+ if (given) return Promise.resolve(null);
929
+ given = true;
930
+ return Promise.resolve(refused);
931
+ }, () => {
932
+ given = true;
933
+ });
934
+ }
935
+ return open(controller, operation, work);
595
936
  },
596
937
  inFlight(controller) {
597
938
  let count = 0;
@@ -602,7 +943,7 @@ const tracker = (controllers, clock, refuses) => {
602
943
  intake = false;
603
944
  },
604
945
  cooperativeAbort() {
605
- for (const flight of flights) flight.abort.abort(/* @__PURE__ */ new Error("the cooperative abort of the drain"));
946
+ for (const flight of [...flights]) flight.cooperative();
606
947
  },
607
948
  drain(deadline) {
608
949
  if (flights.size === 0) return Promise.resolve();
@@ -617,10 +958,7 @@ const tracker = (controllers, clock, refuses) => {
617
958
  };
618
959
  idle.add(done);
619
960
  const cancel = clock.after(Math.max(0, deadline - clock.now()), () => {
620
- for (const flight of [...flights]) {
621
- flight.abort.abort(/* @__PURE__ */ new Error("the drain reached its deadline"));
622
- flight.answer(cut(flight, "the drain reached its deadline"));
623
- }
961
+ for (const flight of [...flights]) flight.cut("the drain reached its deadline");
624
962
  done();
625
963
  });
626
964
  });
@@ -641,7 +979,9 @@ const tracker = (controllers, clock, refuses) => {
641
979
  * - `close transports`: each transport closes.
642
980
  * - `stop the poller`: each check and watchdog stops. Then the signal of the service aborts.
643
981
  * - `tear down dependencies`: each dependency closes, in the reverse sequence of its connection.
644
- * - `close the health server`: the probes stop last.
982
+ * - `close the health server`: the probes stop.
983
+ * - `flush the output`: the work of the owners of this phase ends, and then the process port
984
+ * writes the buffered output. Without this phase, the process exits without a wait.
645
985
  */
646
986
  const STAGES = [
647
987
  "fail readiness",
@@ -650,7 +990,8 @@ const STAGES = [
650
990
  "close transports",
651
991
  "stop the poller",
652
992
  "tear down dependencies",
653
- "close the health server"
993
+ "close the health server",
994
+ "flush the output"
654
995
  ];
655
996
  /** This function gives the place of a stage in the sequence, or -1 for a phase of another name. */
656
997
  const stageRank = (name) => STAGES.indexOf(name);
@@ -658,12 +999,12 @@ const stageRank = (name) => STAGES.indexOf(name);
658
999
  const ABORT_AFTER = stageRank("stop the poller");
659
1000
  /**
660
1001
  * This function gives the time of the cooperative abort, in milliseconds after the drain starts. It
661
- * is the cap less the cooperative window, and not less than half the cap. A window without a bound
662
- * gives half the cap.
1002
+ * is the cap less the cooperative window. With the floor `half`, it is not less than half the cap,
1003
+ * and a window without a bound gives half the cap. With the floor `none`, it is not less than 0.
663
1004
  */
664
- const cooperativeAbortAt = (cap, window) => {
1005
+ const cooperativeAbortAt = (cap, window, floor = "half") => {
665
1006
  const before = window.value === "infinite" ? Number.POSITIVE_INFINITY : valueIn(window, "ms");
666
- return Math.max(cap - before, cap / 2);
1007
+ return Math.max(cap - before, floor === "none" ? 0 : cap / 2);
667
1008
  };
668
1009
  const message = (error) => error instanceof Error ? error.message : String(error);
669
1010
  /**
@@ -688,12 +1029,12 @@ const lifecycleProblems = (description, components) => {
688
1029
  };
689
1030
  /**
690
1031
  * This function starts the lifecycle of a service. The startup has a sequence: the health server
691
- * first, then each dependency, then each transport, and the startup gate last. A signal during the
1032
+ * first, then each dependency, then each transport, and the startup gate last. A trigger during the
692
1033
  * startup gets its shutdown after the startup ends. The shutdown does the declared phases in their
693
- * sequence, each one capped. Only the lifecycle ends the process: 0 after a clean shutdown, and 1
694
- * after an error.
1034
+ * sequence, each one capped. Only the lifecycle ends the process. Its exit code is the code of the
1035
+ * exit request, else 1 after an error, else 0.
695
1036
  */
696
- const lifecycle = (description, runtime, components, options = {}) => {
1037
+ const lifecycle = (description, runtime, components, options = {}, bind = () => {}) => {
697
1038
  const clock = options.clock ?? realClock;
698
1039
  const port = options.process ?? nodeProcess;
699
1040
  const record = (machine, from, to, on, detail) => options.record?.({
@@ -707,8 +1048,11 @@ const lifecycle = (description, runtime, components, options = {}) => {
707
1048
  });
708
1049
  const service = new AbortController();
709
1050
  const errors = [];
1051
+ const exitCodes = description.shutdown.exitCodes ?? {};
1052
+ const repeatPolicy = description.shutdown.onRepeatSignal;
710
1053
  let state = "starting";
711
1054
  let exitCode = null;
1055
+ let requested;
712
1056
  let intakeStopped = false;
713
1057
  let shutdownStarted = false;
714
1058
  let failedStartup = false;
@@ -719,14 +1063,14 @@ const lifecycle = (description, runtime, components, options = {}) => {
719
1063
  };
720
1064
  const fail = (cause) => {
721
1065
  errors.push(cause);
722
- record("exit", exitState, exitState, "error", cause);
1066
+ if (exitState !== "exited") record("exit", exitState, exitState, "error", cause);
723
1067
  };
724
1068
  const requests = tracker(description.controllers, clock, (controller) => {
725
1069
  if (state === "starting") return "the service has not started";
726
1070
  if (state === "exited") return "the service has exited";
727
1071
  if (state === "draining") return null;
728
1072
  return runtime.verdict().controllers[controller] === true ? null : `controller ${controller} is not ready`;
729
- });
1073
+ }, components.choices);
730
1074
  let finish = () => {};
731
1075
  const done = new Promise((resolve) => {
732
1076
  finish = resolve;
@@ -734,45 +1078,49 @@ const lifecycle = (description, runtime, components, options = {}) => {
734
1078
  let healthListening = false;
735
1079
  const connected = [];
736
1080
  const startedTransports = [];
737
- const offs = EXIT_SIGNALS.map((signal) => port.on(signal, () => {
738
- shutdown(signal);
739
- }));
740
- const startup = (async () => {
741
- runtime.beginStartup();
742
- const started = (what) => record("gate", "starting", "starting", "component.started", what);
743
- try {
744
- if (components.healthServer !== null) {
745
- await components.healthServer.listen(runtime);
746
- healthListening = true;
747
- started("the health server");
748
- }
749
- for (const dependency of description.dependencies) {
750
- const handle = components.dependencies.get(dependency.name);
751
- if (handle === void 0) continue;
752
- await handle.connect(service.signal);
753
- connected.push([dependency.name, handle]);
754
- started(`dependency ${dependency.name}`);
755
- }
756
- for (const transport of description.transports) {
757
- const impl = components.transports.get(transport.name);
758
- await impl.start(service.signal);
759
- startedTransports.push([transport.name, impl]);
760
- started(`${transport.kind} ${transport.name}`);
1081
+ let startupEnded = () => {};
1082
+ const startup = new Promise((resolve) => {
1083
+ startupEnded = resolve;
1084
+ });
1085
+ let offs = [];
1086
+ let release = () => {};
1087
+ /** This function ends the process with a code, one time, and removes each handler of a trigger and the hold. */
1088
+ const end = (code, on) => {
1089
+ state = "exited";
1090
+ exitCode = code;
1091
+ for (const off of offs) off();
1092
+ offs = [];
1093
+ moveExit("exited", on, `exit ${code}`);
1094
+ finish(code);
1095
+ release();
1096
+ port.exit(code);
1097
+ };
1098
+ /**
1099
+ * This function receives a trigger of the process: the first one starts the shutdown. The cause
1100
+ * names the trigger, and a message that stands for a signal tells it.
1101
+ */
1102
+ const triggered = (trigger, cause = trigger) => {
1103
+ if (exitState === "exited") return;
1104
+ if (shutdownStarted || exitState === "stopping") {
1105
+ if (isExitSignal(trigger) && repeatPolicy === "exit") {
1106
+ fail(`${cause} came again while the shutdown ran, and the declaration exits at once`);
1107
+ end(1, "exit.repeat");
1108
+ return;
761
1109
  }
762
- await runtime.warmUp();
763
- if (state === "starting") state = "running";
764
- } catch (error) {
765
- const cause = `the startup failed: ${message(error)}`;
766
- failedStartup = true;
767
- runtime.failStartup(cause);
768
- fail(cause);
769
- if (exitState === "running") moveExit("stopping", "startup.fail", cause);
770
- else record("exit", exitState, exitState, "startup.fail", cause);
1110
+ record("exit", exitState, exitState, "exit.repeat", cause);
1111
+ return;
771
1112
  }
772
- })();
773
- startup.then(() => {
774
- if (failedStartup && !shutdownStarted) shutdown("the startup failed");
775
- });
1113
+ const code = exitCodes[trigger];
1114
+ shutdown(cause, code === void 0 ? {} : { code });
1115
+ };
1116
+ const messages = description.shutdown.messages ?? [];
1117
+ /** This function receives a message of the IPC channel. The first declared shape that it matches gives its trigger. */
1118
+ const received = (message) => {
1119
+ const found = messages.find((each) => matchesMessage(each.match, message));
1120
+ if (found === void 0) return;
1121
+ if (found.as === void 0) triggered("ipc-message");
1122
+ else triggered(found.as, `${found.as} (a message of the IPC channel)`);
1123
+ };
776
1124
  /** This function does the work of one phase, until its cap. It gives true when the work ended in time. */
777
1125
  const runPhase = async (phase, hold, work) => {
778
1126
  const cap = valueIn(phase.cap, "ms");
@@ -802,14 +1150,22 @@ const lifecycle = (description, runtime, components, options = {}) => {
802
1150
  capped.abort(/* @__PURE__ */ new Error(`the phase ${phase.name} reached its cap`));
803
1151
  return pending === 0;
804
1152
  };
805
- async function shutdown(cause = "the service asked for it") {
806
- if (shutdownStarted || exitState === "exited") {
1153
+ async function shutdown(cause = "the service asked for it", request = {}) {
1154
+ if (request.code !== void 0 && !isExitCode(request.code)) throw new Error(`${description.name}: the exit code ${request.code} is not a whole number from 0 to 255`);
1155
+ if (exitState === "exited") return done;
1156
+ if (request.code !== void 0 && requested === void 0) requested = request.code;
1157
+ if (shutdownStarted) {
807
1158
  if (exitState === "stopping") record("exit", exitState, exitState, "exit.repeat", cause);
808
1159
  return done;
809
1160
  }
810
1161
  shutdownStarted = true;
811
1162
  if (exitState === "running") moveExit("stopping", "exit.request", cause);
812
1163
  await startup;
1164
+ if (exitState === "exited") {
1165
+ runtime.stop();
1166
+ components.stopPollers();
1167
+ return exitCode;
1168
+ }
813
1169
  state = "draining";
814
1170
  runtime.drain();
815
1171
  let drainState = "running";
@@ -825,10 +1181,12 @@ const lifecycle = (description, runtime, components, options = {}) => {
825
1181
  const phases = description.shutdown.phases;
826
1182
  let on = "exit.request";
827
1183
  for (const phase of phases) {
1184
+ if (exitState === "exited") break;
828
1185
  const rank = stageRank(phase.name);
829
1186
  moveDrain(phase.name, on);
830
1187
  if (rank > ABORT_AFTER) abortService();
831
1188
  const work = [];
1189
+ const owned = components.phaseWork.get(phase.name) ?? [];
832
1190
  if (phase.name === "stop intake") {
833
1191
  work.push(() => {
834
1192
  intakeStopped = true;
@@ -840,7 +1198,7 @@ const lifecycle = (description, runtime, components, options = {}) => {
840
1198
  if (phase.name === "drain") {
841
1199
  const cap = valueIn(phase.cap, "ms");
842
1200
  const cooperative = new AbortController();
843
- const fireAt = cooperativeAbortAt(cap, description.shutdown.cooperativeWindow);
1201
+ const fireAt = cooperativeAbortAt(cap, description.shutdown.cooperativeWindow, description.shutdown.cooperativeFloor);
844
1202
  cancelAbort = clock.after(fireAt, () => {
845
1203
  cooperative.abort(/* @__PURE__ */ new Error("the cooperative abort of the drain"));
846
1204
  requests.cooperativeAbort();
@@ -861,26 +1219,33 @@ const lifecycle = (description, runtime, components, options = {}) => {
861
1219
  const server = components.healthServer;
862
1220
  work.push(() => server.close());
863
1221
  }
864
- for (const owned of components.phaseWork.get(phase.name) ?? []) work.push(owned);
1222
+ if (phase.name === "flush the output") work.push(async (context) => {
1223
+ await Promise.all(owned.map(async (each) => {
1224
+ try {
1225
+ await each(context);
1226
+ } catch (error) {
1227
+ fail(`the phase ${phase.name} failed: ${message(error)}`);
1228
+ }
1229
+ }));
1230
+ await port.flush?.();
1231
+ });
1232
+ else for (const each of owned) work.push(each);
865
1233
  const inTime = await runPhase(phase, phase.name === "fail readiness", work);
866
1234
  cancelAbort();
867
1235
  if (!inTime) fail(`the phase ${phase.name} did not end in its cap of ${valueIn(phase.cap, "s")} s`);
868
1236
  on = inTime ? "phase.done" : "phase.cap";
869
1237
  }
870
- moveDrain("done", on);
1238
+ if (exitState !== "exited") moveDrain("done", on);
871
1239
  abortService();
872
1240
  runtime.stop();
873
1241
  components.stopPollers();
874
1242
  if (!intakeStopped) requests.stopIntake();
875
- for (const off of offs) off();
876
- state = "exited";
877
- exitCode = errors.length > 0 ? 1 : 0;
878
- moveExit("exited", "shutdown.done", `exit ${exitCode}`);
879
- finish(exitCode);
880
- port.exit(exitCode);
881
- return exitCode;
1243
+ if (exitState === "exited") return exitCode;
1244
+ const code = requested !== void 0 && requested !== 0 ? requested : errors.length > 0 ? 1 : 0;
1245
+ end(code, "shutdown.done");
1246
+ return code;
882
1247
  }
883
- return {
1248
+ const life = {
884
1249
  health: runtime,
885
1250
  get state() {
886
1251
  return state;
@@ -895,10 +1260,52 @@ const lifecycle = (description, runtime, components, options = {}) => {
895
1260
  done,
896
1261
  signal: service.signal,
897
1262
  shutdown,
898
- request: (controller, route, work) => requests.request(controller, route, work),
1263
+ request: (controller, route, work, request) => requests.request(controller, route, work, request),
899
1264
  inFlight: (controller) => requests.inFlight(controller),
900
1265
  keepAlive: () => state === "running" && runtime.verdict().ready
901
1266
  };
1267
+ bind(life);
1268
+ release = port.hold?.() ?? (() => {});
1269
+ offs = [...EXIT_SIGNALS, ...description.shutdown.triggers ?? []].map((trigger) => port.on(trigger, trigger === "ipc-message" ? received : () => triggered(trigger)));
1270
+ (async () => {
1271
+ const started = (what) => record("gate", "starting", "starting", "component.started", what);
1272
+ try {
1273
+ runtime.beginStartup();
1274
+ if (components.healthServer !== null) {
1275
+ await components.healthServer.listen(runtime);
1276
+ healthListening = true;
1277
+ started("the health server");
1278
+ }
1279
+ for (const dependency of description.dependencies) {
1280
+ const handle = components.dependencies.get(dependency.name);
1281
+ if (handle === void 0) continue;
1282
+ await handle.connect(service.signal);
1283
+ connected.push([dependency.name, handle]);
1284
+ started(`dependency ${dependency.name}`);
1285
+ }
1286
+ for (const transport of description.transports) {
1287
+ const impl = components.transports.get(transport.name);
1288
+ await impl.start(service.signal, life);
1289
+ startedTransports.push([transport.name, impl]);
1290
+ started(`${transport.kind} ${transport.name}`);
1291
+ }
1292
+ await runtime.warmUp();
1293
+ if (state === "starting") state = "running";
1294
+ } catch (error) {
1295
+ const cause = `the startup failed: ${message(error)}`;
1296
+ failedStartup = true;
1297
+ runtime.failStartup(cause);
1298
+ fail(cause);
1299
+ if (exitState === "running") moveExit("stopping", "startup.fail", cause);
1300
+ else if (exitState === "stopping") record("exit", exitState, exitState, "startup.fail", cause);
1301
+ } finally {
1302
+ startupEnded();
1303
+ }
1304
+ })();
1305
+ startup.then(() => {
1306
+ if (failedStartup && !shutdownStarted && exitState !== "exited") shutdown("the startup failed");
1307
+ });
1308
+ return life;
902
1309
  };
903
1310
  //#endregion
904
1311
  //#region src/watchdog.ts
@@ -952,8 +1359,8 @@ const watchdog = (clock, spec, trace) => {
952
1359
  };
953
1360
  //#endregion
954
1361
  //#region src/builder.ts
955
- const isTime = (amount) => convertible(amount.unit, "s");
956
- const above = (amount) => amount.value === "infinite" || isTime(amount) && valueIn(amount, "s") > 0;
1362
+ const isTime$1 = (amount) => convertible(amount.unit, "s");
1363
+ const above = (amount) => amount.value === "infinite" || isTime$1(amount) && valueIn(amount, "s") > 0;
957
1364
  const whole = (count) => Number.isInteger(count) && count >= 1;
958
1365
  /** This function gives the problems of a timing: a zero interval or count fails decoding. */
959
1366
  const timingProblems = (policy, timing) => {
@@ -964,15 +1371,33 @@ const timingProblems = (policy, timing) => {
964
1371
  if (!whole(timing.recoverAfter)) problems.push(`the ${policy} debounce recovers after ${timing.recoverAfter} polls, not a whole number above zero`);
965
1372
  return problems;
966
1373
  }
967
- const negative = (amount) => amount !== void 0 && (!isTime(amount) || valueIn(amount, "s") < 0);
1374
+ const negative = (amount) => amount !== void 0 && (!isTime$1(amount) || valueIn(amount, "s") < 0);
968
1375
  return negative(timing.failAfter) || negative(timing.recoverAfter) ? [`the ${policy} window is not a time of zero or more`] : [];
969
1376
  };
1377
+ /** This function gives the problems of the rules of the shutdown: an unknown name, or an exit code that a process cannot give. */
1378
+ const shutdownProblems = (floor, repeat, triggers, exitCodes, messages) => {
1379
+ const problems = [];
1380
+ if (floor !== void 0 && floor !== "half" && floor !== "none") problems.push(`the cooperative floor ${String(floor)} is not "half" or "none"`);
1381
+ if (repeat !== void 0 && repeat !== "ignore" && repeat !== "exit") problems.push(`the policy of a repeat signal ${String(repeat)} is not "ignore" or "exit"`);
1382
+ for (const trigger of triggers) if (!PROCESS_TRIGGERS.includes(trigger)) problems.push(`the trigger ${String(trigger)} is not a process trigger (${PROCESS_TRIGGERS.join(", ")})`);
1383
+ if (triggers.includes("ipc-message") && messages.length === 0) problems.push(`the trigger ipc-message has no message to match: declare it with svc.trigger("ipc-message", { match })`);
1384
+ for (const message of messages) {
1385
+ if (!isMessageShape(message.match)) problems.push(`the match of a message trigger is not a value of JSON: ${String(message.match)}`);
1386
+ if (message.as !== void 0 && !isExitSignal(message.as)) problems.push(`a message trigger stands for ${String(message.as)}, not an exit signal (${EXIT_SIGNALS.join(", ")})`);
1387
+ }
1388
+ for (const [trigger, code] of Object.entries(exitCodes)) {
1389
+ if (!(EXIT_SIGNALS.includes(trigger) || triggers.includes(trigger))) problems.push(`the exit code of ${trigger} names no exit signal and no declared trigger`);
1390
+ if (code === void 0 || !isExitCode(code)) problems.push(`the exit code of ${trigger} is ${String(code)}, not a whole number from 0 to 255`);
1391
+ }
1392
+ return problems;
1393
+ };
970
1394
  /** This function starts the composition of a service. */
971
1395
  const service = (name) => {
972
1396
  if (name.trim().length === 0) throw new Error("a service needs a name");
973
1397
  const transports = [];
974
1398
  const dependencies = [];
975
1399
  const controllers = [];
1400
+ const choices = /* @__PURE__ */ new Map();
976
1401
  const checks = [];
977
1402
  const runs = /* @__PURE__ */ new Map();
978
1403
  const couplings = [];
@@ -981,11 +1406,17 @@ const service = (name) => {
981
1406
  let liveness;
982
1407
  let startup;
983
1408
  let cooperative;
1409
+ let floor;
1410
+ let repeat;
1411
+ const triggers = [];
1412
+ const messages = [];
1413
+ const exitCodes = {};
984
1414
  let started = false;
985
1415
  let partial = false;
986
1416
  let runtime;
987
1417
  let live;
988
1418
  let running;
1419
+ let life;
989
1420
  const transportImpls = /* @__PURE__ */ new Map();
990
1421
  const handles = /* @__PURE__ */ new Map();
991
1422
  const phaseWork = /* @__PURE__ */ new Map();
@@ -1054,6 +1485,9 @@ const service = (name) => {
1054
1485
  get health() {
1055
1486
  return live ?? null;
1056
1487
  },
1488
+ get lifecycle() {
1489
+ return life ?? null;
1490
+ },
1057
1491
  dependency(entry, spec, handle) {
1058
1492
  once(dependencies, entry, "dependency");
1059
1493
  if (handle !== void 0 && !started) handles.set(entry, handle);
@@ -1078,7 +1512,8 @@ const service = (name) => {
1078
1512
  const held = {
1079
1513
  name: entry,
1080
1514
  requires: [],
1081
- routes: {}
1515
+ routes: {},
1516
+ patterns: []
1082
1517
  };
1083
1518
  controllers.push(held);
1084
1519
  const handle = {
@@ -1092,6 +1527,21 @@ const service = (name) => {
1092
1527
  if (held.routes[served] !== void 0) throw new Error(`${entry} already serves ${served}`);
1093
1528
  held.routes[served] = { ...operation };
1094
1529
  return handle;
1530
+ },
1531
+ pattern(pattern, operations, operationOf) {
1532
+ composing(`${entry}'s route patterns`);
1533
+ if (held.patterns.some((each) => each.pattern === pattern)) throw new Error(`${entry} already has the route pattern ${pattern}`);
1534
+ if (operationOf === void 0 && Object.keys(operations).length > 1) throw new Error(`${entry}'s pattern ${pattern} has ${Object.keys(operations).length} operations, thus it needs the function that picks one`);
1535
+ held.patterns.push({
1536
+ pattern,
1537
+ operations: Object.fromEntries(Object.entries(operations).map(([name, operation]) => [name, { ...operation }]))
1538
+ });
1539
+ if (operationOf !== void 0) {
1540
+ const mine = choices.get(entry) ?? /* @__PURE__ */ new Map();
1541
+ mine.set(pattern, operationOf);
1542
+ choices.set(entry, mine);
1543
+ }
1544
+ return handle;
1095
1545
  }
1096
1546
  };
1097
1547
  return handle;
@@ -1169,6 +1619,47 @@ const service = (name) => {
1169
1619
  cooperative = window;
1170
1620
  return builder;
1171
1621
  },
1622
+ cooperativeFloor(given) {
1623
+ composing("the cooperative floor");
1624
+ floor = given;
1625
+ return builder;
1626
+ },
1627
+ onRepeatSignal(policy) {
1628
+ composing("the policy of a repeat signal");
1629
+ repeat = policy;
1630
+ return builder;
1631
+ },
1632
+ trigger(...given) {
1633
+ composing("a trigger");
1634
+ const [first, message] = given;
1635
+ if (first === "ipc-message" && typeof message === "object" && message !== null) {
1636
+ if (!triggers.includes("ipc-message")) triggers.push("ipc-message");
1637
+ messages.push({ ...message });
1638
+ return builder;
1639
+ }
1640
+ for (const each of given) if (typeof each === "string" && !triggers.includes(each)) triggers.push(each);
1641
+ return builder;
1642
+ },
1643
+ exitCode(trigger, code) {
1644
+ composing(`the exit code of ${trigger}`);
1645
+ exitCodes[trigger] = code;
1646
+ return builder;
1647
+ },
1648
+ profile(given) {
1649
+ composing("a profile");
1650
+ if (given.readiness !== void 0) builder.readiness(given.readiness);
1651
+ if (given.liveness !== void 0) builder.liveness(given.liveness);
1652
+ if (given.startup !== void 0) builder.startup(given.startup);
1653
+ for (const coupling of given.couplings ?? []) builder.couple(coupling);
1654
+ for (const each of given.phases ?? []) builder.phase(each.name, each.cap);
1655
+ if (given.cooperativeWindow !== void 0) builder.cooperativeWindow(given.cooperativeWindow);
1656
+ if (given.cooperativeFloor !== void 0) builder.cooperativeFloor(given.cooperativeFloor);
1657
+ if (given.onRepeatSignal !== void 0) builder.onRepeatSignal(given.onRepeatSignal);
1658
+ if (given.triggers !== void 0) builder.trigger(...given.triggers);
1659
+ for (const message of given.messages ?? []) builder.trigger("ipc-message", message);
1660
+ for (const [trigger, code] of Object.entries(given.exitCodes ?? {})) if (code !== void 0) builder.exitCode(trigger, code);
1661
+ return builder;
1662
+ },
1172
1663
  describe() {
1173
1664
  const missing = [];
1174
1665
  if (readiness === void 0) missing.push("readiness (svc.readiness)");
@@ -1176,6 +1667,7 @@ const service = (name) => {
1176
1667
  if (startup === void 0) missing.push("a startup budget (svc.startup)");
1177
1668
  if (phases.length === 0) missing.push("its shutdown phases (svc.phase)");
1178
1669
  if (cooperative === void 0) missing.push("a cooperative window, or no bound (svc.cooperativeWindow)");
1670
+ if (repeat === void 0) missing.push(`what a repeat signal does, "ignore" or "exit" (svc.onRepeatSignal)`);
1179
1671
  if (missing.length > 0) throw new Error(`${name} does not declare ${missing.join("; ")}: the runtime has no defaults`);
1180
1672
  const known = new Set(dependencies.map((entry) => entry.name));
1181
1673
  const problems = [];
@@ -1186,25 +1678,31 @@ const service = (name) => {
1186
1678
  for (const gate of ["readiness", "liveness"]) problems.push(...timingProblems(gate, policies[gate]));
1187
1679
  for (const check of checks) {
1188
1680
  if (check.dependency !== null && !known.has(check.dependency)) problems.push(`check ${check.name} exercises ${check.dependency}, which is not a dependency`);
1189
- if (!isTime(check.poll.interval) || !isTime(check.poll.timeout)) problems.push(`check ${check.name} polls in a unit that is not a time`);
1681
+ if (!isTime$1(check.poll.interval) || !isTime$1(check.poll.timeout)) problems.push(`check ${check.name} polls in a unit that is not a time`);
1190
1682
  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`);
1191
1683
  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`);
1192
1684
  const timing = check.gates === null ? null : policies[check.gates];
1193
- 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`);
1685
+ if (timing?.type === "debounce" && isTime$1(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`);
1194
1686
  }
1195
1687
  for (const controller of controllers) {
1196
1688
  for (const needed of controller.requires) if (!known.has(needed)) problems.push(`controller ${controller.name} requires ${needed}, which is not a dependency`);
1197
1689
  for (const [route, operation] of Object.entries(controller.routes)) problems.push(...operationProblems(`controller ${controller.name}`, route, operation));
1690
+ for (const declared of controller.patterns) problems.push(...routePatternProblems(`controller ${controller.name}`, declared));
1198
1691
  }
1199
1692
  for (const dependency of dependencies) for (const [name, operation] of Object.entries(dependency.operations)) problems.push(...operationProblems(`dependency ${dependency.name}`, name, operation));
1200
- for (const phase of phases) if (!isTime(phase.cap)) problems.push(`phase ${phase.name}'s cap is not a time`);
1693
+ for (const phase of phases) if (!isTime$1(phase.cap)) problems.push(`phase ${phase.name}'s cap is not a time`);
1694
+ problems.push(...shutdownProblems(floor, repeat, triggers, exitCodes, messages));
1201
1695
  const ranked = phases.map((phase) => phase.name).filter((each) => stageRank(each) >= 0);
1202
1696
  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(", ")}`);
1203
1697
  if (problems.length > 0) throw new Error(`${name}: ${problems.join("; ")}`);
1204
1698
  const controllersOut = controllers.map((entry) => ({
1205
1699
  name: entry.name,
1206
1700
  requires: [...entry.requires],
1207
- routes: { ...entry.routes }
1701
+ routes: { ...entry.routes },
1702
+ ...entry.patterns.length === 0 ? {} : { patterns: entry.patterns.map((each) => ({
1703
+ pattern: each.pattern,
1704
+ operations: { ...each.operations }
1705
+ })) }
1208
1706
  }));
1209
1707
  return {
1210
1708
  format: FORMAT,
@@ -1232,7 +1730,12 @@ const service = (name) => {
1232
1730
  couplings: couplings.map((entry) => ({ ...entry })),
1233
1731
  shutdown: {
1234
1732
  phases: phases.map((entry) => ({ ...entry })),
1235
- cooperativeWindow: cooperative
1733
+ cooperativeWindow: cooperative,
1734
+ ...floor === void 0 ? {} : { cooperativeFloor: floor },
1735
+ onRepeatSignal: repeat,
1736
+ ...triggers.length === 0 ? {} : { triggers: [...triggers] },
1737
+ ...messages.length === 0 ? {} : { messages: messages.map((each) => ({ ...each })) },
1738
+ ...Object.keys(exitCodes).length === 0 ? {} : { exitCodes: { ...exitCodes } }
1236
1739
  }
1237
1740
  };
1238
1741
  },
@@ -1253,7 +1756,6 @@ const service = (name) => {
1253
1756
  run(options = {}) {
1254
1757
  if (running !== void 0) return running;
1255
1758
  if (runtime !== void 0) return Promise.reject(/* @__PURE__ */ new Error(`${name} started its health only: run() starts the whole lifecycle`));
1256
- let life;
1257
1759
  try {
1258
1760
  const description = builder.describe();
1259
1761
  const components = {
@@ -1261,6 +1763,7 @@ const service = (name) => {
1261
1763
  dependencies: handles,
1262
1764
  healthServer: probes,
1263
1765
  phaseWork,
1766
+ choices,
1264
1767
  stopPollers: () => {}
1265
1768
  };
1266
1769
  const problems = lifecycleProblems(description, components);
@@ -1268,14 +1771,16 @@ const service = (name) => {
1268
1771
  const made = makeRuntime(options);
1269
1772
  started = true;
1270
1773
  live = made.runtime;
1271
- life = lifecycle(made.description, made.runtime, {
1774
+ const ran = lifecycle(made.description, made.runtime, {
1272
1775
  ...components,
1273
1776
  stopPollers: made.stopPollers
1274
- }, options);
1777
+ }, options, (given) => {
1778
+ life = given;
1779
+ });
1780
+ running = ran.started.then(() => ran);
1275
1781
  } catch (error) {
1276
1782
  return Promise.reject(error);
1277
1783
  }
1278
- running = life.started.then(() => life);
1279
1784
  return running;
1280
1785
  }
1281
1786
  };
@@ -1374,6 +1879,7 @@ const SERVICE_RULES = [
1374
1879
  "O17",
1375
1880
  "O18",
1376
1881
  "O19",
1882
+ "O20",
1377
1883
  "O21",
1378
1884
  "O22",
1379
1885
  "O23",
@@ -1383,6 +1889,14 @@ const SERVICE_RULES = [
1383
1889
  const secondsOf = (amount) => rationals.toNumber(convertExact(rationals.fromNumber(amount.value), amount.unit, "s"));
1384
1890
  /** This function gives an amount, or no bound, as the law binds it. */
1385
1891
  const boundOf = (amount) => amount.value === "infinite" ? "inf" : secondsOf(amount);
1892
+ /** This function tells if a unit is a time. A unit that the package does not know is not a time. */
1893
+ const isTime = (unit) => {
1894
+ try {
1895
+ return convertible(unit, "s");
1896
+ } catch {
1897
+ return false;
1898
+ }
1899
+ };
1386
1900
  /** This function gives the largest of some numbers of seconds, or undefined when there are none. */
1387
1901
  const most = (values) => values.length === 0 ? void 0 : Math.max(...values);
1388
1902
  /** This function gives what the declaration tells about the service, as symbols of the law, and the source of each. */
@@ -1424,15 +1938,64 @@ const dependencyBindings = (dependency, scenario) => {
1424
1938
  for (const [symbol, amount] of Object.entries(scenario.dependencies?.[dependency.name] ?? {})) bind[symbol.includes("@") ? symbol : `${symbol}@dependency`] = boundOf(amount);
1425
1939
  return bind;
1426
1940
  };
1941
+ /**
1942
+ * These are the hops of the law at which the knobs of a transport bind, by its kind. A worker does
1943
+ * its units as a consumer does, thus both bind at `consumer` (O20). A server binds at no hop yet.
1944
+ */
1945
+ const TRANSPORT_HOPS = {
1946
+ server: null,
1947
+ consumer: "consumer",
1948
+ worker: "consumer"
1949
+ };
1950
+ /**
1951
+ * This function gives the time knobs of a transport at the hop of its kind, and the values of the
1952
+ * scenario for its source. A knob with a hop in its name binds at that hop. A count binds no symbol.
1953
+ */
1954
+ const transportBindings = (transport, hop, scenario) => {
1955
+ const bind = {};
1956
+ for (const [knob, amount] of Object.entries(transport.knobs)) {
1957
+ if (!isTime(amount.unit)) continue;
1958
+ bind[knob.includes("@") ? knob : `${knob}@${hop}`] = boundOf(amount);
1959
+ }
1960
+ for (const [symbol, amount] of Object.entries(scenario.transports?.[transport.name] ?? {})) bind[symbol] = boundOf(amount);
1961
+ return bind;
1962
+ };
1427
1963
  const SCOPED = /@(client|dependency|server)$/;
1428
- /** This function applies each service rule to a declaration and a scenario: one time for the service, and one time for each client. */
1964
+ const TRANSPORT_SCOPED = /@consumer$/;
1965
+ /** This identifier is the rule of the findings about a field that the declaration does not give. */
1966
+ const DECLARATION_RULE = "declaration";
1967
+ /**
1968
+ * This function gives a finding for each field of the shutdown that a description does not give.
1969
+ * `describe()` refuses such a declaration. A description from another source, for example the
1970
+ * JSON of 0.4, can lack the field. The verdict is `depends`, and `unbound` names the field.
1971
+ */
1972
+ const declarationFindings = (svc) => svc.shutdown.onRepeatSignal === void 0 ? [{
1973
+ rule: DECLARATION_RULE,
1974
+ scope: svc.name,
1975
+ verdict: "depends",
1976
+ statement: "the declaration tells what a signal does while the shutdown runs: ignore or exit",
1977
+ window: "a repeat signal has no declared effect",
1978
+ evaluated: [],
1979
+ unbound: ["shutdown.onRepeatSignal"]
1980
+ }] : [];
1981
+ /**
1982
+ * This function applies each service rule to a declaration and a scenario. A rule applies one time
1983
+ * for the service, for each client, or for each consumer and worker. The function also gives a
1984
+ * finding for each necessary field that the description does not give (`DECLARATION_RULE`).
1985
+ */
1429
1986
  const lint = (svc, scenario, rules) => {
1430
1987
  const service = serviceBindings(svc, scenario).bind;
1431
- const findings = [];
1988
+ const findings = [...declarationFindings(svc)];
1432
1989
  for (const rule of rules) {
1433
1990
  if (!SERVICE_RULES.includes(rule.id)) continue;
1434
1991
  const symbols = symbolsOf(rule);
1435
- const scopes = symbols.some((symbol) => SCOPED.test(symbol)) ? svc.dependencies.map((dependency) => [dependency.name, {
1992
+ const scopes = symbols.some((symbol) => TRANSPORT_SCOPED.test(symbol)) ? svc.transports.flatMap((transport) => {
1993
+ const hop = TRANSPORT_HOPS[transport.kind];
1994
+ return hop === null ? [] : [[transport.name, {
1995
+ ...service,
1996
+ ...transportBindings(transport, hop, scenario)
1997
+ }]];
1998
+ }) : symbols.some((symbol) => SCOPED.test(symbol)) ? svc.dependencies.map((dependency) => [dependency.name, {
1436
1999
  ...service,
1437
2000
  ...dependencyBindings(dependency, scenario)
1438
2001
  }]) : [[svc.name, service]];
@@ -1452,6 +2015,767 @@ const lint = (svc, scenario, rules) => {
1452
2015
  return findings;
1453
2016
  };
1454
2017
  //#endregion
2018
+ //#region src/profiles.ts
2019
+ const s = (value) => ({
2020
+ value,
2021
+ unit: "s"
2022
+ });
2023
+ const phase = (name, cap) => ({
2024
+ name,
2025
+ cap
2026
+ });
2027
+ /**
2028
+ * This profile is for a service on the computer of a developer. No load balancer routes to it,
2029
+ * thus it has no readiness hold. Its caps sum to 10 s, and a second signal exits at once. It
2030
+ * declares no checks, thus the caller declares readiness, liveness and the startup budget.
2031
+ */
2032
+ const localDev = {
2033
+ phases: [
2034
+ phase("stop intake", s(1)),
2035
+ phase("drain", s(4)),
2036
+ phase("close transports", s(1)),
2037
+ phase("stop the poller", s(1)),
2038
+ phase("tear down dependencies", s(1)),
2039
+ phase("close the health server", s(1)),
2040
+ phase("flush the output", s(1))
2041
+ ],
2042
+ cooperativeWindow: s(2),
2043
+ cooperativeFloor: "half",
2044
+ onRepeatSignal: "exit"
2045
+ };
2046
+ /**
2047
+ * This profile is for a background host on the computer of a developer, under a supervisor or a
2048
+ * command that stops it. It has the caps of `localDev`. A repeat signal has no effect, thus the
2049
+ * host ends its drain. It has no process trigger: a background host has no parent on stdin.
2050
+ */
2051
+ const daemon = {
2052
+ phases: localDev.phases.map((each) => ({ ...each })),
2053
+ cooperativeWindow: localDev.cooperativeWindow,
2054
+ cooperativeFloor: "half",
2055
+ onRepeatSignal: "ignore"
2056
+ };
2057
+ /**
2058
+ * This profile is for a small process: a command that runs one time, or a child process on stdio.
2059
+ * It has no checks and no probes, and its policies gate nothing. The cooperative abort occurs at the
2060
+ * start of the drain, thus a first Ctrl-C stops the work at once. A second signal exits at once.
2061
+ * The exit codes tell the shell how the process stopped: 130 for SIGINT, 143 for SIGTERM and 131
2062
+ * for SIGQUIT. A stdio child also declares `triggers: ["stdin-close"]`.
2063
+ */
2064
+ const ephemeral = {
2065
+ readiness: {
2066
+ type: "window",
2067
+ failAfter: s(0)
2068
+ },
2069
+ liveness: {
2070
+ type: "window",
2071
+ failAfter: s(0)
2072
+ },
2073
+ startup: s(10),
2074
+ phases: [
2075
+ phase("stop intake", s(1)),
2076
+ phase("drain", s(2)),
2077
+ phase("close transports", s(1)),
2078
+ phase("stop the poller", s(1)),
2079
+ phase("tear down dependencies", s(2)),
2080
+ phase("flush the output", s(1))
2081
+ ],
2082
+ cooperativeWindow: s(2),
2083
+ cooperativeFloor: "none",
2084
+ onRepeatSignal: "exit",
2085
+ exitCodes: {
2086
+ SIGINT: 130,
2087
+ SIGTERM: 143,
2088
+ SIGQUIT: 131
2089
+ }
2090
+ };
2091
+ /** These are the parts of the budget of each stage in the profile `kubernetes`, in thirtieths. */
2092
+ const KUBERNETES_SHARES = [
2093
+ ["fail readiness", 2],
2094
+ ["stop intake", 5],
2095
+ ["drain", 15],
2096
+ ["close transports", 2],
2097
+ ["stop the poller", 1],
2098
+ ["tear down dependencies", 2],
2099
+ ["close the health server", 1]
2100
+ ];
2101
+ /** This part of the budget is the cooperative window, in thirtieths. */
2102
+ const KUBERNETES_WINDOW = 5;
2103
+ /**
2104
+ * This function gives the profile of a pod in Kubernetes. The budget is the grace less the preStop
2105
+ * sleep: the time from SIGTERM to SIGKILL. Each stage gets a part of the budget, rounded down to
2106
+ * the millisecond.
2107
+ * - The parts in thirtieths are 2, 5, 15, 2, 1, 2 and 1, in the sequence of the stages. Thus 2
2108
+ * thirtieths of the budget stay as a margin before SIGKILL.
2109
+ * - The cooperative window is 5 thirtieths of the budget.
2110
+ * - The kubelet sends SIGTERM one time, thus a repeat signal has no effect.
2111
+ *
2112
+ * At the defaults of Kubernetes (a grace of 30 s and no preStop hook), the caps are 2, 5, 15, 2, 1,
2113
+ * 2 and 1 s. Thus the sum of the caps and the preStop sleep fits in the grace (O10). The window is
2114
+ * shorter than the drain, which is shorter than the budget (O16).
2115
+ */
2116
+ const kubernetes = (pod) => {
2117
+ const time = (amount, what) => {
2118
+ if (!convertible(amount.unit, "s")) throw new Error(`the ${what} of the pod is not a time`);
2119
+ return valueIn(amount, "ms");
2120
+ };
2121
+ const grace = time(pod.grace, "grace");
2122
+ const preStop = time(pod.preStop, "preStop sleep");
2123
+ if (preStop < 0) throw new Error("the preStop sleep of the pod is less than 0");
2124
+ const budget = grace - preStop;
2125
+ if (!(budget > 0)) throw new Error(`the grace of the pod (${grace} ms) is not longer than its preStop sleep (${preStop} ms)`);
2126
+ const part = (thirtieths) => s(Math.floor(budget * thirtieths / 30) / 1e3);
2127
+ return {
2128
+ phases: KUBERNETES_SHARES.map(([name, thirtieths]) => phase(name, part(thirtieths))),
2129
+ cooperativeWindow: part(KUBERNETES_WINDOW),
2130
+ cooperativeFloor: "half",
2131
+ onRepeatSignal: "ignore"
2132
+ };
2133
+ };
2134
+ //#endregion
2135
+ //#region src/units.ts
2136
+ /** This function gives the cause of an abort as text. */
2137
+ const causeOf = (signal) => signal.reason instanceof Error ? signal.reason.message : String(signal.reason ?? "the unit got an abort");
2138
+ /**
2139
+ * This function does one unit of work, and gives its end. With a request, the unit goes through
2140
+ * `life.request`, thus the service counts it, drains it and can cut it. A throw after the abort of
2141
+ * the transport is a cut, not a failure (S6 A10). A throw after the bound is a failure.
2142
+ */
2143
+ const runUnit = async (run) => {
2144
+ const { clock, bound, abort, life, request, unit, work } = run;
2145
+ const timeout = new AbortController();
2146
+ const cancel = Number.isFinite(bound) ? clock.after(bound, () => timeout.abort(/* @__PURE__ */ new Error(`the unit did not end in its unit_processing_time of ${bound} ms`))) : () => {};
2147
+ const call = async (tracker) => work(AbortSignal.any(tracker === void 0 ? [abort.signal, timeout.signal] : [
2148
+ abort.signal,
2149
+ timeout.signal,
2150
+ tracker
2151
+ ]));
2152
+ const thrown = (error) => {
2153
+ if (abort.signal.aborted) return {
2154
+ kind: "cut",
2155
+ cause: causeOf(abort.signal),
2156
+ retryable: false
2157
+ };
2158
+ if (timeout.signal.aborted) return {
2159
+ kind: "failed",
2160
+ error: timeout.signal.reason
2161
+ };
2162
+ return {
2163
+ kind: "failed",
2164
+ error
2165
+ };
2166
+ };
2167
+ try {
2168
+ if (request === void 0) try {
2169
+ return {
2170
+ kind: "done",
2171
+ value: await call()
2172
+ };
2173
+ } catch (error) {
2174
+ return thrown(error);
2175
+ }
2176
+ let collected;
2177
+ try {
2178
+ const route = typeof request.route === "string" ? request.route : request.route(unit);
2179
+ const detail = request.detail?.(unit);
2180
+ collected = life.request(request.controller, route, (signal) => call(signal).then((value) => ({ value })), detail === void 0 ? {} : { detail }).collect();
2181
+ } catch (error) {
2182
+ return {
2183
+ kind: "failed",
2184
+ error
2185
+ };
2186
+ }
2187
+ const { items, end } = await collected;
2188
+ if (end.kind === "done") return {
2189
+ kind: "done",
2190
+ value: items[0]?.value
2191
+ };
2192
+ if (end.kind === "failed") return thrown(end.error);
2193
+ if (end.kind === "refused") return {
2194
+ kind: "refused",
2195
+ cause: end.cause
2196
+ };
2197
+ return {
2198
+ kind: "cut",
2199
+ cause: end.cause,
2200
+ retryable: end.retryable
2201
+ };
2202
+ } finally {
2203
+ cancel();
2204
+ }
2205
+ };
2206
+ /** This function makes a flight set on a clock. */
2207
+ const flights = (clock) => {
2208
+ const units = /* @__PURE__ */ new Set();
2209
+ const idle = /* @__PURE__ */ new Set();
2210
+ const lower = /* @__PURE__ */ new Set();
2211
+ let aborted = null;
2212
+ const settle = () => {
2213
+ for (const done of [...lower]) done();
2214
+ lower.clear();
2215
+ if (units.size === 0) for (const done of [...idle]) done();
2216
+ };
2217
+ const abort = (reason) => {
2218
+ aborted ??= { reason };
2219
+ for (const unit of [...units]) if (!unit.signal.aborted) unit.abort(aborted.reason);
2220
+ };
2221
+ return {
2222
+ get size() {
2223
+ return units.size;
2224
+ },
2225
+ add(unit) {
2226
+ units.add(unit);
2227
+ if (aborted !== null && !unit.signal.aborted) unit.abort(aborted.reason);
2228
+ return () => {
2229
+ if (units.delete(unit)) settle();
2230
+ };
2231
+ },
2232
+ abort,
2233
+ clear() {
2234
+ units.clear();
2235
+ settle();
2236
+ },
2237
+ drain(deadline, signal) {
2238
+ const cooperative = () => abort(signal.reason);
2239
+ if (signal.aborted) cooperative();
2240
+ else signal.addEventListener("abort", cooperative, { once: true });
2241
+ if (units.size === 0 || deadline <= clock.now()) {
2242
+ signal.removeEventListener("abort", cooperative);
2243
+ return Promise.resolve();
2244
+ }
2245
+ return new Promise((resolve) => {
2246
+ let cancel = () => {};
2247
+ const done = () => {
2248
+ idle.delete(done);
2249
+ cancel();
2250
+ signal.removeEventListener("abort", cooperative);
2251
+ resolve();
2252
+ };
2253
+ idle.add(done);
2254
+ cancel = clock.after(Math.max(0, deadline - clock.now()), done);
2255
+ });
2256
+ },
2257
+ less() {
2258
+ return new Promise((resolve) => lower.add(resolve));
2259
+ },
2260
+ release() {
2261
+ for (const done of [...lower]) done();
2262
+ lower.clear();
2263
+ }
2264
+ };
2265
+ };
2266
+ /** This function makes a sleeper on a clock. */
2267
+ const sleeper = (clock) => {
2268
+ let wakes = 0;
2269
+ let stopped = false;
2270
+ const sleeps = /* @__PURE__ */ new Set();
2271
+ const backoffs = /* @__PURE__ */ new Set();
2272
+ const wait = (ms, into) => stopped ? Promise.resolve() : new Promise((resolve) => {
2273
+ let cancel = () => {};
2274
+ const done = () => {
2275
+ if (!into.delete(done)) return;
2276
+ cancel();
2277
+ resolve();
2278
+ };
2279
+ into.add(done);
2280
+ if (Number.isFinite(ms)) cancel = clock.after(ms, done);
2281
+ });
2282
+ return {
2283
+ get wakes() {
2284
+ return wakes;
2285
+ },
2286
+ sleep(ms, since) {
2287
+ if (since !== wakes) return Promise.resolve();
2288
+ return wait(ms, sleeps);
2289
+ },
2290
+ backoff(ms) {
2291
+ return wait(ms, backoffs);
2292
+ },
2293
+ wake() {
2294
+ wakes += 1;
2295
+ const ended = sleeps.size;
2296
+ for (const done of [...sleeps]) done();
2297
+ return ended;
2298
+ },
2299
+ stop() {
2300
+ stopped = true;
2301
+ for (const done of [...sleeps, ...backoffs]) done();
2302
+ }
2303
+ };
2304
+ };
2305
+ /** This function waits for a work until a bound on the clock, in milliseconds. */
2306
+ const settleWithin = (clock, ms, work) => new Promise((resolve) => {
2307
+ const cancel = Number.isFinite(ms) ? clock.after(ms, () => resolve({ kind: "bound" })) : () => {};
2308
+ work.then(() => {
2309
+ cancel();
2310
+ resolve({ kind: "done" });
2311
+ }, (error) => {
2312
+ cancel();
2313
+ resolve({
2314
+ kind: "failed",
2315
+ error
2316
+ });
2317
+ });
2318
+ });
2319
+ /**
2320
+ * This function gives the backoff of the knobs of a loop. The first delay is `retry_base`, and each
2321
+ * delay is two times longer, up to `retry_cap`. Each delay has a jitter up to the base, as in the Redis client.
2322
+ */
2323
+ const retryOf = (knobs, owner) => {
2324
+ const base = boundedMillisecondsOf(knobs, "retry_base", owner);
2325
+ const backoff = {
2326
+ base,
2327
+ factor: 2,
2328
+ cap: boundedMillisecondsOf(knobs, "retry_cap", owner),
2329
+ jitter: {
2330
+ kind: "add",
2331
+ amount: base
2332
+ }
2333
+ };
2334
+ const problems = backoffProblems(backoff);
2335
+ if (problems.length > 0) throw new Error(`${owner}: the retry_base and retry_cap give no backoff: ${problems.join(", ")}`);
2336
+ return backoff;
2337
+ };
2338
+ /**
2339
+ * This function gives the problems of the bounds of a loop in a declaration. The close bound fits
2340
+ * in the cap of `close transports`. The bound of a unit fits in the cap of `drain`, or the drain can cut a unit (S6 A10).
2341
+ */
2342
+ const boundProblems = (owner, closeBound, unitBound, description) => {
2343
+ const capOf = (name) => {
2344
+ const phase = description.shutdown.phases.find((each) => each.name === name);
2345
+ return phase === void 0 ? void 0 : valueIn(phase.cap, "ms");
2346
+ };
2347
+ const found = [];
2348
+ const close = capOf("close transports");
2349
+ if (close === void 0) found.push(`${owner} needs the phase "close transports" to close`);
2350
+ else if (closeBound >= close) found.push(`the close_bound of ${owner}, ${closeBound} ms, does not fit in the cap of "close transports", ${close} ms`);
2351
+ const drain = capOf("drain");
2352
+ if (drain === void 0) found.push(`${owner} needs the phase "drain" to end its units`);
2353
+ else if (!Number.isFinite(unitBound)) found.push(`the unit_processing_time of ${owner} has no bound, thus the drain can cut a unit at its cap of ${drain} ms`);
2354
+ else if (unitBound >= drain) found.push(`the unit_processing_time of ${owner}, ${unitBound} ms, does not fit in the cap of "drain", ${drain} ms`);
2355
+ return found;
2356
+ };
2357
+ //#endregion
2358
+ //#region src/worker.ts
2359
+ const OWNER$1 = "workerLoop";
2360
+ /**
2361
+ * This function makes a worker loop as a transport (S6 A10). It has a setup, an iteration and a
2362
+ * teardown, and each lane does its iterations one at a time.
2363
+ * - The start does the setup. A failed setup gets its teardown, and the start throws.
2364
+ * - The first iteration of each lane comes after the startup ends. Each sleep and each backoff is on the clock.
2365
+ * - `stopIntake` starts no new iteration, and it ends each sleep and each backoff.
2366
+ * - `drain` waits for each iteration in flight until the deadline, and gives it the cooperative
2367
+ * abort. A throw after that abort is not a failure.
2368
+ * - `close` aborts each iteration, waits for its end, and does the teardown, all in `close_bound`.
2369
+ */
2370
+ const workerLoop = (knobs, options) => {
2371
+ const sleep = millisecondsOf(knobs, "worker_sleep", OWNER$1);
2372
+ const bound = millisecondsOf(knobs, "unit_processing_time", OWNER$1);
2373
+ const backoff = retryOf(knobs, OWNER$1);
2374
+ const closeBound = boundedMillisecondsOf(knobs, "close_bound", OWNER$1);
2375
+ const lanes = countOf(knobs, "concurrency", OWNER$1);
2376
+ if (!Number.isFinite(lanes)) throw new Error(`${OWNER$1} needs a concurrency with a bound: each lane is a loop`);
2377
+ const clock = options.clock ?? realClock;
2378
+ const random = options.random ?? Math.random;
2379
+ const running = flights(clock);
2380
+ const rests = sleeper(clock);
2381
+ const intake = new AbortController();
2382
+ const pending = /* @__PURE__ */ new Set();
2383
+ let started = false;
2384
+ let setup = null;
2385
+ let state;
2386
+ let tornDown = null;
2387
+ let closing = null;
2388
+ const report = (error, failures) => {
2389
+ try {
2390
+ options.onError?.(error, failures);
2391
+ } catch {}
2392
+ };
2393
+ /** This function does the teardown one time, after the setup ends, and only when the setup started. */
2394
+ const teardown = () => {
2395
+ if (tornDown !== null) return tornDown;
2396
+ const release = options.teardown;
2397
+ const made = setup;
2398
+ tornDown = made === null || release === void 0 ? Promise.resolve() : made.then((given) => release(given), () => release(void 0));
2399
+ return tornDown;
2400
+ };
2401
+ const keep = (work) => {
2402
+ pending.add(work);
2403
+ const forget = () => {
2404
+ pending.delete(work);
2405
+ };
2406
+ work.then(forget, forget);
2407
+ return work;
2408
+ };
2409
+ /** This function is one lane: an iteration, then its sleep or its backoff, until the intake stops. */
2410
+ const lane = async (index, life) => {
2411
+ let failures = 0;
2412
+ let since = rests.wakes;
2413
+ await rests.sleep(0, since);
2414
+ for (;;) {
2415
+ if (intake.signal.aborted) return;
2416
+ since = rests.wakes;
2417
+ const abort = new AbortController();
2418
+ const remove = running.add(abort);
2419
+ const count = failures;
2420
+ const end = await runUnit({
2421
+ clock,
2422
+ bound,
2423
+ abort,
2424
+ life,
2425
+ request: options.request,
2426
+ unit: index,
2427
+ work: (signal) => keep((async () => options.iteration({
2428
+ signal,
2429
+ intake: intake.signal,
2430
+ state,
2431
+ lane: index,
2432
+ failures: count,
2433
+ life
2434
+ }))())
2435
+ });
2436
+ remove();
2437
+ if (end.kind === "cut") return;
2438
+ if (end.kind === "done") {
2439
+ failures = 0;
2440
+ await rests.sleep(end.value === "more" ? 0 : sleep, end.value === "more" ? rests.wakes : since);
2441
+ continue;
2442
+ }
2443
+ failures += 1;
2444
+ report(end.kind === "failed" ? end.error : /* @__PURE__ */ new Error(`the service refused the iteration: ${end.cause}`), failures);
2445
+ await rests.backoff(backoffDelay(backoff, failures, random));
2446
+ }
2447
+ };
2448
+ return {
2449
+ knobs: { ...knobs },
2450
+ async start(signal, life) {
2451
+ if (closing !== null) throw new Error(`${OWNER$1}: a closed loop does not start again`);
2452
+ if (started) throw new Error(`${OWNER$1}: the loop started already`);
2453
+ started = true;
2454
+ const make = options.setup;
2455
+ setup = make === void 0 ? Promise.resolve(void 0) : new Promise((resolve) => resolve(make(signal)));
2456
+ try {
2457
+ await setup.then((given) => {
2458
+ state = given;
2459
+ });
2460
+ } catch (error) {
2461
+ await settleWithin(clock, closeBound, teardown());
2462
+ throw error;
2463
+ }
2464
+ life.started.then(() => {
2465
+ if (intake.signal.aborted || life.state !== "running") return;
2466
+ for (let index = 0; index < lanes; index += 1) lane(index, life).catch((error) => report(error, 0));
2467
+ });
2468
+ },
2469
+ stopIntake() {
2470
+ if (intake.signal.aborted) return;
2471
+ intake.abort(/* @__PURE__ */ new Error(`${OWNER$1} stopped its intake`));
2472
+ rests.stop();
2473
+ },
2474
+ drain(deadline, signal) {
2475
+ return running.drain(deadline, signal);
2476
+ },
2477
+ inFlight: () => running.size,
2478
+ wake() {
2479
+ if (intake.signal.aborted) return false;
2480
+ rests.wake();
2481
+ return true;
2482
+ },
2483
+ close() {
2484
+ if (closing !== null) return closing;
2485
+ if (!intake.signal.aborted) intake.abort(/* @__PURE__ */ new Error(`${OWNER$1} closed`));
2486
+ rests.stop();
2487
+ running.abort(/* @__PURE__ */ new Error(`${OWNER$1} closed`));
2488
+ running.clear();
2489
+ closing = (async () => {
2490
+ const began = clock.now();
2491
+ const ended = await settleWithin(clock, closeBound, Promise.allSettled([...pending]));
2492
+ const down = await settleWithin(clock, Math.max(0, closeBound - (clock.now() - began)), teardown());
2493
+ if (ended.kind === "bound") throw new Error(`${OWNER$1}: an iteration did not end in the close_bound of ${closeBound} ms after its abort`);
2494
+ if (down.kind === "failed") throw down.error;
2495
+ if (down.kind === "bound") throw new Error(`${OWNER$1}: the teardown did not end in the close_bound of ${closeBound} ms`);
2496
+ })();
2497
+ return closing;
2498
+ },
2499
+ problems(description) {
2500
+ return boundProblems(OWNER$1, closeBound, bound, description);
2501
+ }
2502
+ };
2503
+ };
2504
+ //#endregion
2505
+ //#region src/queue.ts
2506
+ /**
2507
+ * This function gives the redelivery policy of S6 A11. A poison message gets `ack`. Each other job
2508
+ * goes back on the queue after its first failure, one time. After a later failure, `then` gives the
2509
+ * action: the declared policy of the queue.
2510
+ */
2511
+ const redeliverOnce = (then, poison) => (failure) => {
2512
+ if (poison?.(failure.error, failure.job) === true) return "ack";
2513
+ if (failure.delivery <= 1) return "requeue";
2514
+ return typeof then === "function" ? then(failure) : then;
2515
+ };
2516
+ const OWNER = "queueDrain";
2517
+ const ACTIONS = [
2518
+ "requeue",
2519
+ "reject",
2520
+ "ack"
2521
+ ];
2522
+ const STOPPED = Symbol("the intake stopped");
2523
+ const isAsyncIterable = (value) => value !== null && (typeof value === "object" || typeof value === "function") && typeof value[Symbol.asyncIterator] === "function";
2524
+ /**
2525
+ * This function makes a queue drain as a transport (S6 A11). One taker takes the jobs from the
2526
+ * source while a slot is free, and each job operates in its slot.
2527
+ * - A success gets `ack`. A failure gets the action of the redelivery policy.
2528
+ * - `stopIntake` takes no new job, and a job that the drain took and did not start goes back (`requeue`, `unstarted`).
2529
+ * - `drain` waits for each job in flight until the deadline, and gives it the cooperative abort.
2530
+ * - `close` aborts each job, waits for its end, and closes the source, all in `close_bound`. A job
2531
+ * that does not end in the bound gets `reject`.
2532
+ */
2533
+ const queueDrain = (knobs, options) => {
2534
+ const poll = millisecondsOf(knobs, "queue_poll_interval", OWNER);
2535
+ const bound = millisecondsOf(knobs, "unit_processing_time", OWNER);
2536
+ const backoff = retryOf(knobs, OWNER);
2537
+ const closeBound = boundedMillisecondsOf(knobs, "close_bound", OWNER);
2538
+ const concurrency = countOf(knobs, "concurrency", OWNER);
2539
+ const { settle } = options;
2540
+ const clock = options.clock ?? realClock;
2541
+ const random = options.random ?? Math.random;
2542
+ const running = flights(clock);
2543
+ const rests = sleeper(clock);
2544
+ const intake = new AbortController();
2545
+ const stopped = new Promise((resolve) => intake.signal.addEventListener("abort", () => resolve(STOPPED), { once: true }));
2546
+ const pending = /* @__PURE__ */ new Set();
2547
+ const active = /* @__PURE__ */ new Set();
2548
+ let refusals = 0;
2549
+ let pausedUntil = Number.NEGATIVE_INFINITY;
2550
+ let cancelSource = () => {};
2551
+ let started = false;
2552
+ let closing = null;
2553
+ const report = (error, job) => {
2554
+ try {
2555
+ options.onError?.(error, job);
2556
+ } catch {}
2557
+ };
2558
+ const keep = (work) => {
2559
+ pending.add(work);
2560
+ const forget = () => {
2561
+ pending.delete(work);
2562
+ };
2563
+ work.then(forget, forget);
2564
+ };
2565
+ /** This function tells the queue the end of a job, one time. A throw of the queue is a failure. */
2566
+ const once = async (unit, tell) => {
2567
+ if (unit.settled) return;
2568
+ unit.settled = true;
2569
+ try {
2570
+ await tell();
2571
+ } catch (error) {
2572
+ report(error, unit.job);
2573
+ }
2574
+ };
2575
+ const handBack = (job) => keep((async () => {
2576
+ try {
2577
+ await settle.requeue(job, "unstarted");
2578
+ } catch (error) {
2579
+ report(error, job);
2580
+ }
2581
+ })());
2582
+ /** This function gives the action of the policy for a failed job. A policy that throws, or that gives no action, rejects the job. */
2583
+ const actionOf = (job, error, delivery) => {
2584
+ try {
2585
+ const action = options.redelivery({
2586
+ job,
2587
+ error,
2588
+ delivery
2589
+ });
2590
+ if (ACTIONS.includes(action)) return action;
2591
+ report(/* @__PURE__ */ new Error(`the redelivery policy gave ${String(action)}, not requeue, reject or ack`), job);
2592
+ } catch (thrown) {
2593
+ report(thrown, job);
2594
+ }
2595
+ return "reject";
2596
+ };
2597
+ const settleEnd = (unit, end, delivery) => {
2598
+ const { job } = unit;
2599
+ if (end.kind === "done") return once(unit, () => settle.ack(job));
2600
+ if (end.kind === "refused") {
2601
+ report(/* @__PURE__ */ new Error(`the service refused the job: ${end.cause}`), job);
2602
+ return once(unit, () => settle.requeue(job, "unstarted"));
2603
+ }
2604
+ if (end.kind === "cut") {
2605
+ if (end.retryable) return once(unit, () => settle.requeue(job, "cut"));
2606
+ const cut = new Error(end.cause);
2607
+ return once(unit, () => settle.reject(job, cut));
2608
+ }
2609
+ const { error } = end;
2610
+ report(error, job);
2611
+ const action = actionOf(job, error, delivery);
2612
+ if (action === "requeue") return once(unit, () => settle.requeue(job, "failed"));
2613
+ return once(unit, () => action === "ack" ? settle.ack(job) : settle.reject(job, error));
2614
+ };
2615
+ /** This function starts one job in a slot. The slot stays in flight until the queue has the end of the job. */
2616
+ const runJob = (job, life) => {
2617
+ const unit = {
2618
+ job,
2619
+ settled: false
2620
+ };
2621
+ active.add(unit);
2622
+ const abort = new AbortController();
2623
+ const remove = running.add(abort);
2624
+ let delivery;
2625
+ try {
2626
+ delivery = settle.delivery(job);
2627
+ } catch (error) {
2628
+ report(error, job);
2629
+ delivery = Number.POSITIVE_INFINITY;
2630
+ }
2631
+ keep((async () => {
2632
+ const end = await runUnit({
2633
+ clock,
2634
+ bound,
2635
+ abort,
2636
+ life,
2637
+ request: options.request,
2638
+ unit: job,
2639
+ work: (signal) => options.handle(job, {
2640
+ signal,
2641
+ intake: intake.signal,
2642
+ delivery,
2643
+ life
2644
+ })
2645
+ });
2646
+ if (end.kind === "refused") {
2647
+ refusals += 1;
2648
+ pausedUntil = clock.now() + backoffDelay(backoff, refusals, random);
2649
+ } else refusals = 0;
2650
+ await settleEnd(unit, end, delivery);
2651
+ })().finally(() => {
2652
+ active.delete(unit);
2653
+ remove();
2654
+ }));
2655
+ };
2656
+ /** This function gives the take of the source. */
2657
+ const takeOf = () => {
2658
+ const source = options.source;
2659
+ if (isAsyncIterable(source)) {
2660
+ const iterator = source[Symbol.asyncIterator]();
2661
+ cancelSource = () => {
2662
+ try {
2663
+ Promise.resolve(iterator.return?.()).catch(() => {});
2664
+ } catch {}
2665
+ };
2666
+ return async () => {
2667
+ const step = await iterator.next();
2668
+ return step.done === true ? { kind: "end" } : {
2669
+ kind: "job",
2670
+ job: step.value
2671
+ };
2672
+ };
2673
+ }
2674
+ const pull = source;
2675
+ return () => new Promise((resolve) => resolve(pull(intake.signal))).then((job) => job === null || job === void 0 ? { kind: "none" } : {
2676
+ kind: "job",
2677
+ job
2678
+ });
2679
+ };
2680
+ /** This function is the taker: it takes a job while a slot is free, until the intake stops or the source ends. */
2681
+ const taker = async (life) => {
2682
+ const take = takeOf();
2683
+ let failures = 0;
2684
+ for (;;) {
2685
+ if (intake.signal.aborted) return;
2686
+ if (running.size >= concurrency) {
2687
+ await running.less();
2688
+ continue;
2689
+ }
2690
+ const pause = pausedUntil - clock.now();
2691
+ if (pause > 0) {
2692
+ await rests.backoff(pause);
2693
+ continue;
2694
+ }
2695
+ const since = rests.wakes;
2696
+ const taking = take().catch((error) => ({
2697
+ kind: "error",
2698
+ error
2699
+ }));
2700
+ const first = await Promise.race([taking, stopped]);
2701
+ if (first === STOPPED || intake.signal.aborted) {
2702
+ if (first === STOPPED) taking.then((late) => {
2703
+ if (late.kind === "job") handBack(late.job);
2704
+ });
2705
+ else if (first.kind === "job") handBack(first.job);
2706
+ return;
2707
+ }
2708
+ if (first.kind === "error") {
2709
+ failures += 1;
2710
+ report(first.error, void 0);
2711
+ await rests.backoff(backoffDelay(backoff, failures, random));
2712
+ continue;
2713
+ }
2714
+ failures = 0;
2715
+ if (first.kind === "end") {
2716
+ report(/* @__PURE__ */ new Error(`the source of ${OWNER} ended while its intake was open: it takes no more jobs`), void 0);
2717
+ return;
2718
+ }
2719
+ if (first.kind === "none") {
2720
+ await rests.sleep(poll, since);
2721
+ continue;
2722
+ }
2723
+ runJob(first.job, life);
2724
+ }
2725
+ };
2726
+ const stopTaking = (cause) => {
2727
+ if (intake.signal.aborted) return;
2728
+ intake.abort(new Error(cause));
2729
+ rests.stop();
2730
+ running.release();
2731
+ cancelSource();
2732
+ };
2733
+ return {
2734
+ knobs: { ...knobs },
2735
+ start(_signal, life) {
2736
+ if (closing !== null) throw new Error(`${OWNER}: a closed drain does not start again`);
2737
+ if (started) throw new Error(`${OWNER}: the drain started already`);
2738
+ started = true;
2739
+ life.started.then(() => {
2740
+ if (intake.signal.aborted || life.state !== "running") return;
2741
+ taker(life).catch((error) => report(error, void 0));
2742
+ });
2743
+ },
2744
+ stopIntake() {
2745
+ stopTaking(`${OWNER} stopped its intake`);
2746
+ },
2747
+ drain(deadline, signal) {
2748
+ return running.drain(deadline, signal);
2749
+ },
2750
+ inFlight: () => running.size,
2751
+ wake() {
2752
+ if (intake.signal.aborted) return false;
2753
+ rests.wake();
2754
+ return true;
2755
+ },
2756
+ close() {
2757
+ if (closing !== null) return closing;
2758
+ stopTaking(`${OWNER} closed`);
2759
+ running.abort(/* @__PURE__ */ new Error(`${OWNER} closed`));
2760
+ running.clear();
2761
+ closing = (async () => {
2762
+ const began = clock.now();
2763
+ const ended = await settleWithin(clock, closeBound, Promise.allSettled([...pending]));
2764
+ if (ended.kind === "bound") for (const unit of [...active]) once(unit, () => settle.reject(unit.job, /* @__PURE__ */ new Error(`${OWNER} closed with the job in flight`)));
2765
+ const shut = options.close;
2766
+ const down = await settleWithin(clock, Math.max(0, closeBound - (clock.now() - began)), shut === void 0 ? Promise.resolve() : (async () => shut())());
2767
+ if (ended.kind === "bound") throw new Error(`${OWNER}: a job did not end in the close_bound of ${closeBound} ms after its abort`);
2768
+ if (down.kind === "failed") throw down.error;
2769
+ if (down.kind === "bound") throw new Error(`${OWNER}: the close of the source did not end in the close_bound of ${closeBound} ms`);
2770
+ })();
2771
+ return closing;
2772
+ },
2773
+ problems(description) {
2774
+ return boundProblems(OWNER, closeBound, bound, description);
2775
+ }
2776
+ };
2777
+ };
2778
+ //#endregion
1455
2779
  //#region src/machines.ts
1456
2780
  /**
1457
2781
  * The machines of the runtime as `mark1russell7.machine/1` documents. The documents are the
@@ -1728,7 +3052,9 @@ const POLLER_MACHINE = {
1728
3052
  };
1729
3053
  /**
1730
3054
  * The exit manager is the only part that ends the process. The first exit request starts the
1731
- * shutdown, and a later one has no effect. An error makes the exit code 1.
3055
+ * shutdown. A later request has no effect, but a repeat signal exits at once when the declaration
3056
+ * says `onRepeatSignal: "exit"`. The exit code is the code of the exit request, else 1 after an
3057
+ * error, else 0.
1732
3058
  */
1733
3059
  const EXIT_MACHINE = {
1734
3060
  ...base("exit", "The exit manager"),
@@ -1740,23 +3066,35 @@ const EXIT_MACHINE = {
1740
3066
  events: {
1741
3067
  "exit.request": {
1742
3068
  kind: "app",
1743
- note: "SIGINT, SIGTERM, SIGQUIT or shutdown()"
3069
+ note: "SIGINT, SIGTERM, SIGQUIT, a declared process trigger or shutdown()"
3070
+ },
3071
+ "exit.repeat": {
3072
+ kind: "app",
3073
+ note: "a trigger or shutdown() while the shutdown runs"
1744
3074
  },
1745
- "exit.repeat": { kind: "app" },
1746
3075
  "startup.fail": { kind: "local" },
1747
3076
  error: { kind: "local" },
1748
3077
  "shutdown.done": { kind: "local" }
1749
3078
  },
1750
- knobs: {},
3079
+ knobs: { on_repeat_signal: {
3080
+ key: "shutdown.onRepeatSignal",
3081
+ scope: "service",
3082
+ unit: null,
3083
+ default: null
3084
+ } },
1751
3085
  timers: {},
1752
3086
  transitions: [
1753
3087
  t("request", "running", "exit.request", "stopping"),
1754
3088
  t("startup-fail", "running", "startup.fail", "stopping"),
1755
3089
  t("error-running", "running", "error", "running"),
1756
- t("repeat", "stopping", "exit.repeat", "stopping"),
3090
+ t("repeat", "stopping", "exit.repeat", "stopping", { guard: "a process trigger or shutdown() repeats, or a signal repeats and on_repeat_signal is not exit" }),
3091
+ t("repeat-exit", "stopping", "exit.repeat", "exited", {
3092
+ guard: "a signal repeats and on_repeat_signal is exit",
3093
+ note: "the process exits at once with the code 1"
3094
+ }),
1757
3095
  t("startup-fail-stopping", "stopping", "startup.fail", "stopping"),
1758
3096
  t("error", "stopping", "error", "stopping"),
1759
- t("exit", "stopping", "shutdown.done", "exited", { note: "the exit code is 1 after an error, else 0" })
3097
+ t("exit", "stopping", "shutdown.done", "exited", { note: "the exit code is the code of the exit request, else 1 after an error, else 0" })
1760
3098
  ]
1761
3099
  };
1762
3100
  /** A watchdog measures how late its own tick comes, and a late tick shows a blocked event loop. */
@@ -1807,7 +3145,8 @@ const capKnob = (phase) => `${phase.replace(/[^A-Za-z0-9]+/g, "_")}_cap`;
1807
3145
  /**
1808
3146
  * This function gives the drain of a declaration as a machine. It has one state for each declared
1809
3147
  * phase, in its sequence, and each state has a timer at its cap. The drain also has the cooperative abort: the
1810
- * cap of the drain less the cooperative window, and half that cap at the least.
3148
+ * cap of the drain less the cooperative window. With the floor `half`, it is half that cap at the
3149
+ * least. With the floor `none`, it is 0 at the least.
1811
3150
  */
1812
3151
  const drainMachine = (description) => {
1813
3152
  const phases = description.shutdown.phases;
@@ -1835,9 +3174,10 @@ const drainMachine = (description) => {
1835
3174
  cancels: ["phase.done"],
1836
3175
  fires: "phase.cap"
1837
3176
  };
3177
+ const floor = description.shutdown.cooperativeFloor === "none" ? "0" : "drain_cap / 2";
1838
3178
  if (phases.some((phase) => phase.name === "drain")) timers["cooperative_abort"] = {
1839
3179
  duration: {
1840
- expr: "max(drain_cap - cooperative_window, drain_cap / 2)",
3180
+ expr: `max(drain_cap - cooperative_window, ${floor})`,
1841
3181
  unit: "s"
1842
3182
  },
1843
3183
  arms: ["enter:drain"],
@@ -1885,6 +3225,6 @@ const MACHINES = {
1885
3225
  watchdog: WATCHDOG_MACHINE
1886
3226
  };
1887
3227
  //#endregion
1888
- export { EXIT_MACHINE, EXIT_SIGNALS, FORMAT, GATE_MACHINE, LATCH_MACHINE, MACHINES, POLLER_MACHINE, SERVICE_RULES, STAGES, WATCHDOG_MACHINE, WINDOW_MACHINE, asDebounce, asWindow, backoffBand, backoffDelay, backoffProblems, capKnob, cooperativeAbortAt, detectionTime, drainMachine, evaluate, failSpan, heartbeat, heartbeatDetection, inequalities, lint, mayReissue, nodeProcess, platformClock, realClock, recoverSpan, replay, service, serviceBindings, stageRank, symbolsOf, verdictOf, watchdog };
3228
+ export { DECLARATION_RULE, EXIT_MACHINE, EXIT_SIGNALS, FORMAT, GATE_MACHINE, LATCH_MACHINE, MACHINES, POLLER_MACHINE, PROCESS_TRIGGERS, SERVICE_RULES, STAGES, WATCHDOG_MACHINE, WINDOW_MACHINE, asDebounce, asWindow, backoffBand, backoffDelay, backoffProblems, capKnob, cooperativeAbortAt, daemon, detectionTime, drainMachine, ephemeral, evaluate, failSpan, heartbeat, heartbeatDetection, inequalities, isExitCode, isExitSignal, isMessageShape, kubernetes, lint, localDev, matchesMessage, mayReissue, nodeProcess, platformClock, processPortOf, queueDrain, realClock, recoverSpan, redeliverOnce, replay, service, serviceBindings, stageRank, symbolsOf, verdictOf, watchdog, workerLoop };
1889
3229
 
1890
3230
  //# sourceMappingURL=index.js.map