Files
five/hbrt/debug.go
CharlesKWON f4ed42556b checkpoint: season-wide bug fix campaign + infra
Cumulative season's silent-bug hunting (~62 fixes) across the FiveSql2
SQL engine, the Five compiler/runtime, and the hbrdd RDD layer. Saved
as a single checkpoint before refactoring the parser to delegate xBase
command translation to the preprocessor.

Highlights:

FiveSql2 engine (_FiveSql2/src/)
- prefix-glob index attach -> explicit convention (<table>_pk.ntx,
  <table>_uq.ntx, <table>.cdx) — fixes silent multi-row INSERT row-drop
- DROP/CREATE TABLE FErase chain extended (.cdx, .fsc, .fsv, .dbt, .fpt)
- COUNT(DISTINCT col) parsed + aggregated via hSeen hash
- UNION column-count mismatch returns SQL_ERR_GRAMMAR (was silent)
- DISTINCT + ORDER BY hidden-col leak fixed (trim before DISTINCT)
- Derived table FROM (SELECT...) + JOIN right-side derived
- Self-FK CASCADE depth 2+ via SqlGetSingleColPK pre-collect
- LAG/LEAD default arg uses SqlEvalRowExpr (handles -N const exprs)
- DATE literal round-trip validation (Feb 29 non-leap rejected)
- CREATE OR REPLACE VIEW; CREATE VIEW errors on already-exists
- AlterTable type dispatcher comma-wrapped (1-char type "A" no longer
  matches CHARACTER)

Compiler / runtime
- gengo: HB_ -> FV_ prefix on emitted Go function names (Five identity)
- gengo split: emit_block.go, emit_stmt.go, folding.go extracted
- parser/stmtreg.go nudges
- hbrt: debug TUI/CLI restructure (debugcmd, debugkey, termios_*),
  windows debug stubs collapsed
- thread/vm/value/class/pcinterp tightening from panic traces

RDD layer (hbrdd/)
- dbf: null bitmap support (null.go + null_test.go), mmap split
  (mmap_posix.go / mmap_windows.go), byte-level numeric parse
- ntx/cdx: windows mmap parity
- workarea + mem RDD: cross-area state-bleed fixes

RTL (hbrtl/)
- errorlog rewrite with platform-specific FD (errorlog_fd_unix /
  errorlog_fd_other)
- sqlscan, sqlhelpers, indexrtl, datetime extensions

Gates green at checkpoint:
- go test ./...        : PASS
- FiveSql2 SQL:1999    : 43/43
- Harbour compat       : 56/56

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:26:25 +09:00

479 lines
14 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
// Five Debugger — line-level debugging with breakpoints, stepping,
// variable inspection, and call stack display.
//
// Architecture:
// gengo emits t.DebugLine(file, line) at each PRG source line.
// Thread.DebugLine() checks breakpoints and invokes the debugger callback.
// The debugger callback can inspect variables, call stack, and control flow.
//
// Unlike Harbour's 370KB debug system, Five's debugger is ~300 lines of Go
// that leverages Go's runtime introspection.
package hbrt
import (
"fmt"
"os"
"strings"
"sync"
)
// DebugMode constants
const (
DbgContinue = 0 // free run, stop at breakpoints only
DbgStepLine = 1 // stop at every line
DbgStepOver = 2 // stop when returning to same or shallower level
DbgStepOut = 3 // stop when returning from current function
DbgToCursor = 4 // run until specific line
)
// Breakpoint represents a source-level breakpoint.
type Breakpoint struct {
Module string // source file name
Line int // line number (1-based)
Function string // optional function name filter
Enabled bool
HitCount int
// Condition is an optional PRG expression. The breakpoint only
// stops when the expression evaluates truthy at hit-time. Empty
// string = unconditional. Evaluation runs through the same macro
// hook the `p` command uses, so LOCALs/fields/function calls all
// work.
Condition string
}
// DebugVarInfo describes a variable visible in the current scope.
type DebugVarInfo struct {
Name string // variable name
Value Value // current value
Scope string // "LOCAL", "STATIC", "PARAM"
Index int // local index (1-based)
}
// DebugStackFrame describes one frame in the call stack.
type DebugStackFrame struct {
Function string // function name
Module string // source file
Line int // current line
Level int // stack depth
}
// DebugCallback is called when the debugger activates.
// The callback can inspect state and return the next debug mode.
type DebugCallback func(info *DebugEvent) int
// DebugEvent contains all information available at a debug stop.
type DebugEvent struct {
Module string // current source file
Line int // current line number
Function string // current function name
Reason string // "breakpoint", "step", "entry"
Thread *Thread // the executing thread
CallStack []DebugStackFrame // call stack
Locals []DebugVarInfo // local variables
Breakpoint *Breakpoint // which breakpoint hit (or nil)
}
// Debugger manages debug state for the VM.
type Debugger struct {
mu sync.Mutex
Enabled bool
Mode int // DbgContinue, DbgStepLine, etc.
Breakpoints []*Breakpoint
Callback DebugCallback // called when debugger activates
StepLevel int // call stack depth for step-over
ToCursorMod string // target module for run-to-cursor
ToCursorLine int // target line for run-to-cursor
Watches []string // PRG expressions auto-evaluated at each stop
// Debug info tables (populated by generated code)
LineInfo map[string]map[int]bool // module → set of valid lines
FuncInfo map[string]string // "MODULE:LINE" → function name
LocalInfo map[string][]string // function → local var names
SourceDir string // base directory for resolving source paths
}
// NewDebugger creates a new debugger instance.
func NewDebugger() *Debugger {
return &Debugger{
Enabled: true,
Mode: DbgStepLine, // start in step mode
LineInfo: make(map[string]map[int]bool),
FuncInfo: make(map[string]string),
LocalInfo: make(map[string][]string),
}
}
// AddBreakpoint adds a breakpoint.
func (d *Debugger) AddBreakpoint(module string, line int) int {
d.mu.Lock()
defer d.mu.Unlock()
bp := &Breakpoint{
Module: strings.ToUpper(module),
Line: line,
Enabled: true,
}
d.Breakpoints = append(d.Breakpoints, bp)
return len(d.Breakpoints) - 1
}
// RemoveBreakpoint removes a breakpoint by index.
func (d *Debugger) RemoveBreakpoint(idx int) {
d.mu.Lock()
defer d.mu.Unlock()
if idx >= 0 && idx < len(d.Breakpoints) {
d.Breakpoints = append(d.Breakpoints[:idx], d.Breakpoints[idx+1:]...)
}
}
// ToggleBreakpoint enables/disables a breakpoint.
func (d *Debugger) ToggleBreakpoint(idx int) {
d.mu.Lock()
defer d.mu.Unlock()
if idx >= 0 && idx < len(d.Breakpoints) {
d.Breakpoints[idx].Enabled = !d.Breakpoints[idx].Enabled
}
}
// FindBreakpoint checks if there's an active breakpoint at module:line.
func (d *Debugger) FindBreakpoint(module string, line int) *Breakpoint {
upper := strings.ToUpper(module)
for _, bp := range d.Breakpoints {
if bp.Enabled && bp.Line == line {
if bp.Module == upper || strings.HasSuffix(upper, bp.Module) {
bp.HitCount++
return bp
}
}
}
return nil
}
// RegisterLine records that a line exists in a module (for valid breakpoint checking).
func (d *Debugger) RegisterLine(module string, line int) {
upper := strings.ToUpper(module)
if d.LineInfo[upper] == nil {
d.LineInfo[upper] = make(map[int]bool)
}
d.LineInfo[upper][line] = true
}
// IsValidLine checks if a line is a valid stop point.
func (d *Debugger) IsValidLine(module string, line int) bool {
upper := strings.ToUpper(module)
if lines, ok := d.LineInfo[upper]; ok {
return lines[line]
}
return false
}
// --- Thread debug integration ---
// DebugLineFast records the current PRG source position on the active
// frame — nothing more. gengo emits a call to this at every statement
// in non-debug builds so that error.log / panic traces still carry a
// line number, without paying for a full DebugLine dispatch (VM lookup
// + debugger flag check) per statement. The body is small enough that
// Go inlines it across the call boundary; in practice this compiles to
// a nil check + two word-sized stores.
//
// Keep the symbol name stable — gengo emits it by string.
func (t *Thread) DebugLineFast(module string, line int) {
if t.curFrame != nil {
t.curFrame.module = module
t.curFrame.line = line
}
}
// DebugLine is the full debugger hook — line recording + trace ring +
// breakpoint/step dispatch. gengo only emits this when compiled with
// debug info (five debug ...), so the expensive path is off by default.
func (t *Thread) DebugLine(module string, line int) {
// Always record on the current frame so panic sites know where we were.
if t.curFrame != nil {
t.curFrame.module = module
t.curFrame.line = line
}
vm := t.VM()
if vm.Debugger == nil || !vm.Debugger.Enabled {
return
}
// Record in this thread's execution trace ring so "hist" can show
// the path taken to reach the break. Only under an attached
// debugger — keeps production runs allocation-free.
if t.traceRing == nil {
t.traceRing = make([]TraceEntry, TraceRingSize)
}
t.traceRing[t.traceHead] = TraceEntry{Module: module, Line: line}
t.traceHead = (t.traceHead + 1) % TraceRingSize
t.traceCount++
dbg := vm.Debugger
dbg.mu.Lock()
mode := dbg.Mode
dbg.mu.Unlock()
shouldStop := false
var hitBP *Breakpoint
reason := ""
switch mode {
case DbgContinue:
// Only stop at breakpoints
hitBP = dbg.FindBreakpoint(module, line)
if hitBP != nil {
shouldStop = true
reason = "breakpoint"
}
case DbgStepLine:
shouldStop = true
reason = "step"
case DbgStepOver:
if t.callSP <= dbg.StepLevel {
shouldStop = true
reason = "step"
} else {
// Check breakpoints even during step-over
hitBP = dbg.FindBreakpoint(module, line)
if hitBP != nil {
shouldStop = true
reason = "breakpoint"
}
}
case DbgStepOut:
if t.callSP < dbg.StepLevel {
shouldStop = true
reason = "step"
}
case DbgToCursor:
if strings.EqualFold(module, dbg.ToCursorMod) && line == dbg.ToCursorLine {
shouldStop = true
reason = "cursor"
} else {
hitBP = dbg.FindBreakpoint(module, line)
if hitBP != nil {
shouldStop = true
reason = "breakpoint"
}
}
}
// Conditional breakpoint: evaluate the expression with the current
// frame visible. If it's not truthy, pretend we didn't hit anything.
if hitBP != nil && hitBP.Condition != "" {
if !evalBPCondition(t, hitBP.Condition) {
shouldStop = false
hitBP = nil
reason = ""
}
}
if !shouldStop {
return
}
// Build debug event
event := &DebugEvent{
Module: module,
Line: line,
Function: t.currentFuncName(),
Reason: reason,
Thread: t,
Breakpoint: hitBP,
}
// Build call stack
event.CallStack = t.DebugCallStack()
// Build locals
event.Locals = t.DebugLocals()
// Invoke callback
if dbg.Callback != nil {
newMode := dbg.Callback(event)
dbg.mu.Lock()
dbg.Mode = newMode
if newMode == DbgStepOver {
dbg.StepLevel = t.callSP
} else if newMode == DbgStepOut {
dbg.StepLevel = t.callSP
}
dbg.mu.Unlock()
}
}
// DebugCallStack returns the current call stack for debugging.
func (t *Thread) DebugCallStack() []DebugStackFrame {
var stack []DebugStackFrame
for i := t.callSP - 1; i >= 0; i-- {
frame := &t.calls[i]
name := "unknown"
if frame.symbol != nil {
name = frame.symbol.Name
}
stack = append(stack, DebugStackFrame{
Function: name,
Module: frame.module,
Line: frame.line,
Level: i,
})
}
return stack
}
// DebugLocals returns local variables for the current frame. If the
// emitter registered PRG-level names via SetLocalNames, those are used;
// otherwise falls back to "_1" / "_2" / ... placeholders.
func (t *Thread) DebugLocals() []DebugVarInfo {
if t.curFrame == nil {
return nil
}
names := t.curFrame.localNames
var vars []DebugVarInfo
for i := 0; i < t.curFrame.localCount; i++ {
idx := t.curFrame.localBase + i
if idx < len(t.locals) {
scope := "LOCAL"
if i < t.curFrame.paramCount {
scope = "PARAM"
}
name := ""
if i < len(names) {
name = names[i]
}
if name == "" {
name = fmt.Sprintf("_%d", i+1)
}
vars = append(vars, DebugVarInfo{
Name: name,
Value: t.locals[idx],
Scope: scope,
Index: i + 1,
})
}
}
return vars
}
// EvalWithFrameLocals evaluates a PRG expression string with the
// current call frame's LOCALs visible as PRIVATEs. Used by both the
// debugger's `p` command and conditional-breakpoint checks. Returns
// the resulting Value plus an error string (empty on success). Any
// panic during eval is captured so a malformed expression can't crash
// the debug loop.
func (t *Thread) EvalWithFrameLocals(expr string) (result Value, evalErr string) {
defer func() {
if pv := recover(); pv != nil {
evalErr = fmt.Sprintf("%v", pv)
}
}()
if macroEvalHook == nil {
return MakeNil(), "macro hook not installed"
}
// Install LOCALs as PRIVATEs for the eval scope so bare-name
// references in the expression resolve to the current frame.
if t.Memvars != nil && t.curFrame != nil {
names := t.curFrame.localNames
t.Memvars.BeginPrivateScope(t.callSP)
defer t.Memvars.EndPrivateScope()
for i := 0; i < t.curFrame.localCount; i++ {
if i >= len(names) || names[i] == "" {
continue
}
idx := t.curFrame.localBase + i
if idx < len(t.locals) {
t.Memvars.SetPrivate(names[i], t.locals[idx], t.callSP)
}
}
}
t.PushString(expr)
macroEvalHook(t)
result = t.Pop2()
return
}
// evalBPCondition returns true when a breakpoint's Condition string
// evaluates truthy in the current frame. Non-logical results are
// coerced: NIL/.F./0/"" → false, everything else → true. Eval errors
// are reported to stderr and treated as "not hit" so a broken condition
// silently skips the stop instead of crashing.
func evalBPCondition(t *Thread, expr string) bool {
val, evalErr := t.EvalWithFrameLocals(expr)
if evalErr != "" {
fmt.Fprintf(os.Stderr, "debug: breakpoint condition %q failed: %s\n", expr, evalErr)
return false
}
switch {
case val.IsNil():
return false
case val.IsLogical():
return val.AsBool()
case val.IsNumeric():
return val.AsNumInt() != 0 || val.AsNumDouble() != 0
case val.IsString():
return len(val.AsString()) > 0
}
return true
}
// Trace returns the last up-to-TraceRingSize PRG (module, line) pairs
// this thread executed while under the debugger, in chronological
// order (oldest first). Also returns the total count since the
// debugger attached — callers can compute "how many lines ago" as
// totalCount - index.
func (t *Thread) Trace() ([]TraceEntry, uint64) {
if t.traceRing == nil || t.traceCount == 0 {
return nil, 0
}
n := len(t.traceRing)
have := int(t.traceCount)
if have > n {
have = n
}
out := make([]TraceEntry, have)
// Start index in the ring depends on whether we've wrapped.
var start int
if int(t.traceCount) <= n {
start = 0
} else {
start = t.traceHead
}
for i := 0; i < have; i++ {
out[i] = t.traceRing[(start+i)%n]
}
return out, t.traceCount
}
// SetLocalNames attaches the PRG-source variable names for params+locals
// to the current call frame. gengo emits a call to this right after
// Frame() so the debugger/error.log can show real names ("i", "nSum")
// instead of slot numbers.
//
// The names slice is expected to be function-lifetime immutable (gengo
// emits a package-level [...]string), so we store the pointer, not a
// copy.
func (t *Thread) SetLocalNames(names []string) {
if t.curFrame != nil {
t.curFrame.localNames = names
}
}
// currentFuncName returns the name of the currently executing function.
func (t *Thread) currentFuncName() string {
if t.callSP > 0 {
frame := &t.calls[t.callSP-1]
if frame.symbol != nil {
return frame.symbol.Name
}
}
return "MAIN"
}