8.6. Debugging

When a foreign function throws — a Python ZeroDivisionError, a C++ std::invalid_argument, an R stop(…​) — the default behavior of a morloc-built program is to surface the language-level error message and exit with non-zero status. That tells the operator something failed but not why: the inputs that triggered the throw are gone, and the stack across the dispatcher boundary doesn’t survive into the user’s terminal.

Compile-time debug-trace mode fills that gap. When enabled at build, every foreign-call manifold gains a try/catch wrap that:

  1. On exception, dumps the call’s arguments to a content-addressed file.

  2. Records a frame entry naming the manifold id and listing each arg’s schema and dump path.

  3. Re-raises the exception, unmodified.

The pool dispatcher concatenates the rendered trace into the fail packet’s error message before returning to the nexus. The trace then appears in summary.json and on stderr alongside the foreign-language error, so the failing inputs are recoverable from disk and the chain of calls is visible without re-running anything.

There is zero runtime cost in a build without --debug: no wraps are emitted, no per-frame state is allocated. Production binaries can ship unchanged; debugging is a recompile.

8.6.1. Enabling at compile time

Pass --debug to morloc make:

morloc make --debug -o my_program main.loc

The flag is a build-time switch only. It affects code generation, so the --debug and non---debug variants are two physically different binaries. There is no runtime flag that re-enables tracing on a binary compiled without --debug.

8.6.2. A failing run

A trivial example: boomP n = idpy (pyThrowAtZero n), where pyThrowAtZero raises on n == 0.

$ ./my_program boomP 0
Error: run failed
ZeroDivisionError: float division by zero
  at m1 (py)
morloc trace (innermost first):
  frame 0 mid=1
    arg[0] :: f8 -> .morloc-debug/inputs/e8b6f11aa7a0ccb4.pkt

The arg’s value is in e8b6f11aa7a0ccb4.pkt — a msgpack-encoded copy of n (0.0 here) usable as a @load target in any morloc program that needs to replay the call.

For a cross-pool failure (Python calls C, C throws), each pool’s catch contributes its own frames; the trace shows both pools' state stacked with the innermost (the C++ throw site) at the top.

8.6.3. Runtime knobs

The compile-time wrap is unconditional, but four runtime knobs shape what the catch records when it fires. All four can be set on the nexus command line or via environment variables; the CLI flag wins when both are present.

Knob Effect

--log-dir PATH (or MORLOC_LOG_DIR=PATH)

Same flag that activates persistent logging (see "Run directory"). When set, debug-trace dumps land under PATH/<run_id>/debug/inputs/ and the rendered trace appears in summary.json.

--debug-cache-depth N (or MORLOC_DEBUG_CACHE_DEPTH=N)

Maximum number of disk writes per dispatch. Default 1. Set to 0 for unlimited. Only successful writes count, so a single huge run with one outlier arg gets that arg dumped without burning the budget on later trivial args. Limit is per-dispatch, not per-process; each fresh dispatch starts the counter at zero.

--debug-cache-max BYTES (or MORLOC_DEBUG_CACHE_MAX=BYTES)

Per-arg size cap on the msgpack-encoded payload. Args whose encoded bytes exceed this are recorded by hash but not written to disk. The rendered trace shows (hash=…​, size exceeded MORLOC_DEBUG_CACHE_MAX) for skipped args. Suffix k/m/g for KiB/MiB/GiB. Default 0 (unlimited).

--debug-recursion-cap N (or MORLOC_DEBUG_RECURSION_CAP=N)

Per-manifold-id frame limit. A recursive function that throws at the bottom of an N-deep stack would otherwise produce N frame entries for the same manifold; this cap silently drops entries past the limit and appends a one-line note that the cap was hit. Default 3. Set to 0 for unlimited.

8.6.4. Where dumps land

Resolution order for the debug-dump directory, highest precedence first:

Source Notes

MORLOC_DEBUG_DIR=PATH

Direct override. Dumps go to PATH/inputs/<hash>.pkt. Set this to collect across many runs in one shared dir.

--log-dir PATH / MORLOC_LOG_DIR=PATH

Composes with the rundir: dumps go to PATH/<run_id>/debug/inputs/, partitioned by run id. The same directory holds log and summary.json. This is the recommended setup for any reproducibility-conscious run.

Default fallback

./.morloc-debug/inputs/<hash>.pkt in the invocation CWD. The directory is created on first write; failure to create (read-only filesystem, missing CWD) degrades to a hash-only frame entry rather than an error. The dotfile keeps the working directory uncluttered for casual users.

The default fallback exists because the wrap is a debugging tool and the most common case is "I just want to see what crashed my function" — no orchestrator, no opt-in flag, no setup. The fallback is only reached when the binary was compiled with --debug (no wraps fire otherwise), so non-debug builds never create .morloc-debug/.

8.6.5. Content-addressed dedup

The dump filename is the xxh64 of the msgpack-encoded arg bytes. Two args with identical content — the same Int 42, the same reference genome passed through five frames — write the same file once and are referenced by hash in each frame’s trace line. Disk cost scales with distinct inputs, not with frame count.

8.6.6. Frame status markers

A frame’s trace line for each arg is one of:

  • arg[0] :: f8 → .morloc-debug/inputs/<hash>.pkt — written to disk.

  • arg[0] :: f8 (hash=…​, size exceeded MORLOC_DEBUG_CACHE_MAX) — arg’s msgpack payload was over the per-arg size cap; the hash is recorded but no file was written.

  • arg[0] :: f8 (hash=…​, depth cap reached — raise --debug-cache-depth to dump more) — this dispatch already wrote --debug-cache-depth args; this one fell off the budget.

  • arg[0] :: f8 (hash=…​, write failed — check that the debug dir is writable) — the directory was resolvable but mkdir or the atomic write returned an error (read-only mount, no permission, full disk).

  • arg[0] :: f8 (serialize failed) — the arg’s schema couldn’t be resolved or the msgpack encoder errored. No hash is meaningful.

The four marker variants exist because the failure cause changes the right next step: a depth-cap miss is a knob adjustment; a write-failed is a filesystem problem; a size-exceeded is a per-arg vs whole-run budget tradeoff.

8.6.7. Recovering an input via @load

Each .pkt is a single msgpack value (the morloc wire form of the dumped arg). The morloc @load path intrinsic can read these directly — no separate decoder is required — because the same code path that loads @savem-written files also handles bare msgpack:

loadArg :: Str -> <IO> Int
loadArg path = do
  Ok v <- @load path
  v
$ ./debug loadArg .morloc-debug/inputs/e8b6f11aa7a0ccb4.pkt
0

If the schema of loadArg’s return type doesn’t match the dumped value’s schema, the load comes back as an `Err arm and the refutable bind above throws it. To try several shapes without crashing, bind the result and match on it, falling through to a default or to another attempt:

loadArgOrZero :: Str -> <IO> Int
loadArgOrZero path = do
  r <- @load path :: <IO> (Try Str Int)
  match r | (Ok v) = v | (Err _) = 0

8.6.8. Interaction with the cache

Stage-3 caching and debug-trace mode are independent layers and combine naturally. A cached call that hits the cache never enters the foreign function, so no debug-trace frame fires. A cached call that misses, falls through to the foreign function, and throws produces a frame just like an uncached call would. The two systems share no state.