@superblocksteam/sdk 2.0.157-next.4 → 2.0.157-next.5

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.
Files changed (67) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
  3. package/dist/cli-replacement/automatic-upgrades.js +4 -8
  4. package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
  5. package/dist/cli-replacement/automatic-upgrades.test.js +2 -1
  6. package/dist/cli-replacement/automatic-upgrades.test.js.map +1 -1
  7. package/dist/cli-replacement/dev-s3-restore.test.mjs +6 -2
  8. package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
  9. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +6 -2
  10. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
  11. package/dist/cli-replacement/dev-startup-lock-acquisition.test.d.mts +2 -0
  12. package/dist/cli-replacement/dev-startup-lock-acquisition.test.d.mts.map +1 -0
  13. package/dist/cli-replacement/dev-startup-lock-acquisition.test.mjs +487 -0
  14. package/dist/cli-replacement/dev-startup-lock-acquisition.test.mjs.map +1 -0
  15. package/dist/cli-replacement/dev.d.mts +6 -6
  16. package/dist/cli-replacement/dev.d.mts.map +1 -1
  17. package/dist/cli-replacement/dev.interception.test.mjs +12 -12
  18. package/dist/cli-replacement/dev.interception.test.mjs.map +1 -1
  19. package/dist/cli-replacement/dev.mjs +100 -42
  20. package/dist/cli-replacement/dev.mjs.map +1 -1
  21. package/dist/dev-utils/dev-server-json-parser.test.d.mts +2 -0
  22. package/dist/dev-utils/dev-server-json-parser.test.d.mts.map +1 -0
  23. package/dist/dev-utils/dev-server-json-parser.test.mjs +60 -0
  24. package/dist/dev-utils/dev-server-json-parser.test.mjs.map +1 -0
  25. package/dist/dev-utils/dev-server.d.mts +36 -1
  26. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  27. package/dist/dev-utils/dev-server.mjs +80 -73
  28. package/dist/dev-utils/dev-server.mjs.map +1 -1
  29. package/dist/dev-utils/dev-server.shutdown-lock.test.d.mts +2 -0
  30. package/dist/dev-utils/dev-server.shutdown-lock.test.d.mts.map +1 -0
  31. package/dist/dev-utils/dev-server.shutdown-lock.test.mjs +48 -0
  32. package/dist/dev-utils/dev-server.shutdown-lock.test.mjs.map +1 -0
  33. package/dist/dev-utils/fatal-process-barrier.d.mts +71 -0
  34. package/dist/dev-utils/fatal-process-barrier.d.mts.map +1 -0
  35. package/dist/dev-utils/fatal-process-barrier.mjs +161 -0
  36. package/dist/dev-utils/fatal-process-barrier.mjs.map +1 -0
  37. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.d.mts +2 -0
  38. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.d.mts.map +1 -0
  39. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.mjs +9 -0
  40. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.mjs.map +1 -0
  41. package/dist/dev-utils/fatal-process-barrier.test.d.mts +2 -0
  42. package/dist/dev-utils/fatal-process-barrier.test.d.mts.map +1 -0
  43. package/dist/dev-utils/fatal-process-barrier.test.mjs +281 -0
  44. package/dist/dev-utils/fatal-process-barrier.test.mjs.map +1 -0
  45. package/dist/telemetry/logging.js +1 -1
  46. package/dist/telemetry/logging.js.map +1 -1
  47. package/dist/telemetry/safe-stringify.d.ts.map +1 -1
  48. package/dist/telemetry/safe-stringify.js +25 -9
  49. package/dist/telemetry/safe-stringify.js.map +1 -1
  50. package/package.json +10 -6
  51. package/src/cli-replacement/automatic-upgrades.test.ts +2 -1
  52. package/src/cli-replacement/automatic-upgrades.ts +4 -11
  53. package/src/cli-replacement/dev-s3-restore.test.mts +6 -2
  54. package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +6 -2
  55. package/src/cli-replacement/dev-startup-lock-acquisition.test.mts +573 -0
  56. package/src/cli-replacement/dev.interception.test.mts +12 -12
  57. package/src/cli-replacement/dev.mts +112 -56
  58. package/src/dev-utils/dev-server-json-parser.test.mts +89 -0
  59. package/src/dev-utils/dev-server.mts +110 -93
  60. package/src/dev-utils/dev-server.shutdown-lock.test.mts +58 -0
  61. package/src/dev-utils/fatal-process-barrier.mts +262 -0
  62. package/src/dev-utils/fatal-process-barrier.spawn-fixture.mts +12 -0
  63. package/src/dev-utils/fatal-process-barrier.test.mts +346 -0
  64. package/src/telemetry/logging.ts +1 -1
  65. package/src/telemetry/safe-stringify.ts +25 -10
  66. package/test/safe-stringify.test.mts +25 -0
  67. package/tsconfig.tsbuildinfo +1 -1
@@ -69,11 +69,12 @@ import {
69
69
  type DevServerFailureType,
70
70
  devServerMetrics,
71
71
  } from "./dev-server-metrics.mjs";
72
+ import { isRecoverableShutdownError, parseIsWarm } from "./fatal-exit.mjs";
72
73
  import {
73
- buildFatalExitLog,
74
- isRecoverableShutdownError,
75
- parseIsWarm,
76
- } from "./fatal-exit.mjs";
74
+ fatalAwareExitCode,
75
+ installFatalProcessBarrier,
76
+ type FatalProcessHandler,
77
+ } from "./fatal-process-barrier.mjs";
77
78
  import { OPTIMIZE_DEPS_CONFIG } from "./optimize-deps-config.mjs";
78
79
  import { prepareInPlaceRestart } from "./prepare-in-place-restart.mjs";
79
80
  import { handleRecoverableBuildError } from "./recoverable-build-error.mjs";
@@ -546,7 +547,7 @@ export function buildStatusPayload<T extends object>(
546
547
  return { ...base, serverErrors: devServerStatus?.serverErrors ?? [] };
547
548
  }
548
549
 
549
- type ProcessErrorHandler = "uncaughtException" | "unhandledRejection";
550
+ type ProcessErrorHandler = FatalProcessHandler;
550
551
 
551
552
  // Both listeners log a recoverable shutdown, but with different wording, and
552
553
  // these lines are what on-call greps when a pod stays up instead of restarting.
@@ -557,6 +558,59 @@ const RECOVERABLE_SHUTDOWN_LOG_MESSAGE: Record<ProcessErrorHandler, string> = {
557
558
  "Ignoring recoverable shutdown rejection (unhandledRejection)",
558
559
  };
559
560
 
561
+ /**
562
+ * What the dev server needs of the lock service on its way out. Structural so a
563
+ * test does not have to stand up a real one.
564
+ */
565
+ type ExitingLockService = Pick<
566
+ LockService,
567
+ "notifyClosedByServer" | "relinquishLock"
568
+ >;
569
+
570
+ /**
571
+ * Give the AppBranchLock to whoever should have it next, then let the caller
572
+ * exit.
573
+ *
574
+ * Which of the two things to do is the dev server's knowledge, not the lock
575
+ * service's — it used to be a `serverInitiated` flag passed down into a
576
+ * `shutdown()` that branched on it, which is how a lock service came to have an
577
+ * opinion about why a process was stopping.
578
+ *
579
+ * Normally this process is the one stopping, so the lock goes back: the
580
+ * heartbeat dies with the process, and anything not released here makes the
581
+ * replacement sandbox's `acquireLock()` fail until the server-side TTL expires
582
+ * — and the replacement is the entire point of exiting.
583
+ *
584
+ * When the server closed us, the lock is already gone. `/_sb_disconnect` is
585
+ * usually a session moving across local↔cloud, so releasing would take the lock
586
+ * from whoever is taking over. The editor is told instead, because it has to
587
+ * distinguish "the server closed you" from a socket dropping.
588
+ *
589
+ * Extracted so both branches are testable: `runGracefulShutdown` is a closure
590
+ * over a booted dev server, so a unit test cannot otherwise reach either one.
591
+ */
592
+ export async function handOverLockOnExit(
593
+ lockService: ExitingLockService | undefined,
594
+ {
595
+ serverInitiated,
596
+ switchingTo,
597
+ initiatedByEmail,
598
+ }: {
599
+ serverInitiated: boolean;
600
+ switchingTo?: "local" | "cloud" | "none";
601
+ initiatedByEmail?: string;
602
+ },
603
+ ): Promise<void> {
604
+ if (!lockService) {
605
+ return;
606
+ }
607
+ if (serverInitiated) {
608
+ await lockService.notifyClosedByServer({ switchingTo, initiatedByEmail });
609
+ return;
610
+ }
611
+ await lockService.relinquishLock();
612
+ }
613
+
560
614
  /**
561
615
  * Decides whether a process-level error keeps the pod alive, and performs the
562
616
  * recording for the cases that do. Returns `true` when the caller must return
@@ -623,6 +677,22 @@ export function handleNonFatalProcessError(params: {
623
677
  export const DEV_SERVER_BOOT_ID = randomUUID();
624
678
  export const DEV_SERVER_STARTED_AT = new Date().toISOString();
625
679
 
680
+ export function mountDevServerJsonParsers(app: IRouter): void {
681
+ app.use(
682
+ "/_sb_policy_gates",
683
+ express.json({
684
+ limit: "1mb",
685
+ strict: true,
686
+ }),
687
+ );
688
+ app.use(
689
+ express.json({
690
+ limit: "100kb",
691
+ strict: true,
692
+ }),
693
+ );
694
+ }
695
+
626
696
  /**
627
697
  * Builds the `/_sb_health` payload, including the per-process boot identity.
628
698
  * Extracted as a pure function so the boot-identity contract can be unit
@@ -871,7 +941,7 @@ export async function createDevServer({
871
941
  }): Promise<void> {
872
942
  try {
873
943
  stopMemoryMetrics();
874
- await lockService?.shutdown({
944
+ await handOverLockOnExit(lockService, {
875
945
  serverInitiated,
876
946
  switchingTo,
877
947
  initiatedByEmail,
@@ -882,16 +952,23 @@ export async function createDevServer({
882
952
  } catch (e) {
883
953
  logger.error(`Error during graceful shutdown: ${e}`);
884
954
  } finally {
955
+ // The statement about this process, made by the thing that owns its exit.
956
+ // It used to be the last line of `LockService.shutdown()`, prefixed
957
+ // `[lock-service]` — a lock service announcing a process exit, which is
958
+ // the tangle this change removes.
959
+ //
960
+ // Load bearing: cli-system-tests waits on this exact substring.
961
+ logger.info("[dev-server] shutting down and exiting dev server");
885
962
  const closeTimeout = setTimeout(() => {
886
963
  logger.info("Dev server closed");
887
- process.exit(0);
964
+ process.exit(fatalAwareExitCode());
888
965
  }, 1000); // Fallback to exit if the HTTP server doesn't close within 1 second
889
966
 
890
967
  // Then close the HTTP server
891
968
  httpServer.close(() => {
892
969
  logger.info("Dev server closed");
893
970
  clearTimeout(closeTimeout);
894
- process.exit(0);
971
+ process.exit(fatalAwareExitCode());
895
972
  });
896
973
  }
897
974
  }
@@ -902,11 +979,7 @@ export async function createDevServer({
902
979
  credentials: true,
903
980
  }),
904
981
  );
905
- app.use(
906
- express.json({
907
- strict: true,
908
- }),
909
- );
982
+ mountDevServerJsonParsers(app);
910
983
 
911
984
  // Per-request metrics for dev-server endpoints. Identify the endpoint by URL
912
985
  // pattern, time the request, record duration + status + coarse failure type
@@ -1266,18 +1339,11 @@ export async function createDevServer({
1266
1339
  // even if the pod is activated later.
1267
1340
  const isWarm = parseIsWarm(process.env.SUPERBLOCKS_WARM_STANDBY);
1268
1341
 
1269
- // Only the first fatal event writes a fatal-exit line and starts shutdown.
1270
- // Without this, one crash could write several lines: an unhandled rejection
1271
- // fires once per rejected promise, so a burst of failures (say a dead DB pool
1272
- // rejecting every in-flight query) would each write a line and each start
1273
- // shutdown, making one crash look like many in the logs and the crash count.
1274
- let fatalExitHandled = false;
1275
-
1276
1342
  // Signal handlers attach `.catch` so a synchronous throw in
1277
1343
  // `runGracefulShutdown` (e.g. logger init or lockService shutdown throwing
1278
1344
  // before the first `await`) is logged rather than surfacing as an
1279
1345
  // unhandled rejection across N handlers (one per fired signal). Symmetric
1280
- // with the `uncaughtException` handler below.
1346
+ // with the process barrier below.
1281
1347
  process.on("SIGINT", () => {
1282
1348
  logger.info("SIGINT received");
1283
1349
  gracefulShutdown({
@@ -1311,86 +1377,37 @@ export async function createDevServer({
1311
1377
  );
1312
1378
  });
1313
1379
 
1314
- // The `.finally` always ends the process once shutdown settles. If an earlier
1315
- // signal already ran shutdown, this handler still needs to exit; without the
1316
- // final `process.exit` the process could hang instead of stopping.
1317
- process.on("uncaughtException", (error) => {
1318
- if (fatalExitHandled) {
1319
- return;
1320
- }
1321
- if (
1380
+ // Preload may already have armed this from a separate module instance
1381
+ // (node_modules export vs this esbuild bundle). A second call updates
1382
+ // shutdown hooks on the shared host state and does not register another
1383
+ // pair of listeners.
1384
+ installFatalProcessBarrier({
1385
+ isNonFatal: (handler, reason) =>
1322
1386
  handleNonFatalProcessError({
1323
- devServerStatus,
1324
1387
  devServerMetrics,
1325
- handler: "uncaughtException",
1326
- logger,
1327
- reason: error,
1328
- })
1329
- ) {
1330
- return;
1331
- }
1332
- fatalExitHandled = true;
1333
- // Write the fatal-exit line first, and synchronously, so the reason reaches
1334
- // the logs before anything else runs. Shutdown below may finish first and
1335
- // call `process.exit(0)`, so the container's real exit code can be 0 even
1336
- // though this was a crash. That is why `exit_code=1` here is the code this
1337
- // handler means to use, not a promise of what the container reports: for the
1338
- // JS-handler lines, trust that the line exists (and the `signal` field) over
1339
- // its `exit_code` when comparing against the pod's exit status.
1340
- logger.error(
1341
- buildFatalExitLog({ handler: "uncaughtException", exitCode: 1, isWarm }),
1342
- getErrorMeta(error),
1343
- );
1344
- gracefulShutdown({
1345
- logger,
1346
- serverInitiated: false,
1347
- source: "uncaughtException",
1348
- })
1349
- .catch((shutdownError) => {
1350
- logger.error(
1351
- "Error during shutdown after uncaught exception:",
1352
- getErrorMeta(shutdownError),
1353
- );
1354
- })
1355
- .finally(() => process.exit(1));
1356
- });
1357
-
1358
- // Without its own listener, Node turns an unhandled promise rejection into an
1359
- // uncaught exception, which would log it under the wrong cause. Handle it here
1360
- // so a rejection is labelled as a rejection, then exit through the same
1361
- // shutdown path as an uncaught exception.
1362
- process.on("unhandledRejection", (reason) => {
1363
- if (fatalExitHandled) {
1364
- return;
1365
- }
1366
- if (
1367
- handleNonFatalProcessError({
1368
1388
  devServerStatus,
1369
- devServerMetrics,
1370
- handler: "unhandledRejection",
1389
+ handler,
1371
1390
  logger,
1372
1391
  reason,
1373
- })
1374
- ) {
1375
- return;
1376
- }
1377
- fatalExitHandled = true;
1378
- logger.error(
1379
- buildFatalExitLog({ handler: "unhandledRejection", exitCode: 1, isWarm }),
1380
- getErrorMeta(reason),
1381
- );
1382
- gracefulShutdown({
1383
- logger,
1384
- serverInitiated: false,
1385
- source: "unhandledRejection",
1386
- })
1387
- .catch((shutdownError) => {
1392
+ }),
1393
+ isWarm,
1394
+ log: (message, meta) => logger.error(message, meta),
1395
+ onFatal: async (handler) => {
1396
+ try {
1397
+ await gracefulShutdown({
1398
+ logger,
1399
+ serverInitiated: false,
1400
+ source: handler,
1401
+ });
1402
+ } catch (shutdownError) {
1388
1403
  logger.error(
1389
- "Error during shutdown after unhandled rejection:",
1404
+ handler === "uncaughtException"
1405
+ ? "Error during shutdown after uncaught exception:"
1406
+ : "Error during shutdown after unhandled rejection:",
1390
1407
  getErrorMeta(shutdownError),
1391
1408
  );
1392
- })
1393
- .finally(() => process.exit(1));
1409
+ }
1410
+ },
1394
1411
  });
1395
1412
 
1396
1413
  if (existingServer) {
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { handOverLockOnExit } from "./dev-server.mjs";
4
+
5
+ /**
6
+ * ENG-6128. The dev server's exit path used to drive the lock service through a
7
+ * `shutdown({ serverInitiated })` that decided, inside the lock service, whether
8
+ * to release. That flag is the dev server's knowledge, not the lock service's,
9
+ * and getting it wrong in either direction is a live-edit outage: releasing a
10
+ * lock the server has already reassigned takes the app away from the session
11
+ * taking over, and failing to release on a normal exit makes the replacement
12
+ * sandbox wait out the TTL.
13
+ */
14
+ describe("handOverLockOnExit", () => {
15
+ const makeLockService = () => ({
16
+ notifyClosedByServer: vi.fn(async () => undefined),
17
+ relinquishLock: vi.fn(async () => undefined),
18
+ });
19
+
20
+ it("hands the lock back when the dev server is the one stopping", async () => {
21
+ const lockService = makeLockService();
22
+
23
+ await handOverLockOnExit(lockService, { serverInitiated: false });
24
+
25
+ // Prompt release is the whole recovery story for this path: the heartbeat
26
+ // dies with the process, so anything not released here blocks the
27
+ // replacement sandbox for the two-minute TTL.
28
+ expect(lockService.relinquishLock).toHaveBeenCalledOnce();
29
+ expect(lockService.notifyClosedByServer).not.toHaveBeenCalled();
30
+ });
31
+
32
+ it("only notifies when the server closed the session", async () => {
33
+ const lockService = makeLockService();
34
+
35
+ await handOverLockOnExit(lockService, {
36
+ serverInitiated: true,
37
+ switchingTo: "local",
38
+ initiatedByEmail: "someone@example.com",
39
+ });
40
+
41
+ // The server has already taken the lock — on a local↔cloud switch it now
42
+ // belongs to the session taking over, so releasing would take it away from
43
+ // them.
44
+ expect(lockService.relinquishLock).not.toHaveBeenCalled();
45
+ expect(lockService.notifyClosedByServer).toHaveBeenCalledWith({
46
+ switchingTo: "local",
47
+ initiatedByEmail: "someone@example.com",
48
+ });
49
+ });
50
+
51
+ it("is a no-op when the dev server never had a lock service", async () => {
52
+ // Startup can fail before the lock service exists, and the exit path still
53
+ // runs.
54
+ await expect(
55
+ handOverLockOnExit(undefined, { serverInitiated: false }),
56
+ ).resolves.toBeUndefined();
57
+ });
58
+ });
@@ -0,0 +1,262 @@
1
+ import {
2
+ sanitizeLogError,
3
+ sanitizeLogMessage,
4
+ } from "@superblocksteam/telemetry";
5
+
6
+ import { safeStringify } from "../telemetry/safe-stringify.js";
7
+ import { buildFatalExitLog, parseIsWarm } from "./fatal-exit.mjs";
8
+
9
+ export type FatalProcessHandler = "uncaughtException" | "unhandledRejection";
10
+
11
+ export type FatalProcessListener = (...args: unknown[]) => void;
12
+
13
+ /**
14
+ * The slice of `process` this needs, so a test can emit a crash without arming
15
+ * the real handlers and taking the runner down with them.
16
+ */
17
+ export type FatalProcessHost = {
18
+ off: (event: string, handler: FatalProcessListener) => void;
19
+ on: (event: string, handler: FatalProcessListener) => void;
20
+ };
21
+
22
+ export type FatalErrorMeta = {
23
+ error: {
24
+ kind: string;
25
+ message: string;
26
+ stack?: string;
27
+ };
28
+ };
29
+
30
+ export type FatalProcessBarrierHooks = {
31
+ isNonFatal?: (handler: FatalProcessHandler, reason: unknown) => boolean;
32
+ /**
33
+ * Runs after the fatal-exit line is written and before `exit(1)`. The server
34
+ * path uses this for graceful shutdown. The early/preload path leaves it
35
+ * unset.
36
+ */
37
+ onFatal?: (
38
+ handler: FatalProcessHandler,
39
+ reason: unknown,
40
+ ) => void | Promise<void>;
41
+ };
42
+
43
+ export type FatalProcessBarrierOptions = FatalProcessBarrierHooks & {
44
+ exit?: (code: number) => void;
45
+ isWarm?: boolean;
46
+ log?: (message: string, meta?: FatalErrorMeta) => void;
47
+ process?: FatalProcessHost;
48
+ };
49
+
50
+ type BarrierState = {
51
+ fatalHandled: boolean;
52
+ hooks: {
53
+ exit: (code: number) => void;
54
+ isNonFatal?: FatalProcessBarrierHooks["isNonFatal"];
55
+ isWarm: boolean;
56
+ log: (message: string, meta?: FatalErrorMeta) => void;
57
+ onFatal?: FatalProcessBarrierHooks["onFatal"];
58
+ };
59
+ off: () => void;
60
+ };
61
+
62
+ /**
63
+ * Armed state lives on `globalThis`, not in a module-level WeakMap: the
64
+ * packaged CLI loads TWO copies of this file (bin/run.js imports the
65
+ * node_modules export; createDevServer uses the esbuild-bundled copy). A
66
+ * per-module map would make the second call register a new listener pair
67
+ * instead of attaching `isNonFatal` / `onFatal`, and the early copy would
68
+ * `exit(1)` on recoverable Vite/socket errors before shutdown ran.
69
+ */
70
+ declare global {
71
+ var __superblocksFatalProcessBarrierArmed:
72
+ | WeakMap<FatalProcessHost, BarrierState>
73
+ | undefined;
74
+ }
75
+
76
+ function armedHosts(): WeakMap<FatalProcessHost, BarrierState> {
77
+ const existing = globalThis.__superblocksFatalProcessBarrierArmed;
78
+ if (existing) {
79
+ return existing;
80
+ }
81
+ const created = new WeakMap<FatalProcessHost, BarrierState>();
82
+ globalThis.__superblocksFatalProcessBarrierArmed = created;
83
+ return created;
84
+ }
85
+
86
+ function fatalErrorMeta(reason: unknown): FatalErrorMeta {
87
+ if (reason instanceof Error) {
88
+ return {
89
+ error: {
90
+ kind: reason.name,
91
+ message: reason.message,
92
+ stack: reason.stack,
93
+ },
94
+ };
95
+ }
96
+ return {
97
+ error: {
98
+ kind: "Unknown Error",
99
+ message: safeStringify(reason),
100
+ },
101
+ };
102
+ }
103
+
104
+ function stringField(value: unknown, key: string): string | undefined {
105
+ if (typeof value !== "object" || value === null || !(key in value)) {
106
+ return undefined;
107
+ }
108
+ const field = Reflect.get(value, key);
109
+ if (typeof field === "string") {
110
+ return field;
111
+ }
112
+ if (field === undefined) {
113
+ return undefined;
114
+ }
115
+ return safeStringify(field);
116
+ }
117
+
118
+ function defaultLog(message: string, meta?: FatalErrorMeta): void {
119
+ const safeMessage = sanitizeLogMessage(message);
120
+ if (!meta?.error) {
121
+ console.error(safeMessage);
122
+ return;
123
+ }
124
+ const sanitized = sanitizeLogError(meta.error);
125
+ console.error(safeMessage, {
126
+ error: {
127
+ kind:
128
+ stringField(sanitized, "kind") ??
129
+ stringField(sanitized, "name") ??
130
+ "Error",
131
+ message: stringField(sanitized, "message"),
132
+ stack: stringField(sanitized, "stack"),
133
+ },
134
+ });
135
+ }
136
+
137
+ function mergeHooks(
138
+ existing: BarrierState["hooks"],
139
+ options: FatalProcessBarrierOptions,
140
+ ): BarrierState["hooks"] {
141
+ return {
142
+ exit: options.exit ?? existing.exit,
143
+ isNonFatal: options.isNonFatal ?? existing.isNonFatal,
144
+ isWarm: options.isWarm ?? existing.isWarm,
145
+ log: options.log ?? existing.log,
146
+ onFatal: options.onFatal ?? existing.onFatal,
147
+ };
148
+ }
149
+
150
+ function handleCrash(
151
+ state: BarrierState,
152
+ handler: FatalProcessHandler,
153
+ reason: unknown,
154
+ ): void {
155
+ if (state.fatalHandled) {
156
+ return;
157
+ }
158
+ try {
159
+ if (state.hooks.isNonFatal?.(handler, reason)) {
160
+ return;
161
+ }
162
+ } catch (predicateError) {
163
+ // A throw inside the non-fatal check must not skip the fatal-exit line.
164
+ state.hooks.log(
165
+ "fatal process barrier isNonFatal threw; continuing as fatal",
166
+ fatalErrorMeta(predicateError),
167
+ );
168
+ }
169
+ state.fatalHandled = true;
170
+ try {
171
+ state.hooks.log(
172
+ buildFatalExitLog({
173
+ exitCode: 1,
174
+ handler,
175
+ isWarm: state.hooks.isWarm,
176
+ }),
177
+ fatalErrorMeta(reason),
178
+ );
179
+ } catch {
180
+ // Meta construction or the logger can throw; still run onFatal and exit(1).
181
+ }
182
+ const finish = (): void => {
183
+ state.hooks.exit(1);
184
+ };
185
+ const onFatal = state.hooks.onFatal;
186
+ if (!onFatal) {
187
+ finish();
188
+ return;
189
+ }
190
+ void Promise.resolve()
191
+ .then(() => onFatal(handler, reason))
192
+ .catch(() => undefined)
193
+ .finally(finish);
194
+ }
195
+
196
+ /**
197
+ * Arm process-level crash handlers once per host. A second call on the same
198
+ * host is a no-op for listeners and updates hooks when the caller supplies them
199
+ * (preload arms early; `createDevServer` later attaches shutdown). Shared
200
+ * across the node_modules and esbuild copies of this module.
201
+ */
202
+ export function installFatalProcessBarrier(
203
+ options: FatalProcessBarrierOptions = {},
204
+ ): () => void {
205
+ const host = options.process ?? process;
206
+ const armed = armedHosts();
207
+ const existing = armed.get(host);
208
+ if (existing) {
209
+ existing.hooks = mergeHooks(existing.hooks, options);
210
+ return existing.off;
211
+ }
212
+
213
+ const state: BarrierState = {
214
+ fatalHandled: false,
215
+ hooks: {
216
+ exit: options.exit ?? ((code: number) => process.exit(code)),
217
+ isNonFatal: options.isNonFatal,
218
+ isWarm:
219
+ options.isWarm ?? parseIsWarm(process.env.SUPERBLOCKS_WARM_STANDBY),
220
+ log: options.log ?? defaultLog,
221
+ onFatal: options.onFatal,
222
+ },
223
+ off: () => undefined,
224
+ };
225
+
226
+ const onException = (error: unknown) => {
227
+ handleCrash(state, "uncaughtException", error);
228
+ };
229
+ const onRejection = (reason: unknown) => {
230
+ handleCrash(state, "unhandledRejection", reason);
231
+ };
232
+
233
+ host.on("uncaughtException", onException);
234
+ host.on("unhandledRejection", onRejection);
235
+
236
+ state.off = () => {
237
+ armed.delete(host);
238
+ host.off("uncaughtException", onException);
239
+ host.off("unhandledRejection", onRejection);
240
+ };
241
+ armed.set(host, state);
242
+ return state.off;
243
+ }
244
+
245
+ export function isFatalProcessHandled(
246
+ host: FatalProcessHost = process,
247
+ ): boolean {
248
+ return armedHosts().get(host)?.fatalHandled ?? false;
249
+ }
250
+
251
+ /** Exit code graceful shutdown should use so a crash is not reported as 0. */
252
+ export function fatalAwareExitCode(host: FatalProcessHost = process): number {
253
+ return isFatalProcessHandled(host) ? 1 : 0;
254
+ }
255
+
256
+ /**
257
+ * Child/warm entry: arm before oclif/`dev()` so a startup crash still writes
258
+ * `event=dev_server_fatal_exit`. Safe to call again later from createDevServer.
259
+ */
260
+ export function installEarlyFatalProcessBarrier(): void {
261
+ installFatalProcessBarrier();
262
+ }
@@ -0,0 +1,12 @@
1
+ import { pathToFileURL } from "node:url";
2
+
3
+ import { installEarlyFatalProcessBarrier } from "./fatal-process-barrier.mjs";
4
+
5
+ const isMain =
6
+ process.argv[1] !== undefined &&
7
+ import.meta.url === pathToFileURL(process.argv[1]).href;
8
+
9
+ if (isMain) {
10
+ installEarlyFatalProcessBarrier();
11
+ throw new TypeError("spawn-fixture crash");
12
+ }