A source-level debugger for Lua 5.1 / LuaJIT programs, implemented as two small files you drop into any project. No C extensions, no luasocket, no luaposix — just the Lua standard library on the child side and LuaJIT FFI on the controller side.
luaprobe_stub.lua— plain Lua 5.1, loaded into the target process viaLUA_INIT. Installs a line hook, coroutine hooks, and call/return hooks; talks to the controller over two FIFOs.luaprobe.lua— LuaJIT-only controller library. Creates the FIFOs, spawns the debugged child with the right env vars, decodes events, sends commands.bin/luaprobe— a simple interactive CLI built on the library. Run it likegdb:luaprobe -b foo.lua:42 foo.lua.
What you get:
- File+line breakpoints (stop or log-only) with snap-forward semantics, so you don't have to pick an executable line exactly.
- Conditional breakpoints:
foo.lua:42 if x > 5 and y ~= nil. The condition is a Lua expression evaluated at hit time with the function's locals and upvalues in scope — fires only when truthy. - Full Lua stack walking at every break, with locals, upvalues, and entry-time snapshots (the parameter values the function was called with, before its body modified them).
- On-demand deep inspection of any variable: press one key, get a full recursive table dump with cycle safety and configurable depth/key caps.
- Eval-during-pause: type any Lua expression at the prompt and it runs in the paused frame's scope (locals/upvalues/globals all visible). Side effects on globals and table mutations persist; writes to locals don't.
- Coroutine-aware — breakpoints fire inside coroutines, the break event identifies which coroutine, and the stub records where each coroutine was spawned so you can trace back to the creation site.
- Stepping:
step(step-into),next(step-over, thread-aware),finish(step-out),continue. - Live add/remove of breakpoints during a pause.
- Linux (uses the POSIX FIFO
O_RDWRtrick and/tmpsemantics) mkfifoonPATH- Controller: LuaJIT (FFI)
- Target process: Lua 5.1 or LuaJIT
No other dependencies.
Copy the two files into your project:
cp luaprobe_stub.lua luaprobe.lua /wherever/you/want/There is no install step. luaprobe.lua is required from your
controller; luaprobe_stub.lua is passed as an absolute path via
LUA_INIT.
Say this is the program you want to debug (examples/demo.lua):
-- demo.lua — target program for the luaprobe quickstart.
-- Try: bin/luaprobe -b demo.lua:7 examples/demo.lua
local function greet(name, times)
local message = "hello, " .. name -- line 5
for i = 1, times do
print(message .. " (" .. i .. ")") -- line 7
end
end
greet("world", 3)A simple interactive debugger ships in bin/luaprobe. Run it like
gdb:
bin/luaprobe -b demo.lua:7 examples/demo.luaIt spawns the target, waits for the breakpoint, drops you into a REPL each time it fires:
luaprobe: launching: lua5.1 examples/demo.lua
luaprobe: breakpoint: demo.lua:7
luaprobe: waiting for events (Ctrl-C to quit)
luaprobe: child attached
*** BREAK at examples/demo.lua:7 [main] (reason=stop)
* #1 greet examples/demo.lua:7
#2 <main> examples/demo.lua:11
#3 [C] =[C]:-1
locals:
name = "world"
times = 3
message = "hello, world"
i = 1
(luaprobe) p message
local message = "hello, world"
(luaprobe) c
*** BREAK at examples/demo.lua:7 ...
(luaprobe) c
...
Commands: c/s/n/f (continue/step/next/finish), bt (stack),
l [N] (source around the current line), locals, p NAME (deep
inspect a variable), e EXPR (evaluate a Lua expression in the
current frame), frame N (select frame), b FILE:L[!] [if EXPR] /
d FILE:L (add/remove breakpoint), bps (list breakpoints), q.
Type help for the full list. Run luaprobe --help for CLI options.
You don't need to use the CLI — it's just ~300 lines of Lua calling
the library. Here's the minimum controller that does roughly the
same thing, so you can see the full loop in one place
(examples/mini_controller.lua):
#!/usr/bin/env luajit
-- Minimal luaprobe controller — break at demo.lua:4, print stack
-- and locals, continue. About 50 lines of real code.
local luaprobe = require("luaprobe")
local here = arg[0]:match("(.*/)") or "./"
local stub = here .. "../luaprobe_stub.lua"
local stub_abs = io.popen("realpath " .. stub):read("*l")
local sess, err = luaprobe.new({
stub_path = stub_abs,
breakpoints = { "demo.lua:4" },
source_roots = { here },
on_status = function(msg) io.stderr:write("[luaprobe] " .. msg .. "\n") end,
})
assert(sess, err)
-- Spawn the child with the env prefix injected into a standard
-- `sh -c 'lua5.1 demo.lua'` wrapper.
local target = arg[1] or (here .. "demo.lua")
local cmd = string.format(
[[sh -c '%s lua5.1 %s' &]], sess:env_prefix(), target)
print("launching:", cmd)
os.execute(cmd)
-- Poll the session in a loop. Events come back as decoded tables.
print("waiting for events... (Ctrl-C to quit)")
while true do
for _, ev in ipairs(sess:poll()) do
if ev.event == "hello" then
print("child attached; breakpoints:", ev.bps)
elseif ev.event == "break" then
print(string.format("\n=== BREAK at %s:%d (reason=%s, thread=%s) ===",
ev.frames[1].source, ev.line, ev.reason, ev.thread or "?"))
for i, f in ipairs(ev.frames) do
print(string.format(" #%d %s %s:%d",
i, f.name, f.source, f.line))
end
print(" locals of top frame:")
for _, v in ipairs(ev.vars[1].locals or {}) do
print(string.format(" %s = %s", v.name, v.value))
end
print(" continuing...")
sess:cmd_continue()
end
end
os.execute("sleep 0.05") -- tiny yield so we don't pin a CPU
endRun it:
cd examples
luajit mini_controller.luaYou'll see something like:
launching: sh -c 'LUAPROBE_FIFO_OUT=... lua5.1 ./demo.lua' &
waiting for events...
child attached; breakpoints: demo.lua:4
=== BREAK at demo.lua:4 (reason=stop, thread=main) ===
#1 greet demo.lua:4
#2 <main> demo.lua:8
locals of top frame:
name = "world"
times = 3
message = "hello, world"
i = 1
continuing...
hello, world (1)
=== BREAK at demo.lua:4 (reason=stop, thread=main) ===
...
That's the full debugger loop: breakpoint fires, the stub serializes
the stack + locals as a single line of Lua literals, the controller
decodes it, prints what it wants, and sends a continue command.
When a breakpoint fires, the break event carries the whole Lua
stack of the running thread, not just the function the breakpoint
landed on. vars[1] is the top frame's locals/upvalues; vars[2]
is its caller; and so on up to wherever the stack ends — the main
chunk for the main thread, or the coroutine's body function for a
coroutine.
After a break, the prompt shows the top frame. bt prints the
full backtrace; frame N selects frame N as the "current" frame,
and locals, p NAME, and e EXPR then all operate on that
frame instead of the top one.
*** BREAK at proxy.lua:218 [main] (reason=stop)
* #1 fn@218 proxy.lua:218
#2 send_request net.lua:42
#3 scheduler_tick scheduler.lua:117
#4 <main> main.lua:8
(luaprobe) frame 3
* #3 scheduler_tick scheduler.lua:117
(luaprobe) locals
locals:
q = <table:32>
pending = 4
now = 1761748332
(luaprobe) e #q + pending
7
(luaprobe) p q
local q = { 1, 7, 12, ...}
session.frames and session.vars mirror what the latest break
event carried. They're parallel arrays — frames[i] and vars[i]
both describe the same frame, frame 1 being the topmost.
-- Print every frame's locals from the most recent break.
for i, frame in ipairs(sess.frames) do
print(string.format("#%d %s %s:%d",
i, frame.name, frame.source, frame.line))
for _, v in ipairs(sess.vars[i].locals or {}) do
print(" " .. v.name .. " = " .. v.value)
end
end
-- Deep-inspect or evaluate against any frame, not just the top one.
-- Both calls take the frame table as the addressing key.
sess:inspect_var(sess.frames[3], "local", "q")
sess:cmd_eval("#q + pending", sess.frames[3])For a "what was this on entry vs. now?" diff, each frame's
vars[i].entry is the locals captured the moment the function was
called, before its body ran — so for an ordinary Lua function you
get exactly the parameter values it was invoked with, alongside
their current (possibly mutated) values in vars[i].locals.
entry is nil for C frames and main chunks.
When a breakpoint fires inside a coroutine, the frames walk that
coroutine's stack — not the main thread's. You'll see the
function that hit the breakpoint at the top and the coroutine's
body function at the bottom; the main-thread caller that did
coroutine.resume(co, ...) to get here is not in the list,
because Lua's debug.getinfo only walks the running thread and
the running thread is the coroutine.
The break event tells you which coroutine you're in and where it was spawned:
*** BREAK at handler.lua:42 [co:7f2ab0] (reason=stop)
coroutine created at scheduler.lua:412
The same data is on the session object after :poll():
sess.thread -- "main" or "thread: 0x7f2a..."
sess.is_main -- true | false
sess.created_src -- file where coroutine.create was called, or nil
sess.created_line -- line number, or nilCreation sites are captured by a monkey-patch on coroutine.create
and coroutine.wrap. If you spawn coroutines through a helper
(scheduler:spawn(fn) -> coroutine.create(fn)), the recorded site
will be inside the helper, not at your :spawn call — only the
immediate caller of coroutine.create is captured, not a full
backtrace.
A practical thing to know: you can only inspect the coroutine that hit the breakpoint. Other live or suspended coroutines are not enumerated — there is no "list all coroutines" command. To poke at a different coroutine, set a breakpoint inside the code it runs and let it execute.
Creates a new debug session. Returns a session object or (nil, errmsg) on failure.
local sess = luaprobe.new({
stub_path = "/abs/path/to/luaprobe_stub.lua", -- required
breakpoints = { "src/foo.lua:42", "bar.lua:88!" },
source_roots = { ".", "src" },
on_status = function(msg) print("[dbg] " .. msg) end,
})stub_path (required) — absolute path to luaprobe_stub.lua.
The session will inject this into the child's LUA_INIT.
breakpoints (optional) — initial breakpoint specs as an array
of strings, each "FILE:LINE" (stop), "FILE:LINE!" (log-only:
send the stack and continue without pausing), or either of the
above followed by " if EXPR" to make it conditional. Examples:
"foo.lua:42", "foo.lua:42!", "foo.lua:42 if x > 5",
"foo.lua:42! if user.id == target_id".
source_roots (optional) — directories to search when resolving
relative source paths in session:get_source(path). Default:
{"."}.
on_status (optional) — callback invoked with a status string
on decode errors and other lifecycle events. Default: no-op.
Returns the shell env-var prefix to inject into your child-process launch command. The string includes:
LUAPROBE_FIFO_OUT="..." LUAPROBE_FIFO_IN="..." LUAPROBE_BREAKPOINTS="..." LUA_INIT="@..."
All values are double-quoted so the string can be safely embedded
inside a single-quoted sh -c '...' wrapper. Use like:
local cmd = "sh -c '" .. sess:env_prefix() .. " lua5.1 target.lua' &"
os.execute(cmd)The controller end of the FIFOs must already be open at the point
you spawn the child — luaprobe.new() handles that for you. Don't
sleep or do blocking work between new() and your os.execute.
Non-blocking drain + decode step. Call it every tick of your main
loop. Returns an array of decoded event tables (possibly empty).
Also updates the session's mirror state (session.frames, .vars,
.paused, .line, .thread, etc.) for simple UIs that don't want
to manage events manually.
Resume execution. If the session isn't currently paused, this is a
no-op. The next break (if any) arrives as a new event from :poll().
Stepping commands. step steps into the next executable line
anywhere. next steps over function calls (same thread, stack
depth ≤ current). finish runs until the current function returns
(same thread, stack depth < current). All three resume immediately;
the next break event arrives when the step condition is met.
Evaluate a Lua expression in a paused frame's scope. frame is
optional — if omitted, the stub picks the topmost user frame. The
expression sees the frame's locals and upvalues directly, with _G
as the fallback for any unbound name.
Reads on locals and upvalues see snapshot values; assignments to
them do not propagate back to the live frame (writes silently
go nowhere). Globals and table mutations work normally — a call
to table.insert(some_table, x) will be visible after continue.
The result arrives asynchronously as an event from the next
:poll():
sess:cmd_eval("self.cache.size", sess.frames[1])
-- ...
for _, ev in ipairs(sess:poll()) do
if ev.event == "eval" then
if ev.err then print("error: " .. ev.err)
else print(ev.expr .. " = " .. ev.repr) end
end
endInternally, the stub first tries to compile the input as
return (EXPR); if that fails (e.g. you passed a statement like
print('hi')), it falls back to compiling the raw text as a
chunk. Statement form runs but the reply carries no repr.
Ask the stub for a deep dump of a variable in a specific frame.
sess:inspect_var(sess.frames[1], "local", "self")frame is one of the entries in session.frames. kind is
"local" or "upvalue". name is the variable name as it appears
in the locals/upvalues list of that frame.
The dump is deep: tables are recursively walked up to the stub's
deep-mode caps (depth 6, 500 keys per table, 50 KB per string —
adjust in the stub if needed). Cycles are rendered as "<cycle>".
The result arrives asynchronously as an event from the next
:poll():
for _, ev in ipairs(sess:poll()) do
if ev.event == "inspect" then
print("inspect " .. ev.name .. " = " .. ev.repr)
end
endAdd a breakpoint during a running session. Takes either a
"FILE:LINE[!] [if EXPR]" string or four explicit arguments
(cond is a raw Lua expression source string, or nil).
Deduplicates against the existing breakpoint list by (path, line)
— there can be only one breakpoint per source location, so adding
a second one with the same key returns false even if the
condition or log-mode differs (remove the old one first).
If the stub is already attached, pushes the breakpoint live via
an add_bp command; the stub's snap logic will kick in on the
next matching hit and the condition (if any) is compiled lazily
on first match.
Returns true if added, false if it was already present or the
spec was malformed.
Remove a breakpoint by path and line. Returns true if removed,
false if no matching breakpoint existed.
Lookup helper. Returns the breakpoint table ({path, line, log})
if present, else nil.
Read a source file into an array of lines, with caching. Tries the
path as given, then each source_roots directory. Never crashes on
directories or non-existent files — unreadable paths return a
one-element array with an error placeholder string. Useful for
rendering a source pane alongside the stack.
Release the FIFO file descriptors and unlink the FIFO files. Call this when your controller exits or when you're tearing down a session to start a new one.
Events arrive as decoded Lua tables via session:poll(). Every
event has an event field identifying its shape.
Emitted once when the stub finishes initializing. bps is the raw
string that was passed via LUAPROBE_BREAKPOINTS, for logging.
Emitted every time a breakpoint or step condition fires. Full shape:
{
event = "break",
reason = "stop" | "log", -- log reasons don't pause
line = <current line>,
thread = "thread: 0x..." | "main",
is_main = true | false,
created_src = "scheduler.lua", -- nil for main thread
created_line = 412,
frames = { <frame>, <frame>, ... }, -- topmost first
vars = { <frame_vars>, ... }, -- same indexing
}frame is:
{
level = 3, -- absolute stack level in stub's walk
name = "greet" | "<main>" | "fn@218" | "[C]",
namewhat = "method" | "local" | "upvalue" | "global" | "",
what = "Lua" | "main" | "C",
source = "demo.lua",
short_src = "demo.lua",
line = 4, -- current line (currentline)
line_def = 1, -- where the function body starts
}Anonymous functions get a synthetic "fn@<linedefined>" name since
Lua's debug.getinfo can't name them otherwise. The linedefined
is where the function(...) keyword sits, so fn@218 means
"anonymous function whose body starts at line 218".
frame_vars for each frame is:
{
locals = { {name="x", value="42"}, ... },
upvalues = { {name="self", value="<table:12>"}, ... },
entry = { {name="x", value="7"}, ... } | nil,
}entry is present only if the stub captured an entry-time snapshot
of this frame's function — which happens automatically for
functions in files with active breakpoints, via the call hook. Diff
locals against entry to show "was:" values alongside the
current ones.
Emitted in response to a session:inspect_var(...) call. repr
is the deep serialization of the variable, ready to be
pretty-printed.
Emitted in response to a session:cmd_eval(...) call. Exactly one
of repr / err is set: repr is the deep serialization of the
expression's result, err is a human-readable string for compile
failures ("compile: ...") or runtime errors ("runtime: ...").
Emitted if the stub catches an internal error during break handling
(e.g., a serialize failure on a table with a hostile __tostring).
The session continues running; you'd typically log this to stderr.
All commands are sent as return {...} Lua chunks. The helper
methods on the session object cover the common ones; you can also
call session:_send_cmd(tbl) directly if you want to extend the
protocol.
| command | payload |
|---|---|
| continue | {cmd="continue"} |
| step | {cmd="step"} |
| next | {cmd="next"} |
| finish | {cmd="finish"} |
| add breakpoint | {cmd="add_bp", spec="foo.lua:42[!] [if EXPR]"} |
| remove breakpoint | {cmd="del_bp", spec="foo.lua:42"} |
| inspect variable | {cmd="inspect", src="foo.lua", src_line=42, kind="local", name="x"} |
| eval expression | {cmd="eval", expr="x + 1", src="foo.lua", src_line=42} (src/src_line optional — defaults to the topmost user frame) |
All commands are fire-and-forget; replies (when they exist) arrive
asynchronously as events from :poll().
Both sides log to /tmp/luaprobe.log (append mode). Useful
patterns:
# See every event the stub has sent
grep SEND /tmp/luaprobe.log
# See every source the hook has encountered at runtime
grep 'new source seen' /tmp/luaprobe.log
# See why a breakpoint might not be firing
grep 'near-match\|snap bp\|break reason' /tmp/luaprobe.logSee DEBUGGER.md for a detailed diagnostic flowchart mapping log
patterns to bug classes.
- Breakpoints only snap forward (nearest executable line at or after the requested line, within the same function scope).
- No watchpoints (no
debugfacility for "break when variable X changes"; you'd need to poll). evalruns against a snapshot of the paused frame's locals and upvalues, so writing back to a local doesn't persist. Globals and table mutations still work normally.- Coroutines created by C code via
lua_newthread(bypassing Lua-levelcoroutine.create) are invisible to the debugger. - FIFO transport is local-only. No network debugging.
- The line hook fires on every line of every Lua function in every coroutine, which adds 2–4× slowdown to a debug session. The call/return hook adds more. Performance-sensitive workloads should detach the stub (or don't pass any breakpoints) when not actively debugging.
See DEBUGGER.md for the full "known limitations" list and
internals.
MIT (see LICENSE).