@superblocksteam/sdk 2.0.156 → 2.0.157-next.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/.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.d.mts +34 -1
- package/dist/dev-utils/dev-server.d.mts.map +1 -1
- package/dist/dev-utils/dev-server.mjs +69 -70
- 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.mts +93 -88
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
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
|
+
});
|
package/src/telemetry/logging.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
function fallbackString(value: unknown): string {
|
|
2
|
+
try {
|
|
3
|
+
return String(value);
|
|
4
|
+
} catch {
|
|
5
|
+
return "[unserializable]";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
1
9
|
export function safeStringify(obj: unknown): string {
|
|
2
10
|
if (obj === null || obj === undefined) {
|
|
3
11
|
return String(obj);
|
|
@@ -9,15 +17,22 @@ export function safeStringify(obj: unknown): string {
|
|
|
9
17
|
return String(obj);
|
|
10
18
|
}
|
|
11
19
|
|
|
12
|
-
const seen = new WeakSet();
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
const seen = new WeakSet<object>();
|
|
21
|
+
try {
|
|
22
|
+
return JSON.stringify(obj, (_key, value: unknown) => {
|
|
23
|
+
if (typeof value === "bigint") {
|
|
24
|
+
return value.toString();
|
|
25
|
+
}
|
|
26
|
+
if (value === null || typeof value !== "object") {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
if (seen.has(value)) {
|
|
30
|
+
return "[Circular]";
|
|
31
|
+
}
|
|
32
|
+
seen.add(value);
|
|
15
33
|
return value;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
seen.add(value);
|
|
21
|
-
return value;
|
|
22
|
-
});
|
|
34
|
+
});
|
|
35
|
+
} catch {
|
|
36
|
+
return fallbackString(obj);
|
|
37
|
+
}
|
|
23
38
|
}
|
|
@@ -15,4 +15,29 @@ describe("safeStringify", () => {
|
|
|
15
15
|
obj.self = obj;
|
|
16
16
|
expect(safeStringify(obj)).toBe('{"a":1,"self":"[Circular]"}');
|
|
17
17
|
});
|
|
18
|
+
|
|
19
|
+
it("does not throw on BigInt fields", () => {
|
|
20
|
+
expect(safeStringify({ id: 8n })).toBe('{"id":"8"}');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("does not throw when toJSON throws", () => {
|
|
24
|
+
const obj = {
|
|
25
|
+
toJSON() {
|
|
26
|
+
throw new Error("cannot serialize");
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
expect(safeStringify(obj)).toBe("[object Object]");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("does not throw when both toJSON and toString throw", () => {
|
|
33
|
+
const obj = {
|
|
34
|
+
toJSON() {
|
|
35
|
+
throw new Error("cannot serialize");
|
|
36
|
+
},
|
|
37
|
+
toString() {
|
|
38
|
+
throw new Error("cannot stringify");
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
expect(safeStringify(obj)).toBe("[unserializable]");
|
|
42
|
+
});
|
|
18
43
|
});
|