@btravstack/core 0.14.0 → 0.15.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/dist/index.mjs CHANGED
@@ -32,6 +32,26 @@ var HealthCheckFailed = class extends TaggedError("HealthCheckFailed") {};
32
32
  */
33
33
  var HealthChecks = class extends Port.many("HealthChecks") {};
34
34
  /**
35
+ * A check that never settles, bounded. `unref`'d, so a pending probe is never
36
+ * the reason a process stays alive, and cleared on the winning branch so a
37
+ * slow-but-answering check leaves no timer behind.
38
+ *
39
+ * It bounds the WAIT, not the check: nothing here can cancel whatever the check
40
+ * is blocked on. That is the same bargain the drain and the stop deadline make,
41
+ * and it is why this reports rather than recovers.
42
+ */
43
+ const answeringWithin = (health, ms) => {
44
+ let timer;
45
+ const timeout = new Promise((resolve) => {
46
+ timer = setTimeout(() => resolve(Err(new HealthCheckFailed({ reason: `did not answer within ${String(ms)} ms` }))), ms);
47
+ timer.unref();
48
+ });
49
+ const answering = OkAsync().flatMap(() => health.check());
50
+ return fromSafePromise(Promise.race([answering, timeout]).finally(() => {
51
+ clearTimeout(timer);
52
+ })).flatMap((settled) => settled);
53
+ };
54
+ /**
35
55
  * Runs every check and folds the results into one report — the whole app is
36
56
  * unhealthy if any single component is.
37
57
  *
@@ -43,8 +63,15 @@ var HealthChecks = class extends Port.many("HealthChecks") {};
43
63
  * a defecting `AsyncResult` alike are recovered into an unhealthy line —
44
64
  * escaped, the first would reach the kernel's own `uncaughtException` handler
45
65
  * and the second would leave `/healthz` hanging with nothing written.
66
+ *
67
+ * **A check that never settles is the third case, and it needed a deadline
68
+ * rather than a recovery.** Neither recovery above can see one: there is no
69
+ * error and no defect, only silence, so `/healthz` held a socket open per hit
70
+ * while kubelet timed out against a report that named nothing. Each check now
71
+ * races {@link HealthOptions.timeoutMs}, and the component that hung is named
72
+ * in the report like any other unhealthy one.
46
73
  */
47
- const runHealthChecks = (checks) => allAsync(checks.map((health) => OkAsync().flatMap(() => health.check()).map(() => ({
74
+ const runHealthChecks = (checks, options = {}) => allAsync(checks.map((health) => answeringWithin(health, health.timeoutMs ?? options.timeoutMs ?? 800).map(() => ({
48
75
  name: health.name,
49
76
  status: "healthy"
50
77
  })).recoverErrCases((matcher) => matcher.with(P.tag("HealthCheckFailed"), (error) => ({
@@ -103,6 +130,24 @@ const observe = (observers, operation) => {
103
130
  for (const settle of settlers) settle(settled);
104
131
  };
105
132
  };
133
+ /**
134
+ * {@link observe}, wrapped around one call: every observer is started before
135
+ * `call` runs and settled from whichever channel it comes back on — `ok` on
136
+ * success, `error` carrying the `Err` or the defect as `cause` otherwise. The
137
+ * wrapper is transparent to the `Result`: whatever `call` answers is what the
138
+ * caller receives.
139
+ *
140
+ * `settled` replaces either default where a starter has more to say: `ok`
141
+ * receives the value (a cache reports a hit or a miss), `failure` the
142
+ * `FailureView` (a store reports a missing object as an ordinary answer).
143
+ */
144
+ const observed = (observers, operation, call, settled = {}) => {
145
+ const settle = observe(observers, operation);
146
+ return call().tap((value) => settle(settled.ok?.(value) ?? { outcome: "ok" })).tapFailure((failure) => settle(settled.failure?.(failure) ?? {
147
+ outcome: "error",
148
+ cause: failure.tag === "Err" ? failure.error : failure.cause
149
+ }));
150
+ };
106
151
  //#endregion
107
152
  //#region src/clock.ts
108
153
  const systemClock = {
@@ -335,7 +380,7 @@ const startProbeServer = (args) => fromSafePromise(new Promise((resolve) => {
335
380
  const ignoreServingError = () => {};
336
381
  server.once("error", onBindError);
337
382
  try {
338
- server.listen(args.port, "127.0.0.1", () => {
383
+ server.listen(args.port, args.hostname, () => {
339
384
  server.removeListener("error", onBindError);
340
385
  server.on("error", ignoreServingError);
341
386
  const address = server.address();
@@ -442,6 +487,13 @@ const createUnitRegistry = () => {
442
487
  };
443
488
  //#endregion
444
489
  //#region src/start.ts
490
+ /**
491
+ * An arm of a race that has lost and has nothing to report: it never settles,
492
+ * so the winner decides. It holds no timer and no handle, which is what makes
493
+ * it safe — the alternative is an arm that answers a report no consumer reads,
494
+ * and whose side effects then have to be guarded one by one.
495
+ */
496
+ const withdrawn = () => fromSafePromise(new Promise(() => {}));
445
497
  const providesEnv = (module) => module.provides.some((provider) => provider.port.portId === Env.portId) || module.imports.some(providesEnv);
446
498
  /**
447
499
  * What the kernel reads from the environment for itself. Each field is pinned
@@ -450,13 +502,21 @@ const providesEnv = (module) => module.provides.some((provider) => provider.port
450
502
  */
451
503
  const KERNEL_DEFAULTS = {
452
504
  probePort: 9e3,
505
+ probeHost: "0.0.0.0",
453
506
  preDrainDelayMs: 5e3,
454
- drainTimeoutMs: 2e4
507
+ drainTimeoutMs: 2e4,
508
+ stopTimeoutMs: 5e3
455
509
  };
456
510
  const readKernelConfig = (options, env) => {
457
- const probePort = options.probes === void 0 ? void 0 : options.probes === false ? KERNEL_DEFAULTS.probePort : options.probes.port;
511
+ const probes = options.probes === false ? {
512
+ port: KERNEL_DEFAULTS.probePort,
513
+ host: KERNEL_DEFAULTS.probeHost
514
+ } : options.probes;
515
+ const probePort = probes?.port;
516
+ const probeHost = probes?.host;
458
517
  const result = Config.object({
459
518
  probePort: Config.pinned(probePort, Config.port("PROBE_PORT", { default: KERNEL_DEFAULTS.probePort })),
519
+ probeHost: Config.pinned(probeHost, Config.string("PROBE_HOST", { default: KERNEL_DEFAULTS.probeHost })),
460
520
  preDrainDelayMs: Config.pinned(options.preDrainDelayMs, Config.integer("PRE_DRAIN_DELAY_MS", {
461
521
  default: KERNEL_DEFAULTS.preDrainDelayMs,
462
522
  min: 0
@@ -464,6 +524,10 @@ const readKernelConfig = (options, env) => {
464
524
  drainTimeoutMs: Config.pinned(options.drainTimeoutMs, Config.integer("DRAIN_TIMEOUT_MS", {
465
525
  default: KERNEL_DEFAULTS.drainTimeoutMs,
466
526
  min: 0
527
+ })),
528
+ stopTimeoutMs: Config.pinned(options.stopTimeoutMs, Config.integer("STOP_TIMEOUT_MS", {
529
+ default: KERNEL_DEFAULTS.stopTimeoutMs,
530
+ min: 0
467
531
  }))
468
532
  })["~standard"].validate(env);
469
533
  return result.issues === void 0 ? Ok(result.value) : Err(new RuntimeStartFailed({
@@ -496,8 +560,14 @@ const start = (module, options = {}) => {
496
560
  const teardownErrors = [];
497
561
  const startedAt = clock.now();
498
562
  const kernelConfig = readKernelConfig(options, env);
499
- const { preDrainDelayMs, drainTimeoutMs } = kernelConfig.getOrElse(() => KERNEL_DEFAULTS);
563
+ const { preDrainDelayMs, drainTimeoutMs, stopTimeoutMs } = kernelConfig.getOrElse(() => KERNEL_DEFAULTS);
500
564
  const skipDrain = new AbortController();
565
+ const stopSettled = new AbortController();
566
+ let lifecycleSettled = false;
567
+ const onLifecycleSettled = () => {
568
+ lifecycleSettled = true;
569
+ stopSettled.abort();
570
+ };
501
571
  let shutdownRequestedAt = startedAt;
502
572
  let shutdownRequested = false;
503
573
  const sinceShutdownRequested = () => clock.now() - shutdownRequestedAt;
@@ -516,7 +586,11 @@ const start = (module, options = {}) => {
516
586
  const ready = () => tracker.current() === "serving" && !forcedUnready;
517
587
  const disposeSignals = options.signals === false ? () => {} : installSignalHandlers({
518
588
  onFirst: () => requestShutdown("signal"),
519
- onSecond: () => skipDrain.abort()
589
+ onSecond: () => {
590
+ skipDrain.abort();
591
+ stopSettled.abort();
592
+ abandonBuild("signal");
593
+ }
520
594
  });
521
595
  const disposeUncaught = options.signals === false ? () => {} : installUncaughtHandlers((cause) => {
522
596
  emit({
@@ -526,17 +600,63 @@ const start = (module, options = {}) => {
526
600
  onUnready();
527
601
  skipDrain.abort();
528
602
  requestShutdown("uncaught");
603
+ abandonBuild("uncaught");
529
604
  });
530
605
  let disposeProbes = () => {};
606
+ const disposeAll = () => {
607
+ disposeSignals();
608
+ disposeUncaught();
609
+ tracker.advanceTo("exited");
610
+ disposeProbes();
611
+ };
612
+ const reportOf = (reason, drain, abandonedAt) => ({
613
+ reason,
614
+ drain,
615
+ teardownErrors,
616
+ uptimeMs: clock.now() - startedAt,
617
+ ...abandonedAt === void 0 ? {} : { abandonedAt }
618
+ });
531
619
  let healthChecks = [];
532
620
  const health = () => runHealthChecks(healthChecks);
533
621
  const probeBound = Promise.withResolvers();
534
622
  const runtimePublished = Promise.withResolvers();
623
+ const stopping = Promise.withResolvers();
624
+ const stopAbandoned = () => fromSafePromise(stopping.promise).flatMap(({ reason, drain }) => clock.sleep(stopTimeoutMs, stopSettled.signal).flatMap(() => lifecycleSettled ? withdrawn() : OkAsync(abandonStop(reason, drain))));
625
+ const abandonStop = (reason, drain) => {
626
+ emit({
627
+ type: "stoppedWaiting",
628
+ phase: "stop",
629
+ afterMs: stopSettled.signal.aborted ? void 0 : stopTimeoutMs
630
+ });
631
+ disposeAll();
632
+ return reportOf(reason, drain, "stop");
633
+ };
634
+ const abandoningBuild = Promise.withResolvers();
635
+ const buildAbandoned = () => fromSafePromise(abandoningBuild.promise).map((reason) => {
636
+ emit({
637
+ type: "stoppedWaiting",
638
+ phase: "build",
639
+ afterMs: void 0
640
+ });
641
+ registry.abortAll();
642
+ runtimePublished.resolve(void 0);
643
+ tracker.advanceTo("stopping");
644
+ disposeAll();
645
+ return reportOf(reason, void 0, "build");
646
+ });
647
+ const abandonBuild = (reason) => {
648
+ const phase = tracker.current();
649
+ if (phase === "building" || phase === "starting") abandoningBuild.resolve(reason);
650
+ };
535
651
  emit({ type: "building" });
536
- const probesOptions = kernelConfig.map(({ probePort }) => options.probes === false ? false : { port: probePort });
652
+ const probesOptions = kernelConfig.map(({ probePort, probeHost }) => options.probes === false ? false : {
653
+ port: probePort,
654
+ hostname: probeHost
655
+ });
537
656
  if (options.probes === false) probeBound.resolve(void 0);
538
657
  const probesStarted = probesOptions.toAsync().flatMap((probes) => probes === false ? OkAsync() : startProbeServer({
539
658
  port: probes.port,
659
+ hostname: probes.hostname,
540
660
  live,
541
661
  ready,
542
662
  health
@@ -591,97 +711,98 @@ const start = (module, options = {}) => {
591
711
  if (reason !== "signal") registry.abortAll();
592
712
  return (reason === "signal" ? runDrain(serving) : OkAsync(void 0)).flatMap((report) => {
593
713
  tracker.advanceTo("stopping");
714
+ stopping.resolve({
715
+ reason,
716
+ drain: report
717
+ });
594
718
  return serving.stop().map(() => {
595
- disposeSignals();
596
- disposeUncaught();
597
- tracker.advanceTo("exited");
598
- disposeProbes();
599
- return {
600
- reason,
601
- drain: report,
602
- teardownErrors,
603
- uptimeMs: clock.now() - startedAt
604
- };
719
+ disposeAll();
720
+ return reportOf(reason, report);
605
721
  });
606
722
  });
607
723
  };
608
- return {
609
- exited: probesStarted.flatMap(() => Module.scoped(root, (ctx) => {
610
- tracker.advanceTo("starting");
611
- const runtime = ctx.get(RuntimePort);
612
- runtimeName = runtime.name;
613
- healthChecks = ctx.get(HealthChecks);
614
- const runtimeCtx = ctx;
615
- const run = (meta, work) => registry.run(meta, (signal) => {
616
- const settled = Promise.withResolvers();
617
- let closing;
618
- let hasSettled = false;
619
- const fork = (module, seed) => {
620
- if (hasSettled) return fromSafePromise(Promise.reject(/* @__PURE__ */ new Error("a unit forks after it has already settled")));
621
- if (closing !== void 0) return fromSafePromise(Promise.reject(/* @__PURE__ */ new Error("a unit forks its scope once")));
622
- const ready = Promise.withResolvers();
623
- closing = Module.forkScope(ctx, module, (forked) => {
624
- ready.resolve(forked);
625
- return fromSafePromise(settled.promise);
626
- }, {
627
- seed,
628
- onTeardownError: (port, cause) => emit({
629
- type: "teardownError",
630
- port,
631
- cause
632
- })
633
- }).recoverDefect((cause) => {
634
- ready.reject(cause);
635
- return Ok();
636
- }).get();
637
- return fromSafePromise(ready.promise);
638
- };
639
- const outcome = (async () => {
640
- try {
641
- return await work({
642
- ctx: runtimeCtx,
643
- fork
644
- }, signal);
645
- } finally {
646
- hasSettled = true;
647
- settled.resolve();
648
- if (closing !== void 0) await closing;
649
- }
650
- })();
651
- return fromSafePromise(outcome).flatMap((result) => result);
652
- });
653
- const host = {
654
- ctx: runtimeCtx,
655
- run
724
+ const built = () => Module.scoped(root, (ctx) => {
725
+ tracker.advanceTo("starting");
726
+ const runtime = ctx.get(RuntimePort);
727
+ runtimeName = runtime.name;
728
+ healthChecks = ctx.get(HealthChecks);
729
+ const runtimeCtx = ctx;
730
+ const run = (meta, work) => registry.run(meta, (signal) => {
731
+ const settled = Promise.withResolvers();
732
+ let closing;
733
+ let hasSettled = false;
734
+ const fork = (module, seed) => {
735
+ if (hasSettled) return fromSafePromise(Promise.reject(/* @__PURE__ */ new Error("a unit forks after it has already settled")));
736
+ if (closing !== void 0) return fromSafePromise(Promise.reject(/* @__PURE__ */ new Error("a unit forks its scope once")));
737
+ const ready = Promise.withResolvers();
738
+ closing = Module.forkScope(ctx, module, (forked) => {
739
+ ready.resolve(forked);
740
+ return fromSafePromise(settled.promise);
741
+ }, {
742
+ seed,
743
+ onTeardownError: (port, cause) => emit({
744
+ type: "teardownError",
745
+ port,
746
+ cause
747
+ })
748
+ }).recoverDefect((cause) => {
749
+ ready.reject(cause);
750
+ return Ok();
751
+ }).get();
752
+ return fromSafePromise(ready.promise);
656
753
  };
657
- return runtime.start(host).flatMap((serving) => {
658
- servingInfo = serving.info;
659
- tracker.advanceTo("serving");
660
- runtimePublished.resolve(serving.info);
661
- return fromSafePromise(shutdown.promise).flatMap((reason) => finish(serving, reason));
662
- });
663
- }, { onTeardownError: (port, cause) => {
664
- teardownErrors.push({
665
- port,
666
- cause
667
- });
668
- emit({
669
- type: "teardownError",
670
- port,
671
- cause
672
- });
673
- } }).tapFailure((failure) => {
674
- if (tracker.current() !== "stopping") emit({
675
- type: "startFailed",
676
- cause: failure.tag === "Err" ? failure.error : failure.cause
754
+ const outcome = (async () => {
755
+ try {
756
+ return await work({
757
+ ctx: runtimeCtx,
758
+ fork
759
+ }, signal);
760
+ } finally {
761
+ hasSettled = true;
762
+ settled.resolve();
763
+ if (closing !== void 0) await closing;
764
+ }
765
+ })();
766
+ return fromSafePromise(outcome).flatMap((result) => result);
767
+ });
768
+ const host = {
769
+ ctx: runtimeCtx,
770
+ run
771
+ };
772
+ return runtime.start(host).flatMap((serving) => {
773
+ servingInfo = serving.info;
774
+ tracker.advanceTo("serving");
775
+ runtimePublished.resolve(serving.info);
776
+ serving.stopped?.().map(() => {
777
+ requestShutdown("runtimeStopped");
677
778
  });
678
- runtimePublished.resolve(void 0);
679
- tracker.advanceTo("stopping");
680
- disposeSignals();
681
- disposeUncaught();
682
- tracker.advanceTo("exited");
683
- disposeProbes();
684
- })),
779
+ return fromSafePromise(shutdown.promise).flatMap((reason) => finish(serving, reason));
780
+ });
781
+ }, { onTeardownError: (port, cause) => {
782
+ teardownErrors.push({
783
+ port,
784
+ cause
785
+ });
786
+ emit({
787
+ type: "teardownError",
788
+ port,
789
+ cause
790
+ });
791
+ } }).tapFailure((failure) => {
792
+ if (tracker.current() !== "stopping") emit({
793
+ type: "startFailed",
794
+ cause: failure.tag === "Err" ? failure.error : failure.cause
795
+ });
796
+ runtimePublished.resolve(void 0);
797
+ tracker.advanceTo("stopping");
798
+ disposeAll();
799
+ }).tap(onLifecycleSettled).tapFailure(onLifecycleSettled);
800
+ return {
801
+ exited: probesStarted.flatMap(() => fromSafePromise(Promise.race([
802
+ built(),
803
+ stopAbandoned(),
804
+ buildAbandoned()
805
+ ])).flatMap((settled) => settled)),
685
806
  stop: () => requestShutdown("runtimeStopped"),
686
807
  requestDrain: () => requestShutdown("signal"),
687
808
  phase: tracker.current,
@@ -697,7 +818,7 @@ const EX_CONFIG = 78;
697
818
  const isConfig = (error) => error instanceof ConfigInvalid || error instanceof RuntimeStartFailed && error.cause instanceof ConfigInvalid;
698
819
  const codeFor = (report) => {
699
820
  if (report.reason === "uncaught") return EX_SOFTWARE;
700
- return (report.drain?.abandoned ?? 0) > 0 || report.teardownErrors.length > 0 ? 2 : 0;
821
+ return (report.drain?.abandoned ?? 0) > 0 || report.teardownErrors.length > 0 || report.abandonedAt !== void 0 ? 2 : 0;
701
822
  };
702
823
  /**
703
824
  * The exit-code half of `runMain`, on its own so the code table can be asserted
@@ -730,6 +851,7 @@ const awaitExit = async (app, exit) => {
730
851
  * | a configuration port that could not be bound (`ConfigInvalid`, `PROBE_PORT` included) | `78` |
731
852
  * | drained with work abandoned | `2` |
732
853
  * | exited with teardown errors | `2` |
854
+ * | stopped waiting for the teardown or a build (`ExitReport.abandonedAt`) | `2` |
733
855
  * | stopped by an uncaught exception or unhandled rejection | `70` |
734
856
  * | a defect | `70` |
735
857
  *
@@ -749,4 +871,4 @@ const runMain = async (module, options = {}, exit = (code) => {
749
871
  await awaitExit(start(module, options), exit);
750
872
  };
751
873
  //#endregion
752
- export { HealthCheckFailed, HealthChecks, Instrumentations, LEVELS, Logger, Meter, Observers, RuntimePort, RuntimeStartFailed, SPAN_STATUS, Tracer, currentUnit, noObserver, observe, releasedBy, runHealthChecks, runMain, start, stderrSink, systemClock, traceIdOfTraceparent };
874
+ export { HealthCheckFailed, HealthChecks, Instrumentations, LEVELS, Logger, Meter, Observers, RuntimePort, RuntimeStartFailed, SPAN_STATUS, Tracer, currentUnit, noObserver, observe, observed, releasedBy, runHealthChecks, runMain, start, stderrSink, systemClock, traceIdOfTraceparent };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/core",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "The application kernel: boot a di module into a running process, and stop it without losing work",
5
5
  "keywords": [
6
6
  "application",
@@ -45,6 +45,8 @@
45
45
  "./package.json": "./package.json"
46
46
  },
47
47
  "devDependencies": {
48
+ "@btravstack/config": "0.15.0",
49
+ "@btravstack/di": "0.15.0",
48
50
  "@btravstack/tsconfig": "0.4.0",
49
51
  "@types/node": "26.4.1",
50
52
  "@unthrown/vitest": "5.8.0",
@@ -52,14 +54,12 @@
52
54
  "tsdown": "0.23.0",
53
55
  "typescript": "7.0.2",
54
56
  "unthrown": "5.8.0",
55
- "vitest": "4.1.11",
56
- "@btravstack/config": "0.14.0",
57
- "@btravstack/di": "0.14.0"
57
+ "vitest": "4.1.11"
58
58
  },
59
59
  "peerDependencies": {
60
- "unthrown": "^5.0.0",
61
- "@btravstack/config": "^0.14.0",
62
- "@btravstack/di": "^0.14.0"
60
+ "@btravstack/config": "^0.15.0",
61
+ "@btravstack/di": "^0.15.0",
62
+ "unthrown": "^5.0.0"
63
63
  },
64
64
  "engines": {
65
65
  "node": ">=22"
@@ -69,7 +69,6 @@
69
69
  "dev": "tsdown src/index.ts --format cjs,esm --dts --watch --tsconfig tsconfig.build.json",
70
70
  "generate": "node ../../docs/scripts/extract-doc-samples.ts --group core --out src/generated/doc-samples",
71
71
  "test": "vitest run --coverage",
72
- "test:types": "tsc --noEmit -p tsconfig.test-d.json",
73
72
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"
74
73
  }
75
74
  }