From 33449c132fc93d8b67b17623a9eb6043c3145007 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Mon, 22 Dec 2025 15:42:04 -0500 Subject: [PATCH 1/3] use null prototype for opaque wrappers --- src/workerd/io/compatibility-date.capnp | 10 ++++++++ src/workerd/io/worker.c++ | 3 +++ src/workerd/jsg/jsg.c++ | 4 +++ src/workerd/jsg/jsg.h | 1 + src/workerd/jsg/setup.c++ | 34 +++++++++++++++---------- src/workerd/jsg/setup.h | 9 +++++++ src/wpt/BUILD.bazel | 10 ++++++-- src/wpt/fetch/api-test.ts | 11 +------- src/wpt/streams-test.ts | 9 +------ 9 files changed, 57 insertions(+), 34 deletions(-) diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index 2b0d85520e8..bed6d18927a 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1316,4 +1316,14 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { # Node.js-compatible versions from node:timers. setTimeout and setInterval return # Timeout objects with methods like refresh(), ref(), unref(), and hasRef(). # This flag requires nodejs_compat or nodejs_compat_v2 to be enabled. + + useNullPrototypeForOpaqueWrappers @154 :Bool + $compatEnableFlag("use_null_prototype_for_opaque_wrappers") + $compatDisableFlag("use_object_prototype_for_opaque_wrappers") + $compatEnableDate("2026-01-13"); + # When enabled, internal opaque wrapper objects (used to pass C++ values through V8 promises) + # have a null prototype instead of inheriting from Object.prototype. This prevents + # Object.prototype.then patches from intercepting internal promise operations, which is + # required for WPT compliance. The streams spec requires that piping operations are not + # observable through Object.prototype.then interception. } diff --git a/src/workerd/io/worker.c++ b/src/workerd/io/worker.c++ index 32a496462e5..f8e5d058354 100644 --- a/src/workerd/io/worker.c++ +++ b/src/workerd/io/worker.c++ @@ -1119,6 +1119,9 @@ Worker::Isolate::Isolate(kj::Own apiParam, if (features.getFastJsgStruct()) { lock->setUsingFastJsgStruct(); } + if (features.getUseNullPrototypeForOpaqueWrappers()) { + lock->setNullPrototypeForOpaqueWrappers(); + } if (impl->inspector != kj::none || ::kj::_::Debug::shouldLog(::kj::LogSeverity::INFO)) { lock->setLoggerCallback([this](jsg::Lock& js, kj::StringPtr message) { diff --git a/src/workerd/jsg/jsg.c++ b/src/workerd/jsg/jsg.c++ index 6fb57542f86..28b8ba41d10 100644 --- a/src/workerd/jsg/jsg.c++ +++ b/src/workerd/jsg/jsg.c++ @@ -239,6 +239,10 @@ void Lock::setImmutablePrototype() { IsolateBase::from(v8Isolate).enableSetImmutablePrototype(); } +void Lock::setNullPrototypeForOpaqueWrappers() { + IsolateBase::from(v8Isolate).enableNullPrototypeForOpaqueWrappers(); +} + void Lock::setLoggerCallback(kj::Function&& logger) { IsolateBase::from(v8Isolate).setLoggerCallback({}, kj::mv(logger)); } diff --git a/src/workerd/jsg/jsg.h b/src/workerd/jsg/jsg.h index cab79368322..9a1b8fce91f 100644 --- a/src/workerd/jsg/jsg.h +++ b/src/workerd/jsg/jsg.h @@ -2651,6 +2651,7 @@ class Lock { bool getThrowOnUnrecognizedImportAssertion() const; void setToStringTag(); void setImmutablePrototype(); + void setNullPrototypeForOpaqueWrappers(); void disableTopLevelAwait(); using Logger = void(Lock&, kj::StringPtr); diff --git a/src/workerd/jsg/setup.c++ b/src/workerd/jsg/setup.c++ index dfc5fd4fa9e..d497cf6f8b9 100644 --- a/src/workerd/jsg/setup.c++ +++ b/src/workerd/jsg/setup.c++ @@ -424,18 +424,6 @@ IsolateBase::IsolateBase(V8System& system, }); ptr->GetHeapProfiler()->AddBuildEmbedderGraphCallback(buildEmbedderGraph, this); - - { - // We don't need a v8::Locker here since there's no way another thread could be using the - // isolate yet, but we do need v8::Isolate::Scope. - v8::Isolate::Scope isolateScope(ptr); - v8::HandleScope scope(ptr); - - // Create opaqueTemplate - auto opaqueTemplate = v8::FunctionTemplate::New(ptr, &throwIllegalConstructor); - opaqueTemplate->InstanceTemplate()->SetInternalFieldCount(Wrappable::INTERNAL_FIELD_COUNT); - this->opaqueTemplate.Reset(ptr, opaqueTemplate); - } }); } @@ -455,8 +443,26 @@ IsolateBase::~IsolateBase() noexcept(false) { } v8::Local IsolateBase::getOpaqueTemplate(v8::Isolate* isolate) { - return static_cast(isolate->GetData(SET_DATA_ISOLATE_BASE)) - ->opaqueTemplate.Get(isolate); + auto& self = *static_cast(isolate->GetData(SET_DATA_ISOLATE_BASE)); + + // Lazily create the template on first use so the flag is already set. + if (self.opaqueTemplate.IsEmpty()) { + v8::HandleScope scope(isolate); + auto tmpl = v8::FunctionTemplate::New(isolate, &throwIllegalConstructor); + tmpl->InstanceTemplate()->SetInternalFieldCount(Wrappable::INTERNAL_FIELD_COUNT); + + if (self.shouldUseNullPrototypeForOpaqueWrappers()) { + // Use null prototype to prevent Object.prototype.then patches from intercepting + // internal promise operations (required for WPT compliance). + auto protoProvider = v8::FunctionTemplate::New(isolate, &throwIllegalConstructor); + protoProvider->RemovePrototype(); + tmpl->SetPrototypeProviderTemplate(protoProvider); + } + + self.opaqueTemplate.Reset(isolate, tmpl); + } + + return self.opaqueTemplate.Get(isolate); } void IsolateBase::dropWrappers(kj::FunctionParam drop) { diff --git a/src/workerd/jsg/setup.h b/src/workerd/jsg/setup.h index 69aebb0460a..97f9e7aa99f 100644 --- a/src/workerd/jsg/setup.h +++ b/src/workerd/jsg/setup.h @@ -186,6 +186,14 @@ class IsolateBase { shouldSetImmutablePrototypeFlag = true; } + inline bool shouldUseNullPrototypeForOpaqueWrappers() const { + return useNullPrototypeForOpaqueWrappersFlag; + } + + void enableNullPrototypeForOpaqueWrappers() { + useNullPrototypeForOpaqueWrappersFlag = true; + } + inline void disableTopLevelAwait() { allowTopLevelAwait = false; } @@ -341,6 +349,7 @@ class IsolateBase { bool nodeJsProcessV2Enabled = false; bool setToStringTag = false; bool shouldSetImmutablePrototypeFlag = false; + bool useNullPrototypeForOpaqueWrappersFlag = false; bool allowTopLevelAwait = true; bool usingNewModuleRegistry = false; bool usingEnhancedErrorSerialization = false; diff --git a/src/wpt/BUILD.bazel b/src/wpt/BUILD.bazel index 86196d08d97..a27b4863e4d 100644 --- a/src/wpt/BUILD.bazel +++ b/src/wpt/BUILD.bazel @@ -70,7 +70,10 @@ wpt_test( wpt_test( name = "fetch/api", size = "large", - compat_flags = ["strip_bom_in_read_all_text"], + compat_flags = [ + "strip_bom_in_read_all_text", + "use_null_prototype_for_opaque_wrappers", + ], config = "fetch/api-test.ts", start_server = True, target_compatible_with = select({ @@ -111,7 +114,10 @@ wpt_test( wpt_test( name = "streams", size = "large", - compat_flags = ["pedantic_wpt"], + compat_flags = [ + "use_null_prototype_for_opaque_wrappers", + "pedantic_wpt", + ], config = "streams-test.ts", wpt_directory = "@wpt//:streams@module", ) diff --git a/src/wpt/fetch/api-test.ts b/src/wpt/fetch/api-test.ts index f948af99100..3e48bc56ad7 100644 --- a/src/wpt/fetch/api-test.ts +++ b/src/wpt/fetch/api-test.ts @@ -842,14 +842,5 @@ export default { 'response/response-stream-disturbed-6.any.js': {}, 'response/response-stream-disturbed-by-pipe.any.js': {}, 'response/response-stream-disturbed-util.js': {}, - 'response/response-stream-with-broken-then.any.js': { - comment: - 'Triggers an internal error: promise.h:103: failed: expected Wrappable::tryUnwrapOpaque(isolate, handle) != nullptr', - expectedFailures: [ - 'Attempt to inject {done: false, value: bye} via Object.prototype.then.', - 'Attempt to inject value: undefined via Object.prototype.then.', - 'Attempt to inject undefined via Object.prototype.then.', - 'Attempt to inject 8.2 via Object.prototype.then.', - ], - }, + 'response/response-stream-with-broken-then.any.js': {}, } satisfies TestRunnerConfig; diff --git a/src/wpt/streams-test.ts b/src/wpt/streams-test.ts index 89cc75c21cb..6eda0403d43 100644 --- a/src/wpt/streams-test.ts +++ b/src/wpt/streams-test.ts @@ -91,14 +91,7 @@ export default { ? ['pipeThrough() should throw if readable/writable getters throw'] : [], }, - 'piping/then-interception.any.js': { - comment: - 'failed: expected Wrappable::tryUnwrapOpaque(isolate, handle) != nullptr', - expectedFailures: [ - 'piping should not be observable', - 'tee should not be observable', - ], - }, + 'piping/then-interception.any.js': {}, 'piping/throwing-options.any.js': {}, 'piping/transform-streams.any.js': {}, From 24e687ec7b9042e2363b02e6e6239e33fbede0f5 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Tue, 23 Dec 2025 17:28:04 -0500 Subject: [PATCH 2/3] Update src/workerd/io/compatibility-date.capnp --- src/workerd/io/compatibility-date.capnp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index bed6d18927a..df6a90d551f 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1320,7 +1320,7 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { useNullPrototypeForOpaqueWrappers @154 :Bool $compatEnableFlag("use_null_prototype_for_opaque_wrappers") $compatDisableFlag("use_object_prototype_for_opaque_wrappers") - $compatEnableDate("2026-01-13"); + $compatEnableDate("2026-01-24"); # When enabled, internal opaque wrapper objects (used to pass C++ values through V8 promises) # have a null prototype instead of inheriting from Object.prototype. This prevents # Object.prototype.then patches from intercepting internal promise operations, which is From 614e0404bc42eb4104aa81789c52856fcc555f18 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Tue, 23 Dec 2025 17:42:50 -0500 Subject: [PATCH 3/3] Apply suggestion from @anonrig --- src/workerd/io/compatibility-date.capnp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index df6a90d551f..dbddbc043f7 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1320,7 +1320,7 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { useNullPrototypeForOpaqueWrappers @154 :Bool $compatEnableFlag("use_null_prototype_for_opaque_wrappers") $compatDisableFlag("use_object_prototype_for_opaque_wrappers") - $compatEnableDate("2026-01-24"); + $compatEnableDate("2026-01-27"); # When enabled, internal opaque wrapper objects (used to pass C++ values through V8 promises) # have a null prototype instead of inheriting from Object.prototype. This prevents # Object.prototype.then patches from intercepting internal promise operations, which is