@mark1russell7/service 0.4.0 → 0.5.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 CHANGED
@@ -497,101 +497,398 @@ const EXIT_SIGNALS = [
497
497
  "SIGTERM",
498
498
  "SIGQUIT"
499
499
  ];
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;
500
+ /** These are the process triggers that a declaration can opt in to. */
501
+ const PROCESS_TRIGGERS = ["stdin-close", "ipc-close"];
502
+ /** This function tells if a number is an exit code of a process: a whole number from 0 to 255. */
503
+ const isExitCode = (code) => Number.isInteger(code) && code >= 0 && code <= 255;
504
+ /** This function tells if a trigger is an exit signal. A repeat signal obeys `onRepeatSignal`. */
505
+ const isExitSignal = (trigger) => EXIT_SIGNALS.includes(trigger);
506
+ /** This function adds a handler to some events of an emitter. The handler operates one time at most. */
507
+ const once = (emitter, events, handler) => {
508
+ let called = false;
509
+ const fire = () => {
510
+ if (called) return;
511
+ called = true;
512
+ handler();
513
+ };
514
+ for (const event of events) emitter.on(event, fire);
515
+ return () => {
516
+ for (const event of events) emitter.off(event, fire);
517
+ };
518
+ };
519
+ /** This function gives a promise that ends when the data before it in a stream is written. */
520
+ const written = (stream) => new Promise((resolve) => {
521
+ if (stream === void 0 || stream.destroyed === true || stream.writable === false) {
522
+ resolve();
523
+ return;
524
+ }
525
+ try {
526
+ stream.write("", () => resolve());
527
+ } catch {
528
+ resolve();
529
+ }
530
+ });
531
+ /**
532
+ * This function makes a process port on a host. `find` gives the host at each use, or undefined
533
+ * when the platform has no process. Then the use throws.
534
+ */
535
+ const processPortOf = (find) => {
536
+ const hostProcess = () => {
537
+ const host = find();
512
538
  if (host === void 0) throw new Error("no process: give the runtime a process port");
513
- host.exit(code);
539
+ return host;
540
+ };
541
+ return {
542
+ on(trigger, handler) {
543
+ const host = hostProcess();
544
+ if (trigger === "stdin-close") {
545
+ const input = host.stdin;
546
+ if (input === void 0) return () => {};
547
+ if (input.readableEnded === true) {
548
+ let live = true;
549
+ queueMicrotask(() => {
550
+ if (live) handler();
551
+ });
552
+ return () => {
553
+ live = false;
554
+ };
555
+ }
556
+ return once(input, ["end", "close"], handler);
557
+ }
558
+ if (trigger === "ipc-close") return once(host, ["disconnect"], handler);
559
+ host.on(trigger, handler);
560
+ return () => {
561
+ host.off(trigger, handler);
562
+ };
563
+ },
564
+ async flush() {
565
+ const host = hostProcess();
566
+ await Promise.all([written(host.stdout), written(host.stderr)]);
567
+ },
568
+ exit(code) {
569
+ hostProcess().exit(code);
570
+ }
571
+ };
572
+ };
573
+ /** This port is the process of Node. */
574
+ const nodeProcess = processPortOf(() => globalThis.process);
575
+ //#endregion
576
+ //#region src/routes.ts
577
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*/;
578
+ /** This function gives the problems of the syntax of a pattern: an empty pattern, a part without a name, or a name two times. */
579
+ const patternProblems = (owner, pattern) => {
580
+ const problems = [];
581
+ if (pattern.length === 0) problems.push(`${owner} has an empty route pattern`);
582
+ const names = /* @__PURE__ */ new Set();
583
+ for (let at = 0; at < pattern.length; at += 1) {
584
+ if (pattern[at] !== ":") continue;
585
+ const name = NAME.exec(pattern.slice(at + 1))?.[0];
586
+ if (name === void 0) {
587
+ problems.push(`${owner}'s pattern ${pattern} has a ":" without a name at ${at}`);
588
+ continue;
589
+ }
590
+ if (names.has(name)) problems.push(`${owner}'s pattern ${pattern} names :${name} two times`);
591
+ names.add(name);
592
+ at += name.length;
514
593
  }
594
+ return problems;
595
+ };
596
+ /** This function gives the problems of a pattern and of each of its operations, which `describe()` refuses. */
597
+ const routePatternProblems = (owner, declared) => {
598
+ const problems = patternProblems(owner, declared.pattern);
599
+ const names = Object.keys(declared.operations);
600
+ if (names.length === 0) problems.push(`${owner}'s pattern ${declared.pattern} declares no operation`);
601
+ for (const name of names) problems.push(...operationProblems(owner, `${declared.pattern} (${name})`, declared.operations[name]));
602
+ return problems;
603
+ };
604
+ /** This function compiles a pattern to a matcher. The match must cover the whole route. */
605
+ const compilePattern = (pattern) => {
606
+ const kinds = [];
607
+ let source = "^";
608
+ for (let at = 0; at < pattern.length; at += 1) {
609
+ const char = pattern[at];
610
+ if (char === "*") {
611
+ source += "([\\s\\S]*)";
612
+ kinds.push({ kind: "wildcard" });
613
+ continue;
614
+ }
615
+ if (char === ":") {
616
+ const name = NAME.exec(pattern.slice(at + 1))?.[0];
617
+ if (name !== void 0) {
618
+ source += "([^/]+)";
619
+ kinds.push({
620
+ kind: "param",
621
+ name
622
+ });
623
+ at += name.length;
624
+ continue;
625
+ }
626
+ }
627
+ source += char.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
628
+ }
629
+ const matcher = new RegExp(`${source}$`);
630
+ return {
631
+ pattern,
632
+ match(route) {
633
+ const found = matcher.exec(route);
634
+ if (found === null) return null;
635
+ const params = {};
636
+ const wildcards = [];
637
+ kinds.forEach((part, index) => {
638
+ const text = found[index + 1] ?? "";
639
+ if (part.kind === "param") params[part.name] = text;
640
+ else wildcards.push(text);
641
+ });
642
+ return {
643
+ params,
644
+ wildcards
645
+ };
646
+ }
647
+ };
515
648
  };
516
649
  //#endregion
517
650
  //#region src/requests.ts
651
+ const isAsyncIterable = (value) => value !== null && (typeof value === "object" || typeof value === "function") && typeof value[Symbol.asyncIterator] === "function";
652
+ const isPromiseLike = (value) => value !== null && (typeof value === "object" || typeof value === "function") && typeof value.then === "function";
653
+ /** This function gives a stream of events from a function that gives the next event. */
654
+ const streamOf = (next, stop) => {
655
+ let chain = Promise.resolve();
656
+ const iterator = {
657
+ next() {
658
+ const step = chain.then(next).then((event) => event === null ? {
659
+ value: void 0,
660
+ done: true
661
+ } : {
662
+ value: event,
663
+ done: false
664
+ });
665
+ chain = step.catch(() => {});
666
+ return step;
667
+ },
668
+ return() {
669
+ stop();
670
+ return Promise.resolve({
671
+ value: void 0,
672
+ done: true
673
+ });
674
+ }
675
+ };
676
+ return {
677
+ [Symbol.asyncIterator]: () => iterator,
678
+ async collect() {
679
+ const items = [];
680
+ for (;;) {
681
+ const step = await iterator.next();
682
+ if (step.done === true) throw new Error("the stream ended without its end");
683
+ if (step.value.kind === "item") items.push(step.value.value);
684
+ else return {
685
+ items,
686
+ end: step.value
687
+ };
688
+ }
689
+ }
690
+ };
691
+ };
518
692
  /**
519
693
  * 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.
694
+ * for a controller at this time, or null when it admits it. `choices` gives the function of each
695
+ * route pattern, by controller and by pattern.
521
696
  */
522
- const tracker = (controllers, clock, refuses) => {
523
- const byName = new Map(controllers.map((controller) => [controller.name, controller]));
697
+ const tracker = (controllers, clock, refuses, choices = /* @__PURE__ */ new Map()) => {
698
+ const byName = new Map(controllers.map((controller) => [controller.name, {
699
+ controller,
700
+ patterns: (controller.patterns ?? []).map((declared) => ({
701
+ declared,
702
+ compiled: compilePattern(declared.pattern)
703
+ }))
704
+ }]));
524
705
  const flights = /* @__PURE__ */ new Set();
525
706
  const idle = /* @__PURE__ */ new Set();
526
707
  let intake = true;
708
+ /** This function gives the operation of a request: its exact route, else the first pattern that matches it. */
709
+ const operationOf = (controller, route, detail) => {
710
+ const held = byName.get(controller);
711
+ if (held === void 0) throw new Error(`there is no controller ${controller}`);
712
+ const exact = held.controller.routes[route];
713
+ if (exact !== void 0) return exact;
714
+ for (const { declared, compiled } of held.patterns) {
715
+ const found = compiled.match(route);
716
+ if (found === null) continue;
717
+ const names = Object.keys(declared.operations);
718
+ const choose = choices.get(controller)?.get(declared.pattern);
719
+ const name = choose === void 0 ? names[0] : choose({
720
+ controller,
721
+ route,
722
+ ...found,
723
+ detail
724
+ });
725
+ const operation = declared.operations[name];
726
+ if (operation === void 0) throw new Error(`the pattern ${declared.pattern} of ${controller} declares no operation ${String(name)} for ${route}`);
727
+ return operation;
728
+ }
729
+ throw new Error(`controller ${controller} does not declare the route ${route}`);
730
+ };
527
731
  const settle = (flight) => {
528
- flights.delete(flight);
732
+ if (!flights.delete(flight)) return;
529
733
  if (flights.size === 0) for (const done of [...idle]) done();
530
734
  };
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
735
+ /** This function starts the work of an admitted request, and gives its stream. */
736
+ const open = (controller, operation, work) => {
737
+ const started = clock.now();
738
+ const abort = new AbortController();
739
+ let delivered = 0;
740
+ let end = null;
741
+ let ended = false;
742
+ let returned = false;
743
+ let source = null;
744
+ let unary = null;
745
+ let interrupt = () => {};
746
+ const interrupted = new Promise((resolve) => {
747
+ interrupt = () => resolve("interrupted");
748
+ });
749
+ const cutEnd = (cause) => {
750
+ const since = quantity(Math.max(0, clock.now() - started) / 1e3, "s");
751
+ const retryable = mayReissue(operation, since);
752
+ return {
753
+ kind: "cut",
754
+ status: delivered > 0 ? null : retryable ? 503 : 500,
755
+ retryable,
756
+ cause,
757
+ delivered
758
+ };
541
759
  };
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
- }
760
+ /** This function ends the work of the request: the end is known, and the request leaves the flights. */
761
+ const finish = (given) => {
762
+ if (end !== null) return;
763
+ end = given;
764
+ settle(flight);
765
+ interrupt();
766
+ };
767
+ /** This function ends the work after a throw: a failure after the shutdown aborted it is a cut. */
768
+ const failed = (error) => finish(abort.signal.aborted ? cutEnd("the shutdown aborted it, and it failed") : {
769
+ kind: "failed",
770
+ error,
771
+ delivered
772
+ });
773
+ const closeSource = () => {
774
+ if (source === null || returned) return;
775
+ returned = true;
776
+ try {
777
+ Promise.resolve(source.return?.()).catch(() => {});
778
+ } catch {}
779
+ };
780
+ const flight = {
781
+ controller,
782
+ cooperative: () => {
783
+ abort.abort(/* @__PURE__ */ new Error("the cooperative abort of the drain"));
784
+ closeSource();
785
+ },
786
+ cut: (cause) => {
787
+ abort.abort(new Error(cause));
788
+ finish(cutEnd(cause));
789
+ closeSource();
790
+ }
791
+ };
792
+ flights.add(flight);
793
+ /** This function takes the result of the work: a stream, or one value. */
794
+ const take = (result) => {
795
+ if (end !== null) return;
796
+ if (isAsyncIterable(result)) {
797
+ source = result[Symbol.asyncIterator]();
798
+ if (abort.signal.aborted) closeSource();
799
+ } else {
800
+ unary = { value: result };
801
+ settle(flight);
802
+ }
803
+ };
804
+ let ready;
805
+ try {
806
+ const result = work(abort.signal);
807
+ if (isAsyncIterable(result) || !isPromiseLike(result)) {
808
+ take(result);
809
+ ready = Promise.resolve();
810
+ } else ready = Promise.resolve(result).then(take, failed);
811
+ } catch (error) {
812
+ failed(error);
813
+ ready = Promise.resolve();
814
+ }
815
+ const next = async () => {
816
+ if (ended) return null;
817
+ if (end === null) await Promise.race([ready, interrupted]);
818
+ if (end === null && unary !== null) {
819
+ const { value } = unary;
820
+ unary = null;
821
+ delivered = 1;
822
+ end = {
823
+ kind: "done",
824
+ delivered
568
825
  };
569
- flights.add(flight);
570
- let promise;
826
+ return {
827
+ kind: "item",
828
+ value
829
+ };
830
+ }
831
+ if (end === null && source !== null) {
832
+ const pending = source.next();
833
+ let step;
571
834
  try {
572
- promise = Promise.resolve(work(flight.abort.signal));
835
+ step = await Promise.race([pending, interrupted]);
573
836
  } catch (error) {
574
- promise = Promise.reject(error);
837
+ failed(error);
838
+ step = "interrupted";
575
839
  }
576
- promise.then((value) => {
577
- if (answered) return;
578
- answered = true;
579
- settle(flight);
580
- resolve({
840
+ if (step !== "interrupted") {
841
+ if (step.done !== true) {
842
+ delivered += 1;
843
+ return {
844
+ kind: "item",
845
+ value: step.value
846
+ };
847
+ }
848
+ finish(abort.signal.aborted ? cutEnd("the shutdown ended the stream") : {
581
849
  kind: "done",
582
- value
850
+ delivered
583
851
  });
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);
852
+ }
853
+ }
854
+ ended = true;
855
+ return end;
856
+ };
857
+ const stop = () => {
858
+ if (end === null) {
859
+ abort.abort(/* @__PURE__ */ new Error("the reader stopped the stream"));
860
+ finish({
861
+ kind: "done",
862
+ delivered
593
863
  });
594
- });
864
+ closeSource();
865
+ }
866
+ ended = true;
867
+ };
868
+ return streamOf(next, stop);
869
+ };
870
+ return {
871
+ request(controller, route, work, options = {}) {
872
+ const operation = operationOf(controller, route, options.detail);
873
+ const refusal = intake ? refuses(controller) : "the service stopped its intake";
874
+ if (refusal !== null) {
875
+ let given = false;
876
+ const refused = {
877
+ kind: "refused",
878
+ status: 503,
879
+ retryable: true,
880
+ cause: refusal,
881
+ delivered: 0
882
+ };
883
+ return streamOf(() => {
884
+ if (given) return Promise.resolve(null);
885
+ given = true;
886
+ return Promise.resolve(refused);
887
+ }, () => {
888
+ given = true;
889
+ });
890
+ }
891
+ return open(controller, operation, work);
595
892
  },
596
893
  inFlight(controller) {
597
894
  let count = 0;
@@ -602,7 +899,7 @@ const tracker = (controllers, clock, refuses) => {
602
899
  intake = false;
603
900
  },
604
901
  cooperativeAbort() {
605
- for (const flight of flights) flight.abort.abort(/* @__PURE__ */ new Error("the cooperative abort of the drain"));
902
+ for (const flight of [...flights]) flight.cooperative();
606
903
  },
607
904
  drain(deadline) {
608
905
  if (flights.size === 0) return Promise.resolve();
@@ -617,10 +914,7 @@ const tracker = (controllers, clock, refuses) => {
617
914
  };
618
915
  idle.add(done);
619
916
  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
- }
917
+ for (const flight of [...flights]) flight.cut("the drain reached its deadline");
624
918
  done();
625
919
  });
626
920
  });
@@ -641,7 +935,9 @@ const tracker = (controllers, clock, refuses) => {
641
935
  * - `close transports`: each transport closes.
642
936
  * - `stop the poller`: each check and watchdog stops. Then the signal of the service aborts.
643
937
  * - `tear down dependencies`: each dependency closes, in the reverse sequence of its connection.
644
- * - `close the health server`: the probes stop last.
938
+ * - `close the health server`: the probes stop.
939
+ * - `flush the output`: the work of the owners of this phase ends, and then the process port
940
+ * writes the buffered output. Without this phase, the process exits without a wait.
645
941
  */
646
942
  const STAGES = [
647
943
  "fail readiness",
@@ -650,7 +946,8 @@ const STAGES = [
650
946
  "close transports",
651
947
  "stop the poller",
652
948
  "tear down dependencies",
653
- "close the health server"
949
+ "close the health server",
950
+ "flush the output"
654
951
  ];
655
952
  /** This function gives the place of a stage in the sequence, or -1 for a phase of another name. */
656
953
  const stageRank = (name) => STAGES.indexOf(name);
@@ -658,12 +955,12 @@ const stageRank = (name) => STAGES.indexOf(name);
658
955
  const ABORT_AFTER = stageRank("stop the poller");
659
956
  /**
660
957
  * 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.
958
+ * is the cap less the cooperative window. With the floor `half`, it is not less than half the cap,
959
+ * and a window without a bound gives half the cap. With the floor `none`, it is not less than 0.
663
960
  */
664
- const cooperativeAbortAt = (cap, window) => {
961
+ const cooperativeAbortAt = (cap, window, floor = "half") => {
665
962
  const before = window.value === "infinite" ? Number.POSITIVE_INFINITY : valueIn(window, "ms");
666
- return Math.max(cap - before, cap / 2);
963
+ return Math.max(cap - before, floor === "none" ? 0 : cap / 2);
667
964
  };
668
965
  const message = (error) => error instanceof Error ? error.message : String(error);
669
966
  /**
@@ -688,12 +985,12 @@ const lifecycleProblems = (description, components) => {
688
985
  };
689
986
  /**
690
987
  * 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
988
+ * first, then each dependency, then each transport, and the startup gate last. A trigger during the
692
989
  * 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.
990
+ * sequence, each one capped. Only the lifecycle ends the process. Its exit code is the code of the
991
+ * exit request, else 1 after an error, else 0.
695
992
  */
696
- const lifecycle = (description, runtime, components, options = {}) => {
993
+ const lifecycle = (description, runtime, components, options = {}, bind = () => {}) => {
697
994
  const clock = options.clock ?? realClock;
698
995
  const port = options.process ?? nodeProcess;
699
996
  const record = (machine, from, to, on, detail) => options.record?.({
@@ -707,8 +1004,11 @@ const lifecycle = (description, runtime, components, options = {}) => {
707
1004
  });
708
1005
  const service = new AbortController();
709
1006
  const errors = [];
1007
+ const exitCodes = description.shutdown.exitCodes ?? {};
1008
+ const repeatPolicy = description.shutdown.onRepeatSignal;
710
1009
  let state = "starting";
711
1010
  let exitCode = null;
1011
+ let requested;
712
1012
  let intakeStopped = false;
713
1013
  let shutdownStarted = false;
714
1014
  let failedStartup = false;
@@ -719,14 +1019,14 @@ const lifecycle = (description, runtime, components, options = {}) => {
719
1019
  };
720
1020
  const fail = (cause) => {
721
1021
  errors.push(cause);
722
- record("exit", exitState, exitState, "error", cause);
1022
+ if (exitState !== "exited") record("exit", exitState, exitState, "error", cause);
723
1023
  };
724
1024
  const requests = tracker(description.controllers, clock, (controller) => {
725
1025
  if (state === "starting") return "the service has not started";
726
1026
  if (state === "exited") return "the service has exited";
727
1027
  if (state === "draining") return null;
728
1028
  return runtime.verdict().controllers[controller] === true ? null : `controller ${controller} is not ready`;
729
- });
1029
+ }, components.choices);
730
1030
  let finish = () => {};
731
1031
  const done = new Promise((resolve) => {
732
1032
  finish = resolve;
@@ -734,45 +1034,36 @@ const lifecycle = (description, runtime, components, options = {}) => {
734
1034
  let healthListening = false;
735
1035
  const connected = [];
736
1036
  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}`);
1037
+ let startupEnded = () => {};
1038
+ const startup = new Promise((resolve) => {
1039
+ startupEnded = resolve;
1040
+ });
1041
+ let offs = [];
1042
+ /** This function ends the process with a code, one time, and removes each handler of a trigger. */
1043
+ const end = (code, on) => {
1044
+ state = "exited";
1045
+ exitCode = code;
1046
+ for (const off of offs) off();
1047
+ offs = [];
1048
+ moveExit("exited", on, `exit ${code}`);
1049
+ finish(code);
1050
+ port.exit(code);
1051
+ };
1052
+ /** This function receives a trigger of the process: the first one starts the shutdown. */
1053
+ const triggered = (trigger) => {
1054
+ if (exitState === "exited") return;
1055
+ if (shutdownStarted || exitState === "stopping") {
1056
+ if (isExitSignal(trigger) && repeatPolicy === "exit") {
1057
+ fail(`${trigger} came again while the shutdown ran, and the declaration exits at once`);
1058
+ end(1, "exit.repeat");
1059
+ return;
761
1060
  }
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);
1061
+ record("exit", exitState, exitState, "exit.repeat", trigger);
1062
+ return;
771
1063
  }
772
- })();
773
- startup.then(() => {
774
- if (failedStartup && !shutdownStarted) shutdown("the startup failed");
775
- });
1064
+ const code = exitCodes[trigger];
1065
+ shutdown(trigger, code === void 0 ? {} : { code });
1066
+ };
776
1067
  /** This function does the work of one phase, until its cap. It gives true when the work ended in time. */
777
1068
  const runPhase = async (phase, hold, work) => {
778
1069
  const cap = valueIn(phase.cap, "ms");
@@ -802,14 +1093,22 @@ const lifecycle = (description, runtime, components, options = {}) => {
802
1093
  capped.abort(/* @__PURE__ */ new Error(`the phase ${phase.name} reached its cap`));
803
1094
  return pending === 0;
804
1095
  };
805
- async function shutdown(cause = "the service asked for it") {
806
- if (shutdownStarted || exitState === "exited") {
1096
+ async function shutdown(cause = "the service asked for it", request = {}) {
1097
+ 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`);
1098
+ if (exitState === "exited") return done;
1099
+ if (request.code !== void 0 && requested === void 0) requested = request.code;
1100
+ if (shutdownStarted) {
807
1101
  if (exitState === "stopping") record("exit", exitState, exitState, "exit.repeat", cause);
808
1102
  return done;
809
1103
  }
810
1104
  shutdownStarted = true;
811
1105
  if (exitState === "running") moveExit("stopping", "exit.request", cause);
812
1106
  await startup;
1107
+ if (exitState === "exited") {
1108
+ runtime.stop();
1109
+ components.stopPollers();
1110
+ return exitCode;
1111
+ }
813
1112
  state = "draining";
814
1113
  runtime.drain();
815
1114
  let drainState = "running";
@@ -825,10 +1124,12 @@ const lifecycle = (description, runtime, components, options = {}) => {
825
1124
  const phases = description.shutdown.phases;
826
1125
  let on = "exit.request";
827
1126
  for (const phase of phases) {
1127
+ if (exitState === "exited") break;
828
1128
  const rank = stageRank(phase.name);
829
1129
  moveDrain(phase.name, on);
830
1130
  if (rank > ABORT_AFTER) abortService();
831
1131
  const work = [];
1132
+ const owned = components.phaseWork.get(phase.name) ?? [];
832
1133
  if (phase.name === "stop intake") {
833
1134
  work.push(() => {
834
1135
  intakeStopped = true;
@@ -840,7 +1141,7 @@ const lifecycle = (description, runtime, components, options = {}) => {
840
1141
  if (phase.name === "drain") {
841
1142
  const cap = valueIn(phase.cap, "ms");
842
1143
  const cooperative = new AbortController();
843
- const fireAt = cooperativeAbortAt(cap, description.shutdown.cooperativeWindow);
1144
+ const fireAt = cooperativeAbortAt(cap, description.shutdown.cooperativeWindow, description.shutdown.cooperativeFloor);
844
1145
  cancelAbort = clock.after(fireAt, () => {
845
1146
  cooperative.abort(/* @__PURE__ */ new Error("the cooperative abort of the drain"));
846
1147
  requests.cooperativeAbort();
@@ -861,26 +1162,33 @@ const lifecycle = (description, runtime, components, options = {}) => {
861
1162
  const server = components.healthServer;
862
1163
  work.push(() => server.close());
863
1164
  }
864
- for (const owned of components.phaseWork.get(phase.name) ?? []) work.push(owned);
1165
+ if (phase.name === "flush the output") work.push(async (context) => {
1166
+ await Promise.all(owned.map(async (each) => {
1167
+ try {
1168
+ await each(context);
1169
+ } catch (error) {
1170
+ fail(`the phase ${phase.name} failed: ${message(error)}`);
1171
+ }
1172
+ }));
1173
+ await port.flush?.();
1174
+ });
1175
+ else for (const each of owned) work.push(each);
865
1176
  const inTime = await runPhase(phase, phase.name === "fail readiness", work);
866
1177
  cancelAbort();
867
1178
  if (!inTime) fail(`the phase ${phase.name} did not end in its cap of ${valueIn(phase.cap, "s")} s`);
868
1179
  on = inTime ? "phase.done" : "phase.cap";
869
1180
  }
870
- moveDrain("done", on);
1181
+ if (exitState !== "exited") moveDrain("done", on);
871
1182
  abortService();
872
1183
  runtime.stop();
873
1184
  components.stopPollers();
874
1185
  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;
1186
+ if (exitState === "exited") return exitCode;
1187
+ const code = requested !== void 0 && requested !== 0 ? requested : errors.length > 0 ? 1 : 0;
1188
+ end(code, "shutdown.done");
1189
+ return code;
882
1190
  }
883
- return {
1191
+ const life = {
884
1192
  health: runtime,
885
1193
  get state() {
886
1194
  return state;
@@ -895,10 +1203,51 @@ const lifecycle = (description, runtime, components, options = {}) => {
895
1203
  done,
896
1204
  signal: service.signal,
897
1205
  shutdown,
898
- request: (controller, route, work) => requests.request(controller, route, work),
1206
+ request: (controller, route, work, request) => requests.request(controller, route, work, request),
899
1207
  inFlight: (controller) => requests.inFlight(controller),
900
1208
  keepAlive: () => state === "running" && runtime.verdict().ready
901
1209
  };
1210
+ bind(life);
1211
+ offs = [...EXIT_SIGNALS, ...description.shutdown.triggers ?? []].map((trigger) => port.on(trigger, () => triggered(trigger)));
1212
+ (async () => {
1213
+ const started = (what) => record("gate", "starting", "starting", "component.started", what);
1214
+ try {
1215
+ runtime.beginStartup();
1216
+ if (components.healthServer !== null) {
1217
+ await components.healthServer.listen(runtime);
1218
+ healthListening = true;
1219
+ started("the health server");
1220
+ }
1221
+ for (const dependency of description.dependencies) {
1222
+ const handle = components.dependencies.get(dependency.name);
1223
+ if (handle === void 0) continue;
1224
+ await handle.connect(service.signal);
1225
+ connected.push([dependency.name, handle]);
1226
+ started(`dependency ${dependency.name}`);
1227
+ }
1228
+ for (const transport of description.transports) {
1229
+ const impl = components.transports.get(transport.name);
1230
+ await impl.start(service.signal, life);
1231
+ startedTransports.push([transport.name, impl]);
1232
+ started(`${transport.kind} ${transport.name}`);
1233
+ }
1234
+ await runtime.warmUp();
1235
+ if (state === "starting") state = "running";
1236
+ } catch (error) {
1237
+ const cause = `the startup failed: ${message(error)}`;
1238
+ failedStartup = true;
1239
+ runtime.failStartup(cause);
1240
+ fail(cause);
1241
+ if (exitState === "running") moveExit("stopping", "startup.fail", cause);
1242
+ else if (exitState === "stopping") record("exit", exitState, exitState, "startup.fail", cause);
1243
+ } finally {
1244
+ startupEnded();
1245
+ }
1246
+ })();
1247
+ startup.then(() => {
1248
+ if (failedStartup && !shutdownStarted && exitState !== "exited") shutdown("the startup failed");
1249
+ });
1250
+ return life;
902
1251
  };
903
1252
  //#endregion
904
1253
  //#region src/watchdog.ts
@@ -967,12 +1316,25 @@ const timingProblems = (policy, timing) => {
967
1316
  const negative = (amount) => amount !== void 0 && (!isTime(amount) || valueIn(amount, "s") < 0);
968
1317
  return negative(timing.failAfter) || negative(timing.recoverAfter) ? [`the ${policy} window is not a time of zero or more`] : [];
969
1318
  };
1319
+ /** This function gives the problems of the rules of the shutdown: an unknown name, or an exit code that a process cannot give. */
1320
+ const shutdownProblems = (floor, repeat, triggers, exitCodes) => {
1321
+ const problems = [];
1322
+ if (floor !== void 0 && floor !== "half" && floor !== "none") problems.push(`the cooperative floor ${String(floor)} is not "half" or "none"`);
1323
+ if (repeat !== void 0 && repeat !== "ignore" && repeat !== "exit") problems.push(`the policy of a repeat signal ${String(repeat)} is not "ignore" or "exit"`);
1324
+ for (const trigger of triggers) if (!PROCESS_TRIGGERS.includes(trigger)) problems.push(`the trigger ${String(trigger)} is not a process trigger (${PROCESS_TRIGGERS.join(", ")})`);
1325
+ for (const [trigger, code] of Object.entries(exitCodes)) {
1326
+ if (!(EXIT_SIGNALS.includes(trigger) || triggers.includes(trigger))) problems.push(`the exit code of ${trigger} names no exit signal and no declared trigger`);
1327
+ if (code === void 0 || !isExitCode(code)) problems.push(`the exit code of ${trigger} is ${String(code)}, not a whole number from 0 to 255`);
1328
+ }
1329
+ return problems;
1330
+ };
970
1331
  /** This function starts the composition of a service. */
971
1332
  const service = (name) => {
972
1333
  if (name.trim().length === 0) throw new Error("a service needs a name");
973
1334
  const transports = [];
974
1335
  const dependencies = [];
975
1336
  const controllers = [];
1337
+ const choices = /* @__PURE__ */ new Map();
976
1338
  const checks = [];
977
1339
  const runs = /* @__PURE__ */ new Map();
978
1340
  const couplings = [];
@@ -981,11 +1343,16 @@ const service = (name) => {
981
1343
  let liveness;
982
1344
  let startup;
983
1345
  let cooperative;
1346
+ let floor;
1347
+ let repeat;
1348
+ const triggers = [];
1349
+ const exitCodes = {};
984
1350
  let started = false;
985
1351
  let partial = false;
986
1352
  let runtime;
987
1353
  let live;
988
1354
  let running;
1355
+ let life;
989
1356
  const transportImpls = /* @__PURE__ */ new Map();
990
1357
  const handles = /* @__PURE__ */ new Map();
991
1358
  const phaseWork = /* @__PURE__ */ new Map();
@@ -1054,6 +1421,9 @@ const service = (name) => {
1054
1421
  get health() {
1055
1422
  return live ?? null;
1056
1423
  },
1424
+ get lifecycle() {
1425
+ return life ?? null;
1426
+ },
1057
1427
  dependency(entry, spec, handle) {
1058
1428
  once(dependencies, entry, "dependency");
1059
1429
  if (handle !== void 0 && !started) handles.set(entry, handle);
@@ -1078,7 +1448,8 @@ const service = (name) => {
1078
1448
  const held = {
1079
1449
  name: entry,
1080
1450
  requires: [],
1081
- routes: {}
1451
+ routes: {},
1452
+ patterns: []
1082
1453
  };
1083
1454
  controllers.push(held);
1084
1455
  const handle = {
@@ -1092,6 +1463,21 @@ const service = (name) => {
1092
1463
  if (held.routes[served] !== void 0) throw new Error(`${entry} already serves ${served}`);
1093
1464
  held.routes[served] = { ...operation };
1094
1465
  return handle;
1466
+ },
1467
+ pattern(pattern, operations, operationOf) {
1468
+ composing(`${entry}'s route patterns`);
1469
+ if (held.patterns.some((each) => each.pattern === pattern)) throw new Error(`${entry} already has the route pattern ${pattern}`);
1470
+ 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`);
1471
+ held.patterns.push({
1472
+ pattern,
1473
+ operations: Object.fromEntries(Object.entries(operations).map(([name, operation]) => [name, { ...operation }]))
1474
+ });
1475
+ if (operationOf !== void 0) {
1476
+ const mine = choices.get(entry) ?? /* @__PURE__ */ new Map();
1477
+ mine.set(pattern, operationOf);
1478
+ choices.set(entry, mine);
1479
+ }
1480
+ return handle;
1095
1481
  }
1096
1482
  };
1097
1483
  return handle;
@@ -1169,6 +1555,40 @@ const service = (name) => {
1169
1555
  cooperative = window;
1170
1556
  return builder;
1171
1557
  },
1558
+ cooperativeFloor(given) {
1559
+ composing("the cooperative floor");
1560
+ floor = given;
1561
+ return builder;
1562
+ },
1563
+ onRepeatSignal(policy) {
1564
+ composing("the policy of a repeat signal");
1565
+ repeat = policy;
1566
+ return builder;
1567
+ },
1568
+ trigger(...given) {
1569
+ composing("a trigger");
1570
+ for (const each of given) if (!triggers.includes(each)) triggers.push(each);
1571
+ return builder;
1572
+ },
1573
+ exitCode(trigger, code) {
1574
+ composing(`the exit code of ${trigger}`);
1575
+ exitCodes[trigger] = code;
1576
+ return builder;
1577
+ },
1578
+ profile(given) {
1579
+ composing("a profile");
1580
+ if (given.readiness !== void 0) builder.readiness(given.readiness);
1581
+ if (given.liveness !== void 0) builder.liveness(given.liveness);
1582
+ if (given.startup !== void 0) builder.startup(given.startup);
1583
+ for (const coupling of given.couplings ?? []) builder.couple(coupling);
1584
+ for (const each of given.phases ?? []) builder.phase(each.name, each.cap);
1585
+ if (given.cooperativeWindow !== void 0) builder.cooperativeWindow(given.cooperativeWindow);
1586
+ if (given.cooperativeFloor !== void 0) builder.cooperativeFloor(given.cooperativeFloor);
1587
+ if (given.onRepeatSignal !== void 0) builder.onRepeatSignal(given.onRepeatSignal);
1588
+ if (given.triggers !== void 0) builder.trigger(...given.triggers);
1589
+ for (const [trigger, code] of Object.entries(given.exitCodes ?? {})) if (code !== void 0) builder.exitCode(trigger, code);
1590
+ return builder;
1591
+ },
1172
1592
  describe() {
1173
1593
  const missing = [];
1174
1594
  if (readiness === void 0) missing.push("readiness (svc.readiness)");
@@ -1176,6 +1596,7 @@ const service = (name) => {
1176
1596
  if (startup === void 0) missing.push("a startup budget (svc.startup)");
1177
1597
  if (phases.length === 0) missing.push("its shutdown phases (svc.phase)");
1178
1598
  if (cooperative === void 0) missing.push("a cooperative window, or no bound (svc.cooperativeWindow)");
1599
+ if (repeat === void 0) missing.push(`what a repeat signal does, "ignore" or "exit" (svc.onRepeatSignal)`);
1179
1600
  if (missing.length > 0) throw new Error(`${name} does not declare ${missing.join("; ")}: the runtime has no defaults`);
1180
1601
  const known = new Set(dependencies.map((entry) => entry.name));
1181
1602
  const problems = [];
@@ -1195,16 +1616,22 @@ const service = (name) => {
1195
1616
  for (const controller of controllers) {
1196
1617
  for (const needed of controller.requires) if (!known.has(needed)) problems.push(`controller ${controller.name} requires ${needed}, which is not a dependency`);
1197
1618
  for (const [route, operation] of Object.entries(controller.routes)) problems.push(...operationProblems(`controller ${controller.name}`, route, operation));
1619
+ for (const declared of controller.patterns) problems.push(...routePatternProblems(`controller ${controller.name}`, declared));
1198
1620
  }
1199
1621
  for (const dependency of dependencies) for (const [name, operation] of Object.entries(dependency.operations)) problems.push(...operationProblems(`dependency ${dependency.name}`, name, operation));
1200
1622
  for (const phase of phases) if (!isTime(phase.cap)) problems.push(`phase ${phase.name}'s cap is not a time`);
1623
+ problems.push(...shutdownProblems(floor, repeat, triggers, exitCodes));
1201
1624
  const ranked = phases.map((phase) => phase.name).filter((each) => stageRank(each) >= 0);
1202
1625
  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
1626
  if (problems.length > 0) throw new Error(`${name}: ${problems.join("; ")}`);
1204
1627
  const controllersOut = controllers.map((entry) => ({
1205
1628
  name: entry.name,
1206
1629
  requires: [...entry.requires],
1207
- routes: { ...entry.routes }
1630
+ routes: { ...entry.routes },
1631
+ ...entry.patterns.length === 0 ? {} : { patterns: entry.patterns.map((each) => ({
1632
+ pattern: each.pattern,
1633
+ operations: { ...each.operations }
1634
+ })) }
1208
1635
  }));
1209
1636
  return {
1210
1637
  format: FORMAT,
@@ -1232,7 +1659,11 @@ const service = (name) => {
1232
1659
  couplings: couplings.map((entry) => ({ ...entry })),
1233
1660
  shutdown: {
1234
1661
  phases: phases.map((entry) => ({ ...entry })),
1235
- cooperativeWindow: cooperative
1662
+ cooperativeWindow: cooperative,
1663
+ ...floor === void 0 ? {} : { cooperativeFloor: floor },
1664
+ onRepeatSignal: repeat,
1665
+ ...triggers.length === 0 ? {} : { triggers: [...triggers] },
1666
+ ...Object.keys(exitCodes).length === 0 ? {} : { exitCodes: { ...exitCodes } }
1236
1667
  }
1237
1668
  };
1238
1669
  },
@@ -1253,7 +1684,6 @@ const service = (name) => {
1253
1684
  run(options = {}) {
1254
1685
  if (running !== void 0) return running;
1255
1686
  if (runtime !== void 0) return Promise.reject(/* @__PURE__ */ new Error(`${name} started its health only: run() starts the whole lifecycle`));
1256
- let life;
1257
1687
  try {
1258
1688
  const description = builder.describe();
1259
1689
  const components = {
@@ -1261,6 +1691,7 @@ const service = (name) => {
1261
1691
  dependencies: handles,
1262
1692
  healthServer: probes,
1263
1693
  phaseWork,
1694
+ choices,
1264
1695
  stopPollers: () => {}
1265
1696
  };
1266
1697
  const problems = lifecycleProblems(description, components);
@@ -1268,14 +1699,16 @@ const service = (name) => {
1268
1699
  const made = makeRuntime(options);
1269
1700
  started = true;
1270
1701
  live = made.runtime;
1271
- life = lifecycle(made.description, made.runtime, {
1702
+ const ran = lifecycle(made.description, made.runtime, {
1272
1703
  ...components,
1273
1704
  stopPollers: made.stopPollers
1274
- }, options);
1705
+ }, options, (given) => {
1706
+ life = given;
1707
+ });
1708
+ running = ran.started.then(() => ran);
1275
1709
  } catch (error) {
1276
1710
  return Promise.reject(error);
1277
1711
  }
1278
- running = life.started.then(() => life);
1279
1712
  return running;
1280
1713
  }
1281
1714
  };
@@ -1425,10 +1858,30 @@ const dependencyBindings = (dependency, scenario) => {
1425
1858
  return bind;
1426
1859
  };
1427
1860
  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. */
1861
+ /** This identifier is the rule of the findings about a field that the declaration does not give. */
1862
+ const DECLARATION_RULE = "declaration";
1863
+ /**
1864
+ * This function gives a finding for each field of the shutdown that a description does not give.
1865
+ * `describe()` refuses such a declaration. A description from another source, for example the
1866
+ * JSON of 0.4, can lack the field. The verdict is `depends`, and `unbound` names the field.
1867
+ */
1868
+ const declarationFindings = (svc) => svc.shutdown.onRepeatSignal === void 0 ? [{
1869
+ rule: DECLARATION_RULE,
1870
+ scope: svc.name,
1871
+ verdict: "depends",
1872
+ statement: "the declaration tells what a signal does while the shutdown runs: ignore or exit",
1873
+ window: "a repeat signal has no declared effect",
1874
+ evaluated: [],
1875
+ unbound: ["shutdown.onRepeatSignal"]
1876
+ }] : [];
1877
+ /**
1878
+ * This function applies each service rule to a declaration and a scenario: one time for the
1879
+ * service, and one time for each client. It also gives a finding for each necessary field that the
1880
+ * description does not give (`DECLARATION_RULE`).
1881
+ */
1429
1882
  const lint = (svc, scenario, rules) => {
1430
1883
  const service = serviceBindings(svc, scenario).bind;
1431
- const findings = [];
1884
+ const findings = [...declarationFindings(svc)];
1432
1885
  for (const rule of rules) {
1433
1886
  if (!SERVICE_RULES.includes(rule.id)) continue;
1434
1887
  const symbols = symbolsOf(rule);
@@ -1452,6 +1905,123 @@ const lint = (svc, scenario, rules) => {
1452
1905
  return findings;
1453
1906
  };
1454
1907
  //#endregion
1908
+ //#region src/profiles.ts
1909
+ const s = (value) => ({
1910
+ value,
1911
+ unit: "s"
1912
+ });
1913
+ const phase = (name, cap) => ({
1914
+ name,
1915
+ cap
1916
+ });
1917
+ /**
1918
+ * This profile is for a service on the computer of a developer. No load balancer routes to it,
1919
+ * thus it has no readiness hold. Its caps sum to 10 s, and a second signal exits at once. It
1920
+ * declares no checks, thus the caller declares readiness, liveness and the startup budget.
1921
+ */
1922
+ const localDev = {
1923
+ phases: [
1924
+ phase("stop intake", s(1)),
1925
+ phase("drain", s(4)),
1926
+ phase("close transports", s(1)),
1927
+ phase("stop the poller", s(1)),
1928
+ phase("tear down dependencies", s(1)),
1929
+ phase("close the health server", s(1)),
1930
+ phase("flush the output", s(1))
1931
+ ],
1932
+ cooperativeWindow: s(2),
1933
+ cooperativeFloor: "half",
1934
+ onRepeatSignal: "exit"
1935
+ };
1936
+ /**
1937
+ * This profile is for a background host on the computer of a developer, under a supervisor or a
1938
+ * command that stops it. It has the caps of `localDev`. A repeat signal has no effect, thus the
1939
+ * host ends its drain. It has no process trigger: a background host has no parent on stdin.
1940
+ */
1941
+ const daemon = {
1942
+ phases: localDev.phases.map((each) => ({ ...each })),
1943
+ cooperativeWindow: localDev.cooperativeWindow,
1944
+ cooperativeFloor: "half",
1945
+ onRepeatSignal: "ignore"
1946
+ };
1947
+ /**
1948
+ * This profile is for a small process: a command that runs one time, or a child process on stdio.
1949
+ * It has no checks and no probes, and its policies gate nothing. The cooperative abort occurs at the
1950
+ * start of the drain, thus a first Ctrl-C stops the work at once. A second signal exits at once.
1951
+ * The exit codes tell the shell how the process stopped: 130 for SIGINT, 143 for SIGTERM and 131
1952
+ * for SIGQUIT. A stdio child also declares `triggers: ["stdin-close"]`.
1953
+ */
1954
+ const ephemeral = {
1955
+ readiness: {
1956
+ type: "window",
1957
+ failAfter: s(0)
1958
+ },
1959
+ liveness: {
1960
+ type: "window",
1961
+ failAfter: s(0)
1962
+ },
1963
+ startup: s(10),
1964
+ phases: [
1965
+ phase("stop intake", s(1)),
1966
+ phase("drain", s(2)),
1967
+ phase("close transports", s(1)),
1968
+ phase("stop the poller", s(1)),
1969
+ phase("tear down dependencies", s(2)),
1970
+ phase("flush the output", s(1))
1971
+ ],
1972
+ cooperativeWindow: s(2),
1973
+ cooperativeFloor: "none",
1974
+ onRepeatSignal: "exit",
1975
+ exitCodes: {
1976
+ SIGINT: 130,
1977
+ SIGTERM: 143,
1978
+ SIGQUIT: 131
1979
+ }
1980
+ };
1981
+ /** These are the parts of the budget of each stage in the profile `kubernetes`, in thirtieths. */
1982
+ const KUBERNETES_SHARES = [
1983
+ ["fail readiness", 2],
1984
+ ["stop intake", 5],
1985
+ ["drain", 15],
1986
+ ["close transports", 2],
1987
+ ["stop the poller", 1],
1988
+ ["tear down dependencies", 2],
1989
+ ["close the health server", 1]
1990
+ ];
1991
+ /** This part of the budget is the cooperative window, in thirtieths. */
1992
+ const KUBERNETES_WINDOW = 5;
1993
+ /**
1994
+ * This function gives the profile of a pod in Kubernetes. The budget is the grace less the preStop
1995
+ * sleep: the time from SIGTERM to SIGKILL. Each stage gets a part of the budget, rounded down to
1996
+ * the millisecond.
1997
+ * - The parts in thirtieths are 2, 5, 15, 2, 1, 2 and 1, in the sequence of the stages. Thus 2
1998
+ * thirtieths of the budget stay as a margin before SIGKILL.
1999
+ * - The cooperative window is 5 thirtieths of the budget.
2000
+ * - The kubelet sends SIGTERM one time, thus a repeat signal has no effect.
2001
+ *
2002
+ * At the defaults of Kubernetes (a grace of 30 s and no preStop hook), the caps are 2, 5, 15, 2, 1,
2003
+ * 2 and 1 s. Thus the sum of the caps and the preStop sleep fits in the grace (O10). The window is
2004
+ * shorter than the drain, which is shorter than the budget (O16).
2005
+ */
2006
+ const kubernetes = (pod) => {
2007
+ const time = (amount, what) => {
2008
+ if (!convertible(amount.unit, "s")) throw new Error(`the ${what} of the pod is not a time`);
2009
+ return valueIn(amount, "ms");
2010
+ };
2011
+ const grace = time(pod.grace, "grace");
2012
+ const preStop = time(pod.preStop, "preStop sleep");
2013
+ if (preStop < 0) throw new Error("the preStop sleep of the pod is less than 0");
2014
+ const budget = grace - preStop;
2015
+ if (!(budget > 0)) throw new Error(`the grace of the pod (${grace} ms) is not longer than its preStop sleep (${preStop} ms)`);
2016
+ const part = (thirtieths) => s(Math.floor(budget * thirtieths / 30) / 1e3);
2017
+ return {
2018
+ phases: KUBERNETES_SHARES.map(([name, thirtieths]) => phase(name, part(thirtieths))),
2019
+ cooperativeWindow: part(KUBERNETES_WINDOW),
2020
+ cooperativeFloor: "half",
2021
+ onRepeatSignal: "ignore"
2022
+ };
2023
+ };
2024
+ //#endregion
1455
2025
  //#region src/machines.ts
1456
2026
  /**
1457
2027
  * The machines of the runtime as `mark1russell7.machine/1` documents. The documents are the
@@ -1728,7 +2298,9 @@ const POLLER_MACHINE = {
1728
2298
  };
1729
2299
  /**
1730
2300
  * 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.
2301
+ * shutdown. A later request has no effect, but a repeat signal exits at once when the declaration
2302
+ * says `onRepeatSignal: "exit"`. The exit code is the code of the exit request, else 1 after an
2303
+ * error, else 0.
1732
2304
  */
1733
2305
  const EXIT_MACHINE = {
1734
2306
  ...base("exit", "The exit manager"),
@@ -1740,23 +2312,35 @@ const EXIT_MACHINE = {
1740
2312
  events: {
1741
2313
  "exit.request": {
1742
2314
  kind: "app",
1743
- note: "SIGINT, SIGTERM, SIGQUIT or shutdown()"
2315
+ note: "SIGINT, SIGTERM, SIGQUIT, a declared process trigger or shutdown()"
2316
+ },
2317
+ "exit.repeat": {
2318
+ kind: "app",
2319
+ note: "a trigger or shutdown() while the shutdown runs"
1744
2320
  },
1745
- "exit.repeat": { kind: "app" },
1746
2321
  "startup.fail": { kind: "local" },
1747
2322
  error: { kind: "local" },
1748
2323
  "shutdown.done": { kind: "local" }
1749
2324
  },
1750
- knobs: {},
2325
+ knobs: { on_repeat_signal: {
2326
+ key: "shutdown.onRepeatSignal",
2327
+ scope: "service",
2328
+ unit: null,
2329
+ default: null
2330
+ } },
1751
2331
  timers: {},
1752
2332
  transitions: [
1753
2333
  t("request", "running", "exit.request", "stopping"),
1754
2334
  t("startup-fail", "running", "startup.fail", "stopping"),
1755
2335
  t("error-running", "running", "error", "running"),
1756
- t("repeat", "stopping", "exit.repeat", "stopping"),
2336
+ t("repeat", "stopping", "exit.repeat", "stopping", { guard: "a process trigger or shutdown() repeats, or a signal repeats and on_repeat_signal is not exit" }),
2337
+ t("repeat-exit", "stopping", "exit.repeat", "exited", {
2338
+ guard: "a signal repeats and on_repeat_signal is exit",
2339
+ note: "the process exits at once with the code 1"
2340
+ }),
1757
2341
  t("startup-fail-stopping", "stopping", "startup.fail", "stopping"),
1758
2342
  t("error", "stopping", "error", "stopping"),
1759
- t("exit", "stopping", "shutdown.done", "exited", { note: "the exit code is 1 after an error, else 0" })
2343
+ 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
2344
  ]
1761
2345
  };
1762
2346
  /** A watchdog measures how late its own tick comes, and a late tick shows a blocked event loop. */
@@ -1807,7 +2391,8 @@ const capKnob = (phase) => `${phase.replace(/[^A-Za-z0-9]+/g, "_")}_cap`;
1807
2391
  /**
1808
2392
  * This function gives the drain of a declaration as a machine. It has one state for each declared
1809
2393
  * 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.
2394
+ * cap of the drain less the cooperative window. With the floor `half`, it is half that cap at the
2395
+ * least. With the floor `none`, it is 0 at the least.
1811
2396
  */
1812
2397
  const drainMachine = (description) => {
1813
2398
  const phases = description.shutdown.phases;
@@ -1835,9 +2420,10 @@ const drainMachine = (description) => {
1835
2420
  cancels: ["phase.done"],
1836
2421
  fires: "phase.cap"
1837
2422
  };
2423
+ const floor = description.shutdown.cooperativeFloor === "none" ? "0" : "drain_cap / 2";
1838
2424
  if (phases.some((phase) => phase.name === "drain")) timers["cooperative_abort"] = {
1839
2425
  duration: {
1840
- expr: "max(drain_cap - cooperative_window, drain_cap / 2)",
2426
+ expr: `max(drain_cap - cooperative_window, ${floor})`,
1841
2427
  unit: "s"
1842
2428
  },
1843
2429
  arms: ["enter:drain"],
@@ -1885,6 +2471,6 @@ const MACHINES = {
1885
2471
  watchdog: WATCHDOG_MACHINE
1886
2472
  };
1887
2473
  //#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 };
2474
+ 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, kubernetes, lint, localDev, mayReissue, nodeProcess, platformClock, processPortOf, realClock, recoverSpan, replay, service, serviceBindings, stageRank, symbolsOf, verdictOf, watchdog };
1889
2475
 
1890
2476
  //# sourceMappingURL=index.js.map