Compiling _FiveSql2/test/test_sql_extreme.prg + a sweep of the FRB
demos surfaced four real bugs in the dynamic-compilation pipeline.
All fixes shipped together because they were on the same critical
path; each is independently revertible.
* **pcode FOR loop ignored STEP and direction.** emitFor in
compiler/genpc emitted a fixed `<= to` comparison and a hardcoded
`+1` increment, then deleted the actual step expression with
slice arithmetic on the byte buffer. Result: `FOR 5 TO 1 STEP
-1` exited on the first iteration; `FOR 1 TO 10 STEP 2` summed
1..10 (55) instead of 1+3+5+7+9 (25). Rewritten to mirror
gengo's emitFor: detect negative step from a literal `-N` or
unary MINUS, pick `<=` vs `>=` accordingly, and emit a clean
`var := var + step` increment per iteration.
* **pcode compound `+=` operator stored only the RHS.** emitAssign
looked at AssignExpr.Op only for the := case; +=/-=/etc.
silently took the same path, so `n += i` compiled as `n := i`,
discarding the accumulator. Loop reduces were wrong: `Reverse`
returned "" and `n := 0; FOR i ... n += i; NEXT` returned only
the last increment. New compoundBinOp helper maps PLUSEQ /
MINUSEQ / STAREQ / SLASHEQ / PERCENTEQ / POWEREQ to their
matching binary opcode; emitAssign emits `local + rhs ; pop
local` for compound forms.
* **Pcode body stack leaks polluted the caller's frame.** A pcode
function whose body left intermediate values on the data stack
(FOR control values, etc.) returned with extra entries past
its declared retVal. FrbDoFunc / FrbExecFunc / FrbRunFunc then
pushed retVal on top of those leaks, so the caller saw the
leaked values where its own preceding arguments should have
been: `? "Fibonacci(10) =", FrbDo(...), "(expect 55)"` printed
`1 55 (expect 55)` because the FOR loop's `1` lived in arg-1's
slot. Two new Thread methods (`SP()` / `SetSP(int)`) let the
three FRB dispatchers snapshot stack depth before the inner
call and clamp it back afterward, so the leaks evaporate before
they reach the caller's frame.
* **FrbExec / FrbRun recursed into the host's Main forever.** Both
looked up "MAIN" via t.VM().FindSymbol, which always resolved
to the OUTER program's Main since FRB modules deliberately keep
Main local. Compile + run + unload became compile + recurse +
OOM. Both now look up Main via mod.FindFunc("MAIN") (module
scope) — Frbload's policy of leaving Main module-local now
actually has the intended effect.
Plus an architectural improvement: in-memory compilation no longer
depends on shelling out to an external `five` binary. New
hbrtl.frbCompileInProc parses + preprocesses + generates pcode in
process, building a FrbModule directly. FrbCompile and FrbExec use
this exclusively, which means dynamic compilation works from any
directory regardless of PATH and without a second process. The
plugin-mode path (with its runtime-version-mismatch fragility) is
left available via hbrt.FrbCompileSource for callers that want it,
but FrbCompile no longer reaches for it by default.
Test suite: tests/frb/ holds five fixtures + a runner. 5/5 pass:
test_frb_simple / test_frb_pcode_load / test_frb_compile /
test_frb_loop / test_frb_step.
Other gates green:
go test ./... : PASS
FiveSql2 SQL:1999 : 43/43
Harbour compat : 56/56
std.ch suite : 14/14
FRB suite : 5/5
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
280 lines
7.0 KiB
Go
280 lines
7.0 KiB
Go
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
|
// All rights reserved.
|
|
|
|
// FRB (Five Runtime Binary) RTL functions.
|
|
//
|
|
// PRG Usage:
|
|
// pMod := FrbLoad("module.frb") // load module
|
|
// FrbDo(pMod, "MYFUNC", args...) // call function
|
|
// FrbUnload(pMod) // unload
|
|
//
|
|
// // Or one-shot:
|
|
// result := FrbRun("module.frb", arg1, arg2)
|
|
|
|
package hbrtl
|
|
|
|
import (
|
|
"five/compiler/genpc"
|
|
"five/compiler/parser"
|
|
"five/compiler/pp"
|
|
"five/hbrt"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// frbCompileInProc compiles PRG source to a pcode FrbModule entirely
|
|
// in-process — no external `five` binary needed. Used by FrbCompile/
|
|
// FrbExec when the host can't shell out (running from a directory
|
|
// where `five` isn't on PATH and isn't next to the binary). Avoids
|
|
// the plugin-runtime-mismatch failure mode of native FRB plugins
|
|
// AND removes the "find the five exe" fragility entirely.
|
|
func frbCompileInProc(vm *hbrt.VM, prgSource string) (*hbrt.FrbModule, error) {
|
|
prep := pp.New()
|
|
processed, errs := prep.Process("dynamic.prg", prgSource)
|
|
if len(errs) > 0 {
|
|
return nil, fmt.Errorf("preprocess: %s", strings.Join(errs, "; "))
|
|
}
|
|
file, perrs := parser.Parse("dynamic.prg", processed)
|
|
if len(perrs) > 0 {
|
|
msgs := make([]string, 0, len(perrs))
|
|
for _, e := range perrs {
|
|
msgs = append(msgs, e.Error())
|
|
}
|
|
return nil, fmt.Errorf("parse: %s", strings.Join(msgs, "; "))
|
|
}
|
|
pcMod := genpc.Generate(file)
|
|
|
|
// Build a FrbModule from the pcode functions. Mirrors what
|
|
// hbrt/frb.go's frbLoadPcode does, but without the disk hop.
|
|
frbMod := &hbrt.FrbModule{
|
|
Name: "dynamic",
|
|
LocalSyms: make(map[string]*hbrt.Symbol),
|
|
OldSyms: make(map[string]*hbrt.Symbol),
|
|
BindMode: hbrt.FrbBindDefault,
|
|
VM: vm,
|
|
}
|
|
for name, fn := range pcMod.Funcs {
|
|
pcFn := fn
|
|
pcModRef := pcMod
|
|
goFunc := func(t *hbrt.Thread) {
|
|
hbrt.ExecPcode(t, pcFn, pcModRef)
|
|
}
|
|
frbMod.LocalSyms[name] = &hbrt.Symbol{
|
|
Name: name,
|
|
Scope: hbrt.FsPublic | hbrt.FsLocal,
|
|
Func: goFunc,
|
|
}
|
|
}
|
|
// Register non-Main symbols globally (Main stays module-local).
|
|
for name, sym := range frbMod.LocalSyms {
|
|
if name == "MAIN" {
|
|
continue
|
|
}
|
|
old := vm.FindSymbol(name)
|
|
if old != nil {
|
|
frbMod.OldSyms[name] = old
|
|
continue
|
|
}
|
|
vm.RegisterSymbol(sym)
|
|
frbMod.Registered = append(frbMod.Registered, name)
|
|
}
|
|
return frbMod, nil
|
|
}
|
|
|
|
// findFiveExe locates the 'five' compiler binary
|
|
func findFiveExe() string {
|
|
// 1. Check same directory as running executable
|
|
if exe, err := os.Executable(); err == nil {
|
|
dir := filepath.Dir(exe)
|
|
fiveExe := filepath.Join(dir, "five")
|
|
if _, err := os.Stat(fiveExe); err == nil {
|
|
return fiveExe
|
|
}
|
|
}
|
|
// 2. Check PATH
|
|
if p, err := exec.LookPath("five"); err == nil {
|
|
return p
|
|
}
|
|
// 3. Check current directory
|
|
if _, err := os.Stat("./five"); err == nil {
|
|
abs, _ := filepath.Abs("./five")
|
|
return abs
|
|
}
|
|
return "five" // hope it's in PATH
|
|
}
|
|
|
|
// FRBLOAD(cFileName) → pModule
|
|
func FrbLoadFunc(t *hbrt.Thread) {
|
|
t.Frame(1, 0)
|
|
defer t.EndProc()
|
|
|
|
filename := t.Local(1).AsString()
|
|
mod, err := hbrt.FrbLoad(t.VM(), filename)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "FrbLoad error: %v\n", err)
|
|
t.RetNil()
|
|
return
|
|
}
|
|
t.RetPointer(mod)
|
|
}
|
|
|
|
// FRBDO(pModule, cFuncName [, args...]) → xResult
|
|
func FrbDoFunc(t *hbrt.Thread) {
|
|
nParams := t.ParamCount()
|
|
t.Frame(nParams, 0)
|
|
defer t.EndProc()
|
|
|
|
modVal := t.Local(1)
|
|
funcName := strings.ToUpper(t.Local(2).AsString())
|
|
|
|
// Look up function: module-local scope first, then VM global
|
|
var fn func(*hbrt.Thread)
|
|
if modVal.IsPointer() {
|
|
if mod, ok := modVal.AsPointer().(*hbrt.FrbModule); ok {
|
|
fn = mod.FindFunc(funcName)
|
|
}
|
|
}
|
|
if fn == nil {
|
|
sym := t.VM().FindSymbol(funcName)
|
|
if sym != nil {
|
|
fn = sym.Func
|
|
}
|
|
}
|
|
if fn == nil {
|
|
t.RetNil()
|
|
return
|
|
}
|
|
|
|
// Snapshot SP *before* pushing args. After the inner call,
|
|
// Frame()/PcOpRetValue should have left SP back at this baseline,
|
|
// but pcode-mode bodies can occasionally leak intermediate stack
|
|
// values (e.g. FOR-loop control vestiges). Reseating SP to the
|
|
// snapshot before reading retVal stops those leaks from polluting
|
|
// the caller's argument frame — which is what made
|
|
// `? "label", FrbDo(...), "tail"` show "1" or "2" in place of the
|
|
// label string when the inner function had a loop.
|
|
savedSP := t.SP()
|
|
|
|
// Push args for the function
|
|
for i := 3; i <= nParams; i++ {
|
|
t.PushValue(t.Local(i))
|
|
}
|
|
t.PendingParams2(nParams - 2)
|
|
fn(t)
|
|
|
|
t.SetSP(savedSP)
|
|
t.PushValue(t.GetRetValue())
|
|
t.RetValue()
|
|
}
|
|
|
|
// FRBUNLOAD(pModule) → NIL
|
|
func FrbUnloadFunc(t *hbrt.Thread) {
|
|
t.Frame(1, 0)
|
|
defer t.EndProc()
|
|
|
|
v := t.Local(1)
|
|
if !v.IsNil() && v.IsPointer() {
|
|
if mod, ok := v.AsPointer().(*hbrt.FrbModule); ok {
|
|
hbrt.FrbUnload(mod)
|
|
}
|
|
}
|
|
t.RetNil()
|
|
}
|
|
|
|
// FRBCOMPILE(cPrgSource) → pModule
|
|
// Compile PRG source string to FRB module in memory. In-process pcode
|
|
// compilation is the default — no external `five` binary or `go`
|
|
// toolchain needed at runtime. The legacy native-plugin path is still
|
|
// reachable via hbrt.FrbCompileSource for callers that want it, but
|
|
// that path is fragile (Go plugins require byte-identical runtime).
|
|
func FrbCompileFunc(t *hbrt.Thread) {
|
|
t.Frame(1, 0)
|
|
defer t.EndProc()
|
|
|
|
source := t.Local(1).AsString()
|
|
mod, err := frbCompileInProc(t.VM(), source)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "FrbCompile error: %v\n", err)
|
|
t.RetNil()
|
|
return
|
|
}
|
|
t.RetPointer(mod)
|
|
}
|
|
|
|
// FRBEXEC(cPrgSource [, args...]) → xResult
|
|
// Compile PRG source, run Main(), unload — all in one call.
|
|
func FrbExecFunc(t *hbrt.Thread) {
|
|
nParams := t.ParamCount()
|
|
t.Frame(nParams, 0)
|
|
defer t.EndProc()
|
|
|
|
source := t.Local(1).AsString()
|
|
mod, err := frbCompileInProc(t.VM(), source)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "FrbExec error: %v\n", err)
|
|
t.RetNil()
|
|
return
|
|
}
|
|
defer hbrt.FrbUnload(mod)
|
|
|
|
// Look up MAIN inside the freshly-compiled module first, NOT
|
|
// via t.VM().FindSymbol — Main is intentionally kept module-local
|
|
// (frbLoadPcode skips it during VM registration), so a global
|
|
// lookup would resolve to the *caller's* Main and recurse forever.
|
|
fn := mod.FindFunc("MAIN")
|
|
if fn == nil {
|
|
t.RetNil()
|
|
return
|
|
}
|
|
|
|
savedSP := t.SP()
|
|
for i := 2; i <= nParams; i++ {
|
|
t.PushValue(t.Local(i))
|
|
}
|
|
t.PendingParams2(nParams - 1)
|
|
fn(t)
|
|
t.SetSP(savedSP)
|
|
|
|
t.PushValue(t.GetRetValue())
|
|
t.RetValue()
|
|
}
|
|
|
|
// FRBRUN(cFileName [, args...]) → xResult
|
|
// Load, execute startup function, unload — all in one call.
|
|
func FrbRunFunc(t *hbrt.Thread) {
|
|
nParams := t.ParamCount()
|
|
t.Frame(nParams, 0)
|
|
defer t.EndProc()
|
|
|
|
filename := t.Local(1).AsString()
|
|
mod, err := hbrt.FrbLoad(t.VM(), filename)
|
|
if err != nil {
|
|
t.RetNil()
|
|
return
|
|
}
|
|
defer hbrt.FrbUnload(mod)
|
|
|
|
// Same module-local Main lookup as FrbExec — see comment there
|
|
// for why a t.VM().FindSymbol("MAIN") would recurse into the
|
|
// outer (caller's) Main.
|
|
fn := mod.FindFunc("MAIN")
|
|
if fn == nil {
|
|
t.RetNil()
|
|
return
|
|
}
|
|
|
|
savedSP := t.SP()
|
|
for i := 2; i <= nParams; i++ {
|
|
t.PushValue(t.Local(i))
|
|
}
|
|
t.PendingParams2(nParams - 1)
|
|
fn(t)
|
|
t.SetSP(savedSP)
|
|
|
|
t.PushValue(t.GetRetValue())
|
|
t.RetValue()
|
|
}
|