Debugging and diagnostics
The practical toolkit for diagnosing a misbehaving Cruft program: reading stack traces, the --audit capability log, semantic breakpoints, finding memory leaks with the GC environment variables, debugging sealed-mode grant denials, and what each exit code tells you.
Your Cruft program is misbehaving. This page is the practical toolkit for finding out why: stack traces, memory, capability footprint, compatibility exceptions, and the leak-hunting knobs, with the gaps you'll hit called out.
Reading a crash
An uncaught error prints the message and a full stack trace, with each frame named and located as a file:/// URL:
$ cruft app.mjs
cruft: evaluation error: Error: crash here
at inner (file:///…/app.mjs:1:35)
at outer (file:///…/app.mjs:2:19)
at file:///…/app.mjs:3:1
Inline -e code shows as bare positions, REPL input as [repl:N]. The same detail is on error.stack if you catch the error yourself.
The trace is column-accurate and reconstructs frames lost to tail-call optimization. For the bugs that never throw — a silent coercion, a missing argument, a property that misses the prototype chain — arm a semantic breakpoint (CRUFT_SEMBP) and Exegesis reports each event with its exact position and stack trace. See Semantic debugging for the full surface.
Both async and synchronous top-level throws fire a process.on('uncaughtException') handler, so a cleanup handler runs whether your main body throws directly or an async callback does:
process.on("uncaughtException", (e) => {
console.error("cleanup then exit:", e.message);
process.exit(1);
});
unhandledRejection fires for async rejections the same way.
Syntax-checking without running
$ cruft --check app.js # exit 0 if valid, 65 + message on a syntax error
Use it in CI or a pre-commit hook to catch parse errors before execution.
"What does this program touch?", --audit
The single most useful behavioral-debugging tool. It runs the program normally and records every I/O capability use, attributed to the module that caused it:
$ cruft --audit app.mjs
# cruft audit log — N records
# format: <caller>\t<capability>\t<operation>\t<unix_micros>
file:///…/app.mjs stdio write(stdout) 1784994699155160
file:///…/node_modules/foo/index.js fs read(/etc/passwd) …
Reach for it when a dependency does something unexpected (that fs read of /etc/passwd you didn't authorize), when you're deciding what to grant before sealing, or just to understand a program's real footprint. --audit-log <path> sends the log to a file instead of stderr.
Memory: finding leaks
process.memoryUsage() reports real resident memory in rss, matching /usr/bin/time:
console.log((process.memoryUsage().rss / 1048576).toFixed(1) + " MB");
Gap to know: only external and arrayBuffers report 0. rss is real, and heapTotal/heapUsed report a real (though coarse — heapTotal == heapUsed) figure that tracks the working set. The external/arrayBuffers breakdown isn't available, so use rss (or the coarse heap figure) for memory tracking.
If RSS climbs and you suspect a leak, the decision procedure:
- Is it the live floor? A worker pool has ~0.3–0.4 MB of irreducible per-worker cost. GC tuning won't touch it.
- Is it churn headroom? Lower
CRUFT_GC_HEADROOM(default2.0, floor1.1) or setCRUFT_GC_TARGET_MB=<N>and watch the plateau drop toward the working set. - Does
CRUFT_GC_STRESS=1eliminate it? Stress mode forces maximal collection. If RSS drops under it, the growth was collectible garbage the trigger hadn't reached, a target will bound it. If RSS holds even under stress, the memory is live: the leak is in your program's reachability (a growing cache, a listener never removed), not the collector.
CRUFT_GC_STRESS=1 is also the tool for reproducing rare corruption: it turns GC-timing-dependent bugs into deterministic ones. See the garbage collector.
Note that WeakRef/FinalizationRegistry hold their targets strongly today, so a WeakRef never reads as collected and finalizers never run. If a cache built on them never shrinks, that is why; do not rely on weak-reference eviction to bound memory.
Understanding a hang
A program that won't exit is usually holding the event loop open:
- An open server (
cruft:serve,node:http) keeps the process alive untilclose(). - A bound socket or a live
setInterval. - A pending promise that never settles.
--audit shows the last operations before the hang; if it's an async handler that stalled, check for an await on something that never resolves. Inside a compartment, the timeout_ms budget turns an infinite loop into a clean termination rather than a hang, a debugging convenience as much as a security feature.
Async context (AsyncLocalStorage)
AsyncLocalStorage context propagates across every async hop: await, Promise.then, queueMicrotask, process.nextTick, and timer callbacks (setTimeout/setImmediate) all see the store set by the enclosing als.run(...):
als.run({ reqId }, () => {
setTimeout(() => als.getStore(), 10); // { reqId }
Promise.resolve().then(() => als.getStore()); // { reqId }
});
So a request id or trace span set at the top of a handler stays visible deep inside it, across timers included.
Sealed-mode grant debugging
If --sealed/--sealed-deps denies something you expected to be allowed:
$ CRUFT_CAPS_VERBOSE=1 cruft --sealed-deps app.mjs
cruft: caps-closure entry_path=… start_dir=…
cruft: sealed import-closure wired from …/cruft-lock.json: 2 packages, …
cruft: root-caps wired from …/cruft-caps.json: fs=1 net=0 …
It shows exactly which grant file was found, what it parsed, and how the dependency closure was computed. The grant file Cruft actually reads is cruft-caps.json, not a caps block in package.json, so if a denial hint points you at the manifest, check the cruft-caps.json the verbose output names instead.
Performance profiling
CRUFT_PROFILE=1 dumps a detailed phase breakdown (parse/compile/eval times, CJS-require internals, per-statement-kind timings) to stderr after the run. It's verbose and engine-oriented rather than user-friendly, but it answers "where did startup time go?" For throughput and JIT-level questions, treat a single profile line as only a hint: run repeatedly, control the input, and write down what you ran before you draw a conclusion.
The exit code tells you the class
Cruft uses sysexits.h for its own errors, the code narrows the failure before you even read the message:
| Exit | Meaning |
|---|---|
0 | clean |
N | your process.exit(N) |
64 | bad/unknown CLI flag |
65 | syntax error (--check) or TypeScript strip refusal |
66 | entry file not found |
1 | uncaught exception / runtime error / unhandled rejection (Node-style) |
See the error reference for the full message-prefix catalogue and capability-denial diagnostics.
Quick reference
| Symptom | First move |
|---|---|
| Crash | read the printed stack, or catch and log e.stack |
| "What is it doing?" | cruft --audit app.mjs |
| RSS climbing | CRUFT_GC_STRESS=1 to classify garbage vs. live |
| Sealed denial you didn't expect | CRUFT_CAPS_VERBOSE=1 |
| Won't exit | find the open server / interval / pending promise |
| Slow startup | CRUFT_PROFILE=1 |
Cache built on WeakRef never shrinks | weak refs hold strongly; evict manually |
| Syntax error hunt | cruft --check |
Silent coercion / arity / undefined property bug | CRUFT_SEMBP=coerce,arity,proto-miss (semantic debugging) |