diff --git a/bridge.c b/bridge.c index 83ff5e4..2af0ce7 100644 --- a/bridge.c +++ b/bridge.c @@ -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) { @@ -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; @@ -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; diff --git a/bridge.h b/bridge.h index 7ff5b26..793b819 100644 --- a/bridge.h +++ b/bridge.h @@ -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); @@ -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); // ============================================================================= diff --git a/callback_dispatch.go b/callback_dispatch.go index 616a936..ef9377c 100644 --- a/callback_dispatch.go +++ b/callback_dispatch.go @@ -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 { diff --git a/context.go b/context.go index 037eb5b..f5e97eb 100644 --- a/context.go +++ b/context.go @@ -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() { @@ -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)} +} diff --git a/context_test.go b/context_test.go index 8f086d7..6e2d4f2 100644 --- a/context_test.go +++ b/context_test.go @@ -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) { @@ -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) diff --git a/runtime.go b/runtime.go index 215b980..7f5c581 100644 --- a/runtime.go +++ b/runtime.go @@ -19,10 +19,34 @@ import ( // Return != 0 if the JS code needs to be interrupted type InterruptHandler func() int +// PromiseHookType mirrors quickjs-ng JSPromiseHookType. +type PromiseHookType int + +const ( + PromiseHookInit PromiseHookType = PromiseHookType(C.JS_PROMISE_HOOK_INIT) + PromiseHookBefore PromiseHookType = PromiseHookType(C.JS_PROMISE_HOOK_BEFORE) + PromiseHookAfter PromiseHookType = PromiseHookType(C.JS_PROMISE_HOOK_AFTER) + PromiseHookResolve PromiseHookType = PromiseHookType(C.JS_PROMISE_HOOK_RESOLVE) +) + +// PromiseHook receives runtime-level promise lifecycle events. +type PromiseHook func(ctx *Context, hookType PromiseHookType, promise *Value, parentPromise *Value) + +// HostPromiseRejectionTracker receives host rejection tracking events. +type HostPromiseRejectionTracker func(ctx *Context, promise *Value, reason *Value, isHandled bool) + type interruptHandlerHolder struct { fn InterruptHandler } +type promiseHookHolder struct { + fn PromiseHook +} + +type hostPromiseRejectionTrackerHolder struct { + fn HostPromiseRejectionTracker +} + // runtimeNewContextHook is used in tests to force JS_NewContext failure paths. // It must remain nil in production. var runtimeNewContextHook func(rt *C.JSRuntime) *C.JSContext @@ -67,6 +91,8 @@ type Runtime struct { ownerGoroutineID atomic.Uint64 ownerThreadID atomic.Uint64 interruptHandlerState atomic.Pointer[interruptHandlerHolder] + promiseHookState atomic.Pointer[promiseHookHolder] + hostRejectionHookState atomic.Pointer[hostPromiseRejectionTrackerHolder] contexts sync.Map contextsByID sync.Map contextIDCounter atomic.Uint64 @@ -160,7 +186,7 @@ const ( DumpFlagAtoms uint64 = C.JS_DUMP_ATOMS DumpFlagShapes uint64 = C.JS_DUMP_SHAPES // DumpFlagAbortOnLeaks aborts on atom/object/string leaks and is intended for testing. - DumpFlagAbortOnLeaks uint64 = C.JS_ABORT_ON_LEAKS + DumpFlagAbortOnLeaks uint64 = C.JS_ABORT_ON_LEAKS ) // IntrinsicSet controls which QuickJS intrinsics are injected into a raw context. @@ -624,7 +650,10 @@ func (r *Runtime) Close() { ref := r.ref r.interruptHandlerState.Store(nil) + r.promiseHookState.Store(nil) + r.hostRejectionHookState.Store(nil) C.ClearInterruptHandler(ref) + C.SetQuickjsGoPromiseHook(ref, 0) C.SetPromiseRejectionTracker(ref, 0) r.constructorRegistry.Range(func(key, _ interface{}) bool { @@ -899,6 +928,111 @@ func (r *Runtime) SetExecuteTimeout(timeout uint64) { r.interruptHandlerState.Store(nil) } +// IsJobPending reports whether there are pending jobs in the runtime queue. +func (r *Runtime) IsJobPending() bool { + if r == nil { + return false + } + if !r.ensureOwnerAccess() { + return false + } + r.mu.RLock() + defer r.mu.RUnlock() + if r.closed.Load() || r.ref == nil { + return false + } + return bool(C.JS_IsJobPending(r.ref)) +} + +// PendingJobContext returns the Context for the next pending job, if any. +func (r *Runtime) PendingJobContext() *Context { + if r == nil { + return nil + } + if !r.ensureOwnerAccess() { + return nil + } + r.mu.RLock() + defer r.mu.RUnlock() + if r.closed.Load() || r.ref == nil { + return nil + } + ctxRef := C.JS_GetPendingJobContext(r.ref) + if ctxRef == nil { + return nil + } + return getContextFromJS(ctxRef) +} + +// GetPendingJobContext returns the Context for the next pending job. +// Deprecated: Use PendingJobContext() instead. +func (r *Runtime) GetPendingJobContext() *Context { + return r.PendingJobContext() +} + +// ExecutePendingJob runs one pending runtime job and returns the raw status code. +// Return values mirror quickjs-ng: +// +// > 0: one job executed +// == 0: no pending jobs +// < 0: job execution raised an exception in the returned Context +func (r *Runtime) ExecutePendingJob() (int, *Context) { + if r == nil { + return 0, nil + } + if !r.ensureOwnerAccess() { + return 0, nil + } + r.mu.RLock() + defer r.mu.RUnlock() + if r.closed.Load() || r.ref == nil { + return 0, nil + } + + var ctxRef *C.JSContext + status := int(C.JS_ExecutePendingJob(r.ref, &ctxRef)) + if ctxRef == nil { + return status, nil + } + return status, getContextFromJS(ctxRef) +} + +// DrainPendingJobs executes pending runtime jobs until the queue is empty, +// an error occurs, or max jobs have been executed. +// +// If max > 0, at most max jobs are executed. If max <= 0, all available +// pending jobs are drained. +func (r *Runtime) DrainPendingJobs(max int) (executed int, lastCtx *Context, err error) { + if r == nil { + return 0, nil, nil + } + + for { + if max > 0 && executed >= max { + return executed, lastCtx, nil + } + + status, ctx := r.ExecutePendingJob() + if ctx != nil { + lastCtx = ctx + } + + if status > 0 { + executed += status + continue + } + if status == 0 { + return executed, lastCtx, nil + } + + err = errors.New("quickjs: failed to execute pending job") + if ctx != nil && ctx.HasException() { + err = ctx.Exception() + } + return executed, lastCtx, err + } +} + // SetStripInfo is retained for backward compatibility only. // Deprecated: quickjs-ng does not expose a runtime-level strip-info API, so this method is a no-op. func (r *Runtime) SetStripInfo(strip int) { @@ -983,6 +1117,50 @@ func (r *Runtime) ClearInterruptHandler() { C.ClearInterruptHandler(r.ref) } +// SetPromiseHook registers a runtime-level promise hook. +// Pass nil to clear the hook. +func (r *Runtime) SetPromiseHook(hook PromiseHook) { + if r == nil { + return + } + if !r.ensureOwnerAccess() { + return + } + r.mu.Lock() + defer r.mu.Unlock() + if r.closed.Load() || r.ref == nil { + return + } + if hook == nil { + r.promiseHookState.Store(nil) + C.SetQuickjsGoPromiseHook(r.ref, 0) + return + } + r.promiseHookState.Store(&promiseHookHolder{fn: hook}) + C.SetQuickjsGoPromiseHook(r.ref, 1) +} + +// SetHostPromiseRejectionTracker registers a host rejection tracker callback. +// Pass nil to clear the hook. +func (r *Runtime) SetHostPromiseRejectionTracker(tracker HostPromiseRejectionTracker) { + if r == nil { + return + } + if !r.ensureOwnerAccess() { + return + } + r.mu.Lock() + defer r.mu.Unlock() + if r.closed.Load() || r.ref == nil { + return + } + if tracker == nil { + r.hostRejectionHookState.Store(nil) + return + } + r.hostRejectionHookState.Store(&hostPromiseRejectionTrackerHolder{fn: tracker}) +} + // callInterruptHandler is called from C layer via runtime mapping (internal use) func (r *Runtime) callInterruptHandler() int { if r == nil { @@ -995,6 +1173,45 @@ func (r *Runtime) callInterruptHandler() int { return 0 // No interrupt } +// callPromiseHook is called from C layer via runtime mapping (internal use). +func (r *Runtime) callPromiseHook(ctxRef *C.JSContext, hookType PromiseHookType, promise C.JSValue, parentPromise C.JSValue) { + if r == nil { + return + } + holder := r.promiseHookState.Load() + if holder == nil || holder.fn == nil { + return + } + ctx := getContextFromJS(ctxRef) + if ctx == nil { + return + } + promiseVal := &Value{ctx: ctx, ref: promise, borrowed: true} + var parentVal *Value + if !bool(C.JS_IsUndefined(parentPromise)) { + parentVal = &Value{ctx: ctx, ref: parentPromise, borrowed: true} + } + holder.fn(ctx, hookType, promiseVal, parentVal) +} + +// callHostPromiseRejectionTracker is called from C layer via runtime mapping (internal use). +func (r *Runtime) callHostPromiseRejectionTracker(ctxRef *C.JSContext, promise C.JSValue, reason C.JSValue, isHandled bool) { + if r == nil { + return + } + holder := r.hostRejectionHookState.Load() + if holder == nil || holder.fn == nil { + return + } + ctx := getContextFromJS(ctxRef) + if ctx == nil { + return + } + promiseVal := &Value{ctx: ctx, ref: promise, borrowed: true} + reasonVal := &Value{ctx: ctx, ref: reason, borrowed: true} + holder.fn(ctx, promiseVal, reasonVal, isHandled) +} + // NewContext creates a new JavaScript context with default host bootstrap. func (r *Runtime) NewContext() *Context { return r.NewContextWithOptions(DefaultBootstrap()) diff --git a/runtime_test.go b/runtime_test.go index f91b255..e97d118 100644 --- a/runtime_test.go +++ b/runtime_test.go @@ -978,6 +978,569 @@ func TestRuntimeSetModuleImportToggle(t *testing.T) { requireImportFailure(ctx) } +func TestRuntimeJobQueuePrimitives(t *testing.T) { + t.Run("PendingAndExecute", func(t *testing.T) { + rt := NewRuntime() + defer rt.Close() + ctx := rt.NewContext() + require.NotNil(t, ctx) + defer ctx.Close() + + setup := ctx.Eval(` + (() => { + globalThis.__jobPrimitiveCounter = 0; + Promise.resolve().then(() => { globalThis.__jobPrimitiveCounter = 1; }); + return 0; + })() + `) + require.NotNil(t, setup) + defer setup.Free() + require.False(t, setup.IsException()) + + require.True(t, rt.IsJobPending()) + + pendingCtx := rt.PendingJobContext() + require.Same(t, pendingCtx, rt.GetPendingJobContext()) + if pendingCtx != nil { + require.Same(t, ctx, pendingCtx) + } + + executed, executedCtx := rt.ExecutePendingJob() + require.Greater(t, executed, 0) + if executedCtx != nil { + require.Same(t, ctx, executedCtx) + } + + for rt.IsJobPending() { + status, jobCtx := rt.ExecutePendingJob() + require.NotEqual(t, 0, status) + if jobCtx != nil { + require.Same(t, ctx, jobCtx) + } + } + + counter := ctx.Eval(`globalThis.__jobPrimitiveCounter`) + require.NotNil(t, counter) + defer counter.Free() + require.False(t, counter.IsException()) + require.EqualValues(t, 1, counter.ToInt32()) + + status, idleCtx := rt.ExecutePendingJob() + require.Equal(t, 0, status) + require.False(t, rt.IsJobPending()) + require.Nil(t, rt.PendingJobContext()) + require.Nil(t, rt.GetPendingJobContext()) + if idleCtx != nil { + require.Same(t, ctx, idleCtx) + } + }) + + t.Run("FailClosed", func(t *testing.T) { + var nilRuntime *Runtime + require.False(t, nilRuntime.IsJobPending()) + require.Nil(t, nilRuntime.PendingJobContext()) + require.Nil(t, nilRuntime.GetPendingJobContext()) + status, pendingCtx := nilRuntime.ExecutePendingJob() + require.Equal(t, 0, status) + require.Nil(t, pendingCtx) + + func() { + ownerHook := ownerCheckCurrentGoroutineID + ownerRT := NewRuntime() + require.NotNil(t, ownerRT) + defer func() { + ownerCheckCurrentGoroutineID = ownerHook + ownerRT.Close() + }() + ownerCheckCurrentGoroutineID = func() uint64 { return 0 } + require.False(t, ownerRT.IsJobPending()) + require.Nil(t, ownerRT.PendingJobContext()) + require.Nil(t, ownerRT.GetPendingJobContext()) + status, pendingCtx = ownerRT.ExecutePendingJob() + require.Equal(t, 0, status) + require.Nil(t, pendingCtx) + }() + + closedRT := NewRuntime() + closedCtx := closedRT.NewContext() + require.NotNil(t, closedCtx) + closedCtx.Close() + closedRT.Close() + + require.False(t, closedRT.IsJobPending()) + require.Nil(t, closedRT.PendingJobContext()) + require.Nil(t, closedRT.GetPendingJobContext()) + status, pendingCtx = closedRT.ExecutePendingJob() + require.Equal(t, 0, status) + require.Nil(t, pendingCtx) + }) +} + +func TestRuntimeDrainPendingJobs(t *testing.T) { + t.Run("LimitAndDrainAll", func(t *testing.T) { + rt := NewRuntime() + defer rt.Close() + ctx := rt.NewContext() + require.NotNil(t, ctx) + defer ctx.Close() + + setup := ctx.Eval(` + (() => { + globalThis.__drainCounter = 0; + Promise.resolve().then(() => { globalThis.__drainCounter += 1; }); + Promise.resolve().then(() => { globalThis.__drainCounter += 1; }); + return 0; + })() + `) + require.NotNil(t, setup) + defer setup.Free() + require.False(t, setup.IsException()) + + require.True(t, rt.IsJobPending()) + + executed, lastCtx, err := rt.DrainPendingJobs(1) + require.NoError(t, err) + require.Equal(t, 1, executed) + if lastCtx != nil { + require.Same(t, ctx, lastCtx) + } + + counterAfterOne := ctx.Eval(`globalThis.__drainCounter`) + require.NotNil(t, counterAfterOne) + defer counterAfterOne.Free() + require.False(t, counterAfterOne.IsException()) + require.EqualValues(t, 1, counterAfterOne.ToInt32()) + + executed, lastCtx, err = rt.DrainPendingJobs(0) + require.NoError(t, err) + require.GreaterOrEqual(t, executed, 1) + if lastCtx != nil { + require.Same(t, ctx, lastCtx) + } + + counter := ctx.Eval(`globalThis.__drainCounter`) + require.NotNil(t, counter) + defer counter.Free() + require.False(t, counter.IsException()) + require.EqualValues(t, 2, counter.ToInt32()) + + executed, lastCtx, err = rt.DrainPendingJobs(0) + require.NoError(t, err) + require.Equal(t, 0, executed) + require.Nil(t, lastCtx) + }) + + t.Run("ErrorPath", func(t *testing.T) { + rt := NewRuntime() + defer rt.Close() + ctx := rt.NewContext() + require.NotNil(t, ctx) + defer ctx.Close() + + setup := ctx.Eval(` + (() => { + queueMicrotask(() => { throw new Error("drain boom"); }); + return 0; + })() + `) + require.NotNil(t, setup) + defer setup.Free() + require.False(t, setup.IsException()) + + require.True(t, rt.IsJobPending()) + + executed, lastCtx, err := rt.DrainPendingJobs(0) + require.Equal(t, 0, executed) + require.Error(t, err) + require.Contains(t, err.Error(), "drain boom") + if lastCtx != nil { + require.Same(t, ctx, lastCtx) + } + }) + + t.Run("FailClosed", func(t *testing.T) { + var nilRuntime *Runtime + executed, lastCtx, err := nilRuntime.DrainPendingJobs(0) + require.Equal(t, 0, executed) + require.Nil(t, lastCtx) + require.NoError(t, err) + + func() { + ownerHook := ownerCheckCurrentGoroutineID + ownerRT := NewRuntime() + require.NotNil(t, ownerRT) + defer func() { + ownerCheckCurrentGoroutineID = ownerHook + ownerRT.Close() + }() + ownerCheckCurrentGoroutineID = func() uint64 { return 0 } + + executed, lastCtx, err = ownerRT.DrainPendingJobs(0) + require.Equal(t, 0, executed) + require.Nil(t, lastCtx) + require.NoError(t, err) + }() + + closedRT := NewRuntime() + closedCtx := closedRT.NewContext() + require.NotNil(t, closedCtx) + closedCtx.Close() + closedRT.Close() + + executed, lastCtx, err = closedRT.DrainPendingJobs(0) + require.Equal(t, 0, executed) + require.Nil(t, lastCtx) + require.NoError(t, err) + }) +} + +func TestRuntimePromiseHookAPI(t *testing.T) { + drainPendingJobs := func(t *testing.T, rt *Runtime, ctx *Context) { + t.Helper() + for rt.IsJobPending() { + status, _ := rt.ExecutePendingJob() + require.NotEqual(t, 0, status) + if status < 0 { + require.Error(t, ctx.Exception()) + return + } + } + } + + t.Run("CallbackLifecycle", func(t *testing.T) { + rt := NewRuntime() + defer rt.Close() + ctx := rt.NewContext() + require.NotNil(t, ctx) + defer ctx.Close() + + type hookEvent struct { + hookType PromiseHookType + hasParent bool + isPromise bool + } + + var ( + eventsMu sync.Mutex + events []hookEvent + ) + ctxMatched := atomic.Bool{} + ctxMatched.Store(true) + + rt.SetPromiseHook(func(hookCtx *Context, hookType PromiseHookType, promise *Value, parentPromise *Value) { + if hookCtx != ctx { + ctxMatched.Store(false) + } + + event := hookEvent{hookType: hookType} + if promise != nil { + event.isPromise = promise.IsPromise() + } + if parentPromise != nil { + event.hasParent = !parentPromise.IsUndefined() + } + + eventsMu.Lock() + events = append(events, event) + eventsMu.Unlock() + }) + + promise := ctx.Eval(` + (() => { + const root = Promise.resolve(1); + return root.then((v) => v + 1); + })() + `) + require.NotNil(t, promise) + defer promise.Free() + require.False(t, promise.IsException()) + + result := ctx.Await(promise) + require.NotNil(t, result) + defer result.Free() + require.False(t, result.IsException()) + require.EqualValues(t, 2, result.ToInt32()) + + drainPendingJobs(t, rt, ctx) + + eventsMu.Lock() + snapshot := append([]hookEvent(nil), events...) + eventsMu.Unlock() + + require.True(t, ctxMatched.Load()) + require.NotEmpty(t, snapshot) + + foundInit := false + foundResolve := false + foundParent := false + for _, event := range snapshot { + if event.hookType == PromiseHookInit { + foundInit = true + } + if event.hookType == PromiseHookResolve { + foundResolve = true + } + if event.hasParent { + foundParent = true + } + require.True(t, event.isPromise) + } + require.True(t, foundInit) + require.True(t, foundResolve) + require.True(t, foundParent) + + rt.SetPromiseHook(nil) + before := len(snapshot) + + clearedPromise := ctx.Eval(`Promise.resolve(3).then((v) => v)`) + require.NotNil(t, clearedPromise) + defer clearedPromise.Free() + require.False(t, clearedPromise.IsException()) + + clearedResult := ctx.Await(clearedPromise) + require.NotNil(t, clearedResult) + defer clearedResult.Free() + require.False(t, clearedResult.IsException()) + require.EqualValues(t, 3, clearedResult.ToInt32()) + + eventsMu.Lock() + after := len(events) + eventsMu.Unlock() + require.Equal(t, before, after) + }) + + t.Run("FailClosed", func(t *testing.T) { + var nilRuntime *Runtime + nilRuntime.SetPromiseHook(func(*Context, PromiseHookType, *Value, *Value) {}) + + func() { + ownerHook := ownerCheckCurrentGoroutineID + ownerRT := NewRuntime() + require.NotNil(t, ownerRT) + defer func() { + ownerCheckCurrentGoroutineID = ownerHook + ownerRT.Close() + }() + ownerCheckCurrentGoroutineID = func() uint64 { return 0 } + ownerRT.SetPromiseHook(func(*Context, PromiseHookType, *Value, *Value) {}) + }() + + closedRT := NewRuntime() + closedCtx := closedRT.NewContext() + require.NotNil(t, closedCtx) + closedCtx.Close() + closedRT.Close() + closedRT.SetPromiseHook(func(*Context, PromiseHookType, *Value, *Value) {}) + }) + + t.Run("DispatchGuards", func(t *testing.T) { + rt := NewRuntime() + defer rt.Close() + ctx := rt.NewContext() + require.NotNil(t, ctx) + defer ctx.Close() + + promise := ctx.Eval(`Promise.resolve(1)`) + require.NotNil(t, promise) + defer promise.Free() + require.False(t, promise.IsException()) + + undefinedParent := ctx.Eval(`void 0`) + require.NotNil(t, undefinedParent) + defer undefinedParent.Free() + require.False(t, undefinedParent.IsException()) + + var nilRuntime *Runtime + nilRuntime.callPromiseHook(ctx.ref, PromiseHookInit, promise.ref, promise.ref) + + // Holder not set should no-op. + rt.callPromiseHook(ctx.ref, PromiseHookInit, promise.ref, promise.ref) + + calls := atomic.Int32{} + rt.SetPromiseHook(func(*Context, PromiseHookType, *Value, *Value) { + calls.Add(1) + }) + + // Nil context should fail closed. + rt.callPromiseHook(nil, PromiseHookInit, promise.ref, promise.ref) + require.EqualValues(t, 0, calls.Load()) + + // Exercise parent undefined and parent present branches. + rt.callPromiseHook(ctx.ref, PromiseHookInit, promise.ref, undefinedParent.ref) + rt.callPromiseHook(ctx.ref, PromiseHookInit, promise.ref, promise.ref) + require.EqualValues(t, 2, calls.Load()) + + // C dispatch guard: runtime mapping miss should no-op. + goPromiseHook(nil, nil, 0, promise.ref, promise.ref) + }) +} + +func TestRuntimeHostPromiseRejectionTrackerAPI(t *testing.T) { + drainPendingJobs := func(t *testing.T, rt *Runtime, ctx *Context) { + t.Helper() + for rt.IsJobPending() { + status, _ := rt.ExecutePendingJob() + require.NotEqual(t, 0, status) + if status < 0 { + require.Error(t, ctx.Exception()) + return + } + } + } + + t.Run("CallbackLifecycle", func(t *testing.T) { + rt := NewRuntime() + defer rt.Close() + ctx := rt.NewContext() + require.NotNil(t, ctx) + defer ctx.Close() + + type rejectionEvent struct { + reason string + isHandled bool + ctxMatched bool + isPromise bool + } + + var ( + eventsMu sync.Mutex + events []rejectionEvent + ) + + rt.SetHostPromiseRejectionTracker(func(hookCtx *Context, promise *Value, reason *Value, isHandled bool) { + event := rejectionEvent{ + isHandled: isHandled, + ctxMatched: hookCtx == ctx, + isPromise: promise != nil && promise.IsPromise(), + } + if reason != nil && !reason.IsUndefined() { + event.reason = reason.ToString() + } + eventsMu.Lock() + events = append(events, event) + eventsMu.Unlock() + }) + + setup := ctx.Eval(` + (() => { + globalThis.__trackerPromise = Promise.reject("tracker-reason"); + return 0; + })() + `) + require.NotNil(t, setup) + defer setup.Free() + require.False(t, setup.IsException()) + + drainPendingJobs(t, rt, ctx) + + attach := ctx.Eval(`globalThis.__trackerPromise.catch(() => {});`) + require.NotNil(t, attach) + defer attach.Free() + require.False(t, attach.IsException()) + + drainPendingJobs(t, rt, ctx) + + eventsMu.Lock() + snapshot := append([]rejectionEvent(nil), events...) + eventsMu.Unlock() + + require.NotEmpty(t, snapshot) + + hasUnhandled := false + hasHandled := false + for _, event := range snapshot { + require.True(t, event.ctxMatched) + require.True(t, event.isPromise) + if event.reason == "tracker-reason" && !event.isHandled { + hasUnhandled = true + } + if event.reason == "tracker-reason" && event.isHandled { + hasHandled = true + } + } + require.True(t, hasUnhandled) + require.True(t, hasHandled) + + rt.SetHostPromiseRejectionTracker(nil) + before := len(snapshot) + + cleared := ctx.Eval(`Promise.reject("tracker-cleared");`) + require.NotNil(t, cleared) + defer cleared.Free() + require.False(t, cleared.IsException()) + + drainPendingJobs(t, rt, ctx) + + eventsMu.Lock() + after := len(events) + eventsMu.Unlock() + require.Equal(t, before, after) + }) + + t.Run("FailClosed", func(t *testing.T) { + var nilRuntime *Runtime + nilRuntime.SetHostPromiseRejectionTracker(func(*Context, *Value, *Value, bool) {}) + + func() { + ownerHook := ownerCheckCurrentGoroutineID + ownerRT := NewRuntime() + require.NotNil(t, ownerRT) + defer func() { + ownerCheckCurrentGoroutineID = ownerHook + ownerRT.Close() + }() + ownerCheckCurrentGoroutineID = func() uint64 { return 0 } + ownerRT.SetHostPromiseRejectionTracker(func(*Context, *Value, *Value, bool) {}) + }() + + closedRT := NewRuntime() + closedCtx := closedRT.NewContext() + require.NotNil(t, closedCtx) + closedCtx.Close() + closedRT.Close() + closedRT.SetHostPromiseRejectionTracker(func(*Context, *Value, *Value, bool) {}) + }) + + t.Run("DispatchGuards", func(t *testing.T) { + rt := NewRuntime() + defer rt.Close() + ctx := rt.NewContext() + require.NotNil(t, ctx) + defer ctx.Close() + + promise := ctx.Eval(`Promise.resolve(1)`) + require.NotNil(t, promise) + defer promise.Free() + require.False(t, promise.IsException()) + + reason := ctx.Eval(`"reason"`) + require.NotNil(t, reason) + defer reason.Free() + require.False(t, reason.IsException()) + + var nilRuntime *Runtime + nilRuntime.callHostPromiseRejectionTracker(ctx.ref, promise.ref, reason.ref, false) + + // Holder not set should no-op. + rt.callHostPromiseRejectionTracker(ctx.ref, promise.ref, reason.ref, false) + + calls := atomic.Int32{} + rt.SetHostPromiseRejectionTracker(func(*Context, *Value, *Value, bool) { + calls.Add(1) + }) + + // Nil context should fail closed. + rt.callHostPromiseRejectionTracker(nil, promise.ref, reason.ref, false) + require.EqualValues(t, 0, calls.Load()) + + rt.callHostPromiseRejectionTracker(ctx.ref, promise.ref, reason.ref, false) + require.EqualValues(t, 1, calls.Load()) + + // C dispatch guard: runtime mapping miss should no-op. + goHostPromiseRejectionTracker(nil, nil, promise.ref, reason.ref, 0) + }) +} + func TestRuntimeTimeoutOpaqueLifecycle(t *testing.T) { base := timeoutOpaqueCount() diff --git a/value.go b/value.go index 6ed1ba5..9ff1918 100644 --- a/value.go +++ b/value.go @@ -1114,26 +1114,34 @@ func (v *Value) IsPromise() bool { type PromiseState int const ( - PromisePending PromiseState = iota - PromiseFulfilled - PromiseRejected + PromiseNotAPromise PromiseState = PromiseState(C.JS_PROMISE_NOT_A_PROMISE) + PromisePending PromiseState = PromiseState(C.JS_PROMISE_PENDING) + PromiseFulfilled PromiseState = PromiseState(C.JS_PROMISE_FULFILLED) + PromiseRejected PromiseState = PromiseState(C.JS_PROMISE_REJECTED) ) -// PromiseState returns the state of the Promise +// PromiseState returns the promise state, or PromiseNotAPromise. func (v *Value) PromiseState() PromiseState { - if !v.IsPromise() { - return PromisePending + if !v.hasValidContext() { + return PromiseNotAPromise } - state := C.JS_PromiseState(v.ctx.ref, v.ref) + state := PromiseState(C.JS_PromiseState(v.ctx.ref, v.ref)) switch state { - case C.JSPromiseStateEnum(C.JS_PROMISE_PENDING): - return PromisePending - case C.JSPromiseStateEnum(C.JS_PROMISE_FULFILLED): - return PromiseFulfilled + case PromiseNotAPromise, PromisePending, PromiseFulfilled, PromiseRejected: + return state default: - return PromiseRejected + return PromiseNotAPromise + } +} + +// PromiseResult returns the current promise result value. +// Non-promise values follow quickjs-ng behavior and return undefined. +func (v *Value) PromiseResult() *Value { + if !v.hasValidContext() { + return nil } + return &Value{ctx: v.ctx, ref: C.JS_PromiseResult(v.ctx.ref, v.ref)} } // Await waits for promise resolution and executes pending jobs diff --git a/value_test.go b/value_test.go index ca12c9f..5672045 100644 --- a/value_test.go +++ b/value_test.go @@ -1542,7 +1542,56 @@ func TestPromiseState(t *testing.T) { // Test non-promise value (covers first if branch) - Updated to use New* methods nonPromise := ctx.NewString("not a promise") defer nonPromise.Free() - require.Equal(t, PromisePending, nonPromise.PromiseState()) + require.Equal(t, PromiseNotAPromise, nonPromise.PromiseState()) + + var nilValue *Value + require.Equal(t, PromiseNotAPromise, nilValue.PromiseState()) +} + +func TestPromiseResult(t *testing.T) { + useStableOwnerHooksForLegacySubtests(t) + + rt := NewRuntime() + defer rt.Close() + ctx := rt.NewContext() + defer ctx.Close() + + fulfilled := ctx.Eval(`Promise.resolve("ok")`) + require.False(t, fulfilled.IsException()) + defer fulfilled.Free() + + fulfilledResult := fulfilled.PromiseResult() + require.NotNil(t, fulfilledResult) + defer fulfilledResult.Free() + require.Equal(t, "ok", fulfilledResult.ToString()) + + rejected := ctx.Eval(`Promise.reject("bad")`) + require.False(t, rejected.IsException()) + defer rejected.Free() + + rejectedResult := rejected.PromiseResult() + require.NotNil(t, rejectedResult) + defer rejectedResult.Free() + require.Equal(t, "bad", rejectedResult.ToString()) + + pending := ctx.Eval(`new Promise(() => {})`) + require.False(t, pending.IsException()) + defer pending.Free() + + pendingResult := pending.PromiseResult() + require.NotNil(t, pendingResult) + defer pendingResult.Free() + require.True(t, pendingResult.IsUndefined()) + + nonPromise := ctx.NewString("not a promise") + defer nonPromise.Free() + nonPromiseResult := nonPromise.PromiseResult() + require.NotNil(t, nonPromiseResult) + defer nonPromiseResult.Free() + require.True(t, nonPromiseResult.IsUndefined()) + + var nilValue *Value + require.Nil(t, nilValue.PromiseResult()) } // TestValueAwait tests promise await functionality