@superblocksteam/sdk 2.0.157-next.5 → 2.0.157

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 +8 -4
  4. package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
  5. package/dist/cli-replacement/automatic-upgrades.test.js +1 -2
  6. package/dist/cli-replacement/automatic-upgrades.test.js.map +1 -1
  7. package/dist/cli-replacement/dev-s3-restore.test.mjs +2 -6
  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 +2 -6
  10. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
  11. package/dist/cli-replacement/dev.d.mts +6 -6
  12. package/dist/cli-replacement/dev.d.mts.map +1 -1
  13. package/dist/cli-replacement/dev.interception.test.mjs +12 -12
  14. package/dist/cli-replacement/dev.interception.test.mjs.map +1 -1
  15. package/dist/cli-replacement/dev.mjs +42 -100
  16. package/dist/cli-replacement/dev.mjs.map +1 -1
  17. package/dist/dev-utils/dev-server.d.mts +1 -36
  18. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  19. package/dist/dev-utils/dev-server.mjs +73 -80
  20. package/dist/dev-utils/dev-server.mjs.map +1 -1
  21. package/dist/telemetry/logging.js +1 -1
  22. package/dist/telemetry/logging.js.map +1 -1
  23. package/dist/telemetry/safe-stringify.d.ts.map +1 -1
  24. package/dist/telemetry/safe-stringify.js +9 -25
  25. package/dist/telemetry/safe-stringify.js.map +1 -1
  26. package/package.json +6 -10
  27. package/src/cli-replacement/automatic-upgrades.test.ts +1 -2
  28. package/src/cli-replacement/automatic-upgrades.ts +11 -4
  29. package/src/cli-replacement/dev-s3-restore.test.mts +2 -6
  30. package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +2 -6
  31. package/src/cli-replacement/dev.interception.test.mts +12 -12
  32. package/src/cli-replacement/dev.mts +56 -112
  33. package/src/dev-utils/dev-server.mts +93 -110
  34. package/src/telemetry/logging.ts +1 -1
  35. package/src/telemetry/safe-stringify.ts +10 -25
  36. package/test/safe-stringify.test.mts +0 -25
  37. package/tsconfig.tsbuildinfo +1 -1
  38. package/dist/cli-replacement/dev-startup-lock-acquisition.test.d.mts +0 -2
  39. package/dist/cli-replacement/dev-startup-lock-acquisition.test.d.mts.map +0 -1
  40. package/dist/cli-replacement/dev-startup-lock-acquisition.test.mjs +0 -487
  41. package/dist/cli-replacement/dev-startup-lock-acquisition.test.mjs.map +0 -1
  42. package/dist/dev-utils/dev-server-json-parser.test.d.mts +0 -2
  43. package/dist/dev-utils/dev-server-json-parser.test.d.mts.map +0 -1
  44. package/dist/dev-utils/dev-server-json-parser.test.mjs +0 -60
  45. package/dist/dev-utils/dev-server-json-parser.test.mjs.map +0 -1
  46. package/dist/dev-utils/dev-server.shutdown-lock.test.d.mts +0 -2
  47. package/dist/dev-utils/dev-server.shutdown-lock.test.d.mts.map +0 -1
  48. package/dist/dev-utils/dev-server.shutdown-lock.test.mjs +0 -48
  49. package/dist/dev-utils/dev-server.shutdown-lock.test.mjs.map +0 -1
  50. package/dist/dev-utils/fatal-process-barrier.d.mts +0 -71
  51. package/dist/dev-utils/fatal-process-barrier.d.mts.map +0 -1
  52. package/dist/dev-utils/fatal-process-barrier.mjs +0 -161
  53. package/dist/dev-utils/fatal-process-barrier.mjs.map +0 -1
  54. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.d.mts +0 -2
  55. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.d.mts.map +0 -1
  56. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.mjs +0 -9
  57. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.mjs.map +0 -1
  58. package/dist/dev-utils/fatal-process-barrier.test.d.mts +0 -2
  59. package/dist/dev-utils/fatal-process-barrier.test.d.mts.map +0 -1
  60. package/dist/dev-utils/fatal-process-barrier.test.mjs +0 -281
  61. package/dist/dev-utils/fatal-process-barrier.test.mjs.map +0 -1
  62. package/src/cli-replacement/dev-startup-lock-acquisition.test.mts +0 -573
  63. package/src/dev-utils/dev-server-json-parser.test.mts +0 -89
  64. package/src/dev-utils/dev-server.shutdown-lock.test.mts +0 -58
  65. package/src/dev-utils/fatal-process-barrier.mts +0 -262
  66. package/src/dev-utils/fatal-process-barrier.spawn-fixture.mts +0 -12
  67. package/src/dev-utils/fatal-process-barrier.test.mts +0 -346
@@ -1,58 +0,0 @@
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
- });
@@ -1,262 +0,0 @@
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
- }
@@ -1,12 +0,0 @@
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
- }
@@ -1,346 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import { createRequire } from "node:module";
3
- import { fileURLToPath } from "node:url";
4
-
5
- import { afterEach, describe, expect, it, vi } from "vitest";
6
-
7
- import { FATAL_EXIT_EVENT } from "./fatal-exit.mjs";
8
- import {
9
- fatalAwareExitCode,
10
- installFatalProcessBarrier,
11
- isFatalProcessHandled,
12
- type FatalProcessHost,
13
- } from "./fatal-process-barrier.mjs";
14
-
15
- /**
16
- * Stands in for `process` so a test can emit crash events without arming the
17
- * real handlers, which would take the test runner down with them.
18
- */
19
- function fakeProcess(): FatalProcessHost & {
20
- emit: (event: string, ...args: unknown[]) => void;
21
- listenerCount: (event: string) => number;
22
- } {
23
- const handlers = new Map<string, ((...args: unknown[]) => void)[]>();
24
- return {
25
- emit(event: string, ...args: unknown[]): void {
26
- for (const handler of handlers.get(event) ?? []) {
27
- handler(...args);
28
- }
29
- },
30
- listenerCount(event: string): number {
31
- return handlers.get(event)?.length ?? 0;
32
- },
33
- off(event: string, handler: (...args: unknown[]) => void): void {
34
- handlers.set(
35
- event,
36
- (handlers.get(event) ?? []).filter((each) => each !== handler),
37
- );
38
- },
39
- on(event: string, handler: (...args: unknown[]) => void): void {
40
- handlers.set(event, [...(handlers.get(event) ?? []), handler]);
41
- },
42
- };
43
- }
44
-
45
- describe("installFatalProcessBarrier", () => {
46
- it("logs a fatal-exit line with error meta and exits 1 when a throw happens before the server exists", async () => {
47
- const host = fakeProcess();
48
- const exit = vi.fn();
49
- const log = vi.fn();
50
- const crash = new TypeError("cannot read properties of null");
51
-
52
- installFatalProcessBarrier({ exit, isWarm: false, log, process: host });
53
- host.emit("uncaughtException", crash);
54
-
55
- await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
56
- expect(log).toHaveBeenCalledTimes(1);
57
- const [message, meta] = log.mock.calls[0] ?? [];
58
- expect(message).toContain(`event=${FATAL_EXIT_EVENT}`);
59
- expect(message).toContain("handler=uncaughtException");
60
- expect(message).toContain("exit_code=1");
61
- expect(meta).toEqual({
62
- error: {
63
- kind: "TypeError",
64
- message: "cannot read properties of null",
65
- stack: crash.stack,
66
- },
67
- });
68
- });
69
-
70
- it("serializes a plain-object rejection so the message is not [object Object]", async () => {
71
- const host = fakeProcess();
72
- const exit = vi.fn();
73
- const log = vi.fn();
74
- const reason = { code: 8, message: "[internal] Connection closed" };
75
-
76
- installFatalProcessBarrier({ exit, isWarm: false, log, process: host });
77
- host.emit("unhandledRejection", reason);
78
-
79
- await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
80
- const [, meta] = log.mock.calls[0] ?? [];
81
- expect(meta).toEqual({
82
- error: {
83
- kind: "Unknown Error",
84
- message: JSON.stringify(reason),
85
- },
86
- });
87
- });
88
-
89
- it("serializes a BigInt field in a non-Error rejection and still exits 1", async () => {
90
- const host = fakeProcess();
91
- const exit = vi.fn();
92
- const log = vi.fn();
93
- const reason = { id: 8n, message: "closed" };
94
-
95
- installFatalProcessBarrier({ exit, isWarm: false, log, process: host });
96
- host.emit("unhandledRejection", reason);
97
-
98
- await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
99
- const [message, meta] = log.mock.calls[0] ?? [];
100
- expect(String(message)).toContain(`event=${FATAL_EXIT_EVENT}`);
101
- expect(meta).toEqual({
102
- error: {
103
- kind: "Unknown Error",
104
- message: '{"id":"8","message":"closed"}',
105
- },
106
- });
107
- });
108
-
109
- it("still writes the fatal-exit line and exits 1 when toJSON throws", async () => {
110
- const host = fakeProcess();
111
- const exit = vi.fn();
112
- const log = vi.fn();
113
- const reason = {
114
- toJSON() {
115
- throw new Error("cannot serialize");
116
- },
117
- };
118
-
119
- installFatalProcessBarrier({ exit, isWarm: false, log, process: host });
120
- host.emit("unhandledRejection", reason);
121
-
122
- await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
123
- expect(log).toHaveBeenCalledTimes(1);
124
- const [message, meta] = log.mock.calls[0] ?? [];
125
- expect(String(message)).toContain(`event=${FATAL_EXIT_EVENT}`);
126
- expect(meta).toEqual({
127
- error: {
128
- kind: "Unknown Error",
129
- message: "[object Object]",
130
- },
131
- });
132
- expect(isFatalProcessHandled(host)).toBe(true);
133
- });
134
-
135
- it("still exits 1 when the fatal log itself throws after the barrier is marked handled", async () => {
136
- const host = fakeProcess();
137
- const exit = vi.fn();
138
-
139
- installFatalProcessBarrier({
140
- exit,
141
- isWarm: false,
142
- log: () => {
143
- throw new Error("logger exploded");
144
- },
145
- process: host,
146
- });
147
- host.emit("uncaughtException", new Error("boom"));
148
-
149
- await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
150
- expect(isFatalProcessHandled(host)).toBe(true);
151
- });
152
-
153
- it("labels an unhandled rejection as a rejection, not an exception", async () => {
154
- const host = fakeProcess();
155
- const exit = vi.fn();
156
- const log = vi.fn();
157
-
158
- installFatalProcessBarrier({ exit, isWarm: false, log, process: host });
159
- host.emit("unhandledRejection", new Error("upstream timed out"));
160
-
161
- await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
162
- const [message] = log.mock.calls[0] ?? [];
163
- expect(message).toContain("handler=unhandledRejection");
164
- });
165
-
166
- it("writes one fatal-exit line for a burst of crashes", async () => {
167
- const host = fakeProcess();
168
- const exit = vi.fn();
169
- const log = vi.fn();
170
-
171
- installFatalProcessBarrier({ exit, isWarm: false, log, process: host });
172
- host.emit("uncaughtException", new Error("first"));
173
- host.emit("unhandledRejection", new Error("second"));
174
- host.emit("uncaughtException", new Error("third"));
175
-
176
- await vi.waitFor(() => expect(exit).toHaveBeenCalledTimes(1));
177
- expect(log).toHaveBeenCalledTimes(1);
178
- expect(String(log.mock.calls[0]?.[0])).toContain(
179
- "handler=uncaughtException",
180
- );
181
- });
182
-
183
- it("arms one set of handlers however many times it is installed", async () => {
184
- const host = fakeProcess();
185
- const exit = vi.fn();
186
- const log = vi.fn();
187
-
188
- installFatalProcessBarrier({ exit, isWarm: false, log, process: host });
189
- installFatalProcessBarrier({ exit, isWarm: false, log, process: host });
190
- host.emit("uncaughtException", new Error("boom"));
191
-
192
- await vi.waitFor(() => expect(exit).toHaveBeenCalledTimes(1));
193
- expect(log).toHaveBeenCalledTimes(1);
194
- });
195
-
196
- it("still exits fatally when isNonFatal throws, and logs the predicate failure", async () => {
197
- const host = fakeProcess();
198
- const exit = vi.fn();
199
- const log = vi.fn();
200
- const predicateError = new Error("predicate exploded");
201
-
202
- installFatalProcessBarrier({
203
- exit,
204
- isNonFatal: () => {
205
- throw predicateError;
206
- },
207
- log,
208
- process: host,
209
- });
210
- host.emit("unhandledRejection", new Error("closed"));
211
-
212
- await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
213
- expect(log).toHaveBeenCalledTimes(2);
214
- const [predicateMessage, predicateMeta] = log.mock.calls[0] ?? [];
215
- expect(String(predicateMessage)).not.toContain(`event=${FATAL_EXIT_EVENT}`);
216
- expect(predicateMeta).toEqual({
217
- error: {
218
- kind: "Error",
219
- message: "predicate exploded",
220
- stack: predicateError.stack,
221
- },
222
- });
223
- const [fatalMessage] = log.mock.calls[1] ?? [];
224
- expect(String(fatalMessage)).toContain(`event=${FATAL_EXIT_EVENT}`);
225
- });
226
-
227
- it("keeps the process alive when isNonFatal says the reason is recoverable", () => {
228
- const host = fakeProcess();
229
- const exit = vi.fn();
230
- const log = vi.fn();
231
-
232
- installFatalProcessBarrier({
233
- exit,
234
- isNonFatal: (_handler, reason) =>
235
- reason instanceof Error && reason.message === "closed",
236
- log,
237
- process: host,
238
- });
239
- host.emit("unhandledRejection", new Error("closed"));
240
-
241
- expect(exit).not.toHaveBeenCalled();
242
- expect(log).not.toHaveBeenCalled();
243
- });
244
-
245
- it("upgrades onFatal on an already-armed host without a second listener", async () => {
246
- const host = fakeProcess();
247
- const exit = vi.fn();
248
- const log = vi.fn();
249
- const onFatal = vi.fn();
250
-
251
- installFatalProcessBarrier({ exit, isWarm: false, log, process: host });
252
- installFatalProcessBarrier({ onFatal, process: host });
253
- host.emit("uncaughtException", new Error("boom"));
254
-
255
- await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
256
- expect(onFatal).toHaveBeenCalledTimes(1);
257
- expect(onFatal.mock.calls[0]?.[0]).toBe("uncaughtException");
258
- });
259
-
260
- it("reports a crash as exit code 1 so graceful shutdown cannot claim 0", async () => {
261
- const host = fakeProcess();
262
- const exit = vi.fn();
263
-
264
- installFatalProcessBarrier({
265
- exit,
266
- log: () => undefined,
267
- onFatal: () => undefined,
268
- process: host,
269
- });
270
- expect(fatalAwareExitCode(host)).toBe(0);
271
- host.emit("uncaughtException", new Error("boom"));
272
- await vi.waitFor(() => expect(isFatalProcessHandled(host)).toBe(true));
273
- expect(fatalAwareExitCode(host)).toBe(1);
274
- });
275
- });
276
-
277
- describe("installFatalProcessBarrier across CLI module copies", () => {
278
- afterEach(() => {
279
- vi.resetModules();
280
- });
281
-
282
- it("shares hooks across module instances, as when bin/run.js and the esbuild bundle each load their own", async () => {
283
- const host = fakeProcess();
284
- const exit = vi.fn();
285
- const log = vi.fn();
286
- const onFatal = vi.fn();
287
-
288
- const early = await import("./fatal-process-barrier.mjs");
289
- early.installFatalProcessBarrier({
290
- exit,
291
- isWarm: false,
292
- log,
293
- process: host,
294
- });
295
- expect(host.listenerCount("uncaughtException")).toBe(1);
296
- expect(host.listenerCount("unhandledRejection")).toBe(1);
297
-
298
- vi.resetModules();
299
- const later = await import("./fatal-process-barrier.mjs");
300
- later.installFatalProcessBarrier({
301
- exit,
302
- isNonFatal: (_handler, reason) =>
303
- reason instanceof Error && reason.message === "closed",
304
- log,
305
- onFatal,
306
- process: host,
307
- });
308
-
309
- expect(host.listenerCount("uncaughtException")).toBe(1);
310
- expect(host.listenerCount("unhandledRejection")).toBe(1);
311
-
312
- host.emit("unhandledRejection", new Error("closed"));
313
- expect(exit).not.toHaveBeenCalled();
314
- expect(log).not.toHaveBeenCalled();
315
- expect(onFatal).not.toHaveBeenCalled();
316
-
317
- host.emit("uncaughtException", new Error("boom"));
318
- await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
319
- expect(onFatal).toHaveBeenCalledTimes(1);
320
- expect(log).toHaveBeenCalledTimes(1);
321
- });
322
- });
323
-
324
- describe("installEarlyFatalProcessBarrier in a real Node process", () => {
325
- it("writes the fatal-exit token and stack to stderr before exiting 1", () => {
326
- const fixture = fileURLToPath(
327
- new URL("./fatal-process-barrier.spawn-fixture.mts", import.meta.url),
328
- );
329
- const require = createRequire(
330
- fileURLToPath(new URL("../../package.json", import.meta.url)),
331
- );
332
- const tsx = require.resolve("tsx");
333
- const result = spawnSync(process.execPath, ["--import", tsx, fixture], {
334
- encoding: "utf8",
335
- env: { ...process.env, NODE_OPTIONS: "" },
336
- timeout: 30_000,
337
- });
338
-
339
- const output = `${result.stdout}${result.stderr}`;
340
- expect(result.status).toBe(1);
341
- expect(output).toContain(`event=${FATAL_EXIT_EVENT}`);
342
- expect(output).toContain("handler=uncaughtException");
343
- expect(output).toContain("spawn-fixture crash");
344
- expect(output).toMatch(/TypeError/);
345
- });
346
- });