miravo 0.4.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 -8
- 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 +137 -16
- 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 +104 -34
- 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 +39 -14
- package/src/output.ts +3 -0
- package/console-dist/assets/index-C3XMYq26.css +0 -2
- package/console-dist/assets/index-hUJSpKtB.js +0 -119
- package/src/commands/start.ts +0 -2023
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
|
@@ -2,14 +2,23 @@ import type { Engine, EngineMetricsSnapshot } from "@miravo/core";
|
|
|
2
2
|
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
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
adapterLogger,
|
|
7
|
+
engineLog,
|
|
8
|
+
isJsonLogFormat,
|
|
9
|
+
isPlain,
|
|
10
|
+
isQuiet,
|
|
11
|
+
writeOutput,
|
|
12
|
+
} from "../logger.ts";
|
|
13
|
+
import { computeAverageThroughput, formatCount, formatDuration, sep } from "../output.ts";
|
|
7
14
|
|
|
8
15
|
const SHUTDOWN_TASK_TIMEOUT_MS = 6_000;
|
|
9
16
|
const FINAL_OUTPUT_GRACE_MS = 50;
|
|
17
|
+
const FORWARDED_SIGINT_COALESCE_WINDOW_MS = 100;
|
|
10
18
|
|
|
11
19
|
type ShutdownTaskStage = "cleanup" | "finalize";
|
|
12
20
|
type ShutdownPhase = "booting" | "running" | "shutting_down" | "forced_exit" | "exited";
|
|
21
|
+
type ShutdownRequestSource = "api" | "sigint" | "sigterm";
|
|
13
22
|
|
|
14
23
|
interface ShutdownTask {
|
|
15
24
|
readonly name: string;
|
|
@@ -17,7 +26,7 @@ interface ShutdownTask {
|
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
/** Parameters for the shutdown controller. Supports dependency injection for tests. */
|
|
20
|
-
|
|
29
|
+
interface ShutdownControllerOptions {
|
|
21
30
|
/** The RNG seed used for this run (printed in summary for reproducibility). */
|
|
22
31
|
readonly seed: number;
|
|
23
32
|
/** Whether the seed was explicitly configured (vs. randomly generated). */
|
|
@@ -28,9 +37,13 @@ export interface ShutdownControllerOptions {
|
|
|
28
37
|
readonly exit?: (code: number) => void;
|
|
29
38
|
/** Optional writer override for the final shutdown summary render. */
|
|
30
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;
|
|
31
44
|
}
|
|
32
45
|
|
|
33
|
-
|
|
46
|
+
interface ShutdownController {
|
|
34
47
|
readonly completion: Promise<void>;
|
|
35
48
|
addTask(stage: ShutdownTaskStage, name: string, run: () => void | Promise<void>): void;
|
|
36
49
|
isShuttingDown(): boolean;
|
|
@@ -54,13 +67,19 @@ export interface ShutdownController {
|
|
|
54
67
|
export function createShutdownController(options: ShutdownControllerOptions): ShutdownController {
|
|
55
68
|
const exit = options.exit ?? ((code: number) => process.exit(code));
|
|
56
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;
|
|
57
73
|
|
|
58
74
|
let phase: ShutdownPhase = "booting";
|
|
59
75
|
let forcedExitRequested = false;
|
|
60
76
|
let engine: Engine | null = null;
|
|
61
77
|
let errorContextCounts = options.errorContextCounts;
|
|
62
78
|
let requestedReason: string | null = null;
|
|
79
|
+
let requestSource: ShutdownRequestSource | null = null;
|
|
63
80
|
let shutdownPromise: Promise<void> | null = null;
|
|
81
|
+
let requestedAtMs = 0;
|
|
82
|
+
let forwardedSigintIgnored = false;
|
|
64
83
|
|
|
65
84
|
const tasks: Record<ShutdownTaskStage, ShutdownTask[]> = {
|
|
66
85
|
cleanup: [],
|
|
@@ -77,9 +96,31 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
|
|
|
77
96
|
});
|
|
78
97
|
|
|
79
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
|
+
|
|
80
112
|
const forceSignalHandler = (): void => {
|
|
113
|
+
if (shouldIgnoreForwardedSigint()) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
81
116
|
forceExit();
|
|
82
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 => {};
|
|
83
124
|
|
|
84
125
|
const forceExit = (): void => {
|
|
85
126
|
if (phase === "forced_exit" || phase === "exited") return;
|
|
@@ -93,14 +134,18 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
|
|
|
93
134
|
|
|
94
135
|
const runWithTimeout = async (name: string, run: () => void | Promise<void>): Promise<void> => {
|
|
95
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;
|
|
96
140
|
try {
|
|
141
|
+
runPromise = Promise.resolve(run()); // sync throws are caught by this try block
|
|
97
142
|
await Promise.race([
|
|
98
|
-
|
|
143
|
+
runPromise,
|
|
99
144
|
new Promise<void>((resolve) => {
|
|
100
145
|
timer = setTimeout(() => {
|
|
101
|
-
engineLog.warn(`${name} timed out after ${
|
|
146
|
+
engineLog.warn(`${name} timed out after ${taskTimeoutMs / 1000}s.`);
|
|
102
147
|
resolve();
|
|
103
|
-
},
|
|
148
|
+
}, taskTimeoutMs);
|
|
104
149
|
}),
|
|
105
150
|
]);
|
|
106
151
|
} catch (error) {
|
|
@@ -109,6 +154,10 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
|
|
|
109
154
|
if (timer !== null) {
|
|
110
155
|
clearTimeout(timer);
|
|
111
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(() => {});
|
|
112
161
|
}
|
|
113
162
|
};
|
|
114
163
|
|
|
@@ -165,27 +214,52 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
|
|
|
165
214
|
const armForceExitHandlers = (): void => {
|
|
166
215
|
process.removeListener("SIGINT", sigintHandler);
|
|
167
216
|
process.removeListener("SIGTERM", sigtermHandler);
|
|
168
|
-
process.
|
|
169
|
-
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);
|
|
170
232
|
};
|
|
171
233
|
|
|
172
|
-
const
|
|
234
|
+
const requestShutdownInternal = (reason: string, source: ShutdownRequestSource): void => {
|
|
173
235
|
if (phase === "forced_exit" || phase === "exited") return;
|
|
174
236
|
if (requestedReason !== null) {
|
|
175
|
-
|
|
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
|
+
}
|
|
176
243
|
return;
|
|
177
244
|
}
|
|
178
245
|
requestedReason = reason;
|
|
246
|
+
requestSource = source;
|
|
247
|
+
requestedAtMs = performance.now();
|
|
248
|
+
forwardedSigintIgnored = false;
|
|
179
249
|
resolveRequest(reason);
|
|
180
250
|
armForceExitHandlers();
|
|
181
251
|
};
|
|
182
252
|
|
|
253
|
+
const requestShutdown = (reason: string): void => {
|
|
254
|
+
requestShutdownInternal(reason, "api");
|
|
255
|
+
};
|
|
256
|
+
|
|
183
257
|
const sigintHandler = (): void => {
|
|
184
|
-
|
|
258
|
+
requestShutdownInternal("user interrupt", "sigint");
|
|
185
259
|
};
|
|
186
260
|
|
|
187
261
|
const sigtermHandler = (): void => {
|
|
188
|
-
|
|
262
|
+
requestShutdownInternal("SIGTERM", "sigterm");
|
|
189
263
|
};
|
|
190
264
|
|
|
191
265
|
process.once("SIGINT", sigintHandler);
|
|
@@ -225,7 +299,7 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
|
|
|
225
299
|
process.removeListener("SIGINT", sigintHandler);
|
|
226
300
|
process.removeListener("SIGTERM", sigtermHandler);
|
|
227
301
|
process.removeListener("SIGINT", forceSignalHandler);
|
|
228
|
-
process.removeListener("SIGTERM",
|
|
302
|
+
process.removeListener("SIGTERM", absorbSigterm);
|
|
229
303
|
clearInterval(keepAlive);
|
|
230
304
|
},
|
|
231
305
|
waitForShutdownRequest() {
|
|
@@ -238,8 +312,6 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
|
|
|
238
312
|
// Shared helpers
|
|
239
313
|
// ---------------------------------------------------------------------------
|
|
240
314
|
|
|
241
|
-
const sep = colors.dim(" \u00B7 ");
|
|
242
|
-
|
|
243
315
|
interface SummaryInput {
|
|
244
316
|
readonly metrics: EngineMetricsSnapshot;
|
|
245
317
|
readonly seed?: number | undefined;
|
|
@@ -255,17 +327,16 @@ function healthStateLabel(state: AdapterHealthSnapshot["state"]): string {
|
|
|
255
327
|
return colors.yellow(state);
|
|
256
328
|
case "saturated":
|
|
257
329
|
return colors.red(state);
|
|
330
|
+
default: {
|
|
331
|
+
const _exhaustive: never = state;
|
|
332
|
+
return _exhaustive;
|
|
333
|
+
}
|
|
258
334
|
}
|
|
259
335
|
}
|
|
260
336
|
|
|
261
|
-
function adapterLogger(name: string) {
|
|
262
|
-
if (name === "mqtt") return mqttLog;
|
|
263
|
-
if (name === "opcua") return opcuaLog;
|
|
264
|
-
return engineLog;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
337
|
function adapterLabel(name: string): string {
|
|
268
338
|
if (name === "opcua") return "OPC UA";
|
|
339
|
+
if (name === "modbus") return "Modbus";
|
|
269
340
|
return name.toUpperCase();
|
|
270
341
|
}
|
|
271
342
|
|
|
@@ -274,16 +345,14 @@ function formatAdapterLine(
|
|
|
274
345
|
m: EngineMetricsSnapshot["adapters"][number]["metrics"],
|
|
275
346
|
uptimeMs: number,
|
|
276
347
|
): string {
|
|
277
|
-
const connStr = m.connected ? colors.green("
|
|
278
|
-
const
|
|
279
|
-
m.errorsCount === 0
|
|
280
|
-
? colors.green(formatCount(m.errorsCount))
|
|
281
|
-
: colors.yellow(formatCount(m.errorsCount));
|
|
348
|
+
const connStr = m.connected ? colors.green("✓") : colors.red("✗");
|
|
349
|
+
const verb = name === "opcua" || name === "modbus" ? "updates" : "published";
|
|
282
350
|
const avgThroughput = computeAverageThroughput(m.messagesPublished, uptimeMs);
|
|
283
351
|
const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
|
|
284
|
-
const
|
|
352
|
+
const errStr =
|
|
353
|
+
m.errorsCount > 0 ? `${sep}${colors.yellow(formatCount(m.errorsCount))} errors` : "";
|
|
285
354
|
const health = m.health ? `${sep}${healthStateLabel(m.health.state)}` : "";
|
|
286
|
-
return `${connStr}${
|
|
355
|
+
return `${connStr} ${formatCount(m.messagesPublished)} ${verb}${throughputStr}${errStr}${health}`;
|
|
287
356
|
}
|
|
288
357
|
|
|
289
358
|
// ---------------------------------------------------------------------------
|
|
@@ -301,14 +370,15 @@ export function printSummaryLine(metrics: EngineMetricsSnapshot): void {
|
|
|
301
370
|
return;
|
|
302
371
|
}
|
|
303
372
|
|
|
373
|
+
// Blank line before each summary block to visually separate it from event lines
|
|
374
|
+
writeOutput();
|
|
375
|
+
|
|
304
376
|
const { core, fleet, adapters } = metrics;
|
|
305
377
|
|
|
306
|
-
// Core stats
|
|
378
|
+
// Core stats — omit tick count (redundant with rate) and instance count (static, already in banner)
|
|
307
379
|
const parts = [
|
|
308
380
|
formatDuration(core.uptimeMs),
|
|
309
|
-
`${formatCount(core.tickCount)} ticks`,
|
|
310
381
|
`${core.actualTicksPerSecond.toFixed(1)} ticks/s`,
|
|
311
|
-
`${formatCount(core.instanceCount)} instances`,
|
|
312
382
|
`${formatCount(core.memberValueCount)} data points`,
|
|
313
383
|
];
|
|
314
384
|
if (fleet.activeFaultCount > 0) {
|
|
@@ -351,7 +421,7 @@ function buildSummaryLines(input: SummaryInput): string[] {
|
|
|
351
421
|
: colors.yellow(formatCount(m.errorsCount));
|
|
352
422
|
const avgThroughput = computeAverageThroughput(m.messagesPublished, core.uptimeMs);
|
|
353
423
|
const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
|
|
354
|
-
const verb = adapter.name === "opcua" ? "updates" : "published";
|
|
424
|
+
const verb = adapter.name === "opcua" || adapter.name === "modbus" ? "updates" : "published";
|
|
355
425
|
lines.push(
|
|
356
426
|
`${label}: ${connStr}${sep}${colors.bold(formatCount(m.messagesPublished))} ${verb}${sep}${errStr} errors${throughputStr}`,
|
|
357
427
|
);
|
|
@@ -547,5 +617,5 @@ async function writeToStream(stream: NodeJS.WriteStream, text: string): Promise<
|
|
|
547
617
|
|
|
548
618
|
async function flushOutputStreams(): Promise<void> {
|
|
549
619
|
await Promise.all([writeToStream(process.stdout, ""), writeToStream(process.stderr, "")]);
|
|
550
|
-
await
|
|
620
|
+
await Bun.sleep(FINAL_OUTPUT_GRACE_MS);
|
|
551
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 {
|