From d8ace9f20ab70e33cad56938f86388c5654b9a3b Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 19 Aug 2026 17:46:32 -0700 Subject: [PATCH 1/5] Add JsConsoleLogger so native code can log to the JS console Native diagnostics written to stdout/stderr are invisible on the platforms where they matter most: on Android and iOS there is no attached terminal, so the message reaches nobody. Routing through the JS console instead puts native messages wherever the host has already directed the script's own. BabylonNative has had this utility for a while, but it lives inside the NativeEngine plugin's private Source directory, so nothing else can use it -- the Canvas polyfill currently writes its font and diagnostic warnings to stderr for exactly that reason. Move it here, to Core/JsRuntime, where it only needs napi and any consumer can reach it. Behavior is unchanged apart from LogMethod, which was a namespace-scope function with external linkage declared in no header; it is now in an anonymous namespace. --- Core/JsRuntime/CMakeLists.txt | 2 + .../Include/Babylon/JsConsoleLogger.h | 25 +++++++ Core/JsRuntime/Source/JsConsoleLogger.cpp | 39 +++++++++++ Tests/UnitTests/CMakeLists.txt | 1 + Tests/UnitTests/Shared/Shared.cpp | 67 +++++++++++++++++++ 5 files changed, 134 insertions(+) create mode 100644 Core/JsRuntime/Include/Babylon/JsConsoleLogger.h create mode 100644 Core/JsRuntime/Source/JsConsoleLogger.cpp diff --git a/Core/JsRuntime/CMakeLists.txt b/Core/JsRuntime/CMakeLists.txt index a5f12428..9a1b36ef 100644 --- a/Core/JsRuntime/CMakeLists.txt +++ b/Core/JsRuntime/CMakeLists.txt @@ -1,6 +1,8 @@ set(SOURCES + "Include/Babylon/JsConsoleLogger.h" "Include/Babylon/JsRuntime.h" "Include/Babylon/JsRuntimeScheduler.h" + "Source/JsConsoleLogger.cpp" "Source/JsRuntime.cpp") add_library(JsRuntime ${SOURCES}) diff --git a/Core/JsRuntime/Include/Babylon/JsConsoleLogger.h b/Core/JsRuntime/Include/Babylon/JsConsoleLogger.h new file mode 100644 index 00000000..808568ea --- /dev/null +++ b/Core/JsRuntime/Include/Babylon/JsConsoleLogger.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +namespace Babylon +{ + /** + * Utility struct to log messages to the JavaScript console. + * + * Native code that writes diagnostics to stdout/stderr is invisible on the platforms + * where diagnostics matter most: on Android and iOS there is no attached terminal, so + * the message reaches nobody. Routing through the JS console instead puts native + * messages in the same place as the script's own, wherever the host has directed it. + * + * Each call is a no-op if no console object, or no such method on it, is present. + */ + struct JsConsoleLogger final + { + JsConsoleLogger() = delete; + + static void LogInfo(Napi::Env env, const char* message); + static void LogWarn(Napi::Env env, const char* message); + static void LogError(Napi::Env env, const char* message); + }; +} diff --git a/Core/JsRuntime/Source/JsConsoleLogger.cpp b/Core/JsRuntime/Source/JsConsoleLogger.cpp new file mode 100644 index 00000000..04652e7b --- /dev/null +++ b/Core/JsRuntime/Source/JsConsoleLogger.cpp @@ -0,0 +1,39 @@ +#include + +namespace Babylon +{ + namespace + { + void LogMethod(Napi::Env env, const char* methodName, const char* message) + { + auto console = env.Global().Get("console"); + + if (console.IsObject()) + { + auto consoleLog{console.ToObject().Get(methodName)}; + + if (consoleLog.IsFunction()) + { + auto consoleLogFunction = consoleLog.As(); + auto messageStr = Napi::String::New(env, message); + consoleLogFunction.Call(console, {messageStr}); + } + } + } + } + + void JsConsoleLogger::LogInfo(Napi::Env env, const char* message) + { + LogMethod(env, "log", message); + } + + void JsConsoleLogger::LogWarn(Napi::Env env, const char* message) + { + LogMethod(env, "warn", message); + } + + void JsConsoleLogger::LogError(Napi::Env env, const char* message) + { + LogMethod(env, "error", message); + } +} diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index 2dbc7619..2e1f7c95 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -65,6 +65,7 @@ target_link_libraries(UnitTests PRIVATE WebSocket PRIVATE gtest_main PRIVATE Foundation + PRIVATE JsRuntime PRIVATE Blob PRIVATE File PRIVATE Performance diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index d1c2aa44..134f6fc8 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -1,5 +1,6 @@ #include "Shared.h" #include +#include #include #include #include @@ -21,6 +22,8 @@ #include #include #include +#include +#include namespace { @@ -139,6 +142,70 @@ TEST(Console, Log) done.get_future().get(); } +TEST(JsConsoleLogger, RoutesToConsole) +{ + // JsConsoleLogger exists so native diagnostics land wherever the host has pointed the + // JS console. On Android and iOS there is no attached terminal, so a message written to + // stdout/stderr instead reaches nobody. + Babylon::AppRuntime runtime{}; + + std::promise done; + std::vector> received; + + runtime.Dispatch([&received](Napi::Env env) mutable { + Babylon::Polyfills::Console::Initialize(env, [&received](const char* message, Babylon::Polyfills::Console::LogLevel logLevel) { + received.emplace_back(logLevel, message); + }); + + Babylon::JsConsoleLogger::LogInfo(env, "info message"); + Babylon::JsConsoleLogger::LogWarn(env, "warn message"); + Babylon::JsConsoleLogger::LogError(env, "error message"); + }); + + Babylon::ScriptLoader loader{runtime}; + loader.Dispatch([&done](auto) { + done.set_value(); + }); + done.get_future().get(); + + ASSERT_EQ(received.size(), 3u); + EXPECT_EQ(received[0].first, Babylon::Polyfills::Console::LogLevel::Log); + EXPECT_EQ(received[0].second, "info message"); + EXPECT_EQ(received[1].first, Babylon::Polyfills::Console::LogLevel::Warn); + EXPECT_EQ(received[1].second, "warn message"); + EXPECT_EQ(received[2].first, Babylon::Polyfills::Console::LogLevel::Error); + EXPECT_EQ(received[2].second, "error message"); +} + +TEST(JsConsoleLogger, NoConsoleIsNotFatal) +{ + // Every method is documented as a no-op when there is no console object, or no such + // method on it. Nothing here installs the Console polyfill. + Babylon::AppRuntime runtime{}; + + std::promise done; + + runtime.Dispatch([](Napi::Env env) mutable { + Babylon::JsConsoleLogger::LogInfo(env, "dropped"); + Babylon::JsConsoleLogger::LogWarn(env, "dropped"); + Babylon::JsConsoleLogger::LogError(env, "dropped"); + + // A console whose methods are not functions must be tolerated too. + auto console = Napi::Object::New(env); + console.Set("warn", Napi::Number::New(env, 42)); + env.Global().Set("console", console); + Babylon::JsConsoleLogger::LogWarn(env, "dropped"); + }); + + Babylon::ScriptLoader loader{runtime}; + loader.Dispatch([&done](auto) { + done.set_value(); + }); + done.get_future().get(); + + SUCCEED(); +} + TEST(Console, CaptureCurrentJsStack) { // Regression: Console::CaptureCurrentJsStack must return a non-empty stack when called from From e0ed6a1d3999813e0f2ef7626771291e3fdb157a Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 19 Aug 2026 18:24:12 -0700 Subject: [PATCH 2/5] Never let a throwing console escape the logger Every step of the log path runs script the host does not control: `console` and the method looked up on it can be accessors that throw, and the call itself is arbitrary user code. Any of that escaping means a diagnostic helper corrupts whatever the caller was doing. Swallow the C++ exception and clear any exception left pending on `env`. Both are needed: node-addon-api clears the pending exception when it converts one into a C++ throw, but that conversion does not happen in a build without C++ exceptions, where the exception stays pending instead and would surface at some unrelated later point. This mirrors the guard already on Console::CaptureCurrentJsStack. --- Core/JsRuntime/Source/JsConsoleLogger.cpp | 33 +++++++++--- Tests/UnitTests/Shared/Shared.cpp | 61 +++++++++++++++++++++++ 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/Core/JsRuntime/Source/JsConsoleLogger.cpp b/Core/JsRuntime/Source/JsConsoleLogger.cpp index 04652e7b..2b3cef35 100644 --- a/Core/JsRuntime/Source/JsConsoleLogger.cpp +++ b/Core/JsRuntime/Source/JsConsoleLogger.cpp @@ -6,19 +6,36 @@ namespace Babylon { void LogMethod(Napi::Env env, const char* methodName, const char* message) { - auto console = env.Global().Get("console"); - - if (console.IsObject()) + try { - auto consoleLog{console.ToObject().Get(methodName)}; + auto console = env.Global().Get("console"); - if (consoleLog.IsFunction()) + if (console.IsObject()) { - auto consoleLogFunction = consoleLog.As(); - auto messageStr = Napi::String::New(env, message); - consoleLogFunction.Call(console, {messageStr}); + auto consoleLog{console.ToObject().Get(methodName)}; + + if (consoleLog.IsFunction()) + { + auto consoleLogFunction = consoleLog.As(); + auto messageStr = Napi::String::New(env, message); + consoleLogFunction.Call(console, {messageStr}); + } } } + catch (...) + { + } + + // Every step above can fail on script the host does not control: `console` and the + // method can be accessors that throw, and the call itself is arbitrary user code. + // N-API also leaves a pending exception on `env` independently of throwing a C++ + // exception, so swallowing the C++ side is not enough. Returning with one pending + // would surface the failure at some unrelated later point in the caller, which is + // never an acceptable outcome for a diagnostic helper. + if (env.IsExceptionPending()) + { + (void)env.GetAndClearPendingException(); + } } } diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index 134f6fc8..ae8aee81 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -206,6 +206,67 @@ TEST(JsConsoleLogger, NoConsoleIsNotFatal) SUCCEED(); } +TEST(JsConsoleLogger, ThrowingConsoleLeavesNoPendingException) +{ + // Every step of the log path runs script the host does not control: `console` and the + // method can be accessors that throw, and the call itself is arbitrary user code. If + // any of that escaped -- as a C++ exception at the call site, or as an exception left + // pending on `env` to surface at some unrelated later point -- a diagnostic helper + // would be corrupting whatever the caller was doing. + Babylon::AppRuntime runtime{}; + + std::promise result; + + runtime.Dispatch([&result](Napi::Env env) mutable { + std::string failures{}; + + // A `console` accessor that throws. + env.Global().DefineProperty(Napi::PropertyDescriptor::Accessor( + env, env.Global(), "console", [](const Napi::CallbackInfo& info) -> Napi::Value { + throw Napi::Error::New(info.Env(), "console getter threw"); + }, napi_configurable)); + + try + { + Babylon::JsConsoleLogger::LogWarn(env, "swallowed"); + } + catch (...) + { + failures += "throwing console getter escaped as a C++ exception; "; + } + if (env.IsExceptionPending()) + { + (void)env.GetAndClearPendingException(); + failures += "throwing console getter left a pending exception; "; + } + + // A `console.warn` that throws when called. + auto console = Napi::Object::New(env); + console.Set("warn", Napi::Function::New(env, [](const Napi::CallbackInfo& info) -> Napi::Value { + throw Napi::Error::New(info.Env(), "warn threw"); + })); + env.Global().DefineProperty(Napi::PropertyDescriptor::Value("console", console, napi_configurable)); + + try + { + Babylon::JsConsoleLogger::LogWarn(env, "swallowed"); + } + catch (...) + { + failures += "throwing console.warn escaped as a C++ exception; "; + } + if (env.IsExceptionPending()) + { + (void)env.GetAndClearPendingException(); + failures += "throwing console.warn left a pending exception; "; + } + + result.set_value(failures); + }); + + EXPECT_EQ(result.get_future().get(), ""); +} + TEST(Console, CaptureCurrentJsStack) { // Regression: Console::CaptureCurrentJsStack must return a non-empty stack when called from From 0584dd8e7049f033b1257991b0e429b260ddbb65 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 19 Aug 2026 19:27:52 -0700 Subject: [PATCH 3/5] Build the throwing-console test with portable Node-API only Napi::Object::DefineProperty and Napi::PropertyDescriptor do not exist in the JSI Node-API port, so Win32_x64_JSI failed to compile. Go through JS's own Object.defineProperty instead, which every engine has. Reverting the guard still fails the test with both cases reported, so it keeps its A/B value. --- Tests/UnitTests/Shared/Shared.cpp | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index ae8aee81..f900c89e 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -220,11 +220,23 @@ TEST(JsConsoleLogger, ThrowingConsoleLeavesNoPendingException) runtime.Dispatch([&result](Napi::Env env) mutable { std::string failures{}; + // Napi::Object::DefineProperty and Napi::PropertyDescriptor do not exist in the JSI + // Node-API port, so go through JS's own Object.defineProperty, which every engine has. + // Redefining is also why plain Set will not do: once `console` is an accessor without + // a setter, assigning to it is either silently dropped or a throw. + auto objectCtor = env.Global().Get("Object").As(); + auto defineProperty = objectCtor.Get("defineProperty").As(); + auto defineConsole = [&env, &defineProperty](Napi::Object descriptor) { + descriptor.Set("configurable", Napi::Boolean::New(env, true)); + defineProperty.Call({env.Global(), Napi::String::New(env, "console"), descriptor}); + }; + // A `console` accessor that throws. - env.Global().DefineProperty(Napi::PropertyDescriptor::Accessor( - env, env.Global(), "console", [](const Napi::CallbackInfo& info) -> Napi::Value { - throw Napi::Error::New(info.Env(), "console getter threw"); - }, napi_configurable)); + auto getterDescriptor = Napi::Object::New(env); + getterDescriptor.Set("get", Napi::Function::New(env, [](const Napi::CallbackInfo& info) -> Napi::Value { + throw Napi::Error::New(info.Env(), "console getter threw"); + })); + defineConsole(getterDescriptor); try { @@ -245,7 +257,9 @@ TEST(JsConsoleLogger, ThrowingConsoleLeavesNoPendingException) console.Set("warn", Napi::Function::New(env, [](const Napi::CallbackInfo& info) -> Napi::Value { throw Napi::Error::New(info.Env(), "warn threw"); })); - env.Global().DefineProperty(Napi::PropertyDescriptor::Value("console", console, napi_configurable)); + auto valueDescriptor = Napi::Object::New(env); + valueDescriptor.Set("value", console); + defineConsole(valueDescriptor); try { From 7e3d72fd9f7cb81bce62fedc44d4d29868ac3a69 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 19 Aug 2026 19:54:49 -0700 Subject: [PATCH 4/5] Throw from JS, not from a Node-API callback, in the console test Throwing out of a Napi::Function callback aborts the process under the JSI port ("Fatal error in v8::ToLocalChecked: Empty MaybeLocal"), so building the throwing accessor and the throwing console.warn out of host functions crashed Win32_x64_JSI. Construct them as real JS functions instead. Reverting the guard still fails the test with both cases reported. --- Tests/UnitTests/Shared/Shared.cpp | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index f900c89e..d7ae602c 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -220,12 +220,21 @@ TEST(JsConsoleLogger, ThrowingConsoleLeavesNoPendingException) runtime.Dispatch([&result](Napi::Env env) mutable { std::string failures{}; + // Everything here is built out of plain JS rather than Node-API host functions. // Napi::Object::DefineProperty and Napi::PropertyDescriptor do not exist in the JSI - // Node-API port, so go through JS's own Object.defineProperty, which every engine has. + // port at all, and throwing out of a Napi::Function callback aborts the process there + // ("Fatal error in v8::ToLocalChecked"), so the throwers have to be real JS functions. // Redefining is also why plain Set will not do: once `console` is an accessor without // a setter, assigning to it is either silently dropped or a throw. + auto functionCtor = env.Global().Get("Function").As(); auto objectCtor = env.Global().Get("Object").As(); auto defineProperty = objectCtor.Get("defineProperty").As(); + + auto makeThrower = [&env, &functionCtor](const char* what) { + return functionCtor + .New({Napi::String::New(env, std::string{"throw new Error('"} + what + "');")}) + .As(); + }; auto defineConsole = [&env, &defineProperty](Napi::Object descriptor) { descriptor.Set("configurable", Napi::Boolean::New(env, true)); defineProperty.Call({env.Global(), Napi::String::New(env, "console"), descriptor}); @@ -233,9 +242,7 @@ TEST(JsConsoleLogger, ThrowingConsoleLeavesNoPendingException) // A `console` accessor that throws. auto getterDescriptor = Napi::Object::New(env); - getterDescriptor.Set("get", Napi::Function::New(env, [](const Napi::CallbackInfo& info) -> Napi::Value { - throw Napi::Error::New(info.Env(), "console getter threw"); - })); + getterDescriptor.Set("get", makeThrower("console getter threw")); defineConsole(getterDescriptor); try @@ -254,9 +261,7 @@ TEST(JsConsoleLogger, ThrowingConsoleLeavesNoPendingException) // A `console.warn` that throws when called. auto console = Napi::Object::New(env); - console.Set("warn", Napi::Function::New(env, [](const Napi::CallbackInfo& info) -> Napi::Value { - throw Napi::Error::New(info.Env(), "warn threw"); - })); + console.Set("warn", makeThrower("warn threw")); auto valueDescriptor = Napi::Object::New(env); valueDescriptor.Set("value", console); defineConsole(valueDescriptor); From 01cb66cfadc416ef4222b066d21cb935c8e9439a Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 19 Aug 2026 20:21:30 -0700 Subject: [PATCH 5/5] Skip the throwing-console test on the JSI backend The V8JSI Node-API shim aborts the process on any JS exception raised through a native property access ("Fatal error in v8::ToLocalChecked: Empty MaybeLocal"), so it dies inside env.Global().Get("console") before LogMethod can guard anything. No amount of guarding in JsConsoleLogger can survive that, so exercise this on the other backends only, matching the existing JSRUNTIMEHOST_NAPI_ENGINE_JSI opt-outs. The guard itself stays unconditional: it is what protects the Chakra, V8, JavaScriptCore and QuickJS backends. --- Tests/UnitTests/Shared/Shared.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index d7ae602c..e27df99f 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -206,6 +206,11 @@ TEST(JsConsoleLogger, NoConsoleIsNotFatal) SUCCEED(); } +// The V8JSI Node-API shim aborts the process on any JS exception raised through a native +// property access ("Fatal error in v8::ToLocalChecked: Empty MaybeLocal"), so it dies inside +// env.Global().Get("console") before LogMethod can guard anything. Nothing JsConsoleLogger +// does can survive that, so exercise this on the other backends only. +#if !defined(JSRUNTIMEHOST_NAPI_ENGINE_JSI) TEST(JsConsoleLogger, ThrowingConsoleLeavesNoPendingException) { // Every step of the log path runs script the host does not control: `console` and the @@ -285,6 +290,7 @@ TEST(JsConsoleLogger, ThrowingConsoleLeavesNoPendingException) EXPECT_EQ(result.get_future().get(), ""); } +#endif TEST(Console, CaptureCurrentJsStack) {