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>
479 lines
14 KiB
Go
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"
|
|
}
|