Files
five/hbrt/frbmem.go
CharlesKWON efb615bed9 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>
2026-05-02 10:25:35 +09:00

253 lines
7.1 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
// FRB in-memory compilation — compile PRG source at runtime and execute.
// This is Five's equivalent of Harbour's hb_compileFromBuf() + hb_hrbRun().
//
// Usage from PRG:
// pMod := FrbCompile(cPrgSource) // compile PRG string → FRB in memory
// result := FrbDo(pMod, "MYFUNC", args) // call compiled function
// FrbUnload(pMod)
//
// // Or one-shot:
// result := FrbExec(cPrgSource) // compile + run Main() + unload
package hbrt
import (
"encoding/binary"
"fmt"
"os"
"os/exec"
"path/filepath"
"plugin"
)
// FrbCompileSource compiles PRG source code to an FRB module in memory.
// 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) {
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
}
// Write PRG source to temp file with unique name
prgFile := filepath.Join(tmpDir, fmt.Sprintf("dynamic_%d.prg", frbSeq))
frbSeq++
if err := os.WriteFile(prgFile, []byte(prgSource), 0644); err != nil {
os.RemoveAll(tmpDir)
return nil, err
}
// Find five executable
if fiveExe == "" {
fiveExe, _ = os.Executable()
}
// Compile PRG → FRB using five frb command
frbFile := filepath.Join(tmpDir, "dynamic.frb")
cmd := exec.Command(fiveExe, "frb", prgFile, "-o", frbFile)
if output, err := cmd.CombinedOutput(); err != nil {
os.RemoveAll(tmpDir)
return nil, fmt.Errorf("compile failed: %s\n%w", string(output), err)
}
// Load FRB
mod, err := FrbLoad(vm, frbFile)
if err != nil {
os.RemoveAll(tmpDir)
return nil, err
}
// Override TempDir to clean up everything
mod.TempDir = tmpDir
return mod, nil
}
// FrbCompileDirect compiles PRG source directly to a Go plugin without
// going through the five CLI. Uses the compiler packages directly.
// This is faster than FrbCompileSource for hot compilation.
func FrbCompileDirect(vm *VM, prgSource string) (*FrbModule, error) {
tmpDir, err := os.MkdirTemp("", "frb-direct-*")
if err != nil {
return nil, err
}
// We need the Five project root for go.mod replace directive
fiveRoot := findFiveRoot()
if fiveRoot == "" {
os.RemoveAll(tmpDir)
return nil, fmt.Errorf("cannot find Five project root (go.mod)")
}
// Write Go source — import compiler packages inline
// This uses exec to run a helper that does the compilation
helperSrc := fmt.Sprintf(`package main
import (
"five/compiler/gengo"
"five/compiler/parser"
"five/compiler/pp"
"fmt"
"os"
)
func main() {
source := %q
pre := pp.New()
processed, _ := pre.Process("dynamic.prg", source)
file, errs := parser.Parse("dynamic.prg", processed)
if len(errs) > 0 {
for _, e := range errs { fmt.Fprintln(os.Stderr, e) }
os.Exit(1)
}
goSrc := gengo.GenerateLibrary(file)
fmt.Print(goSrc)
}
`, prgSource)
helperFile := filepath.Join(tmpDir, "helper.go")
os.WriteFile(helperFile, []byte(helperSrc), 0644)
// Write go.mod for helper
goMod := fmt.Sprintf("module frbhelper\n\ngo 1.21\n\nrequire five v0.0.0\nreplace five => %s\n", fiveRoot)
os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goMod), 0644)
// Run go mod tidy + generate
tidyCmd := exec.Command("go", "mod", "tidy")
tidyCmd.Dir = tmpDir
tidyCmd.CombinedOutput()
genCmd := exec.Command("go", "run", "helper.go")
genCmd.Dir = tmpDir
goSrcBytes, err := genCmd.Output()
if err != nil {
os.RemoveAll(tmpDir)
return nil, fmt.Errorf("codegen failed: %w", err)
}
// Write generated module.go
os.WriteFile(filepath.Join(tmpDir, "module.go"), goSrcBytes, 0644)
os.Remove(helperFile) // remove helper, keep module.go
// Build plugin
soFile := filepath.Join(tmpDir, "module.so")
buildCmd := exec.Command("go", "build", "-buildmode=plugin", "-o", soFile, "module.go")
buildCmd.Dir = tmpDir
if output, err := buildCmd.CombinedOutput(); err != nil {
os.RemoveAll(tmpDir)
return nil, fmt.Errorf("plugin build failed: %s\n%w", string(output), err)
}
// Load plugin
p, err := plugin.Open(soFile)
if err != nil {
os.RemoveAll(tmpDir)
return nil, fmt.Errorf("plugin load failed: %w", err)
}
vm.RegisterLibModules()
return &FrbModule{
Name: "<dynamic>",
Plugin: p,
TempDir: tmpDir,
}, nil
}
// findFiveRoot locates the Five project root by searching for go.mod
func findFiveRoot() string {
// Try executable location first
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
for d := dir; ; d = filepath.Dir(d) {
if _, err := os.Stat(filepath.Join(d, "go.mod")); err == nil {
return d
}
if d == filepath.Dir(d) {
break
}
}
}
// Try current directory
if cwd, err := os.Getwd(); err == nil {
for d := cwd; ; d = filepath.Dir(d) {
if _, err := os.Stat(filepath.Join(d, "go.mod")); err == nil {
return d
}
if d == filepath.Dir(d) {
break
}
}
}
return ""
}
var frbSeq int // sequence number for unique module names
// isGoAvailable checks if the Go compiler is installed.
func isGoAvailable() bool {
for _, p := range []string{"go", "/usr/local/go/bin/go", "/usr/bin/go"} {
if _, err := exec.LookPath(p); err == nil {
return true
}
if _, err := os.Stat(p); err == nil {
return true
}
}
return false
}
// frbCompilePcode compiles PRG source to pcode FRB (no Go needed).
func frbCompilePcode(vm *VM, prgSource string, fiveExe string) (*FrbModule, error) {
tmpDir, err := os.MkdirTemp("", "frb-pcode-*")
if err != nil {
return nil, err
}
prgFile := filepath.Join(tmpDir, fmt.Sprintf("dynamic_%d.prg", frbSeq))
frbSeq++
os.WriteFile(prgFile, []byte(prgSource), 0644)
frbFile := filepath.Join(tmpDir, "dynamic.frb")
cmd := exec.Command(fiveExe, "frb", prgFile, "-o", frbFile, "--pcode")
if output, err := cmd.CombinedOutput(); err != nil {
os.RemoveAll(tmpDir)
return nil, fmt.Errorf("pcode compile failed: %s\n%w", string(output), err)
}
mod, err := FrbLoad(vm, frbFile)
if err != nil {
os.RemoveAll(tmpDir)
return nil, err
}
mod.TempDir = tmpDir
return mod, nil
}
var _ = binary.LittleEndian // keep import