@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
- package/dist/cli-replacement/automatic-upgrades.js +4 -8
- package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
- package/dist/cli-replacement/automatic-upgrades.test.js +2 -1
- package/dist/cli-replacement/automatic-upgrades.test.js.map +1 -1
- package/dist/cli-replacement/dev-s3-restore.test.mjs +6 -2
- package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +6 -2
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
- package/dist/cli-replacement/dev-startup-lock-acquisition.test.d.mts +2 -0
- package/dist/cli-replacement/dev-startup-lock-acquisition.test.d.mts.map +1 -0
- package/dist/cli-replacement/dev-startup-lock-acquisition.test.mjs +487 -0
- package/dist/cli-replacement/dev-startup-lock-acquisition.test.mjs.map +1 -0
- package/dist/cli-replacement/dev.d.mts +6 -6
- package/dist/cli-replacement/dev.d.mts.map +1 -1
- package/dist/cli-replacement/dev.interception.test.mjs +12 -12
- package/dist/cli-replacement/dev.interception.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.mjs +100 -42
- package/dist/cli-replacement/dev.mjs.map +1 -1
- package/dist/dev-utils/dev-server-json-parser.test.d.mts +2 -0
- package/dist/dev-utils/dev-server-json-parser.test.d.mts.map +1 -0
- package/dist/dev-utils/dev-server-json-parser.test.mjs +60 -0
- package/dist/dev-utils/dev-server-json-parser.test.mjs.map +1 -0
- package/dist/dev-utils/dev-server.d.mts +36 -1
- package/dist/dev-utils/dev-server.d.mts.map +1 -1
- package/dist/dev-utils/dev-server.mjs +80 -73
- package/dist/dev-utils/dev-server.mjs.map +1 -1
- package/dist/dev-utils/dev-server.shutdown-lock.test.d.mts +2 -0
- package/dist/dev-utils/dev-server.shutdown-lock.test.d.mts.map +1 -0
- package/dist/dev-utils/dev-server.shutdown-lock.test.mjs +48 -0
- package/dist/dev-utils/dev-server.shutdown-lock.test.mjs.map +1 -0
- package/dist/dev-utils/fatal-process-barrier.d.mts +71 -0
- package/dist/dev-utils/fatal-process-barrier.d.mts.map +1 -0
- package/dist/dev-utils/fatal-process-barrier.mjs +161 -0
- package/dist/dev-utils/fatal-process-barrier.mjs.map +1 -0
- package/dist/dev-utils/fatal-process-barrier.spawn-fixture.d.mts +2 -0
- package/dist/dev-utils/fatal-process-barrier.spawn-fixture.d.mts.map +1 -0
- package/dist/dev-utils/fatal-process-barrier.spawn-fixture.mjs +9 -0
- package/dist/dev-utils/fatal-process-barrier.spawn-fixture.mjs.map +1 -0
- package/dist/dev-utils/fatal-process-barrier.test.d.mts +2 -0
- package/dist/dev-utils/fatal-process-barrier.test.d.mts.map +1 -0
- package/dist/dev-utils/fatal-process-barrier.test.mjs +281 -0
- package/dist/dev-utils/fatal-process-barrier.test.mjs.map +1 -0
- package/dist/telemetry/logging.js +1 -1
- package/dist/telemetry/logging.js.map +1 -1
- package/dist/telemetry/safe-stringify.d.ts.map +1 -1
- package/dist/telemetry/safe-stringify.js +25 -9
- package/dist/telemetry/safe-stringify.js.map +1 -1
- package/package.json +10 -6
- package/src/cli-replacement/automatic-upgrades.test.ts +2 -1
- package/src/cli-replacement/automatic-upgrades.ts +4 -11
- package/src/cli-replacement/dev-s3-restore.test.mts +6 -2
- package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +6 -2
- package/src/cli-replacement/dev-startup-lock-acquisition.test.mts +573 -0
- package/src/cli-replacement/dev.interception.test.mts +12 -12
- package/src/cli-replacement/dev.mts +112 -56
- package/src/dev-utils/dev-server-json-parser.test.mts +89 -0
- package/src/dev-utils/dev-server.mts +110 -93
- package/src/dev-utils/dev-server.shutdown-lock.test.mts +58 -0
- package/src/dev-utils/fatal-process-barrier.mts +262 -0
- package/src/dev-utils/fatal-process-barrier.spawn-fixture.mts +12 -0
- package/src/dev-utils/fatal-process-barrier.test.mts +346 -0
- package/src/telemetry/logging.ts +1 -1
- package/src/telemetry/safe-stringify.ts +25 -10
- package/test/safe-stringify.test.mts +25 -0
- 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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
} from "./fatal-
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
-
|
|
1370
|
-
handler: "unhandledRejection",
|
|
1389
|
+
handler,
|
|
1371
1390
|
logger,
|
|
1372
1391
|
reason,
|
|
1373
|
-
})
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|