@dbx-tools/appkit 0.9.121 → 0.9.122

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.
@@ -1,238 +0,0 @@
1
- /**
2
- * AppKit child-process lifecycle built on `@dbx-tools/core/exec`.
3
- *
4
- * @module
5
- */
6
- import * as exec from "@dbx-tools/core/exec";
7
- import { asyncUtils } from "@dbx-tools/shared-core";
8
-
9
- const DEFAULT_HEALTH_CHECK_INTERVAL_MS = 200;
10
- const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 30_000;
11
- const DEFAULT_RUN_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
12
-
13
- /** Spawn arguments accepted by {@link AppKitChildProcess}. */
14
- export type AppKitSpawnArgs = exec.SpawnArgs<exec.ExecOptions>;
15
-
16
- /** Context passed to one child-process readiness probe. */
17
- export interface AppKitChildProcessHealthCheckContext {
18
- /** Spawned process being checked. */
19
- readonly process: exec.ChildProcessResult;
20
- /** Aborts when shutdown begins or the readiness timeout expires. */
21
- readonly signal: AbortSignal;
22
- /** Zero-based readiness attempt. */
23
- readonly attempt: number;
24
- }
25
-
26
- /** Return `true` when the spawned process is ready to serve callers. */
27
- export type AppKitChildProcessHealthCheck = (
28
- context: AppKitChildProcessHealthCheckContext,
29
- ) => boolean | PromiseLike<boolean>;
30
-
31
- /** Readiness and tree-shutdown policy for an AppKit child process. */
32
- export interface AppKitChildProcessOptions extends exec.KillOptions {
33
- /** Optional readiness probe polled after the process is spawned. */
34
- healthCheck?: AppKitChildProcessHealthCheck;
35
- /** Delay between readiness probes. Defaults to 200 milliseconds. */
36
- healthCheckIntervalMs?: number;
37
- /** Maximum time allowed for readiness. Defaults to 30 seconds. */
38
- healthCheckTimeoutMs?: number;
39
- }
40
-
41
- /** Foreground signal policy for {@link AppKitChildProcess.run}. */
42
- export interface AppKitChildProcessRunOptions {
43
- /** Parent signals that trigger tree shutdown before being re-raised. */
44
- signals?: readonly NodeJS.Signals[];
45
- }
46
-
47
- /**
48
- * Start and stop one AppKit-owned subprocess.
49
- *
50
- * The spawn arguments use {@link exec.ExecOptions}, including core exec's
51
- * stdin, output capture, line handlers, exit checking, and trim policy.
52
- * When configured, {@link AppKitChildProcessOptions.healthCheck} is polled
53
- * until ready before {@link start} resolves. Shutdown aborts any active probe
54
- * and delegates process-tree termination to {@link exec.kill}.
55
- */
56
- export class AppKitChildProcess {
57
- readonly #spawnArgs: AppKitSpawnArgs;
58
- readonly #killOptions: exec.KillOptions;
59
- readonly #healthCheck?: AppKitChildProcessHealthCheck;
60
- readonly #healthCheckIntervalMs: number;
61
- readonly #healthCheckTimeoutMs: number;
62
-
63
- #child?: exec.ChildProcessResult;
64
- #healthCheckController?: AbortController;
65
- #shutdownPromise?: Promise<void>;
66
-
67
- constructor(spawnArgs: AppKitSpawnArgs, options: AppKitChildProcessOptions = {}) {
68
- this.#spawnArgs = spawnArgs;
69
- const {
70
- healthCheck,
71
- healthCheckIntervalMs = DEFAULT_HEALTH_CHECK_INTERVAL_MS,
72
- healthCheckTimeoutMs = DEFAULT_HEALTH_CHECK_TIMEOUT_MS,
73
- ...killOptions
74
- } = options;
75
- this.#healthCheck = healthCheck;
76
- this.#healthCheckIntervalMs = positiveMilliseconds(
77
- healthCheckIntervalMs,
78
- "healthCheckIntervalMs",
79
- );
80
- this.#healthCheckTimeoutMs = positiveMilliseconds(healthCheckTimeoutMs, "healthCheckTimeoutMs");
81
- this.#killOptions = killOptions;
82
- }
83
-
84
- /** Current child handle, or `undefined` when no child is running. */
85
- get process(): exec.ChildProcessResult | undefined {
86
- return this.#child;
87
- }
88
-
89
- /** Whether the current child has not reported an exit code or signal. */
90
- get running(): boolean {
91
- return this.#child !== undefined && !hasExited(this.#child);
92
- }
93
-
94
- /** Spawn the child and resolve only after its optional readiness probe succeeds. */
95
- start(): Promise<void> {
96
- if (this.running) throw new Error("Child process is already running");
97
- if (this.#shutdownPromise) {
98
- throw new Error("Child process is shutting down or has been shut down");
99
- }
100
-
101
- const child = exec.spawn(...this.#spawnArgs);
102
- const controller = new AbortController();
103
- this.#child = child;
104
- this.#healthCheckController = controller;
105
- void child.once("close", () => {
106
- if (this.#child === child) this.#child = undefined;
107
- });
108
- return this.#completeStart(child, controller);
109
- }
110
-
111
- /**
112
- * Start, await, and shut down one foreground child while preserving parent
113
- * termination signals.
114
- */
115
- async run(options: AppKitChildProcessRunOptions = {}): Promise<exec.ExecResult> {
116
- const signals = options.signals ?? DEFAULT_RUN_SIGNALS;
117
- if (signals.includes("SIGKILL")) {
118
- throw new Error("SIGKILL cannot be intercepted by a Node process");
119
- }
120
- const handlers = new Map<NodeJS.Signals, () => void>();
121
- let stopping = false;
122
- const removeHandlers = () => {
123
- for (const [signal, handler] of handlers) process.off(signal, handler);
124
- };
125
- for (const signal of signals) {
126
- const handler = () => {
127
- if (stopping) return;
128
- stopping = true;
129
- removeHandlers();
130
- void this.shutdown()
131
- .finally(() => process.kill(process.pid, signal))
132
- .catch(() => undefined);
133
- };
134
- handlers.set(signal, handler);
135
- process.once(signal, handler);
136
- }
137
-
138
- try {
139
- await this.start();
140
- const child = this.#child;
141
- if (!child) throw new Error("Child process exited after startup");
142
- return await child;
143
- } finally {
144
- removeHandlers();
145
- await this.shutdown();
146
- }
147
- }
148
-
149
- /**
150
- * Abort readiness and stop the process tree once.
151
- *
152
- * An explicit non-`SIGKILL` signal runs only the graceful polling phase.
153
- * Explicit `SIGKILL` skips directly to forced polling. Omitting the signal
154
- * uses the configured graceful and forced policy.
155
- */
156
- shutdown(signal?: NodeJS.Signals): Promise<void> {
157
- this.#healthCheckController?.abort(
158
- new DOMException("Child process shutdown requested", "AbortError"),
159
- );
160
- const killOptions: exec.KillOptions =
161
- signal === undefined
162
- ? this.#killOptions
163
- : signal === "SIGKILL"
164
- ? { ...this.#killOptions, gracefulSignal: false, forceSignal: signal }
165
- : { ...this.#killOptions, gracefulSignal: signal, forceSignal: false };
166
- this.#shutdownPromise ??= this.#performShutdown(killOptions);
167
- return this.#shutdownPromise;
168
- }
169
-
170
- async #completeStart(child: exec.ChildProcessResult, controller: AbortController): Promise<void> {
171
- try {
172
- if (this.#healthCheck) {
173
- await Promise.race([
174
- this.#waitUntilHealthy(child, controller.signal),
175
- child.then((result) => {
176
- throw new Error(
177
- `Child process ${child.pid ?? "<unknown>"} exited before becoming ready ` +
178
- `(exit ${result.exitCode})`,
179
- );
180
- }),
181
- ]);
182
- }
183
- } catch (error) {
184
- try {
185
- await this.shutdown();
186
- } catch (shutdownError) {
187
- throw new AggregateError(
188
- [error, shutdownError],
189
- `Child process ${child.pid ?? "<unknown>"} failed readiness and shutdown`,
190
- );
191
- }
192
- throw error;
193
- } finally {
194
- if (this.#healthCheckController === controller) {
195
- this.#healthCheckController = undefined;
196
- }
197
- }
198
- }
199
-
200
- async #waitUntilHealthy(child: exec.ChildProcessResult, signal: AbortSignal): Promise<void> {
201
- const healthCheck = this.#healthCheck;
202
- if (!healthCheck) return;
203
- for await (const healthy of asyncUtils.poll(
204
- ({ attempt, signal: pollSignal }) =>
205
- healthCheck({ process: child, signal: pollSignal, attempt }),
206
- {
207
- intervalMs: this.#healthCheckIntervalMs,
208
- timeoutMs: this.#healthCheckTimeoutMs,
209
- signal,
210
- predicate: (healthy) => !healthy,
211
- },
212
- )) {
213
- if (healthy) return;
214
- }
215
- }
216
-
217
- async #performShutdown(options: exec.KillOptions): Promise<void> {
218
- const child = this.#child;
219
- if (!child) {
220
- this.#child = undefined;
221
- return;
222
- }
223
-
224
- await exec.kill(child, options);
225
- if (this.#child === child) this.#child = undefined;
226
- }
227
- }
228
-
229
- function hasExited(child: exec.ChildProcessResult): boolean {
230
- return child.exitCode !== null || child.signalCode !== null;
231
- }
232
-
233
- function positiveMilliseconds(value: number, name: string): number {
234
- if (!Number.isFinite(value) || value <= 0) {
235
- throw new RangeError(`${name} must be a finite positive number`);
236
- }
237
- return value;
238
- }