miravo 0.5.0 → 0.6.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 +1 -1
- package/console-dist/assets/activity-DsWeRq4h.js +1 -0
- package/console-dist/assets/api-4AqKvsm9.js +1 -0
- package/console-dist/assets/arrow-right-Ea_UhTNi.js +1 -0
- package/console-dist/assets/arrow-up-down-DFSjzzl2.js +1 -0
- package/console-dist/assets/blueprint-card-classes-DBFCDy0M.js +1 -0
- package/console-dist/assets/box-C1WaoOMF.js +1 -0
- package/console-dist/assets/card-BjeP32PN.js +1 -0
- package/console-dist/assets/catalog-BJzyzbLR.js +1 -0
- package/console-dist/assets/catalog-DKjEm-c4.js +1 -0
- package/console-dist/assets/catalog-template-icons-BrUeHaCY.js +1 -0
- package/console-dist/assets/checkbox-DZlJXTE4.js +1 -0
- package/console-dist/assets/chunk-OE4NN4TA-CEjpfhS6.js +3 -0
- package/console-dist/assets/combobox-BGnuCYxO.js +1 -0
- package/console-dist/assets/control-CPhvwx9o.js +1 -0
- package/console-dist/assets/control-Cg2up9NA.js +1 -0
- package/console-dist/assets/copy-button-WkbNkMcd.js +1 -0
- package/console-dist/assets/createLucideIcon-Bwxw859K.js +1 -0
- package/console-dist/assets/deploy-tab-JeWDwPoZ.js +1 -0
- package/console-dist/assets/dist-BhbDML0a.js +1 -0
- package/console-dist/assets/dist-Cy8XTNFT.js +2 -0
- package/console-dist/assets/download-DQ6NH16U.js +1 -0
- package/console-dist/assets/engine-store-CrLnz692.js +1 -0
- package/console-dist/assets/events-CXm29Ok4.js +1 -0
- package/console-dist/assets/events-D01IxhnP.js +1 -0
- package/console-dist/assets/fleet-CfxbKXt9.js +1 -0
- package/console-dist/assets/fleet-lotMGVqe.js +1 -0
- package/console-dist/assets/format-kXTonpDJ.js +1 -0
- package/console-dist/assets/gauge-Ci8zNZlk.js +1 -0
- package/console-dist/assets/get-error-message-3Q5Q-2G5.js +1 -0
- package/console-dist/assets/index-CVPKSDvJ.js +12 -0
- package/console-dist/assets/index-DJkgDeun.css +2 -0
- package/console-dist/assets/input-BWEQFptx.js +1 -0
- package/console-dist/assets/instance-detail-C5IVwuh3.js +1 -0
- package/console-dist/assets/label-uephX5ZG.js +1 -0
- package/console-dist/assets/layers-CUsLQ1fq.js +1 -0
- package/console-dist/assets/login-C3RJli0m.js +126 -0
- package/console-dist/assets/models-B_UioWQ5.js +1 -0
- package/console-dist/assets/models-_IPlPiZ7.js +1 -0
- package/console-dist/assets/not-found-B8gAm08S.js +1 -0
- package/console-dist/assets/platform-targets-R3T-4eJ7.js +1 -0
- package/console-dist/assets/platforms-BWh0BQ3Y.js +1 -0
- package/console-dist/assets/platforms-DStNbtVc.js +1 -0
- package/console-dist/assets/proxy-CsnYqnPp.js +9 -0
- package/console-dist/assets/query-keys-BvtE7BMs.js +1 -0
- package/console-dist/assets/radio-CoHegOv5.js +1 -0
- package/console-dist/assets/rolldown-runtime-COnpUsM8.js +1 -0
- package/console-dist/assets/select-CeO4vP0G.js +1 -0
- package/console-dist/assets/select-display-items-CIzY8953.js +1 -0
- package/console-dist/assets/server-D9Kfn8fQ.js +1 -0
- package/console-dist/assets/shallow-6lGjQhtJ.js +1 -0
- package/console-dist/assets/tags-CiXOIHtY.js +4 -0
- package/console-dist/assets/tags-FQCXIxrP.js +1 -0
- package/console-dist/assets/tooltip-HGLF366Q.js +1 -0
- package/console-dist/assets/transition-nav-link-Df4Rk6vV.js +1 -0
- package/console-dist/assets/transition-types-CPlGtM8w.js +1 -0
- package/console-dist/assets/triangle-alert-adNk_sea.js +1 -0
- package/console-dist/assets/use-platform-stream-DGbK1k2L.js +2 -0
- package/console-dist/assets/use-transition-navigate-CCpcWXB0.js +2 -0
- package/console-dist/assets/validation-Blvud9_O.js +1 -0
- package/console-dist/assets/vendor-charts-CAZdaPlq.js +60 -0
- package/console-dist/assets/vendor-table-wd8JuMmT.js +4 -0
- package/console-dist/assets/vendor-ui-ByahnaY0.js +1 -0
- package/console-dist/index.html +70 -2
- package/package.json +9 -9
- package/src/__tests__/integration/executable-methods-runner.ts +7 -6
- package/src/__tests__/integration/executable-methods.integration.ts +214 -122
- package/src/__tests__/integration/opcua-concurrent-clients.integration.ts +314 -0
- package/src/commands/adapter.ts +180 -7
- package/src/commands/apply.test.ts +62 -0
- package/src/commands/apply.ts +216 -0
- package/src/commands/cleanup.test.ts +34 -0
- package/src/commands/cleanup.ts +201 -0
- package/src/commands/export.test.ts +51 -0
- package/src/commands/export.ts +404 -0
- package/src/commands/fork.ts +2 -2
- package/src/commands/inject.ts +2 -2
- package/src/commands/load.ts +1 -1
- package/src/commands/logs.ts +13 -15
- package/src/commands/models.test.ts +1 -1
- package/src/commands/models.ts +1 -1
- package/src/commands/pause.ts +3 -3
- package/src/commands/platforms.test.ts +186 -0
- package/src/commands/platforms.ts +371 -0
- package/src/commands/remove.ts +1 -1
- package/src/commands/reset.ts +2 -2
- package/src/commands/spawn.ts +2 -2
- package/src/commands/speed.ts +2 -2
- package/src/commands/start/adapter-config.ts +157 -0
- package/src/commands/start/catalog-le-dt.test.ts +50 -0
- package/src/commands/start/catalog-le-dt.ts +187 -0
- package/src/commands/start/catalog-model-schema.ts +89 -0
- package/src/commands/start/catalog-template-detail.ts +54 -0
- package/src/commands/start/daemon.ts +255 -0
- package/src/commands/start/display.ts +150 -0
- package/src/commands/start/engine-setup.ts +247 -0
- package/src/commands/start/events.ts +126 -0
- package/src/commands/start/index.ts +897 -0
- package/src/commands/start/protocol-plans.ts +239 -0
- package/src/commands/start/template-loader.ts +197 -0
- package/src/commands/start/template-resolver.ts +185 -0
- package/src/commands/start/types.ts +73 -0
- package/src/commands/start.test.ts +20 -21
- package/src/commands/status.ts +1 -1
- package/src/commands/stop.ts +19 -8
- package/src/commands/templates.test.ts +1 -1
- package/src/commands/templates.ts +1 -3
- package/src/index.ts +11 -3
- package/src/lib/admin-auth.ts +157 -0
- package/src/lib/admin-client.test.ts +31 -15
- package/src/lib/admin-client.ts +106 -6
- package/src/lib/admin-platform-routes.ts +1017 -0
- package/src/lib/admin-platform-stream.test.ts +434 -0
- package/src/lib/admin-platform-stream.ts +788 -0
- package/src/lib/admin-responses.ts +211 -0
- package/src/lib/admin-server.test.ts +274 -55
- package/src/lib/admin-server.ts +387 -248
- package/src/lib/auth-store.ts +87 -0
- package/src/lib/daemon-registry.ts +12 -14
- package/src/lib/le/le-apply.test.ts +591 -0
- package/src/lib/le/le-apply.ts +832 -0
- package/src/lib/le/le-auth.ts +89 -0
- package/src/lib/le/le-cleanup.test.ts +168 -0
- package/src/lib/le/le-cleanup.ts +399 -0
- package/src/lib/le/le-cli-helpers.ts +77 -0
- package/src/lib/le/le-client.ts +184 -0
- package/src/lib/le/le-concurrency.test.ts +157 -0
- package/src/lib/le/le-concurrency.ts +78 -0
- package/src/lib/le/le-connection-test.ts +42 -0
- package/src/lib/le/le-devicehub-properties.test.ts +53 -0
- package/src/lib/le/le-devicehub-properties.ts +33 -0
- package/src/lib/le/le-devicehub.ts +213 -0
- package/src/lib/le/le-digital-twins.ts +285 -0
- package/src/lib/le/le-drivers.ts +23 -0
- package/src/lib/le/le-endpoint.ts +40 -0
- package/src/lib/le/le-environments.test.ts +73 -0
- package/src/lib/le/le-environments.ts +156 -0
- package/src/lib/le/le-markers.ts +87 -0
- package/src/lib/le/le-plan.test.ts +26 -0
- package/src/lib/le/le-plan.ts +109 -0
- package/src/lib/le/le-platform-operations.test.ts +198 -0
- package/src/lib/le/le-platform-operations.ts +340 -0
- package/src/lib/le/le-static-model-schema.ts +78 -0
- package/src/lib/le/le-static-planner.test.ts +255 -0
- package/src/lib/le/le-static-planner.ts +442 -0
- package/src/lib/le/le-template-exporter.test.ts +527 -0
- package/src/lib/le/le-template-exporter.ts +459 -0
- package/src/lib/le/le-value-types.ts +23 -0
- package/src/lib/mqtt-client.ts +1 -2
- package/src/lib/resolve-simulation.ts +1 -6
- package/src/lib/session-store.ts +110 -0
- package/src/lib/shutdown.test.ts +184 -2
- package/src/lib/shutdown.ts +96 -36
- package/src/lib/template-category.ts +3 -3
- package/src/lib/template-locator.ts +8 -2
- package/src/lib/ws-hub.ts +72 -46
- package/src/logger.ts +18 -10
- package/src/output.ts +3 -0
- package/console-dist/assets/index-C1oOHU1J.js +0 -119
- package/console-dist/assets/index-C3XMYq26.css +0 -2
- package/src/commands/start.ts +0 -2253
package/src/lib/shutdown.test.ts
CHANGED
|
@@ -141,6 +141,43 @@ describe("createShutdownController", () => {
|
|
|
141
141
|
expect(exitCode).toBe(0);
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
test("SIGTERM during graceful shutdown does not force-exit (parent runner cleanup)", async () => {
|
|
145
|
+
const engine = createMockEngine();
|
|
146
|
+
let exitCode: number | undefined;
|
|
147
|
+
let engineStopped = false;
|
|
148
|
+
|
|
149
|
+
(engine as unknown as Record<string, unknown>)["stop"] = () =>
|
|
150
|
+
new Promise<void>((resolve) =>
|
|
151
|
+
setTimeout(() => {
|
|
152
|
+
engineStopped = true;
|
|
153
|
+
resolve();
|
|
154
|
+
}, 50),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const controller = createShutdownController({
|
|
158
|
+
seed: 42,
|
|
159
|
+
seedExplicit: true,
|
|
160
|
+
exit: (code) => {
|
|
161
|
+
exitCode = code;
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
teardowns.push(controller.teardown);
|
|
165
|
+
controller.setEngine(engine);
|
|
166
|
+
|
|
167
|
+
process.emit("SIGINT");
|
|
168
|
+
await controller.waitForShutdownRequest();
|
|
169
|
+
const shutdownPromise = controller.runShutdown();
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
171
|
+
// Simulate bun run / bunx forwarding SIGTERM to the child as the parent exits
|
|
172
|
+
process.emit("SIGTERM");
|
|
173
|
+
await controller.completion;
|
|
174
|
+
await shutdownPromise;
|
|
175
|
+
|
|
176
|
+
// Graceful shutdown must complete: engine stopped, exit(0), not force-stopped
|
|
177
|
+
expect(engineStopped).toBe(true);
|
|
178
|
+
expect(exitCode).toBe(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
144
181
|
test("second signal during shutdown calls exit(1)", async () => {
|
|
145
182
|
const engine = createMockEngine();
|
|
146
183
|
const exitCodes: number[] = [];
|
|
@@ -151,6 +188,7 @@ describe("createShutdownController", () => {
|
|
|
151
188
|
const controller = createShutdownController({
|
|
152
189
|
seed: 42,
|
|
153
190
|
seedExplicit: true,
|
|
191
|
+
sigintCoalesceWindowMs: 20,
|
|
154
192
|
exit: (code) => {
|
|
155
193
|
exitCodes.push(code);
|
|
156
194
|
},
|
|
@@ -161,12 +199,86 @@ describe("createShutdownController", () => {
|
|
|
161
199
|
process.emit("SIGINT");
|
|
162
200
|
await controller.waitForShutdownRequest();
|
|
163
201
|
const shutdownPromise = controller.runShutdown();
|
|
164
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
202
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
165
203
|
process.emit("SIGINT");
|
|
166
204
|
await controller.completion;
|
|
167
205
|
await shutdownPromise;
|
|
168
206
|
|
|
169
|
-
expect(exitCodes).
|
|
207
|
+
expect(exitCodes).toEqual([1]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("duplicate SIGINT from bun run does not force-exit on a single Ctrl+C", async () => {
|
|
211
|
+
const engine = createMockEngine();
|
|
212
|
+
let exitCode: number | undefined;
|
|
213
|
+
let engineStopped = false;
|
|
214
|
+
|
|
215
|
+
(engine as unknown as Record<string, unknown>)["stop"] = () =>
|
|
216
|
+
new Promise<void>((resolve) =>
|
|
217
|
+
setTimeout(() => {
|
|
218
|
+
engineStopped = true;
|
|
219
|
+
resolve();
|
|
220
|
+
}, 50),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const controller = createShutdownController({
|
|
224
|
+
seed: 42,
|
|
225
|
+
seedExplicit: true,
|
|
226
|
+
sigintCoalesceWindowMs: 20,
|
|
227
|
+
exit: (code) => {
|
|
228
|
+
exitCode = code;
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
teardowns.push(controller.teardown);
|
|
232
|
+
controller.setEngine(engine);
|
|
233
|
+
|
|
234
|
+
process.emit("SIGINT");
|
|
235
|
+
await controller.waitForShutdownRequest();
|
|
236
|
+
const shutdownPromise = controller.runShutdown();
|
|
237
|
+
// Regression: `bun run <script>` forwards a second SIGINT to its child
|
|
238
|
+
// immediately after the terminal already delivered SIGINT to the group.
|
|
239
|
+
process.emit("SIGINT");
|
|
240
|
+
await controller.completion;
|
|
241
|
+
await shutdownPromise;
|
|
242
|
+
|
|
243
|
+
expect(engineStopped).toBe(true);
|
|
244
|
+
expect(exitCode).toBe(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("runShutdown() without prior requestShutdown() throws synchronously", () => {
|
|
248
|
+
const controller = createShutdownController({
|
|
249
|
+
seed: 1,
|
|
250
|
+
seedExplicit: true,
|
|
251
|
+
exit: () => {},
|
|
252
|
+
});
|
|
253
|
+
teardowns.push(controller.teardown);
|
|
254
|
+
|
|
255
|
+
expect(() => controller.runShutdown()).toThrow(
|
|
256
|
+
"Shutdown was not requested before runShutdown()",
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("duplicate requestShutdown before runShutdown is deduplicated (no force-exit)", async () => {
|
|
261
|
+
const engine = createMockEngine();
|
|
262
|
+
const exitCodes: number[] = [];
|
|
263
|
+
|
|
264
|
+
const controller = createShutdownController({
|
|
265
|
+
seed: 1,
|
|
266
|
+
seedExplicit: true,
|
|
267
|
+
exit: (code) => {
|
|
268
|
+
exitCodes.push(code);
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
teardowns.push(controller.teardown);
|
|
272
|
+
controller.setEngine(engine);
|
|
273
|
+
|
|
274
|
+
// First request via signal, second via direct API (simulating admin /shutdown race with SIGINT)
|
|
275
|
+
process.emit("SIGINT");
|
|
276
|
+
await controller.waitForShutdownRequest();
|
|
277
|
+
controller.requestShutdown("admin api"); // second call before runShutdown starts
|
|
278
|
+
await controller.runShutdown();
|
|
279
|
+
|
|
280
|
+
expect(exitCodes).toEqual([0]);
|
|
281
|
+
expect(engine.stopCalled).toBe(true);
|
|
170
282
|
});
|
|
171
283
|
|
|
172
284
|
test("shutdown request is inert until the owner runs shutdown", async () => {
|
|
@@ -195,6 +307,76 @@ describe("createShutdownController", () => {
|
|
|
195
307
|
expect(stopCallCount).toBe(1);
|
|
196
308
|
});
|
|
197
309
|
|
|
310
|
+
test("task that hangs past timeout: warns and shutdown continues", async () => {
|
|
311
|
+
const engine = createMockEngine();
|
|
312
|
+
const steps: string[] = [];
|
|
313
|
+
let exitCode: number | undefined;
|
|
314
|
+
|
|
315
|
+
const controller = createShutdownController({
|
|
316
|
+
seed: 1,
|
|
317
|
+
seedExplicit: true,
|
|
318
|
+
taskTimeoutMs: 30,
|
|
319
|
+
exit: (code) => {
|
|
320
|
+
exitCode = code;
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
teardowns.push(controller.teardown);
|
|
324
|
+
controller.setEngine(engine);
|
|
325
|
+
|
|
326
|
+
controller.addTask("cleanup", "hanging task", () => new Promise<void>(() => {}));
|
|
327
|
+
controller.addTask("finalize", "post-timeout step", () => {
|
|
328
|
+
steps.push("finalize ran");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
process.emit("SIGINT");
|
|
332
|
+
await controller.waitForShutdownRequest();
|
|
333
|
+
await controller.runShutdown();
|
|
334
|
+
|
|
335
|
+
expect(steps).toEqual(["finalize ran"]);
|
|
336
|
+
expect(exitCode).toBe(0);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("task that rejects after timeout does not cause unhandled rejection", async () => {
|
|
340
|
+
const engine = createMockEngine();
|
|
341
|
+
const unhandledReasons: unknown[] = [];
|
|
342
|
+
const onUnhandled = (reason: unknown) => {
|
|
343
|
+
unhandledReasons.push(reason);
|
|
344
|
+
};
|
|
345
|
+
process.on("unhandledRejection", onUnhandled);
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
const controller = createShutdownController({
|
|
349
|
+
seed: 1,
|
|
350
|
+
seedExplicit: true,
|
|
351
|
+
taskTimeoutMs: 30,
|
|
352
|
+
exit: () => {},
|
|
353
|
+
});
|
|
354
|
+
teardowns.push(controller.teardown);
|
|
355
|
+
controller.setEngine(engine);
|
|
356
|
+
|
|
357
|
+
let rejectLate!: (err: Error) => void;
|
|
358
|
+
controller.addTask(
|
|
359
|
+
"cleanup",
|
|
360
|
+
"slow-then-reject",
|
|
361
|
+
() =>
|
|
362
|
+
new Promise<void>((_, reject) => {
|
|
363
|
+
rejectLate = reject;
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
process.emit("SIGINT");
|
|
368
|
+
await controller.waitForShutdownRequest();
|
|
369
|
+
await controller.runShutdown(); // task times out after 30ms
|
|
370
|
+
|
|
371
|
+
rejectLate(new Error("late rejection after timeout"));
|
|
372
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
373
|
+
} finally {
|
|
374
|
+
process.removeListener("unhandledRejection", onUnhandled);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
expect(unhandledReasons).toHaveLength(0);
|
|
378
|
+
});
|
|
379
|
+
|
|
198
380
|
test("teardown removes signal handlers", async () => {
|
|
199
381
|
const engine = createMockEngine();
|
|
200
382
|
|
package/src/lib/shutdown.ts
CHANGED
|
@@ -3,21 +3,22 @@ import type { AdapterHealthSnapshot } from "@miravo/shared";
|
|
|
3
3
|
import type { TreeItem } from "consola/utils";
|
|
4
4
|
import { box, colors, formatTree, stripAnsi } from "consola/utils";
|
|
5
5
|
import {
|
|
6
|
+
adapterLogger,
|
|
6
7
|
engineLog,
|
|
7
8
|
isJsonLogFormat,
|
|
8
9
|
isPlain,
|
|
9
10
|
isQuiet,
|
|
10
|
-
|
|
11
|
-
mqttLog,
|
|
12
|
-
opcuaLog,
|
|
11
|
+
writeOutput,
|
|
13
12
|
} from "../logger.ts";
|
|
14
|
-
import { computeAverageThroughput, formatCount, formatDuration } from "../output.ts";
|
|
13
|
+
import { computeAverageThroughput, formatCount, formatDuration, sep } from "../output.ts";
|
|
15
14
|
|
|
16
15
|
const SHUTDOWN_TASK_TIMEOUT_MS = 6_000;
|
|
17
16
|
const FINAL_OUTPUT_GRACE_MS = 50;
|
|
17
|
+
const FORWARDED_SIGINT_COALESCE_WINDOW_MS = 100;
|
|
18
18
|
|
|
19
19
|
type ShutdownTaskStage = "cleanup" | "finalize";
|
|
20
20
|
type ShutdownPhase = "booting" | "running" | "shutting_down" | "forced_exit" | "exited";
|
|
21
|
+
type ShutdownRequestSource = "api" | "sigint" | "sigterm";
|
|
21
22
|
|
|
22
23
|
interface ShutdownTask {
|
|
23
24
|
readonly name: string;
|
|
@@ -25,7 +26,7 @@ interface ShutdownTask {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/** Parameters for the shutdown controller. Supports dependency injection for tests. */
|
|
28
|
-
|
|
29
|
+
interface ShutdownControllerOptions {
|
|
29
30
|
/** The RNG seed used for this run (printed in summary for reproducibility). */
|
|
30
31
|
readonly seed: number;
|
|
31
32
|
/** Whether the seed was explicitly configured (vs. randomly generated). */
|
|
@@ -36,9 +37,13 @@ export interface ShutdownControllerOptions {
|
|
|
36
37
|
readonly exit?: (code: number) => void;
|
|
37
38
|
/** Optional writer override for the final shutdown summary render. */
|
|
38
39
|
readonly writeFinalOutput?: (text: string) => Promise<void>;
|
|
40
|
+
/** Per-task timeout in ms. Defaults to SHUTDOWN_TASK_TIMEOUT_MS. Inject a small value in tests. */
|
|
41
|
+
readonly taskTimeoutMs?: number;
|
|
42
|
+
/** Duplicate SIGINT coalescing window in ms. Inject a small value in tests. */
|
|
43
|
+
readonly sigintCoalesceWindowMs?: number;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
|
|
46
|
+
interface ShutdownController {
|
|
42
47
|
readonly completion: Promise<void>;
|
|
43
48
|
addTask(stage: ShutdownTaskStage, name: string, run: () => void | Promise<void>): void;
|
|
44
49
|
isShuttingDown(): boolean;
|
|
@@ -62,13 +67,19 @@ export interface ShutdownController {
|
|
|
62
67
|
export function createShutdownController(options: ShutdownControllerOptions): ShutdownController {
|
|
63
68
|
const exit = options.exit ?? ((code: number) => process.exit(code));
|
|
64
69
|
const writeFinalOutput = options.writeFinalOutput ?? writeShutdownOutput;
|
|
70
|
+
const taskTimeoutMs = options.taskTimeoutMs ?? SHUTDOWN_TASK_TIMEOUT_MS;
|
|
71
|
+
const sigintCoalesceWindowMs =
|
|
72
|
+
options.sigintCoalesceWindowMs ?? FORWARDED_SIGINT_COALESCE_WINDOW_MS;
|
|
65
73
|
|
|
66
74
|
let phase: ShutdownPhase = "booting";
|
|
67
75
|
let forcedExitRequested = false;
|
|
68
76
|
let engine: Engine | null = null;
|
|
69
77
|
let errorContextCounts = options.errorContextCounts;
|
|
70
78
|
let requestedReason: string | null = null;
|
|
79
|
+
let requestSource: ShutdownRequestSource | null = null;
|
|
71
80
|
let shutdownPromise: Promise<void> | null = null;
|
|
81
|
+
let requestedAtMs = 0;
|
|
82
|
+
let forwardedSigintIgnored = false;
|
|
72
83
|
|
|
73
84
|
const tasks: Record<ShutdownTaskStage, ShutdownTask[]> = {
|
|
74
85
|
cleanup: [],
|
|
@@ -85,9 +96,31 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
|
|
|
85
96
|
});
|
|
86
97
|
|
|
87
98
|
const keepAlive = setInterval(() => {}, 60_000);
|
|
99
|
+
const shouldIgnoreForwardedSigint = (): boolean => {
|
|
100
|
+
if (requestSource !== "sigint" || forwardedSigintIgnored) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const elapsedMs = performance.now() - requestedAtMs;
|
|
104
|
+
if (elapsedMs > sigintCoalesceWindowMs) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
forwardedSigintIgnored = true;
|
|
108
|
+
engineLog.debug(`Ignoring duplicate SIGINT ${elapsedMs.toFixed(1)}ms after shutdown request.`);
|
|
109
|
+
return true;
|
|
110
|
+
};
|
|
111
|
+
|
|
88
112
|
const forceSignalHandler = (): void => {
|
|
113
|
+
if (shouldIgnoreForwardedSigint()) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
89
116
|
forceExit();
|
|
90
117
|
};
|
|
118
|
+
// Absorbs SIGTERM during graceful shutdown without terminating.
|
|
119
|
+
// bun run / bunx forwards SIGTERM to the child process when the parent
|
|
120
|
+
// runner receives SIGINT and exits — leaving SIGTERM unhandled would restore
|
|
121
|
+
// the OS default (abrupt termination). This no-op keeps the process alive
|
|
122
|
+
// long enough for cleanup tasks and engine.stop() to complete.
|
|
123
|
+
const absorbSigterm = (): void => {};
|
|
91
124
|
|
|
92
125
|
const forceExit = (): void => {
|
|
93
126
|
if (phase === "forced_exit" || phase === "exited") return;
|
|
@@ -101,14 +134,18 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
|
|
|
101
134
|
|
|
102
135
|
const runWithTimeout = async (name: string, run: () => void | Promise<void>): Promise<void> => {
|
|
103
136
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
137
|
+
// Declared outside try so finally can suppress late rejections on timeout.
|
|
138
|
+
// Remains null if run() throws synchronously — no dangling promise in that case.
|
|
139
|
+
let runPromise: Promise<void> | null = null;
|
|
104
140
|
try {
|
|
141
|
+
runPromise = Promise.resolve(run()); // sync throws are caught by this try block
|
|
105
142
|
await Promise.race([
|
|
106
|
-
|
|
143
|
+
runPromise,
|
|
107
144
|
new Promise<void>((resolve) => {
|
|
108
145
|
timer = setTimeout(() => {
|
|
109
|
-
engineLog.warn(`${name} timed out after ${
|
|
146
|
+
engineLog.warn(`${name} timed out after ${taskTimeoutMs / 1000}s.`);
|
|
110
147
|
resolve();
|
|
111
|
-
},
|
|
148
|
+
}, taskTimeoutMs);
|
|
112
149
|
}),
|
|
113
150
|
]);
|
|
114
151
|
} catch (error) {
|
|
@@ -117,6 +154,10 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
|
|
|
117
154
|
if (timer !== null) {
|
|
118
155
|
clearTimeout(timer);
|
|
119
156
|
}
|
|
157
|
+
// If the timeout fired first, runPromise is still pending. Attach a no-op
|
|
158
|
+
// catch so a future rejection from the dangling task does not become an
|
|
159
|
+
// unhandled-rejection crash.
|
|
160
|
+
runPromise?.catch(() => {});
|
|
120
161
|
}
|
|
121
162
|
};
|
|
122
163
|
|
|
@@ -173,27 +214,52 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
|
|
|
173
214
|
const armForceExitHandlers = (): void => {
|
|
174
215
|
process.removeListener("SIGINT", sigintHandler);
|
|
175
216
|
process.removeListener("SIGTERM", sigtermHandler);
|
|
176
|
-
process.
|
|
177
|
-
process
|
|
217
|
+
// Use process.on (not once): under `bun run <script>` a single Ctrl+C sends
|
|
218
|
+
// SIGINT to the entire foreground process group, then Bun forwards the same
|
|
219
|
+
// SIGINT to its child process as well. The first duplicate is coalesced
|
|
220
|
+
// inside forceSignalHandler(); later SIGINTs still force-exit.
|
|
221
|
+
process.on("SIGINT", forceSignalHandler);
|
|
222
|
+
// SIGTERM is absorbed (no-op) rather than re-armed as a force handler.
|
|
223
|
+
// bun run / bunx forwards SIGTERM to the child process when the parent
|
|
224
|
+
// runner exits from its own SIGINT — leaving SIGTERM unhandled would
|
|
225
|
+
// restore the OS default (abrupt kill), so we absorb it instead and let
|
|
226
|
+
// cleanup tasks finish. A second Ctrl+C (SIGINT) still force-stops.
|
|
227
|
+
// Use process.on (not once): after the first SIGTERM is absorbed the listener
|
|
228
|
+
// must remain registered. If it were once, a second OS-level SIGTERM would
|
|
229
|
+
// find no handler and revert to the OS default (abrupt termination).
|
|
230
|
+
// teardown() removes it when shutdown completes.
|
|
231
|
+
process.on("SIGTERM", absorbSigterm);
|
|
178
232
|
};
|
|
179
233
|
|
|
180
|
-
const
|
|
234
|
+
const requestShutdownInternal = (reason: string, source: ShutdownRequestSource): void => {
|
|
181
235
|
if (phase === "forced_exit" || phase === "exited") return;
|
|
182
236
|
if (requestedReason !== null) {
|
|
183
|
-
|
|
237
|
+
// Only force-exit if shutdown is actively running. A duplicate request
|
|
238
|
+
// that arrives before runShutdown() starts (e.g. admin /shutdown racing
|
|
239
|
+
// with SIGINT) is silently deduplicated — first caller wins.
|
|
240
|
+
if (phase === "shutting_down") {
|
|
241
|
+
forceExit();
|
|
242
|
+
}
|
|
184
243
|
return;
|
|
185
244
|
}
|
|
186
245
|
requestedReason = reason;
|
|
246
|
+
requestSource = source;
|
|
247
|
+
requestedAtMs = performance.now();
|
|
248
|
+
forwardedSigintIgnored = false;
|
|
187
249
|
resolveRequest(reason);
|
|
188
250
|
armForceExitHandlers();
|
|
189
251
|
};
|
|
190
252
|
|
|
253
|
+
const requestShutdown = (reason: string): void => {
|
|
254
|
+
requestShutdownInternal(reason, "api");
|
|
255
|
+
};
|
|
256
|
+
|
|
191
257
|
const sigintHandler = (): void => {
|
|
192
|
-
|
|
258
|
+
requestShutdownInternal("user interrupt", "sigint");
|
|
193
259
|
};
|
|
194
260
|
|
|
195
261
|
const sigtermHandler = (): void => {
|
|
196
|
-
|
|
262
|
+
requestShutdownInternal("SIGTERM", "sigterm");
|
|
197
263
|
};
|
|
198
264
|
|
|
199
265
|
process.once("SIGINT", sigintHandler);
|
|
@@ -233,7 +299,7 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
|
|
|
233
299
|
process.removeListener("SIGINT", sigintHandler);
|
|
234
300
|
process.removeListener("SIGTERM", sigtermHandler);
|
|
235
301
|
process.removeListener("SIGINT", forceSignalHandler);
|
|
236
|
-
process.removeListener("SIGTERM",
|
|
302
|
+
process.removeListener("SIGTERM", absorbSigterm);
|
|
237
303
|
clearInterval(keepAlive);
|
|
238
304
|
},
|
|
239
305
|
waitForShutdownRequest() {
|
|
@@ -246,8 +312,6 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
|
|
|
246
312
|
// Shared helpers
|
|
247
313
|
// ---------------------------------------------------------------------------
|
|
248
314
|
|
|
249
|
-
const sep = colors.dim(" \u00B7 ");
|
|
250
|
-
|
|
251
315
|
interface SummaryInput {
|
|
252
316
|
readonly metrics: EngineMetricsSnapshot;
|
|
253
317
|
readonly seed?: number | undefined;
|
|
@@ -263,16 +327,13 @@ function healthStateLabel(state: AdapterHealthSnapshot["state"]): string {
|
|
|
263
327
|
return colors.yellow(state);
|
|
264
328
|
case "saturated":
|
|
265
329
|
return colors.red(state);
|
|
330
|
+
default: {
|
|
331
|
+
const _exhaustive: never = state;
|
|
332
|
+
return _exhaustive;
|
|
333
|
+
}
|
|
266
334
|
}
|
|
267
335
|
}
|
|
268
336
|
|
|
269
|
-
function adapterLogger(name: string) {
|
|
270
|
-
if (name === "mqtt") return mqttLog;
|
|
271
|
-
if (name === "opcua") return opcuaLog;
|
|
272
|
-
if (name === "modbus") return modbusLog;
|
|
273
|
-
return engineLog;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
337
|
function adapterLabel(name: string): string {
|
|
277
338
|
if (name === "opcua") return "OPC UA";
|
|
278
339
|
if (name === "modbus") return "Modbus";
|
|
@@ -284,16 +345,14 @@ function formatAdapterLine(
|
|
|
284
345
|
m: EngineMetricsSnapshot["adapters"][number]["metrics"],
|
|
285
346
|
uptimeMs: number,
|
|
286
347
|
): string {
|
|
287
|
-
const connStr = m.connected ? colors.green("
|
|
288
|
-
const
|
|
289
|
-
m.errorsCount === 0
|
|
290
|
-
? colors.green(formatCount(m.errorsCount))
|
|
291
|
-
: colors.yellow(formatCount(m.errorsCount));
|
|
348
|
+
const connStr = m.connected ? colors.green("✓") : colors.red("✗");
|
|
349
|
+
const verb = name === "opcua" || name === "modbus" ? "updates" : "published";
|
|
292
350
|
const avgThroughput = computeAverageThroughput(m.messagesPublished, uptimeMs);
|
|
293
351
|
const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
|
|
294
|
-
const
|
|
352
|
+
const errStr =
|
|
353
|
+
m.errorsCount > 0 ? `${sep}${colors.yellow(formatCount(m.errorsCount))} errors` : "";
|
|
295
354
|
const health = m.health ? `${sep}${healthStateLabel(m.health.state)}` : "";
|
|
296
|
-
return `${connStr}${
|
|
355
|
+
return `${connStr} ${formatCount(m.messagesPublished)} ${verb}${throughputStr}${errStr}${health}`;
|
|
297
356
|
}
|
|
298
357
|
|
|
299
358
|
// ---------------------------------------------------------------------------
|
|
@@ -311,14 +370,15 @@ export function printSummaryLine(metrics: EngineMetricsSnapshot): void {
|
|
|
311
370
|
return;
|
|
312
371
|
}
|
|
313
372
|
|
|
373
|
+
// Blank line before each summary block to visually separate it from event lines
|
|
374
|
+
writeOutput();
|
|
375
|
+
|
|
314
376
|
const { core, fleet, adapters } = metrics;
|
|
315
377
|
|
|
316
|
-
// Core stats
|
|
378
|
+
// Core stats — omit tick count (redundant with rate) and instance count (static, already in banner)
|
|
317
379
|
const parts = [
|
|
318
380
|
formatDuration(core.uptimeMs),
|
|
319
|
-
`${formatCount(core.tickCount)} ticks`,
|
|
320
381
|
`${core.actualTicksPerSecond.toFixed(1)} ticks/s`,
|
|
321
|
-
`${formatCount(core.instanceCount)} instances`,
|
|
322
382
|
`${formatCount(core.memberValueCount)} data points`,
|
|
323
383
|
];
|
|
324
384
|
if (fleet.activeFaultCount > 0) {
|
|
@@ -557,5 +617,5 @@ async function writeToStream(stream: NodeJS.WriteStream, text: string): Promise<
|
|
|
557
617
|
|
|
558
618
|
async function flushOutputStreams(): Promise<void> {
|
|
559
619
|
await Promise.all([writeToStream(process.stdout, ""), writeToStream(process.stderr, "")]);
|
|
560
|
-
await
|
|
620
|
+
await Bun.sleep(FINAL_OUTPUT_GRACE_MS);
|
|
561
621
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DiscoveredTemplate } from "@miravo/shared";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
type TemplateCategory = "Industrial Scenarios" | "Stress & Benchmark";
|
|
4
4
|
|
|
5
5
|
const CATEGORY_ORDER: readonly TemplateCategory[] = ["Industrial Scenarios", "Stress & Benchmark"];
|
|
6
6
|
|
|
@@ -16,7 +16,7 @@ export function categorizeTemplate(template: Pick<DiscoveredTemplate, "id">): Te
|
|
|
16
16
|
return "Industrial Scenarios";
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
function compareTemplatesByCategoryAndName(
|
|
20
20
|
a: Pick<DiscoveredTemplate, "id" | "name">,
|
|
21
21
|
b: Pick<DiscoveredTemplate, "id" | "name">,
|
|
22
22
|
): number {
|
|
@@ -39,7 +39,7 @@ export function sortTemplatesByCategoryAndName<T extends Pick<DiscoveredTemplate
|
|
|
39
39
|
return [...templates].sort(compareTemplatesByCategoryAndName);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
interface TemplateCategoryGroup<T extends Pick<DiscoveredTemplate, "id" | "name">> {
|
|
43
43
|
readonly category: TemplateCategory;
|
|
44
44
|
readonly templates: readonly T[];
|
|
45
45
|
}
|
|
@@ -5,9 +5,15 @@ import { MiravoError, TEMPLATE_FILE_EXTENSION } from "@miravo/shared";
|
|
|
5
5
|
/**
|
|
6
6
|
* Create a ContentCatalog with default settings.
|
|
7
7
|
* Shared across all CLI commands to ensure consistent resolution.
|
|
8
|
+
*
|
|
9
|
+
* @param opts.extraModelsDir - Optional extra directory scanned for .twin.yaml files
|
|
10
|
+
* before the built-in catalog. Useful when template files live in a sibling
|
|
11
|
+
* directory not on the default discovery path (e.g. --models-dir ./models).
|
|
8
12
|
*/
|
|
9
|
-
export function createCatalog(): ContentCatalog {
|
|
10
|
-
return new ContentCatalog(
|
|
13
|
+
export function createCatalog(opts: { extraModelsDir?: string } = {}): ContentCatalog {
|
|
14
|
+
return new ContentCatalog(
|
|
15
|
+
opts.extraModelsDir ? { extraModelsDirs: [resolve(opts.extraModelsDir)] } : undefined,
|
|
16
|
+
);
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
export function isTemplatePathLike(idNameOrPath: string): boolean {
|