Debugger Integration¶
gdbforge connects to debug targets through backend.Backend (internal/gdbforge/backend), which wraps adapters that implement core.Session (Debugger + lifetime + PTY mux). Supported today: GDB MI2 (gdb.GDBClient) and Delve (dlv.Client) via -g gdb|dlv. One PTY for the debugger, plus a separate inferior PTY for the debugged program’s stdin/stdout (ptyx.TTY). The session is owned by DebuggerApp (through Backend) and shared by the console view, in-app :AI, and MCP; program I/O is controlled by the app and painted in the IO console (:b io).
Companion docs: PTY_ARCHITECTURE.md (master/slave dual PTY, Delve TCP) · ARCHITECTURE.md · UI_ARCHITECTURE.md · EXEC_SHELL.md · PLUGINS.md
Table of contents¶
- Integration overview
- Debugger interface
- PTY mux
- Inferior I/O (dual PTY)
- Breakpoints and source sync
- Breakpoint persistence
- GDB integration
- GdbMcpService and in-app AI
- MI2 parsing pipeline
- GDB console bridge (MVC)
- Delve backend (peer of GDB)
- Delve inferior I/O (dual PTY)
- Future OpenOCD integration
- Future JTAG integration
- Kernel debugging
- Design constraints
Integration overview¶
Application data flows Service → Controller → Model → Widget (MVC).
flowchart TB
subgraph UI["UI · views"]
GDBW["GDBWidget · paint + OnSubmit"]
Cons["ConsolePane"]
IOW["OutputWidget · paint + OnSubmit"]
end
subgraph App["Application · cmd/gdbforge"]
Ctrl["Controllers · gdb_console / io_console / breakpoints"]
Models["models · BreakpointList ThreadList CallStack"]
AI[":AI OnAI"]
MCP["GdbMcpService"]
end
subgraph Domain["Domain · core"]
SessIF["Session / Debugger / PTYWriter"]
PtyMsg["PtyOutputMsg"]
UIMsg["GdbOutputMsg · ExecOutputMsg · InferiorOutputMsg"]
end
subgraph PTYLayer["PTY · ptyx"]
Pty["ptyx.Client · GDB MI"]
Inf["ptyx.TTY · inferior stdio"]
end
subgraph BackendPkg["backend.Backend"]
Client["GDBClient or dlv.Client · owned via Backend"]
MI["InputState · MiUpdate / Delve parse"]
end
subgraph External["External"]
GDB["GDB --interpreter=mi2 · or dlv"]
Prog["Debugged program"]
LLM["Claude / OpenAI API"]
end
GDBW --> Cons
Ctrl -->|"owns Backend"| Client
Ctrl -->|"SetItems / Paint"| GDBW
Ctrl -->|"Paint"| IOW
Ctrl --> Models
Client --> Pty
Client -->|"inferior tty"| Inf
Ctrl -->|"Send / Subscribe"| Inf
Inf <--> Prog
MCP -->|"Session only"| SessIF
AI --> MCP
AI --> LLM
SessIF --> Client
Pty -->|"Subscribe fan-out"| PtyMsg
PtyMsg -->|"UI bridge"| UIMsg
UIMsg --> Ctrl
Pty --> GDB
Ctrl --> MI
Dependency rules:
internal/gdb,internal/dlv, andinternal/ptyxmust not importinternal/termuiDebuggerAppownsbackend.Backend(concrete GDB or Delve client); views never holdSession- External APIs use
app.GDB() core.Session(works for-g dlvtoo) - Prefer
Backendmethods over newisDLV()branches - Never
Close()the session from MCP/AI — the app owns lifetime
Debugger interface¶
type Debugger interface {
Send(cmd string) error
SendRaw(raw string) error
}
type PTYWriter interface {
Send(cmd string) error
SendRaw(raw string) error
}
// Session: lifetime + exclusive write + shared read.
type Session interface {
Debugger
Close()
Subscribe() (ch <-chan PtyOutputMsg, cancel func())
WithWrite(ctx context.Context, fn func(w PTYWriter) error) error
}
Minimal by design — sends commands to the backend. Responses arrive asynchronously via Subscribe / UI events, not as return values from Send.
Design rationale: MI2 and GDB CLI are streaming protocols. Blocking Send → response would deadlock when async *stopped records arrive mid-command. GdbMcpService.GdbCommand adds a windowed capture on a private subscription for tool results (best-effort until tokenized MI waiters).
Ownership: DebuggerApp creates and owns backend.Backend in initBuiltins (backend.NewGDB / NewDLV → Close). gdbMcp is a peer constructed from app.GDB(). Views (GDBWidget, OutputWidget, …) receive paint APIs and host intents / SetOn* only. Domain models live on controllers (breakCtl.list, …).
Future extensions (separate interfaces):
| Interface | Purpose |
|---|---|
BreakpointManager |
Add/remove/list breakpoints |
RegisterReader |
Read register sets |
MemoryReader |
Read/write memory |
These will emit core.Event updates rather than synchronous returns.
PTY mux¶
gdbforge uses two PTY roles for a debug session:
| PTY | Type | Role |
|---|---|---|
| GDB / MI | ptyx.Client |
GDB process (--interpreter=mi2); commands and MI records |
| Inferior / program | ptyx.TTY |
Debugged program stdin/stdout; wired with -inferior-tty-set |
GDB PTY (ptyx.Client)¶
One ptmx for GDB. Two rules:
| Direction | Rule |
|---|---|
| Write | Exclusive — WithWrite / Send / SendRaw share one mutex; only one of UI / MCP / App holds it at a time |
| Read | Shared — every Subscribe() channel receives the same chunks |
Writers (PTYOwner on AppState):
| Owner | Who | Console paint |
|---|---|---|
ui |
GDB / Exec / IO console submit (app controller) | Yes (always) |
mcp |
:AI / GdbCommand |
Painted by default; :set nogdblistenprint to silence |
app |
Silent MI / App writes: -break-list, file list, breakpoint toggle/delete, stop-driven thread/stack Query |
Painted by default; :set nogdblistenprint to silence |
flowchart LR
UI["Controller PTYOwnerUI"]
MCP["GdbMcpService PTYOwnerMCP"]
App["App silent Query PTYOwnerApp"]
Lock["write lock"]
UI --> Lock
MCP --> Lock
App --> Lock
Lock --> PTMX["GDB ptmx"]
PTMX --> Fan["broadcast"]
Fan --> ChUI["UI Subscribe"]
Fan --> ChMCP["MCP Subscribe"]
GDB and exec (:!) both use *ptyx.Client owned by the app. UI bridges convert PtyOutputMsg → GdbOutputMsg / ExecOutputMsg / InferiorOutputMsg for interrupt routing.
Inferior I/O (dual PTY)¶
Architecture overview (master/slave, Delve TCP, external terminal diagrams): PTY_ARCHITECTURE.md.
There is no GDB MI command that writes program stdin. gdbforge allocates a bare PTY (ptyx.OpenTTY), keeps the master in-process, and tells GDB to attach the inferior to the slave:
gdbforge
├── PTY #1 master ←→ gdb (MI / CLI)
└── PTY #2 master ←→ program stdin/stdout ← IO console (:b io)
│
└── slave path → GDB: -inferior-tty-set /dev/pts/N
| Side | Who holds it | Purpose |
|---|---|---|
| Master (PTY #2) | gdbforge (ptyx.TTY) |
Read program stdout; write program stdin |
| Slave (PTY #2) | inferior (via GDB) | Program’s terminal |
Startup (gdb.NewGDBClient):
- Start GDB on PTY #1 (
ptyx.New) with--interpreter=mi2and-iex set pagination off(unless the user already disabled pagination) - Wait up to 90s for the first
(gdb)prompt so-x/-exscripts (target remote,load, …) can finish before app MI - Capture startup PTY bytes for replay into the GDB pane
ptyx.OpenTTY()→ PTY #2- Send
-inferior-tty-set <slaveName>only after the first prompt
Pass GDB options after --: gdbforge -- -nx -x script.gdb elf. gdb.HasInitScript detects -x/-ex so the app skips default break main when an init script is present.
External terminal stdio (TUI targets)¶
The IO pane is a line console, not a full VT emulator. For TUI inferiors (gdbforge itself, htop, games, …) or programs that flood stdout, route stdio to a real terminal instead.
:b io vs :set inferior-tty
:b io (internal) |
:set inferior-tty (external) |
|
|---|---|---|
| Best for | Normal debug prints, short interactive prompts | TUI/curses, high-rate printf, anything that needs a real VT |
| Smoothness under flood | Interruptible (Ctrl-C / backpressure), paint less smooth than a dedicated emulator — known GUI limit | Emulator owns display; typically smooth like “native GDB + xterm” |
| Who holds PTY master | gdbforge (ptyx.TTY) → UI event loop |
GDBFORGE_TERMINAL (mate-terminal, kitty, …) |
Advantages of :set inferior-tty: smooth high-volume output, real VT features, program I/O does not compete with gdbforge’s debugger panes for redraw, live attach on GDB (-inferior-tty-set without restart).
| Mode | How | IO pane |
|---|---|---|
| internal (default) | Internal PTY + wire :b io |
Program stdin/stdout |
| external | :set inferior-tty (opens terminal), GDBFORGE_INFERIOR_TTY, Lua open_external_tty / set_inferior_tty |
Note only — no subscribe |
| GDB | Delve (-g dlv) |
|
|---|---|---|
| Attach stdio | -inferior-tty-set live (no restart) |
dlv exec --tty only at spawn; :set inferior-tty restarts Delve |
| Switch back | :set inferior-tty internal |
same (restart with internal PTY) |
| Best for TUI | :set inferior-tty / :lua terminal_debug / gdbserver_tui |
:lua dlv_ext_port [port] [args…] (alias dlv_port) — headless dlv in another window + dlv_connect; stdio never leaves that window |
| Terminal picker | GDBFORGE_TERMINAL (kitty, xterm, mate-terminal, gnome-terminal, …) |
same |
Pattern B — local GDB/dlv, external pts
- Lua
gdbforge.open_external_tty()opens kitty/xterm/… (GDBFORGE_TERMINAL) that runstty > file; sleep infinity. gdbforge.set_inferior_tty(pts)→ GDB-inferior-tty-set(live). Delve restartsdlv exec --tty …with the new path (same program args).- Examples:
lua/external_tty,lua/terminal_debug.
Pattern A — gdbserver / headless dlv in the other window
- GDB:
gdbforge.spawn_terminal("gdbserver", ":2345", "./my_tui")thentarget remote. - Delve:
:lua dlv_ext_port/dlv_port(orspawn_dlv_headless+dlv_connect) — inferior inherits that terminal’s stdio. - Examples and how to use each script:
lua/README.md; code:lua/gdbserver_tui,lua/dlv_ext_port.
Do not hold an internal PTY master and point -inferior-tty-set / --tty at an external slave at the same time. Closing the external window does not auto-rewire IO — use :set inferior-tty internal.
IO console (OutputWidget, pane name IO, :b io, alias :b output):
| Action | Path |
|---|---|
| Program prints | App Subscribe → InferiorOutputMsg → AppendInferior |
| User types + Enter | View OnSubmit → app TTY.Send(line) |
| Ctrl-C / Ctrl-D | View intents → app SendRaw to inferior (not GDB) |
| Ctrl-Z | Global / IO: SuspendInferior — prefer ^Z (\x1a) on the inferior PTY; else kill(SIGTSTP) |
| Pane resize | View SetSizeFunc → app TTY.SetSize |
The widget does not hold *ptyx.TTY; wiring lives in cmd/gdbforge/io_console.go.
Separation rules:
- GDB console keys go only to the GDB PTY — they never become program stdin
- IO console keys go only to the inferior PTY — they are not GDB commands
- When a dedicated inferior TTY is active, program stdout stays on PTY #2. Raw non-MI text on the GDB PTY is still painted in the GDB console (GDB
make/shellchild output, load messages) — it is not redirected to the IO pane +MI status records (e.g.+downloadduringload) are filtered from display; human-readable load lines remain
flowchart LR
subgraph gdbforge
GDBPane["GDB pane"]
IOPane["IO pane"]
M1["PTY#1 master"]
M2["PTY#2 master"]
end
GDBPane --> M1
IOPane --> M2
M1 --> GDB["gdb"]
M2 --> PROG["program"]
GDB -.->|"-inferior-tty-set slave"| PROG
Session model on AppState: SourceFiles (refreshed from -file-list-exec-source-files on stop / :edit), StopFile / StopLine (StopLocation — real PC from *stopped, drives ━━▶), CurrentFile / CurrentLine (browse / frame selection — blue cursor), theme colors (MarkColor, MarkDimColor, BreakColor, BreakDisabledColor, BreakCondColor, PCColor, StackBreakColor, CodeSelColor, MutedColor; see :set), EscToCode (Esc focuses CodeWidget; :set esctocode / :set noesctocode; default on), BreakMain (insert break main on GDB session start; skipped when restoring ./.gdbforge/breakpoints.yaml or when HasInitScript; :set breakmain / :set nobreakmain; default on), GdbListenPrint (paint App/MCP replies in GDB console; :set gdblistenprint / :set nogdblistenprint; default on), ContinueAfterClear. Each open source file has its own CodeWidget (:edit name); :b filename switches among open file buffers and builtins. :edit opens a FileListWidget of project sources. When source is missing and the backend supports assembly, asmCtl.autoAsm swaps the location leaf to Assembly and reclaims Code when a readable frame returns. Breakpoint gutters sync via =breakpoint-* / Space hooks → coalesced -break-list into BreakGutter maps (line and addr).
Breakpoints and source sync¶
Breakpoints are coordinated across the debugger console, CodeWidget, AssemblyWidget, BreakpointWidget, and MCP. GDB/MCP notifies publish BreakpointsChangedMsg (cmd/gdbforge/events.go) on platform.EventBus; breakCtl refreshes from that event (coalesced; no sleep/timer debounce).
Breakpoints while the inferior is running¶
While the program is in continue / ^running, sync GDB does not process a queued break until the target stops. Space (and BreakpointWidget e/d) therefore:
- Send Ctrl-C (
\x03) to interrupt - Send
break/clear/-break-delete - On insert (
break/tbreak/-break-insert): sendcontinueso execution can hit the new breakpoint - On remove (
clear/-break-delete): sendcontinueonly if:set continueafterclear(default off — stay stopped)
Other App PTY commands (-stack-select-frame, -thread-select, …) also interrupt when running, but do not auto-continue — a surprise resume was resuming the inferior after call-stack / thread clicks.
AppState.InferiorRunning tracks ^running → *stopped for this path. AppState.ContinueAfterClear is toggled with :set continueafterclear / :set nocontinueafterclear.
Builtins and keys¶
| Surface | How to open | Keys |
|---|---|---|
| BreakpointWidget | :b breakpoint (default pane) |
j/k or Up/Down / Enter / click-release — select and browse Code at that BP (blue cursor; ━━▶ stays on StopLocation); Enter focuses Code; row at stop PC uses stackbreakcolor (stays green when selected); e — toggle enable/disable; d — delete; rows use AppState break colors (red/yellow/orange for conditional) |
| OutputWidget (IO) | :b io (alias :b output; default pane, top-right) |
Program stdin/stdout via inferior PTY; type + Enter → stdin; PgUp/PgDn scroll; <C-l> clear; Ctrl-C/D → inferior; Ctrl-Z → SIGTSTP; ANSI |
| ThreadWidget | :b threads (default pane) |
j/k or Up/Down / Enter / click-release — bold selection and MI -thread-select <id> + refresh stack + show code; Enter focuses Code; green when current thread matches StopLocation; filled on stop |
| CallStackWidget | :b callstack (default pane) |
j/k or Up/Down / Enter / click-release — bold selection and MI -stack-select-frame <level> + show code; Enter focuses Code; green on frame 0 only when it matches StopLocation; shared libs / missing sources → centered not available + path (may autoAsm) |
| FileListWidget | :edit |
j/k or Up/Down — mark color from :set markcolor; Enter opens; mouse: first click selects, second click on marked row opens CodeWidget |
| CodeWidget | :edit name / stop / :b file |
Up/Down or j/k — blue browse cursor (codeselcolor); ━━▶ = StopLocation (pccolor); Space — insert/remove break; e — enable/disable (yellow gutter when disabled; orange when conditional). Missing file or .so path: centered not available (Assembly may auto-swap). Global n/s/c (normal; insert when Code focused) → -exec-next / -exec-step / -exec-continue. |
| AssemblyWidget | :b asm / :layout … asm / autoAsm |
Disassembly; Space toggles addr breakpoint; synced from frame/stop like Code |
Empty Breakpoint list shows no breakpoints. Otherwise each row is breakpoint info only (no column header), e.g. 1 y hello.c:23. Disabled rows are gray (n).
Ownership of the list¶
models.BreakpointList on breakCtl (a.breaks.list) is the shared model (GUI + MCP):
| Action | Model | GDB | Code/Asm gutters |
|---|---|---|---|
| e while enabled | Row stays, Enabled=false |
-break-delete |
Cleared for that line/addr |
| e while disabled | Row stays, Enabled=true |
break file:line |
Restored |
| d | Row removed | Deleted if it was in GDB | Cleared |
External b / Space / MCP |
MergeFromGDB on the model |
As GDB reports | Enabled rows; conditional → orange (BreakCondColor) |
Disabled rows are kept across -break-list refresh (they are intentionally absent from GDB). Controllers call syncBreakpointViews() → BreakpointWidget.SetItems + BreakGutter paint on Code/Asm.
Host / callback chain¶
Wired in cmd/gdbforge/builtins.go / breakpoints.go (breakCtl):
| Hook | Handler |
|---|---|
GdbMcpService.OnBreakpointsChanged |
onBreakpointsChanged → Publish(BreakpointsChangedMsg) |
EventBus Subscribe |
coalesced -break-list via breakCtl |
BreakpointHost (toggle / delete / activate) |
breakCtl → model + SendCmd |
CodeWidget.SetOnBreakToggle |
breakCtl → model + SendCmd |
AssemblyHost (asm break toggle) |
breakCtl / asmCtl → model + SendCmd |
flowchart TD
MI["MI =breakpoint-created/deleted"] --> OBC["onBreakpointsChanged"]
MCP["MCP GdbCommand"] --> MCPCB["OnBreakpointsChanged"]
MCPCB --> OBC
OBC --> BUS["Publish BreakpointsChangedMsg"]
BUS --> SUB["Subscribe → coalesce -break-list"]
SUB --> MERGE["breaks.list.MergeFromGDB"]
MERGE --> SYNC["syncBreakpointViews"]
BP["BreakpointHost e/d"] --> CTRL["breakCtl"]
CODE["CodeWidget OnBreakToggle"] --> CTRL
ASM["AssemblyHost ToggleAsmBreak"] --> CTRL
CTRL --> MERGE
SYNC --> CW["CodeWidget.SetBreakInfos"]
SYNC --> AW["AssemblyWidget.SetBreakInfos"]
SYNC --> BPW["BreakpointWidget.SetItems"]
breakCtl Subscribes to BreakpointsChangedMsg in initBuiltins and runs a coalesced -break-list:
| State | Behavior |
|---|---|
| Idle | First publish starts a background runBreakpointRefresh |
| Refresh in flight | Further publishes set bpRefreshPending only |
| After refresh | If pending → one trailing -break-list; else clear running flag |
No time.Sleep debounce — coalesce is event-driven (pending flag). Redraw uses PostEvent(breakpointsUIMsg) on the UI thread.
Only =breakpoint-created / =breakpoint-deleted trigger a -break-list refresh (not =breakpoint-modified hit-count updates during n/continue).
CodeWidget details¶
- Syntax highlight via Chroma (
terminal256); line numbers; breakpoint lines use a red number background. - PC line:
━━▶+pccolorrow background (StopLocation from*stoppedonly — not moved by BP list clicks or j/k). - Browse cursor (when focused):
codeselcolor(default dark blue); independent of ━━▶. - Breakpoint list activate →
ShowSelection/ browse only (blue cursor); does not rewrite StopLocation. - Space uses basename locations (
break hello.c:23/clear hello.c:23) underPTYOwnerApp. - Horizontal scroll in ANSI mode uses visible columns (not raw byte offsets) so panes stay readable after
:vs.
PTY exclusivity remains ptyx.WithWrite; PTYOwner + sticky silence tell GDBWidget when to suppress console paint for App/MCP listener traffic (:set nogdblistenprint). Default is to paint those replies (gdblistenprint on). UI console submit always paints.
Threads and call stack on stop¶
On each non-exit *stopped (breakpoint, step, Ctrl-C / signal-received, etc.), DebuggerApp refreshes shared models then paints the Threads / Call Stack views.
When the refresh runs (avoid racing the stop reply):
onGdbStoppedarmspendingDebugInfo- Trigger on MI PromptReady (
(gdb)), or a ~120ms fallback if the prompt is missed - Coalesced worker (
scheduleDebugInfoRefresh/ pending flag) runs the queries
What the worker does:
Query("-thread-info")— apply only if the capture containsthreads=(incomplete/stale captures are retried a few times, not applied)Query("-stack-list-frames")— apply only if the capture containsstack=- Update
models.ThreadList/models.CallStackoff the UI thread PostEvent(debugInfoUIMsg)→ on the UI thread:SetItemson the widgets, align Code from the stack,RequestFrame
Independent of BreakpointsChangedMsg (BP marks stay on breakpoint-change events). Clicking a thread still re-queries (onThreadActivate) for an explicit thread <id> switch — stop refresh should not require a click.
Breakpoint persistence¶
Breakpoints are saved and restored via YAML under the process cwd (usually the build directory):
| Path | Role |
|---|---|
./.gdbforge/breakpoints.yaml |
Persist file (internal/gdbforge/persist) |
Save — on app quit (Close → saveBreakpointsOnQuit): writes the last BreakpointList snapshot (enabled + disabled rows with file / line / enabled).
Restore — on GDB session start (builtins.go):
persist.LoadBreakpoints(".")(missing file → no-op)- If saved BPs exist (or
HasInitScript), skip defaultbreak main restoreSavedBreakpoints: merge GDB’s current list (e.g. from-x),breakany missing locations, re-apply disabled flags, refresh the BP pane + Code gutters
Example file:
Run gdbforge from the project/build dir so the YAML matches the sources you debug.
GDB integration¶
Client startup¶
gdb.NewGDBClient() wraps ptyx.Client (gdb_client.go / ptyx/client.go):
- Builds
gdb --interpreter=mi2argv; injects-iex set pagination offunless already set. - Appends user
gdbArgsafter--(e.g.-nx -x script.gdb elf). ptyx.New— PTY, raw mode, reader fan-out.- Waits for the first
(gdb)(up to 90s); captures startup output for the GDB pane. - Opens inferior PTY and sends
-inferior-tty-setonly after that prompt.
argv := []string{"gdb", "--interpreter=mi2", "-iex", "set pagination off", "hello"}
pty, err := ptyx.New(argv, ptyx.Options{})
Current limitation: reply correlation (tokenized MI → waiter) is not built yet — GdbCommand uses idle/max window capture on the raw stream.
Send paths¶
| Method | Use |
|---|---|
Send(cmd) |
Append \n, send CLI/MI command (takes write lock) |
SendRaw(raw) |
Send raw bytes (SIGINT, …) under write lock |
SuspendInferior() |
SIGTSTP like terminal Ctrl-Z (^Z on inferior PTY or kill) |
WithWrite(ctx, fn) |
Hold write lock for multi-step MCP capture |
CLIExecToMI(cmd) |
Map CLI next/step/continue/… → -exec-* so console/n/s/c do not dump source into the GDB pane; Code follows *stopped |
Output path¶
ch, cancel := client.Subscribe()
defer cancel()
for msg := range ch {
// UI posts EventInterrupt(GdbOutputMsg); MCP/AI parse the same stream
}
Close() (or cancel) closes subscription channels → GDBWidget receives "gdb-exit" when its subscription ends.
GdbMcpService and in-app AI¶
Same process as the TUI (required for one debug context).
| Piece | Role |
|---|---|
internal/mcp/gdb_service.go |
Tool core: GdbCommand under WithWrite + Subscribe capture |
internal/mcp/agent.go |
LLM tool loop (Anthropic or OpenAI) |
:AI / :ai |
Rest-args colon command → OnAI → Ask in a goroutine |
:AI why is there a memory leak
→ GdbMcpService.Ask
→ LLM may call gdb_command("info leak") / "b main" / …
→ same Session as the GDB pane (exclusive write, shared read)
→ answer appended to GDB console
Env: ANTHROPIC_API_KEY or OPENAI_API_KEY; optional GDBFORGE_AI_MODEL.
Do not use stdio MCP inside the TUI process (keyboard owns stdin). Optional TCP MCP for external hosts can expose the same tools later; in-app :AI is the primary door.
MI2 parsing pipeline¶
GDB MI2 emits several record types:
| Prefix | Type | Example |
|---|---|---|
^ |
Result | ^done, ^error, ^running |
~ |
Console stream | ~"Hello\n" |
@ |
Target stream | @"program output\n" |
& |
Log stream | &"warning\n" |
* |
Exec async | *stopped,reason="breakpoint-hit" |
= |
Notify async | =breakpoint-created,... |
(gdb) |
Prompt | Ready for input |
GdbInputState (streaming)¶
PushRaw is called on the UI thread for each coalesced GdbOutputMsg chunk:
- Append bytes to
lineBuf; split on\n. - For each complete line, classify the MI record and accumulate an
MiUpdate. - Return immediately — no timer, no wait for a “full burst”.
| Record | Effect on MiUpdate |
|---|---|
~ / @ |
Decode stream payload → DisplayLines (ANSI ESC kept; do not strip 0x1b) |
| Non-MI raw line | Paint into DisplayLines (GDB make / shell child stdout on the GDB PTY) |
+… status |
Filtered (e.g. +download noise during load); keep readable load text |
& |
Ignored for display (CLI echo; UI already echoes submits) |
^error |
Set Error state; surface msg= |
^done / ^running / … |
Update GdbState |
*stopped |
Fill Stopped (reason, thread-id) |
(gdb) |
PromptReady |
Incomplete lines remain in lineBuf across chunks.
Note: GDB itself often buffers make / shell stdout until the child exits — live line-by-line build output may require :! make in an exec pane instead.
Design rationale: GDB often splits writes mid-line; newline buffering is required. Per-line dispatch is enough for correctness and feels faster than a 100ms debounce.
MiMsg (batch helper)¶
MiMsg / NewMiMsg / CreateBufferForLine remain as a batch-oriented helper for offline or test parsing. The live GDB pane uses streaming MiUpdate from GdbInputState.
type MiUpdate struct {
DisplayLines []string
PromptReady bool
State GdbState
ErrorMsg string
Stopped *MiStopMsg
}
MI string decoding¶
DecodeMIString handles GDB's C-style escapes including octal UTF-8 sequences (\342\235\214). ExpandTabs expands tab stops for aligned output.
Implementation: mi.go, mi_msg.go, mi_state.go.
GDB console bridge (MVC)¶
GDBWidget is a display-only console view. The app owns the session and MI policy (cmd/gdbforge/gdb_console.go):
InputLine → ConsolePane → GDBWidget (view)
(edit/hist) (scrollback) ↑ paint / OnSubmit
DebuggerApp controller
→ GDBClient → ptyx
initBuiltins creates gdb.NewGDBClient; startGdbConsoleBridge coalesces Subscribe → EventInterrupt(GdbOutputMsg) (~16ms / 64KiB). Presentation is a native GDB session ((gdb) b main then raw console output, including make/shell text and ANSI when present). GDB console ANSI is enabled (SetANSI(true)).
Job control (Ctrl-Z): onGdbConsoleSuspend — if InferiorRunning, SuspendInferior; otherwise TermApp.Suspend uses tcell Screen.Suspend/Resume (not Fini/Init, which break on the second suspend with “already engaged”). Bound globally via withGlobalKeys in every mode — see INPUT.md.
sequenceDiagram
participant User
participant GDBW as GDBWidget
participant Cons as ConsolePane
participant Ctrl as DebuggerApp
participant State as GdbInputState
participant Client as GDBClient
participant GDB as GDB
User->>Cons: type / Enter
Cons->>Ctrl: OnSubmit(cmd)
Ctrl->>GDBW: EchoSubmit / ClearInput
Ctrl->>Client: Send(cmd)
Client->>GDB: PTY write
GDB-->>Client: MI output chunk
Client-->>Ctrl: EventInterrupt GdbOutputMsg
Ctrl->>State: PushRaw(chunk)
State-->>Ctrl: MiUpdate
Ctrl->>GDBW: PaintMiDisplay
| Component | File | Role |
|---|---|---|
InputLine |
termui/input_line.go |
Text, cursor, readline history/editing |
ConsolePane |
termui/console_pane.go |
Scrollback Viewport, walking prompt, EchoSubmit |
GDBWidget |
widgets/gdb_widget.go |
View — SetOnSubmit / paint APIs only |
| Controller | cmd/gdbforge/gdb_console.go |
Owns client bridge, MI, quit gate, Send |
GdbInputState |
gdb/mi_state.go |
Stream PushRaw → MiUpdate |
ptyx.Client |
ptyx/client.go |
Shared PTY mux |
Console layout (walking prompt)¶
Terminal-style after Ctrl+L (clear / screen reset) — owned by termui.ConsolePane:
- Empty scrollback →
(gdb)+ caret at top-left. - Each new output line → prompt moves one row down.
- While free rows remain → leave blank space below; do not jump the prompt to the bottom.
- When the pane is full → pin prompt to the last row and scroll the viewport (
followTail).
While the user scrolls history (followTail off), the prompt stays on the bottom row.
Echo is prompt+cmd only (native GDB session, not chat labels).
Draw highlights:
- Lines starting with
>>>— teal bold (future: stop reason). - Echoed / prompt text with
(gdb)— yellow.
Delve backend (peer of GDB)¶
Delve plugs into the same architecture as GDB — no new control plane:
| Piece | Role |
|---|---|
| CLI | gdbforge -g dlv [-d dlv] prog [args…] |
backend.DLVBackend |
Policy wrapper; DebuggerApp.backend |
internal/dlv.Client |
Implements core.Session over ptyx (dlv exec -- prog…) |
dlv.InputState |
Peer of GdbInputState — parse (dlv) prompt, [Y/n]? confirms, > file:line stops, BP lines |
| Console | Same GDBWidget + consoleCtl; prompt token (dlv) |
| Pane refresh | Via Backend + local branches in stopped.go / breakpoints.go: breakpoints, stack, goroutines |
flowchart LR
CLI["gdbforge -g gdb|dlv"] --> App["DebuggerApp"]
App --> BE["backend.Backend"]
BE --> GDB["gdb.GDBClient"]
BE --> DLV["dlv.Client"]
GDB --> Sess["core.Session"]
DLV --> Sess
Sess --> PTY["ptyx"]
MVP limits: Delve CLI output parsing is less structured than MI (known debt). :edit source-file list from -file-list-exec-source-files is skipped under Delve. MCP/:AI tools remain GDB-oriented; the shared Query helper still drives pane refreshes with prompt token (dlv).
Interactive yes/no: After the inferior exits, Delve may ask Set a suspended breakpoint … [Y/n]?. gdbforge detects that prompt (including when it arrives without a trailing newline), paints it as a live host (same idea as GDB quit confirm), and answers with the next console submit. While confirming, breakpoint Query("breakpoints") is deferred so the query line cannot be consumed as y/n. Ctrl-C at a yes/no prompt sends n (cancel); SIGINT/^C is only used when the inferior is actually running.
Tab completion: GDB console Tab uses MI -complete. Under Delve, Tab completes command names from a static list, and for break/b/trace/… locspecs it runs funcs ^<prefix> (e.g. b main. → main.main). Symbol completion is prefix-based via Delve’s funcs regex — not a full MI-style completer. File:line locspecs are not completed yet.
Examples:
Default entry breakpoint under Delve is break main.main (not break main).
Delve inferior I/O (dual PTY)¶
Dual-PTY like GDB, but --tty is spawn-only:
| Piece | Role |
|---|---|
dlv.Client |
Opens a ptyx.TTY (or uses InferiorTTY) |
dlv exec --tty <slave> |
Program stdin/stdout go to :b io or an external terminal — not the Delve console |
:set inferior-tty / Lua set_inferior_tty |
Restarts Delve with a new --tty (same program args) |
For Go TUI programs, prefer :lua dlv_port (headless Delve in another window + dlv connect) so stdio never leaves that window and you avoid a mid-session restart. See PTY_ARCHITECTURE.md and External terminal (stdio / TUI targets).
Future OpenOCD integration¶
OpenOCD exposes a Telnet command port and TCL scripting for embedded targets.
Planned adapter: internal/openocd (not yet created).
| Aspect | Plan |
|---|---|
| Transport | TCP telnet or pipe to openocd process |
| Protocol | TCL commands + event text (not MI2) |
| Interface | Same core.Session for send/subscribe; adapter translates |
| UI impact | None — new backend package only |
flowchart LR
UI["termui"]
Core["core.Session"]
GDB["gdb.GDBClient"]
DLV["dlv.Client"]
OOCD["openocd.Client · planned"]
UI --> Core
Core --> GDB
Core --> DLV
Core --> OOCD
Design decision: OpenOCD is a separate backend, not a GDB wrapper. Some workflows may use both (OpenOCD for flash/reset, GDB for symbols) — session orchestration belongs in app/core.
Future JTAG integration¶
JTAG debugging may arrive through:
- OpenOCD as transport (preferred — reuse OpenOCD adapter).
- Direct JTAG library (e.g., libftdi) for specialized hardware bring-up.
gdbforge UI would expose:
- Chain scan / device selection pane.
- TAP state indicator.
- DR/IR scan views.
These are feature panes (PLUGINS.md), not core UI changes.
Kernel debugging¶
Current (Lua): kgdb bring-up without Go mux — :lua kgdb_uart (UART + external kdmx) and :lua kgdb_net (Ethernet / target remote). See KERNEL_KGDB.md. Same design rule as remotegdb: GDB owns RSP; gdbforge is MI UI + orchestration.
Kernel debugging still introduces longer-term UI requirements:
| Requirement | UI response |
|---|---|
| Multiple address spaces | Memory pane with context selector |
| Crash dumps / vmcore | Read-only source + backtrace panes |
| Remote targets | Backend connection manager (not UI) |
| Module / symbol load | Async events → source pane refresh |
Further planned options:
- Optional in-process UART mux (replace external
kdmx; same:lua kgdb_uartUX) crashutility integration for dump analysis- Custom
/proc/kcorereaders
Design constraint: kernel workflows must not fork the UI. New panes and backends extend the existing widget and event model.
Design constraints¶
| Constraint | Reason |
|---|---|
Backends never import termui |
Testability, headless automation |
| Async-only responses | MI2 / OpenOCD are streaming |
| Exclusive PTY write / shared read | UI + AI share one ptmx safely |
| Parse MI in gdb layer | Widgets display buffers, not raw protocol |
| Session config outside MCP | Target binary/args from CLI; AI uses live session |
| Same-process AI | One debug context for manual + :AI |
Related documentation¶
- EXEC_SHELL.md —
:!exec panes (sameptyx) - INPUT.md — GDB key forwarding
- PLUGINS.md — custom debugger panes
- ROADMAP.md — backend milestones
- DIRECTORY_STRUCTURE.md — package map