diff --git a/Core/Node-API/Source/env_quickjs.cc b/Core/Node-API/Source/env_quickjs.cc index 382fe86c..6cf36e18 100644 --- a/Core/Node-API/Source/env_quickjs.cc +++ b/Core/Node-API/Source/env_quickjs.cc @@ -114,6 +114,17 @@ namespace Napi } env_ptr->handle_scope_stack.clear(); + // Handles escaped from scopes that were never closed are held aside + // rather than on the stack, so free them here too. + for (auto& entry : env_ptr->escapable_scopes) + { + if (entry.second.escaped) + { + JS_FreeValue(env_ptr->context, *entry.second.escaped); + } + } + env_ptr->escapable_scopes.clear(); + // Run the cycle collector so napi_wrap finalizers (which // destroy C++ wrapper objects and release any embedded // napi_refs) get a chance to execute while the env is still diff --git a/Core/Node-API/Source/js_native_api_chakra.cc b/Core/Node-API/Source/js_native_api_chakra.cc index 67066d92..6e5d3e72 100644 --- a/Core/Node-API/Source/js_native_api_chakra.cc +++ b/Core/Node-API/Source/js_native_api_chakra.cc @@ -1929,7 +1929,9 @@ napi_status napi_open_escapable_handle_scope( napi_escapable_handle_scope* result) { CHECK_ENV(env); CHECK_ARG(env, result); - *result = reinterpret_cast(1); + const size_t token = ++env->next_escapable_scope_token; + env->open_escapable_scopes.emplace(token, false); + *result = reinterpret_cast(token); return napi_ok; } @@ -1939,11 +1941,17 @@ napi_status napi_close_escapable_handle_scope( napi_escapable_handle_scope scope) { CHECK_ENV(env); CHECK_ARG(env, scope); + const auto it = env->open_escapable_scopes.find(reinterpret_cast(scope)); + if (it == env->open_escapable_scopes.end()) { + return napi_set_last_error(env, napi_invalid_arg); + } + env->open_escapable_scopes.erase(it); return napi_ok; } -// Stub implementation of handle scope apis for JSRT. -// This one will return escapee value as this is called from leveldown db. +// JSRT roots values independently of any scope, so the escapee is returned as +// is. The scope is still tracked so a second escape is rejected as Node-API +// requires. napi_status napi_escape_handle(napi_env env, napi_escapable_handle_scope scope, napi_value escapee, @@ -1952,6 +1960,14 @@ napi_status napi_escape_handle(napi_env env, CHECK_ARG(env, scope); CHECK_ARG(env, escapee); CHECK_ARG(env, result); + const auto it = env->open_escapable_scopes.find(reinterpret_cast(scope)); + if (it == env->open_escapable_scopes.end()) { + return napi_set_last_error(env, napi_invalid_arg); + } + if (it->second) { + return napi_set_last_error(env, napi_escape_called_twice); + } + it->second = true; *result = escapee; return napi_ok; } diff --git a/Core/Node-API/Source/js_native_api_chakra.h b/Core/Node-API/Source/js_native_api_chakra.h index 420cdaaa..2dfa59de 100644 --- a/Core/Node-API/Source/js_native_api_chakra.h +++ b/Core/Node-API/Source/js_native_api_chakra.h @@ -7,6 +7,7 @@ #include #include #include +#include struct napi_env__ { JsSourceContext source_context = JS_SOURCE_CONTEXT_NONE; @@ -15,6 +16,14 @@ struct napi_env__ { JsPropertyIdRef wrap_property_id = JS_INVALID_REFERENCE; + // Escapable scope bookkeeping: token -> whether that scope has escaped. Values + // are rooted by the engine rather than by a scope here, so this exists only to + // honour the one-escape-per-scope rule and to reject tokens that are not open. + // The token is a monotonic counter, never an index into anything, so two scopes + // can never share one. + size_t next_escapable_scope_token = 0; + std::map open_escapable_scopes; + const std::thread::id thread_id{std::this_thread::get_id()}; }; diff --git a/Core/Node-API/Source/js_native_api_javascriptcore.cc b/Core/Node-API/Source/js_native_api_javascriptcore.cc index 6d1ade9c..5c8583bc 100644 --- a/Core/Node-API/Source/js_native_api_javascriptcore.cc +++ b/Core/Node-API/Source/js_native_api_javascriptcore.cc @@ -2143,7 +2143,9 @@ napi_status napi_open_escapable_handle_scope(napi_env env, napi_escapable_handle_scope* result) { CHECK_ENV(env); CHECK_ARG(env, result); - *result = reinterpret_cast(1); + const size_t token = ++env->next_escapable_scope_token; + env->open_escapable_scopes.emplace(token, false); + *result = reinterpret_cast(token); return napi_ok; } @@ -2152,11 +2154,17 @@ napi_status napi_close_escapable_handle_scope(napi_env env, napi_escapable_handle_scope scope) { CHECK_ENV(env); CHECK_ARG(env, scope); + const auto it = env->open_escapable_scopes.find(reinterpret_cast(scope)); + if (it == env->open_escapable_scopes.end()) { + return napi_set_last_error(env, napi_invalid_arg); + } + env->open_escapable_scopes.erase(it); return napi_ok; } -// Stub implementation of handle scope apis for JSC. -// This one will return escapee value as this is called from leveldown db. +// JSC roots values independently of any scope, so the escapee is returned as +// is. The scope is still tracked so a second escape is rejected as Node-API +// requires. napi_status napi_escape_handle(napi_env env, napi_escapable_handle_scope scope, napi_value escapee, @@ -2165,6 +2173,14 @@ napi_status napi_escape_handle(napi_env env, CHECK_ARG(env, scope); CHECK_ARG(env, escapee); CHECK_ARG(env, result); + const auto it = env->open_escapable_scopes.find(reinterpret_cast(scope)); + if (it == env->open_escapable_scopes.end()) { + return napi_set_last_error(env, napi_invalid_arg); + } + if (it->second) { + return napi_set_last_error(env, napi_escape_called_twice); + } + it->second = true; *result = escapee; return napi_ok; } diff --git a/Core/Node-API/Source/js_native_api_javascriptcore.h b/Core/Node-API/Source/js_native_api_javascriptcore.h index da74596e..8d8dbd02 100644 --- a/Core/Node-API/Source/js_native_api_javascriptcore.h +++ b/Core/Node-API/Source/js_native_api_javascriptcore.h @@ -7,6 +7,7 @@ #include #include #include +#include struct napi_env__ { JSGlobalContextRef context{}; @@ -20,6 +21,14 @@ struct napi_env__ { JSValueRef reference_info_symbol{}; JSValueRef wrapper_info_symbol{}; + // Escapable scope bookkeeping: token -> whether that scope has escaped. Values + // are rooted by the engine rather than by a scope here, so this exists only to + // honour the one-escape-per-scope rule and to reject tokens that are not open. + // The token is a monotonic counter, never an index into anything, so two scopes + // can never share one. + size_t next_escapable_scope_token{0}; + std::map open_escapable_scopes{}; + const std::thread::id thread_id{std::this_thread::get_id()}; napi_env__(JSGlobalContextRef context) : context{context} { diff --git a/Core/Node-API/Source/js_native_api_quickjs.cc b/Core/Node-API/Source/js_native_api_quickjs.cc index c9e2823d..ba25fefa 100644 --- a/Core/Node-API/Source/js_native_api_quickjs.cc +++ b/Core/Node-API/Source/js_native_api_quickjs.cc @@ -1885,6 +1885,12 @@ napi_status napi_open_handle_scope(napi_env env, napi_handle_scope* result) { CHECK_ENV(env); CHECK_ARG(env, result); + // This token is a position in handle_scope_stack, not an identity: scopes opened + // with no handle allocated between them get the same value. That is sufficient + // here because the token is only ever used to work out where to truncate, and two + // closes truncating to the same index is a no-op. Do not key per-scope state on + // it -- napi_open_escapable_handle_scope did exactly that and the colliding + // scopes shared one entry; it uses a counter for that reason. env->current_scope_start = env->handle_scope_stack.size(); *result = reinterpret_cast(env->current_scope_start + 1); @@ -1917,9 +1923,13 @@ napi_status napi_open_escapable_handle_scope(napi_env env, napi_escapable_handle CHECK_ENV(env); CHECK_ARG(env, result); - // Same as regular handle scope for QuickJS env->current_scope_start = env->handle_scope_stack.size(); - *result = reinterpret_cast(env->current_scope_start + 1); + + // The token is a counter, not a position: scopes opened with no handle allocated + // between them share a position and would otherwise be indistinguishable. + const size_t token = ++env->next_escapable_scope_token; + env->escapable_scopes.emplace(token, napi_env__::EscapableScope{env->current_scope_start, nullptr}); + *result = reinterpret_cast(token); napi_clear_last_error(env); return napi_ok; @@ -1929,14 +1939,36 @@ napi_status napi_close_escapable_handle_scope(napi_env env, napi_escapable_handl CHECK_ENV(env); CHECK_ARG(env, scope); - // Same cleanup as regular handle scope - size_t scope_start = reinterpret_cast(scope) - 1; + const auto it = env->escapable_scopes.find(reinterpret_cast(scope)); + if (it == env->escapable_scopes.end()) { + return napi_set_last_error(env, napi_invalid_arg); + } + + const size_t scope_start = it->second.scope_start; + + // A scope closed out of LIFO order would leave scope_start past the end of the + // stack, and resize would then grow it with null entries that the next close + // dereferences. Node-API forbids that ordering, so report it rather than + // corrupting the stack. + if (scope_start > env->handle_scope_stack.size()) { + return napi_set_last_error(env, napi_handle_scope_mismatch); + } for (size_t i = scope_start; i < env->handle_scope_stack.size(); i++) { JS_FreeValue(env->context, *env->handle_scope_stack[i]); } env->handle_scope_stack.resize(scope_start); + + // The escaped handle, if any, was held aside by napi_escape_handle rather than + // stored on the stack. Now that this scope's own handles are gone it can be + // pushed on: it lands at scope_start, which belongs to the parent scope, so it + // outlives this close and is freed when the parent closes. + if (it->second.escaped) { + env->handle_scope_stack.push_back(std::move(it->second.escaped)); + } + env->escapable_scopes.erase(it); + env->current_scope_start = scope_start; napi_clear_last_error(env); @@ -1949,43 +1981,23 @@ napi_status napi_escape_handle(napi_env env, napi_escapable_handle_scope scope, CHECK_ARG(env, escapee); CHECK_ARG(env, result); - // Get the scope start index - size_t scope_start = reinterpret_cast(scope) - 1; + const auto it = env->escapable_scopes.find(reinterpret_cast(scope)); + if (it == env->escapable_scopes.end()) { + return napi_set_last_error(env, napi_invalid_arg); + } - // Duplicate the JSValue to create a new handle that will outlive the current scope - JSValue jsValue = ToJSValue(escapee); - JSValue escapedValue = JS_DupValue(env->context, jsValue); - - // Store the escaped value in the parent scope (before scope_start) - auto parentPtr = std::make_unique(escapedValue); - napi_value parentHandle = reinterpret_cast(parentPtr.get()); - - // Insert at parent scope position (before current scope) - if (scope_start > 0) { - env->handle_scope_stack.insert( - env->handle_scope_stack.begin() + scope_start, - std::move(parentPtr) - ); - - // Note: Inserting shifts indices, but since we're inserting at scope_start, - // the current scope's start index is now scope_start + 1 - // We need to update current_scope_start if it was pointing to this scope - if (env->current_scope_start == scope_start) { - env->current_scope_start = scope_start + 1; - } - } else { - // No parent scope - just add to the beginning - env->handle_scope_stack.insert( - env->handle_scope_stack.begin(), - std::move(parentPtr) - ); - - if (env->current_scope_start == 0) { - env->current_scope_start = 1; - } + // Node-API allows napi_escape_handle to be called at most once per scope. + if (it->second.escaped) { + return napi_set_last_error(env, napi_escape_called_twice); } - *result = parentHandle; + // Duplicate the JSValue to create a new handle that will outlive the current scope + JSValue escapedValue = JS_DupValue(env->context, ToJSValue(escapee)); + + auto holder = std::make_unique(escapedValue); + *result = reinterpret_cast(holder.get()); + it->second.escaped = std::move(holder); + napi_clear_last_error(env); return napi_ok; } diff --git a/Core/Node-API/Source/js_native_api_quickjs.h b/Core/Node-API/Source/js_native_api_quickjs.h index 7b84fe12..38f9118f 100644 --- a/Core/Node-API/Source/js_native_api_quickjs.h +++ b/Core/Node-API/Source/js_native_api_quickjs.h @@ -12,6 +12,7 @@ #include #include #include +#include #include // Reference info for preventing GC. Defined in the header so that both @@ -33,6 +34,22 @@ struct napi_env__ { std::vector> handle_scope_stack; size_t current_scope_start = 0; + // One record per open escapable scope, keyed by the opaque token handed to the + // caller. The token is a monotonic counter rather than a position in + // handle_scope_stack: two scopes opened with no handle allocated between them + // occupy the same position, so a position-derived token cannot tell them apart. + // The escaped handle is held here rather than on handle_scope_stack because + // inserting into the middle of the stack would shift every entry above it and + // invalidate the recorded start of any nested scope that is still open. + // napi_close_escapable_handle_scope pushes it onto the stack once the scope's own + // handles are gone, at which point it lands in the parent scope and is freed with it. + struct EscapableScope { + size_t scope_start; + std::unique_ptr escaped; + }; + std::map escapable_scopes; + size_t next_escapable_scope_token = 0; + // Tracks every RefInfo* created by napi_create_reference so that // pending strong references can be released during Detach. Without // this, any napi_ref held by a native object (e.g. a polyfill's diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index a920fa1f..d1c2aa44 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -401,6 +401,377 @@ TEST(NodeApi, GetValueStringUtf16HandlesZeroBufsize) EXPECT_TRUE(zeroSafe.get_future().get()); EXPECT_TRUE(normalWorks.get_future().get()); } + +// Closes an escapable handle scope however the test leaves it. Without this, a +// failing assertion returns with the scope still open, the enclosing +// Napi::HandleScope then fails to close, and Napi::Error::Fatal throws out of its +// implicitly-noexcept destructor -- so the process terminates with no FAILED line +// instead of reporting the assertion. +class ScopedEscapableHandleScope +{ +public: + ScopedEscapableHandleScope(napi_env env, napi_escapable_handle_scope scope) + : m_env{env} + , m_scope{scope} + { + } + + ~ScopedEscapableHandleScope() + { + Close(); + } + + ScopedEscapableHandleScope(const ScopedEscapableHandleScope&) = delete; + ScopedEscapableHandleScope& operator=(const ScopedEscapableHandleScope&) = delete; + + napi_status Close() + { + if (m_scope == nullptr) + { + return napi_ok; + } + + const napi_escapable_handle_scope scope{m_scope}; + m_scope = nullptr; + return napi_close_escapable_handle_scope(m_env, scope); + } + +private: + napi_env m_env; + napi_escapable_handle_scope m_scope; +}; + +// Regression: a handle returned by napi_escape_handle must stay alive after its +// escapable scope is closed. The escaped handle is stored in the parent scope, so +// closing the scope must not free it along with the scope's own handles. This is the +// contract Napi::ObjectReference::Get relies on, which in turn is what +// Napi::Error::Message and Napi::Error::what use, so getting it wrong turns any +// report of a native error message into a use-after-free. +TEST(NodeApi, EscapedHandleOutlivesItsScope) +{ + Babylon::AppRuntime runtime{}; + + std::promise escapedValueIsIntact; + + runtime.Dispatch([&escapedValueIsIntact](Napi::Env env) mutable { + napi_env nenv{env}; + + // Assertions stay on the test thread: the dispatched lambda reports through the + // promise and returns early on failure so the waiter can never deadlock. + napi_escapable_handle_scope scope{}; + if (napi_open_escapable_handle_scope(nenv, &scope) != napi_ok) + { + escapedValueIsIntact.set_value(false); + return; + } + ScopedEscapableHandleScope scopeGuard{nenv, scope}; + + napi_value inner{}; + if (napi_create_string_utf8(nenv, "escape me", NAPI_AUTO_LENGTH, &inner) != napi_ok) + { + escapedValueIsIntact.set_value(false); + return; + } + + napi_value escaped{}; + if (napi_escape_handle(nenv, scope, inner, &escaped) != napi_ok) + { + escapedValueIsIntact.set_value(false); + return; + } + + if (scopeGuard.Close() != napi_ok) + { + escapedValueIsIntact.set_value(false); + return; + } + + // Allocate through the parent scope so a dangling escaped handle is likely to + // have been reused by the time it is read back. + for (int i = 0; i < 32; ++i) + { + napi_value filler{}; + napi_create_string_utf8(nenv, "filler filler filler", NAPI_AUTO_LENGTH, &filler); + } + + char buffer[32]{}; + size_t copied{0}; + const napi_status status{napi_get_value_string_utf8(nenv, escaped, buffer, sizeof(buffer), &copied)}; + escapedValueIsIntact.set_value(status == napi_ok && copied == 9 && std::string{buffer} == "escape me"); + }); + + EXPECT_TRUE(escapedValueIsIntact.get_future().get()); +} + +// Regression: two escapable scopes open at once, both escaping before either closes, +// then closed innermost first. An implementation that stores an escaped handle by +// inserting it into the middle of the handle stack shifts every entry above it, +// silently invalidating the start index the still-open inner scope was handed. Closing +// the inner scope then keeps the wrong slot and frees the inner escaped handle, +// reintroducing the dangling napi_value this fix is about. +// +// Engines differ on whether the outer scope may escape while an inner one is open, so +// the test only requires that of the engines that allow it. +TEST(NodeApi, NestedEscapableScopesBothEscape) +{ + Babylon::AppRuntime runtime{}; + + std::promise bothValuesIntact; + + runtime.Dispatch([&bothValuesIntact](Napi::Env env) mutable { + napi_env nenv{env}; + + const auto fail = [&bothValuesIntact]() { bothValuesIntact.set_value(false); }; + + napi_escapable_handle_scope outerScope{}; + if (napi_open_escapable_handle_scope(nenv, &outerScope) != napi_ok) + { + return fail(); + } + ScopedEscapableHandleScope outerGuard{nenv, outerScope}; + + // Give the outer scope handles of its own, so the inner scope starts at a + // different index and the shifting bug is observable. + for (int i = 0; i < 4; ++i) + { + napi_value outerFiller{}; + if (napi_create_string_utf8(nenv, "outer filler", NAPI_AUTO_LENGTH, &outerFiller) != napi_ok) + { + return fail(); + } + } + + napi_value outerSource{}; + if (napi_create_string_utf8(nenv, "outer value", NAPI_AUTO_LENGTH, &outerSource) != napi_ok) + { + return fail(); + } + + napi_escapable_handle_scope innerScope{}; + if (napi_open_escapable_handle_scope(nenv, &innerScope) != napi_ok) + { + return fail(); + } + ScopedEscapableHandleScope innerGuard{nenv, innerScope}; + + napi_value innerSource{}; + if (napi_create_string_utf8(nenv, "inner value", NAPI_AUTO_LENGTH, &innerSource) != napi_ok) + { + return fail(); + } + + // Inner escapes first, then the still-open outer scope escapes. + napi_value innerEscaped{}; + if (napi_escape_handle(nenv, innerScope, innerSource, &innerEscaped) != napi_ok) + { + return fail(); + } + + // Hermes only permits escaping from the innermost open scope and reports + // napi_handle_scope_mismatch here. That is a legitimate refusal rather than a + // failure, so record whether the engine allows this and keep checking the part + // that applies either way. + napi_value outerEscaped{}; + const napi_status outerEscapeStatus{napi_escape_handle(nenv, outerScope, outerSource, &outerEscaped)}; + const bool outerEscapeSupported{outerEscapeStatus == napi_ok}; + if (!outerEscapeSupported && outerEscapeStatus != napi_handle_scope_mismatch) + { + return fail(); + } + + // Close innermost first, as the scopes must be. + if (innerGuard.Close() != napi_ok) + { + return fail(); + } + + // The inner escaped handle now belongs to the outer scope and must still read + // back while that scope is open. Churn allocations first: a wrongly freed handle + // only reads back wrong once its block has been reused, so allocate enough to + // make that near certain rather than a matter of luck. + for (int i = 0; i < 512; ++i) + { + napi_value filler{}; + napi_create_string_utf8(nenv, "filler filler filler", NAPI_AUTO_LENGTH, &filler); + } + + char innerBuffer[32]{}; + size_t innerCopied{0}; + if (napi_get_value_string_utf8(nenv, innerEscaped, innerBuffer, sizeof(innerBuffer), &innerCopied) != napi_ok || + std::string{innerBuffer} != "inner value") + { + return fail(); + } + + if (outerGuard.Close() != napi_ok) + { + return fail(); + } + + for (int i = 0; i < 512; ++i) + { + napi_value filler{}; + napi_create_string_utf8(nenv, "filler filler filler", NAPI_AUTO_LENGTH, &filler); + } + + if (!outerEscapeSupported) + { + // Nothing escaped from the outer scope, so the inner check above is the whole + // result on this engine. + bothValuesIntact.set_value(true); + return; + } + + char outerBuffer[32]{}; + size_t outerCopied{0}; + const napi_status status{napi_get_value_string_utf8(nenv, outerEscaped, outerBuffer, sizeof(outerBuffer), &outerCopied)}; + bothValuesIntact.set_value(status == napi_ok && std::string{outerBuffer} == "outer value"); + }); + + EXPECT_TRUE(bothValuesIntact.get_future().get()); +} + +// Node-API permits at most one escape per escapable scope. The second call must be +// rejected with napi_escape_called_twice, and must leave the first escaped handle +// untouched rather than replacing or freeing it. +TEST(NodeApi, SecondEscapeIsRejected) +{ + Babylon::AppRuntime runtime{}; + + std::promise secondEscapeRejected; + std::promise firstValueIntact; + + runtime.Dispatch([&secondEscapeRejected, &firstValueIntact](Napi::Env env) mutable { + napi_env nenv{env}; + + const auto fail = [&secondEscapeRejected, &firstValueIntact]() { + secondEscapeRejected.set_value(false); + firstValueIntact.set_value(false); + }; + + napi_escapable_handle_scope scope{}; + if (napi_open_escapable_handle_scope(nenv, &scope) != napi_ok) + { + return fail(); + } + ScopedEscapableHandleScope scopeGuard{nenv, scope}; + + napi_value first{}; + napi_value second{}; + if (napi_create_string_utf8(nenv, "first", NAPI_AUTO_LENGTH, &first) != napi_ok || + napi_create_string_utf8(nenv, "second", NAPI_AUTO_LENGTH, &second) != napi_ok) + { + return fail(); + } + + napi_value firstEscaped{}; + if (napi_escape_handle(nenv, scope, first, &firstEscaped) != napi_ok) + { + return fail(); + } + + napi_value secondEscaped{}; + secondEscapeRejected.set_value( + napi_escape_handle(nenv, scope, second, &secondEscaped) == napi_escape_called_twice); + + if (scopeGuard.Close() != napi_ok) + { + firstValueIntact.set_value(false); + return; + } + + for (int i = 0; i < 32; ++i) + { + napi_value filler{}; + napi_create_string_utf8(nenv, "filler filler filler", NAPI_AUTO_LENGTH, &filler); + } + + char buffer[32]{}; + size_t copied{0}; + const napi_status status{napi_get_value_string_utf8(nenv, firstEscaped, buffer, sizeof(buffer), &copied)}; + firstValueIntact.set_value(status == napi_ok && std::string{buffer} == "first"); + }); + + EXPECT_TRUE(secondEscapeRejected.get_future().get()); + EXPECT_TRUE(firstValueIntact.get_future().get()); +} + +// Regression: two escapable scopes opened with no handle allocated between them. +// An implementation whose opaque token is derived from a position in the handle +// stack hands both scopes the same token, so the second scope to escape is refused +// with napi_escape_called_twice despite never having escaped. Deriving the token +// from a counter instead keeps the two apart. +TEST(NodeApi, AdjacentEscapableScopesEscapeIndependently) +{ + Babylon::AppRuntime runtime{}; + + std::promise bothEscapesAccepted; + + runtime.Dispatch([&bothEscapesAccepted](Napi::Env env) mutable { + napi_env nenv{env}; + + const auto fail = [&bothEscapesAccepted]() { bothEscapesAccepted.set_value(false); }; + + napi_escapable_handle_scope outerScope{}; + if (napi_open_escapable_handle_scope(nenv, &outerScope) != napi_ok) + { + return fail(); + } + ScopedEscapableHandleScope outerGuard{nenv, outerScope}; + + // Deliberately allocate nothing here: this is what makes the two scopes share a + // position in the handle stack. + napi_escapable_handle_scope innerScope{}; + if (napi_open_escapable_handle_scope(nenv, &innerScope) != napi_ok) + { + return fail(); + } + ScopedEscapableHandleScope innerGuard{nenv, innerScope}; + + napi_value innerSource{}; + if (napi_create_string_utf8(nenv, "inner value", NAPI_AUTO_LENGTH, &innerSource) != napi_ok) + { + return fail(); + } + + napi_value innerEscaped{}; + if (napi_escape_handle(nenv, innerScope, innerSource, &innerEscaped) != napi_ok) + { + return fail(); + } + + napi_value outerSource{}; + if (napi_create_string_utf8(nenv, "outer value", NAPI_AUTO_LENGTH, &outerSource) != napi_ok) + { + return fail(); + } + + // The outer scope has not escaped yet, so this must not be refused. + napi_value outerEscaped{}; + const napi_status outerEscapeStatus{napi_escape_handle(nenv, outerScope, outerSource, &outerEscaped)}; + if (outerEscapeStatus == napi_handle_scope_mismatch) + { + // Engines that only allow escaping from the innermost open scope cannot + // exercise this case at all; the inner escape above is the whole result. + bothEscapesAccepted.set_value(true); + return; + } + if (outerEscapeStatus != napi_ok) + { + return fail(); + } + + if (innerGuard.Close() != napi_ok || outerGuard.Close() != napi_ok) + { + return fail(); + } + + bothEscapesAccepted.set_value(true); + }); + + EXPECT_TRUE(bothEscapesAccepted.get_future().get()); +} + #endif int RunTests()