fix(frb,genpc): in-process compile + 4 pcode bugs

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>
This commit is contained in:
2026-05-02 10:25:35 +09:00
parent 3ce0eceed5
commit efb615bed9
11 changed files with 429 additions and 36 deletions

View File

@@ -14,6 +14,9 @@
package hbrtl
import (
"five/compiler/genpc"
"five/compiler/parser"
"five/compiler/pp"
"five/hbrt"
"fmt"
"os"
@@ -22,6 +25,65 @@ import (
"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
@@ -86,6 +148,16 @@ func FrbDoFunc(t *hbrt.Thread) {
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))
@@ -93,6 +165,7 @@ func FrbDoFunc(t *hbrt.Thread) {
t.PendingParams2(nParams - 2)
fn(t)
t.SetSP(savedSP)
t.PushValue(t.GetRetValue())
t.RetValue()
}
@@ -112,14 +185,17 @@ func FrbUnloadFunc(t *hbrt.Thread) {
}
// FRBCOMPILE(cPrgSource) → pModule
// Compile PRG source string to FRB module in memory.
// 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()
fiveExe := findFiveExe()
mod, err := hbrt.FrbCompileSource(t.VM(), source, fiveExe)
mod, err := frbCompileInProc(t.VM(), source)
if err != nil {
fmt.Fprintf(os.Stderr, "FrbCompile error: %v\n", err)
t.RetNil()
@@ -136,8 +212,7 @@ func FrbExecFunc(t *hbrt.Thread) {
defer t.EndProc()
source := t.Local(1).AsString()
fiveExe := findFiveExe()
mod, err := hbrt.FrbCompileSource(t.VM(), source, fiveExe)
mod, err := frbCompileInProc(t.VM(), source)
if err != nil {
fmt.Fprintf(os.Stderr, "FrbExec error: %v\n", err)
t.RetNil()
@@ -145,18 +220,23 @@ func FrbExecFunc(t *hbrt.Thread) {
}
defer hbrt.FrbUnload(mod)
// Find and execute MAIN
sym := t.VM().FindSymbol("MAIN")
if sym == nil || sym.Func == nil {
// 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)
sym.Func(t)
fn(t)
t.SetSP(savedSP)
t.PushValue(t.GetRetValue())
t.RetValue()
@@ -177,19 +257,22 @@ func FrbRunFunc(t *hbrt.Thread) {
}
defer hbrt.FrbUnload(mod)
// Find MAIN symbol
sym := t.VM().FindSymbol("MAIN")
if sym == nil || sym.Func == nil {
// 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
}
// Push args
savedSP := t.SP()
for i := 2; i <= nParams; i++ {
t.PushValue(t.Local(i))
}
t.PendingParams2(nParams - 1)
sym.Func(t)
fn(t)
t.SetSP(savedSP)
t.PushValue(t.GetRetValue())
t.RetValue()