Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ The runtime sources under deps/quickjs are no longer updated via git submodule.
- In `Runtime.NewContextWithOptions(...)`, enabling `WithBootstrapTimers(true)` implicitly enables std/os registration, because timer injection imports `setTimeout`/`clearTimeout` from the `os` module.
- Call `value.Free()` for `*Value` objects you create or receive. Runtime and Context objects must be cleaned up via `Close()`.
- `Value.Free()` is fail-closed when its context pointer is no longer valid, avoiding unsafe cgo calls after close.
- Value boundary APIs are fail-closed under foreign-context or closed/orphan context inputs: `Set`/`SetIdx` become no-ops, and `Call`/`Execute`/`CallConstructor`/deprecated `Context.Invoke` return `nil`.
- `Marshal`/`Unmarshal` are fail-closed on invalid context/value inputs: they return controlled errors for nil/closed contexts, nil source values, or cross-context values, and do not pass invalid refs into C.
- After `Context.Close()`, boundary queries are fail-closed: `Runtime()` returns `nil`, `HasException()` returns `false`, `Exception()` returns `nil`, `Loop()` becomes a no-op, and `Schedule()` returns `false`.
- Bridge mapping and handle-store reads are fail-closed under corrupted entries (wrong types), preventing panic-based crashes.
- Class instance opaque data is resolved via runtime-scoped object identity (`contextID` + `handleID`), so finalizer and `Value.GetGoObject()` use deterministic ownership lookup.
Expand Down
4 changes: 3 additions & 1 deletion README_zh-cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,14 @@ deps/quickjs 中的运行时源码不再通过 git submodule 更新,而是按

### 生命周期、内存与并发约束
- QuickJS 本身不是线程安全的;同一个 Runtime 及其 Context 必须由同一个串行 owner goroutine 创建、使用和关闭。
- 库默认对触达 QuickJS 的 API 强制执行 owner goroutine 校验(非 owner 调用按 fail-closed 语义拒绝);可以使用`WithOwnerGoroutineCheck(false)`显示关闭 owner goroutine 校验, 注意`WithOwnerGoroutineCheck(false)` 是显式不安全开关,仅适用于由你自己的调度器在外部严格串行化所有 QuickJS 访问的场景,一旦该前提被破坏,跨 goroutine 调用可能与 QuickJS 内部状态竞争并导致内存破坏。
- 库默认对触达 QuickJS 的 API 强制执行 owner goroutine 校验(非 owner 调用按 fail-closed 语义拒绝);可以使用 `WithOwnerGoroutineCheck(false)` 显式关闭 owner goroutine 校验。注意 `WithOwnerGoroutineCheck(false)` 是显式不安全开关,仅适用于由你自己的调度器在外部严格串行化所有 QuickJS 访问的场景,一旦该前提被破坏,跨 goroutine 调用可能与 QuickJS 内部状态竞争并导致内存破坏。
- 如需额外校验 Runtime 是否固定在同一个 OS 线程上,可启用 `WithStrictOSThread(true)`;该选项只负责校验,不会自动绑定线程。如果启用了该模式,请由调用方在 owner goroutine 中自行调用 `runtime.LockOSThread()`,并在创建 Runtime 之前完成绑定。
- `Runtime.NewContext()` 保持默认宿主 bootstrap 行为(注册 `std/os` 并注入全局 `setTimeout`/`clearTimeout`)。如需更细粒度控制,可使用 `Runtime.NewBareContext()` 后手动调用 `BootstrapStdOS` / `BootstrapTimers`,或使用 `Runtime.NewContextWithOptions(...)` 配合 `DefaultBootstrap`、`MinimalBootstrap`、`NoBootstrap`。
- 在 `Runtime.NewContextWithOptions(...)` 中,启用 `WithBootstrapTimers(true)` 会隐式启用 `std/os` 注册,因为计时器注入依赖从 `os` 模块导入 `setTimeout`/`clearTimeout`。
- 对您创建或接收的 `*Value` 对象调用 `value.Free()`;Runtime 和 Context 使用完毕后通过 `Close()` 清理。
- 当 `Value` 的上下文引用已失效时,`Value.Free()` 会 fail-closed,避免关闭后的不安全 cgo 调用。
- Value 边界 API 在 foreign-context 或 closed/orphan context 输入下采用 fail-closed:`Set`/`SetIdx` 变为 no-op,`Call`/`Execute`/`CallConstructor`/已废弃的 `Context.Invoke` 返回 `nil`。
- `Marshal`/`Unmarshal` 在无效 context/value 输入下采用 fail-closed:对 nil/closed context、nil 源值或跨 Context 值返回受控错误,不再向 C 层传递无效 ref。
- 在 `Context.Close()` 之后,边界查询采用 fail-closed:`Runtime()` 返回 `nil`,`HasException()` 返回 `false`,`Exception()` 返回 `nil`,`Loop()` 变为 no-op,`Schedule()` 返回 `false`。
- bridge 映射和 handleStore 在遇到损坏条目(错误类型)时采用 fail-closed,避免 panic 级崩溃。
- 类实例 opaque 数据通过 runtime 级对象身份(`contextID` + `handleID`)解析,finalizer 与 `Value.GetGoObject()` 使用确定性归属定位。
Expand Down
7 changes: 7 additions & 0 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -749,8 +749,15 @@ func (ctx *Context) AtomIdx(idx uint32) *Atom {
// Invoke invokes a function with given this value and arguments.
// Deprecated: Use Value.Execute() instead for better API consistency.
func (ctx *Context) Invoke(fn *Value, this *Value, args ...*Value) *Value {
if ctx == nil || !ctx.hasValidRef() || !fn.belongsTo(ctx) || !this.belongsTo(ctx) {
return nil
}

cargs := []C.JSValue{}
for _, x := range args {
if !x.belongsTo(ctx) {
return nil
}
cargs = append(cargs, x.ref)
}
var val *Value
Expand Down
46 changes: 46 additions & 0 deletions context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,52 @@ func TestContextErrorHandling(t *testing.T) {
})
}

func TestContextInvokeFailClosedContracts(t *testing.T) {
useStableOwnerHooksForLegacySubtests(t)

rt := NewRuntime()
defer rt.Close()

ctx := rt.NewContext()
require.NotNil(t, ctx)

other := rt.NewContext()
require.NotNil(t, other)

fn := ctx.NewFunction(func(ctx *Context, this *Value, args []*Value) *Value {
if len(args) > 0 {
return ctx.NewString("ok:" + args[0].ToString())
}
return ctx.NewString("ok")
})

thisVal := ctx.NewNull()
arg := ctx.NewString("x")

result := ctx.Invoke(fn, thisVal, arg)
require.NotNil(t, result)
require.EqualValues(t, "ok:x", result.ToString())
result.Free()

foreignThis := other.NewObject()
foreignArg := other.NewString("y")

require.Nil(t, ctx.Invoke(fn, foreignThis, arg))
require.Nil(t, ctx.Invoke(fn, thisVal, foreignArg))
require.Nil(t, ctx.Invoke(nil, thisVal, arg))
require.Nil(t, ctx.Invoke(fn, nil, arg))

foreignArg.Free()
foreignThis.Free()
arg.Free()
thisVal.Free()
fn.Free()
other.Close()

ctx.Close()
require.Nil(t, ctx.Invoke(fn, thisVal, arg))
}

func TestContextUtilities(t *testing.T) {
useStableOwnerHooksForLegacySubtests(t)

Expand Down
26 changes: 26 additions & 0 deletions marshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,26 @@ package quickjs

import (
"encoding/binary"
"errors"
"fmt"
"math"
"reflect"
"strconv"
"strings"
)

var errMarshalContextUnavailable = errors.New("marshal context is not available")

func (ctx *Context) ensureMarshalContextAvailable() error {
if ctx == nil || ctx.ref == nil || ctx.runtime == nil || !ctx.runtime.isAlive() {
return errMarshalContextUnavailable
}
if !ctx.runtime.ensureOwnerAccess() {
return errOwnerAccessDenied
}
return nil
}

// Marshaler is the interface implemented by types that can marshal themselves into a JavaScript value.
type Marshaler interface {
MarshalJS(ctx *Context) (*Value, error)
Expand Down Expand Up @@ -138,6 +151,9 @@ func isEmptyValue(v reflect.Value) bool {
//
// Types implementing the Marshaler interface are marshaled using their MarshalJS method.
func (ctx *Context) Marshal(v interface{}) (*Value, error) {
if err := ctx.ensureMarshalContextAvailable(); err != nil {
return nil, err
}
if v == nil {
return ctx.NewNull(), nil
}
Expand Down Expand Up @@ -175,6 +191,16 @@ func (ctx *Context) Marshal(v interface{}) (*Value, error) {
//
// Types implementing the Unmarshaler interface are unmarshaled using their UnmarshalJS method.
func (ctx *Context) Unmarshal(jsVal *Value, v interface{}) error {
if err := ctx.ensureMarshalContextAvailable(); err != nil {
return err
}
if jsVal == nil {
return errors.New("unmarshal source value must be non-nil")
}
if !jsVal.belongsTo(ctx) {
return errors.New("unmarshal source value must belong to the same live context")
}

rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return fmt.Errorf("unmarshal target must be a non-nil pointer")
Expand Down
101 changes: 101 additions & 0 deletions marshal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"math"
"math/big"
"reflect"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -1353,3 +1354,103 @@ func TestMarshalNilAndInvalidValues(t *testing.T) {
require.NoError(t, err)
require.True(t, val3.IsNull())
}

func TestMarshalUnmarshalFailClosedContracts(t *testing.T) {
useStableOwnerHooksForLegacySubtests(t)

rt := NewRuntime()
defer rt.Close()

ctx := rt.NewContext()
require.NotNil(t, ctx)

other := rt.NewContext()
require.NotNil(t, other)

jsVal, err := ctx.Marshal(map[string]int{"a": 1})
require.NoError(t, err)
require.NotNil(t, jsVal)

var out map[string]int
err = ctx.Unmarshal(jsVal, &out)
require.NoError(t, err)
require.Equal(t, map[string]int{"a": 1}, out)
jsVal.Free()

foreign := other.NewInt32(7)
require.NotNil(t, foreign)

var target int32
err = ctx.Unmarshal(foreign, &target)
require.Error(t, err)
require.Contains(t, err.Error(), "same live context")

err = ctx.Unmarshal(nil, &target)
require.Error(t, err)
require.Contains(t, err.Error(), "non-nil")

var nilCtx *Context
val, err := nilCtx.Marshal(1)
require.ErrorIs(t, err, errMarshalContextUnavailable)
require.Nil(t, val)
err = nilCtx.Unmarshal(foreign, &target)
require.ErrorIs(t, err, errMarshalContextUnavailable)

orphanCtx := &Context{}
val, err = orphanCtx.Marshal(1)
require.ErrorIs(t, err, errMarshalContextUnavailable)
require.Nil(t, val)
err = orphanCtx.Unmarshal(foreign, &target)
require.ErrorIs(t, err, errMarshalContextUnavailable)

foreign.Free()

ctx.Close()
val, err = ctx.Marshal(1)
require.ErrorIs(t, err, errMarshalContextUnavailable)
require.Nil(t, val)

afterClose := other.NewInt32(9)
require.NotNil(t, afterClose)
err = ctx.Unmarshal(afterClose, &target)
require.ErrorIs(t, err, errMarshalContextUnavailable)
afterClose.Free()

other.Close()
}

func TestMarshalUnmarshalOwnerDeniedFailClosed(t *testing.T) {
oldGIDHook := ownerCheckCurrentGoroutineID
oldThreadHook := ownerCheckCurrentThreadID
defer func() {
ownerCheckCurrentGoroutineID = oldGIDHook
ownerCheckCurrentThreadID = oldThreadHook
}()

var gid atomic.Uint64
gid.Store(101)
ownerCheckCurrentGoroutineID = func() uint64 { return gid.Load() }
ownerCheckCurrentThreadID = func() uint64 { return 1 }

rt := NewRuntime()
require.NotNil(t, rt)
ctx := rt.NewContext()
require.NotNil(t, ctx)

source := ctx.NewInt32(1)
require.NotNil(t, source)

gid.Store(202)
val, err := ctx.Marshal(1)
require.ErrorIs(t, err, errOwnerAccessDenied)
require.Nil(t, val)

var out int32
err = ctx.Unmarshal(source, &out)
require.ErrorIs(t, err, errOwnerAccessDenied)

gid.Store(101)
source.Free()
ctx.Close()
rt.Close()
}
11 changes: 11 additions & 0 deletions runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1474,7 +1474,9 @@ func TestRuntimeOwnerCheckGatesRuntimeContextAndValuePaths(t *testing.T) {
require.Nil(t, adderObj.Call("add", val1, val2))
require.Nil(t, incFn.Execute(obj))
require.Nil(t, adderFn.Execute(thisVal, val1, val2))
require.Nil(t, ctx.Invoke(adderFn, thisVal, val1, val2))
require.Nil(t, adderFn.Execute(thisVal, otherVal))
require.Nil(t, ctx.Invoke(adderFn, thisVal, otherVal))
require.Nil(t, ctor.CallConstructor())
require.Nil(t, ctorWithArg.CallConstructor(val1))
_, err = obj.GetGoObject()
Expand Down Expand Up @@ -1525,8 +1527,17 @@ func TestRuntimeOwnerCheckGatesRuntimeContextAndValuePaths(t *testing.T) {
require.False(t, execResult.IsException())
require.EqualValues(t, 3, execResult.ToInt32())
execResult.Free()

invokeResult := ctx.Invoke(adderFn, thisVal, val1, val2)
require.NotNil(t, invokeResult)
require.False(t, invokeResult.IsException())
require.EqualValues(t, 3, invokeResult.ToInt32())
invokeResult.Free()

require.Nil(t, adderFn.Execute(thisVal, otherVal))
require.Nil(t, ctx.Invoke(adderFn, thisVal, otherVal))
require.Nil(t, adderFn.Execute(otherVal, val1))
require.Nil(t, ctx.Invoke(adderFn, otherVal, val1))

execZeroArg := incFn.Execute(obj)
require.NotNil(t, execZeroArg)
Expand Down
31 changes: 22 additions & 9 deletions value.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,26 @@ func (v *Value) hasValidContext() bool {
return v != nil && v.ctx != nil && v.ctx.hasValidRef()
}

// isAlive reports whether a Value can safely reach QuickJS.
func (v *Value) isAlive() bool {
return v != nil && v.ctx != nil && v.ctx.isAlive() && v.ctx.hasValidRef()
}

// belongsTo reports whether a Value belongs to the given live Context.
func (v *Value) belongsTo(ctx *Context) bool {
return v != nil && ctx != nil && v.ctx == ctx && v.hasValidContext()
}

func sameContextRef(a *Value, b *Value) bool {
return a != nil && b != nil && a.ctx != nil && b.ctx != nil && a.ctx == b.ctx
}

// sameContext reports whether two values belong to the same live context.
func sameContext(a *Value, b *Value) bool {
return sameContextRef(a, b) && a.ctx.hasValidRef()
if !sameContextRef(a, b) {
return false
}
return a.hasValidContext() && b.hasValidContext()
}

// Free the value.
Expand Down Expand Up @@ -171,7 +184,7 @@ func (v *Value) ByteLen() int64 {

// Set sets the value of the property with the given name.
func (v *Value) Set(name string, val *Value) {
if !v.hasValidContext() || !sameContextRef(v, val) {
if !v.isAlive() || !val.belongsTo(v.ctx) {
return
}
var namePtr *C.char
Expand All @@ -183,7 +196,7 @@ func (v *Value) Set(name string, val *Value) {

// SetIdx sets the value of the property with the given index.
func (v *Value) SetIdx(idx int64, val *Value) {
if !v.hasValidContext() || !sameContextRef(v, val) {
if !v.isAlive() || !val.belongsTo(v.ctx) {
return
}
C.JS_SetPropertyUint32(v.ctx.ref, v.ref, C.uint32_t(idx), val.ref)
Expand Down Expand Up @@ -211,7 +224,7 @@ func (v *Value) GetIdx(idx int64) *Value {

// Call calls the function with the given arguments.
func (v *Value) Call(fname string, args ...*Value) *Value {
if !v.hasValidContext() {
if !v.isAlive() {
return nil
}
var fnamePtr *C.char
Expand All @@ -220,7 +233,7 @@ func (v *Value) Call(fname string, args ...*Value) *Value {
}
cargs := []C.JSValue{}
for _, x := range args {
if !sameContextRef(v, x) {
if !x.belongsTo(v.ctx) {
return nil
}
cargs = append(cargs, x.ref)
Expand All @@ -237,12 +250,12 @@ func (v *Value) Call(fname string, args ...*Value) *Value {

// Execute the function with the given arguments.
func (v *Value) Execute(this *Value, args ...*Value) *Value {
if !v.hasValidContext() || !sameContextRef(v, this) {
if !v.isAlive() || !this.belongsTo(v.ctx) {
return nil
}
cargs := []C.JSValue{}
for _, x := range args {
if !sameContextRef(v, x) {
if !x.belongsTo(v.ctx) {
return nil
}
cargs = append(cargs, x.ref)
Expand Down Expand Up @@ -275,12 +288,12 @@ func (v *Value) New(args ...*Value) *Value {
// This replaces the previous NewInstance method and provides automatic property binding
// and simplified constructor semantics where constructors work with pre-created instances.
func (v *Value) CallConstructor(args ...*Value) *Value {
if !v.hasValidContext() {
if !v.isAlive() {
return nil
}
cargs := []C.JSValue{}
for _, x := range args {
if !sameContextRef(v, x) {
if !x.belongsTo(v.ctx) {
return nil
}
cargs = append(cargs, x.ref)
Expand Down
Loading
Loading