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:

  1. 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.
  2. Is it churn headroom? Lower CRUFT_GC_HEADROOM (default 2.0, floor 1.1) or set CRUFT_GC_TARGET_MB=<N> and watch the plateau drop toward the working set.
  3. Does CRUFT_GC_STRESS=1 eliminate 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 until close().
  • 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:

ExitMeaning
0clean
Nyour process.exit(N)
64bad/unknown CLI flag
65syntax error (--check) or TypeScript strip refusal
66entry file not found
1uncaught exception / runtime error / unhandled rejection (Node-style)

See the error reference for the full message-prefix catalogue and capability-denial diagnostics.

Quick reference

SymptomFirst move
Crashread the printed stack, or catch and log e.stack
"What is it doing?"cruft --audit app.mjs
RSS climbingCRUFT_GC_STRESS=1 to classify garbage vs. live
Sealed denial you didn't expectCRUFT_CAPS_VERBOSE=1
Won't exitfind the open server / interval / pending promise
Slow startupCRUFT_PROFILE=1
Cache built on WeakRef never shrinksweak refs hold strongly; evict manually
Syntax error huntcruft --check
Silent coercion / arity / undefined property bugCRUFT_SEMBP=coerce,arity,proto-miss (semantic debugging)