@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/README.md CHANGED
@@ -2,18 +2,19 @@
2
2
 
3
3
  A service that you declare as data before it starts. You compose it with plain calls: its dependencies, its checks, its policies and its shutdown. `describe()` gives the whole graph as data, and it starts nothing. `lint()` then applies the timer-ordering law to that graph. `start()` gives the graph to the health runtime, and `run()` starts the whole lifecycle.
4
4
 
5
- This release has the declaration, its export, the law, the health runtime and the lifecycle. The wrappers of the clients come in a later release.
5
+ This release has the declaration, its export, the law, the health runtime, the lifecycle and the edge of a service in Node (`@mark1russell7/service/http`). The wrappers of the clients come in a later release.
6
6
 
7
7
  ## What the package does
8
8
 
9
9
  - **The builder.** `service(name)` gives a builder. You add each part with a call: `svc.dependency(…)`, `svc.server(…)`, `svc.controller("checkout").requires(…).route(…)`, `svc.check(check, run)`, the policies, the couplings and the shutdown phases.
10
- - **No defaults.** The runtime has no defaults of its own. Thus `describe()` refuses a service that does not declare its readiness, its liveness, its startup budget, its shutdown phases or its cooperative window. The error tells which. `start()` also refuses a check without its function.
10
+ - **No defaults.** The runtime has no defaults of its own. Thus `describe()` refuses a service that does not declare its readiness, its liveness, its startup budget, its shutdown phases or its cooperative window. It also refuses a service that does not declare what a repeat signal does. The error tells which. `start()` also refuses a check without its function.
11
11
  - **One timing shape.** Each policy has a `Timing`: a debounce of polls, or a window of time. Each converts to the other (`asWindow`, `asDebounce`). `failSpan` and `recoverSpan` give the earliest and the latest time to detect a change. At 3 polls of 5 s ± 20 %, a latch fails in 8–18 s on an error, and in 12–22 s on a 4 s timeout.
12
12
  - **Checks.** A check names the step of the connection that it examines (`dial`, `round-trip`, `checkout` and others). It also tells if it uses the connection of the application, and which policy it controls. The policy has no default.
13
13
  - **Idempotency for each operation.** One dependency has the two kinds of operation. A Redis GET is safe to send again. An INCR that goes again after a timeout counts two times. Thus each operation of a dependency, and each route of a controller, declares if it can go again: `true`, `false`, or keyed.
14
14
  - **Keyed operations.** A keyed operation has the window that the server keeps its key for. `mayReissue` answers from the declaration of an operation. An operation without a declaration does not go again.
15
15
  - **The law.** `evaluate` and `verdictOf` decide one rule of the timer-ordering law on one binding. A rule is data: linear inequalities over `symbol@hop`, with a margin. A verdict is `holds`, `violated`, `unbounded` (it holds only because a timer is absent) or `depends` (nothing is bound).
16
16
  - **The linter.** `lint(description, scenario, rules)` applies the service rules (`SERVICE_RULES`) to a declaration. The declaration binds the symbols of the service: the polls of its checks, the windows of its policies, its drain. The scenario binds the symbols of the platform and of each dependency. A `depends` finding names the symbols that have no bound.
17
+ - **The scopes of a rule.** A rule about a client applies one time for each dependency. A rule about a consumer, for example O20, applies one time for each consumer and each worker.
17
18
  - **The hold.** The cap of the `fail readiness` phase binds `readiness_hold@process`, and a service without that phase holds for zero. The hold keeps the listener open after the signal, as a preStop sleep does. Thus O04 can compare the sum of the two with the propagation of the endpoints.
18
19
 
19
20
  The format of a description is `mark1russell7.service/1`.
@@ -36,17 +37,34 @@ The format of a description is `mark1russell7.service/1`.
36
37
 
37
38
  `await svc.run()` starts the whole life of the service, and gives a `Lifecycle` when the startup ends. It refuses a declaration that it cannot start: a transport without its implementation, or a stage with work and no declared phase.
38
39
 
39
- - **The parts.** `svc.server(name, knobs, impl)`, `svc.consumer(…)` and `svc.worker(…)` give a transport its implementation. Each transport has one contract: `start`, `stopIntake`, `drain(deadline, signal)`, `inFlight` and `close`. `svc.dependency(name, spec, handle)` gives a dependency a handle that connects and closes. `svc.healthServer(server)` gives the server of the probes.
40
+ - **The parts.** `svc.server(name, knobs, impl)`, `svc.consumer(…)` and `svc.worker(…)` give a transport its implementation. Each transport has one contract: `start(signal, life)`, `stopIntake`, `drain(deadline, signal)`, `inFlight` and `close`. `workerLoop` and `queueDrain` give the implementation of a worker and of a consumer ([Workers and queues](#workers-and-queues)). `svc.dependency(name, spec, handle)` gives a dependency a handle that connects and closes. `svc.healthServer(server)` gives the server of the probes.
40
41
  - **The startup.** The health server starts first, thus the probes answer while the dependencies connect. Then each dependency connects, in its declared sequence. Then each transport starts, and the startup gate opens last, after one warm-up sweep of each check. A part that does not start fails the startup. Then only the parts that started stop, and the exit code is 1.
41
42
  - **The startup budget.** When the gate is not open at the end of its budget, the gate says so. The runtime does not stop itself, because the startup probe of the platform acts on it.
42
- - **The signals.** The runtime owns the handlers of SIGINT, SIGTERM and SIGQUIT. The first signal starts the shutdown, and a later signal has no effect. A signal during the startup gets its shutdown after the startup ends.
43
+ - **The signals.** The runtime owns the handlers of SIGINT, SIGTERM and SIGQUIT. The first signal starts the shutdown. A signal during the startup gets its shutdown after the startup ends.
44
+ - **A repeat signal.** Each declaration tells what a signal during the shutdown does (`svc.onRepeatSignal`). With `"exit"`, the signal ends the process at once, with the code 1. With `"ignore"`, the signal has no effect, as in 0.4. A second `shutdown()` and a second process trigger do not end the process.
45
+ - **A declaration without the policy.** `describe()` refuses a declaration that does not declare `onRepeatSignal`. A description from another source can lack it, for example the JSON of 0.4. Then `lint()` gives a `depends` finding of the rule `declaration`, and `unbound` names the field.
46
+ - **Process triggers.** `svc.trigger("stdin-close")` starts the shutdown when the standard input ends, as when the parent of a stdio child stops. `"ipc-close"` starts it when the IPC channel to the parent closes. Each trigger is opt-in. The process must read its input, as a stdio transport does, or Node does not see the end.
47
+ - **A message of the IPC channel.** Windows cannot give a child a SIGTERM that the child can catch, thus a parent sends a message. `svc.trigger("ipc-message", { match: "shutdown", as: "SIGTERM" })` starts the shutdown at that message.
48
+ - **The shape of a message.** `match` is a value of JSON. A string, a number, a boolean or null matches an equal message. An object matches a message that has each of its fields, and an array matches item by item.
49
+ - **A message as a signal.** With `as`, the message is that exit signal: it gets its exit code and the policy of a repeat signal. Without `as`, the message is the trigger `ipc-message`, with its own exit code (`svc.exitCode("ipc-message", code)`).
43
50
  - **The shutdown.** Readiness fails first. Then the declared phases occur in their sequence, and each phase races its cap. `svc.onPhase(name, work)` adds the work of an owner to a phase.
44
- - **The stages.** A declared phase with the name of a stage gets the work of that stage. The stages are `fail readiness`, `stop intake`, `drain`, `close transports`, `stop the poller`, `tear down dependencies` and `close the health server`. The phase `fail readiness` holds for its cap while the endpoints update. The signal of the service aborts after the poller stops. `describe()` refuses the stages out of their sequence.
45
- - **The cooperative abort.** During the drain, the requests and the transports get a signal that aborts at the cap less the cooperative window. That time is half the cap at the least, and a window without a bound gives half the cap.
46
- - **Requests.** `life.request(controller, route, work)` does one request on a declared route. While the service cannot take new work, it refuses the request with a retryable 503. That occurs before the start, after the intake stops, or while the controller is not ready.
47
- - **Cut requests.** When the drain cuts a request, an idempotent route answers 503, retryable. Each other route answers 500, because the work can have had its effect. A keyed route is retryable only while the server keeps its key.
51
+ - **The stages.** A declared phase with the name of a stage gets the work of that stage. The stages are `fail readiness`, `stop intake`, `drain`, `close transports`, `stop the poller`, `tear down dependencies`, `close the health server` and `flush the output`. The phase `fail readiness` holds for its cap while the endpoints update. The signal of the service aborts after the poller stops. `describe()` refuses the stages out of their sequence.
52
+ - **The cooperative abort.** During the drain, the requests and the transports get a signal that aborts at the cap less the cooperative window. With the floor `half`, that time is half the cap at the least, and a window without a bound gives half the cap. With `svc.cooperativeFloor("none")`, a window of the whole cap aborts the work at the start of the drain.
53
+ - **Requests.** `life.request(controller, route, work, { detail })` does one request on a declared route or route pattern. While the service cannot take new work, it refuses the request with a retryable 503. That occurs before the start, after the intake stops, or while the controller is not ready. A route that no route and no pattern declares throws, because it has no idempotency.
54
+ - **Each request is a stream.** The request gives a `RequestStream`: each item of the work, and then one end. A work that gives an async iterable is a stream, also through a promise. Each other value is a stream of one item. `stream.collect()` reads the items and the end.
55
+ - **The reader controls the work.** The stream asks the work for an item only when its reader asks for the next event. The request stays in flight until the iterator of the work ends. Thus the drain waits for an open stream. When the reader stops before the end, the work gets its abort and the `return()` of its iterator.
56
+ - **The end.** The end is `done`, `failed`, `refused` or `cut`, with the count of the delivered items. Before the first item, `refused` and `cut` have a status. After the first item, the end is in-band: the transport sent its answer, thus it writes the end as the last line of the stream.
57
+ - **Requests while the transports start.** Each transport gets the lifecycle at its start, and `svc.lifecycle` gives it from the start of `run()`. Thus a transport tracks its requests from its start. A request before the end of the startup gets a retryable 503, and `keepAlive()` is false.
58
+ - **Cut requests.** When the drain cuts a request, an idempotent route answers 503, retryable. Each other route answers 500, because the work can have had its effect. A keyed route is retryable only while the server keeps its key. After the first item, a cut has no status, and `retryable` tells the caller if it can send the request again.
59
+ - **The cooperative abort of a stream.** At the cooperative abort, the work gets its abort, and its iterator gets `return()`. A stream that ends after the abort ends as a cut. At the deadline of the drain, each request that is left gets a cut at once.
60
+ - **Route patterns.** `controller.pattern(pattern, operations, operationOf)` matches the routes that the exact routes do not name, in the sequence of the patterns. In a pattern, `*` matches each text, and `:name` matches one part without `/`. The declaration names each operation that a request of the pattern can be, with its idempotency.
61
+ - **The idempotency of each request.** The function `operationOf` gets the route, the parts of the pattern and the `detail` of the request. It gives the name of the operation, thus the retry rule and the answer of a cut. A pattern with one operation needs no function. `describe()` exports each pattern and its operations as data, and it checks them as it checks a route.
48
62
  - **Keep-alive.** `life.keepAlive()` is false while the service is not ready. Thus each response then closes its connection.
49
63
  - **The exit.** Only the lifecycle ends the process, through its `ProcessPort`. The exit code is 0 after a clean shutdown. It is 1 after an error: a startup that failed, a phase whose work did not end in its cap, or a work that threw. A phase over its cap does not stop the sequence.
64
+ - **Exit codes.** `life.shutdown(cause, { code })` gives the exit code of the work, a whole number from 0 to 255. The first request with a code sets it. `svc.exitCode(trigger, code)` gives the code of a shutdown that a trigger starts, for example 130 for SIGINT. A code that is not 0 stays after an error. A code of 0, or no code, becomes 1 after an error.
65
+ - **The output.** The stage `flush the output` comes last. The owners of the phase write their last output first, for example a log or a trace exporter. Then the `ProcessPort` writes the buffered output of stdout and stderr, in the cap of the stage. Without this phase, the process exits without a wait, as in 0.4.
66
+ - **The hold.** The lifecycle holds its process from its start to its exit, through `ProcessPort.hold`, as a listening socket does. The hold is one timer of the event loop, and the exit releases it. The other timers of the runtime still do not keep the process alive (A14).
67
+ - **Why the hold lasts to the exit.** A drain can wait on a timer that does not keep the process alive. Without the hold, Node then ends the process with the code 0 in the drain. The teardown and the declared exit code are then lost. Thus the hold does not end at the stop of the intake, and it is not a knob.
50
68
  - **The watchdog.** `svc.watchdog(spec)` declares a check on the event loop. A blocked loop delays each timer, thus a tick that comes late by more than the threshold fails the check. A liveness window then reads the wedge of the process.
51
69
 
52
70
  ## The machines
@@ -55,6 +73,193 @@ The machines of the runtime are `mark1russell7.machine/1` documents, which `@mar
55
73
 
56
74
  The tests replay the real runtime against each document. They also compare each timer of the documents, from the algebra, with the time that the runtime keeps.
57
75
 
76
+ ## Composition
77
+
78
+ A process has one service, and one entry that uses `run()`. A component gives parts to that service, and it does not start them. For example, a component is the procedures host of another repository, or one of the HTTP hosts of a process.
79
+
80
+ - **The parts.** A component gives a `TransportImpl` for each source of work, a `DependencyHandle` for each dependency that it owns, and each check with its `CheckFn`. It can also give work for a declared phase (`onPhase`).
81
+ - **The entry.** The entry declares one service: the parts of each component, the policies, the startup budget, the phases and the cooperative window. Only the entry uses `run()`. Thus the process has one set of signal handlers, one staged shutdown and one exit.
82
+ - **What a component does not do.** A component does not use `start()` or `run()`. It installs no signal handler, and it does not end the process. A service has one health server, thus the entry chooses it.
83
+ - **Names.** A service refuses a second part of one kind with the same name. Thus the entry gives each component a prefix, for example `app.http` and `ingest.http`.
84
+ - **The sequence.** The lifecycle connects the dependencies of all components in their declared sequence, and then it starts each transport. Each stage of the shutdown acts on the transports of all components together. The dependencies close in the reverse sequence.
85
+ - **Requests.** The transports start before the startup ends, but `run()` gives the `Lifecycle` only at that end. Thus each transport gets the lifecycle at its start (`start(signal, life)`), and its request tracking operates at once.
86
+ - **The builder form.** A component can also take the builder of the entry and declare its parts on it. The Redis client does this: `attach(svc, options)` declares its dependency, its handle and its checks.
87
+
88
+ ```ts
89
+ import type { Extent } from "@mark1russell7/quantity";
90
+ import { service } from "@mark1russell7/service";
91
+ import type { Check, CheckFn, Dependency, DependencyHandle, ServiceBuilder, TransportImpl } from "@mark1russell7/service";
92
+
93
+ // the parts of a component, as data: the component declares nothing, and it does not run
94
+ interface Parts {
95
+ readonly servers: ReadonlyArray<{ name: string; knobs: Record<string, Extent>; impl: TransportImpl }>;
96
+ readonly dependencies: ReadonlyArray<{ name: string; spec: Omit<Dependency, "name">; handle: DependencyHandle }>;
97
+ readonly checks: ReadonlyArray<{ check: Check; run: CheckFn }>;
98
+ }
99
+
100
+ const declare = (svc: ServiceBuilder, parts: Parts): void => {
101
+ for (const { name, spec, handle } of parts.dependencies) svc.dependency(name, spec, handle);
102
+ for (const { name, knobs, impl } of parts.servers) svc.server(name, knobs, impl);
103
+ for (const { check, run } of parts.checks) svc.check(check, run);
104
+ };
105
+
106
+ const s = (value: number) => ({ value, unit: "s" });
107
+ const svc = service("app");
108
+ // three hosts in one process: each component gives its parts under its own prefix
109
+ for (const [prefix, port] of [["app", 4820], ["ingest", 4830], ["remote", 4840]] as const)
110
+ declare(svc, createHost({ prefix, port }));
111
+ svc
112
+ .healthServer(probes) // the entry chooses the one health server of the process
113
+ .readiness({ type: "debounce", interval: s(5), failAfter: 3, recoverAfter: 1 })
114
+ .liveness({ type: "window", failAfter: s(45) })
115
+ .startup(s(60))
116
+ .phase("fail readiness", s(2))
117
+ .phase("stop intake", s(5))
118
+ .phase("drain", s(15))
119
+ .phase("close transports", s(2))
120
+ .phase("stop the poller", s(1))
121
+ .phase("tear down dependencies", s(2))
122
+ .phase("close the health server", s(1))
123
+ .cooperativeWindow(s(5))
124
+ .onRepeatSignal("ignore");
125
+
126
+ const life = await svc.run(); // only the entry calls run(), and the process exits after the shutdown
127
+ ```
128
+
129
+ The requirements that the lifecycle follows are in [the requirements of S6](https://github.com/mark1russell7/systems/blob/main/docs/S6.md).
130
+
131
+ ## Workers and queues
132
+
133
+ `workerLoop(knobs, options)` makes a worker from a loop, and `queueDrain(knobs, options)` makes a consumer from a queue. Each one is a `TransportImpl`, with no defaults. Give `loop.knobs` to `svc.worker(name, knobs, impl)`, and `drain.knobs` to `svc.consumer(…)`. Thus the declaration and the law read the same knobs.
134
+
135
+ - **The worker loop (S6 A10).** A worker has a setup, an iteration and a teardown. The start does the setup, and each iteration gets its state. Each lane does its iterations one at a time, thus two iterations of a lane do not overlap. `concurrency` is the number of lanes.
136
+ - **The sleep.** `worker_sleep` is the time from the end of one iteration to the start of the next. An iteration that gives `"more"` starts the next one after a zero sleep. A sleep without a bound waits for `wake()`.
137
+ - **The first iteration.** The first iteration of each lane comes after the startup ends, because the service refuses a request before that end.
138
+ - **The failures.** A throw is a failure, and the lane then waits its backoff. The first delay is `retry_base`, and each delay is two times longer, up to `retry_cap`, with a jitter up to the base. A success ends the sequence. `onError` gets each failure, and the loop does not end the process (A5).
139
+ - **`wake()`.** It ends each sleep at once, also a sleep that comes after the iteration in flight. It does not end a backoff. After the stop of the intake, it gives false.
140
+ - **The queue drain (S6 A11).** One taker takes a job from the source while a slot is free. `concurrency` is the number of slots, and a count without a bound takes each job that comes. The source is a function that takes the next job, or an async iterable.
141
+ - **An empty source.** A take with no job waits `queue_poll_interval`, or until `wake()`. A failed take waits the backoff.
142
+ - **The end of a source.** The end of an async iterable stops the takes. While the intake is open, `onError` gets that end, because the drain then takes no more jobs. An async generator that throws gives its throw and then its end.
143
+ - **The end of each job.** A success gets `settle.ack`. A failure gets the action of the redelivery policy: `requeue`, `reject` or `ack`. The queue declares its policy, because the drain has no default.
144
+ - **The policy of A11.** `redeliverOnce(then, poison)` gives `ack` to a poison message. Each other job goes back one time after its first failure, and `then` decides each later failure.
145
+ - **The deliveries.** The policy gets the count of deliveries of the job, 1 at the first. `settle.delivery` gives that count from the queue, for example `x-delivery-count` of RabbitMQ or `ApproximateReceiveCount` of SQS. It is necessary, because a broker gives a new object at each delivery. A queue in memory counts for itself.
146
+ - **The bound of a unit.** `unit_processing_time` is the most time of one iteration or one job. At it, the signal of the unit aborts, and a throw after it is a failure. The law reads it as `unit_processing_time@consumer`.
147
+ - **The stop of the intake.** No new iteration starts, and the drain takes no new job. Each sleep and each backoff ends. The drain ends an async iterable through `return()`. A take that gives a job after the stop gives it back (`settle.requeue(job, "unstarted")`).
148
+ - **The drain.** `drain(deadline, signal)` waits for each unit in flight until the deadline, and gives each one the cooperative abort. A throw after that abort is a cut, not a failure.
149
+ - **A cut job.** A cut job goes back (`"cut"`) only when its route can go again (A12). Each other cut job gets `settle.reject`.
150
+ - **The close.** `close` aborts each unit, waits for its end, and then does the teardown or `options.close`, all in `close_bound`. The teardown occurs each time that the setup started, also after a failed setup. The close rejects a job that does not end in the bound.
151
+ - **The request tracking.** With `request: { controller, route }`, each unit goes through `life.request`. The service then admits, counts, drains and cuts each unit as it does each request. A route function and `detail` can give each job a route of a pattern.
152
+ - **The refusals.** While the service refuses its units, the loop and the taker wait their backoff, and each refused job goes back (`"unstarted"`). Thus a worker stops while a dependency of its controller is not ready.
153
+ - **The clock.** The sleeps, the backoffs and the bounds use the clock of the options. Give the clock of the lifecycle. With the real clock, the timers do not keep a Node process alive (A14). The lifecycle holds the process until its exit, thus a process whose only parts are workers does not end while it waits.
154
+ - **The law.** `lint()` applies O20 one time for each consumer and each worker. A knob of the transport binds at the hop `consumer`, and a knob with a hop in its name binds at that hop. The scenario gives the lease of each source, for example the visibility timeout of SQS.
155
+ - **The problems.** `problems(description)` compares the bounds with the caps. `close_bound` fits in the cap of `close transports`. `unit_processing_time` fits in the cap of `drain`, or the drain can cut a unit.
156
+
157
+ ```ts
158
+ import { kubernetes, lint, queueDrain, redeliverOnce, service, workerLoop } from "@mark1russell7/service";
159
+
160
+ const s = (value: number) => ({ value, unit: "s" });
161
+ const count = (value: number) => ({ value, unit: "count" });
162
+
163
+ // a heartbeat each 6 h, in one lane: a bound of 10 s for each beat, in the drain of 15 s, and a backoff from 10 s to 5 min
164
+ const heartbeat = workerLoop(
165
+ { worker_sleep: s(6 * 3600), unit_processing_time: s(10), retry_base: s(10), retry_cap: s(300), close_bound: s(1), concurrency: count(1) },
166
+ {
167
+ setup: () => openClient(),
168
+ iteration: ({ state, signal }) => state.beat(signal),
169
+ teardown: (state) => state?.close(),
170
+ onError: (error, failures) => log.warn({ error, failures }, "the heartbeat failed"),
171
+ },
172
+ );
173
+
174
+ // the jobs of a table, 4 at a time: a poll each 2 s, and the policy of A11
175
+ const jobs = queueDrain(
176
+ { queue_poll_interval: s(2), unit_processing_time: s(10), retry_base: s(1), retry_cap: s(30), close_bound: s(1), concurrency: count(4) },
177
+ {
178
+ source: (signal) => db.claimNextJob(signal), // a job, or null
179
+ handle: (job, { signal, delivery }) => runPipeline(job, { signal, delivery }),
180
+ settle: {
181
+ ack: (job) => db.markDone(job),
182
+ delivery: (job) => job.attempts, // the count of the table, 1 at the first take
183
+ requeue: (job) => db.markQueued(job),
184
+ reject: (job, error) => db.markFailed(job, error),
185
+ },
186
+ redelivery: redeliverOnce("reject", (error) => error instanceof InvalidJobError),
187
+ request: { controller: "jobs", route: "RUN" },
188
+ },
189
+ );
190
+
191
+ const app = service("app")
192
+ .dependency("db", { system: null, knobs: {}, operations: {} }, { connect: () => db.connect(), close: () => db.end() })
193
+ .worker("heartbeat", heartbeat.knobs, heartbeat)
194
+ .consumer("jobs", jobs.knobs, jobs)
195
+ .profile(kubernetes({ grace: s(30), preStop: s(0) }))
196
+ .readiness({ type: "debounce", interval: s(5), failAfter: 3, recoverAfter: 1 })
197
+ .liveness({ type: "window", failAfter: s(45) })
198
+ .startup(s(60));
199
+ // a job that the shutdown cuts goes back on the queue, because its route can go again
200
+ app.controller("jobs").requires("db").route("RUN", { idempotent: true });
201
+
202
+ // O20: the lease of the queue, 30 s, against a job of 10 s
203
+ const scenario = { platform: {}, probes: { readiness: s(30), liveness: s(30) }, transports: { jobs: { "lease_duration@broker": s(30) } } };
204
+ const findings = lint(app.describe(), scenario, rules);
205
+ const problems = [...heartbeat.problems(app.describe()), ...jobs.problems(app.describe())];
206
+ const life = await app.run();
207
+ ```
208
+
209
+ ## Profiles
210
+
211
+ A profile is a part of a declaration as data: the phases, the cooperative window, the policies and the rules of the shutdown. `svc.profile(profile)` declares each field of a profile, as the method of that field does. The runtime still has no defaults, because the caller gives the profile. The caller changes a field with a spread, and `describe()` shows the values.
212
+
213
+ | Profile | For | What it declares |
214
+ | --- | --- | --- |
215
+ | `localDev` | A service on the computer of a developer | No readiness hold, caps that sum to 10 s, a flush of the output, and an exit on a second signal |
216
+ | `daemon` | A background host on the computer of a developer | The caps of `localDev`, no effect for a repeat signal, and no process trigger |
217
+ | `kubernetes({ grace, preStop })` | A pod | Caps from the grace less the preStop sleep, and no effect for a repeat signal |
218
+ | `ephemeral` | A command that runs one time, or a stdio child | No gating checks, an abort at the start of the drain, a flush of the output, and the exit codes 130, 143 and 131 |
219
+
220
+ - **Kubernetes.** The budget is the grace less the preStop sleep: the time from SIGTERM to SIGKILL. The stages get 2, 5, 15, 2, 1, 2 and 1 thirtieths of the budget, rounded down to the millisecond. The cooperative window is 5 thirtieths. Thus 2 thirtieths stay as a margin, and O10 and O16 hold.
221
+ - **The defaults of Kubernetes.** At a grace of 30 s and no preStop hook, the caps are 2, 5, 15, 2, 1, 2 and 1 s, with a window of 5 s. These are the caps of jarvis and of the kind lab.
222
+ - **Local development.** No load balancer routes to a computer of a developer, thus the profile has no readiness hold. A second Ctrl-C exits at once.
223
+ - **A background host.** A supervisor or a command stops the host with a signal. A repeat signal has no effect, thus the host ends its drain. The host has no parent on stdin, thus the profile declares no process trigger.
224
+ - **The repeat signal.** Each profile declares `onRepeatSignal`: `"exit"` for `localDev` and `ephemeral`, and `"ignore"` for `daemon` and `kubernetes`.
225
+ - **The policies.** `localDev` and `kubernetes` do not declare readiness, liveness or the startup budget, because they depend on the checks of the service.
226
+
227
+ ```ts
228
+ import { kubernetes, service } from "@mark1russell7/service";
229
+
230
+ const s = (value: number) => ({ value, unit: "s" });
231
+ const svc = service("app")
232
+ .profile({ ...kubernetes({ grace: s(60), preStop: s(5) }), onRepeatSignal: "exit" })
233
+ .readiness({ type: "debounce", interval: s(5), failAfter: 3, recoverAfter: 1 })
234
+ .liveness({ type: "window", failAfter: s(45) })
235
+ .startup(s(60));
236
+ ```
237
+
238
+ ### A small process
239
+
240
+ A command that runs one time and a stdio child also use `run()`. Then the process has one shutdown and one exit, with the code of its work. The minimal declaration has no probes, no health server and no checks.
241
+
242
+ ```ts
243
+ import { ephemeral, service } from "@mark1russell7/service";
244
+
245
+ // a stdio child: the end of stdin starts the staged shutdown
246
+ const mcp = service("mcp").profile({ ...ephemeral, triggers: ["stdin-close"] }).server("stdio", {}, stdio);
247
+ mcp.controller("tools").route("tools/call", { idempotent: false });
248
+ await mcp.run();
249
+
250
+ // a command: it runs one time, and the process exits with its code
251
+ const tool = service("tool").profile(ephemeral);
252
+ tool.controller("command").route("RUN", { idempotent: false });
253
+ const life = await tool.run();
254
+ const { items, end } = await life.request("command", "RUN", (signal) => main(signal)).collect();
255
+ await life.shutdown("the command ended", { code: end.kind === "done" ? items[0] : 1 });
256
+ ```
257
+
258
+ - **The declaration.** The profile `ephemeral` gives the policies, the startup budget, the phases and the rules of the shutdown. The recipe passes `describe()` and `lint()`, and a test holds it to them.
259
+ - **Ctrl-C.** The floor of the cooperative abort is `none`, and the window is the whole drain. Thus a first Ctrl-C stops the work at once, and the process exits 130. A second Ctrl-C exits 1 at once.
260
+ - **The output.** The stage `flush the output` waits for stdout and stderr. Thus the last lines of a command are not cut.
261
+ - **The dependencies.** The phase `tear down dependencies` caps the close of the dependencies at 2 s.
262
+
58
263
  ## The timers
59
264
 
60
265
  The clock, the heartbeat and the backoff have their own entries. Each entry imports no `node:` module, no zod and no other package. Thus a client in a browser can use them. Each entry is less than 1 kB of gzip. The root of the package also gives each function.
@@ -94,15 +299,108 @@ socket.addEventListener("message", (event) => {
94
299
  const delay = backoffDelay({ base: 1000, factor: 1.5, cap: 30_000, jitter: { kind: "subtract", fraction: 1 } }, attempt);
95
300
  ```
96
301
 
302
+ ## The HTTP edge
303
+
304
+ `@mark1russell7/service/http` is the edge of a service in Node. Of the modules of Node, it imports only `node:http` and `node:net`. The root of the package does not import it. It is not a framework: it has no routing language, no body parser and no middleware.
305
+
306
+ | Export | Function |
307
+ | --- | --- |
308
+ | `httpTransport(server, knobs, options)` | A server of `node:http` as a transport of the lifecycle, with a list of mounts |
309
+ | `probeServer({ port, host, server_retry_after })` | A health server on a port of its own |
310
+ | `probeHandler(health, { prefix })` | The probes as a request handler on the port of the application |
311
+ | `handle(controller, route, work, options)` | A request handler that does each request of a route through `life.request` |
312
+ | `writeOutcome(res, stream, options)` | The stream of a request as a response of `node:http`, unary or a stream |
313
+ | `ndjson`, `sse` | The formats of a stream: NDJSON, and server-sent events |
314
+ | `outcomeResponse`, `endRecord`, `probeResponse`, `pathOf` | The same answers as data, for any HTTP framework or none |
315
+
316
+ - **The transport.** `httpTransport(server, knobs, options)` makes a server of `node:http` a transport of the lifecycle. Give `transport.knobs` to `svc.server(name, knobs, impl)`. Thus the declaration and the law read the same knobs.
317
+ - **The knobs.** The transport has no defaults. `server_idle_sweep_interval` is the interval of the sweep of idle connections. `server_retry_after` goes in the `Retry-After` of each 503 of the transport.
318
+ - **The idle close.** `server_idle_close` is the real idle close of the server, which O01 compares with the idle time of a client. Node closes an idle connection at `keepAliveTimeout` plus `keepAliveTimeoutBuffer`. Thus the transport sets `keepAliveTimeout` to `server_idle_close` less the buffer of the server.
319
+ - **The hint.** The buffer stays, thus the hint of each response (`Keep-Alive: timeout=…`) is less than the real close. A Node without a buffer gives 0. The transport refuses a `server_idle_close` at or below the buffer. A time without a bound stops the timer.
320
+ - **The three times.** `transport.knobs` adds `server_idle_close_hint` and `server_idle_close_buffer` to the knobs of the caller. Thus the declaration shows the hint, the buffer and the real close.
321
+ - **The count.** The count of the work in flight comes from events. Each response counts until its `close` event, and each upgraded connection counts until it closes. The transport polls nothing.
322
+ - **The stop of the intake.** `stopIntake()` closes the listener, thus each new connection gets a refusal. It closes the idle connections at once, and again at each `server_idle_sweep_interval`. Each later response gets `Connection: close`, and each later request gets a retryable 503.
323
+ - **The drain and the close.** `drain(deadline, signal)` ends when no work is in flight, or at the deadline. It gives the cooperative abort to the handlers. `close()` destroys each connection, the upgraded connections too. A second close gives the same promise.
324
+ - **The lifecycle.** The transport gets the lifecycle at its start (`start(signal, life)`), before the warm-up. Each handler gets it in its context (`context.life`), with `server_retry_after` (`context.retryAfter`).
325
+ - **Keep-alive.** The transport reads `life.keepAlive()` when the headers of each response go, or the option `keepAlive` in its place. While it gives false, each response gets `Connection: close`. That occurs during the warm-up too. An upgrade does not get it.
326
+ - **The clock.** Give the transport the clock of the lifecycle (`options.clock`). The deadline of the drain and the sweep use it.
327
+ - **The mounts.** `transport.mount(handler)` adds a request handler at the end of the list. The first handler that gives true answers the request. A handler that gives false lets the next handler try, and a request that no handler takes gets 404.
328
+ - **The upgrades.** `transport.upgrade(path, handler)` gives an upgrade to its handler, by its path or by a function. An upgrade that no handler takes gets 404. Thus several faces and the routes of the application share one port.
329
+ - **One owner of the listener.** The transport refuses a server that has a request or upgrade listener of its own. Mount that listener as a handler: `transport.mount((req, res) => (app(req, res), true))`.
330
+ - **The signals of a handler.** `context.intake` aborts when the intake stops, and `context.signal` aborts at the cooperative abort or at the close. A long stream ends in its own protocol at them.
331
+ - **A mount through the drain.** A mount with `{ until: "close transports" }` answers until the close. While it is mounted, the listener stays open through the drain, and each other request gets a retryable 503.
332
+ - **The probes on their own port.** `probeServer({ port, host, server_retry_after })` is a health server. It starts first and closes last, thus its probes answer through the whole shutdown. It has no defaults, thus `server_retry_after` is necessary, and each 503 of a probe gets its `Retry-After`.
333
+ - **The probes on the port of the application.** `probeHandler(health, { prefix })` answers the same probes. Its mount answers until `close transports`, thus it answers through the drain. After the close of the transports, only a probe server answers.
334
+ - **The probe paths.** The paths are `/startup`, `/readiness`, `/readiness/<controller>`, `/liveness` and `/report`, after the prefix. Each answer comes from the cache, as `application/health+json` with 200 or 503.
335
+ - **A route through the request tracking.** `handle(controller, route, work, options)` gives a request handler. Each request that it takes goes through `life.request`, thus the service admits it, counts it and can cut it.
336
+ - **The route of a handler.** A route name takes a request whose method and path are that name, for example `POST /orders`. A function gives the name of a route or of a pattern (`controller.pattern`), or null for a request of another handler. `options.detail` gives the detail of a request for the function of a pattern.
337
+ - **The work.** The work gets the request and a signal. The signal aborts when the shutdown cuts the request, at the cooperative abort, or when the client goes away.
338
+ - **A unary response.** Without `options.stream`, the response waits for the end of the work. A value gets 200, and no value gets 204. A work that gives more than one value gets 500.
339
+ - **A stream response.** With `options.stream` (`ndjson` or `sse`), the status 200 goes with the first item. Then each item goes as it comes, and the next item waits while the client reads slowly.
340
+ - **The end of a stream.** An end after the first item goes in the body, as the last record (`endRecord`), with no status of its own. The cooperative abort and a cut end a stream in this way. When the client goes away, the work stops through `return()`.
341
+ - **An end before the first byte.** A refusal gets 503: the service did not start, it stopped its intake, or a controller is not ready. A cut gets 503 for an idempotent route (A12), and 500 for each other route. A failure of the route gets 500 with no detail, and not 503.
342
+ - **Each 503 gets `Retry-After`.** Each 503 gets the `Retry-After` of `server_retry_after` (RFC 9110), and a 500 gets none. The transport applies the rule when the headers of each response go, whatever wrote them. Thus the rule applies to a probe, to a refusal of the transport and to a handler that writes its own status.
343
+ - **The rule for a handler.** A 503 without `Retry-After` gets it, and a 503 with its own `Retry-After` keeps it. A 500 loses each `Retry-After`, from `setHeader` and from `writeHead`. The other statuses keep their headers.
344
+ - **Another framework.** `writeOutcome(res, stream, options)` writes the stream of any `life.request` to a response of `node:http`. `outcomeResponse(items, end, options)` gives a unary response as data.
345
+ - **Tracing in an ESM service.** An ESM service that uses OpenTelemetry starts with a preload, for example `node --import ./instrumentation.js app.js`. The preload must register `@opentelemetry/instrumentation/hook.mjs` before the application loads. Without that hook, the instrumentation does not see the ESM imports, and the spans of express are lost. The telemetry check of Graph found this when its server moved to ESM.
346
+
347
+ ```js
348
+ // instrumentation.js, the preload: node --import ./instrumentation.js app.js
349
+ import { register } from "node:module";
350
+ register("@opentelemetry/instrumentation/hook.mjs", import.meta.url);
351
+ // then start the SDK of OpenTelemetry, before the application loads
352
+ ```
353
+
354
+ ```ts
355
+ import { createServer } from "node:http";
356
+ import { service } from "@mark1russell7/service";
357
+ import { handle, httpTransport, ndjson, probeServer } from "@mark1russell7/service/http";
358
+
359
+ const s = (value: number) => ({ value, unit: "s" });
360
+ const http = httpTransport(
361
+ createServer(),
362
+ { server_idle_close: s(65), server_idle_sweep_interval: s(1), server_retry_after: s(1) },
363
+ { listen: { port: 8080 } },
364
+ );
365
+ // a unary route: one status and one value
366
+ http.mount(handle("orders", "POST /orders", (req, signal) => createOrder(req, signal)));
367
+ // a stream: each row as it comes, and the end as the last line
368
+ http.mount(handle("orders", "GET /orders/export", (_req, signal) => exportRows(signal), { stream: ndjson }));
369
+ // a WebSocket server in its noServer mode shares the port
370
+ http.upgrade("/ws", (req, socket, head) => wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req)));
371
+
372
+ const app = service("app")
373
+ .dependency("db", { system: null, knobs: {}, operations: {} }, { connect: () => db.connect(), close: () => db.end() })
374
+ .server("http", http.knobs, http)
375
+ .healthServer(probeServer({ port: 9090, server_retry_after: s(1) }))
376
+ .readiness({ type: "debounce", interval: s(5), failAfter: 3, recoverAfter: 1 })
377
+ .liveness({ type: "window", failAfter: s(45) })
378
+ .startup(s(60))
379
+ .phase("fail readiness", s(2))
380
+ .phase("stop intake", s(5))
381
+ .phase("drain", s(15))
382
+ .phase("close transports", s(2))
383
+ .phase("stop the poller", s(1))
384
+ .phase("tear down dependencies", s(2))
385
+ .phase("close the health server", s(1))
386
+ .cooperativeWindow(s(5))
387
+ .onRepeatSignal("ignore");
388
+ app.controller("orders").requires("db").route("POST /orders", { idempotent: false }).route("GET /orders/export", { idempotent: true });
389
+
390
+ const life = await app.run(); // the process exits after SIGTERM, with 0 or 1
391
+ ```
392
+
97
393
  ## The testing subpath
98
394
 
99
395
  `@mark1russell7/service/testing` has the seams that a test of a service needs:
100
396
 
101
397
  - `fakeClock()` gives a clock that moves only on `advance(ms)`. Pass it to `start({ clock })`, and the timing of the runtime is exact and repeatable. `block(ms)` moves the clock and starts no timer, as a blocked event loop does.
102
- - `fakeProcess()` gives a process port that takes signals from a test, and keeps each exit code in place of an exit.
398
+ - `fakeProcess({ flush, clock })` gives a process port that takes signals and process triggers from a test (`signal(name)`, `trigger(name)`). It keeps each exit code in place of an exit. `flush` is its flush of the output, and `log` gives the time of each flush and each exit. `holds` is the count of holds of the process, and `heldAtExit` gives that count at each exit. `message(value)` sends a message of the IPC channel.
103
399
  - `seeded(seed)` gives a seeded random number generator for the jitter (`start({ random })`).
104
400
  - `scriptedCheck(behavior)` gives a check whose behavior a test sets: `"ok"`, `"error"`, `"indeterminate"`, `"throw"`, `"timeout"` or `"hang"`.
105
401
  - `quietServer()` starts a TCP echo server that can go quiet. Its connections stay open while it is quiet, as with a frozen peer.
402
+ - `detachedLifecycle()` gives a lifecycle with no service, for the start of a transport in a test.
403
+ - `transportConformance(make, options)` generates the conformance test of a transport. `make` gives a subject: the transport, a way to offer new work and a way to hold work in flight. The cases check that `stopIntake` refuses new work, and that the count is correct and comes from events. They also check that `drain` holds to its deadline and gives the cooperative abort, and that `close` is idempotent and bounded. The fake clock makes the cases fast, and the real clock measures them.
106
404
  - `contractCases(declare)` generates the contract test of a declaration. The cases are the service at rest, each dependency unavailable, each dependency degraded by 100 ms, each recovery, and a negative control. A dependency that no check exercises is a finding, and so is a check whose timeout a latency of 100 ms fails.
107
405
 
108
406
  ## The Redis client
@@ -124,12 +422,12 @@ const delay = backoffDelay({ base: 1000, factor: 1.5, cap: 30_000, jitter: { kin
124
422
  ## Installation
125
423
 
126
424
  ```sh
127
- pnpm add @mark1russell7/service zod
425
+ pnpm add @mark1russell7/service
128
426
  ```
129
427
 
130
428
  For the Redis client, the application also installs `ioredis@6`.
131
429
 
132
- The package uses [`@mark1russell7/quantity`](https://www.npmjs.com/package/@mark1russell7/quantity) for each amount, and `quantity` uses `zod` as a peer dependency. Thus the application installs `zod`.
430
+ The package uses [`@mark1russell7/quantity`](https://www.npmjs.com/package/@mark1russell7/quantity) for each amount. No entry of the service loads `zod` at run time, thus `zod` is an optional peer dependency. An application that validates amounts with the schemas of `quantity` installs `zod`.
133
431
 
134
432
  ## Example
135
433
 
@@ -158,7 +456,8 @@ const svc = service("app")
158
456
  .couple({ from: "liveness", to: "readiness", effect: "fails" })
159
457
  .phase("fail readiness", s(2))
160
458
  .phase("drain", s(15))
161
- .cooperativeWindow(s(5));
459
+ .cooperativeWindow(s(5))
460
+ .onRepeatSignal("ignore");
162
461
 
163
462
  const rules: Rule[] = []; // the rules of the law, as data
164
463
  const scenario = {
@@ -171,13 +470,18 @@ const health = await svc.start();
171
470
  const answer = health.probe("readiness"); // { status: 200, contentType, body }
172
471
  ```
173
472
 
174
- A service that starts its whole lifecycle declares its parts and each stage of its shutdown:
473
+ A service that starts its whole lifecycle declares its parts and each stage of its shutdown. [The HTTP edge](#the-http-edge) has a whole example, with the mounts and the probes.
175
474
 
176
475
  ```ts
476
+ const http = httpTransport(
477
+ createServer(),
478
+ { server_idle_close: s(65), server_idle_sweep_interval: s(1), server_retry_after: s(1) },
479
+ { listen: { port: 8080 } },
480
+ );
177
481
  const app = service("app")
178
482
  .dependency("db", { system: null, knobs: {}, operations: {} }, { connect: () => db.connect(), close: () => db.end() })
179
- .server("http", {}, httpTransport)
180
- .healthServer(probeServer)
483
+ .server("http", http.knobs, http)
484
+ .healthServer(probeServer({ port: 9090, server_retry_after: s(1) }))
181
485
  .check(roundTrip, roundTripFn)
182
486
  .readiness({ type: "debounce", interval: s(5), failAfter: 3, recoverAfter: 1 })
183
487
  .liveness({ type: "window", failAfter: s(45) })
@@ -189,11 +493,32 @@ const app = service("app")
189
493
  .phase("stop the poller", s(1))
190
494
  .phase("tear down dependencies", s(2))
191
495
  .phase("close the health server", s(1))
192
- .cooperativeWindow(s(5));
496
+ .cooperativeWindow(s(5))
497
+ .onRepeatSignal("ignore");
193
498
  app.controller("orders").requires("db").route("POST /orders", { idempotent: false });
194
499
 
195
500
  const life = await app.run(); // the process exits after SIGTERM, with 0 or 1
196
- const outcome = await life.request("orders", "POST /orders", (signal) => createOrder(signal));
501
+ const { items, end } = await life.request("orders", "POST /orders", (signal) => createOrder(signal)).collect();
502
+ ```
503
+
504
+ A registry that changes while the service runs declares a pattern, and each request a stream:
505
+
506
+ ```ts
507
+ app
508
+ .controller("procedures")
509
+ .requires("db")
510
+ .pattern(
511
+ "POST /rpc/:procedure",
512
+ { read: { idempotent: true }, write: { idempotent: false } },
513
+ ({ detail }) => ((detail as Procedure).readOnly ? "read" : "write"),
514
+ );
515
+
516
+ const stream = life.request("procedures", "POST /rpc/logs.tail", (signal) => tail(signal), { detail: procedure });
517
+ for await (const event of stream) {
518
+ if (event.kind === "item") write(JSON.stringify(event.value));
519
+ else if (event.delivered > 0) write(JSON.stringify({ end: event.kind, delivered: event.delivered })); // in-band
520
+ else respond(event); // a status: 503, 500 or the error of the route
521
+ }
197
522
  ```
198
523
 
199
524
  ## Tests
@@ -213,7 +538,22 @@ const outcome = await life.request("orders", "POST /orders", (signal) => createO
213
538
  - **The Redis client.** These tests use a fake ioredis and a fake clock. They cover the declaration, the three outcomes, and the commands that the client sends again or not. They also cover the join, the backoff, the watchdog, the close, the checks and the manifestations. Five scenarios replay against the document of the client.
214
539
  - **F6 for Redis.** The fault matrix crosses each manifestation (a refusal, a frozen process, a black hole, a death without a close, and an app stall), each gating posture, two timings and two jitters. That is 60 cases on the fake. Each case holds the trip and the release of its policy to `failSpan` and `recoverSpan`. Five more cases shut the service down during each manifestation, and each one exits 0.
215
540
  - **The timers.** These tests use a fake clock, and they cover the hooks of the heartbeat and its two kinds of ping. A property test holds the death of a silent peer to `detectionTime` in 300 seeded cases. Another one compares the heartbeat with a WebSocket heartbeat that sends one ping at a time, on 300 schedules of pongs. The backoff gives the delays of three earlier backoffs, number for number, and each delay is in its band.
216
- - **The entries.** The timer entries reach no other package, no `node:` module and no zod.
541
+ - **The entries.** The timer entries reach no other package, no `node:` module and no zod. No entry of the service reaches zod, also through `quantity` and the replay of the algebra. The root reaches no `node:` module. The HTTP edge reaches only `node:http`, `node:net` and `quantity`. The worker loop and the queue drain reach only `quantity`.
542
+ - **The requests.** A unary work is a stream of one item, and a stream gives each item when its reader asks. The drain waits for an open stream. The cooperative abort ends a stream through `return()`, and a cut or a failure after the first item ends in-band. A reader that stops early stops the work. The function of a route pattern decides the answer of each cut request.
543
+ - **The options of the runner.** These tests cover the requests of a transport while the startup runs, the policy of a repeat signal, the profiles and the process triggers. They also cover the exit codes, a first Ctrl-C that aborts at once, and the flush of the output.
544
+ - **The composition.** Two components, each with a transport, a dependency and a check, share one service and one `run()`. The parts start and stop in their sequence, and the process exits one time.
545
+ - **The process port.** The port of Node, on a fake host: the signals, the end of stdin and the close of the IPC channel. The tests also cover the messages, the hold, the flush and the exit. The lifecycle holds its fake process from its start to its exit.
546
+ - **The HTTP edge.** The transport, the mounts, the upgrades, the probes and `handle` run on fake servers and the fake clock, with no socket. A service under `run()` drains a held request, and its probes answer through the drain. A request during the warm-up gets 503, and a stream ends in-band after its first item.
547
+ - **The headers of the outcomes.** Each path of the request tracker gets its status. Each 503 gets `Retry-After`: the refusals, the retryable cut and the cut of a unary stream. No 500 gets it.
548
+ - **The headers on the port.** The transport adds `Retry-After` to each 503 of a probe and of a handler that writes its own status, in each form of `writeHead`. It takes `Retry-After` from each 500. The probe server gives its 503s the time of its knob.
549
+ - **The conformance of a transport.** A reference transport passes each case, and each of nine mutations of it fails the case of its part. The HTTP transport passes each case on a fake server, for its requests and its upgrades.
550
+ - **The worker loop.** These tests use the fake clock. They cover the sleep, `"more"`, the backoff and its jitter, `wake()`, the stop of the intake and the drain with its cooperative abort. They also cover the bound of an iteration, the lanes, the setup, the teardown, the close bound and the law (O20).
551
+ - **The queue drain.** These tests cover the slots, the poll, the policy of A11 and the count of deliveries. They also cover a job that goes back at the stop, an async iterable and a failed take. A cut job of each kind of route, the close bound and the refusals of the service have tests too.
552
+ - **The conformance of the helpers.** The worker loop passes each case of the kit with three lanes, and the queue drain with three slots. Each one passes with the request tracking and without it. With one lane, the loop passes each case that holds one work.
553
+ - **The IPC message.** A message that matches a declared shape starts the staged shutdown on the fake process, with the exit code of its signal. A second message obeys the policy of a repeat signal.
554
+ - **On real sockets.** `HTTP_IT=1` starts the tests of the HTTP edge on the loopback. They cover the conformance on the fake clock and on the real clock, keep-alive and the stop of the intake. They also cover the upgrades, the probe server and a service after SIGTERM. They measure time, thus only CI starts them (`http.yml`), on Node 22.19, 24 and 26. CI keeps the raw records of each job as an artifact.
555
+ - **On the real clock.** `WORKERS_IT=1` starts the kit of the worker loop and of the queue drain on the real clock, in the jobs of `http.yml`.
556
+ - **A real child process.** `PROCESS_IT=1` starts a child under `run()` with an IPC channel, on Linux and on Windows (`process.yml`). The message `shutdown` stops it in stages, with the code 143. A control shows that a SIGTERM of the parent does that only on Linux. A child whose only part is a worker on the real clock stays alive through the hold, and on Linux a SIGTERM gives its teardown.
217
557
  - **Against a real Redis.** `REDIS_IT=1` starts the tests of the client against Redis 7.4. `REDIS_F6=1` starts the F6 matrix in the VM of Docker. The injector of `@mark1russell7/harness` makes the network faults, and an agent in the namespaces of Redis makes the process faults. All of them use one monotonic clock.
218
558
 
219
559
  `pnpm --filter @mark1russell7/service build` writes the package for npm into `build/`.