# 8.6. Debugging

Morloc Manual > Managing Runs | https://morloc-project.github.io/docs/runs/debugging.html | prev: https://morloc-project.github.io/docs/runs/compression.md | next: https://morloc-project.github.io/docs/runs/random-access-and-streaming.md

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`:

```shell
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`.

```shell
$ ./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:

```morloc
loadArg :: Str -> <IO> Int
loadArg path = do
  Ok v <- @load path
  v
```

```shell
$ ./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:

```morloc
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.
