aboutsummaryrefslogtreecommitdiffstats
path: root/drive
diff options
context:
space:
mode:
Diffstat (limited to '')
-rwxr-xr-xdrive240
-rw-r--r--driver.py564
2 files changed, 804 insertions, 0 deletions
diff --git a/drive b/drive
new file mode 100755
index 0000000..bea9bd9
--- /dev/null
+++ b/drive
@@ -0,0 +1,240 @@
+#!/usr/bin/env python3
+"""drive: ergonomic client for gdb-driver (Milestone 1).
+
+Auto-resolves the unix socket (single live pane), speaks JSONL, prints terse
+text. Usage:
+ drive ping
+ drive methods
+ drive quit
+ drive raw <method> [k=v ...]
+ drive --sock /path/to.sock ping
+"""
+
+import json
+import os
+import socket
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import pane # noqa: E402
+
+PROTO = 1
+
+
+def resolve_sock(explicit):
+ try:
+ return pane.resolve(explicit)
+ except RuntimeError as e:
+ die(str(e))
+
+
+def die(msg, code=1):
+ sys.stderr.write(f"drive: {msg}\n")
+ sys.exit(code)
+
+
+def fmt_state(r):
+ if not r:
+ return "?"
+ st = r.get("status")
+ if st == "stopped":
+ loc = r.get("loc") or (f"{r['file']}:{r['line']}"
+ if r.get("file") else "?")
+ return (f"{r.get('function')} @ {r['pc']} at {loc} "
+ f"[stopped] thr#{r.get('thread')}")
+ return st
+
+
+def call(sock, method, params=None, timeout=35):
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ s.settimeout(timeout)
+ s.connect(sock)
+ f = s.makefile("rwb")
+ f.write((json.dumps({"id": 1, "method": method,
+ "params": params or {}}) + "\n").encode())
+ f.flush()
+ line = f.readline()
+ s.close()
+ if not line:
+ die("no response (connection closed)")
+ msg = json.loads(line)
+ if "error" in msg and msg["error"]:
+ die(msg["error"].get("message", "unknown error"))
+ return msg.get("result")
+
+
+def parse_kv(args):
+ params = {}
+ for a in args:
+ if "=" not in a:
+ die(f"expected k=v, got {a!r}")
+ k, v = a.split("=", 1)
+ try:
+ params[k] = json.loads(v)
+ except json.JSONDecodeError:
+ params[k] = v
+ return params
+
+
+SEMANTIC = {"run", "start", "cont", "continue", "c", "next", "n", "step", "s",
+ "stepi", "si", "nexti", "ni", "finish", "until", "break", "b",
+ "tbreak", "delete", "watch", "print", "p", "x", "set", "frame",
+ "up", "down"}
+
+_ALIAS = {"c": "cont", "continue": "cont", "n": "next", "s": "step",
+ "si": "stepi", "ni": "nexti", "b": "break", "p": "print"}
+
+
+def build_semantic(cmd, rest):
+ cmd = _ALIAS.get(cmd, cmd)
+ if cmd == "run":
+ return "run", ({"args": " ".join(rest)} if rest else {})
+ if cmd in ("next", "step", "stepi", "nexti", "up", "down"):
+ return cmd, ({"n": int(rest[0])} if rest else {})
+ if cmd in ("start", "finish", "cont"):
+ return cmd, {}
+ if cmd == "until":
+ return "until", ({"loc": rest[0]} if rest else {})
+ if cmd in ("break", "tbreak"):
+ return cmd, {"loc": " ".join(rest)}
+ if cmd == "watch":
+ return "watch", {"expr": " ".join(rest)}
+ if cmd == "delete":
+ return "delete", ({"n": int(rest[0])} if rest else {})
+ if cmd == "print":
+ return "print", {"expr": " ".join(rest)}
+ if cmd == "x":
+ return "x", {"fmt": rest[0], "addr": " ".join(rest[1:])}
+ if cmd == "set":
+ return "set", {"expr": " ".join(rest)}
+ if cmd == "frame":
+ return "frame", {"n": int(rest[0])}
+ die(f"cannot build semantic verb: {cmd}")
+
+
+def main(argv):
+ # --sock may appear anywhere (global option)
+ sock = None
+ args = []
+ it = iter(argv)
+ for a in it:
+ if a == "--sock":
+ sock = next(it, None)
+ else:
+ args.append(a)
+ if not args:
+ die("usage: drive <cmd> [args] (try: drive ping)")
+ cmd, rest = args[0], args[1:]
+
+ # --- pane lifecycle (don't need a resolved socket first) ----------
+ if cmd == "spawn":
+ envs, positional = [], []
+ it = iter(rest)
+ for a in it:
+ if a == "--env":
+ envs.append(next(it, ""))
+ elif a.startswith("--env="):
+ envs.append(a[len("--env="):])
+ else:
+ positional.append(a)
+ if not positional:
+ die("usage: drive spawn [--env K=V ...] <binary> [prog-args...]")
+ try:
+ e = pane.spawn(positional[0], positional[1:], env=envs)
+ except Exception as ex: # noqa: BLE001
+ die(str(ex))
+ print(f"ready pane={e['pane']} sock={e['sock']}\n"
+ f"bin={e['bin']} {' '.join(e['args'])}".rstrip())
+ return
+ if cmd == "list":
+ prune = "--prune" in rest
+ entries = pane.list_all(prune=prune)
+ if not entries:
+ print("(no panes)")
+ for e in entries:
+ flag = "ready" if e["ready"] else ("alive" if e["alive"]
+ else "dead")
+ print(f"{flag:<5} {e['pane']:<6} {os.path.basename(e['bin'])}"
+ f" {e['sock']}")
+ return
+ if cmd == "stop":
+ target = sock or (rest[0] if rest else None)
+ try:
+ target = pane.resolve(target)
+ except RuntimeError as ex:
+ die(str(ex))
+ print("stopped" if pane.stop(target) else "stopped (untracked)")
+ return
+
+ sock = resolve_sock(sock)
+
+ # --- raw keys (client-side tmux; works during a blocking cont) -----
+ if cmd == "keys":
+ if not rest:
+ die("usage: drive keys <tmux-key ...> e.g. drive keys C-c")
+ e = pane.entry(sock)
+ if not e:
+ die("no registry entry for socket")
+ pane.send_keys(e["pane"], rest)
+ print(f"sent {len(rest)} key(s) to gdb pane {e['pane']}")
+ return
+
+ if cmd == "ping":
+ r = call(sock, "ping")
+ print(f"ok proto={r['proto']} ready={r['ready']} pid={r['pid']}")
+ elif cmd == "methods":
+ for m in call(sock, "methods"):
+ print(f"{m['name']:<12} [{m['tier']}] {m['desc']}")
+ elif cmd == "quit":
+ r = call(sock, "quit")
+ print("bye" if r.get("bye") else json.dumps(r))
+ elif cmd in ("state", "where"):
+ print(fmt_state(call(sock, "state")))
+ elif cmd in ("breakpoints", "bp", "info-break"):
+ for b in call(sock, "breakpoints"):
+ loc = b.get("loc") or b.get("expr") or "?"
+ t = "t" if b.get("temporary") else " "
+ print(f"#{b['num']}{t} {loc} hits={b['hits']} "
+ f"{'on' if b['enabled'] else 'off'}")
+ elif cmd == "cmd":
+ if not rest:
+ die("usage: drive cmd <gdb command ...>")
+ r = call(sock, "cmd", {"line": " ".join(rest)}, timeout=130)
+ if r.get("output"):
+ print(r["output"])
+ elif cmd in SEMANTIC:
+ method, params = build_semantic(cmd, rest)
+ r = call(sock, method, params, timeout=130)
+ if r.get("output"):
+ print(r["output"])
+ print(fmt_state(r.get("state")))
+ elif cmd == "regs":
+ for k, v in call(sock, "regs").items():
+ print(f"{k:<8} {v}")
+ elif cmd == "bt":
+ params = {"n": int(rest[0])} if rest else {}
+ for fr in call(sock, "bt", params):
+ loc = fr.get("loc") or (f"{fr['file']}:{fr['line']}"
+ if fr.get("file") else "?")
+ print(f"#{fr['level']:<2} {fr['pc']} {fr.get('func')} at {loc}")
+ elif cmd == "mem":
+ if not rest:
+ die("usage: drive mem <addr-expr> [len]")
+ params = {"addr": rest[0]}
+ if len(rest) > 1:
+ params["len"] = int(rest[1])
+ print(call(sock, "mem", params)["dump"])
+ elif cmd == "screen":
+ color = bool(rest and rest[0] in ("-c", "color", "1"))
+ sys.stdout.write(call(sock, "screen", {"color": color})["text"])
+ elif cmd == "raw":
+ if not rest:
+ die("usage: drive raw <method> [k=v ...]")
+ r = call(sock, rest[0], parse_kv(rest[1:]))
+ print(json.dumps(r, indent=2))
+ else:
+ die(f"unknown command: {cmd}")
+
+
+if __name__ == "__main__":
+ main(sys.argv[1:])
diff --git a/driver.py b/driver.py
new file mode 100644
index 0000000..0492393
--- /dev/null
+++ b/driver.py
@@ -0,0 +1,564 @@
+"""gdb-driver: embedded RPC plugin (Milestone 1 skeleton).
+
+Load inside gdb: gdb -q -x driver.py --args ./prog ...
+
+Architecture (see TUI_DRIVING_BLUEPRINT.md):
+ - A background thread owns a unix-socket JSONL server.
+ - gdb's Python API is MAIN-THREAD-ONLY, so any handler that touches gdb
+ state is marshalled onto gdb's thread via gdb.post_event() and we block
+ on a threading.Event for the result. (Proven: post_event fires while gdb
+ sits idle at the prompt.)
+ - Wire protocol: {"id":N,"method":str,"params":{}}\\n
+ -> {"id":N,"result":...} | {"id":N,"error":{"message":...}}
+
+Milestone 1 surface: ping / methods / quit (lifecycle only).
+"""
+
+import gdb
+import json
+import os
+import socket
+import stat
+import subprocess
+import sys
+import threading
+import traceback
+
+PROTO = 1
+MAIN_THREAD_TIMEOUT = 30.0 # seconds to wait for a main-thread callback
+
+# --------------------------------------------------------------------------
+# socket path resolution
+# --------------------------------------------------------------------------
+
+def _runtime_dir():
+ base = os.environ.get("XDG_RUNTIME_DIR") or f"/tmp/gdb-driver-{os.getuid()}"
+ d = os.path.join(base, "gdb-driver")
+ os.makedirs(d, mode=0o700, exist_ok=True)
+ return d
+
+
+def _sock_path():
+ p = os.environ.get("GDB_DRIVER_SOCK")
+ if p:
+ return p
+ return os.path.join(_runtime_dir(), f"{os.getpid()}.sock")
+
+
+# --------------------------------------------------------------------------
+# main-thread marshalling
+# --------------------------------------------------------------------------
+
+def on_main(fn, timeout=MAIN_THREAD_TIMEOUT):
+ """Run fn() on gdb's main thread; return its value or raise its exception."""
+ box = {}
+ done = threading.Event()
+
+ def cb():
+ try:
+ box["ok"] = fn()
+ except Exception as e: # noqa: BLE001 - propagate to caller
+ box["err"] = e
+ finally:
+ done.set()
+
+ gdb.post_event(cb)
+ if not done.wait(timeout):
+ raise TimeoutError(f"main-thread callback timed out after {timeout}s")
+ if "err" in box:
+ raise box["err"]
+ return box.get("ok")
+
+
+# --------------------------------------------------------------------------
+# adapter (grows per milestone; M1 = lifecycle)
+# --------------------------------------------------------------------------
+
+# gdb prints exactly ONE prompt at startup (before the interactive loop) and
+# does NOT reprint after a programmatic gdb.execute(). So: consume that one
+# pending prompt for the first driven command, then render our own prompt for
+# every command after -- giving a consistent 'gef> cmd' line every time.
+_PROMPT_PENDING = True
+
+
+def _current_prompt():
+ """The live (GEF-colored) prompt string, matching what a human sees."""
+ try:
+ hook = getattr(gdb, "prompt_hook", None)
+ if hook:
+ p = hook(lambda: "")
+ if p:
+ return p
+ except Exception: # noqa: BLE001
+ pass
+ try:
+ return gdb.parameter("prompt") or "(gdb) "
+ except Exception: # noqa: BLE001
+ return "(gdb) "
+
+
+def echo_cmd(cmdline):
+ """Make a programmatically-driven command look hand-typed in the pane."""
+ global _PROMPT_PENDING
+ try:
+ prompt = "" if _PROMPT_PENDING else _current_prompt()
+ _PROMPT_PENDING = False
+ gdb.write(prompt + cmdline + "\n", gdb.STDOUT)
+ gdb.flush()
+ except Exception: # noqa: BLE001 - echoing must never break driving
+ pass
+
+
+def _sal_of(frame):
+ """(file, line) for a frame, or (None, None)."""
+ try:
+ sal = frame.find_sal()
+ if sal and sal.symtab:
+ return sal.symtab.filename, sal.line
+ except gdb.error:
+ pass
+ return None, None
+
+
+def _sym_for(pc):
+ """'func+0xoff' for a pc via the (minimal) symbol table, else None.
+ Works for no-debug binaries where find_sal() has no file/line."""
+ try:
+ out = gdb.execute(f"info symbol {pc:#x}", to_string=True).strip()
+ except gdb.error:
+ return None
+ if not out or out.startswith("No symbol"):
+ return None
+ # 'add + 10 in section .text of /path' -> 'add+0x10'
+ part = out.split(" in section")[0].strip()
+ if " + " in part:
+ name, off = part.split(" + ", 1)
+ try:
+ return f"{name}+{int(off):#x}"
+ except ValueError:
+ return part.replace(" + ", "+")
+ return part
+
+
+def _require_frame():
+ """Return the selected frame or raise a clean 'not ready' error."""
+ try:
+ return gdb.selected_frame()
+ except gdb.error:
+ raise RuntimeError("not ready: no stack frame (inferior not stopped)")
+
+
+class GdbAdapter:
+ def __init__(self):
+ self.ready = False
+ self.pane = os.environ.get("TMUX_PANE")
+
+ # --- method table --------------------------------------------------
+ def methods(self):
+ return [
+ {"name": "ping", "tier": "lifecycle",
+ "desc": "liveness; {ok,proto,ready,pid}"},
+ {"name": "methods", "tier": "lifecycle",
+ "desc": "this verb table (self-documentation)"},
+ {"name": "quit", "tier": "lifecycle",
+ "desc": "reply, then gdb quit"},
+ {"name": "state", "tier": "read",
+ "desc": "{status,pc,function,file,line,thread}"},
+ {"name": "regs", "tier": "read",
+ "desc": "general-purpose registers {name:hex}"},
+ {"name": "bt", "tier": "read",
+ "desc": "backtrace [{level,pc,func,file,line}]; param n"},
+ {"name": "mem", "tier": "read",
+ "desc": "read memory; params addr(expr), len, [fmt]"},
+ {"name": "screen", "tier": "read",
+ "desc": "tmux capture-pane of the gdb pane; param color"},
+ {"name": "breakpoints", "tier": "read",
+ "desc": "list breakpoints [{num,type,loc,enabled,hits}]"},
+ {"name": "run/start/cont/next/step/finish/until",
+ "tier": "semantic", "desc": "run-control; settles at next stop"},
+ {"name": "break/tbreak/delete/watch", "tier": "semantic",
+ "desc": "breakpoint mgmt"},
+ {"name": "print/x/set/frame/up/down", "tier": "semantic",
+ "desc": "eval / examine / assign / frame nav"},
+ {"name": "cmd", "tier": "semantic",
+ "desc": "run ANY gdb/GEF command; param line. escape hatch "
+ "(use run-control verbs for continue/step -- cmd does "
+ "not settle on stop)"},
+ ]
+
+ # --- lifecycle -----------------------------------------------------
+ def ping(self):
+ return {"ok": True, "proto": PROTO, "ready": self.ready,
+ "pid": os.getpid()}
+
+ # --- read tier (all run on gdb's main thread) ----------------------
+ def state(self):
+ inf = gdb.selected_inferior()
+ if inf is None or not inf.is_valid() or inf.pid == 0:
+ return {"status": "no-inferior"}
+ try:
+ thr = gdb.selected_thread()
+ tnum = thr.num if thr else None
+ except gdb.error:
+ tnum = None
+ try:
+ fr = gdb.selected_frame()
+ except gdb.error:
+ return {"status": "running", "thread": tnum}
+ pc = int(fr.pc())
+ f, ln = _sal_of(fr)
+ loc = f"{f}:{ln}" if f else (_sym_for(pc) or hex(pc))
+ return {"status": "stopped", "pc": hex(pc),
+ "function": fr.name(), "file": f, "line": ln, "loc": loc,
+ "thread": tnum}
+
+ def regs(self):
+ fr = _require_frame()
+ arch = fr.architecture()
+ out = {}
+ for rd in arch.registers("general"):
+ try:
+ val = fr.read_register(rd)
+ # mask to the register's OWN width, not a hardcoded 64 bits --
+ # otherwise a 32-bit reg with the high bit set sign-extends to
+ # a bogus 0xffffffffXXXXXXXX value.
+ size = val.type.sizeof or 8
+ out[rd.name] = hex(int(val) & ((1 << (8 * size)) - 1))
+ except (gdb.error, ValueError, gdb.MemoryError):
+ out[rd.name] = str(val)
+ return out
+
+ def bt(self, n=None):
+ _require_frame()
+ frames = []
+ fr = gdb.newest_frame()
+ lvl = 0
+ while fr is not None and (n is None or lvl < n):
+ pc = int(fr.pc())
+ f, ln = _sal_of(fr)
+ loc = f"{f}:{ln}" if f else (_sym_for(pc) or hex(pc))
+ frames.append({"level": lvl, "pc": hex(pc),
+ "func": fr.name(), "file": f, "line": ln,
+ "loc": loc})
+ try:
+ fr = fr.older()
+ except gdb.error:
+ break
+ lvl += 1
+ return frames
+
+ def mem(self, addr, len=64, fmt="xb"):
+ inf = gdb.selected_inferior()
+ if inf is None or inf.pid == 0:
+ raise RuntimeError("not ready: no inferior")
+ base = int(gdb.parse_and_eval(str(addr)))
+ length = int(len)
+ raw = bytes(inf.read_memory(base, length))
+ lines = []
+ for off in range(0, length, 16):
+ chunk = raw[off:off + 16]
+ hexs = " ".join(f"{b:02x}" for b in chunk)
+ asc = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
+ lines.append(f"{base + off:#018x} {hexs:<47} {asc}")
+ return {"addr": hex(base), "len": length,
+ "hex": raw.hex(), "dump": "\n".join(lines)}
+
+ def breakpoints(self):
+ out = []
+ for b in gdb.breakpoints():
+ out.append({"num": b.number, "type": b.type,
+ "loc": b.location, "expr": b.expression,
+ "enabled": b.enabled, "hits": b.hit_count,
+ "temporary": b.temporary})
+ return out
+
+ # --- mutating tier -------------------------------------------------
+ # run-control: execute WITHOUT to_string so GEF's context renders in the
+ # viewer pane; gdb.execute blocks until the next stop (sync all-stop mode)
+ # => settled the moment it returns. Return fresh structured state.
+ def _run(self, cmdline):
+ echo_cmd(cmdline)
+ gdb.execute(cmdline, to_string=False)
+ return {"cmd": cmdline, "state": self.state()}
+
+ # quick command: capture output for the agent AND replay it into the pane
+ # so the livestream shows both the command and its result.
+ def _cmd(self, cmdline):
+ echo_cmd(cmdline)
+ out = gdb.execute(cmdline, to_string=True)
+ if out:
+ gdb.write(out if out.endswith("\n") else out + "\n")
+ gdb.flush()
+ return {"cmd": cmdline, "output": out.rstrip("\n"),
+ "state": self.state()}
+
+ # NB: run-control verbs are NOT here -- they need stop-event settle and
+ # are orchestrated from the socket thread (see run_control_dispatch).
+ def brk(self, loc):
+ return self._cmd(f"break {loc}")
+
+ def tbreak(self, loc):
+ return self._cmd(f"tbreak {loc}")
+
+ def delete(self, n=None):
+ return self._cmd("delete" + (f" {n}" if n is not None else ""))
+
+ def watch(self, expr):
+ return self._cmd(f"watch {expr}")
+
+ def eval(self, expr):
+ return self._cmd(f"print {expr}")
+
+ def examine(self, fmt, addr):
+ return self._cmd(f"x/{fmt} {addr}")
+
+ def setvar(self, expr):
+ return self._cmd(f"set var {expr}")
+
+ def cmd(self, line):
+ return self._cmd(line)
+
+ def frame(self, n):
+ return self._run(f"frame {int(n)}")
+
+ def up(self, n=1):
+ return self._run(f"up {int(n)}")
+
+ def down(self, n=1):
+ return self._run(f"down {int(n)}")
+
+ # --- screen (does NOT touch gdb state; runs on socket thread) -------
+ def screen(self, color=False):
+ if not self.pane:
+ raise RuntimeError("no TMUX_PANE (not spawned inside tmux?)")
+ cmd = ["tmux", "capture-pane", "-p", "-t", self.pane]
+ if color:
+ cmd.insert(2, "-e")
+ text = subprocess.check_output(cmd, text=True)
+ return {"pane": self.pane, "text": text}
+
+
+ADAPTER = GdbAdapter()
+
+# read verbs (touch gdb state; quick)
+_READ = {"state", "regs", "bt", "mem", "breakpoints"}
+
+# mutating verbs: wire name -> adapter attribute
+_MUT = {
+ "break": "brk", "tbreak": "tbreak", "delete": "delete", "watch": "watch",
+ "print": "eval", "x": "examine", "set": "setvar",
+ "frame": "frame", "up": "up", "down": "down", "cmd": "cmd",
+}
+
+# run-control verbs may execute arbitrary inferior code -> generous timeout
+_RUNCONTROL = {"run", "start", "cont", "continue", "next", "step",
+ "nexti", "stepi", "finish", "until"}
+RUNCONTROL_TIMEOUT = 120.0
+
+
+def _rc_cmdline(method, params):
+ if method == "run":
+ a = params.get("args")
+ return "run" + (f" {a}" if a else "")
+ if method == "start":
+ return "start"
+ if method in ("cont", "continue"):
+ return "continue"
+ if method in ("next", "step", "nexti", "stepi"):
+ return f"{method} {int(params.get('n', 1))}"
+ if method == "finish":
+ return "finish"
+ if method == "until":
+ loc = params.get("loc")
+ return "until" + (f" {loc}" if loc else "")
+ raise ValueError(f"not a run-control verb: {method}")
+
+
+def run_control_dispatch(method, params):
+ """Orchestrated on the SOCKET thread. gdb.execute() run-control returns
+ async under post_event, so we settle on the real stop/exited event.
+ The main thread must stay free to *deliver* that event, hence we only
+ marshal the execute() + state read onto it, and wait here."""
+ cmdline = _rc_cmdline(method, params)
+ settled = threading.Event()
+ err = {}
+
+ def on_stop(_evt):
+ settled.set()
+
+ def on_exit(_evt):
+ settled.set()
+
+ def arm_and_go():
+ # arm listeners then fire the command, all on the main thread so the
+ # ordering is race-free even if execute() blocks until stop (sync mode)
+ gdb.events.stop.connect(on_stop)
+ gdb.events.exited.connect(on_exit)
+ echo_cmd(cmdline)
+ try:
+ gdb.execute(cmdline, to_string=False)
+ except gdb.error as e:
+ err["e"] = str(e)
+ settled.set()
+
+ gdb.post_event(arm_and_go)
+ ok = settled.wait(RUNCONTROL_TIMEOUT)
+ # tear down listeners on the main thread
+ def disarm():
+ try:
+ gdb.events.stop.disconnect(on_stop)
+ except Exception: # noqa: BLE001
+ pass
+ try:
+ gdb.events.exited.disconnect(on_exit)
+ except Exception: # noqa: BLE001
+ pass
+ try:
+ on_main(disarm, timeout=10)
+ except Exception: # noqa: BLE001
+ pass
+ if "e" in err:
+ msg = err["e"]
+ # a program that exits/finishes during the command is a normal
+ # outcome, not an error: settle to the (no-inferior) state + note.
+ low = msg.lower()
+ if "exited" in low or "not being run" in low:
+ return {"cmd": cmdline, "state": on_main(ADAPTER.state),
+ "note": msg}
+ raise RuntimeError(msg)
+ if not ok:
+ raise TimeoutError(
+ f"run-control {cmdline!r} did not settle in {RUNCONTROL_TIMEOUT}s")
+ return {"cmd": cmdline, "state": on_main(ADAPTER.state)}
+
+
+def dispatch(method, params):
+ """Return a result dict/value, or raise. Runs on the socket thread."""
+ if method == "ping":
+ return ADAPTER.ping()
+ if method == "methods":
+ return ADAPTER.methods()
+ if method == "quit":
+ # scheduled by the server after the reply is flushed
+ return {"ok": True, "bye": True}
+ if method == "screen":
+ return ADAPTER.screen(**params)
+ if method in _READ:
+ fn = getattr(ADAPTER, method)
+ return on_main(lambda: fn(**params))
+ if method in _RUNCONTROL:
+ return run_control_dispatch(method, params)
+ if method in _MUT:
+ fn = getattr(ADAPTER, _MUT[method])
+ return on_main(lambda: fn(**params))
+ raise ValueError(f"unknown method: {method}")
+
+
+# --------------------------------------------------------------------------
+# server (background thread)
+# --------------------------------------------------------------------------
+
+class Server(threading.Thread):
+ daemon = True
+
+ def __init__(self, path):
+ super().__init__(name="gdb-driver-rpc")
+ self.path = path
+ self._sock = None
+ self._busy = threading.Lock() # single-driver gate
+
+ def run(self):
+ try:
+ if os.path.exists(self.path):
+ os.unlink(self.path)
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ s.bind(self.path)
+ os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR) # 0600
+ s.listen(4)
+ self._sock = s
+ ADAPTER.ready = True
+ # NB: log to a file, not the pane -- writing here would land on
+ # gdb's startup prompt line and corrupt the hand-typed look.
+ try:
+ with open(self.path + ".log", "a") as lf:
+ lf.write(f"listening on {self.path}\n")
+ except OSError:
+ pass
+ while True:
+ conn, _ = s.accept()
+ # handle each connection concurrently so the single-driver
+ # gate can *refuse* a second client instead of queuing it
+ threading.Thread(target=self._serve, args=(conn,),
+ daemon=True).start()
+ except Exception: # noqa: BLE001
+ sys.stderr.write("[gdb-driver] server crashed:\n")
+ traceback.print_exc()
+
+ def _serve(self, conn):
+ try:
+ self._loop(conn)
+ finally:
+ conn.close()
+
+ def _loop(self, conn):
+ f = conn.makefile("rwb")
+ for raw in f:
+ raw = raw.strip()
+ if not raw:
+ continue
+ rid = None
+ try:
+ msg = json.loads(raw)
+ rid = msg.get("id")
+ method = msg["method"]
+ params = msg.get("params") or {}
+ except Exception as e: # noqa: BLE001 - malformed frame
+ f.write(_enc({"id": rid, "error": {"message": str(e)}}))
+ f.flush()
+ continue
+ # single-driver gate is PER-REQUEST: it guards against two clients
+ # dispatching concurrently, but is not held during the idle
+ # readline wait (which would race a fast sequential client's
+ # teardown and spuriously refuse it).
+ if not self._busy.acquire(blocking=False):
+ f.write(_enc({"id": rid, "error": {
+ "message": "busy: another request is in flight"}}))
+ f.flush()
+ continue
+ try:
+ resp = {"id": rid, "result": dispatch(method, params)}
+ except Exception as e: # noqa: BLE001 - errors are data
+ resp = {"id": rid, "error": {"message": str(e),
+ "type": type(e).__name__}}
+ finally:
+ # release BEFORE writing: the write flush lets the client fire
+ # its next request, which (on its own thread) must not see the
+ # gate still held (that race caused spurious 'busy').
+ self._busy.release()
+ f.write(_enc(resp))
+ f.flush()
+ if method == "quit":
+ gdb.post_event(lambda: gdb.execute("quit"))
+ return
+
+
+def _enc(obj):
+ return (json.dumps(obj, separators=(",", ":")) + "\n").encode()
+
+
+# --------------------------------------------------------------------------
+# bootstrap
+# --------------------------------------------------------------------------
+
+def _init():
+ # make gdb non-interactive-safe for programmatic driving
+ for cmd in ("set pagination off", "set confirm off"):
+ try:
+ gdb.execute(cmd, to_string=True)
+ except gdb.error:
+ pass
+ path = _sock_path()
+ Server(path).start()
+
+
+_init()