Skip to content
Closed
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
42 changes: 42 additions & 0 deletions bridge.c
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ static void QuickjsGoPromiseRejectionTracker(JSContext *ctx,
JSRuntime *rt = JS_GetRuntime(ctx);
(void)opaque;

goHostPromiseRejectionTracker(rt, ctx, promise, reason, is_handled ? 1 : 0);

qjsgo_mutex_lock(&g_rejection_states_mu);
RejectionStateEntry *state = getRejectionState(rt, 1);
if (!state) {
Expand Down Expand Up @@ -249,6 +251,29 @@ static void QuickjsGoPromiseRejectionTracker(JSContext *ctx,
qjsgo_mutex_unlock(&g_rejection_states_mu);
}

static void QuickjsGoPromiseHook(JSContext *ctx,
JSPromiseHookType type,
JSValueConst promise,
JSValueConst parent_promise,
void *opaque) {
JSRuntime *rt = JS_GetRuntime(ctx);
(void)opaque;
goPromiseHook(rt, ctx, (int)type, promise, parent_promise);
}

void SetQuickjsGoPromiseHook(JSRuntime *rt, int enabled) {
if (!rt) {
return;
}

if (enabled) {
JS_SetPromiseHook(rt, QuickjsGoPromiseHook, NULL);
return;
}

JS_SetPromiseHook(rt, NULL, NULL);
}

void SetPromiseRejectionTracker(JSRuntime *rt, int enabled) {
if (!rt) {
return;
Expand Down Expand Up @@ -344,6 +369,23 @@ JSValue CallPropertyByNameLen(JSContext *ctx, JSValueConst obj, const char *name
return ret;
}

static JSValue QuickjsGoCallableJob(JSContext *ctx, int argc, JSValueConst *argv) {
if (argc < 1) {
return JS_ThrowInternalError(ctx, "invalid native job payload");
}
if (!JS_IsFunction(ctx, argv[0])) {
return JS_ThrowTypeError(ctx, "native job target must be callable");
}
return JS_Call(ctx, argv[0], JS_UNDEFINED, argc - 1, argv + 1);
}

int EnqueueCallableJob(JSContext *ctx, int argc, JSValue *argv) {
if (!ctx || argc < 1 || !argv) {
return -1;
}
return JS_EnqueueJob(ctx, QuickjsGoCallableJob, argc, argv);
}

int DetectModuleSourceWithProbe(JSContext *ctx, const char *code, size_t code_len) {
if (!JS_DetectModule(code, code_len)) {
return 0;
Expand Down
2 changes: 2 additions & 0 deletions bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ extern int JS_DeletePropertyInt64(JSContext *ctx, JSValueConst obj, int64_t idx,
extern int SetPropertyByNameLen(JSContext *ctx, JSValueConst obj, const char *name, size_t name_len, JSValue val);
extern JSValue GetPropertyByNameLen(JSContext *ctx, JSValueConst obj, const char *name, size_t name_len);
extern JSValue CallPropertyByNameLen(JSContext *ctx, JSValueConst obj, const char *name, size_t name_len, int argc, JSValue *argv);
extern int EnqueueCallableJob(JSContext *ctx, int argc, JSValue *argv);
extern int DetectModuleSourceWithProbe(JSContext *ctx, const char *code, size_t code_len);
extern JSValue AwaitValue(JSContext *ctx, JSValue obj);
extern JSValue EvalAndAwait(JSContext *ctx, const char *input, size_t input_len, const char *filename, int eval_flags);
Expand Down Expand Up @@ -130,6 +131,7 @@ extern void SetInterruptHandler(JSRuntime *rt);
extern void ClearInterruptHandler(JSRuntime *rt);
extern void SetExecuteTimeout(JSRuntime *rt, time_t timeout);
extern void SetPromiseRejectionTracker(JSRuntime *rt, int enabled);
extern void SetQuickjsGoPromiseHook(JSRuntime *rt, int enabled);
extern int GetTimeoutOpaqueCount(void);

// =============================================================================
Expand Down
18 changes: 18 additions & 0 deletions callback_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@ func goInterruptHandler(runtimePtr *C.JSRuntime) C.int {
return C.int(runtime.callInterruptHandler())
}

//export goPromiseHook
func goPromiseHook(runtimePtr *C.JSRuntime, ctx *C.JSContext, hookType C.int, promise C.JSValueConst, parentPromise C.JSValueConst) {
runtime := getRuntimeFromJS(runtimePtr)
if runtime == nil {
return
}
runtime.callPromiseHook(ctx, PromiseHookType(hookType), C.JSValue(promise), C.JSValue(parentPromise))
}

//export goHostPromiseRejectionTracker
func goHostPromiseRejectionTracker(runtimePtr *C.JSRuntime, ctx *C.JSContext, promise C.JSValueConst, reason C.JSValueConst, isHandled C.int) {
runtime := getRuntimeFromJS(runtimePtr)
if runtime == nil {
return
}
runtime.callHostPromiseRejectionTracker(ctx, C.JSValue(promise), C.JSValue(reason), isHandled != 0)
}

//export goFunctionProxy
func goFunctionProxy(ctx *C.JSContext, thisVal C.JSValueConst,
argc C.int, argv *C.JSValueConst, magic C.int) C.JSValue {
Expand Down
63 changes: 63 additions & 0 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,30 @@ func (ctx *Context) Schedule(job func(*Context)) bool {
}
}

// EnqueueJob enqueues a job to run on the Context thread.
func (ctx *Context) EnqueueJob(job func(*Context)) bool {
return ctx.Schedule(job)
}

// EnqueueNativeJob enqueues a callable into the QuickJS native job queue.
// The callable and all args must belong to the same Context.
func (ctx *Context) EnqueueNativeJob(fn *Value, args ...*Value) bool {
if !ctx.hasValidRef() || fn == nil || !fn.belongsTo(ctx) || !fn.IsFunction() {
return false
}

cArgs := make([]C.JSValue, len(args)+1)
cArgs[0] = fn.ref
for i, arg := range args {
if arg == nil || !arg.belongsTo(ctx) {
return false
}
cArgs[i+1] = arg.ref
}

return C.EnqueueCallableJob(ctx.ref, C.int(len(cArgs)), &cArgs[0]) == 0
}

// ProcessJobs drains all pending scheduled jobs.
// Call this regularly (e.g., inside Loop or Await) to allow resolve/reject handlers to run.
func (ctx *Context) ProcessJobs() {
Expand Down Expand Up @@ -1469,3 +1493,42 @@ func (ctx *Context) NewPromise(executor func(resolve, reject func(*Value))) *Val
func (ctx *Context) Promise(executor func(resolve, reject func(*Value))) *Value {
return ctx.NewPromise(executor)
}

// PromiseCapability mirrors QuickJS promise capability tuple.
type PromiseCapability struct {
Promise *Value
Resolve *Value
Reject *Value
}

// NewPromiseCapability creates a promise plus its resolve/reject functions.
func (ctx *Context) NewPromiseCapability() *PromiseCapability {
if !ctx.hasValidRef() {
return nil
}

resolving := [2]C.JSValue{C.JS_NewUndefined(), C.JS_NewUndefined()}
promise := C.JS_NewPromiseCapability(ctx.ref, &resolving[0])
return &PromiseCapability{
Promise: &Value{ctx: ctx, ref: promise},
Resolve: &Value{ctx: ctx, ref: resolving[0]},
Reject: &Value{ctx: ctx, ref: resolving[1]},
}
}

// NewSettledPromise creates an already-fulfilled or already-rejected promise.
func (ctx *Context) NewSettledPromise(value *Value, isReject bool) *Value {
if !ctx.hasValidRef() {
return nil
}

input := C.JS_NewUndefined()
if value != nil {
if !value.belongsTo(ctx) {
return nil
}
input = value.ref
}

return &Value{ctx: ctx, ref: C.JS_NewSettledPromise(ctx.ref, C.bool(isReject), input)}
}
196 changes: 195 additions & 1 deletion context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1160,7 +1160,7 @@ func TestContextPromise(t *testing.T) {
// Test PromiseState on non-Promise
nonPromise := ctx.NewString("not a promise")
defer nonPromise.Free()
require.Equal(t, PromisePending, nonPromise.PromiseState()) // Should return default
require.Equal(t, PromiseNotAPromise, nonPromise.PromiseState())
})

t.Run("ValueAwait", func(t *testing.T) {
Expand Down Expand Up @@ -1269,6 +1269,200 @@ func TestContextPromise(t *testing.T) {
})
}

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

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

t.Run("NewPromiseCapability", func(t *testing.T) {
capability := ctx.NewPromiseCapability()
require.NotNil(t, capability)
require.NotNil(t, capability.Promise)
require.NotNil(t, capability.Resolve)
require.NotNil(t, capability.Reject)
defer capability.Promise.Free()
defer capability.Resolve.Free()
defer capability.Reject.Free()
require.True(t, capability.Promise.IsPromise())

thisVal := ctx.NewUndefined()
defer thisVal.Free()

resolvedValue := ctx.NewString("capability resolved")
defer resolvedValue.Free()
resolvedCall := capability.Resolve.Execute(thisVal, resolvedValue)
require.NotNil(t, resolvedCall)
defer resolvedCall.Free()
require.False(t, resolvedCall.IsException())

resolvedResult := ctx.Await(capability.Promise)
defer resolvedResult.Free()
require.False(t, resolvedResult.IsException())
require.Equal(t, "capability resolved", resolvedResult.ToString())

rejectedCapability := ctx.NewPromiseCapability()
require.NotNil(t, rejectedCapability)
defer rejectedCapability.Promise.Free()
defer rejectedCapability.Resolve.Free()
defer rejectedCapability.Reject.Free()

errObj := ctx.NewError(errors.New("capability rejected"))
defer errObj.Free()
rejectedCall := rejectedCapability.Reject.Execute(thisVal, errObj)
require.NotNil(t, rejectedCall)
defer rejectedCall.Free()
require.False(t, rejectedCall.IsException())

rejectedResult := ctx.Await(rejectedCapability.Promise)
defer rejectedResult.Free()
require.True(t, rejectedResult.IsException())
err := ctx.Exception()
require.Error(t, err)
require.Contains(t, err.Error(), "capability rejected")
})

t.Run("NewSettledPromise", func(t *testing.T) {
fulfilledValue := ctx.NewString("settled fulfilled")
defer fulfilledValue.Free()
fulfilledPromise := ctx.NewSettledPromise(fulfilledValue, false)
require.NotNil(t, fulfilledPromise)
defer fulfilledPromise.Free()
require.True(t, fulfilledPromise.IsPromise())

fulfilledResult := ctx.Await(fulfilledPromise)
defer fulfilledResult.Free()
require.False(t, fulfilledResult.IsException())
require.Equal(t, "settled fulfilled", fulfilledResult.ToString())

errObj := ctx.NewError(errors.New("settled rejected"))
defer errObj.Free()
rejectedPromise := ctx.NewSettledPromise(errObj, true)
require.NotNil(t, rejectedPromise)
defer rejectedPromise.Free()
require.True(t, rejectedPromise.IsPromise())

rejectedResult := ctx.Await(rejectedPromise)
defer rejectedResult.Free()
require.True(t, rejectedResult.IsException())
err := ctx.Exception()
require.Error(t, err)
require.Contains(t, err.Error(), "settled rejected")

undefinedPromise := ctx.NewSettledPromise(nil, false)
require.NotNil(t, undefinedPromise)
defer undefinedPromise.Free()
undefinedResult := ctx.Await(undefinedPromise)
defer undefinedResult.Free()
require.True(t, undefinedResult.IsUndefined())
})

t.Run("PromisePrimitiveFailClosed", func(t *testing.T) {
var nilCtx *Context
require.Nil(t, nilCtx.NewPromiseCapability())
require.Nil(t, nilCtx.NewSettledPromise(nil, false))
require.False(t, nilCtx.EnqueueJob(func(*Context) {}))
require.False(t, nilCtx.EnqueueNativeJob(nil))

otherRT := NewRuntime()
defer otherRT.Close()
otherCtx := otherRT.NewContext()
require.NotNil(t, otherCtx)
defer otherCtx.Close()
foreignValue := otherCtx.NewString("foreign")
defer foreignValue.Free()
require.Nil(t, ctx.NewSettledPromise(foreignValue, false))

foreignFn := otherCtx.Eval(`() => 1`)
require.NotNil(t, foreignFn)
defer foreignFn.Free()
require.False(t, foreignFn.IsException())
require.False(t, ctx.EnqueueNativeJob(foreignFn))

nativeFn := ctx.Eval(`() => 0`)
require.NotNil(t, nativeFn)
defer nativeFn.Free()
require.False(t, nativeFn.IsException())
require.False(t, ctx.EnqueueNativeJob(nativeFn, foreignValue))

notCallable := ctx.NewString("not callable")
defer notCallable.Free()
require.False(t, ctx.EnqueueNativeJob(notCallable))
require.False(t, ctx.EnqueueNativeJob(nil))

closedRT := NewRuntime()
closedCtx := closedRT.NewContext()
require.NotNil(t, closedCtx)
closedCtx.Close()
require.Nil(t, closedCtx.NewPromiseCapability())
require.Nil(t, closedCtx.NewSettledPromise(nil, false))
require.False(t, closedCtx.EnqueueJob(func(*Context) {}))
require.False(t, closedCtx.EnqueueNativeJob(nil))
closedRT.Close()
})

t.Run("EnqueueJob", func(t *testing.T) {
executed := false
require.True(t, ctx.EnqueueJob(func(inner *Context) {
require.Same(t, ctx, inner)
executed = true
}))
ctx.ProcessJobs()
require.True(t, executed)
})

t.Run("EnqueueNativeJob", func(t *testing.T) {
initCounter := ctx.Eval(`globalThis.__nativeJobCounter = 0;`)
require.NotNil(t, initCounter)
defer initCounter.Free()
require.False(t, initCounter.IsException())

callable := ctx.Eval(`(delta) => { globalThis.__nativeJobCounter += delta; return globalThis.__nativeJobCounter; }`)
require.NotNil(t, callable)
defer callable.Free()
require.False(t, callable.IsException())

arg := ctx.NewInt32(2)
defer arg.Free()
require.True(t, ctx.EnqueueNativeJob(callable, arg))

before := ctx.Eval(`globalThis.__nativeJobCounter`)
require.NotNil(t, before)
defer before.Free()
require.False(t, before.IsException())
require.EqualValues(t, 0, before.ToInt32())

status, jobCtx := rt.ExecutePendingJob()
require.Greater(t, status, 0)
if jobCtx != nil {
require.Same(t, ctx, jobCtx)
}

after := ctx.Eval(`globalThis.__nativeJobCounter`)
require.NotNil(t, after)
defer after.Free()
require.False(t, after.IsException())
require.EqualValues(t, 2, after.ToInt32())

thrower := ctx.Eval(`() => { throw new Error("native job failed"); }`)
require.NotNil(t, thrower)
defer thrower.Free()
require.False(t, thrower.IsException())

require.True(t, ctx.EnqueueNativeJob(thrower))
status, jobCtx = rt.ExecutePendingJob()
require.Less(t, status, 0)
if jobCtx != nil {
require.Same(t, ctx, jobCtx)
}
err := ctx.Exception()
require.Error(t, err)
require.Contains(t, err.Error(), "native job failed")
})
}

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

Expand Down
Loading
Loading