Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions Polyfills/Console/Include/Babylon/Polyfills/Console.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
#include <napi/env.h>
#include <Babylon/Api.h>

#include <string>

namespace Babylon::Polyfills::Console
{
/**
Expand All @@ -15,7 +17,34 @@ namespace Babylon::Polyfills::Console
Error,
};

/**
* Callback invoked for each `console.log` / `console.warn` / `console.error` call.
*
* The `const char*` message is the formatted message produced by joining the call arguments
* (like browsers do). Its storage is only valid for the duration of the callback -- copy if
* you need to retain it. The `LogLevel` indicates which `console.*` method was invoked.
*/
using CallbackT = std::function<void BABYLON_API (const char*, LogLevel)>;

void BABYLON_API Initialize(Napi::Env env, CallbackT callback);

/**
* Capture the JavaScript callstack at the current JS execution point.
*
* Only meaningful when called from within a `CallbackT` invocation (or any other Napi callback
* the polyfill itself registered) -- captures the JS frames that produced the originating
* `console.*` call. The polyfill's own shim frame(s) are skipped, so the top frame is the
* user's call site.
*
* Format is engine-defined opaque text -- treat as diagnostic-only, do not parse. Capture is
* **best-effort**: returns an empty string if no JS context is active, the engine doesn't
* expose a `stack` property on `Error`, or any internal operation fails. Hosts must check
* for an empty result before using the value.
*
* Cost is non-trivial (currently implemented via `new Error().stack` under the hood); the
* caller decides per-message whether to invoke. Hosts that want stacks only on error
* messages can branch on `LogLevel`; hosts that want them everywhere are free to do so;
* hosts that don't care pay nothing.
*/
std::string BABYLON_API CaptureCurrentJsStack(Napi::Env env);
}
15 changes: 15 additions & 0 deletions Polyfills/Console/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,18 @@ Babylon::Polyfills::Console::Initialize(env, [](const char* message, auto) {
fprintf(stdout, "%s", message);
fflush(stdout);
});
```

Inside the callback you can optionally capture the JavaScript callstack at the originating `console.*` call site via `Babylon::Polyfills::Console::CaptureCurrentJsStack(env)`. The polyfill's own shim frames are skipped, so the top frame is the user's call site. Capture is best-effort -- the returned string can be empty (no JS context active, engine doesn't expose `Error.stack`, etc.), so always check before using. The capture is opt-in per-call -- hosts that don't need stacks pay nothing; hosts that want them on a specific level can branch on the `LogLevel` argument:
```c++
Babylon::Polyfills::Console::Initialize(env, [env](const char* message, auto level) {
fprintf(stdout, "%s", message);
if (level == Babylon::Polyfills::Console::LogLevel::Error) {
auto stack = Babylon::Polyfills::Console::CaptureCurrentJsStack(env);
if (!stack.empty()) {
fprintf(stdout, "\n%s", stack.c_str());
}
}
fflush(stdout);
});
```
38 changes: 38 additions & 0 deletions Polyfills/Console/Source/Console.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,42 @@ namespace Babylon::Polyfills::Console
AddMethod(console, "warn", LogLevel::Warn, callback);
AddMethod(console, "error", LogLevel::Error, callback);
}

std::string BABYLON_API CaptureCurrentJsStack(Napi::Env env)
{
// Construct a JS `Error` object via N-API which fills its `stack` property using the
// engine's current JS frames; on every backend we support (V8 / JSC / ChakraCore) the
// resulting string omits the C++ Napi wrapper frame, so the topmost JS frame is the
// user's call site. Best effort -- any failure (no `Error` global, engine doesn't expose
// a `stack` property, etc.) returns an empty string.
std::string stack{};
try
{
Napi::HandleScope scope{env};
Napi::Value errorCtorValue = env.Global().Get("Error");
if (errorCtorValue.IsFunction())
{
Napi::Object errObj = errorCtorValue.As<Napi::Function>().New({});
Napi::Value stackValue = errObj.Get("stack");
if (stackValue.IsString())
{
stack = stackValue.As<Napi::String>().Utf8Value();
}
}
}
catch (...)
{
}

// N-API operations can leave a pending JS exception on `env` independently of throwing a
// C++ exception (e.g., `Object::Get` on a property accessor that throws); returning from
// the callback with a pending exception would cause `console.*` itself to throw on the
// JS side. Clear it so stack capture is truly side-effect free.
if (env.IsExceptionPending())
{
(void)env.GetAndClearPendingException();
}

return stack;
}
}
56 changes: 54 additions & 2 deletions Tests/UnitTests/Shared/Shared.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,17 @@ TEST(JavaScript, All)
Babylon::AppRuntime runtime{options};

runtime.Dispatch([&exitCodePromise](Napi::Env env) mutable {
Babylon::Polyfills::Console::Initialize(env, [](const char* message, Babylon::Polyfills::Console::LogLevel logLevel) {
std::cout << "[" << EnumToString(logLevel) << "] " << message << std::endl;
Babylon::Polyfills::Console::Initialize(env, [env](const char* message, Babylon::Polyfills::Console::LogLevel logLevel) {
std::cout << "[" << EnumToString(logLevel) << "] " << message;
if (logLevel == Babylon::Polyfills::Console::LogLevel::Error)
{
std::string stack = Babylon::Polyfills::Console::CaptureCurrentJsStack(env);
if (!stack.empty())
{
std::cout << std::endl << stack;
}
}
std::cout << std::endl;
std::cout.flush();
});

Expand Down Expand Up @@ -123,6 +132,49 @@ TEST(Console, Log)
done.get_future().get();
}

TEST(Console, CaptureCurrentJsStack)
{
// Regression: Console::CaptureCurrentJsStack must return a non-empty stack when called from
// within a callback fired by `console.error`, and when called from `console.log` (any frame
// produced by JS execution).
Babylon::AppRuntime runtime{};

std::promise<std::string> errorStackPromise;
std::promise<std::string> logStackPromise;

runtime.Dispatch([&errorStackPromise, &logStackPromise](Napi::Env env) mutable {
Babylon::Polyfills::Console::Initialize(env, [env, &errorStackPromise, &logStackPromise](const char* /*message*/, Babylon::Polyfills::Console::LogLevel logLevel) {
std::string stack = Babylon::Polyfills::Console::CaptureCurrentJsStack(env);
if (logLevel == Babylon::Polyfills::Console::LogLevel::Error)
{
errorStackPromise.set_value(std::move(stack));
}
else if (logLevel == Babylon::Polyfills::Console::LogLevel::Log)
{
logStackPromise.set_value(std::move(stack));
}
});
});

Babylon::ScriptLoader loader{runtime};
loader.Eval("console.log('log message');", "");
loader.Eval("function inner() { console.error('error message'); } inner();", "");

auto errorFuture = errorStackPromise.get_future();
auto logFuture = logStackPromise.get_future();
constexpr auto timeout = std::chrono::seconds(30);
ASSERT_EQ(errorFuture.wait_for(timeout), std::future_status::ready)
<< "console.error callback did not fire within timeout";
ASSERT_EQ(logFuture.wait_for(timeout), std::future_status::ready)
<< "console.log callback did not fire within timeout";

std::string errorStack = errorFuture.get();
std::string logStack = logFuture.get();

Comment thread
bkaradzic-microsoft marked this conversation as resolved.
EXPECT_FALSE(errorStack.empty()) << "console.error path must capture a non-empty JS stack";
EXPECT_FALSE(logStack.empty()) << "console.log path must capture a non-empty JS stack";
}

TEST(AppRuntime, DestroyDoesNotDeadlock)
{
// Regression test verifying AppRuntime destruction doesn't deadlock.
Expand Down
Loading