Skip to content

Add JS callstack to console.error callback#166

Open
bkaradzic-microsoft wants to merge 6 commits into
BabylonJS:mainfrom
bkaradzic-microsoft:add-console-js-stack-callback
Open

Add JS callstack to console.error callback#166
bkaradzic-microsoft wants to merge 6 commits into
BabylonJS:mainfrom
bkaradzic-microsoft:add-console-js-stack-callback

Conversation

@bkaradzic-microsoft
Copy link
Copy Markdown
Contributor

@bkaradzic-microsoft bkaradzic-microsoft commented May 13, 2026

Problem

Host applications consuming JsRuntimeHost have no way to associate a console.error(...) (or any other console.*) call with the JavaScript call site that produced it. The Console::CallbackT signature only delivers the formatted message and the log level. To get a JS stack today, hosts must monkey-patch console.error from JavaScript (new Error().stack, push as extra arg, call original) -- which both pollutes the global console object and masks the real call site under one or two synthetic frames.

Fix

Add an additive helper:

namespace Babylon::Polyfills::Console
{
    using CallbackT = std::function<void BABYLON_API (const char*, LogLevel)>;
    void BABYLON_API Initialize(Napi::Env env, CallbackT callback);
    std::string BABYLON_API CaptureCurrentJsStack(Napi::Env env);
}

Hosts opt in by calling CaptureCurrentJsStack(env) inside their existing 2-arg CallbackT. The polyfill's own shim frames are skipped, so the top frame is the user's call site.

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);
});

The format of the returned string is engine-defined opaque text -- consumers should treat it as diagnostic-only and not parse.

Why a helper instead of a third CallbackT parameter

The first iteration of this PR added a 3rd const char* jsStack parameter to CallbackT. Reviewer feedback (#166 review thread) rightly pointed out three problems with that approach:

  1. Breaking API change. Every existing host with an Initialize(env, [](msg, lvl){ ... }) lambda would have to update.
  2. Always-on cost. new Error() + stack format + UTF-8 conversion would run on every console.error even when the host doesn't use the parameter.
  3. Unnecessary. When CallbackT fires, the JS frames that produced the console.* call are still alive on the JS stack -- the engine is paused mid-call, one or two C++ transition layers behind. The host can capture from inside the existing 2-arg callback.

The helper-based design fixes all three:

  • Non-breaking. Every existing 2-arg CallbackT continues to compile and behave identically. Hosts that don't care about stacks need not change a line.
  • No always-on cost. new Error().stack runs only when the host invokes the helper -- per-call, per-level, per-call-site, fully host-controlled.
  • Frame-skip count is an implementation detail of the polyfill (the engine new Error() capture skips the Napi wrapper frame already). Hosts don't need to know it.

Internally the helper is currently implemented via N-API new Error().stack; when a cheap N-API stack-capture primitive lands later, the body can be swapped without touching the public API.

Tests

New Console.CaptureCurrentJsStack gtest in Tests/UnitTests/Shared/Shared.cpp: registers a 2-arg CallbackT that calls CaptureCurrentJsStack(env) inside its body, asserts both console.error and console.log paths yield a non-empty stack.

In-tree consumer updates

  • Tests/UnitTests/Shared/Shared.cpp -- both Console::Initialize lambdas use the helper.
  • Polyfills/Console/Readme.md -- documents the helper alongside the existing Initialize example.

Add a third `const char* jsStack` parameter to the
`Babylon::Polyfills::Console::CallbackT` signature. When `console.error`
is invoked, the polyfill now captures the JavaScript call stack at the
call site (via N-API `new Error()` -> `stack`) and passes it to the
host callback. The stack is empty for `log` / `warn` to avoid the
per-call cost on hot paths.

This lets host apps (e.g. BabylonNative's Playground) surface a
JS callstack alongside the native callstack in their diagnostic banners
without monkey-patching `console.error` from JavaScript -- which is the
only way to do this today and which masks the real call site under one
or two synthetic frames.

This is a breaking change to the public `Console` API. Existing
host code must change its 2-arg lambda to a 3-arg lambda; the new
parameter can be safely ignored to preserve existing behavior.

Add a `Console.ErrorProvidesJsStack` gtest exercising both the
log (must be empty) and error (must be non-empty) paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the Babylon::Polyfills::Console polyfill so host applications can associate console.error(...) output with the originating JavaScript call site by providing a JS stack string to the host callback. This improves diagnostics without requiring JS-side monkey-patching of console.

Changes:

  • Updated Babylon::Polyfills::Console::CallbackT to include a third const char* jsStack parameter.
  • Implemented best-effort JS stack capture for console.error via new Error().stack and passed it to the callback (empty string for log/warn).
  • Updated unit tests and documentation to reflect the new callback signature and behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
Tests/UnitTests/Shared/Shared.cpp Updates Console::Initialize callbacks to the new 3-arg signature and adds a regression test for console.error stack delivery.
Polyfills/Console/Source/Console.cpp Captures JS stack for LogLevel::Error and passes it into the console callback.
Polyfills/Console/Readme.md Updates documentation and sample initialization code for the new jsStack callback parameter.
Polyfills/Console/Include/Babylon/Polyfills/Console.h Updates the public callback type and documents the new jsStack parameter.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Polyfills/Console/Source/Console.cpp Outdated
Comment thread Polyfills/Console/Include/Babylon/Polyfills/Console.h Outdated
Comment thread Polyfills/Console/Readme.md Outdated
Comment thread Tests/UnitTests/Shared/Shared.cpp
@bghgary bghgary self-requested a review May 13, 2026 16:14
Copy link
Copy Markdown
Contributor

@bghgary bghgary left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Reviewed by Copilot on behalf of @bghgary]

Concerns with the contract change; suggesting we reshape this as an additive helper instead of changing CallbackT. Rationale in the inline comment on Console.h.

Comment thread Polyfills/Console/Include/Babylon/Polyfills/Console.h Outdated
Add an additive `Babylon::Polyfills::Console::CaptureCurrentJsStack(env)`
helper that captures the JavaScript callstack at the current JS execution
point (the polyfill's own shim frames are skipped, so the top frame is the
user's call site). Hosts opt in by calling the helper inside their existing
`CallbackT`.

Currently implemented via N-API `new Error().stack` under the hood; when a
cheap N-API stack-capture primitive lands later, the helper's body can be
swapped without touching the public API.

Rationale -- compared to making `CallbackT` itself carry the stack:

- **Non-breaking.** Every existing 2-arg `CallbackT` continues to compile
  and behave identically. Hosts that don't care about stacks need not
  change a line.
- **No always-on cost.** `new Error().stack` runs only when the host
  invokes the helper -- per-call, per-level, per-call-site, fully
  host-controlled. Hosts can capture on log/warn/error or any subset; hosts
  that ignore the feature pay nothing.
- **Frame-skip count is an implementation detail** of how the polyfill
  registers `error` / `warn` / `log` with the engine. Hosts don't
  need to know it.

Replace the `Console.ErrorProvidesJsStack` test with
`Console.CaptureCurrentJsStack`: register a normal 2-arg callback that
calls the helper in its body, and assert that both error and log paths
yield a non-empty stack.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@bkaradzic-microsoft bkaradzic-microsoft enabled auto-merge (squash) May 13, 2026 23:30
Copilot AI added 2 commits May 13, 2026 16:30
Previously the test blocked on `errorStackPromise.get_future().get()` /
`logStackPromise.get_future().get()` with no timeout. If either callback
never fired (script eval throws, polyfill regresses, etc.) the test would
hang the whole gtest suite indefinitely.

Use `future::wait_for(30s)` and fail with an informative gtest message
if the callback hasn't fired by then -- the same pattern used by
`AppRuntime.DestroyDoesNotDeadlock` below it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- `Console.cpp`: clear any pending N-API exception after the
  best-effort stack capture. N-API operations like `Object::Get` can
  leave a pending JS exception on `env` independently of throwing a
  C++ exception (e.g., a property accessor that throws). Without
  clearing, returning to the polyfill's wrapper function would surface
  the pending exception and `console.*` would itself throw on the JS
  side -- defeating the "side-effect free" contract.
- `Console.h`: document the lifetime of `CallbackT`'s `const
  char*` message (valid only for the duration of the callback; copy
  to retain). Strengthen the `CaptureCurrentJsStack` doc to
  explicitly state capture is best-effort, may return empty even from
  a valid JS context, and hosts must check before using.
- `Readme.md`: note that the helper is best-effort and may return
  empty.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread Tests/UnitTests/Shared/Shared.cpp Outdated
Comment thread Polyfills/Console/Source/Console.cpp Outdated
bkaradzic-microsoft and others added 2 commits May 13, 2026 17:09
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
`Napi::Function::New(...)` already returns `Napi::Object` (see
`napi-inl.h:1447`), so the explicit cast was a no-op.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants