fix(napi): return napi_function for AsyncContextFrame in napi_typeof (#26511)

## Summary
- `napi_typeof` was returning `napi_object` for `AsyncContextFrame`
values, which are internally callable JSObjects
- Native addons that check callback types (e.g. encore.dev's runtime)
would fail with `expect Function, got: Object` and panic
- Added a `jsDynamicCast<AsyncContextFrame*>` check before the final
`napi_object` fallback to correctly report these values as
`napi_function`

Closes #25933

## Test plan
- [x] Verify encore.dev + supertokens reproduction from the issue no
longer panics
- [ ] Existing napi tests continue to pass

Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Dylan Conway
2026-01-27 13:35:15 -08:00
committed by GitHub
parent 9a16f4c345
commit 2f510724a9
6 changed files with 208 additions and 2 deletions

View File

@@ -43,6 +43,11 @@ JSValue AsyncContextFrame::withAsyncContextIfNeeded(JSGlobalObject* globalObject
return callback; return callback;
} }
// If already wrapped in an AsyncContextFrame, return as-is to avoid double-wrapping.
if (jsDynamicCast<AsyncContextFrame*>(callback)) {
return callback;
}
// Construct a low-overhead wrapper // Construct a low-overhead wrapper
auto& vm = JSC::getVM(globalObject); auto& vm = JSC::getVM(globalObject);
return AsyncContextFrame::create( return AsyncContextFrame::create(

View File

@@ -2427,6 +2427,11 @@ extern "C" napi_status napi_typeof(napi_env env, napi_value val,
return napi_clear_last_error(env); return napi_clear_last_error(env);
} }
if (JSC::jsDynamicCast<AsyncContextFrame*>(value)) {
*result = napi_function;
return napi_clear_last_error(env);
}
*result = napi_object; *result = napi_object;
return napi_clear_last_error(env); return napi_clear_last_error(env);

View File

@@ -677,7 +677,7 @@ pub export fn napi_make_callback(env_: napi_env, _: *anyopaque, recv_: napi_valu
return envIsNull(); return envIsNull();
}; };
const recv, const func = .{ recv_.get(), func_.get() }; const recv, const func = .{ recv_.get(), func_.get() };
if (func.isEmptyOrUndefinedOrNull() or !func.isCallable()) { if (func.isEmptyOrUndefinedOrNull() or (!func.isCallable() and !func.isAsyncContextFrame())) {
return env.setLastError(.function_expected); return env.setLastError(.function_expected);
} }
@@ -1762,7 +1762,7 @@ pub export fn napi_create_threadsafe_function(
}; };
const func = func_.get(); const func = func_.get();
if (call_js_cb == null and (func.isEmptyOrUndefinedOrNull() or !func.isCallable())) { if (call_js_cb == null and (func.isEmptyOrUndefinedOrNull() or (!func.isCallable() and !func.isAsyncContextFrame()))) {
return env.setLastError(.function_expected); return env.setLastError(.function_expected);
} }

View File

@@ -895,4 +895,52 @@ nativeTests.test_napi_typeof_boxed_primitives = () => {
console.log("All boxed primitive tests passed!"); console.log("All boxed primitive tests passed!");
}; };
// https://github.com/oven-sh/bun/issues/25933
// Test that napi_typeof returns napi_function for callbacks wrapped in
// AsyncContextFrame (which happens inside AsyncLocalStorage.run()).
nativeTests.test_napi_typeof_async_context_frame = async () => {
const { AsyncLocalStorage } = require("node:async_hooks");
const als = new AsyncLocalStorage();
await als.run({ key: "value" }, () => {
return new Promise(resolve => {
// Pass a callback to the native addon. Because we're inside
// AsyncLocalStorage.run(), Bun wraps it in AsyncContextFrame.
// The native call_js_cb will call napi_typeof on the received
// js_callback and print the result.
nativeTests.test_issue_25933(() => {});
// The threadsafe function callback fires asynchronously.
setTimeout(resolve, 50);
});
});
};
// Test that napi_make_callback works when the func is an AsyncContextFrame
// (received by a threadsafe function's call_js_cb inside AsyncLocalStorage.run()).
nativeTests.test_make_callback_with_async_context = async () => {
const { AsyncLocalStorage } = require("node:async_hooks");
const als = new AsyncLocalStorage();
await als.run({ key: "value" }, () => {
return new Promise(resolve => {
nativeTests.test_napi_make_callback_async_context_frame(() => {});
setTimeout(resolve, 50);
});
});
};
// Test that napi_create_threadsafe_function with call_js_cb=NULL accepts an
// AsyncContextFrame as the func (received from another threadsafe function's call_js_cb).
nativeTests.test_create_tsfn_with_async_context = async () => {
const { AsyncLocalStorage } = require("node:async_hooks");
const als = new AsyncLocalStorage();
await als.run({ key: "value" }, () => {
return new Promise(resolve => {
nativeTests.test_napi_create_tsfn_async_context_frame(() => {});
setTimeout(resolve, 100);
});
});
};
module.exports = nativeTests; module.exports = nativeTests;

View File

@@ -1998,6 +1998,127 @@ static napi_value test_napi_get_named_property_copied_string(const Napi::Callbac
return ok(env); return ok(env);
} }
// https://github.com/oven-sh/bun/issues/25933
// When a threadsafe function is created inside AsyncLocalStorage.run(),
// the js_callback gets wrapped in AsyncContextFrame. napi_typeof must
// still report it as napi_function, not napi_object.
static napi_threadsafe_function tsfn_25933 = nullptr;
static void test_issue_25933_callback(napi_env env, napi_value js_callback,
void *context, void *data) {
napi_valuetype type;
napi_status status = napi_typeof(env, js_callback, &type);
if (status != napi_ok) {
printf("FAIL: napi_typeof returned error status %d\n", status);
} else if (type == napi_function) {
printf("PASS: napi_typeof returned napi_function\n");
} else {
printf("FAIL: napi_typeof returned %d, expected napi_function (%d)\n",
type, napi_function);
}
napi_release_threadsafe_function(tsfn_25933, napi_tsfn_release);
tsfn_25933 = nullptr;
}
static napi_value test_issue_25933(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
Napi::HandleScope scope(env);
// The first argument is the JS callback function.
// When called inside AsyncLocalStorage.run(), Bun wraps this in
// AsyncContextFrame via withAsyncContextIfNeeded.
napi_value js_cb = info[0];
napi_value name = Napi::String::New(env, "tsfn_typeof_test");
NODE_API_CALL(env,
napi_create_threadsafe_function(
env, js_cb, nullptr, name, 0, 1, nullptr, nullptr,
nullptr, &test_issue_25933_callback, &tsfn_25933));
NODE_API_CALL(env, napi_call_threadsafe_function(tsfn_25933, nullptr,
napi_tsfn_nonblocking));
return env.Undefined();
}
// When a threadsafe function's call_js_cb receives a js_callback that is an
// AsyncContextFrame, calling napi_make_callback on it should work (not fail
// with function_expected).
static napi_threadsafe_function tsfn_make_callback = nullptr;
static void test_make_callback_tsfn_cb(napi_env env, napi_value js_callback,
void *context, void *data) {
napi_value recv;
napi_get_global(env, &recv);
napi_value result;
napi_status status = napi_make_callback(env, nullptr, recv, js_callback, 0, nullptr, &result);
if (status == napi_ok) {
printf("PASS: napi_make_callback succeeded\n");
} else {
printf("FAIL: napi_make_callback returned status %d\n", status);
}
napi_release_threadsafe_function(tsfn_make_callback, napi_tsfn_release);
tsfn_make_callback = nullptr;
}
static napi_value test_napi_make_callback_async_context_frame(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
Napi::HandleScope scope(env);
napi_value js_cb = info[0];
napi_value name = Napi::String::New(env, "tsfn_make_callback_test");
NODE_API_CALL(env,
napi_create_threadsafe_function(
env, js_cb, nullptr, name, 0, 1, nullptr, nullptr,
nullptr, &test_make_callback_tsfn_cb, &tsfn_make_callback));
NODE_API_CALL(env, napi_call_threadsafe_function(tsfn_make_callback, nullptr,
napi_tsfn_nonblocking));
return env.Undefined();
}
// When a threadsafe function's call_js_cb receives a js_callback that is an
// AsyncContextFrame, passing it to a second napi_create_threadsafe_function
// with call_js_cb=NULL should succeed (not fail with function_expected).
static napi_threadsafe_function tsfn_create_outer = nullptr;
static void test_create_tsfn_outer_cb(napi_env env, napi_value js_callback,
void *context, void *data) {
// js_callback here is an AsyncContextFrame in Bun.
// Try to create a new threadsafe function with it and call_js_cb=NULL.
napi_value name;
napi_create_string_utf8(env, "inner_tsfn", NAPI_AUTO_LENGTH, &name);
napi_threadsafe_function inner_tsfn = nullptr;
napi_status status = napi_create_threadsafe_function(
env, js_callback, nullptr, name, 0, 1, nullptr, nullptr,
nullptr, /* call_js_cb */ nullptr, &inner_tsfn);
if (status != napi_ok) {
printf("FAIL: napi_create_threadsafe_function returned status %d\n", status);
} else {
printf("PASS: napi_create_threadsafe_function accepted AsyncContextFrame\n");
// Release immediately — we only needed to verify creation succeeds.
napi_release_threadsafe_function(inner_tsfn, napi_tsfn_release);
}
napi_release_threadsafe_function(tsfn_create_outer, napi_tsfn_release);
tsfn_create_outer = nullptr;
}
static napi_value test_napi_create_tsfn_async_context_frame(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
Napi::HandleScope scope(env);
napi_value js_cb = info[0];
napi_value name = Napi::String::New(env, "tsfn_create_test");
NODE_API_CALL(env,
napi_create_threadsafe_function(
env, js_cb, nullptr, name, 0, 1, nullptr, nullptr,
nullptr, &test_create_tsfn_outer_cb, &tsfn_create_outer));
NODE_API_CALL(env, napi_call_threadsafe_function(tsfn_create_outer, nullptr,
napi_tsfn_nonblocking));
return env.Undefined();
}
void register_standalone_tests(Napi::Env env, Napi::Object exports) { void register_standalone_tests(Napi::Env env, Napi::Object exports) {
REGISTER_FUNCTION(env, exports, test_issue_7685); REGISTER_FUNCTION(env, exports, test_issue_7685);
REGISTER_FUNCTION(env, exports, test_issue_11949); REGISTER_FUNCTION(env, exports, test_issue_11949);
@@ -2033,6 +2154,9 @@ void register_standalone_tests(Napi::Env env, Napi::Object exports) {
REGISTER_FUNCTION(env, exports, napi_get_typeof); REGISTER_FUNCTION(env, exports, napi_get_typeof);
REGISTER_FUNCTION(env, exports, test_external_buffer_data_lifetime); REGISTER_FUNCTION(env, exports, test_external_buffer_data_lifetime);
REGISTER_FUNCTION(env, exports, test_napi_get_named_property_copied_string); REGISTER_FUNCTION(env, exports, test_napi_get_named_property_copied_string);
REGISTER_FUNCTION(env, exports, test_issue_25933);
REGISTER_FUNCTION(env, exports, test_napi_make_callback_async_context_frame);
REGISTER_FUNCTION(env, exports, test_napi_create_tsfn_async_context_frame);
} }
} // namespace napitests } // namespace napitests

View File

@@ -798,6 +798,30 @@ describe("cleanup hooks", () => {
expect(output).toContain("napi_typeof"); expect(output).toContain("napi_typeof");
}); });
it("should return napi_function for AsyncContextFrame in threadsafe callback", async () => {
// Test for https://github.com/oven-sh/bun/issues/25933
// When a threadsafe function is created inside AsyncLocalStorage.run(),
// the callback gets wrapped in AsyncContextFrame. napi_typeof must
// report it as napi_function, not napi_object.
const output = await checkSameOutput("test_napi_typeof_async_context_frame", []);
expect(output).toContain("PASS: napi_typeof returned napi_function");
});
it("should handle AsyncContextFrame in napi_make_callback", async () => {
// When a threadsafe function's call_js_cb receives an AsyncContextFrame
// as js_callback and passes it to napi_make_callback, it should succeed.
const output = await checkSameOutput("test_make_callback_with_async_context", []);
expect(output).toContain("PASS: napi_make_callback succeeded");
});
it("should accept AsyncContextFrame in napi_create_threadsafe_function with null call_js_cb", async () => {
// When a threadsafe function's call_js_cb receives an AsyncContextFrame
// and passes it to a second napi_create_threadsafe_function with
// call_js_cb=NULL, it should not reject with function_expected.
const output = await checkSameOutput("test_create_tsfn_with_async_context", []);
expect(output).toContain("PASS: napi_create_threadsafe_function accepted AsyncContextFrame");
});
it("should return napi_object for boxed primitives (String, Number, Boolean)", async () => { it("should return napi_object for boxed primitives (String, Number, Boolean)", async () => {
// Regression test for https://github.com/oven-sh/bun/issues/25351 // Regression test for https://github.com/oven-sh/bun/issues/25351
// napi_typeof was incorrectly returning napi_string for String objects (new String("hello")) // napi_typeof was incorrectly returning napi_string for String objects (new String("hello"))