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

@@ -24,14 +24,34 @@ import (
)
// FrbCompileSource compiles PRG source code to an FRB module in memory.
// If Go compiler is available, uses native plugin mode.
// If not, falls back to pcode interpreter mode (--pcode).
// Strategy:
// 1. If Go isn't installed, go straight to pcode mode.
// 2. Try the native Go-plugin path first (faster, native speed).
// 3. If the plugin build fails, OR if loading the resulting plugin
// fails (the most common failure: "plugin was built with a
// different version of package runtime", which fires whenever
// the host binary and the plugin weren't compiled byte-for-byte
// against the same Go runtime — happens routinely after `go
// build` rebuilds), fall back to pcode mode. Pcode is a few x
// slower but always works.
func FrbCompileSource(vm *VM, prgSource string, fiveExe string) (*FrbModule, error) {
// Check if Go is available
if !isGoAvailable() {
return frbCompilePcode(vm, prgSource, fiveExe)
}
mod, err := frbCompilePlugin(vm, prgSource, fiveExe)
if err == nil {
return mod, nil
}
// Plugin path failed — try pcode. Don't surface the plugin
// error: pcode either works (return its module) or doesn't
// (return its error).
return frbCompilePcode(vm, prgSource, fiveExe)
}
// frbCompilePlugin is the original native-plugin path, factored out
// of FrbCompileSource so the latter can pick a fallback on failure.
func frbCompilePlugin(vm *VM, prgSource string, fiveExe string) (*FrbModule, error) {
tmpDir, err := os.MkdirTemp("", "frb-mem-*")
if err != nil {
return nil, err

View File

@@ -219,6 +219,30 @@ 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.