js/pool.go

483 lines
12 KiB
Go

package js
import (
"context"
"fmt"
"reflect"
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"apigo.cc/go/jsmod"
"apigo.cc/go/log"
"github.com/dop251/goja"
)
type scriptEntry struct {
name string
code string
version int64
}
type vmInstance struct {
runtime *goja.Runtime
version int32
}
// Pool represents an isolated JS execution environment with its own script registry and VM pool.
type Pool struct {
version int32
scripts []*scriptEntry
scriptMap map[string]*scriptEntry
functions map[string]struct{}
mu sync.RWMutex
pool sync.Pool
// Lifecycle management
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
closed int32
}
// NewPool creates a new isolated JS execution environment.
func NewPool() *Pool {
p := &Pool{
scriptMap: make(map[string]*scriptEntry),
functions: make(map[string]struct{}),
}
p.ctx, p.cancel = context.WithCancel(context.Background())
p.pool = sync.Pool{
New: func() any {
return &vmInstance{
runtime: createNewRuntime(),
version: 0,
}
},
}
return p
}
// DefaultPool is the global singleton pool.
var DefaultPool = NewPool()
func createNewRuntime() *goja.Runtime {
vm := goja.New()
// 1. Inject Native Modules from jsmod
goObj := vm.NewObject()
_ = vm.Set("go", goObj)
modules := jsmod.GetModules()
for modName, mod := range modules {
modObj := vm.NewObject()
for name, val := range mod.Exports {
isUnsafe := mod.UnsafeList[name]
if val != nil && reflect.TypeOf(val).Kind() == reflect.Func {
_ = modObj.Set(name, wrapGoFunc(vm, val, isUnsafe))
} else {
_ = modObj.Set(name, vm.ToValue(val))
}
}
_ = goObj.Set(modName, modObj)
_ = vm.Set(modName, modObj) // Also inject into global
}
return vm
}
// --- Error ---
// Error wraps a JS execution error with merged call stacks from JS and Go,
// ordered from innermost (JS error site) to outermost (Go cause chain).
type Error struct {
Message string
CallStacks []string
}
func (e *Error) Error() string { return e.Message }
// --- Validation ---
var namedFuncRe = regexp.MustCompile(`\bfunction\s+[a-zA-Z$_]`)
func validateCode(name, code string) error {
if strings.TrimSpace(code) == "" {
return fmt.Errorf("js.Define [%s]: code must not be empty", name)
}
if namedFuncRe.MatchString(code) {
return fmt.Errorf("js.Define [%s]: named function declarations are not allowed, use an anonymous function expression", name)
}
return nil
}
// --- Define ---
// Define registers a JS function identified by name. code must be an anonymous
// function expression (arrow or function literal). version is used by
// CheckVersion for cache invalidation.
func (p *Pool) Define(name string, code string, version int64) error {
if name == "" {
return fmt.Errorf("js.Define: name must not be empty")
}
if err := validateCode(name, code); err != nil {
return err
}
wrapped := fmt.Sprintf("globalThis['%s'] = (%s);", name, code)
p.mu.Lock()
defer p.mu.Unlock()
entry := &scriptEntry{name: name, code: wrapped, version: version}
p.scripts = append(p.scripts, entry)
p.scriptMap[name] = entry
p.functions[name] = struct{}{}
atomic.AddInt32(&p.version, 1)
return nil
}
// Define is the global convenience wrapper.
func Define(name string, code string, version int64) error {
return DefaultPool.Define(name, code, version)
}
// --- CheckVersion ---
func (p *Pool) CheckVersion(name string, version int64) bool {
p.mu.RLock()
defer p.mu.RUnlock()
if entry, ok := p.scriptMap[name]; ok {
return entry.version >= version
}
return false
}
func CheckVersion(name string, version int64) bool {
return DefaultPool.CheckVersion(name, version)
}
// --- Error formatting ---
// parseJSFrame parses a single stack trace line from Goja.
// Format is:
// named: \tat funcName (src:line:col) (optionalPC)
// anon: \tat src:line:col (optionalPC)
func parseJSFrame(line string) (src, lineNum, col string, ok bool) {
if !strings.HasPrefix(line, "\t") {
return "", "", "", false
}
// Strip leading tab
line = line[1:]
if !strings.HasPrefix(line, "at ") {
return "", "", "", false
}
// Strip "at "
line = line[3:]
// Strip PC suffix like " (12)" or "(12)" at the end of the line
if idx := strings.LastIndexByte(line, '('); idx != -1 && strings.HasSuffix(line, ")") {
inner := line[idx+1 : len(line)-1]
isPC := true
for i := 0; i < len(inner); i++ {
if inner[i] < '0' || inner[i] > '9' {
isPC = false
break
}
}
if isPC && len(inner) > 0 {
if idx > 0 && line[idx-1] == ' ' {
line = line[:idx-1]
} else {
line = line[:idx]
}
}
}
// Try named format first: "funcName (src:line:col)"
if idx := strings.Index(line, " ("); idx != -1 && strings.HasSuffix(line, ")") {
srcLineCol := line[idx+2 : len(line)-1]
return parseSrcLineCol(srcLineCol)
}
// Try anonymous format: "src:line:col"
return parseSrcLineCol(line)
}
func parseSrcLineCol(s string) (src, lineNum, col string, ok bool) {
colIdx := strings.LastIndexByte(s, ':')
if colIdx == -1 {
return "", "", "", false
}
lineIdx := strings.LastIndexByte(s[:colIdx], ':')
if lineIdx == -1 {
return "", "", "", false
}
return s[:lineIdx], s[lineIdx+1 : colIdx], s[colIdx+1:], true
}
// buildJSError constructs an *Error from a raw goja error, extracting
// JS stack frames and Go cause chain into CallStacks (inner to outer).
func buildJSError(funcName string, err error) *Error {
if err == nil {
return nil
}
if exc, ok := err.(*goja.Exception); ok {
return buildExceptionError(funcName, exc)
}
if intErr, ok := err.(*goja.InterruptedError); ok {
return &Error{Message: intErr.String()}
}
return &Error{Message: err.Error()}
}
func buildExceptionError(funcName string, exc *goja.Exception) *Error {
// 1. Extract the pure error description for Message.
// Goja produces "Error: msg" or "GoError: [func at file:line] msg"
errorSummary := exc.Value().String()
errorSummary = strings.TrimPrefix(errorSummary, "GoError: ")
goFileLine := ""
if strings.HasPrefix(errorSummary, "[") {
if idx := strings.Index(errorSummary, "] "); idx != -1 {
goTag := errorSummary[1:idx] // "funcName at file:line"
errorSummary = errorSummary[idx+2:]
if pos := strings.LastIndex(goTag, " at "); pos != -1 {
goFileLine = goTag[pos+4:] // just "file:line"
}
}
}
// 2. Parse JS stack frames: keep only source:line:col, skip bridge internals.
s := exc.String()
var jsFrames []string
lines := strings.Split(s, "\n")
for _, line := range lines {
src, lineno, col, ok := parseJSFrame(line)
if !ok {
continue
}
if src == "native" || strings.Contains(src, "apigo.cc/go/js.") || strings.Contains(src, "apigo.cc/go/js/") {
continue
}
jsFrames = append(jsFrames, fmt.Sprintf("%s:%s:%s", src, lineno, col))
}
// Find if there is a *jsmod.Error in the error chain
var jsmodErr *jsmod.Error
if unwrapped := exc.Unwrap(); unwrapped != nil {
for curr := unwrapped; curr != nil; {
if je, ok := curr.(*jsmod.Error); ok {
jsmodErr = je
break
}
if u, ok := curr.(interface{ Unwrap() error }); ok {
curr = u.Unwrap()
} else {
break
}
}
}
// 3. Build CallStacks: Go cause first (root), then JS frames (effect)
var callStacks []string
// Only prepend the static goFileLine if we do not have a dynamic jsmodErr.
// This removes redundant or inaccurate entry-point lines from the stack.
if jsmodErr == nil && goFileLine != "" {
callStacks = append(callStacks, goFileLine)
}
callStacks = append(callStacks, jsFrames...)
// 4. For errors/panics: append Go caller stacks if available
if unwrapped := exc.Unwrap(); unwrapped != nil {
if jsmodErr != nil {
callStacks = append(callStacks, jsmodErr.CallStacks...)
} else if stackErr, ok := unwrapped.(interface{ Stack() string }); ok {
for _, frame := range strings.Split(stackErr.Stack(), "\n") {
frame = strings.TrimSpace(frame)
if frame == "" || strings.HasPrefix(frame, "panic(") || strings.HasPrefix(frame, ">") {
continue
}
// Extract file:line from Go trace format "/path/to/file.go:123"
if idx := strings.Index(frame, ".go:"); idx != -1 {
end := idx + len(".go:")
for end < len(frame) && frame[end] >= '0' && frame[end] <= '9' {
end++
}
callStacks = append(callStacks, frame[:end])
}
}
}
}
return &Error{
Message: errorSummary,
CallStacks: callStacks,
}
}
// --- Call ---
// Call executes a JS function from the pool.
// On error, returns *Error with merged JS + Go call stacks.
func (p *Pool) Call(funcName string, timeout time.Duration, injects map[string]any, args ...any) (any, *Error) {
if atomic.LoadInt32(&p.closed) == 1 {
return nil, &Error{Message: "js.Pool: pool is closed"}
}
instance := p.pool.Get().(*vmInstance)
defer p.pool.Put(instance)
// Tracking active calls for graceful shutdown
p.wg.Add(1)
defer p.wg.Done()
vm := instance.runtime
vm.ClearInterrupt()
// 1. Synchronize scripts if version is behind
currentVersion := atomic.LoadInt32(&p.version)
if instance.version < currentVersion {
p.mu.RLock()
for i := int(instance.version); i < len(p.scripts); i++ {
_, err := vm.RunScript(p.scripts[i].name, p.scripts[i].code)
if err != nil {
p.mu.RUnlock()
syncErr := buildJSError("", err)
return nil, &Error{
Message: fmt.Sprintf("js.sync error [%s]", p.scripts[i].name),
CallStacks: syncErr.CallStacks,
}
}
}
instance.version = currentVersion
p.mu.RUnlock()
}
// 2. Prepare Context
execCtx := jsmod.NewContext(p.ctx, injects)
var cancel context.CancelFunc
if timeout > 0 {
execCtx, cancel = context.WithTimeout(execCtx, timeout)
defer cancel()
}
// 3. Set VM environment
_ = vm.Set("__ctx__", vm.ToValue(execCtx))
// 4. Set up interruption
stopInterrupter := make(chan struct{})
defer close(stopInterrupter)
go func() {
select {
case <-execCtx.Done():
reason := "execution timeout/canceled"
if p.ctx.Err() != nil {
reason = "application stopping"
}
vm.Interrupt(reason)
case <-stopInterrupter:
}
}()
// 5. Get and Call JS Function
fnVal := vm.Get(funcName)
if fnVal == nil || goja.IsUndefined(fnVal) {
return nil, &Error{Message: fmt.Sprintf("js.Call: function '%s' not found", funcName)}
}
callable, ok := goja.AssertFunction(fnVal)
if !ok {
return nil, &Error{Message: fmt.Sprintf("js.Call: '%s' is not a function", funcName)}
}
jsArgs := make([]goja.Value, len(args))
for i, arg := range args {
jsArgs[i] = vm.ToValue(arg)
}
// 6. Execution with error capture
var result goja.Value
var err error
func() {
defer func() {
if r := recover(); r != nil {
if exc, ok := r.(*goja.Exception); ok {
err = exc
} else if intErr, ok := r.(*goja.InterruptedError); ok {
err = intErr
} else {
err = fmt.Errorf("js panic: %v", r)
}
}
}()
result, err = callable(goja.Undefined(), jsArgs...)
}()
if err != nil {
return nil, buildJSError(funcName, err)
}
return result.Export(), nil
}
// Call is the global convenience wrapper.
func Call(funcName string, timeout time.Duration, injects map[string]any, args ...any) (any, *Error) {
return DefaultPool.Call(funcName, timeout, injects, args...)
}
// --- Starter Interface Implementation ---
func (p *Pool) Start(ctx context.Context, logger *log.Logger) error {
if p.ctx.Err() != nil {
p.ctx, p.cancel = context.WithCancel(context.Background())
}
atomic.StoreInt32(&p.closed, 0)
return nil
}
func (p *Pool) Stop(ctx context.Context) error {
atomic.StoreInt32(&p.closed, 1)
p.cancel()
done := make(chan struct{})
go func() {
p.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return fmt.Errorf("js.Pool: shutdown timeout, active tasks may still be running")
}
}
func (p *Pool) Status() (string, error) {
p.mu.RLock()
defer p.mu.RUnlock()
return fmt.Sprintf("scripts: %d, functions: %d, version: %d, closed: %v", len(p.scripts), len(p.functions), p.version, atomic.LoadInt32(&p.closed) == 1), nil
}
// FuncList returns the list of all registered function names.
func (p *Pool) FuncList() []string {
p.mu.RLock()
defer p.mu.RUnlock()
list := make([]string, 0, len(p.scriptMap))
for name := range p.scriptMap {
list = append(list, name)
}
sort.Strings(list)
return list
}
func FuncList() []string {
return DefaultPool.FuncList()
}