diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b2addcc..667e17b56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add support for `Isolate.SetPromiseRejectedCallback` allowing the embedder to react to unhandled promise rejectsions, or when resolving a promise multiple times. + ### Changed ## [v0.33.0] - 2025-05-15 diff --git a/context.cc b/context.cc index ccaaf2d8b..0f5034f4a 100644 --- a/context.cc +++ b/context.cc @@ -1,3 +1,5 @@ +#include "context.h" +#include "deps/include/v8-external.h" #include "deps/include/v8-template.h" #include "context-macros.h" @@ -27,11 +29,13 @@ ContextPtr NewContext(IsolatePtr iso, // side to lookup the context in the context registry. We use slot 1 as slot 0 // has special meaning for the Chrome debugger. Local local_ctx = Context::New(iso, nullptr, global_template); - local_ctx->SetEmbedderData(1, Integer::New(iso, ref)); m_ctx* ctx = new m_ctx; ctx->ptr.Reset(iso, local_ctx); ctx->iso = iso; + local_ctx->SetEmbedderData(ContextDataIndex::REF, Integer::New(iso, ref)); + local_ctx->SetEmbedderData(ContextDataIndex::WRAPPER_PTR, + External::New(iso, ctx)); return ctx; } @@ -56,6 +60,15 @@ void ContextFree(ContextPtr ctx) { delete ctx; } +m_value* track_value(m_ctx* ctx, Local value) { + m_value* val = new m_value; + val->id = 0; + val->iso = ctx->iso; + val->ctx = ctx; + val->ptr = Global(ctx->iso, value); + return tracked_value(ctx, val); +} + m_value* tracked_value(m_ctx* ctx, m_value* val) { // (rogchap) we track values against a context so that when the context is // closed (either manually or GC'd by Go) we can also release all the @@ -119,3 +132,9 @@ RtnValue RunScript(ContextPtr ctx, const char* source, const char* origin) { rtn.value = tracked_value(ctx, val); return rtn; } + +m_ctx* m_ctx::FromV8Context(v8::Local ctx) { + return (m_ctx*)ctx->GetEmbedderData(ContextDataIndex::WRAPPER_PTR) + .As() + ->Value(); +} diff --git a/context.h b/context.h index 40eb4e9c4..51a04d11d 100644 --- a/context.h +++ b/context.h @@ -13,6 +13,7 @@ #include "value.h" namespace v8 { +class Value; class Isolate; class Context; } // namespace v8 @@ -20,16 +21,39 @@ class Context; typedef v8::Isolate v8Isolate; typedef struct m_unboundScript m_unboundScript; +// ContextDataIndex defines the indexes for "embedder data". +enum ContextDataIndex { + // We start at 1, as slot 0 has special meaning for the Chrome debugger + + // Is an integer "handle" created in Go code, so given a specific V8 context, + // Go code can find its corresponding *Context value. + REF = 1, + + // WRAPPER_PTR is the pointer being transferred between C++ and Go, a pointer + // to a C++ object responsible for maintenance tasks. + WRAPPER_PTR = 2, +}; + +// A wrapper on top of V8's Context object providing additional maintenance +// tasks for V8 go, e.g., releaseing unreleased Value objects associated with +// the context. +// +// Exactly one wrapper instance must exist for a V8 context. A pointer to the +// corresponding wrapper is stored as embedded data in the V8 context. struct m_ctx { v8::Isolate* iso; std::unordered_map vals; std::vector unboundScripts; v8::Persistent ptr; long nextValId; + + // Retrieves the wrapper context for a specific V8 context. + static m_ctx* FromV8Context(v8::Local ctx); }; typedef m_ctx* ContextPtr; extern m_value* tracked_value(m_ctx* ctx, m_value* val); +extern m_value* track_value(m_ctx* ctx, v8::Local val); extern "C" { #else diff --git a/isolate.cc b/isolate.cc index 7402a2e5d..8285fa7a1 100644 --- a/isolate.cc +++ b/isolate.cc @@ -1,8 +1,11 @@ #include "deps/include/v8-context.h" +#include "deps/include/v8-external.h" #include "deps/include/v8-initialization.h" #include "deps/include/v8-locker.h" #include "deps/include/v8-platform.h" +#include "deps/include/v8-promise.h" +#include "_cgo_export.h" #include "context.h" #include "isolate.h" #include "libplatform/libplatform.h" @@ -74,6 +77,26 @@ int IsolateIsExecutionTerminating(IsolatePtr iso) { return iso->IsExecutionTerminating(); } +void promiseRejectedCallback(v8::PromiseRejectMessage message) { + auto iso = message.GetPromise()->GetIsolate(); + + Local prom = message.GetPromise(); + auto handle = iso->GetData(1); + Local v8Ctx = prom->GetCreationContext(iso).ToLocalChecked(); + int ctx_ref = + v8Ctx->GetEmbedderData(ContextDataIndex::REF).As()->Value(); + m_ctx* ctx = m_ctx::FromV8Context(v8Ctx); + Local val = message.GetValue(); + goRejectedPromiseCallback(ctx_ref, handle, message.GetEvent(), + track_value(ctx, prom), track_value(ctx, val)); +} + +void IsolateSetPromiseRejectedCallback(IsolatePtr iso, void* handle) { + ISOLATE_SCOPE(iso) + iso->SetData(1, handle); + iso->SetPromiseRejectCallback(promiseRejectedCallback); +} + IsolateHStatistics IsolationGetHeapStatistics(IsolatePtr iso) { if (iso == nullptr) { return IsolateHStatistics{0}; diff --git a/isolate.go b/isolate.go index b4f63f153..aff5b1894 100644 --- a/isolate.go +++ b/isolate.go @@ -9,10 +9,75 @@ package v8go import "C" import ( + "runtime/cgo" + "strconv" "sync" "unsafe" ) +// PromiseRejectEvent represents the type of event passed to +// [RejectedPromiseCallback]. The values reflect the values of +// v8::PromiseRejectEvent. +// +// See also: https://v8.github.io/api/head/classv8_1_1PromiseRejectMessage.html +type PromiseRejectEvent uint8 + +const ( + // PromiseRejectWithNoHandler is the event that represents an unhandled + // rejection. + PromiseRejectWithNoHandler PromiseRejectEvent = 0 + // PromiseHandlerAddedAfterReject is sent when a rejection handler is added + // to a promise that has already rejected. E.g., the following code will + // result in a kPromiseRejectWithNoHandler event followed by an + // PromiseHandlerAddedAfterReject event. + // + // Promise.reject("dummy").catch(e => {}) + // + // The promise has already rejected when catch is called. + PromiseHandlerAddedAfterReject PromiseRejectEvent = 1 + // PromiseRejectAfterResolved is sent when a project is rejected after it + // has settled, e.g., the following will generate a + // PromiseRejectAfterResolved event. + // + // new Promise((resolve, reject) => { + // resolve() + // reject() + // }) + // + // If the first resolve call is replaced with a reject, a + // kPromiseRejectWithNoHandler event is sent first, followed by the + // PromiseRejectAfterResolved event. + PromiseRejectAfterResolved PromiseRejectEvent = 2 + // PromiseResolveAfterResolved is sent when a project is resolves after it + // has settled, e.g., the following will generate a + // PromiseResolveAfterResolved event. + // + // new Promise((resolve, reject) => { + // resolve() // or reject() + // resolve() + // }) + // + // If the first resolve call is replaced with a reject, a + // kPromiseRejectWithNoHandler event is sent first, followed by the + // PromiseResolveAfterResolved event. + PromiseResolveAfterResolved PromiseRejectEvent = 3 +) + +func (u PromiseRejectEvent) String() string { + switch u { + case PromiseRejectWithNoHandler: + return "kPromiseRejectWithNoHandler" + case PromiseHandlerAddedAfterReject: + return "kPromiseHandlerAddedAfterReject" + case PromiseRejectAfterResolved: + return "kPromiseRejectAfterResolved" + case PromiseResolveAfterResolved: + return "kPromiseResolveAfterResolved" + default: + return strconv.Itoa(int(u)) + } +} + // Isolate is a JavaScript VM instance with its own heap and // garbage collector. Most applications will create one isolate // with many V8 contexts for execution. @@ -22,6 +87,7 @@ type Isolate struct { cbMutex sync.RWMutex cbSeq int cbs map[int]FunctionCallbackWithError + handles []cgo.Handle null *Value undefined *Value @@ -144,6 +210,9 @@ func (i *Isolate) Dispose() { if i.ptr == nil { return } + for _, h := range i.handles { + h.Delete() + } C.IsolateDispose(i.ptr) i.ptr = nil } @@ -184,3 +253,50 @@ func (i *Isolate) getCallback(ref int) FunctionCallbackWithError { defer i.cbMutex.RUnlock() return i.cbs[ref] } + +//export goRejectedPromiseCallback +func goRejectedPromiseCallback(ctxref int, handle unsafe.Pointer, event PromiseRejectEvent, promise C.ValuePtr, value C.ValuePtr) { + if p, err := (&Value{ptr: promise}).AsPromise(); err == nil { + msg := PromiseRejectMessage{ + Context: getContext(ctxref), + Promise: p, + Event: event, + } + if value != nil { + msg.Value = &Value{ptr: value} + } + cb := (cgo.Handle)(handle).Value().(RejectedPromiseCallback) + cb(msg) + } +} + +// PromiseRejectMessage is passed to a [RejectedPromiseCallback] that is +// installed using [Isolate.SetPromiseRejectedCallback]. The values reflect the +// values in V8::PromiseRejectMessage +// +// See also: https://v8.github.io/api/head/classv8_1_1PromiseRejectMessage.html +type PromiseRejectMessage struct { + // Context contains the execution context where the promise was rejected + Context *Context + Promise *Promise + Event PromiseRejectEvent + // Value contains the rejected value + Value *Value +} + +// RejectedPromiseCallback is the type for a callback clients can supply to be +// notified of rejected promises. +type RejectedPromiseCallback = func(PromiseRejectMessage) + +func (i *Isolate) addHandle(h cgo.Handle) cgo.Handle { + i.handles = append(i.handles, h) + return h +} + +// SetPromiseRejectedCallback installs a callback to be called when a promise is +// rejected. This includes rejections that may occur after a script value has +// been evaluated and V8 is running microtasks. +func (i *Isolate) SetPromiseRejectedCallback(cb RejectedPromiseCallback) { + handle := unsafe.Pointer(uintptr(i.addHandle(cgo.NewHandle(cb)))) + C.IsolateSetPromiseRejectedCallback(i.ptr, handle) +} diff --git a/isolate.h b/isolate.h index a4604eb62..bef621818 100644 --- a/isolate.h +++ b/isolate.h @@ -48,6 +48,7 @@ extern IsolatePtr NewIsolate(); extern void IsolatePerformMicrotaskCheckpoint(IsolatePtr ptr); extern void IsolateDispose(IsolatePtr ptr); extern void IsolateTerminateExecution(IsolatePtr ptr); +extern void IsolateSetPromiseRejectedCallback(IsolatePtr iso, void* handle); extern int IsolateIsExecutionTerminating(IsolatePtr ptr); extern IsolateHStatistics IsolationGetHeapStatistics(IsolatePtr ptr); diff --git a/isolate_test.go b/isolate_test.go index 7e5dd2d5c..df01943ac 100644 --- a/isolate_test.go +++ b/isolate_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "math/rand" + "reflect" "strings" "testing" @@ -130,7 +131,9 @@ func TestIsolateCompileUnboundScript_InvalidOptions(t *testing.T) { CachedData: &v8.CompilerCachedData{Bytes: []byte("unused")}, Mode: v8.CompileModeEager, } - panicErr := recoverPanic(func() { iso.CompileUnboundScript("console.log(1)", "script.js", opts) }) + panicErr := recoverPanic( + func() { iso.CompileUnboundScript("console.log(1)", "script.js", opts) }, + ) if panicErr == nil { t.Error("expected panic") } @@ -255,6 +258,74 @@ func TestIsolateThrowException(t *testing.T) { } } +func TestIsolateSetPromiseRejectedCallback(t *testing.T) { + t.Parallel() + iso := v8.NewIsolate() + defer iso.Dispose() + ctx := v8.NewContext(iso) + defer ctx.Close() + + var events []v8.PromiseRejectEvent + var lastValue *v8.Value + + iso.SetPromiseRejectedCallback(func(msg v8.PromiseRejectMessage) { + events = append(events, msg.Event) + lastValue = msg.Value + }) + + _, err := ctx.RunScript("Promise.reject('value')", "") + fatalIf(t, err) + + want := []v8.PromiseRejectEvent{v8.PromiseRejectWithNoHandler} + if !reflect.DeepEqual(events, want) { + t.Errorf("Unexpected events. Want: %v. Got: %v", want, events) + } + if lastValue == nil || lastValue.String() != "value" { + t.Errorf("Unexpected value. Want 'value', got: %v", lastValue) + } + + events = nil + _, err = ctx.RunScript("Promise.reject('value').catch(err => { /* ignore */ })", "") + fatalIf(t, err) + + want = []v8.PromiseRejectEvent{v8.PromiseRejectWithNoHandler, v8.PromiseHandlerAddedAfterReject} + if !reflect.DeepEqual(events, want) { + t.Errorf("Unexpected events. Want: %v. Got: %v", want, events) + } + + testsWithMultipleResolutions := []struct { + op1 string // The first operation to run, resolve() or reject() + op2 string // The second operation to run, resolve() or reject() + want []v8.PromiseRejectEvent // The expected events generated + }{ + {"resolve()", "resolve()", []v8.PromiseRejectEvent{v8.PromiseResolveAfterResolved}}, + {"resolve()", "reject()", []v8.PromiseRejectEvent{v8.PromiseRejectAfterResolved}}, + { + "reject()", "resolve()", + []v8.PromiseRejectEvent{v8.PromiseRejectWithNoHandler, v8.PromiseResolveAfterResolved}, + }, + { + "reject()", "reject()", + []v8.PromiseRejectEvent{v8.PromiseRejectWithNoHandler, v8.PromiseRejectAfterResolved}, + }, + } + for _, test := range testsWithMultipleResolutions { + name := fmt.Sprintf("Call %s after %s", test.op2, test.op1) + t.Run(name, func(t *testing.T) { + events = nil + ctx.RunScript(` + new Promise((resolve, reject) => { + `+test.op1 /* resolve() or reject() */ +` + `+test.op2 /* resolve() or reject() */ +` + }) + `, "") + if !reflect.DeepEqual(test.want, events) { + t.Errorf("Unexpected events. Want: %v. Get: %v", test.want, events) + } + }) + } +} + func BenchmarkIsolateInitialization(b *testing.B) { b.ReportAllocs() for n := 0; n < b.N; n++ {