Files
five/hbrt/thread.go
CharlesKWON cde86730b8 fix(compiler,hbrt,hbrdd,cli): pre-1.0 audit — 13 critical fixes
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>
2026-05-13 05:29:56 +09:00

809 lines
22 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
package hbrt
import (
"fmt"
"os"
)
// Default stack/frame sizes
const (
DefaultStackSize = 2048 // initial eval stack capacity
MaxStackSize = 65536
MaxCallDepth = 256
InitialCallDepth = 32 // start small, grow if needed
)
// CallFrame saves the state of a function call.
// Harbour equivalent: HB_STACK_STATE
type CallFrame struct {
symbol *Symbol // function symbol (for debugging/profiling)
base int // stack base (start of this frame's args)
localBase int // where locals start in the locals slice
localCount int // number of locals in this frame
paramCount int // number of parameters passed
retVal Value // return value
module string // current PRG source file (updated by DebugLine)
line int // current PRG source line
localNames []string // PRG-source names of params+locals (nil = none registered)
}
// CurFrame returns the current call frame (for closure capture).
func (t *Thread) CurFrame() *CallFrame { return t.curFrame }
// CurBlock returns the *HbBlock for the codeblock currently executing
// (or nil outside a block body). Used by pcode dispatch to resolve
// detached-local opcodes against the running block's capture slice.
func (t *Thread) CurBlock() *HbBlock { return t.curBlock }
// SetCurBlock installs the executing block. The pcode block wrapper
// pairs Set(block) with a deferred Set(prev) to nest correctly across
// blocks that call other blocks (`{|| eval(b1) + eval(b2) }`).
func (t *Thread) SetCurBlock(b *HbBlock) { t.curBlock = b }
// LocalsSlice returns the underlying locals array (for closure capture).
func (t *Thread) LocalsSlice() []Value { return t.locals }
// GetLocal reads a local variable from a captured frame (1-based index).
func (f *CallFrame) GetLocal(n int, locals []Value) Value {
idx := f.localBase + n - 1
return locals[idx]
}
// SetLocal writes a local variable in a captured frame (1-based index).
func (f *CallFrame) SetLocal(n int, v Value, locals []Value) {
idx := f.localBase + n - 1
locals[idx] = v
}
// Thread is the per-goroutine execution context.
// Harbour equivalent: HB_STACK (thread-local stack)
//
// Each goroutine that runs Harbour code gets its own Thread.
// No locking needed for stack/locals/calls — they are goroutine-local.
// The TID is VM-unique and assigned at construction time for debugger
// thread listing.
// TraceEntry captures one step of execution history — module+line where
// DebugLine fired. Populated only when the debugger is attached so
// regular runs don't pay the ring-buffer cost.
type TraceEntry struct {
Module string
Line int
}
// Size of the per-thread execution trace ring buffer. 256 entries gives
// enough runway to answer "how did we get here?" across most loops
// without meaningfully bloating per-thread memory.
const TraceRingSize = 256
type Thread struct {
tid uint32
// traceRing is a ring buffer of recent DebugLine hits. traceHead
// points at the slot for the next write. Total recorded entries
// across the program's lifetime for this thread is tracked via
// traceCount so the debugger can render "N lines ago".
traceRing []TraceEntry
traceHead int
traceCount uint64
// Eval stack (goroutine-local, no lock needed)
stack []Value
sp int // stack pointer (next free slot)
// Local variables: flat array, each frame gets a slice via localBase+localCount
locals []Value
// Call stack
calls []CallFrame
callSP int // call stack pointer
curFrame *CallFrame
// Return value (passed between caller/callee)
retVal Value
// Pending function call stack (PushSymbol pushes, Function pops).
// Depth=1 is the common case (non-nested call) and gets a scalar
// fast slot to skip slice append/trim; nested calls fall back to
// the heap slice. Balanced push/pop keeps the invariant:
// pendingSymFast set → slice empty
// slice non-empty → pendingSymFast may or may not be set.
pendingSymFast *Symbol
pendingSyms []*Symbol
pendingParams int // number of params for next Frame call
pendingCallSym *Symbol // symbol for next Frame (for PROCNAME)
// STATIC variables (per-module, shared but rarely written)
// Accessed via PushStatic/PopStatic with module reference
statics map[string][]Value
// OOP: current Self object (set during method dispatch)
self Value
// Current code block (set while a block body is executing).
// Pcode opcodes PcOpPushDetached / PcOpPopDetached read/write
// Detached[i] through this pointer. The block's wrapper Fn
// sets it before ExecPcode and restores on exit. Stays nil
// outside block bodies; nil-checking opcodes fall back to NIL.
curBlock *HbBlock
// Error handling: last error from BEGIN SEQUENCE
lastError *HbError
// MEMVAR: PUBLIC/PRIVATE variables (shared across call stack)
Memvars *MemvarTable
// WorkArea manager (goroutine-local, no locks needed)
WA interface{} // *hbrdd.WorkAreaManager — set by caller to avoid import cycle
// FastFieldGetter is a hot-path closure set by SqlScan (or any other
// scan loop) to short-circuit PcOpFieldGet. When non-nil, the pcode
// interpreter calls this instead of going through PushSymbol +
// Function dispatch + FieldGet RTL's own Frame/EndProc. Caller is
// responsible for setting and clearing it around a scan.
FastFieldGetter func(int) Value
waStack []uint16 // saved workarea numbers for (expr)->(expr) context switching
// VM reference (shared, read-mostly)
vm *VM
}
// NewThread creates a new execution thread.
func NewThread(vm *VM) *Thread {
t := &Thread{
stack: make([]Value, DefaultStackSize),
sp: 0,
locals: make([]Value, 256), // will grow as needed
calls: make([]CallFrame, InitialCallDepth),
callSP: 0,
statics: make(map[string][]Value),
Memvars: NewMemvarTable(),
vm: vm,
}
return t
}
// --- Stack operations ---
func (t *Thread) push(v Value) {
if t.sp >= len(t.stack) {
if t.sp >= MaxStackSize {
panic(t.runtimeError("stack overflow"))
}
newStack := make([]Value, len(t.stack)*2)
copy(newStack, t.stack[:t.sp])
t.stack = newStack
}
t.stack[t.sp] = v
t.sp++
}
func (t *Thread) pop() Value {
if t.sp <= 0 {
panic(t.runtimeError("stack underflow"))
}
t.sp--
v := t.stack[t.sp]
t.stack[t.sp] = cachedNil
return v
}
func (t *Thread) peek() Value {
if t.sp <= 0 {
panic(t.runtimeError("stack underflow (peek)"))
}
return t.stack[t.sp-1]
}
func (t *Thread) peekPtr() *Value {
if t.sp <= 0 {
panic(t.runtimeError("stack underflow (peekPtr)"))
}
return &t.stack[t.sp-1]
}
func (t *Thread) setTop(v Value) {
if t.sp <= 0 {
panic(t.runtimeError("stack underflow (setTop)"))
}
t.stack[t.sp-1] = v
}
// stackAt returns a pointer to stack item at offset from top.
// 0 = top, -1 = second from top, etc.
func (t *Thread) stackAt(offset int) *Value {
idx := t.sp - 1 + offset
if idx < 0 || idx >= t.sp {
panic(t.runtimeError("stack access out of range"))
}
return &t.stack[idx]
}
// --- Push convenience methods (used by generated code) ---
func (t *Thread) PushNil() { t.push(MakeNil()) }
func (t *Thread) PushBool(b bool) { t.push(MakeBool(b)) }
func (t *Thread) PushInt(n int) { t.push(MakeInt(n)) }
func (t *Thread) PushLong(n int64) { t.push(MakeLong(n)) }
func (t *Thread) PushDouble(v float64, length, decimal uint16) {
t.push(MakeDouble(v, length, decimal))
}
func (t *Thread) PushString(s string) { t.push(MakeString(s)) }
func (t *Thread) PushValue(v Value) { t.push(v) }
func (t *Thread) Pop() { t.pop() }
func (t *Thread) Pop2() Value { return t.pop() } // pop and return
func (t *Thread) Dup() { t.push(t.peek()) }
// SP returns the current data-stack depth. Paired with SetSP for
// callers that need to clamp the stack across an inner function
// dispatch — used by FrbDo to neutralise pcode-body stack leaks.
func (t *Thread) SP() int { return t.sp }
// SetSP forcibly resets the data-stack depth. Truncates if newSP < sp;
// extends with NIL if newSP > sp (defensive — should never grow here
// in practice). Bounds-checked against the underlying slice so a
// negative or out-of-range value can't corrupt the runtime.
func (t *Thread) SetSP(newSP int) {
if newSP < 0 {
newSP = 0
}
if newSP > len(t.stack) {
newSP = len(t.stack)
}
// Clear any slots being abandoned so stale values can't surface
// later through Dup/peek paths.
for i := newSP; i < t.sp; i++ {
t.stack[i] = cachedNil
}
t.sp = newSP
}
// --- Frame management ---
// Harbour: hb_xvmFrame(params, locals)
// Called at the start of every function.
func (t *Thread) Frame(params, locals int) {
if t.callSP >= MaxCallDepth {
panic(t.runtimeError("call stack overflow"))
}
// Grow call stack dynamically if needed
if t.callSP >= len(t.calls) {
newSize := len(t.calls) * 2
if newSize > MaxCallDepth {
newSize = MaxCallDepth
}
newCalls := make([]CallFrame, newSize)
copy(newCalls, t.calls)
t.calls = newCalls
}
// Ensure locals slice has enough space
localBase := 0
if t.curFrame != nil {
localBase = t.curFrame.localBase + t.curFrame.localCount
}
needed := localBase + params + locals
if needed > len(t.locals) {
newLocals := make([]Value, needed*2)
copy(newLocals, t.locals)
t.locals = newLocals
}
// Save frame
// Handle case where fewer args were pushed than declared params
actual := t.pendingParams
if actual > params {
actual = params
}
if actual > t.sp {
actual = t.sp
}
frame := &t.calls[t.callSP]
frame.base = t.sp - actual // only actual args on stack
frame.localBase = localBase
frame.localCount = params + locals
frame.paramCount = t.pendingParams // actual args passed by caller (not declared count)
frame.retVal = MakeNil()
frame.symbol = t.pendingCallSym
t.pendingCallSym = nil
// Copy actual parameters from stack to locals
for i := 0; i < actual; i++ {
t.locals[localBase+i] = t.stack[frame.base+i]
}
// Initialize missing params and locals to NIL
for i := actual; i < params+locals; i++ {
t.locals[localBase+i] = MakeNil()
}
// Pop args from stack (they're now in locals)
t.sp = frame.base
t.curFrame = frame
t.callSP++
}
// EndProc is called via defer at the end of every function.
// Handles recover for BEGIN SEQUENCE and restores frame.
// All panics are re-panicked so the generated SEQUENCE/RECOVER handler
// can catch them. HbError + BreakValue (from Break() in hbrtl) are
// re-panicked silently; unknown panics also re-panic but with a
// diagnostic message on stderr.
func (t *Thread) EndProc() {
if r := recover(); r != nil {
t.endFrame()
if _, ok := r.(*HbError); ok {
panic(r) // HbError — re-panic silently
}
// Check for BreakValue from hbrtl.Break() via duck typing.
// We can't import hbrtl (cycle), so we check the type name.
rType := fmt.Sprintf("%T", r)
if rType == "hbrtl.BreakValue" {
panic(r) // BreakValue — re-panic silently for RECOVER USING
}
fmt.Fprintf(os.Stderr, "Five runtime error: %v [recovered, repanicked]\n", r)
panic(r)
}
t.endFrame()
}
// EndProcFast is called by RTL functions that don't need recover().
// ~3x faster than EndProc (no defer recover overhead).
func (t *Thread) EndProcFast() {
t.endFrame()
}
// endFrame restores the previous call frame.
func (t *Thread) endFrame() {
if t.callSP > 0 {
t.callSP--
if t.callSP > 0 {
t.curFrame = &t.calls[t.callSP-1]
} else {
t.curFrame = nil
}
}
}
// EndProcNoRecover cleans up the frame without recover (used by Break).
func (t *Thread) EndProcNoRecover() {
if t.callSP > 0 {
t.callSP--
if t.callSP > 0 {
t.curFrame = &t.calls[t.callSP-1]
} else {
t.curFrame = nil
}
}
}
// --- Local variable access ---
// Harbour convention: local index 1-based (1 = first param or local)
func (t *Thread) PushLocal(n int) {
idx := t.localIndex(n)
v := t.locals[idx]
if v.Type() == tByref {
t.push((*HbRefCell)(v.ptr).V)
} else {
t.push(v)
}
}
func (t *Thread) PopLocal(n int) {
idx := t.localIndex(n)
val := t.pop()
if e := t.locals[idx]; e.Type() == tByref {
(*HbRefCell)(e.ptr).V = val
} else {
t.locals[idx] = val
}
}
func (t *Thread) Local(n int) Value {
v := t.locals[t.localIndex(n)]
if v.Type() == tByref {
return (*HbRefCell)(v.ptr).V
}
return v
}
func (t *Thread) SetLocal(n int, v Value) {
idx := t.localIndex(n)
if e := t.locals[idx]; e.Type() == tByref {
(*HbRefCell)(e.ptr).V = v
} else {
t.locals[idx] = v
}
}
// Fast variants — no bounds checking (gengo guarantees valid indices).
// Byref-aware: transparently dereference/write-through RefCell.
func (t *Thread) PushLocalFast(n int) {
v := t.locals[t.curFrame.localBase+n-1]
if v.Type() == tByref {
t.push((*HbRefCell)(v.ptr).V)
} else {
t.push(v)
}
}
func (t *Thread) PopLocalFast(n int) {
idx := t.curFrame.localBase + n - 1
val := t.pop()
if e := t.locals[idx]; e.Type() == tByref {
(*HbRefCell)(e.ptr).V = val
} else {
t.locals[idx] = val
}
}
func (t *Thread) LocalFast(n int) Value {
v := t.locals[t.curFrame.localBase+n-1]
if v.Type() == tByref {
return (*HbRefCell)(v.ptr).V
}
return v
}
func (t *Thread) SetLocalFast(n int, v Value) {
idx := t.curFrame.localBase + n - 1
if e := t.locals[idx]; e.Type() == tByref {
(*HbRefCell)(e.ptr).V = v
} else {
t.locals[idx] = v
}
}
// PushLocalRef creates a shared RefCell and pushes it for @param.
// Both caller's local and callee's param point to the same cell.
func (t *Thread) PushLocalRef(n int) {
idx := t.localIndex(n)
v := t.locals[idx]
if v.Type() == tByref {
t.push(v) // already a RefCell — share it
return
}
cell := &HbRefCell{V: v}
ref := MakeByref(cell)
t.locals[idx] = ref // caller's local becomes RefCell
t.push(ref) // callee gets same RefCell
}
func (t *Thread) LocalAsString(n int) string {
return t.Local(n).AsString()
}
// EnsureLocalRef converts a local to a RefCell if it isn't one already.
// Used by closure capture to enable shared mutable access.
func (t *Thread) EnsureLocalRef(n int) {
idx := t.curFrame.localBase + n - 1
v := t.locals[idx]
if v.Type() != tByref {
cell := &HbRefCell{V: v}
t.locals[idx] = MakeByref(cell)
}
}
// LocalRaw returns the raw Value at local slot (including RefCell wrapper).
// Used by closure capture to grab the RefCell itself, not the dereferenced value.
func (t *Thread) LocalRaw(n int) Value {
return t.locals[t.curFrame.localBase+n-1]
}
// SetLocalRaw sets a local slot to the raw Value (including RefCell wrapper).
// Used by closure to inject shared RefCell into block locals.
func (t *Thread) SetLocalRaw(n int, v Value) {
t.locals[t.curFrame.localBase+n-1] = v
}
// LocalSetInt is an optimization: set local directly without stack. Byref-aware.
func (t *Thread) LocalSetInt(n int, val int) {
idx := t.localIndex(n)
if e := t.locals[idx]; e.Type() == tByref {
(*HbRefCell)(e.ptr).V = MakeInt(val)
} else {
t.locals[idx] = MakeInt(val)
}
}
func (t *Thread) localIndex(n int) int {
if t.curFrame == nil {
panic(t.runtimeError("no active frame"))
}
idx := t.curFrame.localBase + n - 1 // 1-based to 0-based
if idx < t.curFrame.localBase || idx >= t.curFrame.localBase+t.curFrame.localCount {
panic(t.runtimeError(fmt.Sprintf("local variable index out of range: %d", n)))
}
return idx
}
// --- Memvar access ---
// PushMemvar pushes a memvar value onto the stack. Harbour: M->varname
func (t *Thread) PushMemvar(name string) {
if v, ok := t.Memvars.Get(name); ok {
t.push(v)
} else {
t.push(MakeNil())
}
}
// PopMemvar pops stack and stores into a memvar. Harbour: M->varname := expr
func (t *Thread) PopMemvar(name string) {
val := t.pop()
if !t.Memvars.Set(name, val) {
// Auto-create as PRIVATE if not exists
t.Memvars.SetPrivate(name, val, t.callSP)
}
}
// DeclarePublic creates a PUBLIC memvar with NIL value.
func (t *Thread) DeclarePublic(name string) {
if !t.Memvars.Exists(name) {
t.Memvars.SetPublic(name, MakeNil())
}
}
// DeclarePrivate creates a PRIVATE memvar with NIL value.
func (t *Thread) DeclarePrivate(name string) {
t.Memvars.SetPrivate(name, MakeNil(), t.callSP)
}
// --- Return value ---
func (t *Thread) RetValue() {
t.retVal = t.pop()
}
func (t *Thread) RetInt(n int64) {
t.retVal = MakeNumInt(n)
}
func (t *Thread) RetNil() {
t.retVal = MakeNil()
}
func (t *Thread) RetString(s string) {
t.retVal = MakeString(s)
}
func (t *Thread) RetBool(b bool) {
t.retVal = MakeBool(b)
}
func (t *Thread) RetLong(n int64) {
t.retVal = MakeLong(n)
}
func (t *Thread) RetDouble(v float64, length, decimal uint16) {
t.retVal = MakeDouble(v, length, decimal)
}
func (t *Thread) RetPointer(val interface{}) {
t.retVal = MakePointer(val)
}
func (t *Thread) RetVal(v Value) {
t.retVal = v
}
// PushRetValue pushes the return value from the last call onto the stack.
func (t *Thread) PushRetValue() {
t.push(t.retVal)
}
// GetRetValue returns the current return value.
func (t *Thread) GetRetValue() Value {
return t.retVal
}
// --- Error handling ---
// HbError represents a Harbour runtime error.
type HbError struct {
Description string
Operation string
Args []Value
SubSystem string
GenCode int
Stack []DebugStackFrame // snapshot at panic site (pre-unwind)
}
func (e *HbError) Error() string {
return fmt.Sprintf("Five runtime error: %s (op: %s)", e.Description, e.Operation)
}
func (t *Thread) runtimeError(msg string) *HbError {
return &HbError{
Description: msg,
SubSystem: "BASE",
Stack: t.DebugCallStack(),
}
}
func (t *Thread) argError(op string, args ...Value) *HbError {
return &HbError{
Description: "argument error",
Operation: op,
Args: args,
SubSystem: "BASE",
GenCode: 1,
Stack: t.DebugCallStack(),
}
}
func (t *Thread) handleSequenceError(err *HbError) {
// BEGIN SEQUENCE / RECOVER: store error for RECOVER USING
t.lastError = err
// The recover block is handled by the generated code's defer/recover pattern.
// EndProc catches the panic and this function stores the error value.
}
// VM returns the VM this thread belongs to.
func (t *Thread) VM() *VM {
return t.vm
}
// ParamCount returns the number of parameters passed to the current call.
// Used by RTL functions that call ParamCount() BEFORE Frame() — returns
// pendingParams set by Function(nArgs). This is the original behavior
// that all existing RTL functions depend on.
//
// For PRG-level PCount(), use CallerParamCount() instead (via PCount RTL).
func (t *Thread) ParamCount() int {
return t.pendingParams
}
// CallerParamCount returns the param count of the calling PRG function
// (one frame below the current). Used by PCount() RTL which needs the
// caller's count, not its own.
func (t *Thread) CallerParamCount() int {
if t.callSP >= 2 {
return t.calls[t.callSP-2].paramCount
}
return 0
}
// PendingParams2 sets pending param count for direct block calls (AEval, ASort etc.)
func (t *Thread) PendingParams2(n int) {
t.pendingParams = n
}
func (t *Thread) pushPendingSym(sym *Symbol) {
// Fast path for depth=1 nesting — store in scalar slot without
// touching the slice. A nil sym (unresolved symbol, caught later
// in Function() with a descriptive error) must not use the fast
// path because `pendingSymFast == nil` already means "empty";
// falling back to the slice preserves distinguishability.
if sym != nil && t.pendingSymFast == nil && len(t.pendingSyms) == 0 {
t.pendingSymFast = sym
return
}
if t.pendingSymFast != nil {
t.pendingSyms = append(t.pendingSyms, t.pendingSymFast)
t.pendingSymFast = nil
}
t.pendingSyms = append(t.pendingSyms, sym)
}
func (t *Thread) popPendingSym() *Symbol {
if n := len(t.pendingSyms); n > 0 {
sym := t.pendingSyms[n-1]
t.pendingSyms = t.pendingSyms[:n-1]
return sym
}
if sym := t.pendingSymFast; sym != nil {
t.pendingSymFast = nil
return sym
}
return nil
}
// PushAliasField pushes a field value from a named alias workarea.
// Harbour: alias->field
func (t *Thread) PushAliasField(alias, field string) {
// Delegate to WorkAreaManager via WA interface
if t.WA != nil {
// Use reflection-free interface assertion
type aliasGetter interface {
GetAliasField(alias, field string) Value
}
if ag, ok := t.WA.(aliasGetter); ok {
t.push(ag.GetAliasField(alias, field))
return
}
}
t.push(MakeNil())
}
// PushDynAliasField pushes a field from dynamic alias: (expr)->field
func (t *Thread) PushDynAliasField(alias, field string) {
t.PushAliasField(alias, field)
}
// GetLastError returns the last error from BEGIN SEQUENCE.
func (t *Thread) GetLastError() *HbError {
return t.lastError
}
// --- STATIC variable access ---
func (t *Thread) RegisterStatics(module string, statics []Value) {
t.statics[module] = statics
}
func (t *Thread) PushStatic(module string, n int) {
statics := t.statics[module]
if n < 1 || n > len(statics) {
panic(t.runtimeError(fmt.Sprintf("static index out of range: %s[%d]", module, n)))
}
t.push(statics[n-1])
}
func (t *Thread) PopStatic(module string, n int) {
statics := t.statics[module]
if n < 1 || n > len(statics) {
panic(t.runtimeError(fmt.Sprintf("static index out of range: %s[%d]", module, n)))
}
statics[n-1] = t.pop()
}
// --- Workarea context switching for (alias)->(expr) ---
//
// The waSel interfaces below use CurrentNum() (uint16 area index), NOT
// Current() (which returns the Area interface on WorkAreaManager). An
// earlier version required `Current() uint16` which silently failed the
// type assertion on the real hbrdd.WorkAreaManager implementation —
// `alias->(expr)` expressions appeared to "work" on the first area but
// collapsed to no-op as soon as a sibling area was opened, because the
// switch/save/restore block was skipped entirely. See repro in
// /tmp/repro_xarea.prg.
func (t *Thread) WASaveAndSelect(areaNum int) {
type waSel interface {
SelectByNum(uint16)
CurrentNum() uint16
}
if wam, ok := t.WA.(waSel); ok {
t.waStack = append(t.waStack, wam.CurrentNum())
wam.SelectByNum(uint16(areaNum))
}
}
func (t *Thread) WASaveAndSelectAlias(alias string) {
type waSel interface {
SelectByAlias(string) bool
SelectByNum(uint16)
CurrentNum() uint16
}
if wam, ok := t.WA.(waSel); ok {
t.waStack = append(t.waStack, wam.CurrentNum())
if !wam.SelectByAlias(alias) {
// Alias not open: switch to the no-area sentinel so the
// inner expression's Current() returns nil and ops like
// DbCloseArea/FieldGet/RecCount short-circuit. Without
// this, the inner expression silently runs against the
// originally-selected WA — which led to `CLOSE bad_alias`
// closing the *current* area.
wam.SelectByNum(0)
}
}
}
func (t *Thread) WARestore() {
if n := len(t.waStack); n > 0 {
saved := t.waStack[n-1]
t.waStack = t.waStack[:n-1]
type waSel interface{ SelectByNum(uint16) }
if wam, ok := t.WA.(waSel); ok {
wam.SelectByNum(saved)
}
}
}