Skip to content
Pre-release. v0.1.0 is not tagged and there are no published binaries yet. Install from source. Commands on this site were run against the current main.

Solve common debugging problems

{/* Watch expressions (M16) and the REPL pane (M17) landed in Wave 7, after this page was written, and have no recipe yet. Add one here rather than expanding the breakpoints guide — and verify it against the binary first, as every other recipe on this page was. */}

Each recipe is a goal, the shortest path to it, and what to look at when it does not work. Every command was run against the current build before it went on the page; output is real, with home directories rewritten to /Users/you and long blobs trimmed where marked.

For what a flag means rather than how to reach a goal, go to the CLI reference. These pages do not repeat each other.

You have a binary that crashes and no idea which line does it.

Launch it and let it run. A crash is a stop like any other, so --wait returns when it happens.

Terminal window
gcc -g -O0 crashes.c -o crashes
lazydap launch ./crashes --format json
lazydap continue --wait --format json

The reply says where and what, trimmed here to the fields that matter:

{
"captured_output": [
{ "category": "stdout", "output": "about to crash\r\n", "timestamp_ms": 1785472019334 },
{ "category": "stderr", "output": "Stop reason: EXC_BAD_ACCESS (code=1, address=0x0)\n",
"timestamp_ms": 1785472019336 }
],
"frame": {
"column": 14, "id": 1001, "line": 8, "name": "main",
"source": { "name": "crashes.c", "path": "/Users/you/crashes.c" }
},
"reason": "exception",
"state": "paused",
"thread_id": 30361859
}

"reason": "exception" with address=0x0 is a null dereference, and frame.line is the line that did it. The program is still paused there, so ask what the pointer held:

Terminal window
$ lazydap eval 'nowhere' --format json
{
"type_name": "int *",
"value": "<null>",
"variables_reference": 1010
}

If it fails. state: "terminated" with no frame means the process died outright rather than stopping — usually a crash the adapter could not catch, and captured_output is then all the evidence there is. If eval says use of undeclared identifier, you are not in the frame you think: run lazydap stack and check which function is on top.

A loop is fine for the first few hundred iterations and wrong later. Stopping by hand is not an option.

Give the breakpoint a hit condition:

Terminal window
lazydap break chatty.c:6 --hit-condition '>= 50'
lazydap launch ./chatty --stop-on-entry
lazydap continue --wait

It stops on the fiftieth arrival and not before:

Terminal window
$ lazydap variables --reference 1003 --format table
NAME VALUE TYPE REFERENCE
done 1 int 0
i 49 int 0

i is 49 because the loop counts from zero — the fiftieth hit is i == 49, and exactly 49 lines had been printed by then.

Use --condition instead when you know the value rather than the count:

Terminal window
lazydap break chatty.c:6 --condition 'i == 120'

--condition takes an expression in the program’s own language. --hit-condition counts arrivals and takes >= n, == n or % n.

If it fails. A condition that cannot compile is reported by the adapter, not by lazydap — check message on the breakpoint after launching. A condition referring to a variable that is not in scope at that line never fires and never errors, which looks identical to a condition that is never true. Set a plain breakpoint on the same line first and confirm it stops at all.

Capture everything a program prints while still stopping at a breakpoint

Section titled “Capture everything a program prints while still stopping at a breakpoint”

You want the program’s output and a breakpoint, and running it twice would change what it prints.

You do not have to choose. Every --wait reply carries the output produced during that wait, and lazydap output carries the whole session’s.

Terminal window
lazydap break chatty.c:6 --hit-condition '>= 50'
lazydap launch ./chatty --stop-on-entry
lazydap continue --wait --format json # stops at hit 50
lazydap break --remove --all
lazydap continue --wait --format json # runs to exit

Read the program’s own lines out of either one, filtering the adapter’s chatter:

Terminal window
lazydap output --format json \
| jq -r '.chunks[] | select(.category=="stdout") | .output'
Terminal window
$ lazydap output --format json | jq '{chunks: (.chunks|length), dropped}'
{
"chunks": 66,
"dropped": 0
}

A chunk is not a line. Those 66 chunks hold 200 printed lines: the adapter batches whatever arrived together, so one chunk routinely contains dozens of newline-separated lines. Join the output strings and split on newlines rather than treating each chunk as a line.

If it fails. dropped above zero, or output_truncated: true on a --wait reply, means the buffer filled before anything read it — the program printed faster than it was drained, and what you have is incomplete. Read output at each stop instead of only at the end. Missing the last few lines usually means the program did not flush; that is the program’s behaviour, not lazydap’s.

The program is Python, not a compiled binary. Same loop, different adapter.

Point --adapter at debugpy. Everything downstream is identical, because the adapter seam is what varies and the commands are not.

Terminal window
lazydap break total.py:4
lazydap launch ./total.py --adapter debugpy --stop-on-entry
lazydap continue --wait
Terminal window
$ lazydap scopes --format table
NAME REFERENCE EXPENSIVE
Locals 3 false
Globals 4 false
$ lazydap variables --reference 3 --format table
NAME VALUE TYPE REFERENCE
i 1 int 0
n 10 int 0
sum 0 int 0
$ lazydap eval 'sum * 10' --format json
{
"type_name": "int",
"value": "0",
"variables_reference": 0
}

eval runs the expression in Python, so Python syntax is what it wants — the same command that took a C expression a section ago.

Two differences worth expecting rather than debugging:

  • Scope names differ. debugpy offers Locals and Globals where codelldb offers Local, Static, Global and Registers. Read the names from scopes rather than assuming them.
  • hit_breakpoint_ids can be empty. The stop reports "reason": "breakpoint" at the right line, but debugpy does not always say which breakpoint caused it. Branch on reason and frame.line, not on the id list being non-empty.

If it fails. --adapter only accepts adapters this build ships; an unknown name is a usage error with exit 2 that names what it does support. debugpy has to be importable by the interpreter that runs your script (python3 -c 'import debugpy'), and it is looked up independently of codelldb — lazydap doctor reports each adapter separately.

Run a configuration from your team’s .vscode/launch.json

Section titled “Run a configuration from your team’s .vscode/launch.json”

Your repository already describes how to launch the thing. You would rather not retype it as flags.

lazydap reads .vscode/launch.json and never writes it.

Terminal window
$ lazydap launches list --format table
NAME SOURCE ADAPTER REQUEST PROGRAM RUNNABLE
Debug crashes launch.json lldb launch /Users/you/crashes yes
Attach to something launch.json lldb attach - no (it attaches to a running process, which lazydap cannot do yet)

Then run one by name:

Terminal window
lazydap launches run "Debug crashes"

${workspaceFolder} and friends are resolved before the program path is used, which is why the table shows an absolute path rather than the string in the file.

If it fails. The RUNNABLE column is the first thing to read, and it explains itself. Running a config that says no gets you the same sentence as an error, with exit code 1:

Terminal window
$ lazydap launches run "Attach to something" --format json
{"details":{"adapter_type":"lldb","name":"Attach to something","source":"launch.json","unresolved":[]},"error":"InvalidLaunchConfig","message":"InvalidLaunchConfig: cannot run `Attach to something`: it attaches to a running process, which lazydap cannot do yet"}

In --format json, each config also carries unresolved — variables lazydap could not substitute — and the top level carries warnings for configs it skipped entirely.

You want a test that fails when a variable is wrong at a particular line, which no unit test can see.

Drive lazydap the way you would any other CLI: --format json, jq, and exit codes.

#!/usr/bin/env bash
# Fails unless ./crashes stops at crashes.c:8 with a null `nowhere`.
set -euo pipefail
lazydap break crashes.c:8 --format json >/dev/null
trap 'lazydap break --remove --all --format json >/dev/null 2>&1 || true' EXIT
lazydap launch ./crashes --stop-on-entry --format json >/dev/null
result=$(lazydap continue --wait --timeout 30 --format json)
state=$(printf '%s' "$result" | jq -r .state)
if [ "$state" != "paused" ]; then
echo "expected to stop, got state=$state" >&2
exit 1
fi
line=$(printf '%s' "$result" | jq -r .frame.line)
value=$(lazydap eval 'nowhere' --format json | jq -r .value)
echo "stopped at line $line with nowhere=$value"
[ "$line" = "8" ] && [ "$value" = "<null>" ]
Terminal window
$ bash assert-line.sh
stopped at line 8 with nowhere=<null>
$ echo $?
0

Four things make this survive CI rather than only working on your laptop:

  • --stop-on-entry, always. Without it the program starts the moment launch returns and can finish before your continue arrives. You then wait on a program that is already gone.
  • An explicit --timeout. A hung debuggee should fail the job, not occupy the runner.
  • trap for breakpoint cleanup. Breakpoints are project state and outlive the process that set them, so a failed run otherwise leaves them behind for the next one.
  • Branch on state, not on the exit code. lazydap continue --wait exits 0 when it successfully reports that your program crashed. The exit code tells you the command worked; state tells you what the program did.

If it fails. set -e will abort on the first lazydap command that exits non-zero, which is what you want everywhere except a check you expect to fail. Exit 3 means no daemon could be started — on a fresh runner that usually means the working directory is not what you think, since the daemon is per project root. Exit 4 means codelldb is not installed on the runner. Both are listed on errors and exit codes.

Work out why a breakpoint never stops anything

Section titled “Work out why a breakpoint never stops anything”

The program runs to completion and hit_breakpoint_ids is empty.

Read verified, which launch reports for every breakpoint it applied:

Terminal window
lazydap break --list --format table
lazydap launch ./program --stop-on-entry --format json | jq '.breakpoints'

Three causes, in the order worth checking.

The binary has no debug info. The clearest signal there is:

Terminal window
$ lazydap launch ./chatty_nodebug --format json | jq '.breakpoints[0]'
{
"verified": false,
"message": "Resolved locations: 0",
...
}

“Resolved locations: 0” means the adapter found nowhere to put it. Rebuild with -g, and -O0 while you are there, since an optimiser can move or delete the line you asked about.

The line has no code. A blank line, a comment, or a declaration that emitted nothing. Move to the next line that does something.

The breakpoint bound but the program outran you. This one is deceptive, because verified is true and message says “Resolved locations: 1” — and the program still exits without stopping:

Terminal window
$ lazydap launch ./chatty --format json | jq '.breakpoints[0].verified'
true
$ lazydap continue --wait --format json | jq '{state, hit_breakpoint_ids}'
{
"state": "exited",
"hit_breakpoint_ids": []
}

Nothing is broken. launch without --stop-on-entry starts the program immediately, it ran past the breakpoint and finished before continue was sent, and continue then resumed a program that had already exited. Add --stop-on-entry and it stops properly.

If none of those. Working under a symlinked directory used to break this on macOS and no longer does — /tmp resolves and binds correctly now. The forensic write-ups for the failures that remain are in codelldb quirks.

Two machines behave differently, or you need a codelldb that is not on PATH.

Adapter discovery checks lazydap’s config first. Create ~/.config/lazydap/config.toml:

[adapter.codelldb]
command = "/Users/you/.local/opt/codelldb-1.12.2/extension/adapter/codelldb"
[general]
wait_timeout_seconds = 60

lazydap doctor tells you whether it was read and which binary won:

Terminal window
$ lazydap doctor --format json
{
"checks": [
{ "detail": "/Users/you/lazydap-config.toml (read)", "name": "config.file", "ok": true },
{ "detail": "/Users/you/.local/bin/codelldb", "name": "adapter.codelldb", "ok": true },
{ "detail": "/opt/homebrew/bin/python3", "name": "adapter.debugpy", "ok": true },
{ "detail": "/Users/you/project/.lazydap/state.toml (1 breakpoints)", "name": "state.file", "ok": true },
{ "detail": "instance cookbook-pin2, pid 27485, protocol v5", "name": "daemon", "ok": true }
],
"ok": true
}

Each adapter is checked separately, so a broken codelldb pin does not hide a working debugpy.

LAZYDAP_CONFIG_PATH points at a different file, which is the tidy way to try a pin without editing your real config.

If it fails. A pin at a path that is not executable is named directly rather than falling back to PATH, so a typo cannot silently give you a different debugger:

Terminal window
$ lazydap doctor --format json
{"details":{},"error":"DaemonInternalError","message":"1 check(s) failed"}
adapter.codelldb false codelldb is pinned to /nope/codelldb in lazydap's config, and that is not an executable

The config file is per user, never per project — project state lives in .lazydap/state.toml. Unknown keys are accepted and skipped, so only [adapter.<name>] command and [general] wait_timeout_seconds do anything in this build.

Breakpoints outlive sessions and daemons outlive commands. Leaving both behind means the next run stops in places nobody asked for.

Preview before removing. Every mutation takes --dry-run, and it uses the same selection logic the real removal uses:

Terminal window
$ lazydap break --list --format table
ID LOCATION ENABLED VERIFIED CONDITION
4 chatty.c:6 true false -
5 crashes.c:5 true true -
6 chatty.c:10 true false -
$ lazydap break --remove --all --dry-run --format json | jq '{dry_run, ids: [.breakpoints[].id]}'
{
"dry_run": true,
"ids": [4, 5, 6]
}

Then remove them, all at once or by id:

Terminal window
lazydap break --remove --all
lazydap break --list --format ids | xargs -I{} lazydap break --remove --id {}

The second form is worth knowing because --format ids and --id are built to feed each other, and it generalises to any subset you can filter.

End the session, the daemon, or both:

Terminal window
lazydap disconnect # end the session, leave the daemon up
lazydap shutdown --dry-run # what would this stop?
lazydap shutdown # stop the daemon and every session it owns
Terminal window
$ lazydap shutdown --dry-run --format json | jq '{dry_run, sessions: (.sessions|length)}'
{
"dry_run": true,
"sessions": 1
}

Nothing needs starting again afterwards; the next command spawns a daemon.

If it fails. xargs -r is GNU-only and does nothing on macOS, so guard an empty list yourself rather than relying on it. Breakpoints removed while a session is live also apply to that session — applied_to_session in the reply says whether that happened.