@arki/dot 0.1.3 → 0.2.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/README.md +427 -18
- package/dist/cli/index.d.ts +5 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +21 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/render-graph.d.ts +36 -0
- package/dist/cli/render-graph.d.ts.map +1 -0
- package/dist/cli/render-graph.js +70 -0
- package/dist/cli/render-graph.js.map +1 -0
- package/dist/define-app.d.ts +10 -0
- package/dist/define-app.d.ts.map +1 -1
- package/dist/define-app.js +2 -0
- package/dist/define-app.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/kernel/app-instance.d.ts +2 -0
- package/dist/kernel/app-instance.d.ts.map +1 -1
- package/dist/kernel/app-instance.js +67 -17
- package/dist/kernel/app-instance.js.map +1 -1
- package/dist/lifecycle.d.ts +2 -0
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +2 -0
- package/dist/lifecycle.js.map +1 -1
- package/dist/signals.d.ts +65 -0
- package/dist/signals.d.ts.map +1 -0
- package/dist/signals.js +97 -0
- package/dist/signals.js.map +1 -0
- package/dist/test-harness.d.ts +75 -1
- package/dist/test-harness.d.ts.map +1 -1
- package/dist/test-harness.js +71 -0
- package/dist/test-harness.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/index.ts +36 -2
- package/src/cli/render-graph.ts +88 -0
- package/src/define-app.ts +13 -0
- package/src/index.ts +5 -2
- package/src/kernel/app-instance.ts +113 -51
- package/src/lifecycle.ts +2 -0
- package/src/signals.ts +134 -0
- package/src/test-harness.ts +140 -1
package/src/cli/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { runNew } from './new.js';
|
|
|
33
33
|
import { probeObservability } from './observability-probe.js';
|
|
34
34
|
import { renderDoctor } from './render-doctor.js';
|
|
35
35
|
import { renderExplain } from './render-explain.js';
|
|
36
|
+
import { renderGraph } from './render-graph.js';
|
|
36
37
|
|
|
37
38
|
const debugCli = createDebugLogger('arki:dot:cli');
|
|
38
39
|
|
|
@@ -57,6 +58,10 @@ Common options:
|
|
|
57
58
|
--app <path> Path to the app entry file (default: discovers
|
|
58
59
|
./dot.config.ts, ./src/app.ts, or ./app.ts)
|
|
59
60
|
--cwd <dir> Working directory (default: current)
|
|
61
|
+
--graph Emit the pip graph as Mermaid flowchart source
|
|
62
|
+
instead of the standard output. explain shows
|
|
63
|
+
declaration (= boot) order; doctor shows the
|
|
64
|
+
wiring observed during boot. Composes with --json.
|
|
60
65
|
|
|
61
66
|
\`doctor\` options:
|
|
62
67
|
--observability Also probe whether an OpenTelemetry SDK is
|
|
@@ -89,6 +94,8 @@ export type CliArgs = {
|
|
|
89
94
|
force?: boolean;
|
|
90
95
|
/** `--observability` (only honored by `doctor`). */
|
|
91
96
|
observability?: boolean;
|
|
97
|
+
/** `--graph` (honored by `explain` and `doctor`). */
|
|
98
|
+
graph?: boolean;
|
|
92
99
|
};
|
|
93
100
|
|
|
94
101
|
/**
|
|
@@ -152,6 +159,7 @@ export function parseArgs(argv: readonly string[]): CliArgs {
|
|
|
152
159
|
'dry-run': { type: 'boolean', default: false },
|
|
153
160
|
force: { type: 'boolean', default: false },
|
|
154
161
|
observability: { type: 'boolean', default: false },
|
|
162
|
+
graph: { type: 'boolean', default: false },
|
|
155
163
|
},
|
|
156
164
|
});
|
|
157
165
|
} catch (err) {
|
|
@@ -174,6 +182,7 @@ export function parseArgs(argv: readonly string[]): CliArgs {
|
|
|
174
182
|
'dry-run'?: boolean;
|
|
175
183
|
force?: boolean;
|
|
176
184
|
observability?: boolean;
|
|
185
|
+
graph?: boolean;
|
|
177
186
|
};
|
|
178
187
|
|
|
179
188
|
if (values.help) command = 'help';
|
|
@@ -205,6 +214,7 @@ export function parseArgs(argv: readonly string[]): CliArgs {
|
|
|
205
214
|
dryRun: values['dry-run'] ?? false,
|
|
206
215
|
force: values.force ?? false,
|
|
207
216
|
observability: values.observability ?? false,
|
|
217
|
+
graph: values.graph ?? false,
|
|
208
218
|
};
|
|
209
219
|
}
|
|
210
220
|
|
|
@@ -220,7 +230,7 @@ async function loadApp(args: CliArgs): Promise<DiscoveredApp> {
|
|
|
220
230
|
*/
|
|
221
231
|
export async function runExplain(
|
|
222
232
|
discovered: DiscoveredApp,
|
|
223
|
-
opts: { json: boolean; out?: (line: string) => void; now?: () => Date },
|
|
233
|
+
opts: { json: boolean; graph?: boolean; out?: (line: string) => void; now?: () => Date },
|
|
224
234
|
): Promise<DotCliEnvelope<unknown>> {
|
|
225
235
|
let configured: DotApp<Record<string, unknown>> | DotAppConfigured<Record<string, unknown>>;
|
|
226
236
|
try {
|
|
@@ -236,6 +246,12 @@ export async function runExplain(
|
|
|
236
246
|
throw wrapLifecycleError(err, 'configure');
|
|
237
247
|
}
|
|
238
248
|
|
|
249
|
+
if (opts.graph === true) {
|
|
250
|
+
return renderGraph(
|
|
251
|
+
{ manifest: configured.manifest, command: 'explain' },
|
|
252
|
+
{ json: opts.json, out: opts.out, now: opts.now },
|
|
253
|
+
);
|
|
254
|
+
}
|
|
239
255
|
return renderExplain({ manifest: configured.manifest }, { json: opts.json, out: opts.out, now: opts.now });
|
|
240
256
|
}
|
|
241
257
|
|
|
@@ -249,6 +265,8 @@ type DoctorRunOptions = {
|
|
|
249
265
|
* present. Default `false`.
|
|
250
266
|
*/
|
|
251
267
|
observability?: boolean;
|
|
268
|
+
/** When `true`, emit the pip graph (Mermaid) instead of diagnostics. */
|
|
269
|
+
graph?: boolean;
|
|
252
270
|
};
|
|
253
271
|
|
|
254
272
|
/**
|
|
@@ -268,6 +286,12 @@ export async function runDoctor(
|
|
|
268
286
|
// Already-booted app: just read diagnostics, don't touch lifecycle.
|
|
269
287
|
if (!guards.isDotAppBuilder(discovered) && !guards.isDotAppConfigured(discovered)) {
|
|
270
288
|
const app = discovered as DotApp<Record<string, unknown>>;
|
|
289
|
+
if (opts.graph === true) {
|
|
290
|
+
return renderGraph(
|
|
291
|
+
{ manifest: app.manifest, command: 'doctor' },
|
|
292
|
+
{ json: opts.json, out: opts.out, now: opts.now },
|
|
293
|
+
);
|
|
294
|
+
}
|
|
271
295
|
const diagnostics = applyObservabilityProbe(app.diagnostics, opts.observability ?? false);
|
|
272
296
|
return renderDoctor({ diagnostics }, { json: opts.json, out: opts.out, now: opts.now });
|
|
273
297
|
}
|
|
@@ -297,6 +321,16 @@ export async function runDoctor(
|
|
|
297
321
|
}
|
|
298
322
|
|
|
299
323
|
try {
|
|
324
|
+
if (opts.graph === true) {
|
|
325
|
+
// Post-boot manifest carries the observed wiring edges; after a boot
|
|
326
|
+
// failure it carries the edges recorded up to the failing pip.
|
|
327
|
+
const manifest = bootedApp ? bootedApp.manifest : configured.manifest;
|
|
328
|
+
const graphEnvelope = renderGraph(
|
|
329
|
+
{ manifest, command: 'doctor' },
|
|
330
|
+
{ json: opts.json, out: opts.out, now: opts.now },
|
|
331
|
+
);
|
|
332
|
+
return bootThrew ? { ...graphEnvelope, status: 'failure' } : graphEnvelope;
|
|
333
|
+
}
|
|
300
334
|
const rawDiagnostics = bootedApp ? bootedApp.diagnostics : configured.diagnostics;
|
|
301
335
|
const diagnostics = applyObservabilityProbe(rawDiagnostics, opts.observability ?? false);
|
|
302
336
|
const envelope = renderDoctor({ diagnostics }, { json: opts.json, out: opts.out, now: opts.now });
|
|
@@ -450,7 +484,7 @@ export async function main(options: MainOptions): Promise<number> {
|
|
|
450
484
|
|
|
451
485
|
try {
|
|
452
486
|
const discovered = await loadApp(args);
|
|
453
|
-
const opts = { json: args.json, out: stdout, now: nowFactory };
|
|
487
|
+
const opts = { json: args.json, graph: args.graph, out: stdout, now: nowFactory };
|
|
454
488
|
let envelope: DotCliEnvelope<unknown>;
|
|
455
489
|
|
|
456
490
|
if (args.command === 'explain') {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer for `dot explain --graph` / `dot doctor --graph`.
|
|
3
|
+
*
|
|
4
|
+
* Emits the app's pip graph as [Mermaid](https://mermaid.js.org) `flowchart`
|
|
5
|
+
* source — paste-able into GitHub markdown, docs, and mermaid.live.
|
|
6
|
+
*
|
|
7
|
+
* The nodes are the pips in declaration order (which IS boot order in v2 —
|
|
8
|
+
* the numbering makes that visible); the edges are the manifest's
|
|
9
|
+
* **observed** dependency edges, recorded by the kernel when a pip's need
|
|
10
|
+
* was satisfied during boot. `explain` never boots, so its graph shows
|
|
11
|
+
* declaration order with whatever edges configure-time metadata declared;
|
|
12
|
+
* `doctor` boots, so its graph shows the real wiring.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { DotAppManifest } from '../manifest.js';
|
|
16
|
+
import type { DotCliEnvelope, RenderOptions } from './render-explain.js';
|
|
17
|
+
|
|
18
|
+
/** Envelope payload for graph output. */
|
|
19
|
+
export type GraphData = {
|
|
20
|
+
format: 'mermaid';
|
|
21
|
+
/** Mermaid `flowchart` source. */
|
|
22
|
+
source: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Escape a pip name for use inside a quoted Mermaid node label. */
|
|
26
|
+
function escapeLabel(name: string): string {
|
|
27
|
+
return name.replaceAll('"', '#quot;');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build Mermaid `flowchart` source from a manifest. Deterministic: node
|
|
32
|
+
* ids follow declaration order, edges follow manifest order.
|
|
33
|
+
*/
|
|
34
|
+
export function buildMermaidGraph(manifest: DotAppManifest): string {
|
|
35
|
+
const lines: string[] = ['flowchart TD'];
|
|
36
|
+
const idByPip = new Map<string, string>();
|
|
37
|
+
|
|
38
|
+
for (const [index, pip] of manifest.pips.entries()) {
|
|
39
|
+
const id = `p${index.toString()}`;
|
|
40
|
+
idByPip.set(pip.name, id);
|
|
41
|
+
const order = (index + 1).toString();
|
|
42
|
+
const version = pip.version === undefined ? '' : `@${pip.version}`;
|
|
43
|
+
lines.push(` ${id}["${order} · ${escapeLabel(pip.name)}${escapeLabel(version)}"]`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const edge of manifest.dependencies) {
|
|
47
|
+
const from = idByPip.get(edge.from);
|
|
48
|
+
const to = idByPip.get(edge.to);
|
|
49
|
+
// Edges referencing unknown pips would be a kernel bug — skip rather
|
|
50
|
+
// than emit invalid Mermaid.
|
|
51
|
+
if (from === undefined || to === undefined) continue;
|
|
52
|
+
lines.push(` ${from} -->|${edge.kind}| ${to}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return `${lines.join('\n')}\n`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Render the graph output. Plain mode prints raw Mermaid source (pipe it
|
|
60
|
+
* straight into a markdown code fence); `--json` wraps it in the standard
|
|
61
|
+
* CLI envelope under `data.source`.
|
|
62
|
+
*/
|
|
63
|
+
export function renderGraph(
|
|
64
|
+
source: { manifest: DotAppManifest; command: 'explain' | 'doctor' },
|
|
65
|
+
opts: RenderOptions,
|
|
66
|
+
): DotCliEnvelope<GraphData> {
|
|
67
|
+
const mermaid = buildMermaidGraph(source.manifest);
|
|
68
|
+
const nowFactory = opts.now ?? (() => new Date());
|
|
69
|
+
const envelope: DotCliEnvelope<GraphData> = {
|
|
70
|
+
status: 'success',
|
|
71
|
+
command: source.command,
|
|
72
|
+
generatedAt: nowFactory().toISOString(),
|
|
73
|
+
data: { format: 'mermaid', source: mermaid },
|
|
74
|
+
errors: [],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const out =
|
|
78
|
+
opts.out ??
|
|
79
|
+
((line: string) => {
|
|
80
|
+
process.stdout.write(line);
|
|
81
|
+
});
|
|
82
|
+
if (opts.json) {
|
|
83
|
+
out(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
84
|
+
} else {
|
|
85
|
+
out(mermaid);
|
|
86
|
+
}
|
|
87
|
+
return envelope;
|
|
88
|
+
}
|
package/src/define-app.ts
CHANGED
|
@@ -176,6 +176,7 @@ type BuilderState = {
|
|
|
176
176
|
pips: AnyPip[];
|
|
177
177
|
config?: Readonly<Record<string, unknown>>;
|
|
178
178
|
observers?: readonly DotLifecycleObserver[];
|
|
179
|
+
hookTimeoutMs?: number;
|
|
179
180
|
};
|
|
180
181
|
|
|
181
182
|
/**
|
|
@@ -204,6 +205,16 @@ export function defineApp(
|
|
|
204
205
|
* `configured.subscribe(...)` or `app.subscribe(...)`.
|
|
205
206
|
*/
|
|
206
207
|
observers?: readonly DotLifecycleObserver[];
|
|
208
|
+
/**
|
|
209
|
+
* Watchdog budget (ms) for each async hook invocation (`boot`, `start`,
|
|
210
|
+
* `stop`, `dispose` — `configure` is sync). A hook exceeding the budget
|
|
211
|
+
* fails with `DOT_LIFECYCLE_E015` naming the pip and hook, and the
|
|
212
|
+
* kernel applies its normal failure rules (boot rollback, teardown
|
|
213
|
+
* aggregation). The hook's promise itself cannot be cancelled — the
|
|
214
|
+
* watchdog makes the hang *visible*, it does not kill it. Default:
|
|
215
|
+
* no watchdog.
|
|
216
|
+
*/
|
|
217
|
+
hookTimeoutMs?: number;
|
|
207
218
|
} = {},
|
|
208
219
|
): DotAppBuilder<EmptyShape> {
|
|
209
220
|
const state: BuilderState = {
|
|
@@ -212,6 +223,7 @@ export function defineApp(
|
|
|
212
223
|
pips: [],
|
|
213
224
|
config: options.config,
|
|
214
225
|
observers: options.observers,
|
|
226
|
+
hookTimeoutMs: options.hookTimeoutMs,
|
|
215
227
|
};
|
|
216
228
|
return makeBuilder<EmptyShape>(state);
|
|
217
229
|
}
|
|
@@ -223,6 +235,7 @@ function buildImpl(state: BuilderState): DotAppImpl {
|
|
|
223
235
|
pips: state.pips,
|
|
224
236
|
config: state.config,
|
|
225
237
|
observers: state.observers,
|
|
238
|
+
hookTimeoutMs: state.hookTimeoutMs,
|
|
226
239
|
});
|
|
227
240
|
}
|
|
228
241
|
|
package/src/index.ts
CHANGED
|
@@ -80,8 +80,11 @@ export type {
|
|
|
80
80
|
export { renderTimeline } from './timeline.js';
|
|
81
81
|
export type { RenderTimelineOptions } from './timeline.js';
|
|
82
82
|
|
|
83
|
-
export { testApp, bootTestApp } from './test-harness.js';
|
|
84
|
-
export type { TestAppOptions } from './test-harness.js';
|
|
83
|
+
export { testApp, bootTestApp, testPip } from './test-harness.js';
|
|
84
|
+
export type { TestAppOptions, TestPipBuilder } from './test-harness.js';
|
|
85
|
+
|
|
86
|
+
export { hookSignals } from './signals.js';
|
|
87
|
+
export type { HookSignalsOptions, SignalTarget } from './signals.js';
|
|
85
88
|
|
|
86
89
|
// Task 9b: CLI envelope type is exported so adapter packages can produce the
|
|
87
90
|
// same shape from related tooling (release-tooling, pip scaffolds, etc.).
|
|
@@ -104,6 +104,8 @@ export type DotAppInternalConfig = {
|
|
|
104
104
|
* happen before there's a public seam to call `subscribe()` on.
|
|
105
105
|
*/
|
|
106
106
|
observers?: readonly DotLifecycleObserver[];
|
|
107
|
+
/** Per-hook watchdog budget in ms — see `defineApp`'s option of the same name. */
|
|
108
|
+
hookTimeoutMs?: number;
|
|
107
109
|
};
|
|
108
110
|
|
|
109
111
|
/**
|
|
@@ -122,6 +124,9 @@ export class DotAppImpl {
|
|
|
122
124
|
/** Macro-state of the app. */
|
|
123
125
|
#state: DotLifecycleState = 'defined';
|
|
124
126
|
|
|
127
|
+
/** Per-hook watchdog budget (ms); `undefined` = no watchdog. */
|
|
128
|
+
readonly #hookTimeoutMs: number | undefined;
|
|
129
|
+
|
|
125
130
|
/** Manifest finalised after `configure`. */
|
|
126
131
|
#manifest: DotAppManifest;
|
|
127
132
|
|
|
@@ -180,6 +185,7 @@ export class DotAppImpl {
|
|
|
180
185
|
constructor(config: DotAppInternalConfig) {
|
|
181
186
|
this.#appName = config.appName;
|
|
182
187
|
this.#appVersion = config.appVersion;
|
|
188
|
+
this.#hookTimeoutMs = config.hookTimeoutMs;
|
|
183
189
|
this.#config = Object.freeze({ ...config.config });
|
|
184
190
|
this.#logger = new Logger('arki:dot:lifecycle', { 'dot.app.name': config.appName });
|
|
185
191
|
this.#observers = new Set(config.observers);
|
|
@@ -675,6 +681,44 @@ export class DotAppImpl {
|
|
|
675
681
|
return ctx;
|
|
676
682
|
}
|
|
677
683
|
|
|
684
|
+
/**
|
|
685
|
+
* Race one hook invocation against the app's `hookTimeoutMs` watchdog.
|
|
686
|
+
* No budget configured → plain pass-through. On timeout the returned
|
|
687
|
+
* promise rejects with `DOT_LIFECYCLE_E015` naming the pip and hook,
|
|
688
|
+
* and the call site's existing catch path applies the phase's normal
|
|
689
|
+
* failure rules (boot rollback, start cascade, teardown aggregation).
|
|
690
|
+
* The hook's own promise cannot be cancelled — the watchdog makes a
|
|
691
|
+
* hang visible and bounded; it does not kill the hung work.
|
|
692
|
+
*/
|
|
693
|
+
async #withHookBudget<T>(pipName: string, hook: DotLifecycleHook, run: () => Promise<T> | T): Promise<T> {
|
|
694
|
+
const budget = this.#hookTimeoutMs;
|
|
695
|
+
if (budget === undefined) return run();
|
|
696
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
697
|
+
const watchdog = new Promise<never>((_resolve, reject) => {
|
|
698
|
+
timer = setTimeout(() => {
|
|
699
|
+
reject(
|
|
700
|
+
new DotLifecycleError({
|
|
701
|
+
code: DotLifecycleErrorCode.HookTimeout,
|
|
702
|
+
phase: hook,
|
|
703
|
+
pip: pipName,
|
|
704
|
+
message:
|
|
705
|
+
`${hook} hook of pip "${pipName}" exceeded the ${budget.toString()}ms hookTimeoutMs watchdog. ` +
|
|
706
|
+
`The kernel treats the hook as failed and applies its normal rollback/aggregation rules, but ` +
|
|
707
|
+
`cannot cancel the hook's promise — find the hang (a missing await? a connection that never ` +
|
|
708
|
+
`settles?) or raise the budget in defineApp(name, { hookTimeoutMs }).`,
|
|
709
|
+
}),
|
|
710
|
+
);
|
|
711
|
+
}, budget);
|
|
712
|
+
// A pending watchdog must never keep an otherwise-finished process alive.
|
|
713
|
+
timer.unref?.();
|
|
714
|
+
});
|
|
715
|
+
try {
|
|
716
|
+
return await Promise.race([Promise.resolve(run()), watchdog]);
|
|
717
|
+
} finally {
|
|
718
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
678
722
|
/**
|
|
679
723
|
* Inner boot loop. Separated from `#runBoot` so the phase span wrapper
|
|
680
724
|
* stays thin and the loop body — which orchestrates rollback on
|
|
@@ -770,26 +814,33 @@ export class DotAppImpl {
|
|
|
770
814
|
this.#emitHook('boot', pip.name, record.order, 'starting');
|
|
771
815
|
let result: ServiceRecord | void;
|
|
772
816
|
try {
|
|
773
|
-
result = await
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
817
|
+
result = await this.#withHookBudget(pip.name, 'boot', () =>
|
|
818
|
+
withPipHookSpan(
|
|
819
|
+
{
|
|
820
|
+
appName: this.#appName,
|
|
821
|
+
pipName: pip.name,
|
|
822
|
+
pipVersion: pip.version,
|
|
823
|
+
hook: 'boot',
|
|
824
|
+
order: record.order,
|
|
825
|
+
logger: pipLogger,
|
|
826
|
+
},
|
|
827
|
+
// Erasure boundary: hooks are stored as `(ctx: never) => ...`;
|
|
828
|
+
// the kernel is the one caller allowed to cross it.
|
|
829
|
+
() => pip.hooks.boot!(ctx as never),
|
|
830
|
+
),
|
|
785
831
|
);
|
|
786
832
|
} catch (error) {
|
|
833
|
+
const timedOut = error instanceof DotLifecycleError && error.code === DotLifecycleErrorCode.HookTimeout;
|
|
787
834
|
return fail({
|
|
788
835
|
record,
|
|
789
|
-
code: DotLifecycleErrorCode.BootFailed,
|
|
790
|
-
message:
|
|
791
|
-
|
|
792
|
-
|
|
836
|
+
code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.BootFailed,
|
|
837
|
+
message: timedOut
|
|
838
|
+
? error.message
|
|
839
|
+
: `boot hook threw for pip "${pip.name}": ${stringifyError(error)}`,
|
|
840
|
+
remediation: timedOut
|
|
841
|
+
? `Find the hang in the boot() hook of "${pip.name}" — a missing await or a connection that never settles — or raise defineApp's hookTimeoutMs.`
|
|
842
|
+
: `Fix the error in the boot() hook of "${pip.name}". If boot opens partial resources before throwing, clean them up locally — DOT only disposes pips whose boot completed.`,
|
|
843
|
+
docsAnchor: timedOut ? 'hook-timeout' : 'boot-failed',
|
|
793
844
|
durationMs: performance.now() - started,
|
|
794
845
|
cause: error,
|
|
795
846
|
rollback: bootedRecords,
|
|
@@ -917,25 +968,32 @@ export class DotAppImpl {
|
|
|
917
968
|
const startedAt = performance.now();
|
|
918
969
|
this.#emitHook('start', pip.name, record.order, 'starting');
|
|
919
970
|
try {
|
|
920
|
-
await
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
971
|
+
await this.#withHookBudget(pip.name, 'start', () =>
|
|
972
|
+
withPipHookSpan(
|
|
973
|
+
{
|
|
974
|
+
appName: this.#appName,
|
|
975
|
+
pipName: pip.name,
|
|
976
|
+
pipVersion: pip.version,
|
|
977
|
+
hook: 'start',
|
|
978
|
+
order: record.order,
|
|
979
|
+
logger: pipLogger,
|
|
980
|
+
},
|
|
981
|
+
() => pip.hooks.start!(ctx as never),
|
|
982
|
+
),
|
|
930
983
|
);
|
|
931
984
|
} catch (error) {
|
|
932
985
|
const durationMs = performance.now() - startedAt;
|
|
986
|
+
const timedOut = error instanceof DotLifecycleError && error.code === DotLifecycleErrorCode.HookTimeout;
|
|
933
987
|
const issue = makeIssue({
|
|
934
|
-
code: DotLifecycleErrorCode.StartFailed,
|
|
988
|
+
code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.StartFailed,
|
|
935
989
|
pip: pip.name,
|
|
936
|
-
message:
|
|
937
|
-
|
|
938
|
-
|
|
990
|
+
message: timedOut
|
|
991
|
+
? error.message
|
|
992
|
+
: `start hook threw for pip "${pip.name}": ${stringifyError(error)}`,
|
|
993
|
+
remediation: timedOut
|
|
994
|
+
? `Find the hang in the start() hook of "${pip.name}" — or raise defineApp's hookTimeoutMs. DOT rolls back as for any start failure.`
|
|
995
|
+
: `Fix the error in the start() hook of "${pip.name}". DOT will stop all already-started pips and dispose all booted pips in reverse order.`,
|
|
996
|
+
docsAnchor: timedOut ? 'hook-timeout' : 'start-failed',
|
|
939
997
|
});
|
|
940
998
|
record.issues.push(issue);
|
|
941
999
|
record.lifecycleDiagnostics.push({
|
|
@@ -958,7 +1016,7 @@ export class DotAppImpl {
|
|
|
958
1016
|
this.#manifest = this.#buildManifest();
|
|
959
1017
|
const failures = [...stopFailures, ...disposeFailures];
|
|
960
1018
|
throw new DotLifecycleError({
|
|
961
|
-
code: DotLifecycleErrorCode.StartFailed,
|
|
1019
|
+
code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.StartFailed,
|
|
962
1020
|
phase: 'start',
|
|
963
1021
|
pip: pip.name,
|
|
964
1022
|
message: issue.message,
|
|
@@ -1053,16 +1111,18 @@ export class DotAppImpl {
|
|
|
1053
1111
|
const startedAt = performance.now();
|
|
1054
1112
|
this.#emitHook('stop', record.pip.name, record.order, 'starting');
|
|
1055
1113
|
try {
|
|
1056
|
-
await
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1114
|
+
await this.#withHookBudget(record.pip.name, 'stop', () =>
|
|
1115
|
+
withPipHookSpan(
|
|
1116
|
+
{
|
|
1117
|
+
appName: this.#appName,
|
|
1118
|
+
pipName: record.pip.name,
|
|
1119
|
+
pipVersion: record.pip.version,
|
|
1120
|
+
hook: 'stop',
|
|
1121
|
+
order: record.order,
|
|
1122
|
+
logger: pipLogger,
|
|
1123
|
+
},
|
|
1124
|
+
() => record.pip.hooks.stop!(ctx as never),
|
|
1125
|
+
),
|
|
1066
1126
|
);
|
|
1067
1127
|
record.started = false;
|
|
1068
1128
|
const durationMs = performance.now() - startedAt;
|
|
@@ -1265,16 +1325,18 @@ export class DotAppImpl {
|
|
|
1265
1325
|
const startedAt = performance.now();
|
|
1266
1326
|
this.#emitHook('dispose', record.pip.name, record.order, 'starting');
|
|
1267
1327
|
try {
|
|
1268
|
-
await
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1328
|
+
await this.#withHookBudget(record.pip.name, 'dispose', () =>
|
|
1329
|
+
withPipHookSpan(
|
|
1330
|
+
{
|
|
1331
|
+
appName: this.#appName,
|
|
1332
|
+
pipName: record.pip.name,
|
|
1333
|
+
pipVersion: record.pip.version,
|
|
1334
|
+
hook: 'dispose',
|
|
1335
|
+
order: record.order,
|
|
1336
|
+
logger: pipLogger,
|
|
1337
|
+
},
|
|
1338
|
+
() => record.pip.hooks.dispose!(ctx as never),
|
|
1339
|
+
),
|
|
1278
1340
|
);
|
|
1279
1341
|
record.booted = false;
|
|
1280
1342
|
const durationMs = performance.now() - startedAt;
|
package/src/lifecycle.ts
CHANGED
|
@@ -76,6 +76,8 @@ export const DotLifecycleErrorCode = {
|
|
|
76
76
|
ServiceCollision: 'DOT_LIFECYCLE_E013',
|
|
77
77
|
/** A needs alias, publish key, or rename target uses the reserved `$` prefix. */
|
|
78
78
|
ReservedServiceKey: 'DOT_LIFECYCLE_E014',
|
|
79
|
+
/** A lifecycle hook exceeded the app's `hookTimeoutMs` watchdog. */
|
|
80
|
+
HookTimeout: 'DOT_LIFECYCLE_E015',
|
|
79
81
|
} as const;
|
|
80
82
|
|
|
81
83
|
export type DotLifecycleErrorCodeValue = (typeof DotLifecycleErrorCode)[keyof typeof DotLifecycleErrorCode];
|
package/src/signals.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal wiring for graceful shutdown.
|
|
3
|
+
*
|
|
4
|
+
* `hookSignals(app)` connects OS termination signals to the app's
|
|
5
|
+
* lifecycle: on the first SIGTERM/SIGINT the app is disposed (which
|
|
6
|
+
* cascades `stop()` when started — transitions are serialized, so a
|
|
7
|
+
* signal racing a slow `start()` still ends in `disposed`), then the
|
|
8
|
+
* signal is re-raised with the default handler restored so the process
|
|
9
|
+
* terminates with conventional signal semantics.
|
|
10
|
+
*
|
|
11
|
+
* Design points:
|
|
12
|
+
*
|
|
13
|
+
* - **First signal drains, second signal kills.** All handlers are
|
|
14
|
+
* removed the moment the first signal fires, so an impatient second
|
|
15
|
+
* Ctrl-C hits the runtime default (immediate termination) instead of
|
|
16
|
+
* a stuck drain.
|
|
17
|
+
* - **Bounded.** `timeoutMs` caps the drain. On timeout or dispose
|
|
18
|
+
* failure the process still terminates — exit code reflects the
|
|
19
|
+
* failure via the re-raised signal; the error is logged on
|
|
20
|
+
* `arki:dot:signals`.
|
|
21
|
+
* - **No `process.exit()`.** The signal is re-raised via
|
|
22
|
+
* `proc.kill(proc.pid, signal)` so the exit status is the standard
|
|
23
|
+
* 128+n signal encoding — supervisors (systemd, Docker, Coolify)
|
|
24
|
+
* read it correctly.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { Logger } from '@arki/log';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The slice of `process` the signal hook touches. Injectable so tests can
|
|
31
|
+
* drive signals through a fake without terminating the test runner —
|
|
32
|
+
* defaults to the real `process`.
|
|
33
|
+
*/
|
|
34
|
+
export type SignalTarget = {
|
|
35
|
+
readonly pid: number;
|
|
36
|
+
once(event: string, listener: (...args: never[]) => void): unknown;
|
|
37
|
+
off(event: string, listener: (...args: never[]) => void): unknown;
|
|
38
|
+
kill(pid: number, signal?: string): unknown;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type HookSignalsOptions = {
|
|
42
|
+
/** Signals to intercept. Default: `['SIGTERM', 'SIGINT']`. */
|
|
43
|
+
readonly signals?: readonly string[];
|
|
44
|
+
/**
|
|
45
|
+
* Maximum time the drain (`dispose()`) may take before the process is
|
|
46
|
+
* terminated anyway. Default: 10 000 ms.
|
|
47
|
+
*/
|
|
48
|
+
readonly timeoutMs?: number;
|
|
49
|
+
/** Test seam — a process-like target. Default: the real `process`. */
|
|
50
|
+
readonly proc?: SignalTarget;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The app surface the hook needs — satisfied by any `DotApp`. */
|
|
54
|
+
export type Disposable = {
|
|
55
|
+
readonly name: string;
|
|
56
|
+
dispose(): Promise<void>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Wire termination signals to `app.dispose()`. Returns an unhook function
|
|
61
|
+
* that removes the handlers without disposing — call it if the app is
|
|
62
|
+
* torn down through another path first.
|
|
63
|
+
*
|
|
64
|
+
* ```ts
|
|
65
|
+
* const app = await defineApp('shop').use(...).start();
|
|
66
|
+
* hookSignals(app); // SIGTERM/SIGINT → drain → exit
|
|
67
|
+
* hookSignals(app, { timeoutMs: 30_000 }); // slower drain budget
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function hookSignals(app: Disposable, options: HookSignalsOptions = {}): () => void {
|
|
71
|
+
const signals = options.signals ?? ['SIGTERM', 'SIGINT'];
|
|
72
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
73
|
+
const proc: SignalTarget = options.proc ?? (process as unknown as SignalTarget);
|
|
74
|
+
const logger = new Logger('arki:dot:signals', { 'dot.app.name': app.name });
|
|
75
|
+
|
|
76
|
+
const handlers = new Map<string, () => void>();
|
|
77
|
+
|
|
78
|
+
const unhook = (): void => {
|
|
79
|
+
for (const [signal, handler] of handlers) proc.off(signal, handler);
|
|
80
|
+
handlers.clear();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
for (const signal of signals) {
|
|
84
|
+
const handler = (): void => {
|
|
85
|
+
// First signal wins; the rest fall through to runtime defaults.
|
|
86
|
+
unhook();
|
|
87
|
+
logger.info('signal received — draining', { 'dot.signal': signal, 'dot.drain.timeout.ms': timeoutMs });
|
|
88
|
+
void drainAndReraise({ app, signal, timeoutMs, proc, logger });
|
|
89
|
+
};
|
|
90
|
+
handlers.set(signal, handler);
|
|
91
|
+
proc.once(signal, handler);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return unhook;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Dispose with a timeout cap, then re-raise the signal for default handling. */
|
|
98
|
+
async function drainAndReraise(args: {
|
|
99
|
+
app: Disposable;
|
|
100
|
+
signal: string;
|
|
101
|
+
timeoutMs: number;
|
|
102
|
+
proc: SignalTarget;
|
|
103
|
+
logger: Logger;
|
|
104
|
+
}): Promise<void> {
|
|
105
|
+
const { app, signal, timeoutMs, proc, logger } = args;
|
|
106
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
107
|
+
const timedOut = new Promise<'timeout'>(resolve => {
|
|
108
|
+
timer = setTimeout(() => {
|
|
109
|
+
resolve('timeout');
|
|
110
|
+
}, timeoutMs);
|
|
111
|
+
// A pending drain timer must never keep an otherwise-finished process alive.
|
|
112
|
+
if (typeof timer === 'object' && 'unref' in timer) timer.unref();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const outcome = await Promise.race([app.dispose().then(() => 'disposed' as const), timedOut]);
|
|
117
|
+
if (outcome === 'timeout') {
|
|
118
|
+
logger.error('drain timed out — terminating without a clean dispose', {
|
|
119
|
+
'dot.signal': signal,
|
|
120
|
+
'dot.drain.timeout.ms': timeoutMs,
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
logger.info('drain complete', { 'dot.signal': signal });
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
logger.error('dispose failed during drain — terminating', {
|
|
127
|
+
'dot.signal': signal,
|
|
128
|
+
'dot.error.message': error instanceof Error ? error.message : String(error),
|
|
129
|
+
});
|
|
130
|
+
} finally {
|
|
131
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
132
|
+
proc.kill(proc.pid, signal);
|
|
133
|
+
}
|
|
134
|
+
}
|