@ngockhoale/ukit 3.0.11 → 3.1.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/CHANGELOG.md +17 -1
- package/README.md +1 -0
- package/manifests/documentation.yaml +12 -0
- package/package.json +1 -1
- package/scripts/bench/data-foundation.mjs +368 -50
- package/src/cli/commands/doctor.js +232 -3
- package/src/cli/commands/feedback.js +64 -1
- package/src/cli/commands/install.js +18 -0
- package/src/cli/commands/memory.js +42 -37
- package/src/cli/commands/telemetry.js +460 -0
- package/src/cli/index.js +7 -0
- package/src/core/agentRuntime/adapters.js +83 -2
- package/src/core/agentRuntime/diagnostics.js +104 -0
- package/src/core/agentRuntime/supervisor.js +137 -0
- package/src/core/agentRuntime/telemetry.js +204 -0
- package/src/core/memory/memoryEmit.js +131 -0
- package/src/core/memory/memoryHit.js +1 -1
- package/src/core/memory/migrate.js +18 -11
- package/src/core/memory/migrateMapping.js +15 -7
- package/src/core/memory/mutateMemory.js +22 -4
- package/src/core/memory/recordIndex.js +10 -3
- package/src/core/memory/recordStore.js +28 -3
- package/src/core/memory/retrieval.js +79 -38
- package/src/core/memory/store.js +37 -37
- package/src/core/memory/storeV2.js +28 -25
- package/src/core/memory/storeV2Loader.js +2 -2
- package/src/core/observability/adapters/ingest.js +576 -0
- package/src/core/observability/analytics/anomalies.js +415 -0
- package/src/core/observability/analytics/summary.js +16 -1
- package/src/core/observability/emit/config.js +69 -1
- package/src/core/observability/emit/crash.js +434 -0
- package/src/core/observability/emit/lifecycle.js +349 -0
- package/src/core/observability/emit/recorder.js +135 -9
- package/src/core/observability/evaluation/aiPacket.js +52 -10
- package/src/core/observability/evaluation/outcomes.js +95 -0
- package/src/core/observability/evaluation/runner.js +225 -0
- package/src/core/observability/privacy/allowlist.js +23 -3
- package/src/core/observability/schema/compatibility.js +48 -3
- package/src/core/observability/schema/constants.js +5 -0
- package/src/core/observability/schema/registry.js +57 -0
- package/src/core/observability/schema/validate.js +68 -6
- package/src/core/observability/segments/internal.js +42 -8
- package/src/core/observability/segments/readSegments.js +35 -1
- package/src/core/observability/segments/recovery.js +3 -2
- package/src/core/observability/segments/retention.js +137 -33
- package/src/core/observability/support/projector.js +88 -18
- package/src/core/observability/support/provision.js +160 -0
- package/src/core/observability/support/renderer.js +2 -2
- package/src/core/observability/support/schedule.js +174 -0
- package/template_project/.claude/hooks/auto-allow-bash.sh +7 -1
- package/template_project/.claude/hooks/auto-prune-bash.sh +16 -7
- package/template_project/.claude/hooks/verification-guard.sh +13 -4
- package/template_project/.claude/ukit/runtime/async-lock.mjs +26 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crash.js (TASK-004, SPEC §5 DF2-FR04, §7, §10) — bounded crash capture.
|
|
3
|
+
*
|
|
4
|
+
* registerCrashCapture({ root, writerId, bootId, now? })
|
|
5
|
+
* → { ok: true, dispose, _onError }
|
|
6
|
+
* → { ok: false, reason, dispose, _onError }
|
|
7
|
+
* readCrashReports(root)
|
|
8
|
+
* → { ok: true, crashes, coverage:{files,parsed,corrupt,expired} }
|
|
9
|
+
* → { ok: false, reason }
|
|
10
|
+
*
|
|
11
|
+
* Behaviour contract (DF2-FR04):
|
|
12
|
+
* - Installs process handlers for uncaughtException, unhandledRejection,
|
|
13
|
+
* SIGINT, SIGTERM. Every capture is BEST-EFFORT: synchronous fs only
|
|
14
|
+
* (signal handlers cannot await), never throws, never blocks.
|
|
15
|
+
* - Writes `crashes/crash-<boot>-<seq>.json` via temp-write + rename
|
|
16
|
+
* (all-or-nothing publish), capped at MAX_CRASH_FILES = 10, FIFO —
|
|
17
|
+
* the oldest file is evicted, newest data wins (flight-recorder rule).
|
|
18
|
+
* The seq counter is shared per (dir, bootId) so a second registration
|
|
19
|
+
* in the same process appends instead of overwriting prior files.
|
|
20
|
+
* - Original signal/error semantics are preserved (AC2). When several
|
|
21
|
+
* captures are live the LAST-registered one decides fate; every
|
|
22
|
+
* sibling still records first (Node emits to a listener snapshot —
|
|
23
|
+
* removal mid-emit never skips a sibling's write). The deciding
|
|
24
|
+
* handler strips all of this module's listeners for the event, so
|
|
25
|
+
* the two-registration case can never mutual-defer into a live
|
|
26
|
+
* process swallowing a fatal error/signal:
|
|
27
|
+
* signals — when a foreign listener survives we return and
|
|
28
|
+
* it decides; otherwise every listener is removed
|
|
29
|
+
* and we re-kill self with the same signal so the
|
|
30
|
+
* OS default (die by signal, exit 128+n) applies.
|
|
31
|
+
* uncaughtException — when no foreign listener survives we print
|
|
32
|
+
* the original stack to stderr and exit(1) —
|
|
33
|
+
* UNCONDITIONALLY, a stderr/write failure must
|
|
34
|
+
* not leave the loop alive. (Re-throwing inside
|
|
35
|
+
* the handler exits 7, not 1 — it cannot restore
|
|
36
|
+
* the default, so we reproduce it explicitly.)
|
|
37
|
+
* unhandledRejection — same rule; the reason is wrapped when it
|
|
38
|
+
* is not Error-like so a printed stack exists.
|
|
39
|
+
* - Crash records pass through sanitizeObserved (the canonical write
|
|
40
|
+
* gate): payload carries codes[], digest, kind/signal/error_kind/
|
|
41
|
+
* exit_code ONLY — no stack strings, no paths, no argv, no messages.
|
|
42
|
+
* `digest` is a sha256 of normalised stack frames (fn name + file
|
|
43
|
+
* basename, line:col stripped) so identical crashes correlate
|
|
44
|
+
* without leaking the filesystem layout.
|
|
45
|
+
*
|
|
46
|
+
* readCrashReports is synchronous by design (same fs-only constraint);
|
|
47
|
+
* callers may still `await` it harmlessly. `coverage.expired` counts
|
|
48
|
+
* crash files older than CRASH_EXPIRY_MS (30 days) — they are excluded
|
|
49
|
+
* from `crashes` and are candidates for projection-side cleanup.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import fs from 'node:fs';
|
|
53
|
+
import path from 'node:path';
|
|
54
|
+
import crypto from 'node:crypto';
|
|
55
|
+
|
|
56
|
+
import { sanitizeObserved } from '../privacy/sanitizeObserved.js';
|
|
57
|
+
import { SCHEMA_VERSION } from '../schema/constants.js';
|
|
58
|
+
import { ioReason, sanitizeNameComponent } from '../segments/internal.js';
|
|
59
|
+
|
|
60
|
+
export const CRASH_DIR_NAME = 'crashes';
|
|
61
|
+
export const MAX_CRASH_FILES = 10;
|
|
62
|
+
export const CRASH_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000;
|
|
63
|
+
|
|
64
|
+
const CRASH_FILE_RE = /^crash-.+\.json$/;
|
|
65
|
+
const SIGNALS = ['SIGINT', 'SIGTERM'];
|
|
66
|
+
/** Conventional shell exit codes (128 + signal number) for the kill fallback. */
|
|
67
|
+
const SIGNAL_EXIT_CODES = Object.freeze({ SIGINT: 130, SIGTERM: 143 });
|
|
68
|
+
const ERROR_CODE_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
69
|
+
const SAFE_IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Process-wide coordination between concurrent registrations.
|
|
73
|
+
*
|
|
74
|
+
* liveOrder — event name → registered handler list in listener order. A
|
|
75
|
+
* second registerCrashCapture must NOT defuse the process: every
|
|
76
|
+
* capture still records (Node emits to a listener snapshot — removing
|
|
77
|
+
* listeners mid-emit never skips them), then the LAST-registered live
|
|
78
|
+
* handler alone decides fate. Without this, two captures each see
|
|
79
|
+
* "another listener exists", both defer, and a fatal signal/error
|
|
80
|
+
* leaves the process alive.
|
|
81
|
+
* fileSeq — file-key prefix (dir + sanitized boot id) → last assigned
|
|
82
|
+
* seq. Two registrations sharing root+bootId would otherwise each
|
|
83
|
+
* count their own seq from 1 and overwrite each other's
|
|
84
|
+
* `crash-<boot>-<seq>.json` records.
|
|
85
|
+
*/
|
|
86
|
+
const liveOrder = new Map();
|
|
87
|
+
const fileSeq = new Map();
|
|
88
|
+
|
|
89
|
+
function orderAdd(event, handler) {
|
|
90
|
+
const order = liveOrder.get(event);
|
|
91
|
+
if (order) order.push(handler);
|
|
92
|
+
else liveOrder.set(event, [handler]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function orderRemove(event, handler) {
|
|
96
|
+
const order = liveOrder.get(event);
|
|
97
|
+
if (!order) return;
|
|
98
|
+
const i = order.indexOf(handler);
|
|
99
|
+
if (i >= 0) order.splice(i, 1);
|
|
100
|
+
if (order.length === 0) liveOrder.delete(event);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** True when this handler is the last live capture for `event`. */
|
|
104
|
+
function decidesFate(event, handler) {
|
|
105
|
+
const order = liveOrder.get(event);
|
|
106
|
+
return Boolean(order) && order.length > 0 && order[order.length - 1] === handler;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Monotonic per-(dir,bootId) sequence — shared across registrations. */
|
|
110
|
+
function nextFileSeq(dir, bootKey) {
|
|
111
|
+
const key = `${dir}::${bootKey}`;
|
|
112
|
+
const seq = (fileSeq.get(key) || 0) + 1;
|
|
113
|
+
fileSeq.set(key, seq);
|
|
114
|
+
return seq;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Numeric seq parsed from a crash filename (0 when absent/malformed). */
|
|
118
|
+
function fileSeqOf(name) {
|
|
119
|
+
const m = /-(\d+)\.json$/.exec(name);
|
|
120
|
+
return m ? Number(m[1]) : 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sha256(text) {
|
|
124
|
+
return 'sha256:' + crypto.createHash('sha256').update(text).digest('hex');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isErrorLike(value) {
|
|
128
|
+
return value instanceof Error || (value !== null && typeof value === 'object' && typeof value.name === 'string' && typeof value.stack === 'string');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Reduce a stack to frame signatures: function name + file basename,
|
|
133
|
+
* absolute path segments and line:col stripped. Never persisted raw —
|
|
134
|
+
* only hashed into `digest`.
|
|
135
|
+
*/
|
|
136
|
+
function normalizeStackFrames(stack) {
|
|
137
|
+
if (typeof stack !== 'string') return null;
|
|
138
|
+
const frames = [];
|
|
139
|
+
for (const rawLine of stack.split('\n')) {
|
|
140
|
+
const line = rawLine.trim();
|
|
141
|
+
if (!line.startsWith('at ')) continue;
|
|
142
|
+
let s = line.slice(3).trim();
|
|
143
|
+
s = s.replace(/^file:\/\//, '');
|
|
144
|
+
// Unwrap `fn (location)` → `fn location`.
|
|
145
|
+
s = s.replace(/\(([^()]*)\)/g, ' $1 ');
|
|
146
|
+
// Collapse any absolute path (posix or windows) to its basename.
|
|
147
|
+
s = s.replace(/(?:[A-Za-z]:)?[\\/][^\s()]*?[\\/]([^\\/()\s]+)/g, '$1');
|
|
148
|
+
s = s.replace(/:[0-9]+(?::[0-9]+)?/g, '');
|
|
149
|
+
s = s.replace(/\s+/g, ' ').trim();
|
|
150
|
+
if (s.length > 0) frames.push(s);
|
|
151
|
+
if (frames.length >= 16) break;
|
|
152
|
+
}
|
|
153
|
+
return frames.length > 0 ? frames : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function errorKindOf(err, trigger) {
|
|
157
|
+
if (trigger === 'SIGINT' || trigger === 'SIGTERM') return null;
|
|
158
|
+
if (isErrorLike(err)) {
|
|
159
|
+
const name = typeof err.name === 'string' && SAFE_IDENTIFIER_RE.test(err.name) ? err.name : 'Error';
|
|
160
|
+
return name;
|
|
161
|
+
}
|
|
162
|
+
return 'non_error';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function codesFor(err, trigger) {
|
|
166
|
+
const codes = [String(trigger).toUpperCase()];
|
|
167
|
+
if (isErrorLike(err) && typeof err.code === 'string' && ERROR_CODE_RE.test(err.code) && !codes.includes(err.code)) {
|
|
168
|
+
codes.push(err.code);
|
|
169
|
+
}
|
|
170
|
+
return codes.slice(0, 4);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function digestOf(err, trigger, errorKind) {
|
|
174
|
+
const frames = isErrorLike(err) ? normalizeStackFrames(err.stack) : null;
|
|
175
|
+
const basis = frames ? `${trigger}\n${frames.join('\n')}` : `${trigger}|${errorKind || ''}`;
|
|
176
|
+
return sha256(basis);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function toError(value) {
|
|
180
|
+
if (isErrorLike(value)) return value;
|
|
181
|
+
return new Error('Unhandled rejection with non-Error reason');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Synchronous read of the crash directory for projection/health surfaces.
|
|
186
|
+
* @returns {{ok:true,crashes:object[],coverage:{files:number,parsed:number,corrupt:number,expired:number}}
|
|
187
|
+
* |{ok:false,reason:string}}
|
|
188
|
+
*/
|
|
189
|
+
export function readCrashReports(root) {
|
|
190
|
+
const empty = { files: 0, parsed: 0, corrupt: 0, expired: 0 };
|
|
191
|
+
if (typeof root !== 'string' || root.length === 0) {
|
|
192
|
+
return { ok: false, reason: 'invalid_root' };
|
|
193
|
+
}
|
|
194
|
+
const dir = path.join(root, CRASH_DIR_NAME);
|
|
195
|
+
let names;
|
|
196
|
+
try {
|
|
197
|
+
const st = fs.lstatSync(dir);
|
|
198
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
199
|
+
return { ok: false, reason: 'unsupported_file_type' };
|
|
200
|
+
}
|
|
201
|
+
names = fs.readdirSync(dir).filter((n) => CRASH_FILE_RE.test(n));
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (err && err.code === 'ENOENT') {
|
|
204
|
+
return { ok: true, crashes: [], coverage: empty };
|
|
205
|
+
}
|
|
206
|
+
return { ok: false, reason: ioReason(err) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const nowMs = Date.now();
|
|
210
|
+
const crashes = [];
|
|
211
|
+
const coverage = { ...empty };
|
|
212
|
+
for (const name of names) {
|
|
213
|
+
coverage.files += 1;
|
|
214
|
+
const file = path.join(dir, name);
|
|
215
|
+
try {
|
|
216
|
+
const st = fs.statSync(file);
|
|
217
|
+
if (nowMs - st.mtimeMs > CRASH_EXPIRY_MS) {
|
|
218
|
+
coverage.expired += 1;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
222
|
+
if (record === null || typeof record !== 'object' || Array.isArray(record)) {
|
|
223
|
+
coverage.corrupt += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
coverage.parsed += 1;
|
|
227
|
+
crashes.push({ file: name, mtime_ms: st.mtimeMs, record });
|
|
228
|
+
} catch {
|
|
229
|
+
coverage.corrupt += 1;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Newest first — mtime desc; numeric seq desc on ties so seq 10 sorts
|
|
233
|
+
// after seq 9 (a lexical filename tiebreak inverts them); lexical asc
|
|
234
|
+
// for deterministic order across boots when both fields tie.
|
|
235
|
+
crashes.sort((a, b) => (b.mtime_ms - a.mtime_ms) || (fileSeqOf(b.file) - fileSeqOf(a.file)) || (a.file < b.file ? -1 : 1));
|
|
236
|
+
return { ok: true, crashes: crashes.map((c) => c.record), coverage };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Install bounded crash capture. Never throws.
|
|
241
|
+
* `now` is an injectable wall clock returning epoch milliseconds.
|
|
242
|
+
*/
|
|
243
|
+
export function registerCrashCapture({ root, writerId, bootId, now } = {}) {
|
|
244
|
+
const validRoot = typeof root === 'string' && root.length > 0;
|
|
245
|
+
const dir = validRoot ? path.join(root, CRASH_DIR_NAME) : null;
|
|
246
|
+
const clock = typeof now === 'function' ? now : Date.now;
|
|
247
|
+
const state = {
|
|
248
|
+
disposed: false,
|
|
249
|
+
bootKey: sanitizeNameComponent(bootId),
|
|
250
|
+
// Rejection reasons re-emitted for capture are deduped once. WeakSet —
|
|
251
|
+
// a long-lived capture must not retain every captured error object.
|
|
252
|
+
seen: new WeakSet(),
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
function wallTimeUtc() {
|
|
256
|
+
try {
|
|
257
|
+
const t = new Date(clock());
|
|
258
|
+
return Number.isNaN(t.getTime()) ? new Date().toISOString() : t.toISOString();
|
|
259
|
+
} catch {
|
|
260
|
+
return new Date().toISOString();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function pruneCrashes() {
|
|
265
|
+
// Oldest-by-sequence evicted first; mtimes are a fallback only (two
|
|
266
|
+
// writes can share an mtime tick and lexical seq order is wrong for
|
|
267
|
+
// seq ≥ 10). Best-effort: individual unlink failures are ignored.
|
|
268
|
+
const names = fs.readdirSync(dir)
|
|
269
|
+
.filter((n) => CRASH_FILE_RE.test(n))
|
|
270
|
+
.map((n) => {
|
|
271
|
+
const m = /-(\d+)\.json$/.exec(n);
|
|
272
|
+
return { name: n, seq: m ? Number(m[1]) : Number.MAX_SAFE_INTEGER };
|
|
273
|
+
})
|
|
274
|
+
.sort((a, b) => a.seq - b.seq || (a.name < b.name ? -1 : 1));
|
|
275
|
+
for (let i = 0; i < names.length - MAX_CRASH_FILES; i += 1) {
|
|
276
|
+
try { fs.unlinkSync(path.join(dir, names[i].name)); } catch {}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function writeRecord(record, name) {
|
|
281
|
+
const target = path.join(dir, name);
|
|
282
|
+
const tmpPath = `${target}.tmp-${process.pid}`;
|
|
283
|
+
try {
|
|
284
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(record)}\n`, 'utf8');
|
|
285
|
+
fs.renameSync(tmpPath, target);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The exported injection seam (tests call it directly; handlers bind it).
|
|
293
|
+
function _onError(err, trigger = 'uncaught_exception') {
|
|
294
|
+
try {
|
|
295
|
+
if (state.disposed || dir === null) return;
|
|
296
|
+
if (err !== null && (typeof err === 'object' || typeof err === 'function')) {
|
|
297
|
+
if (state.seen.has(err)) return;
|
|
298
|
+
state.seen.add(err);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const isSignal = trigger === 'SIGINT' || trigger === 'SIGTERM';
|
|
302
|
+
const errorKind = errorKindOf(err, trigger);
|
|
303
|
+
const payload = {
|
|
304
|
+
kind: isSignal ? 'signal' : 'error',
|
|
305
|
+
codes: codesFor(err, trigger),
|
|
306
|
+
digest: digestOf(err, trigger, errorKind),
|
|
307
|
+
};
|
|
308
|
+
if (isSignal) {
|
|
309
|
+
payload.signal = trigger;
|
|
310
|
+
payload.exit_code = SIGNAL_EXIT_CODES[trigger] || 1;
|
|
311
|
+
} else {
|
|
312
|
+
payload.error_kind = errorKind;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const sanitized = sanitizeObserved({
|
|
316
|
+
record_type: 'fact',
|
|
317
|
+
semantic_name: 'telemetry.crashed',
|
|
318
|
+
schema_version: SCHEMA_VERSION,
|
|
319
|
+
boot_id: String(bootId ?? 'unknown'),
|
|
320
|
+
writer_id: String(writerId ?? 'unknown'),
|
|
321
|
+
wall_time_utc: wallTimeUtc(),
|
|
322
|
+
importance: 'high',
|
|
323
|
+
privacy_class: 'private',
|
|
324
|
+
payload,
|
|
325
|
+
});
|
|
326
|
+
if (!sanitized.ok) return;
|
|
327
|
+
|
|
328
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
329
|
+
writeRecord(
|
|
330
|
+
sanitized.record,
|
|
331
|
+
`crash-${state.bootKey}-${nextFileSeq(dir, state.bootKey)}.json`,
|
|
332
|
+
);
|
|
333
|
+
pruneCrashes();
|
|
334
|
+
} catch {
|
|
335
|
+
// Best-effort by contract: a capture failure can never take the
|
|
336
|
+
// process down, so every path out of here is silent.
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// --- handler wiring -------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Reproduce Node's default fatal semantics once we are the sole
|
|
344
|
+
* listener: raw stack (or reason text) to stderr, exit code 1. The
|
|
345
|
+
* exit is UNCONDITIONAL — a stderr/write failure must still end the
|
|
346
|
+
* process, never silently swallow a fatal error (setting only
|
|
347
|
+
* process.exitCode leaves an alive loop running: STILL ALIVE repro).
|
|
348
|
+
*/
|
|
349
|
+
function fatalExit(err) {
|
|
350
|
+
try {
|
|
351
|
+
process.removeAllListeners('uncaughtException');
|
|
352
|
+
process.removeAllListeners('unhandledRejection');
|
|
353
|
+
const errLike = toError(err);
|
|
354
|
+
process.stderr.write(`${errLike.stack || errLike}\n`);
|
|
355
|
+
} catch {
|
|
356
|
+
// Reporting is best-effort; the exit below must run regardless.
|
|
357
|
+
}
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function onUncaughtException(err) {
|
|
362
|
+
_onError(err, 'uncaught_exception');
|
|
363
|
+
if (!decidesFate('uncaughtException', onUncaughtException)) return;
|
|
364
|
+
// A surviving foreign delegate owns the process fate — do not exit.
|
|
365
|
+
if (process.listenerCount('uncaughtException') > liveOrder.get('uncaughtException').length) return;
|
|
366
|
+
fatalExit(err);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function onUnhandledRejection(reason) {
|
|
370
|
+
_onError(reason, 'unhandled_rejection');
|
|
371
|
+
if (!decidesFate('unhandledRejection', onUnhandledRejection)) return;
|
|
372
|
+
if (process.listenerCount('unhandledRejection') > liveOrder.get('unhandledRejection').length) return;
|
|
373
|
+
fatalExit(reason);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const signalHandlers = new Map();
|
|
377
|
+
function onSignal(signal) {
|
|
378
|
+
const handler = signalHandlers.get(signal);
|
|
379
|
+
_onError(null, signal);
|
|
380
|
+
if (!decidesFate(signal, handler)) return;
|
|
381
|
+
const order = liveOrder.get(signal);
|
|
382
|
+
// Foreign delegate owns the fate when one survives our listeners.
|
|
383
|
+
if (process.listenerCount(signal) > order.length) return;
|
|
384
|
+
// Restore OS semantics: strip every listener on the signal (sibling
|
|
385
|
+
// captures already recorded — Node emitted to a snapshot, so removal
|
|
386
|
+
// mid-emit never skips them), then re-raise the same signal on self.
|
|
387
|
+
// POSIX kills synchronously; the explicit exit covers platforms where
|
|
388
|
+
// the kill is emulated (Windows) so 128+n is still honoured.
|
|
389
|
+
process.removeAllListeners(signal);
|
|
390
|
+
try {
|
|
391
|
+
process.kill(process.pid, signal);
|
|
392
|
+
} catch {}
|
|
393
|
+
process.exit(SIGNAL_EXIT_CODES[signal] || 1);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
process.on('uncaughtException', onUncaughtException);
|
|
397
|
+
orderAdd('uncaughtException', onUncaughtException);
|
|
398
|
+
process.on('unhandledRejection', onUnhandledRejection);
|
|
399
|
+
orderAdd('unhandledRejection', onUnhandledRejection);
|
|
400
|
+
for (const signal of SIGNALS) {
|
|
401
|
+
const handler = () => onSignal(signal);
|
|
402
|
+
signalHandlers.set(signal, handler);
|
|
403
|
+
process.on(signal, handler);
|
|
404
|
+
orderAdd(signal, handler);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function dispose() {
|
|
408
|
+
if (state.disposed) return;
|
|
409
|
+
state.disposed = true;
|
|
410
|
+
process.removeListener('uncaughtException', onUncaughtException);
|
|
411
|
+
orderRemove('uncaughtException', onUncaughtException);
|
|
412
|
+
process.removeListener('unhandledRejection', onUnhandledRejection);
|
|
413
|
+
orderRemove('unhandledRejection', onUnhandledRejection);
|
|
414
|
+
for (const [signal, handler] of signalHandlers) {
|
|
415
|
+
process.removeListener(signal, handler);
|
|
416
|
+
orderRemove(signal, handler);
|
|
417
|
+
}
|
|
418
|
+
signalHandlers.clear();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// --- initial dir probe ----------------------------------------------------
|
|
422
|
+
// Handlers are installed even when the probe fails: the dir may become
|
|
423
|
+
// writable later, and every write retries mkdir — capture stays silent
|
|
424
|
+
// and best-effort either way.
|
|
425
|
+
if (!validRoot) {
|
|
426
|
+
return { ok: false, reason: 'invalid_root', dispose, _onError };
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
430
|
+
} catch (err) {
|
|
431
|
+
return { ok: false, reason: ioReason(err), dispose, _onError };
|
|
432
|
+
}
|
|
433
|
+
return { ok: true, dispose, _onError };
|
|
434
|
+
}
|