Senior-engineer / QA audit landed 13 silent-miscompile and data-
integrity fixes spanning the whole compiler+runtime+storage stack.
Each fix is paired with either an integration test in the suite or
a focused regression check; all 6 release gates stay green:
go test ./..., FiveSql2 43/43, Harbour compat 56/56, std.ch 17/17,
FRB 7/7, examples 65/71.
Compiler
--------
* genpc IF/ELSEIF jumpEnd2 patching (compiler/genpc/genpc.go).
Per-ELSEIF branch terminators were stashed into `_ = jumpEnd2`
and never patched — the relative offset stayed 0 and the runtime
walked the next ELSEIF's PcOpJumpFalse opcode as if it were
jump-offset data. Bytecode-level corruption in pcode mode. Now
collected into a slice and patched at end-of-IF. Verified via
Grade(95..50) cases 11a-e added to tests/frb/test_frb_pcode_sweep.
* countLocalsInStmts / scanBodyLocals missing bodies
(compiler/gengo/gen_util.go, compiler/gengo/gengo.go). Frame-size
counter skipped WATCH/TIMEOUT/PARALLEL FOR bodies, so a LOCAL
declared inside one of those constructs got a slot index past
the runtime's allocated count — silent NIL reads or out-of-range
stomps.
* emitMethodDeclStandalone nested LOCAL (compiler/gengo/gen_class.go).
Same bug class but on the *method* side. Pre-fix repro:
METHOD Stomp(n) CLASS T
LOCAL a := 1, b := 2
IF n > 0
LOCAL c := 30, d := 40, e := 50, f := 60
Inner( n )
IF c != 30 .OR. d != 40 .OR. e != 50 .OR. f != 60 ...
printed `c, d, e, f = 5, NIL, NIL, NIL` because Inner's frame
collided with Stomp's underallocated slot range. Now counts
body-nested LOCALs into the frame and pre-allocates indices via
scanBodyLocals.
* genpc unsupported-AST diagnostic surface (compiler/genpc/genpc.go,
hbrt/pcode.go, cmd/five/main.go, hbrtl/frb.go). The `default`
cases in emitStmt / emitExpr silently emitted PushNil / no-op
for nodes the pcode generator doesn't implement (ClassDecl,
MethodDecl, xBase commands, concurrency primitives, …). Added
`PcodeModule.Warnings []string` populated by noteUnsupported,
surfaced on stderr from the build pipeline. Users now see
"pcode: AST node not supported in --pcode/FRB-pcode mode: stmt
*ast.GoBlockStmt" instead of getting a silently broken module.
Runtime
-------
* class.go Send/tryBinaryOp t.self defer-restore (hbrt/class.go).
Restoration was a plain `t.self = oldSelf` after `fn(t)`. Any
panic in the method body skipped the line, so the next BEGIN
SEQUENCE / RECOVER handler ran with the THROWING object's Self
— `::field` resolved against the wrong receiver. Wrapped both
restore sites in `defer func() { t.self = oldSelf }()`.
Verified: pre-fix RECOVER saw "THROWER", post-fix "OUTER".
* hbfunc.go HB_FUNC parameter Frame() (hbrt/hbfunc.go). The
RegisterDynamicFunc wrapper called `fn(ctx)` without ever
calling Frame, so `ctx.ParC(1)` / `ctx.Local(n)` read through
`t.curFrame.localBase + n - 1` against the *caller's* frame.
Every #pragma BEGINDUMP HB_FUNC taking parameters silently
returned "" / 0 / "" for them — masked by ParNIDef-style
defaults. Wrapper now does `t.Frame(t.pendingParams, 0); defer
t.EndProc()` before dispatch.
* pcode codeblock closure capture (hbrt/pcinterp.go, hbrt/pcode.go,
hbrt/thread.go, compiler/genpc/genpc.go). PcOpPushBlock recorded
`nDetached` but never copied enclosing locals; free vars in the
block body fell through to memvar lookup → NIL. Wired full
capture pipeline:
- New opcodes PcOpPushDetached (0x59) / PcOpPopDetached (0x5A).
- PushBlock now reads per-slot source-local indices and
snapshots into bb.Detached at construction time.
- New detachedMap in genpc auto-promotes any free var that
resolves to an enclosing-frame local into a capture slot.
- emitAssignAsExpr leaves the assigned value on the eval stack
so SeqExpr items like `{|v| acc += v, acc }` work.
- Thread tracks curBlock with paired Set/restore in the block's
Fn wrapper for nested-block evaluation.
Mutating capture (acc += v across successive Evals) now works.
* vm.NewThread statics + waFactory propagation (hbrt/vm.go).
GoLaunch / GoLaunchBlock call NewThread directly. Previously
the statics map and WA factory were applied only in Run(), so
goroutine-spawned PRG code panicked on STATIC access ("static
index out of range") and crashed dereferencing nil WA on any
DB call. Both now happen inside NewThread under the same lock
as TID assignment.
Data layer
----------
* dbf concurrent Append lock (hbrdd/dbf/dbf.go,
hbrdd/dbf/locks_posix.go, hbrdd/dbf/locks_windows.go). Append
bumped a local recCount with no file-system serialization. Two
shared-mode processes both wrote at the same RecordOffset; one
record silently overwrote the other. Added an append-intent
byte-range lock at offset 0x7FFFFFFE + bounded retry, on-disk
header refresh inside the locked region, and immediate header
write so peers refresh past our slot.
* indexer negative numeric key encoding (hbrdd/dbf/indexer.go +
new hbrdd/dbf/encode_numeric_test.go). `%20.10f` formats `-100`
as `" -100.0000000000"` and `99` as `" 99.0000000000"`.
ASCII ' ' (0x20) < '-' (0x2D), so `99` lex-compared LESS than
`-100` — every NTX/CDX index over a column that ever held a
negative number returned wrong rows for SEEK / range scans.
Replaced with a 1-byte sign prefix + 21-byte zero-padded
magnitude (negatives use digit-complement) so byte order
matches numeric order across signs and magnitudes. Format
change: existing indexes built with the old encoding must be
REINDEXed. Three unit tests pin the order.
* dbf Append index maintenance hooks (hbrdd/dbf/dbf.go,
hbrdd/dbf/indexer.go). Append never inserted into open NTX/CDX
indexes — the audit's canonical scenario `SET INDEX TO …;
APPEND BLANK; REPLACE …; dbSeek …` silently missed the new
record. Added optional IndexWriter interface, queue the new
recNo in pendingIdxInserts, drain after flushRecord by calling
InsertKey on every open writer-supporting engine. NTX
participates (its existing rebuild-on-insert is correct);
CDX online maintenance is deferred to a follow-up — those
indexes still need REINDEX. Verified: post-fix SEEK("Charlie")
after APPEND BLANK + REPLACE finds the new record.
* dbf PACK crash-safety (hbrdd/dbf/dbf.go). The old in-place
rewrite read record N, overwrote slot M<N, then truncated.
Power loss after partial loop left a file with overwritten
prefix and no original copies of the records already advanced
past — silent data loss. Rewrote to:
1) drop mmap, build `<file>.pack.tmp` with all surviving
records,
2) Sync(),
3) close original handle + os.Rename(tmp, orig) (atomic on
same FS),
4) reopen + re-mmap.
TestComp_Pack passes; readers always see either the pre-PACK
or post-PACK contents, never a half-state.
* mem RDD torn reads (hbrdd/mem/memrdd.go). The comment claimed
in-place PutValue was safe because hbrt.Value "fits in a
single machine word + pointer". hbrt.Value is 24 bytes (3
words) — a concurrent reader could observe new type tag with
stale scalar/ptr and type-confuse on the next AsXxx() call.
Switched mu to sync.RWMutex; GetValue takes RLock,
Append/PutValue/Delete/Recall take Lock. `go test -race
./hbrdd/mem/` clean.
Files touched
-------------
compiler/gengo/gen_class.go, gen_util.go, gengo.go
compiler/genpc/genpc.go
hbrt/class.go, hbfunc.go, pcinterp.go, pcode.go, thread.go, vm.go
hbrdd/dbf/dbf.go, indexer.go, locks_posix.go, locks_windows.go
hbrdd/dbf/encode_numeric_test.go (new)
hbrdd/mem/memrdd.go
cmd/five/main.go
hbrtl/frb.go
tests/frb/test_frb_pcode_sweep.prg
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
283 lines
8.1 KiB
Go
283 lines
8.1 KiB
Go
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
|
// All rights reserved.
|
|
|
|
package hbrt
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"runtime/pprof"
|
|
"sync"
|
|
)
|
|
|
|
// VM is the shared state across all threads.
|
|
type VM struct {
|
|
mu sync.RWMutex
|
|
modules []*Module
|
|
symbols map[string]*Symbol
|
|
statics map[string][]Value
|
|
threads []*Thread // all threads created (for shutdown cleanup + debugger listing)
|
|
nextTID uint32 // monotonic thread id
|
|
waFactory func() interface{} // creates WorkAreaManager for new threads
|
|
onExit func() // called when Run() finishes (restore terminal etc.)
|
|
Debugger *Debugger // nil = no debugging; set by five debug command
|
|
}
|
|
|
|
// SetWAFactory sets the factory for creating WorkAreaManagers.
|
|
func (vm *VM) SetWAFactory(f func() interface{}) {
|
|
vm.waFactory = f
|
|
}
|
|
|
|
// SetOnExit sets a callback for when Run() finishes.
|
|
func (vm *VM) SetOnExit(f func()) {
|
|
vm.onExit = f
|
|
}
|
|
|
|
// Library modules registered via init() — protected by mutex for FRB concurrent loading.
|
|
var (
|
|
libModules []*Module
|
|
dynamicFuncs []Symbol // from HB_FUNC() in #pragma BEGINDUMP
|
|
libRegistryMu sync.Mutex
|
|
)
|
|
|
|
// RegisterLibModule registers a module from a library PRG file.
|
|
// Called by init() in generated library code.
|
|
func RegisterLibModule(m *Module) {
|
|
libRegistryMu.Lock()
|
|
libModules = append(libModules, m)
|
|
libRegistryMu.Unlock()
|
|
}
|
|
|
|
// RegisterDynamicFunc registers a Go function callable from PRG.
|
|
// Called from init() in #pragma BEGINDUMP code via HB_FUNC().
|
|
func RegisterDynamicFunc(name string, fn func(*Thread)) {
|
|
libRegistryMu.Lock()
|
|
dynamicFuncs = append(dynamicFuncs, Symbol{
|
|
Name: name,
|
|
Scope: FsPublic | FsLocal,
|
|
Func: fn,
|
|
})
|
|
libRegistryMu.Unlock()
|
|
}
|
|
|
|
// RegisterLibModules registers any pending lib modules and dynamic functions.
|
|
func (vm *VM) RegisterLibModules() {
|
|
libRegistryMu.Lock()
|
|
mods := libModules
|
|
libModules = nil
|
|
dyns := dynamicFuncs
|
|
dynamicFuncs = nil
|
|
libRegistryMu.Unlock()
|
|
|
|
for _, m := range mods {
|
|
vm.RegisterModule(m)
|
|
}
|
|
for i := range dyns {
|
|
sym := &dyns[i]
|
|
vm.RegisterSymbol(sym)
|
|
}
|
|
dynamicFuncs = nil
|
|
}
|
|
|
|
// NewVM creates a new VM instance.
|
|
func NewVM() *VM {
|
|
return &VM{
|
|
modules: make([]*Module, 0),
|
|
symbols: make(map[string]*Symbol),
|
|
statics: make(map[string][]Value),
|
|
}
|
|
}
|
|
|
|
// RegisterModule registers a module's symbols with the VM.
|
|
func (vm *VM) RegisterModule(m *Module) {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
vm.modules = append(vm.modules, m)
|
|
for i := range m.Symbols {
|
|
sym := &m.Symbols[i]
|
|
vm.symbols[sym.Name] = sym
|
|
}
|
|
}
|
|
|
|
// RegisterSymbol registers a single symbol.
|
|
func (vm *VM) RegisterSymbol(sym *Symbol) {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
vm.symbols[sym.Name] = sym
|
|
}
|
|
|
|
// UnregisterSymbol removes a symbol by name. Returns the old symbol if any.
|
|
func (vm *VM) UnregisterSymbol(name string) *Symbol {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
old := vm.symbols[name]
|
|
delete(vm.symbols, name)
|
|
return old
|
|
}
|
|
|
|
// SymbolNames returns all registered symbol names.
|
|
func (vm *VM) SymbolNames() []string {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
names := make([]string, 0, len(vm.symbols))
|
|
for n := range vm.symbols {
|
|
names = append(names, n)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// FindSymbol looks up a symbol by name.
|
|
func (vm *VM) FindSymbol(name string) *Symbol {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
return vm.symbols[name]
|
|
}
|
|
|
|
// GetSym returns the cached Symbol, performing a one-time FindSymbol
|
|
// lookup on first access and stashing the pointer in *cache for all
|
|
// subsequent calls. Generated code (gengo) declares a package-level
|
|
// `var _sym_NAME *Symbol` per unique call target and routes every
|
|
// PushSymbol through this helper so the hot path becomes a single
|
|
// non-nil check instead of vm.symbols map + RWMutex per invocation.
|
|
func (t *Thread) GetSym(cache **Symbol, name string) *Symbol {
|
|
if s := *cache; s != nil {
|
|
return s
|
|
}
|
|
s := t.vm.FindSymbol(name)
|
|
if s != nil {
|
|
// Only cache successful resolutions — nil might be due to
|
|
// init-order (another module's registrations pending);
|
|
// retry on next call once those complete.
|
|
*cache = s
|
|
}
|
|
return s
|
|
}
|
|
|
|
// NewThread creates a new Thread attached to this VM.
|
|
//
|
|
// Statics + WA are initialized here (not just in Run) so threads
|
|
// spawned via GoLaunch / GoLaunchBlock — which call NewThread
|
|
// directly — see the same module-static map and have a workarea
|
|
// manager available. Without this, PRG code running in a goroutine
|
|
// that touched a STATIC panicked with "static index out of range",
|
|
// and any DB/RDD call crashed dereferencing nil WA.
|
|
func (vm *VM) NewThread() *Thread {
|
|
t := NewThread(vm)
|
|
vm.mu.Lock()
|
|
vm.nextTID++
|
|
t.tid = vm.nextTID
|
|
vm.threads = append(vm.threads, t)
|
|
// Snapshot the statics map under the same lock — late
|
|
// goroutines see whatever was registered up to this point.
|
|
for k, v := range vm.statics {
|
|
t.statics[k] = v
|
|
}
|
|
wf := vm.waFactory
|
|
vm.mu.Unlock()
|
|
if t.WA == nil && wf != nil {
|
|
t.WA = wf()
|
|
}
|
|
return t
|
|
}
|
|
|
|
// Threads returns a snapshot of all threads currently tracked by the
|
|
// VM. Used by the debugger's `threads` command. Returned slice is a
|
|
// copy — callers can iterate without holding any lock.
|
|
func (vm *VM) Threads() []*Thread {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
out := make([]*Thread, len(vm.threads))
|
|
copy(out, vm.threads)
|
|
return out
|
|
}
|
|
|
|
// TID returns this thread's VM-unique id. Main thread gets 1.
|
|
func (t *Thread) TID() uint32 { return t.tid }
|
|
|
|
// Run starts execution from the named function.
|
|
func (vm *VM) Run(funcName string) Value {
|
|
// Register any library modules from init()
|
|
for _, m := range libModules {
|
|
vm.RegisterModule(m)
|
|
}
|
|
libModules = nil
|
|
|
|
sym := vm.FindSymbol(funcName)
|
|
if sym == nil {
|
|
panic("function not found: " + funcName)
|
|
}
|
|
if sym.Func == nil {
|
|
panic("function has no implementation: " + funcName)
|
|
}
|
|
|
|
t := vm.NewThread()
|
|
|
|
// Auto-initialize WorkAreaManager if not set
|
|
if t.WA == nil && vm.waFactory != nil {
|
|
t.WA = vm.waFactory()
|
|
}
|
|
|
|
// Copy statics to thread
|
|
vm.mu.RLock()
|
|
for k, v := range vm.statics {
|
|
t.statics[k] = v
|
|
}
|
|
vm.mu.RUnlock()
|
|
|
|
// Install signal handlers for clean shutdown
|
|
vm.InstallSignalHandlers()
|
|
|
|
// Optional CPU profiling — FIVE_CPUPROFILE=<path> writes a pprof
|
|
// file covering the whole program run. Used to collect default.pgo
|
|
// input for profile-guided compilation of Five-runtime code.
|
|
if path := os.Getenv("FIVE_CPUPROFILE"); path != "" {
|
|
if f, err := os.Create(path); err == nil {
|
|
if werr := pprof.StartCPUProfile(f); werr == nil {
|
|
defer f.Close()
|
|
defer pprof.StopCPUProfile()
|
|
defer fmt.Fprintf(os.Stderr, "CPU profile written to %s\n", path)
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "FIVE_CPUPROFILE: StartCPUProfile: %v\n", werr)
|
|
f.Close()
|
|
}
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "FIVE_CPUPROFILE: cannot create %s: %v\n", path, err)
|
|
}
|
|
}
|
|
|
|
// Call the function, ensure full shutdown on exit.
|
|
// On unhandled *HbError, route through DefaultErrorHook (writes
|
|
// error.log) before letting the panic surface. The Go panic still
|
|
// propagates — we only add the diagnostic side effect.
|
|
defer vm.Shutdown()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
if DefaultErrorHook != nil {
|
|
DefaultErrorHook(t, r)
|
|
}
|
|
panic(r)
|
|
}
|
|
}()
|
|
// Attach the symbol so the entry frame shows its name in stack traces.
|
|
// Normal calls go through Function() which sets pendingCallSym; direct
|
|
// VM.Run needs to do it manually.
|
|
t.pendingCallSym = sym
|
|
sym.Func(t)
|
|
|
|
return t.retVal
|
|
}
|
|
|
|
// DefaultErrorHook runs when an unhandled panic escapes Main. hbrtl sets
|
|
// this at package init to dump error.log. Nil by default — set once at
|
|
// startup, not swapped at runtime, so no synchronization.
|
|
var DefaultErrorHook func(t *Thread, panicValue interface{})
|
|
|
|
// DebugDiagnosticHook renders the error.log-style state dump (workareas,
|
|
// SET flags, runtime memory) for the debugger's `diag` command. hbrtl
|
|
// sets this at init time — keeping the renderers in hbrtl avoids a
|
|
// circular import (hbrdd → hbrt ← hbrt needs hbrdd types).
|
|
//
|
|
// section values: "" (everything), "wa", "set", "mem". Unknown sections
|
|
// fall back to everything. The hook writes one line per call to `emit`.
|
|
var DebugDiagnosticHook func(t *Thread, section string, emit func(string))
|