Files
jscriptx/vmstate_test.go
T
what 7f484d7a71 test: 引擎核心的测试与基准
除了常规用例,有几组是专门钉住「引擎的既定事实」的,改动时会先红:

  - vmstate_test  goja 的硬限制:非原始值不是 goroutine 安全的、对象跨不了
                  Runtime。其中一条是哨兵——goja 哪天允许对象跨 Runtime 了,
                  它会失败,提醒我们可以简化设计。
  - govalue_test  Go 的切片/map 进到脚本里长什么样。这不是本库的行为而是
                  goja 的,但脚本作者照着它写代码,变了会静默走错分支。
  - safety_test   并发下坏 VM 不会被别的 goroutine 捡到。
  - bench_gobind  脚本碰 Go 对象的单次开销,README 性能一节的数据来源。

missing_global_test 里有一条断言 typeof setTimeout === "undefined":
定时器必须保持未定义,库里的特性探测才能正常降级,防止以后有人把桩加回来。
2026-09-05 22:13:57 +08:00

588 lines
16 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_test
import (
"fmt"
"slices"
"sync"
"testing"
"time"
"github.com/dop251/goja"
)
// 这一组直接用 goja,不经过本库的任何封装,钉住整个设计赖以成立的两条引擎事实:
//
// 1. Program 无状态,可以给任意多个 Runtime 共用——本库共用编译结果的前提
// 2. **模块顶层的变量跟着 Runtime 走,不是共享状态**
//
// 第 2 条是最容易误解的地方。它导致:一个脚本串行开发时表现正常(一直命中同一个
// Runtime),并发起来行为却随调度漂移——有几个 Runtime 就有几份独立的顶层状态,
// 一次调用落到哪个 Runtime 取决于当时的 goroutine 调度。
//
// 所以要在并发之间共享状态,只能把它放到 Go 侧(写一个扩展,见 Extension 接口):
// Go 对象可以被每个 Runtime 各自包一层,并发安全由 Go 自己保证。
const gojaCounter = `
var counter = 0;
function bump() { return ++counter }
bump`
// compileCounter 编一份 Program。它是无状态的,几个用例共用同一份。
func compileCounter(t *testing.T) *goja.Program {
t.Helper()
prog, err := goja.Compile("counter.js", gojaCounter, false)
if err != nil {
t.Fatal(err)
}
return prog
}
// bumpOf 在一个新 Runtime 里跑一遍 Program,拿到那个闭包函数。
func bumpOf(t *testing.T, prog *goja.Program) goja.Callable {
t.Helper()
rt := goja.New()
v, err := rt.RunProgram(prog)
if err != nil {
t.Fatal(err)
}
fn, ok := goja.AssertFunction(v)
if !ok {
t.Fatalf("Program 的完成值不是函数: %v", v)
}
return fn
}
func callBump(t *testing.T, fn goja.Callable) int64 {
t.Helper()
v, err := fn(goja.Undefined())
if err != nil {
t.Fatal(err)
}
return v.ToInteger()
}
// 同一个 Runtime 反复调用,顶层变量一路累加——闭包还在,Program 不会重跑。
func TestGoja_同一个Runtime里顶层变量会累加(t *testing.T) {
fn := bumpOf(t, compileCounter(t))
for i := int64(1); i <= 5; i++ {
if got := callBump(t, fn); got != i {
t.Fatalf("第 %d 次调用该拿到 %d,拿到 %d", i, i, got)
}
}
}
// 换一个 Runtime 就是另一份顶层变量,哪怕用的是同一个 Program。
// 这正是「N 个 VM = N 份独立副本」的根源。
func TestGoja_不同Runtime各有一份顶层变量(t *testing.T) {
prog := compileCounter(t)
a := bumpOf(t, prog)
b := bumpOf(t, prog)
for range 3 { // a 先数到 3
callBump(t, a)
}
if got := callBump(t, b); got != 1 { // b 是全新的
t.Errorf("另一个 Runtime 该从 1 开始,拿到 %d", got)
}
if got := callBump(t, a); got != 4 { // a 不受影响
t.Errorf("原来那个 Runtime 该接着数到 4,拿到 %d", got)
}
}
// Program 可以并发给任意多个 Runtime 用。32 个各调一次,全都拿到 1
// ——32 份独立的顶层状态,互不影响。
func TestGoja_Program可以并发共用(t *testing.T) {
prog := compileCounter(t)
var mu sync.Mutex
var got []int64
var wg sync.WaitGroup
for range 32 {
wg.Add(1)
go func() {
defer wg.Done()
rt := goja.New()
v, err := rt.RunProgram(prog) // 同一份 Program,不同 Runtime
if err != nil {
t.Error(err)
return
}
fn, ok := goja.AssertFunction(v)
if !ok {
t.Error("完成值不是函数")
return
}
r, err := fn(goja.Undefined())
if err != nil {
t.Error(err)
return
}
mu.Lock()
got = append(got, r.ToInteger())
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(got)
if len(got) != 32 {
t.Fatalf("该有 32 个结果,拿到 %d 个", len(got))
}
for _, v := range got {
if v != 1 {
t.Fatalf("每个 Runtime 都是新的,该全是 1,出现了 %d;全部结果 %v", v, got)
}
}
}
// 对象不能跨 Runtime 传。这是 goja 的硬限制,也是「JS 内部变量没法并发共享」的根据。
//
// 维护者在 dop251/goja#232 里说得很直白:非原始类型的 goja.Value 连并发只读都不安全,
// 用 Proxy 包一层也不行,因为 handler 最终还是要访问原来那个值。
//
// 这条测试是个哨兵:哪天 goja 放开了这个限制,它会失败,提醒我们重新评估
// 「共享状态必须放 Go 侧」这个结论。
func TestGoja_对象不能跨Runtime传(t *testing.T) {
rt1 := goja.New()
obj, err := rt1.RunString(`var o = { n: 1 }; o`)
if err != nil {
t.Fatal(err)
}
rt2 := goja.New()
setErr := rt2.Set("borrowed", obj) // 把 rt1 的对象塞进 rt2
_, runErr := rt2.RunString(`borrowed.n`)
if setErr == nil && runErr == nil {
t.Fatal("跨 Runtime 传对象居然成功了——goja 的限制变了," +
"本库关于「共享状态只能放 Go 侧」的结论需要重新评估")
}
t.Logf("Set 的结果: %v", setErr)
t.Logf("读取的结果: %v", runErr)
}
// 原始值不受这条限制——字符串、数字、布尔不是对象,可以跨 Runtime 传。
func TestGoja_原始值可以跨Runtime传(t *testing.T) {
rt1 := goja.New()
v, err := rt1.RunString(`"hello"`)
if err != nil {
t.Fatal(err)
}
rt2 := goja.New()
if err := rt2.Set("s", v); err != nil {
t.Fatalf("原始值该能传: %v", err)
}
got, err := rt2.RunString(`s + " world"`)
if err != nil {
t.Fatal(err)
}
if got.String() != "hello world" {
t.Errorf("拿到 %q", got.String())
}
}
// ============================================================================
// 并发之间要真正共享一个计数器(1,2,3… 不重不漏)该怎么做
// ============================================================================
// sharedCounter 是放在 Go 侧的计数器,并发安全由它自己保证。
// 每个 Runtime 各自把它包一层——这是 goja 维护者认可的唯一共享方式。
type sharedCounter struct {
mu sync.Mutex
n int64
}
func (c *sharedCounter) Bump() int64 {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
return c.n
}
// 方案一:状态放 Go 侧,Runtime 各自独立。**推荐这条。**
//
// 32 个 goroutine 各有各的 Runtime(互不阻塞),但 Set 进去的是同一个 Go 对象,
// 所以 Bump() 拿到的是同一个计数器:结果正好 1..32,不重不漏。
//
// 只有 Bump 里那把 Go 锁是串行的,脚本执行本身全程并行。
// 把它包成扩展之后,脚本侧写 store.Incr("counter", 1) 就行——本仓库的
// internal/testext 是个最小示例,框架层那份在 framework 的 internal/jscript/ext/store。
func TestGoja_共享状态放Go侧(t *testing.T) {
const n = 32
prog, err := goja.Compile("shared.js", `
function bump() { return shared.Bump() }
bump`, false)
if err != nil {
t.Fatal(err)
}
counter := &sharedCounter{} // 进程级,只有一个
got := runConcurrent(t, n, func() (int64, error) {
rt := goja.New()
if err := rt.Set("shared", counter); err != nil { // 同一个指针,各包一层
return 0, err
}
v, err := rt.RunProgram(prog)
if err != nil {
return 0, err
}
fn, ok := goja.AssertFunction(v)
if !ok {
return 0, fmt.Errorf("完成值不是函数")
}
r, err := fn(goja.Undefined())
if err != nil {
return 0, err
}
return r.ToInteger(), nil
})
fmt.Println("方案一 共享 Go 对象:", got)
assertSequence(t, got, n)
}
// 方案二:共用一个 Runtime,靠锁串行。
//
// 这样 JS 里的 counter 是真共享的——因为只有一个 Runtime。代价是**所有脚本执行
// 完全串行**:脚本跑 1ms,32 个并发就是 32ms 串起来。
//
// 除非脚本极快且并发很低,否则别走这条。状态放 Go 侧更安全,也不用牺牲并发。
func TestGoja_共用一个Runtime靠锁串行(t *testing.T) {
const n = 32
prog, err := goja.Compile("locked.js", `
var counter = 0;
function bump() { return ++counter }
bump`, false)
if err != nil {
t.Fatal(err)
}
rt := goja.New()
v, err := rt.RunProgram(prog)
if err != nil {
t.Fatal(err)
}
fn, ok := goja.AssertFunction(v)
if !ok {
t.Fatal("完成值不是函数")
}
var rtMu sync.Mutex // Runtime 不是并发安全的,这把锁不能省
got := runConcurrent(t, n, func() (int64, error) {
rtMu.Lock()
defer rtMu.Unlock()
r, err := fn(goja.Undefined())
if err != nil {
return 0, err
}
return r.ToInteger(), nil
})
fmt.Println("方案二 共用 Runtime:", got)
assertSequence(t, got, n)
}
// runConcurrent 并发跑 n 次 do,收集结果并排序。
func runConcurrent(t *testing.T, n int, do func() (int64, error)) []int64 {
t.Helper()
var mu sync.Mutex
out := make([]int64, 0, n)
var wg sync.WaitGroup
for range n {
wg.Add(1)
go func() {
defer wg.Done()
v, err := do()
if err != nil {
t.Error(err)
return
}
mu.Lock()
out = append(out, v)
mu.Unlock()
}()
}
wg.Wait()
slices.Sort(out)
return out
}
// assertSequence 断言结果正好是 1..n,不重不漏。
func assertSequence(t *testing.T, got []int64, n int) {
t.Helper()
if len(got) != n {
t.Fatalf("该有 %d 个结果,拿到 %d 个: %v", n, len(got), got)
}
for i, v := range got {
if v != int64(i+1) {
t.Fatalf("该是 1..%d 的连续序列,第 %d 个是 %d;全部结果 %v", n, i+1, v, got)
}
}
}
// ============================================================================
// 脚本里 +1 之后阻塞会怎样:两个方案在这里差别最大
// ============================================================================
const blockOnce = 100 * time.Millisecond
// sleeper 暴露给脚本的阻塞函数,模拟脚本里做 IO(查库、调接口)。
func sleeper(d time.Duration) func() { return func() { time.Sleep(d) } }
// 方案一:计数器在 Go 侧,Runtime 各自独立。
//
// 脚本先 +1 拿到自己的号,再阻塞。16 个各睡 20ms,总耗时接近 20ms 而不是 320ms
// ——一个脚本卡在 IO 上,不挡别人。而且 +1 在阻塞之前就已经对其他人可见了。
func TestGoja_阻塞时独立Runtime并行(t *testing.T) {
const n = 16
prog, err := goja.Compile("blocking.js", `
function bump() {
var mine = shared.Bump(); // 先 +1,拿到自己的号
sleep(); // 再阻塞
return mine
}
bump`, false)
if err != nil {
t.Fatal(err)
}
counter := &sharedCounter{}
sleep := sleeper(blockOnce)
start := time.Now()
got := runConcurrent(t, n, func() (int64, error) {
rt := goja.New()
if err := rt.Set("shared", counter); err != nil {
return 0, err
}
if err := rt.Set("sleep", sleep); err != nil {
return 0, err
}
v, err := rt.RunProgram(prog)
if err != nil {
return 0, err
}
fn, ok := goja.AssertFunction(v)
if !ok {
return 0, fmt.Errorf("完成值不是函数")
}
r, err := fn(goja.Undefined())
if err != nil {
return 0, err
}
return r.ToInteger(), nil
})
elapsed := time.Since(start)
fmt.Printf("方案一 独立 Runtime: %d 个各阻塞 %v,总耗时 %v\n",
n, blockOnce, elapsed.Round(time.Millisecond))
fmt.Println(got)
assertSequence(t, got, n)
if limit := blockOnce * n / 2; elapsed > limit {
t.Errorf("独立 Runtime 该并行,总耗时 %v 超过了 %v——像是串行了", elapsed, limit)
}
}
// 方案二:计数器就在 JS 里,共用一个 Runtime。
//
// 脚本先 counter++(JS 侧的变量,真共享),再阻塞。同样 16 个各睡 20ms,
// 总耗时接近 320ms:Runtime 不是并发安全的,锁必须一直持到脚本返回,
// **脚本在 IO 上卡多久,锁就被占多久**,包括 +1 之后那段。
//
// 所以"共用 Runtime 换 JS 侧共享状态"的代价不是慢一点,是并发归零。
func TestGoja_阻塞时共用Runtime串行(t *testing.T) {
const n = 16
// 导出两个方法:最后一个表达式是什么,RunProgram 就返回什么。
// 想导出多个就返回一个对象——注意外面要包一层括号,
// 不然 { 会被当成语句块而不是对象字面量。
prog, err := goja.Compile("blocking.js", `
var counter = 0;
function bump() {
counter++; // 先 +1,这是 JS 里的变量,共用 Runtime 才共享得了
sleep(); // 再阻塞
return counter
}
function read() { return counter } // 不 +1,只读当前值
({ bump: bump, read: read })`, false)
if err != nil {
t.Fatal(err)
}
rt := goja.New()
if err := rt.Set("sleep", sleeper(blockOnce)); err != nil {
t.Fatal(err)
}
v, err := rt.RunProgram(prog)
if err != nil {
t.Fatal(err)
}
exports := v.ToObject(rt)
fn, ok := goja.AssertFunction(exports.Get("bump"))
if !ok {
t.Fatal("bump 不是函数")
}
read, ok := goja.AssertFunction(exports.Get("read"))
if !ok {
t.Fatal("read 不是函数")
}
var rtMu sync.Mutex
start := time.Now()
got := runConcurrent(t, n, func() (int64, error) {
rtMu.Lock()
defer rtMu.Unlock() // 锁持到脚本返回,包括 +1 之后阻塞的那段
r, err := fn(goja.Undefined())
if err != nil {
return 0, err
}
return r.ToInteger(), nil
})
fmt.Println(got)
elapsed := time.Since(start)
fmt.Printf("方案二 共用 Runtime: %d 个各阻塞 %v,总耗时 %v\n",
n, blockOnce, elapsed.Round(time.Millisecond))
assertSequence(t, got, n)
if floor := blockOnce * n * 4 / 5; elapsed < floor {
t.Errorf("共用 Runtime 必然串行,总耗时才 %v,不到 %v——是不是没锁住", elapsed, floor)
}
// 全部并发结束后读最终值。读也要加锁——它跟 bump 用的是同一个 Runtime。
rtMu.Lock()
final, err := read(goja.Undefined())
rtMu.Unlock()
if err != nil {
t.Fatal(err)
}
fmt.Printf(" 并发结束后 read(): %d\n", final.ToInteger())
if got := final.ToInteger(); got != int64(n) {
t.Errorf("%d 个并发各 +1 一次,最终该是 %d,读到 %d", n, n, got)
}
}
// 对照:独立 Runtime + JS 侧的 counter++。
//
// 跟"共用 Runtime"那条用的是**完全相同的脚本**,只有 Runtime 的用法不同。
// 结果:并行是并行了(~20ms 而不是 320ms),但 16 个全拿到 1
// ——每个 Runtime 一份自己的 counter,压根没共享。
//
// 这条和前两条合起来是个 2×2
//
// JS 侧 counter Go 侧 counter
// 独立 Runtime 全是 1,并行 ✗ 1..16,并行 ✓
// 共用 Runtime 1..16,串行 ✗ (没必要,白白串行)
//
// 只有右上角那格两头都占。
func TestGoja_独立Runtime里的JS计数器不共享(t *testing.T) {
const n = 16
// 跟 TestGoja_阻塞时共用Runtime串行 用的是同一段脚本
prog, err := goja.Compile("blocking.js", `
var counter = 0;
function bump() {
counter++;
sleep();
return counter
}
function read() { return counter }
({ bump: bump, read: read })`, false)
if err != nil {
t.Fatal(err)
}
sleep := sleeper(blockOnce)
// 把每个 Runtime 的 read 留下来,跑完再各读各的
var readMu sync.Mutex
var readers []goja.Callable
start := time.Now()
got := runConcurrent(t, n, func() (int64, error) {
rt := goja.New() // 每个 goroutine 一个 Runtime
if err := rt.Set("sleep", sleep); err != nil {
return 0, err
}
v, err := rt.RunProgram(prog)
if err != nil {
return 0, err
}
exports := v.ToObject(rt)
fn, ok := goja.AssertFunction(exports.Get("bump"))
if !ok {
return 0, fmt.Errorf("bump 不是函数")
}
read, ok := goja.AssertFunction(exports.Get("read"))
if !ok {
return 0, fmt.Errorf("read 不是函数")
}
r, err := fn(goja.Undefined())
if err != nil {
return 0, err
}
readMu.Lock()
readers = append(readers, read)
readMu.Unlock()
return r.ToInteger(), nil
})
elapsed := time.Since(start)
fmt.Printf("对照 独立 Runtime + JS 计数器: %v 总耗时 %v\n",
got, elapsed.Round(time.Millisecond))
// 并行了
if limit := blockOnce * n / 2; elapsed > limit {
t.Errorf("独立 Runtime 该并行,总耗时 %v 超过了 %v", elapsed, limit)
}
// 但没共享:每个 Runtime 自己的 counter 都是第一次 +1
for _, v := range got {
if v != 1 {
t.Fatalf("每个 Runtime 一份 counter,该全是 1,出现了 %d;全部结果 %v", v, got)
}
}
// 全部结束后各读各的:16 个 Runtime16 份 counter,每份都是 1。
// 对比共用 Runtime 那条——那边最终读到 16。
//
// 每个 Runtime 只有创建它的那条路径碰过,此刻也没人在跑,所以读不用加锁。
finals := make([]int64, 0, len(readers))
for _, read := range readers {
v, err := read(goja.Undefined())
if err != nil {
t.Fatal(err)
}
finals = append(finals, v.ToInteger())
}
fmt.Printf(" 并发结束后各自 read(): %v\n", finals)
for i, v := range finals {
if v != 1 {
t.Errorf("第 %d 个 Runtime 的 counter 该是 1,读到 %d", i, v)
}
}
}