miravo 0.2.0 → 0.2.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/package.json +5 -5
- package/src/commands/start.ts +69 -61
- package/src/index.ts +26 -2
- package/src/lib/shutdown.test.ts +220 -92
- package/src/lib/shutdown.ts +324 -127
- package/src/logger.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "miravo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Industrial environment platform with canonical asset models and MQTT + OPC UA protocol adapters",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@clack/prompts": "^1.1.0",
|
|
34
|
-
"@miravo/core": "0.2.
|
|
35
|
-
"@miravo/mqtt": "0.2.
|
|
36
|
-
"@miravo/opcua": "0.2.
|
|
37
|
-
"@miravo/shared": "0.2.
|
|
34
|
+
"@miravo/core": "0.2.1",
|
|
35
|
+
"@miravo/mqtt": "0.2.1",
|
|
36
|
+
"@miravo/opcua": "0.2.1",
|
|
37
|
+
"@miravo/shared": "0.2.1",
|
|
38
38
|
"commander": "^13.0.0",
|
|
39
39
|
"consola": "^3.4.0",
|
|
40
40
|
"mqtt": "^5.15.0"
|
package/src/commands/start.ts
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
parsePositiveFloat,
|
|
36
36
|
parsePositiveInt,
|
|
37
37
|
} from "../lib/parse.ts";
|
|
38
|
-
import {
|
|
38
|
+
import { createShutdownController, printSummaryLine } from "../lib/shutdown.ts";
|
|
39
39
|
import {
|
|
40
40
|
groupTemplatesByCategory,
|
|
41
41
|
isStressBenchmarkTemplate,
|
|
@@ -156,8 +156,9 @@ export function registerStartCommand(program: Command): void {
|
|
|
156
156
|
|
|
157
157
|
async function startAction(options: StartOptions): Promise<void> {
|
|
158
158
|
let runtimeLogContextSet = false;
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
let shutdown: ReturnType<typeof createShutdownController> | null = null;
|
|
160
|
+
let engine: Engine | undefined;
|
|
161
|
+
let stopCliResources = (): void => {};
|
|
161
162
|
try {
|
|
162
163
|
const catalog = createCatalog();
|
|
163
164
|
|
|
@@ -307,31 +308,60 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
307
308
|
runtimeLogContextSet = true;
|
|
308
309
|
controlLog.info(`Run ID: ${runId}`);
|
|
309
310
|
|
|
310
|
-
|
|
311
|
-
const engine = createAndPopulateEngine(
|
|
312
|
-
template,
|
|
313
|
-
models,
|
|
311
|
+
shutdown = createShutdownController({
|
|
314
312
|
seed,
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
)
|
|
313
|
+
seedExplicit,
|
|
314
|
+
});
|
|
315
|
+
const shutdownRequested = shutdown.waitForShutdownRequest();
|
|
316
|
+
shutdown.addTask("finalize", "runtime log context", () => {
|
|
317
|
+
if (runtimeLogContextSet) {
|
|
318
|
+
clearRuntimeLogContext();
|
|
319
|
+
runtimeLogContextSet = false;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Step 4: Create engine and populate
|
|
324
|
+
engine = createAndPopulateEngine(template, models, seed, options, templatePath, simulationName);
|
|
325
|
+
shutdown.setEngine(engine);
|
|
326
|
+
|
|
327
|
+
let controlPlane: ControlPlane | null = null;
|
|
328
|
+
let adminServer: { endpoint: string; stop: () => void } | null = null;
|
|
329
|
+
let lastTickProgressAt = performance.now();
|
|
330
|
+
let tickProgressSubscribed = false;
|
|
331
|
+
const onTickProgress = (): void => {
|
|
332
|
+
lastTickProgressAt = performance.now();
|
|
333
|
+
};
|
|
334
|
+
let unsubscribeEvents = (): void => {};
|
|
335
|
+
let summaryTimer: ReturnType<typeof setInterval> | null = null;
|
|
336
|
+
|
|
337
|
+
stopCliResources = (): void => {
|
|
338
|
+
controlPlane?.stop();
|
|
339
|
+
adminServer?.stop();
|
|
340
|
+
if (summaryTimer !== null) {
|
|
341
|
+
clearInterval(summaryTimer);
|
|
342
|
+
summaryTimer = null;
|
|
343
|
+
}
|
|
344
|
+
if (tickProgressSubscribed && engine) {
|
|
345
|
+
engine.bus.off("tick:complete", onTickProgress);
|
|
346
|
+
tickProgressSubscribed = false;
|
|
347
|
+
}
|
|
348
|
+
unsubscribeEvents();
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
shutdown.addTask("cleanup", "CLI resources", stopCliResources);
|
|
319
352
|
|
|
320
353
|
// Step 5: Start MQTT adapter (unless --no-mqtt)
|
|
321
354
|
const mqttStartResult = mqttEnabled
|
|
322
355
|
? await startMQTTAdapter(engine, template, portOption, mqttOverrides, simulationName)
|
|
323
356
|
: null;
|
|
324
357
|
const adapter = mqttStartResult?.adapter ?? null;
|
|
325
|
-
if (adapter) pendingAdapterStops.push(() => adapter.stop());
|
|
326
358
|
|
|
327
359
|
// Step 5a: Start OPC UA adapter (only when --opcua)
|
|
328
360
|
const opcuaResult = opcuaEnabled
|
|
329
361
|
? await startOPCUAAdapter(engine, template, opcuaPort, opcuaHost, simulationName)
|
|
330
362
|
: null;
|
|
331
|
-
if (opcuaResult) pendingAdapterStops.push(() => opcuaResult.adapter.stop());
|
|
332
363
|
|
|
333
364
|
// Step 5b: Wire control plane
|
|
334
|
-
let controlPlane: ControlPlane | null = null;
|
|
335
365
|
if (adapter) {
|
|
336
366
|
const transport = adapter.getControlTransport();
|
|
337
367
|
if (transport) {
|
|
@@ -347,12 +377,8 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
347
377
|
}
|
|
348
378
|
|
|
349
379
|
// Step 5c: Start admin HTTP server
|
|
350
|
-
let adminServer: { endpoint: string; stop: () => void } | null = null;
|
|
351
|
-
let lastTickProgressAt = performance.now();
|
|
352
|
-
const onTickProgress = (): void => {
|
|
353
|
-
lastTickProgressAt = performance.now();
|
|
354
|
-
};
|
|
355
380
|
engine.bus.on("tick:complete", onTickProgress);
|
|
381
|
+
tickProgressSubscribed = true;
|
|
356
382
|
try {
|
|
357
383
|
adminServer = startAdminServer({
|
|
358
384
|
engine,
|
|
@@ -367,6 +393,7 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
367
393
|
});
|
|
368
394
|
controlLog.info(`admin server ready · endpoint: ${colors.cyan(adminServer.endpoint)}`);
|
|
369
395
|
} catch (error) {
|
|
396
|
+
tickProgressSubscribed = false;
|
|
370
397
|
engine.bus.off("tick:complete", onTickProgress);
|
|
371
398
|
const message = error instanceof Error ? error.message : String(error);
|
|
372
399
|
throw new MiravoError(
|
|
@@ -390,46 +417,30 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
390
417
|
|
|
391
418
|
// Step 7: Subscribe to events + periodic summary
|
|
392
419
|
const errorContextCounts = new Map<string, number>();
|
|
393
|
-
|
|
420
|
+
unsubscribeEvents = subscribeToEvents(engine, errorContextCounts);
|
|
421
|
+
shutdown.setErrorContextCounts(errorContextCounts);
|
|
394
422
|
const summaryInterval = options.summaryInterval ?? 5;
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
// Step 8: Install shutdown handler and start
|
|
398
|
-
const teardownShutdown = installShutdownHandlers({
|
|
399
|
-
engine,
|
|
400
|
-
seed,
|
|
401
|
-
seedExplicit,
|
|
402
|
-
cleanup: () => {
|
|
403
|
-
controlPlane?.stop();
|
|
404
|
-
adminServer?.stop();
|
|
405
|
-
engine.bus.off("tick:complete", onTickProgress);
|
|
406
|
-
clearInterval(summaryTimer);
|
|
407
|
-
unsubscribeEvents();
|
|
408
|
-
clearRuntimeLogContext();
|
|
409
|
-
runtimeLogContextSet = false;
|
|
410
|
-
},
|
|
411
|
-
errorContextCounts,
|
|
412
|
-
});
|
|
423
|
+
summaryTimer = startPeriodicSummary(engine, summaryInterval);
|
|
413
424
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
} catch (error) {
|
|
417
|
-
teardownShutdown();
|
|
418
|
-
controlPlane?.stop();
|
|
419
|
-
adminServer?.stop();
|
|
420
|
-
engine.bus.off("tick:complete", onTickProgress);
|
|
421
|
-
clearInterval(summaryTimer);
|
|
422
|
-
unsubscribeEvents();
|
|
423
|
-
clearRuntimeLogContext();
|
|
424
|
-
runtimeLogContextSet = false;
|
|
425
|
-
throw error;
|
|
426
|
-
}
|
|
427
|
-
// Engine running — shutdown handlers now own adapter lifecycle
|
|
428
|
-
pendingAdapterStops.length = 0;
|
|
425
|
+
engine.start();
|
|
426
|
+
shutdown.markRunning();
|
|
429
427
|
engineLog.success("Simulation started.");
|
|
428
|
+
await shutdownRequested;
|
|
429
|
+
await shutdown.runShutdown();
|
|
430
|
+
shutdown.teardown();
|
|
430
431
|
} catch (error) {
|
|
431
|
-
|
|
432
|
-
|
|
432
|
+
if (shutdown?.isShuttingDown()) {
|
|
433
|
+
await shutdown.runShutdown();
|
|
434
|
+
shutdown.teardown();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
shutdown?.teardown();
|
|
438
|
+
stopCliResources();
|
|
439
|
+
try {
|
|
440
|
+
await engine?.stop("boot error");
|
|
441
|
+
} catch {
|
|
442
|
+
// engine may not exist yet or stop may fail — swallow
|
|
443
|
+
}
|
|
433
444
|
if (runtimeLogContextSet) {
|
|
434
445
|
clearRuntimeLogContext();
|
|
435
446
|
runtimeLogContextSet = false;
|
|
@@ -826,9 +837,7 @@ async function startMQTTAdapter(
|
|
|
826
837
|
...(mqttConfig?.metadata !== undefined ? { metadata: mqttConfig.metadata } : {}),
|
|
827
838
|
};
|
|
828
839
|
|
|
829
|
-
await adapter.start(
|
|
830
|
-
...resolvedConfig,
|
|
831
|
-
});
|
|
840
|
+
await adapter.start(resolvedConfig);
|
|
832
841
|
engine.registerAdapter(adapter);
|
|
833
842
|
mqttLog.ready(`connected · broker: ${colors.cyan(endpoint)}`);
|
|
834
843
|
return { adapter, endpoint };
|
|
@@ -861,7 +870,6 @@ async function startOPCUAAdapter(
|
|
|
861
870
|
opcuaLog.debug(`starting adapter · endpoint: opc.tcp://${host}:${port}`);
|
|
862
871
|
|
|
863
872
|
const opcuaAdapter = new OPCUAAdapter(engine.bus, engine);
|
|
864
|
-
|
|
865
873
|
try {
|
|
866
874
|
const resolvedConfig = {
|
|
867
875
|
name: "opcua" as const,
|
|
@@ -1009,7 +1017,7 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
|
|
|
1009
1017
|
};
|
|
1010
1018
|
|
|
1011
1019
|
const onWarn = (event: { message: string; context: string }): void => {
|
|
1012
|
-
engineLog.
|
|
1020
|
+
engineLog.debug(event.message, { context: event.context });
|
|
1013
1021
|
};
|
|
1014
1022
|
|
|
1015
1023
|
const onError = (event: { context: string; error: Error }): void => {
|
|
@@ -1068,7 +1076,7 @@ function startPeriodicSummary(
|
|
|
1068
1076
|
): ReturnType<typeof setInterval> {
|
|
1069
1077
|
return setInterval(() => {
|
|
1070
1078
|
const metrics = engine.getMetrics();
|
|
1071
|
-
|
|
1079
|
+
printSummaryLine(metrics);
|
|
1072
1080
|
}, intervalSeconds * 1_000);
|
|
1073
1081
|
}
|
|
1074
1082
|
|
package/src/index.ts
CHANGED
|
@@ -80,11 +80,35 @@ export function createProgram(): Command {
|
|
|
80
80
|
|
|
81
81
|
// Only run when executed directly, not when imported in tests
|
|
82
82
|
if (import.meta.path === Bun.main) {
|
|
83
|
-
//
|
|
83
|
+
// In compiled binaries, extract embedded content to a temp directory so
|
|
84
|
+
// ContentCatalog's filesystem-based discovery can find built-in models/templates.
|
|
84
85
|
// The manifest only exists during `bun run build:standalone`; the catch is a no-op in dev.
|
|
85
86
|
try {
|
|
86
87
|
// @ts-expect-error — file is generated at build time and does not exist in source tree
|
|
87
|
-
await import("./_embedded-manifest.ts");
|
|
88
|
+
const manifest = await import("./_embedded-manifest.ts");
|
|
89
|
+
const { modelPaths, templatePaths } = manifest as {
|
|
90
|
+
modelPaths: string[];
|
|
91
|
+
templatePaths: string[];
|
|
92
|
+
};
|
|
93
|
+
const { basename, join } = await import("node:path");
|
|
94
|
+
const { tmpdir } = await import("node:os");
|
|
95
|
+
const { mkdirSync } = await import("node:fs");
|
|
96
|
+
const { registerEmbeddedContentDir } = await import("@miravo/core");
|
|
97
|
+
|
|
98
|
+
const contentDir = join(tmpdir(), "miravo-embedded");
|
|
99
|
+
const modelsDir = join(contentDir, "models");
|
|
100
|
+
const templatesDir = join(contentDir, "templates");
|
|
101
|
+
mkdirSync(modelsDir, { recursive: true });
|
|
102
|
+
mkdirSync(templatesDir, { recursive: true });
|
|
103
|
+
|
|
104
|
+
for (const p of modelPaths) {
|
|
105
|
+
await Bun.write(join(modelsDir, basename(p)), Bun.file(p));
|
|
106
|
+
}
|
|
107
|
+
for (const p of templatePaths) {
|
|
108
|
+
await Bun.write(join(templatesDir, basename(p)), Bun.file(p));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
registerEmbeddedContentDir(contentDir);
|
|
88
112
|
} catch {
|
|
89
113
|
// Not a compiled binary or manifest not present — expected in dev mode
|
|
90
114
|
}
|
package/src/lib/shutdown.test.ts
CHANGED
|
@@ -11,9 +11,52 @@ import {
|
|
|
11
11
|
lockLogger,
|
|
12
12
|
logger,
|
|
13
13
|
mqttLog,
|
|
14
|
+
opcuaLog,
|
|
14
15
|
unlockLogger,
|
|
15
16
|
} from "../logger.ts";
|
|
16
|
-
import {
|
|
17
|
+
import { createShutdownController } from "./shutdown.ts";
|
|
18
|
+
|
|
19
|
+
const noopReporter = { log() {} };
|
|
20
|
+
|
|
21
|
+
function configureSummaryLogger(
|
|
22
|
+
options: { plain?: boolean; logFormat?: "pretty" | "json" } = {},
|
|
23
|
+
): void {
|
|
24
|
+
unlockLogger();
|
|
25
|
+
configureLogger({
|
|
26
|
+
plain: options.plain ?? false,
|
|
27
|
+
logFormat: options.logFormat ?? "pretty",
|
|
28
|
+
});
|
|
29
|
+
for (const l of [
|
|
30
|
+
logger,
|
|
31
|
+
engineLog,
|
|
32
|
+
mqttLog,
|
|
33
|
+
opcuaLog,
|
|
34
|
+
configLog,
|
|
35
|
+
lifecycleLog,
|
|
36
|
+
faultLog,
|
|
37
|
+
controlLog,
|
|
38
|
+
]) {
|
|
39
|
+
l.level = -999;
|
|
40
|
+
l.setReporters([noopReporter]);
|
|
41
|
+
}
|
|
42
|
+
lockLogger();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setStdoutIsTTY(value: boolean): () => void {
|
|
46
|
+
const descriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
|
47
|
+
Object.defineProperty(process.stdout, "isTTY", {
|
|
48
|
+
configurable: true,
|
|
49
|
+
value,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return () => {
|
|
53
|
+
if (descriptor) {
|
|
54
|
+
Object.defineProperty(process.stdout, "isTTY", descriptor);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
delete (process.stdout as { isTTY?: boolean }).isTTY;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
17
60
|
|
|
18
61
|
function createMockEngine(): Engine & { stopCalled: boolean; stopReason: string | undefined } {
|
|
19
62
|
const metrics: EngineMetricsSnapshot = {
|
|
@@ -53,31 +96,33 @@ function createMockEngine(): Engine & { stopCalled: boolean; stopReason: string
|
|
|
53
96
|
return mock;
|
|
54
97
|
}
|
|
55
98
|
|
|
56
|
-
describe("
|
|
57
|
-
|
|
99
|
+
describe("createShutdownController", () => {
|
|
100
|
+
const teardowns: Array<() => void> = [];
|
|
58
101
|
|
|
59
102
|
afterEach(() => {
|
|
60
|
-
|
|
61
|
-
|
|
103
|
+
while (teardowns.length > 0) {
|
|
104
|
+
teardowns.pop()?.();
|
|
105
|
+
}
|
|
106
|
+
muteAllLoggers();
|
|
62
107
|
});
|
|
63
108
|
|
|
64
109
|
test("first SIGINT calls engine.stop and exit(0)", async () => {
|
|
65
110
|
const engine = createMockEngine();
|
|
66
111
|
let exitCode: number | undefined;
|
|
67
112
|
|
|
68
|
-
|
|
69
|
-
engine,
|
|
113
|
+
const controller = createShutdownController({
|
|
70
114
|
seed: 42,
|
|
71
115
|
seedExplicit: true,
|
|
72
116
|
exit: (code) => {
|
|
73
117
|
exitCode = code;
|
|
74
118
|
},
|
|
75
119
|
});
|
|
120
|
+
teardowns.push(controller.teardown);
|
|
121
|
+
controller.setEngine(engine);
|
|
76
122
|
|
|
77
123
|
process.emit("SIGINT");
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
124
|
+
await controller.waitForShutdownRequest();
|
|
125
|
+
await controller.runShutdown();
|
|
81
126
|
|
|
82
127
|
expect(engine.stopCalled).toBe(true);
|
|
83
128
|
expect(engine.stopReason).toBe("user interrupt");
|
|
@@ -88,153 +133,236 @@ describe("installShutdownHandlers", () => {
|
|
|
88
133
|
const engine = createMockEngine();
|
|
89
134
|
const exitCodes: number[] = [];
|
|
90
135
|
|
|
91
|
-
// Make stop() slow so we can fire a second signal during shutdown
|
|
92
136
|
(engine as unknown as Record<string, unknown>)["stop"] = () =>
|
|
93
137
|
new Promise((resolve) => setTimeout(resolve, 200));
|
|
94
138
|
|
|
95
|
-
|
|
96
|
-
engine,
|
|
139
|
+
const controller = createShutdownController({
|
|
97
140
|
seed: 42,
|
|
98
|
-
seedExplicit:
|
|
141
|
+
seedExplicit: true,
|
|
99
142
|
exit: (code) => {
|
|
100
143
|
exitCodes.push(code);
|
|
101
144
|
},
|
|
102
145
|
});
|
|
146
|
+
teardowns.push(controller.teardown);
|
|
147
|
+
controller.setEngine(engine);
|
|
103
148
|
|
|
104
|
-
// First signal starts shutdown
|
|
105
149
|
process.emit("SIGINT");
|
|
106
|
-
|
|
150
|
+
await controller.waitForShutdownRequest();
|
|
151
|
+
const shutdownPromise = controller.runShutdown();
|
|
107
152
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
108
|
-
// Second signal should force exit
|
|
109
153
|
process.emit("SIGINT");
|
|
110
|
-
|
|
111
|
-
await
|
|
154
|
+
await controller.completion;
|
|
155
|
+
await shutdownPromise;
|
|
112
156
|
|
|
113
157
|
expect(exitCodes).toContain(1);
|
|
114
158
|
});
|
|
115
159
|
|
|
116
|
-
test("
|
|
160
|
+
test("shutdown request is inert until the owner runs shutdown", async () => {
|
|
117
161
|
const engine = createMockEngine();
|
|
118
|
-
let
|
|
162
|
+
let stopCallCount = 0;
|
|
119
163
|
|
|
120
|
-
// Make stop() throw
|
|
121
164
|
(engine as unknown as Record<string, unknown>)["stop"] = async () => {
|
|
122
|
-
|
|
165
|
+
stopCallCount++;
|
|
123
166
|
};
|
|
124
167
|
|
|
125
|
-
|
|
126
|
-
engine,
|
|
168
|
+
const controller = createShutdownController({
|
|
127
169
|
seed: 1,
|
|
128
170
|
seedExplicit: true,
|
|
129
|
-
exit: (
|
|
130
|
-
exitCode = code;
|
|
131
|
-
},
|
|
171
|
+
exit: () => {},
|
|
132
172
|
});
|
|
173
|
+
teardowns.push(controller.teardown);
|
|
174
|
+
controller.setEngine(engine);
|
|
133
175
|
|
|
134
176
|
process.emit("SIGINT");
|
|
135
|
-
await
|
|
177
|
+
await controller.waitForShutdownRequest();
|
|
136
178
|
|
|
137
|
-
expect(
|
|
179
|
+
expect(stopCallCount).toBe(0);
|
|
180
|
+
|
|
181
|
+
await controller.runShutdown();
|
|
182
|
+
|
|
183
|
+
expect(stopCallCount).toBe(1);
|
|
138
184
|
});
|
|
139
185
|
|
|
140
|
-
test("
|
|
186
|
+
test("teardown removes signal handlers", async () => {
|
|
141
187
|
const engine = createMockEngine();
|
|
142
|
-
let stopCallCount = 0;
|
|
143
|
-
|
|
144
|
-
(engine as unknown as Record<string, unknown>)["stop"] = async () => {
|
|
145
|
-
stopCallCount++;
|
|
146
|
-
};
|
|
147
188
|
|
|
148
|
-
|
|
149
|
-
engine,
|
|
189
|
+
const controller = createShutdownController({
|
|
150
190
|
seed: 1,
|
|
151
191
|
seedExplicit: true,
|
|
152
192
|
exit: () => {},
|
|
153
193
|
});
|
|
194
|
+
controller.setEngine(engine);
|
|
195
|
+
controller.teardown();
|
|
154
196
|
|
|
155
|
-
// Fire both signals nearly simultaneously
|
|
156
197
|
process.emit("SIGINT");
|
|
157
|
-
process.emit("SIGTERM");
|
|
158
198
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
159
199
|
|
|
160
|
-
|
|
161
|
-
expect(stopCallCount).toBe(1);
|
|
200
|
+
expect(engine.stopCalled).toBe(false);
|
|
162
201
|
});
|
|
163
202
|
|
|
164
|
-
test("
|
|
203
|
+
test("runs cleanup before summary and exit", async () => {
|
|
165
204
|
const engine = createMockEngine();
|
|
205
|
+
const steps: string[] = [];
|
|
206
|
+
configureSummaryLogger({ plain: false, logFormat: "pretty" });
|
|
166
207
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
seed: 1,
|
|
208
|
+
const controller = createShutdownController({
|
|
209
|
+
seed: 42,
|
|
170
210
|
seedExplicit: true,
|
|
171
|
-
|
|
211
|
+
writeFinalOutput: async () => {
|
|
212
|
+
steps.push("summary");
|
|
213
|
+
},
|
|
214
|
+
exit: () => {
|
|
215
|
+
steps.push("exit");
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
teardowns.push(controller.teardown);
|
|
219
|
+
controller.setEngine(engine);
|
|
220
|
+
controller.addTask("cleanup", "cleanup", async () => {
|
|
221
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
222
|
+
steps.push("cleanup");
|
|
223
|
+
});
|
|
224
|
+
controller.addTask("finalize", "finalize", () => {
|
|
225
|
+
steps.push("finalize");
|
|
172
226
|
});
|
|
173
227
|
|
|
174
|
-
// Remove handlers
|
|
175
|
-
teardown();
|
|
176
|
-
teardown = undefined;
|
|
177
|
-
|
|
178
|
-
// Fire SIGINT after teardown — our handler should NOT run.
|
|
179
|
-
// If handlers weren't removed, the mock engine would get stopped.
|
|
180
228
|
process.emit("SIGINT");
|
|
181
|
-
await
|
|
182
|
-
|
|
229
|
+
await controller.waitForShutdownRequest();
|
|
230
|
+
await controller.runShutdown();
|
|
231
|
+
|
|
232
|
+
expect(steps).toEqual(["cleanup", "summary", "finalize", "exit"]);
|
|
183
233
|
});
|
|
184
234
|
|
|
185
|
-
test("
|
|
235
|
+
test("cleanup failure does not suppress the shutdown summary", async () => {
|
|
186
236
|
const engine = createMockEngine();
|
|
187
237
|
let exitCode: number | undefined;
|
|
188
|
-
const
|
|
189
|
-
const calls: string[] = [];
|
|
190
|
-
console.log = (...args: unknown[]) => {
|
|
191
|
-
calls.push(args.map((arg) => String(arg)).join(" "));
|
|
192
|
-
};
|
|
238
|
+
const renders: string[] = [];
|
|
193
239
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
240
|
+
configureSummaryLogger({ plain: false, logFormat: "pretty" });
|
|
241
|
+
|
|
242
|
+
const controller = createShutdownController({
|
|
243
|
+
seed: 42,
|
|
244
|
+
seedExplicit: true,
|
|
245
|
+
writeFinalOutput: async (text) => {
|
|
246
|
+
renders.push(text);
|
|
247
|
+
},
|
|
248
|
+
exit: (code) => {
|
|
249
|
+
exitCode = code;
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
teardowns.push(controller.teardown);
|
|
253
|
+
controller.setEngine(engine);
|
|
254
|
+
controller.addTask("cleanup", "cleanup", () => {
|
|
255
|
+
throw new Error("cleanup failed");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
process.emit("SIGINT");
|
|
259
|
+
await controller.waitForShutdownRequest();
|
|
260
|
+
await controller.runShutdown();
|
|
261
|
+
|
|
262
|
+
expect(exitCode).toBe(0);
|
|
263
|
+
expect(renders).toHaveLength(1);
|
|
264
|
+
expect(renders[0]?.includes("Shutdown Summary")).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("renders the rounded shutdown box in pretty mode even when stdout is not a TTY", async () => {
|
|
268
|
+
const engine = createMockEngine();
|
|
269
|
+
let exitCode: number | undefined;
|
|
270
|
+
const renders: string[] = [];
|
|
271
|
+
const restoreTTY = setStdoutIsTTY(false);
|
|
272
|
+
configureSummaryLogger({ plain: false, logFormat: "pretty" });
|
|
205
273
|
|
|
206
274
|
try {
|
|
207
|
-
|
|
208
|
-
engine,
|
|
275
|
+
const controller = createShutdownController({
|
|
209
276
|
seed: 42,
|
|
210
277
|
seedExplicit: true,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
["mqtt:backpressure", 5],
|
|
215
|
-
["mqtt:reconnect", 3],
|
|
216
|
-
['circuit-breaker instance="pump-001"', 2],
|
|
217
|
-
["mqtt:client", 1],
|
|
218
|
-
]),
|
|
278
|
+
writeFinalOutput: async (text) => {
|
|
279
|
+
renders.push(text);
|
|
280
|
+
},
|
|
219
281
|
exit: (code) => {
|
|
220
282
|
exitCode = code;
|
|
221
283
|
},
|
|
222
284
|
});
|
|
285
|
+
teardowns.push(controller.teardown);
|
|
286
|
+
controller.setEngine(engine);
|
|
223
287
|
|
|
224
288
|
process.emit("SIGINT");
|
|
225
|
-
await
|
|
289
|
+
await controller.waitForShutdownRequest();
|
|
290
|
+
await controller.runShutdown();
|
|
226
291
|
} finally {
|
|
227
|
-
|
|
228
|
-
muteAllLoggers();
|
|
292
|
+
restoreTTY();
|
|
229
293
|
}
|
|
230
294
|
|
|
231
295
|
expect(exitCode).toBe(0);
|
|
232
|
-
expect(
|
|
233
|
-
expect(
|
|
234
|
-
expect(
|
|
235
|
-
expect(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
296
|
+
expect(renders).toHaveLength(1);
|
|
297
|
+
expect(renders[0]?.includes("Shutdown Summary")).toBe(true);
|
|
298
|
+
expect(renders[0]?.includes("╭")).toBe(true);
|
|
299
|
+
expect(renders[0]?.includes("╰")).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("keeps shutdown output plain when --plain is enabled", async () => {
|
|
303
|
+
const engine = createMockEngine();
|
|
304
|
+
const renders: string[] = [];
|
|
305
|
+
configureSummaryLogger({ plain: true, logFormat: "pretty" });
|
|
306
|
+
|
|
307
|
+
const controller = createShutdownController({
|
|
308
|
+
seed: 42,
|
|
309
|
+
seedExplicit: true,
|
|
310
|
+
writeFinalOutput: async (text) => {
|
|
311
|
+
renders.push(text);
|
|
312
|
+
},
|
|
313
|
+
exit: () => {},
|
|
314
|
+
});
|
|
315
|
+
teardowns.push(controller.teardown);
|
|
316
|
+
controller.setEngine(engine);
|
|
317
|
+
|
|
318
|
+
process.emit("SIGINT");
|
|
319
|
+
await controller.waitForShutdownRequest();
|
|
320
|
+
await controller.runShutdown();
|
|
321
|
+
|
|
322
|
+
expect(renders).toHaveLength(1);
|
|
323
|
+
expect(renders[0]?.startsWith("Shutdown Summary\n")).toBe(true);
|
|
324
|
+
expect(renders[0]?.includes("╭")).toBe(false);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("prints top 5 error contexts in shutdown summary", async () => {
|
|
328
|
+
const engine = createMockEngine();
|
|
329
|
+
let exitCode: number | undefined;
|
|
330
|
+
const renders: string[] = [];
|
|
331
|
+
configureSummaryLogger({ plain: true, logFormat: "pretty" });
|
|
332
|
+
|
|
333
|
+
const controller = createShutdownController({
|
|
334
|
+
seed: 42,
|
|
335
|
+
seedExplicit: true,
|
|
336
|
+
errorContextCounts: new Map([
|
|
337
|
+
["mqtt:publish", 9],
|
|
338
|
+
["tick-overrun", 7],
|
|
339
|
+
["mqtt:backpressure", 5],
|
|
340
|
+
["mqtt:reconnect", 3],
|
|
341
|
+
['circuit-breaker instance="pump-001"', 2],
|
|
342
|
+
["mqtt:client", 1],
|
|
343
|
+
]),
|
|
344
|
+
writeFinalOutput: async (text) => {
|
|
345
|
+
renders.push(text);
|
|
346
|
+
},
|
|
347
|
+
exit: (code) => {
|
|
348
|
+
exitCode = code;
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
teardowns.push(controller.teardown);
|
|
352
|
+
controller.setEngine(engine);
|
|
353
|
+
|
|
354
|
+
process.emit("SIGINT");
|
|
355
|
+
await controller.waitForShutdownRequest();
|
|
356
|
+
await controller.runShutdown();
|
|
357
|
+
|
|
358
|
+
const output = renders[0] ?? "";
|
|
359
|
+
expect(exitCode).toBe(0);
|
|
360
|
+
expect(output.includes("Top errors")).toBe(true);
|
|
361
|
+
expect(output.includes("mqtt:publish")).toBe(true);
|
|
362
|
+
expect(output.includes("tick-overrun")).toBe(true);
|
|
363
|
+
expect(output.includes("mqtt:backpressure")).toBe(true);
|
|
364
|
+
expect(output.includes("mqtt:reconnect")).toBe(true);
|
|
365
|
+
expect(output.includes('circuit-breaker instance="pump-001"')).toBe(true);
|
|
366
|
+
expect(output.includes("mqtt:client")).toBe(false);
|
|
239
367
|
});
|
|
240
368
|
});
|
package/src/lib/shutdown.ts
CHANGED
|
@@ -1,124 +1,245 @@
|
|
|
1
1
|
import type { Engine, EngineMetricsSnapshot } from "@miravo/core";
|
|
2
2
|
import type { AdapterHealthSnapshot } from "@miravo/shared";
|
|
3
3
|
import type { TreeItem } from "consola/utils";
|
|
4
|
-
import { box, colors, formatTree } from "consola/utils";
|
|
5
|
-
import { engineLog, isJsonLogFormat, isPlain, isSilent,
|
|
4
|
+
import { box, colors, formatTree, stripAnsi } from "consola/utils";
|
|
5
|
+
import { engineLog, isJsonLogFormat, isPlain, isSilent, mqttLog, opcuaLog } from "../logger.ts";
|
|
6
6
|
import { computeAverageThroughput, formatCount, formatDuration } from "../output.ts";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
const SHUTDOWN_TASK_TIMEOUT_MS = 6_000;
|
|
9
|
+
const FINAL_OUTPUT_GRACE_MS = 50;
|
|
10
|
+
|
|
11
|
+
type ShutdownTaskStage = "cleanup" | "finalize";
|
|
12
|
+
type ShutdownPhase = "booting" | "running" | "shutting_down" | "forced_exit" | "exited";
|
|
13
|
+
|
|
14
|
+
interface ShutdownTask {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly run: () => void | Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Parameters for the shutdown controller. Supports dependency injection for tests. */
|
|
20
|
+
export interface ShutdownControllerOptions {
|
|
12
21
|
/** The RNG seed used for this run (printed in summary for reproducibility). */
|
|
13
22
|
readonly seed: number;
|
|
14
23
|
/** Whether the seed was explicitly configured (vs. randomly generated). */
|
|
15
24
|
readonly seedExplicit: boolean;
|
|
16
|
-
/** Optional cleanup callback for timers, intervals, etc. */
|
|
17
|
-
readonly cleanup?: () => void;
|
|
18
25
|
/** Optional aggregated engine-error counts by context. */
|
|
19
26
|
readonly errorContextCounts?: ReadonlyMap<string, number>;
|
|
20
27
|
/** Process exit function. Defaults to process.exit. Inject for testing. */
|
|
21
28
|
readonly exit?: (code: number) => void;
|
|
29
|
+
/** Optional writer override for the final shutdown summary render. */
|
|
30
|
+
readonly writeFinalOutput?: (text: string) => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ShutdownController {
|
|
34
|
+
readonly completion: Promise<void>;
|
|
35
|
+
addTask(stage: ShutdownTaskStage, name: string, run: () => void | Promise<void>): void;
|
|
36
|
+
isShuttingDown(): boolean;
|
|
37
|
+
markRunning(): void;
|
|
38
|
+
requestShutdown(reason: string): void;
|
|
39
|
+
runShutdown(): Promise<void>;
|
|
40
|
+
setEngine(engine: Engine): void;
|
|
41
|
+
setErrorContextCounts(counts: ReadonlyMap<string, number>): void;
|
|
42
|
+
teardown(): void;
|
|
43
|
+
waitForShutdownRequest(): Promise<string>;
|
|
22
44
|
}
|
|
23
45
|
|
|
24
46
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* - First signal: stop engine, drain adapters, print summary, exit 0.
|
|
28
|
-
* - Second signal during shutdown: force exit immediately with exit 1.
|
|
47
|
+
* Create a single-owner shutdown controller for `miravo start`.
|
|
29
48
|
*
|
|
30
|
-
*
|
|
49
|
+
* Signals are installed immediately. The controller owns the full shutdown
|
|
50
|
+
* lifecycle: pre-stop cleanup, engine stop, post-stop cleanup, final summary,
|
|
51
|
+
* and process exit. Callers should await `completion` to keep the command alive
|
|
52
|
+
* until shutdown fully finishes.
|
|
31
53
|
*/
|
|
32
|
-
export function
|
|
33
|
-
const { engine, seed, seedExplicit, cleanup, errorContextCounts } = options;
|
|
54
|
+
export function createShutdownController(options: ShutdownControllerOptions): ShutdownController {
|
|
34
55
|
const exit = options.exit ?? ((code: number) => process.exit(code));
|
|
56
|
+
const writeFinalOutput = options.writeFinalOutput ?? writeShutdownOutput;
|
|
57
|
+
|
|
58
|
+
let phase: ShutdownPhase = "booting";
|
|
59
|
+
let forcedExitRequested = false;
|
|
60
|
+
let engine: Engine | null = null;
|
|
61
|
+
let errorContextCounts = options.errorContextCounts;
|
|
62
|
+
let requestedReason: string | null = null;
|
|
63
|
+
let shutdownPromise: Promise<void> | null = null;
|
|
64
|
+
|
|
65
|
+
const tasks: Record<ShutdownTaskStage, ShutdownTask[]> = {
|
|
66
|
+
cleanup: [],
|
|
67
|
+
finalize: [],
|
|
68
|
+
};
|
|
35
69
|
|
|
36
|
-
let
|
|
37
|
-
|
|
70
|
+
let resolveCompletion = () => {};
|
|
71
|
+
const completion = new Promise<void>((resolve) => {
|
|
72
|
+
resolveCompletion = resolve;
|
|
73
|
+
});
|
|
74
|
+
let resolveRequest = (_reason: string) => {};
|
|
75
|
+
const request = new Promise<string>((resolve) => {
|
|
76
|
+
resolveRequest = resolve;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const keepAlive = setInterval(() => {}, 60_000);
|
|
80
|
+
const forceSignalHandler = (): void => {
|
|
81
|
+
forceExit();
|
|
82
|
+
};
|
|
38
83
|
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
41
|
-
|
|
84
|
+
const forceExit = (): void => {
|
|
85
|
+
if (phase === "forced_exit" || phase === "exited") return;
|
|
86
|
+
forcedExitRequested = true;
|
|
87
|
+
phase = "forced_exit";
|
|
88
|
+
clearInterval(keepAlive);
|
|
42
89
|
engineLog.warn("Force stopping. Goodbye.");
|
|
90
|
+
resolveCompletion();
|
|
43
91
|
exit(1);
|
|
44
92
|
};
|
|
45
93
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
if (shuttingDown) {
|
|
49
|
-
forceExitHandler();
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
shuttingDown = true;
|
|
53
|
-
|
|
54
|
-
engineLog.start("Stopping simulation...");
|
|
55
|
-
|
|
56
|
-
// Capture metrics before stop() clears bus listeners
|
|
57
|
-
const finalMetrics = engine.getMetrics();
|
|
58
|
-
|
|
59
|
-
let timedOut = false;
|
|
60
|
-
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
94
|
+
const runWithTimeout = async (name: string, run: () => void | Promise<void>): Promise<void> => {
|
|
95
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
61
96
|
try {
|
|
62
|
-
engineLog.start("Flushing pending messages (5s timeout)...");
|
|
63
|
-
const stopResult = engine.stop(signal === "SIGINT" ? "user interrupt" : signal);
|
|
64
|
-
// Suppress unhandled rejection if stop() rejects after timeout wins the race
|
|
65
|
-
stopResult.catch(() => {});
|
|
66
97
|
await Promise.race([
|
|
67
|
-
|
|
98
|
+
Promise.resolve(run()),
|
|
68
99
|
new Promise<void>((resolve) => {
|
|
69
|
-
|
|
70
|
-
|
|
100
|
+
timer = setTimeout(() => {
|
|
101
|
+
engineLog.warn(`${name} timed out after ${SHUTDOWN_TASK_TIMEOUT_MS / 1000}s.`);
|
|
71
102
|
resolve();
|
|
72
|
-
},
|
|
103
|
+
}, SHUTDOWN_TASK_TIMEOUT_MS);
|
|
73
104
|
}),
|
|
74
105
|
]);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
} else {
|
|
78
|
-
engineLog.success("Stopped.");
|
|
79
|
-
}
|
|
80
|
-
} catch (err) {
|
|
81
|
-
engineLog.error("Error during shutdown:", err);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
engineLog.error(`Error during ${name}:`, error);
|
|
82
108
|
} finally {
|
|
83
|
-
if (
|
|
84
|
-
clearTimeout(
|
|
109
|
+
if (timer !== null) {
|
|
110
|
+
clearTimeout(timer);
|
|
85
111
|
}
|
|
86
112
|
}
|
|
113
|
+
};
|
|
87
114
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
115
|
+
const runStage = async (stage: ShutdownTaskStage): Promise<void> => {
|
|
116
|
+
const stageTasks = tasks[stage];
|
|
117
|
+
let index = 0;
|
|
118
|
+
while (index < stageTasks.length) {
|
|
119
|
+
const task = stageTasks[index];
|
|
120
|
+
if (task !== undefined) {
|
|
121
|
+
await runWithTimeout(task.name, task.run);
|
|
122
|
+
}
|
|
123
|
+
index++;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
91
126
|
|
|
92
|
-
|
|
93
|
-
|
|
127
|
+
const shutdown = async (reason: string): Promise<void> => {
|
|
128
|
+
if (phase === "forced_exit" || phase === "exited") return;
|
|
129
|
+
phase = "shutting_down";
|
|
130
|
+
engineLog.info("Stopping simulation...");
|
|
131
|
+
|
|
132
|
+
const finalMetrics = engine?.getMetrics() ?? null;
|
|
133
|
+
|
|
134
|
+
await runStage("cleanup");
|
|
135
|
+
|
|
136
|
+
const engineToStop = engine;
|
|
137
|
+
if (engineToStop) {
|
|
138
|
+
await runWithTimeout("engine stop", () => engineToStop.stop(reason));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!forcedExitRequested && finalMetrics !== null) {
|
|
142
|
+
await printShutdownSummary(
|
|
143
|
+
finalMetrics,
|
|
144
|
+
options.seed,
|
|
145
|
+
options.seedExplicit,
|
|
146
|
+
errorContextCounts,
|
|
147
|
+
writeFinalOutput,
|
|
148
|
+
).catch((error) => {
|
|
149
|
+
engineLog.error("Error rendering shutdown summary:", error);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await runStage("finalize");
|
|
154
|
+
|
|
155
|
+
const forced = forcedExitRequested;
|
|
156
|
+
clearInterval(keepAlive);
|
|
157
|
+
phase = "exited";
|
|
158
|
+
resolveCompletion();
|
|
159
|
+
|
|
160
|
+
if (!forced) {
|
|
161
|
+
exit(0);
|
|
94
162
|
}
|
|
163
|
+
};
|
|
95
164
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
165
|
+
const armForceExitHandlers = (): void => {
|
|
166
|
+
process.removeListener("SIGINT", sigintHandler);
|
|
167
|
+
process.removeListener("SIGTERM", sigtermHandler);
|
|
168
|
+
process.once("SIGINT", forceSignalHandler);
|
|
169
|
+
process.once("SIGTERM", forceSignalHandler);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const requestShutdown = (reason: string): void => {
|
|
173
|
+
if (phase === "forced_exit" || phase === "exited") return;
|
|
174
|
+
if (requestedReason !== null) {
|
|
175
|
+
forceExit();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
requestedReason = reason;
|
|
179
|
+
resolveRequest(reason);
|
|
180
|
+
armForceExitHandlers();
|
|
99
181
|
};
|
|
100
182
|
|
|
101
183
|
const sigintHandler = (): void => {
|
|
102
|
-
|
|
184
|
+
requestShutdown("user interrupt");
|
|
103
185
|
};
|
|
104
186
|
|
|
105
187
|
const sigtermHandler = (): void => {
|
|
106
|
-
|
|
188
|
+
requestShutdown("SIGTERM");
|
|
107
189
|
};
|
|
108
190
|
|
|
109
|
-
process.
|
|
110
|
-
process.
|
|
111
|
-
|
|
112
|
-
return
|
|
113
|
-
|
|
114
|
-
|
|
191
|
+
process.once("SIGINT", sigintHandler);
|
|
192
|
+
process.once("SIGTERM", sigtermHandler);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
completion,
|
|
196
|
+
addTask(stage, name, run) {
|
|
197
|
+
tasks[stage].push({ name, run });
|
|
198
|
+
},
|
|
199
|
+
isShuttingDown() {
|
|
200
|
+
return requestedReason !== null || phase === "shutting_down" || phase === "forced_exit";
|
|
201
|
+
},
|
|
202
|
+
markRunning() {
|
|
203
|
+
if (phase === "booting") {
|
|
204
|
+
phase = "running";
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
requestShutdown,
|
|
208
|
+
runShutdown() {
|
|
209
|
+
if (shutdownPromise) {
|
|
210
|
+
return shutdownPromise;
|
|
211
|
+
}
|
|
212
|
+
if (requestedReason === null) {
|
|
213
|
+
throw new Error("Shutdown was not requested before runShutdown().");
|
|
214
|
+
}
|
|
215
|
+
shutdownPromise = shutdown(requestedReason);
|
|
216
|
+
return shutdownPromise;
|
|
217
|
+
},
|
|
218
|
+
setEngine(nextEngine) {
|
|
219
|
+
engine = nextEngine;
|
|
220
|
+
},
|
|
221
|
+
setErrorContextCounts(counts) {
|
|
222
|
+
errorContextCounts = counts;
|
|
223
|
+
},
|
|
224
|
+
teardown() {
|
|
225
|
+
process.removeListener("SIGINT", sigintHandler);
|
|
226
|
+
process.removeListener("SIGTERM", sigtermHandler);
|
|
227
|
+
process.removeListener("SIGINT", forceSignalHandler);
|
|
228
|
+
process.removeListener("SIGTERM", forceSignalHandler);
|
|
229
|
+
clearInterval(keepAlive);
|
|
230
|
+
},
|
|
231
|
+
waitForShutdownRequest() {
|
|
232
|
+
return request;
|
|
233
|
+
},
|
|
115
234
|
};
|
|
116
235
|
}
|
|
117
236
|
|
|
118
237
|
// ---------------------------------------------------------------------------
|
|
119
|
-
// Shared
|
|
238
|
+
// Shared helpers
|
|
120
239
|
// ---------------------------------------------------------------------------
|
|
121
240
|
|
|
241
|
+
const sep = colors.dim(" \u00B7 ");
|
|
242
|
+
|
|
122
243
|
interface SummaryInput {
|
|
123
244
|
readonly metrics: EngineMetricsSnapshot;
|
|
124
245
|
readonly seed?: number | undefined;
|
|
@@ -137,16 +258,81 @@ function healthStateLabel(state: AdapterHealthSnapshot["state"]): string {
|
|
|
137
258
|
}
|
|
138
259
|
}
|
|
139
260
|
|
|
261
|
+
function adapterLogger(name: string) {
|
|
262
|
+
if (name === "mqtt") return mqttLog;
|
|
263
|
+
if (name === "opcua") return opcuaLog;
|
|
264
|
+
return engineLog;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function adapterLabel(name: string): string {
|
|
268
|
+
if (name === "opcua") return "OPC UA";
|
|
269
|
+
return name.toUpperCase();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function formatAdapterLine(
|
|
273
|
+
name: string,
|
|
274
|
+
m: EngineMetricsSnapshot["adapters"][number]["metrics"],
|
|
275
|
+
uptimeMs: number,
|
|
276
|
+
): string {
|
|
277
|
+
const connStr = m.connected ? colors.green("connected") : colors.red("disconnected");
|
|
278
|
+
const errStr =
|
|
279
|
+
m.errorsCount === 0
|
|
280
|
+
? colors.green(formatCount(m.errorsCount))
|
|
281
|
+
: colors.yellow(formatCount(m.errorsCount));
|
|
282
|
+
const avgThroughput = computeAverageThroughput(m.messagesPublished, uptimeMs);
|
|
283
|
+
const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
|
|
284
|
+
const verb = name === "opcua" ? "updates" : "published";
|
|
285
|
+
const health = m.health ? `${sep}${healthStateLabel(m.health.state)}` : "";
|
|
286
|
+
return `${connStr}${sep}${formatCount(m.messagesPublished)} ${verb}${sep}${errStr} errors${throughputStr}${health}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Periodic summary (single-line per component)
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
140
293
|
/**
|
|
141
|
-
*
|
|
142
|
-
* Used by both the shutdown summary and periodic summary.
|
|
294
|
+
* Print a compact periodic summary: one line for core stats, one per adapter.
|
|
143
295
|
*/
|
|
296
|
+
export function printSummaryLine(metrics: EngineMetricsSnapshot): void {
|
|
297
|
+
if (isSilent()) return;
|
|
298
|
+
|
|
299
|
+
if (isJsonLogFormat()) {
|
|
300
|
+
engineLog.info("Periodic summary", buildJsonSummary({ metrics }));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const { core, fleet, adapters } = metrics;
|
|
305
|
+
|
|
306
|
+
// Core stats
|
|
307
|
+
const parts = [
|
|
308
|
+
formatDuration(core.uptimeMs),
|
|
309
|
+
`${formatCount(core.tickCount)} ticks`,
|
|
310
|
+
`${core.actualTicksPerSecond.toFixed(1)} ticks/s`,
|
|
311
|
+
`${formatCount(core.instanceCount)} instances`,
|
|
312
|
+
`${formatCount(core.memberValueCount)} data points`,
|
|
313
|
+
];
|
|
314
|
+
if (fleet.activeFaultCount > 0) {
|
|
315
|
+
parts.push(`${colors.yellow(String(fleet.activeFaultCount))} faults`);
|
|
316
|
+
}
|
|
317
|
+
engineLog.info(parts.join(sep));
|
|
318
|
+
|
|
319
|
+
// Per-adapter line
|
|
320
|
+
for (const adapter of adapters) {
|
|
321
|
+
adapterLogger(adapter.name).info(
|
|
322
|
+
formatAdapterLine(adapter.name, adapter.metrics, core.uptimeMs),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Shutdown summary (box)
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
144
331
|
function buildSummaryLines(input: SummaryInput): string[] {
|
|
145
332
|
const { core, fleet, adapters } = input.metrics;
|
|
146
|
-
const sep = colors.dim(" \u00B7 ");
|
|
147
333
|
const lines: string[] = [];
|
|
148
334
|
|
|
149
|
-
//
|
|
335
|
+
// Core stats
|
|
150
336
|
lines.push(
|
|
151
337
|
`${colors.bold(formatDuration(core.uptimeMs))}${sep}${colors.bold(formatCount(core.tickCount))} ticks${sep}${colors.bold(String(core.actualTicksPerSecond.toFixed(1)))} ticks/s`,
|
|
152
338
|
);
|
|
@@ -154,11 +340,10 @@ function buildSummaryLines(input: SummaryInput): string[] {
|
|
|
154
340
|
`${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.memberValueCount))} data points${sep}sim: ${colors.bold(core.simulationTimeSeconds.toFixed(1))}s`,
|
|
155
341
|
);
|
|
156
342
|
|
|
157
|
-
//
|
|
343
|
+
// Adapters
|
|
158
344
|
for (const adapter of adapters) {
|
|
159
345
|
const m = adapter.metrics;
|
|
160
|
-
const label = adapter.name
|
|
161
|
-
const verb = adapter.name === "opcua" ? "updates" : "published";
|
|
346
|
+
const label = adapterLabel(adapter.name);
|
|
162
347
|
const connStr = m.connected ? colors.green("connected") : colors.red("disconnected");
|
|
163
348
|
const errStr =
|
|
164
349
|
m.errorsCount === 0
|
|
@@ -166,21 +351,20 @@ function buildSummaryLines(input: SummaryInput): string[] {
|
|
|
166
351
|
: colors.yellow(formatCount(m.errorsCount));
|
|
167
352
|
const avgThroughput = computeAverageThroughput(m.messagesPublished, core.uptimeMs);
|
|
168
353
|
const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
|
|
169
|
-
const
|
|
170
|
-
m.messagesFiltered > 0 ? `${sep}${colors.dim(formatCount(m.messagesFiltered))} filtered` : "";
|
|
354
|
+
const verb = adapter.name === "opcua" ? "updates" : "published";
|
|
171
355
|
lines.push(
|
|
172
|
-
`${label}: ${connStr}${sep}${colors.bold(formatCount(m.messagesPublished))} ${verb}${sep}${errStr} errors${throughputStr}
|
|
356
|
+
`${label}: ${connStr}${sep}${colors.bold(formatCount(m.messagesPublished))} ${verb}${sep}${errStr} errors${throughputStr}`,
|
|
173
357
|
);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
lines.push(` ${
|
|
358
|
+
if (m.health) {
|
|
359
|
+
const pendingPrefix =
|
|
360
|
+
m.health.pendingWorkCount > 0
|
|
361
|
+
? `${formatCount(m.health.pendingWorkCount)} pending${sep}`
|
|
362
|
+
: "";
|
|
363
|
+
lines.push(` ${pendingPrefix}${healthStateLabel(m.health.state)}`);
|
|
180
364
|
}
|
|
181
365
|
}
|
|
182
366
|
|
|
183
|
-
//
|
|
367
|
+
// Fleet health warnings
|
|
184
368
|
const healthParts: string[] = [];
|
|
185
369
|
if (fleet.activeFaultCount > 0) {
|
|
186
370
|
healthParts.push(`${colors.yellow(String(fleet.activeFaultCount))} active faults`);
|
|
@@ -201,7 +385,7 @@ function buildSummaryLines(input: SummaryInput): string[] {
|
|
|
201
385
|
lines.push(healthParts.join(sep));
|
|
202
386
|
}
|
|
203
387
|
|
|
204
|
-
//
|
|
388
|
+
// Fault breakdown
|
|
205
389
|
if (fleet.faultBreakdown.length > 0) {
|
|
206
390
|
const faultItems: TreeItem[] = fleet.faultBreakdown.map(
|
|
207
391
|
(f) => `${colors.yellow(String(f.count))} ${f.fault}`,
|
|
@@ -209,7 +393,7 @@ function buildSummaryLines(input: SummaryInput): string[] {
|
|
|
209
393
|
lines.push(formatTree(faultItems, { color: "yellow", prefix: " " }).trimEnd());
|
|
210
394
|
}
|
|
211
395
|
|
|
212
|
-
//
|
|
396
|
+
// Models & lifecycle
|
|
213
397
|
const treeItems: TreeItem[] = [];
|
|
214
398
|
if (fleet.modelBreakdown.length > 0) {
|
|
215
399
|
treeItems.push({
|
|
@@ -230,7 +414,7 @@ function buildSummaryLines(input: SummaryInput): string[] {
|
|
|
230
414
|
lines.push(formatTree(treeItems, { color: "gray", prefix: " " }).trimEnd());
|
|
231
415
|
}
|
|
232
416
|
|
|
233
|
-
//
|
|
417
|
+
// Seed
|
|
234
418
|
if (input.seed !== undefined) {
|
|
235
419
|
const seedLine = input.seedExplicit
|
|
236
420
|
? `Seed: ${colors.bold(String(input.seed))}`
|
|
@@ -238,7 +422,7 @@ function buildSummaryLines(input: SummaryInput): string[] {
|
|
|
238
422
|
lines.push(seedLine);
|
|
239
423
|
}
|
|
240
424
|
|
|
241
|
-
//
|
|
425
|
+
// Top error contexts
|
|
242
426
|
if (input.errorContextCounts && input.errorContextCounts.size > 0) {
|
|
243
427
|
const topContexts = [...input.errorContextCounts.entries()]
|
|
244
428
|
.sort((a, b) => b[1] - a[1])
|
|
@@ -246,16 +430,13 @@ function buildSummaryLines(input: SummaryInput): string[] {
|
|
|
246
430
|
const errorItems: TreeItem[] = topContexts.map(
|
|
247
431
|
([context, count]) => `${context} ${colors.dim("\u00B7")} ${colors.bold(formatCount(count))}`,
|
|
248
432
|
);
|
|
249
|
-
lines.push(
|
|
433
|
+
lines.push(colors.cyan("Top errors"));
|
|
250
434
|
lines.push(formatTree(errorItems, { color: "red", prefix: " " }).trimEnd());
|
|
251
435
|
}
|
|
252
436
|
|
|
253
437
|
return lines;
|
|
254
438
|
}
|
|
255
439
|
|
|
256
|
-
/**
|
|
257
|
-
* Build a structured summary object for JSON log output.
|
|
258
|
-
*/
|
|
259
440
|
function buildJsonSummary(input: SummaryInput): Record<string, unknown> {
|
|
260
441
|
const { core, fleet, adapters } = input.metrics;
|
|
261
442
|
|
|
@@ -306,49 +487,65 @@ function buildJsonSummary(input: SummaryInput): Record<string, unknown> {
|
|
|
306
487
|
return summary;
|
|
307
488
|
}
|
|
308
489
|
|
|
309
|
-
|
|
310
|
-
* Render a summary box (or plain/JSON equivalent) with a given title.
|
|
311
|
-
* Shared renderer for shutdown and periodic summaries.
|
|
312
|
-
*/
|
|
313
|
-
export function printSummaryBox(title: string, input: SummaryInput, logLabel: string): void {
|
|
314
|
-
if (isSilent()) return;
|
|
315
|
-
|
|
316
|
-
if (isJsonLogFormat()) {
|
|
317
|
-
engineLog.info(logLabel, buildJsonSummary(input));
|
|
318
|
-
}
|
|
319
|
-
|
|
490
|
+
function renderSummaryText(title: string, input: SummaryInput): string {
|
|
320
491
|
const lines = buildSummaryLines(input);
|
|
321
492
|
|
|
322
493
|
if (isPlain()) {
|
|
323
|
-
|
|
324
|
-
for (const line of lines) {
|
|
325
|
-
writeOutput(` ${line}`);
|
|
326
|
-
}
|
|
327
|
-
writeOutput();
|
|
328
|
-
return;
|
|
494
|
+
return [title, ...lines.map((line) => ` ${stripAnsi(line)}`), ""].join("\n");
|
|
329
495
|
}
|
|
330
496
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}),
|
|
336
|
-
);
|
|
497
|
+
return box(lines.join("\n"), {
|
|
498
|
+
title: ` ${title} `,
|
|
499
|
+
style: { borderColor: "gray", borderStyle: "rounded" },
|
|
500
|
+
});
|
|
337
501
|
}
|
|
338
502
|
|
|
339
|
-
|
|
340
|
-
// Shutdown-specific
|
|
341
|
-
// ---------------------------------------------------------------------------
|
|
342
|
-
|
|
343
|
-
function printShutdownSummary(
|
|
503
|
+
async function printShutdownSummary(
|
|
344
504
|
metrics: EngineMetricsSnapshot,
|
|
345
505
|
seed: number,
|
|
346
506
|
seedExplicit: boolean,
|
|
347
507
|
errorContextCounts?: ReadonlyMap<string, number>,
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
508
|
+
writeFinal: (text: string) => Promise<void> = writeShutdownOutput,
|
|
509
|
+
): Promise<void> {
|
|
510
|
+
if (isSilent()) return;
|
|
511
|
+
|
|
512
|
+
if (isJsonLogFormat()) {
|
|
513
|
+
engineLog.info(
|
|
514
|
+
"Shutdown summary",
|
|
515
|
+
buildJsonSummary({ metrics, seed, seedExplicit, errorContextCounts }),
|
|
516
|
+
);
|
|
517
|
+
await flushOutputStreams();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
await writeFinal(
|
|
522
|
+
renderSummaryText("Shutdown Summary", {
|
|
523
|
+
metrics,
|
|
524
|
+
seed,
|
|
525
|
+
seedExplicit,
|
|
526
|
+
errorContextCounts,
|
|
527
|
+
}),
|
|
353
528
|
);
|
|
354
529
|
}
|
|
530
|
+
|
|
531
|
+
async function writeShutdownOutput(text: string): Promise<void> {
|
|
532
|
+
await writeToStream(process.stdout, text.endsWith("\n") ? text : `${text}\n`);
|
|
533
|
+
await flushOutputStreams();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function writeToStream(stream: NodeJS.WriteStream, text: string): Promise<void> {
|
|
537
|
+
await new Promise<void>((resolve, reject) => {
|
|
538
|
+
stream.write(text, (error?: Error | null) => {
|
|
539
|
+
if (error) {
|
|
540
|
+
reject(error);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
resolve();
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function flushOutputStreams(): Promise<void> {
|
|
549
|
+
await Promise.all([writeToStream(process.stdout, ""), writeToStream(process.stderr, "")]);
|
|
550
|
+
await new Promise<void>((resolve) => setTimeout(resolve, FINAL_OUTPUT_GRACE_MS));
|
|
551
|
+
}
|
package/src/logger.ts
CHANGED
|
@@ -19,7 +19,7 @@ const LOG_LEVEL_MAP: Readonly<Record<LogLevel, number>> = {
|
|
|
19
19
|
const TAG_COLORS: Readonly<Record<string, (s: string) => string>> = {
|
|
20
20
|
engine: colors.cyan,
|
|
21
21
|
mqtt: colors.green,
|
|
22
|
-
opcua: colors.
|
|
22
|
+
opcua: colors.blue,
|
|
23
23
|
config: colors.yellow,
|
|
24
24
|
lifecycle: colors.magenta,
|
|
25
25
|
fault: colors.yellowBright,
|