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:
-
On exception, dumps the call’s arguments to a content-addressed file.
-
Records a frame entry naming the manifold id and listing each arg’s schema and dump path.
-
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 |
|---|---|
|
Same flag that activates persistent logging (see "Run directory").
When set, debug-trace dumps land under |
|
Maximum number of disk writes per dispatch. Default |
|
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 |
|
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 |
8.6.4. Where dumps land
Resolution order for the debug-dump directory, highest precedence first:
| Source | Notes |
|---|---|
|
Direct override. Dumps go to |
|
Composes with the rundir: dumps go to |
Default fallback |
|
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-depthargs; this one fell off the budget. -
arg[0] :: f8 (hash=…, write failed — check that the debug dir is writable)— the directory was resolvable butmkdiror 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.