From ea8aaf6047bfc41a51d9953250107978cf0540a5 Mon Sep 17 00:00:00 2001 From: Kelly Kinkade Date: Sat, 10 Oct 2026 16:27:03 -0500 Subject: [PATCH 1/3] a first attempt at an interactive lua debugger implemented as a DAP listener on port 43030 enable with `enable luadebugger`. see `devtools` directory for related utilties --- devtools/luadebug-cli.py | 219 ++++++ devtools/luadebugger-test/e2e.py | 253 +++++++ devtools/luadebugger-test/e2e_driver.lua | 62 ++ devtools/luadebugger-test/harness.lua | 345 +++++++++ devtools/luadebugger-test/json_stub.lua | 121 ++++ devtools/luadebugger-test/target.lua | 22 + devtools/vscode-dfhack-lua/extension.js | 24 + devtools/vscode-dfhack-lua/package.json | 55 ++ docs/changelog.txt | 1 + docs/plugins/luadebugger.rst | 105 +++ plugins/CMakeLists.txt | 1 + plugins/lua/luadebugger.lua | 874 +++++++++++++++++++++++ plugins/luadebugger.cpp | 419 +++++++++++ 13 files changed, 2501 insertions(+) create mode 100644 devtools/luadebug-cli.py create mode 100644 devtools/luadebugger-test/e2e.py create mode 100644 devtools/luadebugger-test/e2e_driver.lua create mode 100644 devtools/luadebugger-test/harness.lua create mode 100644 devtools/luadebugger-test/json_stub.lua create mode 100644 devtools/luadebugger-test/target.lua create mode 100644 devtools/vscode-dfhack-lua/extension.js create mode 100644 devtools/vscode-dfhack-lua/package.json create mode 100644 docs/plugins/luadebugger.rst create mode 100644 plugins/lua/luadebugger.lua create mode 100644 plugins/luadebugger.cpp diff --git a/devtools/luadebug-cli.py b/devtools/luadebug-cli.py new file mode 100644 index 0000000000..90dd10c7cb --- /dev/null +++ b/devtools/luadebug-cli.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Minimal interactive DAP client for the DFHack luadebugger plugin. + +Usage: + python luadebug-cli.py [host] [port] + +Commands: + b : set a breakpoint (replaces all breakpoints in that file) + bc clear breakpoints in a file + c continue + n next (step over) + s step in + o step out + pause break at the next Lua line + t list threads + bt [frameid] print stack trace; also prints scopes of frame + v expand a variablesReference + p evaluate an expression in the selected frame (or global) + f select a frame for evaluation + q disconnect and quit +""" +import json +import socket +import sys +import threading + + +class DapClient: + def __init__(self, host, port): + self.sock = socket.create_connection((host, port)) + self.seq = 0 + self.buf = b"" + self.cv = threading.Condition() + self.responses = {} + self.events = [] + self.alive = True + threading.Thread(target=self._read_loop, daemon=True).start() + + def _read_loop(self): + while True: + try: + data = self.sock.recv(65536) + except OSError: + data = b"" + if not data: + with self.cv: + self.alive = False + self.cv.notify_all() + return + self.buf += data + while True: + msg = self._try_parse() + if msg is None: + break + self._handle(msg) + + def _try_parse(self): + head, sep, _ = self.buf.partition(b"\r\n\r\n") + if not sep: + return None + length = None + for line in head.split(b"\r\n"): + if line.lower().startswith(b"content-length:"): + length = int(line.split(b":", 1)[1].strip()) + if length is None: + self.buf = b"" + return None + if len(self.buf) < len(head) + 4 + length: + return None + body = self.buf[len(head) + 4:len(head) + 4 + length] + self.buf = self.buf[len(head) + 4 + length:] + return json.loads(body) + + def _handle(self, msg): + with self.cv: + if msg.get("type") == "response": + self.responses[msg["request_seq"]] = msg + else: + self.events.append(msg) + self.cv.notify_all() + + def request(self, command, arguments=None, wait=True): + self.seq += 1 + seq = self.seq + body = json.dumps({ + "seq": seq, "type": "request", + "command": command, "arguments": arguments or {}, + }).encode() + self.sock.sendall( + b"Content-Length: %d\r\n\r\n" % len(body) + body) + if not wait: + return None + with self.cv: + while seq not in self.responses and self.alive: + self.cv.wait(timeout=30) + return self.responses.pop(seq, None) + + def drain_events(self): + with self.cv: + ev, self.events = self.events, [] + return ev + + def wait_for_stop(self, timeout=120): + """Wait until a 'stopped' event arrives.""" + with self.cv: + end = threading.Event() + import time + deadline = time.time() + timeout + while self.alive: + for i, ev in enumerate(self.events): + if ev.get("event") == "stopped": + return self.events.pop(i) + remaining = deadline - time.time() + if remaining <= 0: + return None + self.cv.wait(timeout=min(remaining, 0.25)) + return None + + +def show(msg, indent=""): + if not msg: + print(indent + "") + return + if msg.get("type") == "response": + ok = "ok" if msg.get("success") else "FAIL" + extra = msg.get("message") or "" + body = json.dumps(msg.get("body"), indent=indent + " ") + print(f"{indent}[{ok}] {msg.get('command')} {extra}\n{indent}{body}") + else: + print(indent + json.dumps(msg, indent=indent + " ")) + + +def main(): + host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1" + port = int(sys.argv[2]) if len(sys.argv) > 2 else 43030 + c = DapClient(host, port) + print(f"connected to {host}:{port}") + + show(c.request("initialize", {"adapterID": "luadebug-cli"})) + c.request("attach", {"stopOnEntry": False}) + show(c.request("configurationDone")) + + sel_frame = None + bp_files = {} + + while c.alive: + for ev in c.drain_events(): + if ev.get("event") == "stopped": + b = ev.get("body", {}) + print(f"\n*** stopped: {b.get('reason')} thread {b.get('threadId')}") + elif ev.get("event") == "output": + print(ev.get("body", {}).get("output", ""), end="") + else: + print(f"\n*** event {ev.get('event')}: {ev.get('body')}") + try: + line = input("\ndap> ").strip() + except (EOFError, KeyboardInterrupt): + break + if not line: + continue + cmd, _, rest = line.partition(" ") + + if cmd == "b" and ":" in rest: + path, ln = rest.rsplit(":", 1) + bp_files[path] = [{"line": int(ln)}] + show(c.request("setBreakpoints", { + "source": {"path": path}, "breakpoints": bp_files[path]})) + elif cmd == "bc": + bp_files[rest] = [] + show(c.request("setBreakpoints", { + "source": {"path": rest}, "breakpoints": []})) + elif cmd == "c": + show(c.request("continue", {"threadId": 1})) + ev = c.wait_for_stop() + if ev: + print(f"stopped: {ev.get('body')}") + elif cmd in ("n", "s", "o"): + show(c.request({"n": "next", "s": "stepIn", "o": "stepOut"}[cmd], + {"threadId": 1})) + ev = c.wait_for_stop() + if ev: + print(f"stopped: {ev.get('body')}") + elif cmd == "pause": + show(c.request("pause", {"threadId": 1})) + elif cmd == "t": + show(c.request("threads")) + elif cmd == "bt": + r = c.request("stackTrace", {"threadId": int(rest or 1)}) + for f in (r or {}).get("body", {}).get("stackFrames", []): + src = (f.get("source") or {}).get("path") or \ + (f.get("source") or {}).get("name", "?") + print(f" #{f['id']} {f['name']} {src}:{f['line']}") + elif cmd == "f": + sel_frame = int(rest) + r = c.request("scopes", {"frameId": sel_frame}) + for s in (r or {}).get("body", {}).get("scopes", []): + print(f" {s['name']} -> ref {s['variablesReference']}") + elif cmd == "v": + r = c.request("variables", {"variablesReference": int(rest)}) + for v in (r or {}).get("body", {}).get("variables", []): + ref = f" (ref {v['variablesReference']})" \ + if v.get("variablesReference") else "" + print(f" {v['name']} = {v['value']}{ref}") + elif cmd == "p": + args = {"expression": rest} + if sel_frame: + args["frameId"] = sel_frame + show(c.request("evaluate", args)) + elif cmd in ("q", "quit", "exit"): + c.request("disconnect", {}, wait=False) + break + else: + print(__doc__) + + c.sock.close() + + +if __name__ == "__main__": + main() diff --git a/devtools/luadebugger-test/e2e.py b/devtools/luadebugger-test/e2e.py new file mode 100644 index 0000000000..d76742d08a --- /dev/null +++ b/devtools/luadebugger-test/e2e.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""End-to-end test for the DFHack Lua debugger. + +Spins up a stand-in for the C++ plugin: a TCP listener speaking DAP +Content-Length framing on one side, and a lua5.3 subprocess running the +real plugins/lua/luadebugger.lua on the other (message bodies shuttled +over the child's stdio, one per line). A scripted DAP client then drives +an actual debugging session over the socket. + +Usage: python3 e2e.py [path-to-lua-interpreter] +""" +import json +import os +import socket +import subprocess +import sys +import threading +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, '..', '..')) +LUA = sys.argv[1] if len(sys.argv) > 1 else 'lua53' +PORT = 43035 + +passed = failed = 0 + + +def check(name, cond, extra=''): + global passed, failed + if cond: + passed += 1 + print(f' ok: {name}') + else: + failed += 1 + print(f' FAIL: {name} {extra}') + + +class LuaShim: + """Plays the role of plugins/luadebugger.cpp.""" + + def __init__(self): + self.proc = subprocess.Popen( + [LUA, os.path.join(HERE, 'e2e_driver.lua')], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=sys.stderr) + self.listener = socket.socket() + self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.listener.bind(('127.0.0.1', PORT)) + self.listener.listen(1) + self.client = None + self.buf = b'' + self.client_cv = threading.Condition() + self.inbound = [] # parsed DAP messages from the client + threading.Thread(target=self._accept_loop, daemon=True).start() + threading.Thread(target=self._lua_out_loop, daemon=True).start() + + def _accept_loop(self): + conn, _ = self.listener.accept() + self.client = conn + while True: + data = conn.recv(65536) + if not data: + break + self.buf += data + while True: + body = self._try_parse() + if body is None: + break + # forward message body to the lua debuggee's mailbox + self.proc.stdin.write(body + b'\n') + self.proc.stdin.flush() + with self.client_cv: + self.inbound.append(json.loads(body)) + self.client_cv.notify_all() + + def _try_parse(self): + head, sep, _ = self.buf.partition(b'\r\n\r\n') + if not sep: + return None + length = None + for line in head.split(b'\r\n'): + if line.lower().startswith(b'content-length:'): + length = int(line.split(b':', 1)[1].strip()) + if length is None or len(self.buf) < len(head) + 4 + length: + return None + body = self.buf[len(head) + 4:len(head) + 4 + length] + self.buf = self.buf[len(head) + 4 + length:] + return body + + def _lua_out_loop(self): + # each stdout line from the child is a DAP body for the client; + # anything else is a driver diagnostic + for raw in self.proc.stdout: + line = raw.strip() + if not line: + continue + if not line.startswith(b'{'): + print(f' [driver] {line.decode(errors="replace")}', + file=sys.stderr) + continue + if self.client: + try: + self.client.sendall( + b'Content-Length: %d\r\n\r\n' % len(line) + line) + except OSError: + return + + def run_command(self, text): + """Send a driver command straight to the child's stdin.""" + self.proc.stdin.write(text.encode() + b'\n') + self.proc.stdin.flush() + + +class Client: + def __init__(self): + self.sock = socket.create_connection(('127.0.0.1', PORT)) + self.seq = 0 + self.buf = b'' + self.cv = threading.Condition() + self.responses = {} + self.events = [] + threading.Thread(target=self._read_loop, daemon=True).start() + + def _read_loop(self): + while True: + data = self.sock.recv(65536) + if not data: + return + self.buf += data + while True: + head, sep, _ = self.buf.partition(b'\r\n\r\n') + if not sep: + break + length = None + for line in head.split(b'\r\n'): + if line.lower().startswith(b'content-length:'): + length = int(line.split(b':', 1)[1].strip()) + if length is None or \ + len(self.buf) < len(head) + 4 + length: + break + body = self.buf[len(head) + 4:len(head) + 4 + length] + self.buf = self.buf[len(head) + 4 + length:] + msg = json.loads(body) + with self.cv: + if msg.get('type') == 'response': + self.responses[msg['request_seq']] = msg + else: + self.events.append(msg) + self.cv.notify_all() + + def request(self, command, arguments=None, timeout=30): + self.seq += 1 + seq = self.seq + body = json.dumps({'seq': seq, 'type': 'request', + 'command': command, + 'arguments': arguments or {}}).encode() + self.sock.sendall( + b'Content-Length: %d\r\n\r\n' % len(body) + body) + end = time.time() + timeout + with self.cv: + while seq not in self.responses and time.time() < end: + self.cv.wait(timeout=0.25) + return self.responses.pop(seq, None) + + def wait_event(self, name, timeout=30): + end = time.time() + timeout + with self.cv: + while time.time() < end: + for i, ev in enumerate(self.events): + if ev.get('event') == name: + return self.events.pop(i) + self.cv.wait(timeout=0.25) + return None + + +def main(): + shim = LuaShim() + c = Client() + target = os.path.join(HERE, 'target.lua') + time.sleep(0.5) # let the lua child initialize + + r = c.request('initialize', {'adapterID': 'e2e'}) + check('initialize response', r and r.get('success')) + check('capabilities present', + r and r.get('body', {}) + .get('supportsConfigurationDoneRequest')) + + r = c.request('attach', {'stopOnEntry': False}) + check('attach response', r and r.get('success')) + + r = c.request('setBreakpoints', { + 'source': {'path': target}, 'breakpoints': [{'line': 10}]}) + check('setBreakpoints response', + r and r.get('success') and + r['body']['breakpoints'][0].get('verified')) + + r = c.request('configurationDone') + check('configurationDone response', r and r.get('success')) + + shim.run_command('RUN ' + target) + + ev = c.wait_event('stopped') + check('stopped event at breakpoint', ev and + ev['body'].get('reason') == 'breakpoint') + tid = ev['body'].get('threadId') if ev else None + + r = c.request('stackTrace', {'threadId': tid}) + frames = (r or {}).get('body', {}).get('stackFrames', []) + check('stackTrace non-empty', len(frames) > 0, str(r)) + check('stopped at line 10', + frames and frames[0].get('line') == 10, str(frames[:1])) + frame_id = frames[0]['id'] if frames else None + + r = c.request('scopes', {'frameId': frame_id}) + scopes = (r or {}).get('body', {}).get('scopes', []) + locals_ref = next((s['variablesReference'] for s in scopes + if s.get('name') == 'Locals'), None) + check('locals scope', locals_ref is not None, str(scopes)) + + r = c.request('variables', {'variablesReference': locals_ref}) + varnames = [v['name'] for v in + (r or {}).get('body', {}).get('variables', [])] + check('local acc visible', 'acc' in varnames, str(varnames)) + check('loop var i visible', 'i' in varnames, str(varnames)) + + r = c.request('evaluate', { + 'expression': 'acc + i', 'frameId': frame_id}) + check('evaluate acc+i', + r and r.get('success') and + r['body'].get('result') == '1', str(r)) + + r = c.request('next', {'threadId': tid}) + check('next response', r and r.get('success')) + ev = c.wait_event('stopped') + check('stopped after next', + ev and ev['body'].get('reason') == 'step', str(ev)) + + r = c.request('continue', {'threadId': tid}) + check('continue response', r and r.get('success')) + + term = c.wait_event('terminated', timeout=15) or \ + c.wait_event('stopped', timeout=15) + # the target ends after the single bp is passed twice more (i=2,3) + # then the coroutine finishes; just make sure we can disconnect + r = c.request('disconnect') + check('disconnect response', r and r.get('success')) + + print(f'\n=== {passed} passed, {failed} failed ===') + sys.exit(1 if failed else 0) + + +if __name__ == '__main__': + main() diff --git a/devtools/luadebugger-test/e2e_driver.lua b/devtools/luadebugger-test/e2e_driver.lua new file mode 100644 index 0000000000..a226f30376 --- /dev/null +++ b/devtools/luadebugger-test/e2e_driver.lua @@ -0,0 +1,62 @@ +-- Driver for the end-to-end test (e2e.py). Runs the real +-- plugins/lua/luadebugger.lua under a plain lua 5.3 interpreter and +-- transports DAP message bodies over stdin/stdout, one message per line +-- (json bodies never contain raw newlines). +-- +-- A stdin line that does not parse as a DAP request is treated as a driver +-- command: +-- RUN loadfile() the target and resume it in a new coroutine + +local DIR = arg[0]:match('^(.*)[/\\][^/\\]*$') or '.' + +local real_print = print +local real_write = io.write +dfhack = { BASE_G=_G, is_core_context=true, run_script=function(n) return n end, + enable_script=function(n,e) return e end, interpreter=function() return nil end } +mkmodule = function(n) return setmetatable({},{__index=_G}) end +local json = dofile(DIR .. '/json_stub.lua') +local real_require = require +require = function(n) if n=='json' then return json end return real_require(n) end + +local inbound = {} +local connected = true + +function debugger_recv(timeout_ms) + if timeout_ms and timeout_ms < 0 then + -- blocking read used by the debug loop + return io.stdin:read('*l') or '' + end + return table.remove(inbound, 1) or '' +end +function debugger_send(msg) + io.stdout:write(msg, '\n') + io.stdout:flush() + return true +end +function debugger_connected() return connected end +function debugger_port() return 43030 end + +local mod = dofile(DIR .. '/../../plugins/lua/luadebugger.lua') + +local function run_target(path) + local fn, err = loadfile(path) + if not fn then + real_write('driver error: ', tostring(err), '\n') + return + end + local co = coroutine.create(fn) + local ok, res = coroutine.resume(co) + real_write('driver: target finished, ok=', tostring(ok), + ' res=', tostring(res), '\n') +end + +while true do + local line = io.stdin:read('*l') + if line == nil then break end + if line:sub(1, 4) == 'RUN ' then + run_target(line:sub(5)) + elseif line ~= '' then + inbound[#inbound + 1] = line + mod.poll() + end +end diff --git a/devtools/luadebugger-test/harness.lua b/devtools/luadebugger-test/harness.lua new file mode 100644 index 0000000000..59242eb48c --- /dev/null +++ b/devtools/luadebugger-test/harness.lua @@ -0,0 +1,345 @@ +-- Standalone test harness for devtools/luadebugger-test/dbg_instrumented.lua. +-- +-- Runs the debuggee under a plain lua 5.3 interpreter (e.g. one built from +-- depends/lua) with the dfhack environment stubbed out: +-- * mkmodule / dfhack.BASE_G / dfhack.{run_script,enable_script,interpreter} +-- * the plugin-exported transport functions (debugger_recv/send/connected) +-- * require('json'), backed by the minimal implementation below +-- +-- The transport is driven by a pre-enqueued inbound message queue. Messages +-- flagged 'stopped' are only delivered to the blocking recv used inside the +-- debug loop, mimicking the fact that a real client only sends stack/eval/ +-- continue traffic while the debuggee is stopped. +-- +-- Usage: lua53 harness.lua [--verbose] + +local DIR = arg[0]:match('^(.*)[/\\][^/\\]*$') or '.' +local VERBOSE = arg[1] == '--verbose' + +-- minimal JSON (encode/decode) for test purposes, API-compatible with +-- dfhack's json module +local json = dofile(DIR .. '/json_stub.lua') + +------------------------------------------------------------------------ +-- dfhack environment stubs +------------------------------------------------------------------------ +local inbound = {} -- {msg=string, when='any'|'stopped'} +local outbound = {} -- raw JSON strings sent by the debuggee +local conn = {connected = true} +local results = {} + +local function note(s) + results[#results + 1] = s + if VERBOSE then io.write(' | ' .. s .. '\n') end +end + +local function enqueue(when, tbl) + tbl.seq = tbl.seq or 0 + tbl.type = 'request' + inbound[#inbound + 1] = {msg = json.encode(tbl), when = when} +end + +-- drop any unconsumed queued messages (e.g. a continue/disconnect whose +-- corresponding stop never happened); scenarios are meant to be independent +local function clear_inbound() + for k in pairs(inbound) do inbound[k] = nil end +end + +local function dequeue_cmd(m) + local ok, r = pcall(json.decode, m.msg) + return ok and r.command or '?' +end +function debugger_recv(timeout_ms) + if timeout_ms and timeout_ms < 0 then + local m = table.remove(inbound, 1) + if VERBOSE and m then + io.write(' >> recv(-1): ', tostring(dequeue_cmd(m)), '\n') + end + return m and m.msg or '' + end + local m = inbound[1] + if m and m.when ~= 'stopped' then + table.remove(inbound, 1) + if VERBOSE then + io.write(' >> recv(0): ', tostring(dequeue_cmd(m)), '\n') + end + return m.msg + end + return '' +end +function debugger_send(msg) + outbound[#outbound + 1] = msg + return true +end +function debugger_connected() return conn.connected end +function debugger_port() return 43030 end + +local dfhack = { + BASE_G = _G, + is_core_context = true, + run_script = function(name) return ('ran %s'):format(name) end, + enable_script = function(name, en) return en end, + interpreter = function() return 'interp' end, +} +_G.dfhack = dfhack + +local function mkmodule(name) + return setmetatable({}, {__index = _G}) +end +_G.mkmodule = mkmodule + +local real_require = require +local function require_stub(name) + if name == 'json' then return json end + return real_require(name) +end +_G.require = require_stub + +------------------------------------------------------------------------ +-- load the debuggee +------------------------------------------------------------------------ +local mod = assert(loadfile(DIR .. '/../../plugins/lua/luadebugger.lua'))() +assert(type(mod) == 'table' and type(mod.poll) == 'function', + 'debuggee module did not load') + +------------------------------------------------------------------------ +-- assertions +------------------------------------------------------------------------ +local n_pass, n_fail = 0, 0 +local function check(cond, label) + if cond then + n_pass = n_pass + 1 + if VERBOSE then io.write('PASS ' .. label .. '\n') end + else + n_fail = n_fail + 1 + io.write('FAIL ' .. label .. '\n') + end +end + +local function drain_outbound() + local msgs = {} + for _, raw in ipairs(outbound) do + msgs[#msgs + 1] = json.decode(raw) + end + outbound = {} + return msgs +end + +local function find(msgs, pred) + for _, m in ipairs(msgs) do + if pred(m) then return m end + end +end + +------------------------------------------------------------------------ +-- scenario 1: attach, breakpoint, inspect, step, continue, disconnect +------------------------------------------------------------------------ +io.write('--- scenario 1: breakpoint + inspect + step ---\n') + +local target_path = DIR .. '/target.lua' +local dap_seq = 0 +local function req(command, args, when) + dap_seq = dap_seq + 1 + enqueue(when or 'any', {seq = dap_seq, command = command, + arguments = args}) + return dap_seq +end + +req('initialize', {adapterID = 'harness'}) +req('attach', {}) +req('setBreakpoints', {source = {path = target_path}, + breakpoints = {{line = 10}}}) +req('configurationDone') +-- queued for consumption while stopped at the breakpoint. Thread ids are +-- assigned in registration order: 1 = main thread, 2 = the target coroutine. +req('stackTrace', {threadId = 2}, 'stopped') +req('scopes', {frameId = 1}, 'stopped') +req('variables', {variablesReference = 1}, 'stopped') -- Locals scope ref +req('evaluate', {expression = 'acc', frameId = 1}, 'stopped') +req('evaluate', {expression = 'i', frameId = 1}, 'stopped') +req('next', {threadId = 2}, 'stopped') +-- after 'next' stops, frame ids were invalidated: re-fetch the stack before +-- evaluating in the new frame context. 'next' lands on the `for` line (9), +-- where the body-scoped `i` is not yet visible; `acc` is (==3 after iter 1). +req('stackTrace', {threadId = 2}, 'stopped') +req('evaluate', {expression = 'acc', frameId = 1}, 'stopped') +req('continue', {threadId = 2}, 'stopped') +req('disconnect') + +mod.poll() + +do + local msgs = drain_outbound() + check(find(msgs, function(m) + return m.type == 'response' and m.command == 'initialize' and m.success + end) ~= nil, 'initialize response') + check(find(msgs, function(m) + return m.type == 'event' and m.event == 'initialized' + end) ~= nil, 'initialized event') + check(find(msgs, function(m) + return m.type == 'response' and m.command == 'setBreakpoints' and + m.success and m.body.breakpoints[1].verified + end) ~= nil, 'setBreakpoints verified') +end + +-- run the target; it should hit the breakpoint at line 10 (first hit: i=1) +local target_fn = assert(loadfile(target_path)) +local co = coroutine.create(target_fn) +local co_ok, co_res = coroutine.resume(co) + +do + local msgs = drain_outbound() + for _, m in ipairs(msgs) do + note(json.encode(m)) + end + local stopped = find(msgs, function(m) + return m.type == 'event' and m.event == 'stopped' + end) + check(stopped ~= nil, 'stopped event emitted') + check(stopped and stopped.body.reason == 'breakpoint', + 'stopped reason == breakpoint') + local trace = find(msgs, function(m) + return m.command == 'stackTrace' and m.success + end) + check(trace ~= nil, 'stackTrace response') + if trace then + local f = trace.body.stackFrames[1] + check(f.source and f.source.path and + f.source.path:gsub('\\', '/'):find('target%.lua$') ~= nil, + 'top frame is target.lua') + check(f.line == 10, 'top frame line == 10 (got ' .. + tostring(f.line) .. ')') + end + local vars = find(msgs, function(m) + return m.command == 'variables' and m.success + end) + check(vars ~= nil, 'variables response') + if vars then + local names = {} + for _, v in ipairs(vars.body.variables) do names[v.name] = v end + check(names.acc ~= nil, 'local acc visible') + check(names.i ~= nil, 'local i visible') + check(names.n ~= nil, 'upvalue/arg n visible') + end + local eval_acc = find(msgs, function(m) + return m.command == 'evaluate' and m.success and + m.body.result == '0' + end) + check(eval_acc ~= nil, 'evaluate acc == 0 on first hit') + local eval_i = find(msgs, function(m) + return m.command == 'evaluate' and m.success and + m.body.result == '1' + end) + check(eval_i ~= nil, 'evaluate i == 1 on first hit') + local eval_acc2 = find(msgs, function(m) + return m.command == 'evaluate' and m.success and + m.body.result == '3' + end) + check(eval_acc2 ~= nil, 'evaluate acc == 3 after next') + check(co_ok == true and co_res ~= nil, + 'target coroutine completed after continue') +end +clear_inbound() + +------------------------------------------------------------------------ +-- scenario 2: breakpoint inside a coroutine + stepIn/stepOut +------------------------------------------------------------------------ +io.write('--- scenario 2: coroutine coverage + stepIn/stepOut ---\n') + +-- The debuggee deactivated on disconnect; start a fresh session. +conn.connected = true +dap_seq = 0 +req('initialize', {adapterID = 'harness'}) +req('attach', {}) +-- breakpoint inside the coroutine body (line 17: return helper(10) + #mine) +req('setBreakpoints', {source = {path = target_path}, + breakpoints = {{line = 17}}}) +req('configurationDone') +req('threads', nil, 'stopped') +-- thread 3 = the coroutine spawned inside target.lua (1=main, 2=outer) +req('stackTrace', {threadId = 3}, 'stopped') +req('stepIn', {threadId = 3}, 'stopped') -- into helper() at line 4 +req('stackTrace', {threadId = 3}, 'stopped') +req('stepOut', {threadId = 3}, 'stopped') +-- helper() is called in a return position, so stepping out of it leaves no +-- further line events in the coroutine: the thread just ends. The queued +-- continue/disconnect below are therefore never consumed; clear_inbound() +-- after the scenario drops them. +req('continue', {threadId = 3}, 'stopped') +req('disconnect') + +mod.poll() +drain_outbound() + +co = coroutine.create(target_fn) +co_ok, co_res = coroutine.resume(co) + +do + local msgs = drain_outbound() + for _, m in ipairs(msgs) do note(json.encode(m)) end + local stops = {} + for _, m in ipairs(msgs) do + if m.type == 'event' and m.event == 'stopped' then + stops[#stops + 1] = m + end + end + check(#stops == 2, 'stopped at coroutine bp + stepIn (' .. + tostring(#stops) .. ' stops)') + local traces = {} + for _, m in ipairs(msgs) do + if m.command == 'stackTrace' and m.success then + traces[#traces + 1] = m + end + end + if traces[1] then + local f = traces[1].body.stackFrames[1] + check(f.line == 17, 'coroutine bp at line 17 (got ' .. + tostring(f.line) .. ')') + end + if traces[2] then + local f = traces[2].body.stackFrames[1] + check(f.line == 4 or f.line == 5, + 'stepIn landed inside helper (line ' .. tostring(f.line) .. ')') + end + local threads_resp = find(msgs, function(m) + return m.command == 'threads' and m.success + end) + check(threads_resp and #threads_resp.body.threads >= 2, + 'threads lists main + coroutine') + check(co_ok == true, 'target coroutine completed') +end +clear_inbound() + +------------------------------------------------------------------------ +-- scenario 3: pause while running +------------------------------------------------------------------------ +io.write('--- scenario 3: pause ---\n') + +conn.connected = true +dap_seq = 0 +req('initialize', {adapterID = 'harness'}) +req('attach', {}) +req('configurationDone') +req('pause') +req('continue', {threadId = 1}, 'stopped') +req('disconnect') + +mod.poll() + +co = coroutine.create(target_fn) +co_ok = coroutine.resume(co) + +do + local msgs = drain_outbound() + for _, m in ipairs(msgs) do note(json.encode(m)) end + -- the stale line-17 breakpoint from scenario 2 (breakpoints persist + -- across sessions) may fire first; just require that a pause stop exists + local stopped = find(msgs, function(m) + return m.type == 'event' and m.event == 'stopped' and + m.body.reason == 'pause' + end) + check(stopped ~= nil, 'stopped event with reason == pause') +end + +io.write(('=== %d passed, %d failed ===\n'):format(n_pass, n_fail)) +os.exit(n_fail > 0 and 1 or 0) diff --git a/devtools/luadebugger-test/json_stub.lua b/devtools/luadebugger-test/json_stub.lua new file mode 100644 index 0000000000..bd7c252afe --- /dev/null +++ b/devtools/luadebugger-test/json_stub.lua @@ -0,0 +1,121 @@ +-- Minimal JSON encode/decode for the luadebugger test harness. +-- API-compatible with dfhack's json module (encode/decode). + +local json = {} + +local esc_map = {['"'] = '\\"', ['\\'] = '\\\\', ['\n'] = '\\n', + ['\r'] = '\\r', ['\t'] = '\\t', ['\b'] = '\\b', + ['\f'] = '\\f'} + +local function enc_str(s) + return '"' .. s:gsub('[%z\1-\31\\"]', function(c) + return esc_map[c] or ('\\u%04x'):format(c:byte()) + end) .. '"' +end + +local function enc(v) + local t = type(v) + if t == 'nil' then return 'null' + elseif t == 'boolean' or t == 'number' then return tostring(v) + elseif t == 'string' then return enc_str(v) + elseif t == 'table' then + if #v > 0 or next(v) == nil then + local parts = {} + for i = 1, #v do parts[i] = enc(v[i]) end + return '[' .. table.concat(parts, ',') .. ']' + end + local parts = {} + for k, val in pairs(v) do + parts[#parts + 1] = enc_str(tostring(k)) .. ':' .. enc(val) + end + return '{' .. table.concat(parts, ',') .. '}' + end + error('json: cannot encode ' .. t) +end +json.encode = enc + +function json.decode(s) + local pos = 1 + local parse + local function skipws() + local _, e = s:find('^[ \t\n\r]+', pos) + if e then pos = e + 1 end + end + local function str() + local i = pos + 1 + local buf = {} + while true do + local c = s:sub(i, i) + if c == '"' then pos = i + 1; break end + if c == '\\' then + local e2 = s:sub(i + 1, i + 1) + local m = {n = '\n', t = '\t', r = '\r', b = '\b', + f = '\f', ['"'] = '"', ['\\'] = '\\', + ['/'] = '/'} + if e2 == 'u' then + buf[#buf + 1] = '' -- approximate: drop unicode escapes + i = i + 6 + else + buf[#buf + 1] = m[e2] or e2 + i = i + 2 + end + else + buf[#buf + 1] = c + i = i + 1 + end + end + return table.concat(buf) + end + local function literal(word, val) + if s:sub(pos, pos + #word - 1) == word then + pos = pos + #word + return val + end + error('bad literal at ' .. pos) + end + function parse() + skipws() + local c = s:sub(pos, pos) + if c == '{' then + pos = pos + 1 + local t = {} + skipws() + if s:sub(pos, pos) == '}' then pos = pos + 1; return t end + while true do + skipws() + local k = str() + skipws() + assert(s:sub(pos, pos) == ':', 'expected : at ' .. pos) + pos = pos + 1 + t[k] = parse() + skipws() + local d = s:sub(pos, pos) + pos = pos + 1 + if d == '}' then return t end + end + elseif c == '[' then + pos = pos + 1 + local t = {} + skipws() + if s:sub(pos, pos) == ']' then pos = pos + 1; return t end + while true do + t[#t + 1] = parse() + skipws() + local d = s:sub(pos, pos) + pos = pos + 1 + if d == ']' then return t end + end + elseif c == '"' then return str() + elseif c == 't' then return literal('true', true) + elseif c == 'f' then return literal('false', false) + elseif c == 'n' then return literal('null', nil) + else + local m = s:match('^[%d%.eE%+%-]+', pos) + pos = pos + #m + return tonumber(m) + end + end + return parse() +end + +return json diff --git a/devtools/luadebugger-test/target.lua b/devtools/luadebugger-test/target.lua new file mode 100644 index 0000000000..f8fa4e7a20 --- /dev/null +++ b/devtools/luadebugger-test/target.lua @@ -0,0 +1,22 @@ +local t = {} +local shared_upval = 42 +local function helper(x) + local inner = x * 2 + return inner + 1 +end +function t.work(n) + local acc = 0 + for i = 1, n do + acc = acc + helper(i) + end + return acc +end +local function spawn() + local co = coroutine.create(function() + local mine = 'coro-local' + return helper(10) + #mine + end) + local ok, res = coroutine.resume(co) + return res +end +return t.work(3) + spawn() diff --git a/devtools/vscode-dfhack-lua/extension.js b/devtools/vscode-dfhack-lua/extension.js new file mode 100644 index 0000000000..5a24176393 --- /dev/null +++ b/devtools/vscode-dfhack-lua/extension.js @@ -0,0 +1,24 @@ +// DFHack Lua debug adapter shim. +// +// The luadebugger DFHack plugin already speaks the Debug Adapter Protocol +// directly on a loopback TCP socket, so this extension does no protocol +// translation at all: it simply points VSCode at the running server via a +// DebugAdapterServer descriptor. +const vscode = require('vscode'); + +function activate(context) { + const factory = { + createDebugAdapterDescriptor(session) { + const config = session.configuration || {}; + const host = config.host || '127.0.0.1'; + const port = config.port || 43030; + return new vscode.DebugAdapterServer(port, host); + } + }; + context.subscriptions.push( + vscode.debug.registerDebugAdapterDescriptorFactory('dfhack-lua', factory)); +} + +function deactivate() {} + +module.exports = { activate, deactivate }; diff --git a/devtools/vscode-dfhack-lua/package.json b/devtools/vscode-dfhack-lua/package.json new file mode 100644 index 0000000000..1bd52c82e3 --- /dev/null +++ b/devtools/vscode-dfhack-lua/package.json @@ -0,0 +1,55 @@ +{ + "name": "dfhack-lua-debug", + "displayName": "DFHack Lua Debugger", + "description": "Attach VSCode to the DFHack luadebugger plugin's DAP endpoint and debug Lua running inside Dwarf Fortress.", + "version": "0.1.0", + "publisher": "dfhack", + "license": "MIT", + "engines": { + "vscode": "^1.75.0" + }, + "categories": [ + "Debuggers" + ], + "main": "./extension.js", + "contributes": { + "debuggers": [ + { + "type": "dfhack-lua", + "label": "DFHack Lua", + "languages": [ + "lua" + ], + "configurationAttributes": { + "attach": { + "properties": { + "host": { + "type": "string", + "description": "Host running DFHack's luadebugger plugin.", + "default": "127.0.0.1" + }, + "port": { + "type": "number", + "description": "TCP port the luadebugger plugin is listening on.", + "default": 43030 + }, + "stopOnEntry": { + "type": "boolean", + "description": "Break at the next Lua line after attaching.", + "default": false + } + } + } + }, + "initialConfigurations": [ + { + "type": "dfhack-lua", + "request": "attach", + "name": "Attach to DFHack", + "port": 43030 + } + ] + } + ] + } +} diff --git a/docs/changelog.txt b/docs/changelog.txt index 21cce92940..ba65e25b04 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -56,6 +56,7 @@ Template for new versions: # Future ## New Tools +- `luadebugger`: Debug Adapter Protocol server for interactively debugging Lua running inside DFHack (breakpoints, stepping, stack/variable inspection, evaluation); attach with the VSCode extension in ``devtools/vscode-dfhack-lua`` or the CLI client at ``devtools/luadebug-cli.py`` ## New Features diff --git a/docs/plugins/luadebugger.rst b/docs/plugins/luadebugger.rst new file mode 100644 index 0000000000..0d22023b5a --- /dev/null +++ b/docs/plugins/luadebugger.rst @@ -0,0 +1,105 @@ +luadebugger +=========== + +.. dfhack-tool:: + :summary: Debug Adapter Protocol server for interactively debugging DFHack Lua code. + :tags: dev + +The ``luadebugger`` plugin hosts a `Debug Adapter Protocol +`__ (DAP) server inside +DFHack so that Lua code running in the game can be debugged interactively from +an external client -- breakpoints, stepping, stack traces, variable +inspection, expression evaluation, and variable assignment are all supported. + +The plugin is needed because DFHack's normal UI interaction paths cannot be +used for debugging: they all rely on yielding control back to Dwarf Fortress +so it can render and process input, and that is impossible while Lua is +stopped at a breakpoint. Instead, the plugin listens on a TCP socket +(``127.0.0.1:43030`` by default) and speaks DAP directly, so any DAP-capable +client -- most notably Visual Studio Code -- can attach to a running game. + +While stopped at a breakpoint, the simulation thread is blocked in the +debugger, so the game is frozen. This is normal; the game resumes when the +debugger continues execution. + +Usage +----- + +``luadebugger`` + Show whether the debug server is running, which port it is bound to, and + whether a client is connected. +``luadebugger status`` + Same as above. +``luadebugger start [port]`` + Start the debug server, listening on ``127.0.0.1:`` + (default ``43030``). The plugin starts the server automatically when it is + enabled; use this to restart it or change the port. +``luadebugger stop`` + Stop the debug server and disconnect any attached client. + +The server accepts a single client connection at a time. When a client +attaches, the Lua-side debuggee installs line hooks on the main Lua thread and +on subsequently created coroutines (including coroutines created by +``dfhack.run_script``, ``dfhack.enable_script``, and the Lua interpreter), so +breakpoints apply across the whole Lua environment. + +Attaching from VSCode +--------------------- + +An extension scaffold is provided in ``devtools/vscode-dfhack-lua``. Install +or symlink it into your VSCode extensions directory, then add a launch +configuration:: + + { + "type": "dfhack-lua", + "request": "attach", + "name": "Attach to DFHack", + "host": "127.0.0.1", + "port": 43030, + "stopOnEntry": false + } + +With ``stopOnEntry`` set to ``true``, execution pauses on the next Lua line +executed after ``configurationDone``, which is useful for catching script +startup. + +Breakpoints set in VSCode are resolved against the chunk name the code was +loaded with (e.g. ``hack/scripts/foo.lua`` style paths used by +``dfhack.script_environment``); paths are normalized and matched by suffix, so +absolute client paths generally match DFHack-internal relative paths. + +Attaching with the CLI client +----------------------------- + +``devtools/luadebug-cli.py`` is a minimal standalone DAP client:: + + python3 devtools/luadebug-cli.py [host] [port] + +It accepts commands for breakpoints (``b path:line``), stepping (``n``, ``s``, +``o``), continuing (``c``), pausing, thread listing (``t``), stack traces +(``bt``), variable expansion (``v``), and expression evaluation (``p expr``). + +Supported protocol features +--------------------------- + +- ``initialize``, ``attach``, ``configurationDone``, ``disconnect``, + ``terminate`` +- ``setBreakpoints``, ``setFunctionBreakpoints``, + ``setExceptionBreakpoints`` (reported but not implemented; exceptions are + not trapped) +- ``threads``, ``stackTrace``, ``scopes``, ``variables``, ``evaluate``, + ``setVariable``, ``loadedSources`` +- ``continue``, ``next`` (step over), ``stepIn``, ``stepOut``, ``pause`` + +Notes and limitations +--------------------- + +- The listener binds to loopback only. Do not expose the port to the network; + the protocol has no authentication and grants full code execution in the + Lua environment. +- Only one debug client may be connected at a time. Connecting a second + client replaces the first. +- Lua output produced while a session is attached is mirrored to the client's + debug console via DAP ``output`` events in addition to the DFHack console. +- ``stepOut`` may not produce a stop event if the stepped-out frame + immediately returns and its thread finishes. diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index 45290fbc3c..99b2b2fa3f 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -96,6 +96,7 @@ if(BUILD_SUPPORTED) #dfhack_plugin(jobutils jobutils.cpp) dfhack_plugin(lair lair.cpp) dfhack_plugin(liquids liquids.cpp Brushes.h LINK_LIBRARIES lua) + dfhack_plugin(luadebugger luadebugger.cpp LINK_LIBRARIES clsocket lua) dfhack_plugin(luasocket luasocket.cpp LINK_LIBRARIES clsocket lua) dfhack_plugin(logistics logistics.cpp LINK_LIBRARIES lua) #dfhack_plugin(manipulator manipulator.cpp) diff --git a/plugins/lua/luadebugger.lua b/plugins/lua/luadebugger.lua new file mode 100644 index 0000000000..d20297c5c2 --- /dev/null +++ b/plugins/lua/luadebugger.lua @@ -0,0 +1,874 @@ +-- Debug Adapter Protocol (DAP) debuggee for DFHack Lua. +-- +-- This module implements the "debug adapter" side of a DAP session in pure +-- Lua. It talks to the remote client through whole DAP message bodies +-- exchanged with the luadebugger plugin's mailboxes (debugger_recv / +-- debugger_send); the plugin owns the TCP listener and the transport threads. +-- +-- Execution model: debug.sethook installs a line hook on the main state and +-- on every coroutine created after session start (coroutine entry points and +-- the dfhack.* entry points that core C++ resumes in fresh threads are +-- wrapped). When a breakpoint is hit the hook enters a command loop that +-- blocks the simulation thread in debugger_recv() until the client resumes +-- execution. The game therefore freezes while stopped, which is the intended +-- "paused" semantics and needs no cooperation from DF's event loop. +-- +-- poll() is invoked once per frame by plugin_onupdate whenever the plugin has +-- inbound traffic or a client state change, so connection setup and commands +-- that don't require a stopped thread are serviced even when no Lua code is +-- running. + +local _ENV = mkmodule('plugins.luadebugger') + +local json = require('json') + +local recv_raw = debugger_recv +local send_raw = debugger_send +local is_connected = debugger_connected + +local DBG_SOURCE = debug.getinfo(1, 'S').source +local main_thread = coroutine.running() + +-- tunables +local POLL_INTERVAL = 128 -- line-hook events between inbound mailbox drains +local MAX_STRING = 1024 -- max string length reported in variables +local MAX_CHILDREN = 1000 -- max table children per variables page + +------------------------------------------------------------------------ +-- session state +------------------------------------------------------------------------ +local active = false -- session active (hooks installed) +local attach_args = nil -- arguments from the attach request +local stopped_co = nil -- coroutine currently blocked in debug_loop +local stopped_depth = 0 -- stack depth of stopped_co measured in hook +local pause_requested = false +local stepping = nil -- {mode='in'|'over'|'out', co=thread, depth=int} +local seq_counter = 0 + +local breakpoints = {} -- normalized client path -> {line -> true} +local chunk_to_path = {} -- chunk source -> normalized client path|false +local seen_sources = {} -- all chunk sources observed by the hook + +local thread_ids = setmetatable({}, {__mode='k'}) -- thread -> int id +local next_thread_id = 0 + +local var_refs = {} -- variablesReference -> descriptor +local next_var_ref = 1 +local frame_refs = {} -- stackFrame id -> {co, rel} +local next_frame_id = 1 + +local saved = {} -- original functions, for unpatch + +------------------------------------------------------------------------ +-- small utilities +------------------------------------------------------------------------ +local function next_seq() + seq_counter = seq_counter + 1 + return seq_counter +end + +local function send_response(req, body, ok, message) + send_raw(json.encode{ + seq = next_seq(), + type = 'response', + request_seq = req.seq, + command = req.command, + success = ok ~= false, + message = message, + body = body, + }) +end + +local function send_event(event, body) + send_raw(json.encode{ + seq = next_seq(), + type = 'event', + event = event, + body = body, + }) +end + +local function tostring_safe(v) + local ok, s = pcall(tostring, v) + if ok then return s end + return '(' .. type(v) .. ': tostring failed)' +end + +local function normalize_path(p) + return tostring(p or ''):gsub('\\', '/'):lower() +end + +local function thread_id(co) + local id = thread_ids[co] + if not id then + next_thread_id = next_thread_id + 1 + id = next_thread_id + thread_ids[co] = id + end + return id +end + +local function thread_of_id(id) + for co, tid in pairs(thread_ids) do + if tid == id then return co end + end +end + +local function thread_name(co) + if co == main_thread then return 'main thread' end + local info = debug.getinfo(co, 0, 'Sl') + if info then + return ('coroutine %s:%d (%s)'):format( + info.short_src, info.currentline, coroutine.status(co)) + end + return 'coroutine (' .. coroutine.status(co) .. ')' +end + +local function register_thread(co) + thread_id(co) +end + +------------------------------------------------------------------------ +-- stack inspection +------------------------------------------------------------------------ +-- Stack layout while the hook is executing (levels as debug.getinfo counts +-- them, from inside hook_impl): +-- 0 the debug.getinfo C call itself +-- 1 hook_impl (this file) +-- 2 pcall (C) +-- 3 hook_fn (this file) +-- 4 the frame that fired the event +-- 5+ user call frames +-- Debugger frames appear only above the firing frame, and user_base_for must +-- therefore subtract one frame per helper function on the call path: a level +-- number is only valid for the frame that computes it. + +-- Returns the getinfo level of co's first non-debugger frame, adjusted so the +-- result is valid when used by this function's caller. +local function user_frame_level(co) + local m = 2 -- 0 = the getinfo call, 1 = this frame; start at the caller + while true do + local info = debug.getinfo(co, m, 'S') + if not info or + (info.source ~= DBG_SOURCE and info.source ~= '=[C]') then + return m - 1 + end + m = m + 1 + end +end + +local function stack_depth(co) + local n = 0 + while debug.getinfo(co, n) do n = n + 1 end + return n +end + +-- getinfo level of a frame in the stopped thread, adjusted for this +-- function's own frame. rel=0 is the user frame that fired the hook; larger +-- rel values walk towards the stack bottom. +local function user_base_for(co) + if co == stopped_co then + return user_frame_level(co) - 1 + end + return 0 +end + +local function frame_source(info) + local src = {name = info.short_src} + if info.source:sub(1, 1) == '@' then + src.path = info.source:sub(2) + end + return src +end + +local function frame_func_env(co, level) + local info = debug.getinfo(co, level, 'f') + if info and info.func then + local i = 1 + while true do + local name, v = debug.getupvalue(info.func, i) + if not name then break end + if name == '_ENV' then return v end + i = i + 1 + end + end +end + +------------------------------------------------------------------------ +-- breakpoint bookkeeping +------------------------------------------------------------------------ +local function chunk_key(source) + if type(source) ~= 'string' or source:sub(1, 1) ~= '@' then + return nil + end + return normalize_path(source:sub(2)) +end + +-- Suffix-match a chunk path against the paths clients have breakpoints on. +-- VSCode sends absolute filesystem paths; DFHack chunks may be absolute or +-- relative (e.g. @./hack/lua/foo.lua), so match on trailing path segments. +local function suffix_match(key, path) + if #key >= #path then + return key:sub(-#path) == path and + (#key == #path or key:sub(-#path - 1, -#path - 1) == '/') + else + return path:sub(-#key) == key and + (#path == #key or path:sub(-#key - 1, -#key - 1) == '/') + end +end + +local function match_bp_path(chunk_src) + local cached = chunk_to_path[chunk_src] + if cached ~= nil then return cached end + local key = chunk_key(chunk_src) + local found = false + if key then + local best = 0 + for bp_path in pairs(breakpoints) do + if suffix_match(key, bp_path) and #bp_path > best then + best = #bp_path + found = bp_path + end + end + end + chunk_to_path[chunk_src] = found + return found +end + +------------------------------------------------------------------------ +-- variable materialization +------------------------------------------------------------------------ +local function make_var_ref(entry) + local id = next_var_ref + next_var_ref = id + 1 + var_refs[id] = entry + return id +end + +local function var_entry(name, v) + local t = type(v) + local e = {name = tostring(name), type = t, variablesReference = 0} + if t == 'string' then + local s = v + if #s > MAX_STRING then s = s:sub(1, MAX_STRING) .. '...' end + e.value = '"' .. s .. '"' + elseif t == 'table' then + e.value = tostring_safe(v) + local named, indexed = 0, 0 + for k in next, v do + if type(k) == 'number' then indexed = indexed + 1 + else named = named + 1 end + end + e.namedVariables = named + e.indexedVariables = indexed + e.variablesReference = make_var_ref{kind = 'table', t = v} + elseif t == 'function' then + local info = debug.getinfo(v, 'nS') + if info then + e.value = ('function %s:%d'):format(info.short_src, info.linedefined) + else + e.value = tostring_safe(v) + end + else + e.value = tostring_safe(v) + end + return e +end + +local function table_children(t, args) + local vars = {} + local start = args.start or 0 + local count = args.count + local filter = args.filter + local idx = 0 + for k, v in next, t do + local indexed = type(k) == 'number' + if not filter or (filter == 'indexed') == indexed then + idx = idx + 1 + if idx > start and (not count or #vars < count) then + vars[#vars + 1] = var_entry(k, v) + end + end + if idx > start + MAX_CHILDREN then + vars[#vars + 1] = {name = '...', value = '(truncated)', + type = 'string', variablesReference = 0} + break + end + end + return vars +end + +------------------------------------------------------------------------ +-- request handlers; a handler returns 'continue' to resume execution +------------------------------------------------------------------------ +local request_handlers = {} + +request_handlers.initialize = function(req, args) + send_response(req, { + supportsConfigurationDoneRequest = true, + supportsEvaluateForHovers = true, + supportsSetVariable = true, + supportsLoadedSourcesRequest = true, + }) + send_event('initialized') +end + +request_handlers.attach = function(req, args) + attach_args = args + send_response(req) +end + +request_handlers.launch = function(req) + send_response(req, nil, false, + 'luadebugger only supports attach requests') +end + +request_handlers.setBreakpoints = function(req, args) + local src = args.source or {} + local path = src.path or src.name + local resp = {} + if path then + local lines = {} + for _, bp in ipairs(args.breakpoints or {}) do + if bp.line then lines[bp.line] = true end + resp[#resp + 1] = {verified = true, line = bp.line} + end + breakpoints[normalize_path(path)] = lines + chunk_to_path = {} -- invalidate the chunk->client path cache + end + send_response(req, {breakpoints = resp}) +end + +request_handlers.setExceptionBreakpoints = function(req) + send_response(req, {breakpoints = {}}) +end + +request_handlers.setFunctionBreakpoints = function(req) + send_response(req, {breakpoints = {}}) +end + +request_handlers.configurationDone = function(req) + send_response(req) + if attach_args and attach_args.stopOnEntry then + pause_requested = true + end +end + +request_handlers.pause = function(req) + pause_requested = true + send_response(req) +end + +request_handlers.threads = function(req) + local list, seen = {}, {} + local function add(co) + if co and not seen[co] and coroutine.status(co) ~= 'dead' then + seen[co] = true + list[#list + 1] = {id = thread_id(co), name = thread_name(co)} + end + end + add(stopped_co) + for co in pairs(thread_ids) do add(co) end + add(main_thread) + send_response(req, {threads = list}) +end + +request_handlers.stackTrace = function(req, args) + local co = thread_of_id(args.threadId) or stopped_co + local frames, total = {}, 0 + if co and coroutine.status(co) ~= 'dead' then + local base = user_base_for(co) + local startf = args.startFrame or 0 + local maxn = args.levels + local rel = 0 + while true do + local info = debug.getinfo(co, base + rel, 'nSl') + if not info then break end + total = total + 1 + if rel >= startf and (not maxn or #frames < maxn) then + local fid = next_frame_id + next_frame_id = fid + 1 + frame_refs[fid] = {co = co, rel = rel} + frames[#frames + 1] = { + id = fid, + name = info.name or + ('%s:%d'):format(info.short_src, info.currentline), + source = frame_source(info), + line = info.currentline, + column = 0, + } + end + rel = rel + 1 + end + end + send_response(req, {stackFrames = frames, totalFrames = total}) +end + +request_handlers.scopes = function(req, args) + local fr = frame_refs[args.frameId] + local scopes = {} + if fr then + scopes[#scopes + 1] = {name = 'Locals', expensive = false, + variablesReference = make_var_ref{kind = 'locals', + co = fr.co, rel = fr.rel}} + scopes[#scopes + 1] = {name = 'Upvalues', expensive = false, + variablesReference = make_var_ref{kind = 'upvalues', + co = fr.co, rel = fr.rel}} + scopes[#scopes + 1] = {name = 'Globals', expensive = true, + variablesReference = make_var_ref{kind = 'globals', + co = fr.co, rel = fr.rel}} + end + send_response(req, {scopes = scopes}) +end + +request_handlers.variables = function(req, args) + local entry = var_refs[args.variablesReference] + local vars = {} + if entry then + if entry.kind == 'table' then + vars = table_children(entry.t, args) + elseif entry.kind == 'globals' then + local level = user_base_for(entry.co) + entry.rel + local fenv = frame_func_env(entry.co, level) or dfhack.BASE_G + vars = table_children(fenv, args) + else + local level = user_base_for(entry.co) + entry.rel + if entry.kind == 'locals' then + local i = 1 + while true do + local n, v = debug.getlocal(entry.co, level, i) + if not n then break end + vars[#vars + 1] = var_entry(n, v) + i = i + 1 + end + i = -1 + while true do + local n, v = debug.getlocal(entry.co, level, i) + if not n then break end + vars[#vars + 1] = var_entry(n, v) + i = i - 1 + end + elseif entry.kind == 'upvalues' then + local info = debug.getinfo(entry.co, level, 'f') + if info and info.func then + local i = 1 + while true do + local n, v = debug.getupvalue(info.func, i) + if not n then break end + vars[#vars + 1] = var_entry(n, v) + i = i + 1 + end + end + end + end + end + send_response(req, {variables = vars}) +end + +local function eval_env(co, rel) + local env = {} + local fenv = dfhack.BASE_G + if co and rel then + local level = user_base_for(co) + rel + local i = 1 + while true do + local n, v = debug.getlocal(co, level, i) + if not n then break end + env[n] = v + i = i + 1 + end + local info = debug.getinfo(co, level, 'f') + if info and info.func then + i = 1 + while true do + local n, v = debug.getupvalue(info.func, i) + if not n then break end + env[n] = v + i = i + 1 + end + end + fenv = frame_func_env(co, level) or fenv + end + return setmetatable(env, {__index = fenv}) +end + +request_handlers.evaluate = function(req, args) + local fr = args.frameId and frame_refs[args.frameId] + local env = fr and eval_env(fr.co, fr.rel) or eval_env() + local chunk = load('return ' .. args.expression, '=(debug eval)', 't', env) + if not chunk then + chunk = load(args.expression, '=(debug eval)', 't', env) + end + if not chunk then + send_response(req, nil, false, + 'cannot parse expression: ' .. args.expression) + return + end + local ok, res = pcall(chunk) + if not ok then + send_response(req, nil, false, tostring_safe(res)) + return + end + local e = var_entry('result', res) + send_response(req, { + result = e.value, type = e.type, + variablesReference = e.variablesReference, + namedVariables = e.namedVariables, + indexedVariables = e.indexedVariables, + }) +end + +request_handlers.setVariable = function(req, args) + local entry = var_refs[args.variablesReference] + if not entry then + send_response(req, nil, false, 'unknown variable') + return + end + local chunk = load('return ' .. args.value, '=(debug setvar)', 't', {}) + local ok, parsed = chunk and pcall(chunk) + if not ok then + send_response(req, nil, false, 'cannot parse value: ' .. args.value) + return + end + if entry.kind == 'table' then + entry.t[tonumber(args.name) or args.name] = parsed + else + local level = user_base_for(entry.co) + entry.rel + if entry.kind == 'locals' then + local i = 1 + while true do + local n = debug.getlocal(entry.co, level, i) + if not n then break end + if n == args.name then + debug.setlocal(entry.co, level, i, parsed) + break + end + i = i + 1 + end + elseif entry.kind == 'upvalues' then + local info = debug.getinfo(entry.co, level, 'f') + if info and info.func then + local i = 1 + while true do + local n = debug.getupvalue(info.func, i) + if not n then break end + if n == args.name then + debug.setupvalue(info.func, i, parsed) + break + end + i = i + 1 + end + end + else + (frame_func_env(entry.co, level) or dfhack.BASE_G)[args.name] = parsed + end + end + local e = var_entry(args.name, parsed) + send_response(req, {value = e.value, type = e.type, + variablesReference = e.variablesReference}) +end + +request_handlers['continue'] = function(req) + send_response(req, {allThreadsContinued = true}) + send_event('continued', { + threadId = stopped_co and thread_id(stopped_co) or 1, + allThreadsContinued = true}) + return 'continue' +end + +local function step(mode) + return function(req) + stepping = {mode = mode, co = stopped_co, depth = stopped_depth} + send_response(req) + return 'continue' + end +end +request_handlers.next = step('over') +request_handlers.stepIn = step('in') +request_handlers.stepOut = step('out') + +request_handlers.disconnect = function(req) + send_response(req) + send_event('terminated') + deactivate() + return 'continue' +end +request_handlers.terminate = request_handlers.disconnect + +request_handlers.loadedSources = function(req) + local srcs = {} + for s in pairs(seen_sources) do + local e = {name = s} + if s:sub(1, 1) == '@' then + e.name = s:sub(2):match('[^/\\]+$') or s:sub(2) + e.path = s:sub(2) + end + srcs[#srcs + 1] = e + end + send_response(req, {sources = srcs}) +end + +------------------------------------------------------------------------ +-- message dispatch +------------------------------------------------------------------------ +local function handle_message(data) + local ok, req = pcall(json.decode, data) + if not ok or type(req) ~= 'table' or req.type ~= 'request' then + return + end + local handler = request_handlers[req.command] + if not handler then + send_response(req, nil, false, + 'unsupported request: ' .. tostring(req.command)) + return + end + local ok2, resume = pcall(handler, req, req.arguments or {}) + if not ok2 then + send_response(req, nil, false, + 'handler error: ' .. tostring_safe(resume)) + return + end + return resume +end + +local function drain_messages() + for _ = 1, 32 do + local data = recv_raw(0) + if data == '' then break end + if not active then activate() end + handle_message(data) + end +end + +------------------------------------------------------------------------ +-- the debug loop: entered from the hook while stopped +------------------------------------------------------------------------ +local function debug_loop(reason, co) + stopped_co = co + var_refs = {} + next_var_ref = 1 + frame_refs = {} + next_frame_id = 1 + + send_event('stopped', { + reason = reason, + threadId = thread_id(co), + allThreadsStopped = true, + }) + + while active and is_connected() do + local data = recv_raw(-1) + if data == '' then break end -- disconnect or plugin shutdown + if handle_message(data) then break end + end + + stopped_co = nil +end + +------------------------------------------------------------------------ +-- the hook +------------------------------------------------------------------------ +local in_hook = false +local poll_counter = 0 + +local function hook_impl(event, line, info) + local co = coroutine.running() + -- info describes the frame that fired this event; if it is debugger code + -- (e.g. the coroutine.create wrapper or captured_print running under a + -- user call), skip the event entirely. + if not info or info.source == DBG_SOURCE then return end + seen_sources[info.source] = true + + poll_counter = poll_counter + 1 + if poll_counter >= POLL_INTERVAL then + poll_counter = 0 + drain_messages() + if not active then return end + end + + if pause_requested then + pause_requested = false + stopped_depth = stack_depth(co) + debug_loop('pause', co) + return + end + + if stepping and stepping.co == co then + -- depths compared here are measured with hook_impl on the stack, so + -- stopped_depth is recorded at this same call site for consistency + local d = stack_depth(co) + local hit = (stepping.mode == 'in') or + (stepping.mode == 'over' and d <= stepping.depth) or + (stepping.mode == 'out' and d < stepping.depth) + if hit then + stepping = nil + stopped_depth = d + debug_loop('step', co) + return + end + end + + if event == 'line' and next(breakpoints) ~= nil then + local bp_path = match_bp_path(info.source) + if bp_path and breakpoints[bp_path][line] then + stopped_depth = stack_depth(co) + debug_loop('breakpoint', co) + end + end +end + +local function hook_fn(event, line) + if in_hook or not active then return end + in_hook = true + -- the frame that triggered this event sits directly below this hook + -- function (level 0 is the getinfo call itself, level 1 is hook_fn) + local info = debug.getinfo(2, 'nSl') + local ok, err = pcall(hook_impl, event, line, info) + in_hook = false + if not ok then + pcall(send_event, 'output', { + category = 'stderr', + output = 'luadebugger: internal error: ' .. + tostring_safe(err) .. '\n', + }) + deactivate() + end +end + +------------------------------------------------------------------------ +-- session lifecycle +------------------------------------------------------------------------ +local function install_thread_hook(co) + co = co or main_thread + debug.sethook(co, hook_fn, 'l') + register_thread(co) +end + +local function wrap_body(fn) + return function(...) + if active then + install_thread_hook(coroutine.running()) + end + return fn(...) + end +end + +local function patch_entry_points() + saved.create = coroutine.create + coroutine.create = function(f, ...) + local co = saved.create(function(...) + install_thread_hook(coroutine.running()) + return f(...) + end) + register_thread(co) + return co + end + saved.wrap = coroutine.wrap + coroutine.wrap = function(f, ...) + -- wrap() does not expose the thread, so it must self-register inside + return saved.wrap(function(...) + local me = coroutine.running() + install_thread_hook(me) + register_thread(me) + return f(...) + end) + end + -- These dfhack functions are the bodies of coroutines created by core C++ + -- (Lua::NewCoroutine via RunCoreQueryLoop); wrapping them hooks those + -- threads at startup. + for _, field in ipairs{'run_script', 'enable_script', 'interpreter'} do + local fn = dfhack[field] + if type(fn) == 'function' then + saved['dfhack.' .. field] = fn + dfhack[field] = wrap_body(fn) + end + end + -- Redirect print output to the debug console while a session is active. + saved.print = dfhack.BASE_G.print + saved.dfhack_print = dfhack.print + local function captured_print(...) + local parts = {} + for i = 1, select('#', ...) do + parts[#parts + 1] = tostring_safe(select(i, ...)) + end + send_event('output', { + category = 'stdout', + output = table.concat(parts, '\t') .. '\n', + }) + saved.print(...) + end + dfhack.BASE_G.print = captured_print + dfhack.print = captured_print +end + +local function unpatch_entry_points() + if saved.create then coroutine.create = saved.create end + if saved.wrap then coroutine.wrap = saved.wrap end + for _, field in ipairs{'run_script', 'enable_script', 'interpreter'} do + local k = 'dfhack.' .. field + if saved[k] then dfhack[field] = saved[k] end + end + if saved.print then dfhack.BASE_G.print = saved.print end + if saved.dfhack_print then dfhack.print = saved.dfhack_print end + saved = {} +end + +function activate() + if active then return end + active = true + patch_entry_points() + install_thread_hook(main_thread) + send_event('output', { + category = 'console', + output = 'luadebugger: debug session started\n', + }) +end + +function deactivate() + if not active then return end + active = false + debug.sethook() -- clear hook on current (main) thread + for co in pairs(thread_ids) do + pcall(debug.sethook, co) -- clear hooks on coroutines + end + unpatch_entry_points() + pause_requested = false + stepping = nil + stopped_co = nil + -- thread/frame/variable ids are session-scoped + thread_ids = setmetatable({}, {__mode='k'}) + next_thread_id = 0 +end + +-- Called once per frame from the plugin's onupdate whenever the transport has +-- inbound traffic or a client state change. in_hook suppresses the line hook +-- for the duration: functions the debuggee itself calls (json decode/encode, +-- client callbacks) are ordinary Lua code and would otherwise trigger hook +-- events that recursively drain the mailbox and reorder message handling. +function poll() + if in_hook then return end + in_hook = true + local ok, err = pcall(function() + if not is_connected() then + deactivate() + return + end + for _ = 1, 64 do + local data = recv_raw(0) + if data == '' then break end + if not active then activate() end + handle_message(data) + end + end) + in_hook = false + if not ok then + pcall(send_event, 'output', { + category = 'stderr', + output = 'luadebugger: poll error: ' .. + tostring_safe(err) .. '\n', + }) + deactivate() + end +end + +return _ENV diff --git a/plugins/luadebugger.cpp b/plugins/luadebugger.cpp new file mode 100644 index 0000000000..9c577c9459 --- /dev/null +++ b/plugins/luadebugger.cpp @@ -0,0 +1,419 @@ +/* + * luadebugger: remote debugging server for DFHack Lua. + * + * Hosts a Debug Adapter Protocol (DAP) endpoint on a loopback TCP socket so + * that external tools (VSCode, nvim-dap, the bundled CLI client) can drive a + * Lua debug session inside the running game. + * + * All socket I/O happens on dedicated threads. The Lua debuggee (the + * plugins.luadebugger module) never touches the socket directly; it exchanges + * whole DAP message bodies (JSON) with this plugin through two condition + * variable mailboxes. This keeps the simulation thread free of socket calls + * and satisfies the rule that the lua_State may only be accessed under + * CoreSuspender on the simulation thread. + * + * While the debuggee is stopped at a breakpoint, the simulation thread blocks + * inside debugger_recv() waiting on the inbound mailbox, which is exactly the + * "paused" behavior a debugger needs -- and requires no cooperation from DF's + * render/event loop. + */ + +#include "Console.h" +#include "Core.h" +#include "Debug.h" +#include "PluginManager.h" +#include "PluginLua.h" +#include "LuaTools.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace DFHack; + +DFHACK_PLUGIN("luadebugger"); +DFHACK_PLUGIN_IS_ENABLED(is_enabled); + +namespace DFHack { + DBG_DECLARE(luadebugger, status, DebugCategory::LINFO); + DBG_DECLARE(luadebugger, io, DebugCategory::LINFO); +} + +static constexpr int32_t DEFAULT_PORT = 43030; + +namespace { + +struct Mailbox { + std::mutex m; + std::condition_variable cv; + std::deque queue; +}; + +class DebugServer { +public: + bool start(color_ostream &out, int32_t port); + void stop(); + + bool is_listening() const { return listening.load(); } + int32_t get_port() const { return bound_port.load(); } + bool has_client() const { return client_connected.load(); } + + // Consumed by plugin_onupdate on the simulation thread. + bool take_poll_flag() { return needs_poll.exchange(false); } + + // Lua-facing mailbox API. Only safe to call from the simulation thread. + std::string recv(int32_t timeout_ms); + bool send(const std::string &msg); + +private: + void accept_loop(); + void client_io_loop(std::unique_ptr sock, uint32_t id); + void client_disconnected(uint32_t id); + + Mailbox inbound; // complete DAP message bodies, socket -> lua + Mailbox outbound; // complete DAP message bodies, lua -> socket + + std::atomic needs_poll{false}; // asks onupdate to call Lua poll() + std::atomic client_connected{false}; + std::atomic client_id{0}; // increments per accepted client + std::atomic stopping{false}; + std::atomic listening{false}; + std::atomic bound_port{0}; + + CPassiveSocket listener; + std::thread accept_thread; + std::thread io_thread; +}; + +DebugServer server; + +// --------------------------------------------------------------------------- +// DAP framing: "Content-Length: \r\n\r\n" +// --------------------------------------------------------------------------- + +struct DapFramer { + std::string buf; + size_t body_start = std::string::npos; + int32_t content_length = -1; + + // Append received bytes; push each complete message body to `out`. + void feed(const uint8_t *data, size_t len, std::deque &out) { + buf.append(reinterpret_cast(data), len); + for (;;) { + if (content_length < 0) { + size_t hdr_end = buf.find("\r\n\r\n"); + if (hdr_end == std::string::npos) + return; + std::string header = buf.substr(0, hdr_end); + body_start = hdr_end + 4; + size_t pos = header.find("Content-Length:"); + if (pos == std::string::npos) { + // malformed; resync after this header block + buf.erase(0, body_start); + body_start = std::string::npos; + continue; + } + content_length = (std::max)(0, std::atoi(header.c_str() + pos + 15)); + } + if (buf.size() < body_start + size_t(content_length)) + return; + out.emplace_back(buf.substr(body_start, content_length)); + buf.erase(0, body_start + content_length); + body_start = std::string::npos; + content_length = -1; + } + } + + static std::string frame(const std::string &body) { + return "Content-Length: " + std::to_string(body.size()) + "\r\n\r\n" + body; + } +}; + +// --------------------------------------------------------------------------- +// server lifecycle +// --------------------------------------------------------------------------- + +bool DebugServer::start(color_ostream &out, int32_t port) { + if (listening.load()) + return true; + + stopping = false; + if (!listener.Initialize()) { + out.printerr("luadebugger: failed to initialize socket\n"); + return false; + } + // The debug channel grants full code execution in the Lua context; it must + // never be reachable from other hosts. + if (!listener.Listen("127.0.0.1", port)) { + out.printerr("luadebugger: failed to bind 127.0.0.1:{}: {}\n", + port, listener.DescribeError()); + listener.Close(); + return false; + } + listener.SetNonblocking(); + bound_port = port; + listening = true; + accept_thread = std::thread(&DebugServer::accept_loop, this); + out.print("luadebugger: listening for debug clients on 127.0.0.1:{}\n", port); + return true; +} + +void DebugServer::accept_loop() { + while (!stopping.load()) { + std::unique_ptr sock{listener.Accept()}; + if (!sock) { + auto err = listener.GetSocketError(); + if (err == CSimpleSocket::SocketInvalidSocket || + err == CSimpleSocket::SocketProtocolError) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(25)); + continue; + } + + if (client_connected.load()) { + // Only a single client session is supported; drop extras. + WARN(io).print("luadebugger: rejecting extra debug client\n"); + sock->Close(); + continue; + } + + sock->SetBlocking(); + sock->SetReceiveTimeout(0, 50000); + sock->SetSendTimeout(0, 50000); + uint32_t id = ++client_id; + client_connected = true; + needs_poll = true; + if (io_thread.joinable()) + io_thread.join(); + io_thread = std::thread(&DebugServer::client_io_loop, this, + std::move(sock), id); + DEBUG(status).print("luadebugger: debug client connected\n"); + } +} + +void DebugServer::client_io_loop(std::unique_ptr sock, uint32_t id) { + DapFramer framer; + bool alive = true; + + while (alive && !stopping.load()) { + int32_t n = sock->Receive(64 * 1024); + if (n > 0) { + { + std::lock_guard lk(inbound.m); + framer.feed(sock->GetData(), n, inbound.queue); + } + inbound.cv.notify_all(); + needs_poll = true; + } + else if (n < 0) { + auto err = sock->GetSocketError(); + if (err != CSimpleSocket::SocketTimedout && + err != CSimpleSocket::SocketEwouldblock) + break; + } + else { + break; // orderly close by peer + } + + // Drain outbound mailbox. + for (;;) { + std::string msg; + { + std::lock_guard lk(outbound.m); + if (outbound.queue.empty()) + break; + msg = std::move(outbound.queue.front()); + outbound.queue.pop_front(); + } + std::string framed = DapFramer::frame(msg); + if (sock->Send(reinterpret_cast(framed.data()), + framed.size()) <= 0) { + alive = false; + break; + } + } + } + + sock->Close(); + client_disconnected(id); +} + +void DebugServer::client_disconnected(uint32_t id) { + // a newer client may already have been accepted; only reset state that + // still belongs to this connection + if (client_id.load() != id) + return; + client_connected = false; + needs_poll = true; + { + std::lock_guard lk(inbound.m); + inbound.queue.clear(); + } + { + std::lock_guard lk(outbound.m); + outbound.queue.clear(); + } + inbound.cv.notify_all(); + DEBUG(status).print("luadebugger: debug client disconnected\n"); +} + +void DebugServer::stop() { + stopping = true; + listener.Close(); + inbound.cv.notify_all(); + outbound.cv.notify_all(); + if (accept_thread.joinable()) + accept_thread.join(); + if (io_thread.joinable()) + io_thread.join(); + { + std::lock_guard lk(inbound.m); + inbound.queue.clear(); + } + { + std::lock_guard lk(outbound.m); + outbound.queue.clear(); + } + client_connected = false; + listening = false; +} + +// --------------------------------------------------------------------------- +// mailbox access from the simulation thread (exposed to Lua) +// --------------------------------------------------------------------------- + +std::string DebugServer::recv(int32_t timeout_ms) { + std::unique_lock lk(inbound.m); + auto ready = [this]() { + return !inbound.queue.empty() || !client_connected.load() || stopping.load(); + }; + if (timeout_ms < 0) + inbound.cv.wait(lk, ready); + else + inbound.cv.wait_for(lk, std::chrono::milliseconds(timeout_ms), ready); + if (inbound.queue.empty()) + return ""; + std::string msg = std::move(inbound.queue.front()); + inbound.queue.pop_front(); + // if messages are left over (e.g. a burst larger than one poll() drain), + // make sure the sim thread gets asked to drain again + if (!inbound.queue.empty()) + needs_poll = true; + return msg; +} + +bool DebugServer::send(const std::string &msg) { + if (!client_connected.load()) + return false; + { + std::lock_guard lk(outbound.m); + outbound.queue.push_back(msg); + } + return true; +} + +} // anonymous namespace + +// --------------------------------------------------------------------------- +// Lua-visible functions (injected into the plugins.luadebugger module env) +// --------------------------------------------------------------------------- + +static std::string debugger_recv(int32_t timeout_ms) { + return server.recv(timeout_ms); +} +static bool debugger_send(std::string msg) { + return server.send(msg); +} +static bool debugger_connected() { + return server.has_client(); +} +static int32_t debugger_port() { + return server.get_port(); +} + +DFHACK_PLUGIN_LUA_FUNCTIONS { + DFHACK_LUA_FUNCTION(debugger_recv), + DFHACK_LUA_FUNCTION(debugger_send), + DFHACK_LUA_FUNCTION(debugger_connected), + DFHACK_LUA_FUNCTION(debugger_port), + DFHACK_LUA_END +}; + +// --------------------------------------------------------------------------- +// plugin glue +// --------------------------------------------------------------------------- + +static command_result luadebugger_cmd(color_ostream &out, std::vector ¶ms); + +DFhackCExport command_result plugin_init(color_ostream &out, std::vector &commands) { + commands.push_back(PluginCommand( + "luadebugger", + "Remote Lua debugging server (DAP over TCP).", + luadebugger_cmd)); + return CR_OK; +} + +DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) { + if (enable == is_enabled) + return CR_OK; + if (enable) { + if (!server.start(out, server.get_port() > 0 ? server.get_port() : DEFAULT_PORT)) + return CR_FAILURE; + } else { + server.stop(); + } + is_enabled = enable; + return CR_OK; +} + +DFhackCExport command_result plugin_onupdate(color_ostream &out) { + if (server.take_poll_flag()) { + Lua::CallLuaModuleFunction(out, "plugins.luadebugger", "poll"); + } + return CR_OK; +} + +DFhackCExport command_result plugin_shutdown(color_ostream &out) { + server.stop(); + return CR_OK; +} + +static command_result luadebugger_cmd(color_ostream &out, std::vector ¶ms) { + if (params.empty() || params[0] == "status") { + out.print("luadebugger: {}{}\n", + server.is_listening() ? "listening" : "not listening", + server.is_listening() + ? " on 127.0.0.1:" + std::to_string(server.get_port()) : ""); + out.print("luadebugger: client {}\n", + server.has_client() ? "connected" : "not connected"); + return CR_OK; + } + if (params[0] == "stop") { + server.stop(); + is_enabled = false; + return CR_OK; + } + if (params[0] == "start") { + int32_t port = DEFAULT_PORT; + if (params.size() > 1) + port = std::atoi(params[1].c_str()); + if (port <= 0 || port > 65535) + return CR_WRONG_USAGE; + if (server.start(out, port)) { + is_enabled = true; + return CR_OK; + } + return CR_FAILURE; + } + return CR_WRONG_USAGE; +} From 1b49358f77f312eb93acc42520ab4d2580f83ace Mon Sep 17 00:00:00 2001 From: Kelly Kinkade Date: Sat, 10 Oct 2026 16:29:25 -0500 Subject: [PATCH 2/3] fix executable flag --- devtools/luadebugger-test/e2e.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 devtools/luadebugger-test/e2e.py diff --git a/devtools/luadebugger-test/e2e.py b/devtools/luadebugger-test/e2e.py old mode 100644 new mode 100755 From 882be94f84e6888ee137e18aaefc15f60e599293 Mon Sep 17 00:00:00 2001 From: Kelly Kinkade Date: Sat, 10 Oct 2026 16:30:27 -0500 Subject: [PATCH 3/3] fix another executable flag --- devtools/luadebug-cli.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 devtools/luadebug-cli.py diff --git a/devtools/luadebug-cli.py b/devtools/luadebug-cli.py old mode 100644 new mode 100755