diff --git a/instance.go b/instance.go index 98597f8..42b346d 100644 --- a/instance.go +++ b/instance.go @@ -119,12 +119,9 @@ func (i *Instance) ensure(ctx context.Context) error { if i.vm != nil { return nil } - // 作用域带来的额外全局(扩展 + ScopeGlobals)现取现用,不在 Instance 上留副本。 - var extra map[string]any - if i.scope != nil { - extra = i.scope.vmGlobals() - } - vm, err := i.script.newVM(ctx, extra, i.ctorArgs) + // 用 i.scope 而不是 ctx 里的:实例属于它**创建时**那个作用域, + // 重建 VM 时要回到那里去,不能跟着当次调用走 + vm, err := i.script.newVM(ctx, i.scope, i.ctorArgs, false) if err != nil { return err } diff --git a/scope_test.go b/scope_test.go index 8fc536e..b69bc21 100644 --- a/scope_test.go +++ b/scope_test.go @@ -431,3 +431,49 @@ func toNumber(t *testing.T, v any) float64 { return 0 } } + +// 作用域实例重建 VM 时,要回到它**创建时**那个作用域。 +// +// Instance 的 VM 超时后会被丢弃,下次调用重建(见 Resets)。重建走的是 i.scope, +// 不是当次调用 ctx 里的——对一个作用域实例调 Call(context.Background()) 不该把 +// 它的扩展弄丢。 +// +// 这条守的是 newVM 的 scope 参数为什么是显式传的:改成从 ctx 嗅探就会破坏它。 +func TestScope_实例重建VM后仍在原作用域(t *testing.T) { + e := newEngine(t, jscriptx.WithTimeout(80*time.Millisecond)) + s := mustCompile(t, e, "s.ts", ` + export default class S { + spin() { while (true) {} } + seeStore() { return typeof store } + } + `) + + st := testext.New() + ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions(st)) + obj, err := s.New(ctx) + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + if got, _ := obj.Call(ctx, "seeStore"); got != "object" { + t.Fatalf("一开始就没看见扩展: %#v", got) + } + + // 超时会让实例丢掉 VM,下次调用重建 + if _, err := obj.Call(ctx, "spin"); !errors.Is(err, jscriptx.ErrTimeout) { + t.Fatalf("该超时: %v", err) + } + if obj.Resets() != 1 { + t.Fatalf("VM 没被丢弃,这条测不到重建(Resets=%d)", obj.Resets()) + } + + // 用**不带作用域**的 ctx 触发重建:实例仍属于原来那个作用域 + got, err := obj.Call(context.Background(), "seeStore") + if err != nil { + t.Fatal(err) + } + if got != "object" { + t.Errorf("重建之后扩展丢了(%#v)——实例被当成了无作用域的", got) + } +} diff --git a/script.go b/script.go index 0f7bfa3..b988b8d 100644 --- a/script.go +++ b/script.go @@ -112,22 +112,16 @@ func (s *Script) Close() { // 代价是这类调用每次约 5μs 建一个 VM。同一作用域下要反复调,用 New 拿实例更划算—— // 实例把 VM 攥在手里,不必每次重建。 func (s *Script) borrow(ctx context.Context) (*vmHandle, error) { - if sc, ok := scopeOf(ctx); ok { - // 作用域至少带一个 scope.key,所以只要有作用域就必然要单造 - vm, err := s.newVM(ctx, sc.vmGlobals(), nil) - if err != nil { - return nil, err + // 有作用域就必然要单造:池里的 VM 没装这个作用域的扩展 + sc, scoped := scopeOf(ctx) + if !scoped { + select { + case inst := <-s.pool: + return inst, nil + default: } - vm.scoped = true - return vm, nil } - - select { - case inst := <-s.pool: - return inst, nil - default: - } - return s.newVM(ctx, nil, nil) + return s.newVM(ctx, sc, nil, false) } // release 归还 VM。healthy 为 false(被中断过或 panic 过)时直接丢弃: @@ -148,36 +142,40 @@ func (s *Script) release(inst *vmHandle, healthy bool) { } } -// newVM 造一个新 VM 并在里面加载这个脚本:注入白名单(extra 是会话专属的额外全局, -// 池化调用传 nil)→ 跑一遍脚本顶层代码 → 记下求值结果。 -func (s *Script) newVM(ctx context.Context, extra map[string]any, ctorArgs []any) (*vmHandle, error) { - rt, err := s.engine.newRuntime(s.name, extra) +// newVM 造一个新 VM 并在里面加载这个脚本:注入白名单 → 跑一遍脚本顶层代码 → +// 记下求值结果。 +// +// **取 VM 的唯一入口**。三条路径(池化 borrow、实例 ensure、静态 borrowStatic) +// 都走它,作用域注入和 scoped 标记的规则才只有一份——这两件事分散过一次, +// 结果就是 borrowStatic 漏标了 scoped。 +// +// sc 是这个 VM 属于哪个作用域,nil 表示不属于任何作用域。**显式传而不是从 ctx 嗅**: +// Instance 重建 VM 时要回到它**创建时**那个作用域,而不是当次调用 ctx 里的那个—— +// 对一个作用域实例调 Call(context.Background()) 不该把它的扩展弄丢。 +// +// ctorArgs 传给 constructor;noInstance 为 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 } - return s.loadInto(ctx, rt, ctorArgs) -} - -// newRuntime 造一个空 VM 并注入白名单。scope 是它在日志和 store 里的身份: -// 池化 VM 用脚本名,会话 VM 用会话 key。 -func (e *Engine) newRuntime(scope string, extra map[string]any) (*goja.Runtime, error) { - rt := goja.New() - if e.maxStack > 0 { - rt.SetMaxCallStackSize(e.maxStack) - } - if err := e.bind(rt, scope, extra); err != nil { - return nil, err - } - return rt, nil -} - -// loadInto 在一个已经建好的 VM 里跑这个脚本,取出它的导出。 -// 顶层代码同样受超时保护,脚本在顶层写死循环不会把调用方卡住。 -// -// 同一个 rt 上可以先后加载多个脚本:打包产物是 IIFE,除了 ModuleGlobal 这一个全局名 -// 之外不往外泄漏东西,而那个名字的值在这里当场就取走了,后一个脚本覆盖它也不影响。 -func (s *Script) loadInto(ctx context.Context, rt *goja.Runtime, ctorArgs []any) (*vmHandle, error) { - return s.load(ctx, rt, ctorArgs, false) + // 带作用域的 VM 装着那个作用域的扩展,绝不能回池——release 靠这个标志拒绝它 + vm.scoped = sc != nil + return vm, nil } // load 把脚本装进一个 VM。noInstance 为 true 时不构造实例—— diff --git a/static.go b/static.go index d2a8b65..c3cd528 100644 --- a/static.go +++ b/static.go @@ -79,23 +79,7 @@ func (s *Script) borrowStatic(ctx context.Context) (*vmHandle, error) { return nil, newError(KindClosed, s.name, "", ErrClosed, "脚本已关闭") } - var extra map[string]any - if sc, ok := scopeOf(ctx); ok { - extra = sc.vmGlobals() - } - - rt, err := s.engine.newRuntime(s.name, extra) - if err != nil { - return nil, err - } - - vm, err := s.load(ctx, rt, nil, true) - if err != nil { - return nil, err - } - // 带作用域的 VM 装着那个作用域的扩展,绝不能回池。今天 staticTarget.finish - // 永远丢弃,标不标都不漏;但同一个约束不该靠两套机制守——以后有人把静态 VM - // 接进 release,漏标就是一次跨请求泄漏 - vm.scoped = extra != nil - return vm, nil + sc, _ := scopeOf(ctx) + // noInstance:静态方法挂在 class 上,不用白跑一遍 constructor + return s.newVM(ctx, sc, nil, true) }