Files
jscriptx/script.go
T
what 1f25425762 refactor: 取 VM 的三条路径合一,newVM 成为唯一入口
borrow(池化)、ensure(实例)、borrowStatic(静态)各自写了一遍「取作用域 →
拼 extra → 建 Runtime → load」。borrowStatic 甚至把 newVM 逐行抄了一遍,只为把
noInstance 传成 true。

结果就是规则会漏:上一个 commit 修的「borrowStatic 没标 scoped」正是这么来的。
现在三条都走 newVM,作用域注入和 scoped 标记只有一份。

顺带删掉两处无意义的间接:

  loadInto     s.load(ctx, rt, ctorArgs, false) 的单行包装,只被 newVM 调一次
  newRuntime   它的 scope 参数两个调用点都传 s.name,而注释描述的「会话 VM 用
               会话 key」这条路径根本没实现(作用域 key 走的是 vmGlobals)。
               参数名还跟同包的 type scope struct 撞名

**scope 是显式参数,不是从 ctx 嗅的**——这一点差点被我改坏:Instance 重建 VM
时要回到它**创建时**那个作用域,而不是当次调用 ctx 里的。对一个作用域实例调
Call(context.Background()) 不该把它的扩展弄丢。加了
TestScope_实例重建VM后仍在原作用域 守这条,用超时逼出 VM 重建(第一版用抛异常,
那不会丢 VM,两种写法都通过,等于没测)。
2026-09-10 15:25:12 +08:00

326 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package jscriptx
import (
"context"
"fmt"
"sync/atomic"
"github.com/dop251/goja"
)
// DefaultFunc 传给 Call/CallInto/WithCall 的 fn 参数时,表示脚本的默认导出本身,
// 也就是 `export default function ...` 这种"整个脚本就是一个函数"的写法。
// 默认导出是 class 或对象时用不上它——那种要按方法名调用。
const DefaultFunc = ""
// Script 是一份编译好的脚本,内部维护一个 VM 池。它并发安全,可以长期持有。
//
// 一个 Script 对应一份不可变的编译产物;热更新时 Engine 会造一个新的 Script
// 顶替它,已经拿着旧 Script 的调用不受影响,会继续跑完旧版本。
type Script struct {
engine *Engine
name string
version string
prog *goja.Program
pool chan *vmHandle
closed atomic.Bool
created atomic.Int64 // 累计新建过多少个 VM
dropped atomic.Int64 // 累计丢弃过多少个 VM(超时/panic/池满)
}
// vmHandle 是一个 VM:一个 goja.Runtime 加上它跑完脚本后的求值结果。
// Runtime 不是并发安全的,同一时刻只能有一个 goroutine 持有它。
//
// 一个 VM 只跑一个脚本。会话下多个脚本要共享数据时靠 store(Go 侧对象,注入到各个 VM
// 里的是同一份引用),而不是共用 Runtime——共用 Runtime 会让会话内的所有脚本被迫串行。
type vmHandle struct {
rt *goja.Runtime
defFn goja.Value // 脚本求值出的函数(单函数入口写法)
exports *goja.Object // 脚本求值出的对象(class 实例写法),方法调用时绑定为 this
ctor *goja.Object // 导出的 class 本身。静态方法挂在它上面,实例的原型链上没有
// scoped 表示这个 VM 注入过某个作用域的扩展,因此**不能**回池给别的作用域用。
// 用完直接丢,见 release。
scoped bool
}
// Name 返回脚本名。
func (s *Script) Name() string { return s.name }
// Version 返回脚本版本号(Loader 给的,或者源码哈希)。
func (s *Script) Version() string { return s.version }
// Stats 是 VM 池的运行统计,用于观测。
type Stats struct {
Pooled int // 池里闲置的 VM 数
MaxVMs int // 池容量
Created int64 // 累计新建
Dropped int64 // 累计丢弃
}
// Stats 返回 VM 池的当前统计。
func (s *Script) Stats() Stats {
return Stats{
Pooled: len(s.pool),
MaxVMs: cap(s.pool),
Created: s.created.Load(),
Dropped: s.dropped.Load(),
}
}
// Has 判断脚本导出的实例上有没有这个方法(继承来的也算)。
func (s *Script) Has(fn string) bool {
inst, err := s.borrow(context.Background())
if err != nil {
return false
}
defer s.release(inst, true)
_, _, _, ok := inst.lookup(fn)
return ok
}
// Close 释放池里所有 VM。之后的调用会返回 ErrClosed。
// 已经借出去、正在执行的调用不受影响,它们的 VM 归还时直接丢弃。
//
// New 出来的实例不在这条链上——它们的生命周期归调用方,Close 之后那些实例的调用
// 会因为脚本已关闭而报错,VM 等 GC 回收。
func (s *Script) Close() {
if s.closed.Swap(true) {
return
}
for {
select {
case <-s.pool:
s.dropped.Add(1)
default:
return
}
}
}
// borrow 从池里取一个 VM;池空就新建一个。
//
// 刻意不阻塞等待:并发量超过池容量时宁可临时多造几个 VM,也不让请求在这里排队。
// 池只是复用缓存,不承担限流职责。
//
// ctx 带了作用域时**不走池**:池是所有调用共享的,而作用域的扩展是这一次调用专属的
// (store、当前请求……)。混用要么让脚本看不见扩展(顶层 import 直接 ReferenceError),
// 要么把上一个作用域的对象漏给下一个。所以为它单造一个 VM,用完丢弃。
//
// 代价是这类调用每次约 5μs 建一个 VM。同一作用域下要反复调,用 New 拿实例更划算——
// 实例把 VM 攥在手里,不必每次重建。
func (s *Script) borrow(ctx context.Context) (*vmHandle, error) {
// 有作用域就必然要单造:池里的 VM 没装这个作用域的扩展
sc, scoped := scopeOf(ctx)
if !scoped {
select {
case inst := <-s.pool:
return inst, nil
default:
}
}
return s.newVM(ctx, sc, nil, false)
}
// release 归还 VM。healthy 为 false(被中断过或 panic 过)时直接丢弃:
// 脚本本来就不该有跨调用状态,重建一个 VM 远比拖着一个状态可疑的 VM 划算。
func (s *Script) release(inst *vmHandle, healthy bool) {
if inst == nil {
return
}
if !healthy || s.closed.Load() || inst.scoped {
// scoped 的 VM 带着某个作用域的扩展,回池就会漏给下一个调用
s.dropped.Add(1)
return
}
select {
case s.pool <- inst:
default:
s.dropped.Add(1) // 池满,丢弃
}
}
// newVM 造一个新 VM 并在里面加载这个脚本:注入白名单 → 跑一遍脚本顶层代码 →
// 记下求值结果。
//
// **取 VM 的唯一入口**。三条路径(池化 borrow、实例 ensure、静态 borrowStatic
// 都走它,作用域注入和 scoped 标记的规则才只有一份——这两件事分散过一次,
// 结果就是 borrowStatic 漏标了 scoped。
//
// sc 是这个 VM 属于哪个作用域,nil 表示不属于任何作用域。**显式传而不是从 ctx 嗅**:
// Instance 重建 VM 时要回到它**创建时**那个作用域,而不是当次调用 ctx 里的那个——
// 对一个作用域实例调 Call(context.Background()) 不该把它的扩展弄丢。
//
// ctorArgs 传给 constructornoInstance 为 true 时不构造实例,只想调静态方法时用,
// 避免白跑一遍 constructor(那是每次调用的准备工作)。
func (s *Script) newVM(ctx context.Context, sc *scope, ctorArgs []any, noInstance bool) (*vmHandle, error) {
var extra map[string]any
if sc != nil {
extra = sc.vmGlobals()
}
rt := goja.New()
if s.engine.maxStack > 0 {
rt.SetMaxCallStackSize(s.engine.maxStack)
}
if err := s.engine.bind(rt, s.name, extra); err != nil {
return nil, err
}
vm, err := s.load(ctx, rt, ctorArgs, noInstance)
if err != nil {
return nil, err
}
// 带作用域的 VM 装着那个作用域的扩展,绝不能回池——release 靠这个标志拒绝它
vm.scoped = sc != nil
return vm, nil
}
// load 把脚本装进一个 VM。noInstance 为 true 时不构造实例——
// 只想调静态方法时用,避免白跑一遍 constructor(那是每次调用的准备工作)。
func (s *Script) load(ctx context.Context, rt *goja.Runtime, ctorArgs []any, noInstance bool) (h *vmHandle, err error) {
stop := guard(ctx, rt, s.engine.timeout)
defer stop()
defer func() {
if r := recover(); r != nil {
h, err = nil, s.panicError(DefaultFunc, nil, r)
}
}()
v, err := rt.RunProgram(s.prog)
if err != nil {
return nil, classify(err, s.name, DefaultFunc, nil)
}
h = &vmHandle{rt: rt}
// 打包产物末尾的入口表达式求值出来的东西决定了脚本的形态:
// class → 由这里 new 出实例,构造参数从 Go 侧传(export default class X {}
// 普通函数 → 单函数入口,用 DefaultFunc 调用(export default function(){}
// 非函数对象 → 直接当导出实例(export default {...},或只有命名导出时的模块对象)
if !empty(v) {
switch {
case isClass(rt, v):
h.ctor = v.ToObject(rt)
if noInstance {
break // 只要静态方法,不跑 constructor
}
obj, err := construct(rt, v, ctorArgs)
if err != nil {
return nil, classify(err, s.name, "constructor", ctorArgs)
}
h.exports = obj
case isCallable(v):
h.defFn = v
default:
if obj, ok := v.(*goja.Object); ok {
h.exports = obj
}
}
}
s.created.Add(1)
return h, nil
}
// classDetector 判断一个值是不是 class。goja 的 AssertConstructor 对普通 function
// 也返回 true,区分不了;靠规范保证的差别来判:class 的 prototype 属性不可写,
// 普通函数的可写,箭头函数和方法简写根本没有 prototype。
var classDetector = goja.MustCompile("jscriptx:isclass", `(function (x) {
if (typeof x !== "function") { return false }
var d = Object.getOwnPropertyDescriptor(x, "prototype")
return !!d && d.writable === false
})`, true)
func isClass(rt *goja.Runtime, v goja.Value) bool {
if _, ok := goja.AssertConstructor(v); !ok {
return false
}
dv, err := rt.RunProgram(classDetector)
if err != nil {
return false
}
detect, ok := goja.AssertFunction(dv)
if !ok {
return false
}
res, err := detect(goja.Undefined(), v)
if err != nil {
return false
}
return res.ToBoolean()
}
func isCallable(v goja.Value) bool {
_, ok := goja.AssertFunction(v)
return ok
}
// construct 从 Go 侧 new 一个 JS class 实例,构造参数按 goja 的规则转换。
func construct(rt *goja.Runtime, class goja.Value, args []any) (*goja.Object, error) {
ctor, ok := goja.AssertConstructor(class)
if !ok {
return nil, fmt.Errorf("不是构造器")
}
jsArgs := make([]goja.Value, len(args))
for i, a := range args {
jsArgs[i] = rt.ToValue(a)
}
return ctor(nil, jsArgs...)
}
// lookup 找要调用的函数,同时给出调用时该绑定的 this。
//
// fn 为空取默认导出本身(export default function 那种);否则找导出实例的方法,
// this 绑定到实例,这样 class 里的 this.xxx 才有意义。
//
// 最后那层全局查找基本只是兜底:脚本经打包后是 IIFE,顶层声明进不了全局,
// 只有脚本显式往 globalThis 上挂东西时才走得到。
func (i *vmHandle) lookup(fn string) (callable goja.Callable, fnVal goja.Value, this goja.Value, ok bool) {
if fn == DefaultFunc {
if i.defFn == nil {
return nil, nil, nil, false
}
callable, ok = goja.AssertFunction(i.defFn)
return callable, i.defFn, goja.Undefined(), ok
}
if i.exports != nil {
// Get 会走原型链,class 方法定义在 prototype 上也找得到
if v := i.exports.Get(fn); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
if callable, ok = goja.AssertFunction(v); ok {
return callable, v, i.exports, true
}
}
}
// 静态方法挂在 class 自身上,不在实例的原型链上,所以实例那边找不到才轮到这里。
// this 绑定到 class 本身,跟 JS 里 C.Startup() 的语义一致。
if i.ctor != nil {
if v := i.ctor.Get(fn); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
if callable, ok = goja.AssertFunction(v); ok {
return callable, v, i.ctor, true
}
}
}
v := i.rt.Get(fn)
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
return nil, nil, nil, false
}
if callable, ok = goja.AssertFunction(v); !ok {
return nil, nil, nil, false
}
return callable, v, goja.Undefined(), true
}
// notFoundHint 在找不到函数时给一句有用的话。最常见的原因是脚本压根没写 export:
// 打包产物是 IIFE,没有导出的顶层代码会被当死代码摇掉,什么都不剩。
func (s *Script) notFoundHint(inst *vmHandle) string {
if inst.exports == nil && inst.defFn == nil {
return "脚本没有任何导出。打包产物是 IIFE,不写 export 的顶层代码会被当死代码摇掉;" +
"请用 export default 指明入口,或者用命名导出"
}
return "脚本里没有这个函数,或者它不是函数"
}