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
9 changes: 5 additions & 4 deletions docs/guides/util/base64.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ const text = bytes.toString("utf8");
<Warning>
The older [`btoa()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa) and [`atob()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/atob) APIs are still available for compatibility, but they operate on binary strings instead of byte arrays. Avoid them in new code, especially when handling arbitrary binary data or non-ASCII text.

```ts
const encoded = btoa("bun"); // => "YnVu"
const decoded = atob(encoded); // => "bun"
```
```ts
const encoded = btoa("bun"); // => "YnVu"
const decoded = atob(encoded); // => "bun"
```

</Warning>

---
Expand Down
36 changes: 18 additions & 18 deletions docs/runtime/web-apis.mdx

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions src/jsc/JSValue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,20 @@ impl JSValue {
AsyncContextFrame__withAsyncContextIfNeeded(global, self)
}

/// Like [`Self::with_async_context_if_needed`], but discards any frame `self`
/// already captured and snapshots the async context active *now*. Used by
/// `Timeout#refresh()` when it reactivates an already-fired timer.
#[inline]
pub fn recapture_async_context_if_needed(self, global: &JSGlobalObject) -> JSValue {
unsafe extern "C" {
safe fn AsyncContextFrame__recaptureAsyncContextIfNeeded(
global: &JSGlobalObject,
callback: JSValue,
) -> JSValue;
}
AsyncContextFrame__recaptureAsyncContextIfNeeded(global, self)
}

/// Protects a JSValue from garbage collection (refcounted). The is_cell
/// check happens on the C++ side (bindings.cpp).
#[inline]
Expand Down
19 changes: 19 additions & 0 deletions src/jsc/bindings/AsyncContextFrame.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#include "ZigGlobalObject.h"
#include "AsyncContextFrame.h"
#include <JavaScriptCore/InternalFieldTuple.h>
#include <JavaScriptCore/JSPromise.h>

#if ASSERT_ENABLED
#include <JavaScriptCore/IntegrityInlines.h>
Expand Down Expand Up @@ -96,6 +97,24 @@ extern "C" JSC::EncodedJSValue AsyncContextFrame__withAsyncContextIfNeeded(JSGlo
return JSValue::encode(AsyncContextFrame::withAsyncContextIfNeeded(globalObject, JSValue::decode(callback)));
}

// Discards any previously captured frame and snapshots the async context that
// is active right now. Used by `Timeout.prototype.refresh()` when it
// reactivates an already-fired timer, matching Node's `initAsyncResource`.
extern "C" JSC::EncodedJSValue AsyncContextFrame__recaptureAsyncContextIfNeeded(JSGlobalObject* globalObject, JSC::EncodedJSValue callbackValue)
{
JSValue callback = JSValue::decode(callbackValue);
if (auto* wrapper = dynamicDowncast<AsyncContextFrame>(callback)) {
callback = wrapper->callback.get();
}
// `timeout._onTimeout = <any value>` writes this slot directly. Only re-wrap what the
// timer can invoke: wrapping anything else would turn the falsy values the timer
// treats as cleared into a truthy object.
if (!callback.isCallable() && !dynamicDowncast<JSPromise>(callback)) {
return callbackValue;
}
return JSValue::encode(AsyncContextFrame::withAsyncContextIfNeeded(globalObject, callback));
}

#define ASYNCCONTEXTFRAME_CALL_IMPL(...) \
if (!functionObject.isCell()) \
return jsUndefined(); \
Expand Down
5 changes: 5 additions & 0 deletions src/jsc/bindings/NodeTimerObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ static bool call(JSGlobalObject* globalObject, JSValue timerObject, JSValue call
auto callData = JSC::getCallData(callbackValue);
if (callData.type == CallData::Type::None) {
Bun__reportUnhandledError(globalObject, JSValue::encode(createNotAFunctionError(globalObject, callbackValue)));
// Restore before the early return, or the wrapper's context stays installed
// for everything the event loop runs next.
if (asyncContextData) {
asyncContextData->putInternalField(vm, 0, restoreAsyncContext);
}
return true;
}

Expand Down
13 changes: 13 additions & 0 deletions src/runtime/timer/timer_object_internals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,19 @@ impl TimerObjectInternals {
return Ok(this_value);
}

// Node re-initializes the async resource when refresh() reactivates a timer whose
// callback already ran (lib/internal/timers.js `insertGuarded`), so the next fire
// observes the refreshing caller's AsyncLocalStorage context, not the creator's.
if self.get_destroyed() {
let callback =
JSTimeout::callback_get_cached(this_value).expect("TimeoutObject callback slot");
JSTimeout::callback_set_cached(
this_value,
global_object,
callback.recapture_async_context_if_needed(global_object),
);
}
Comment thread
robobun marked this conversation as resolved.

self.this_value
.with_mut(|r| r.set_strong(this_value, global_object));
self.reschedule(
Expand Down
83 changes: 83 additions & 0 deletions test/js/node/async_hooks/AsyncLocalStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,89 @@ describe("async context passes through", () => {
expect(s.getStore()).toBe(undefined);
expect(await promise).toBe("value");
});
// Node re-initializes the async resource when refresh() reactivates a timer whose
// callback already ran, so later fires observe the refreshing caller's context.
test("setTimeout().refresh() after the callback ran", async () => {
const s = new AsyncLocalStorage<string>();
const seen: (string | null)[] = [];
let onFire!: () => void;
const fired = () => new Promise<void>(r => (onFire = r));

let t!: ReturnType<typeof setTimeout>;
let refreshFromInsideCallback = false;
const callback = () => {
seen.push(s.getStore() ?? null);
if (refreshFromInsideCallback) {
refreshFromInsideCallback = false;
// The callback is still running, so the timer is not destroyed yet and
// this refresh() must not re-bind it (Node keeps the existing context too).
s.run("inside", () => t.refresh());
}
onFire();
};

let wait = fired();
s.run("creator", () => {
t = setTimeout(callback, 1);
});
// Refreshing a timer that has not fired yet does not re-bind it.
s.run("early", () => t.refresh());
await wait;

// The callback already ran, so refresh() re-captures the caller's context.
expect((t as any)._destroyed).toBe(true);
wait = fired();
s.run("refresher", () => t.refresh());
await wait;

// Outside of any context, refresh() drops the previously captured context.
wait = fired();
t.refresh();
await wait;

// And a later refresh() binds the (previously unbound) callback again. That fire
// refreshes itself from inside its own callback, which must keep the same context.
refreshFromInsideCallback = true;
wait = fired();
s.run("again", () => t.refresh());
await wait;

wait = fired();
await wait;

expect(seen).toEqual(["creator", "refresher", null, "again", "again"]);
});
// A non-callable `_onTimeout` is treated as cleared when the timer fires, so refresh()
// must not wrap it in an async context frame: that would make it truthy and leave the
// refresher's context installed globally once the invoke fails.
test("setTimeout().refresh() does not re-bind a non-callable _onTimeout", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const { AsyncLocalStorage } = require("async_hooks");
const als = new AsyncLocalStorage();
const errors = [];
process.on("uncaughtException", err => errors.push(String(err)));
async function main() {
let t;
await new Promise(resolve => (t = setTimeout(resolve, 1)));
t._onTimeout = null;
als.run("refresher", () => t.refresh());
setTimeout(() => {
console.log(JSON.stringify({ store: als.getStore() ?? null, errors }));
}, 20);
}
main();`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe('{"store":null,"errors":[]}');
expect(exitCode).toBe(0);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
test("setInterval", async () => {
let resolve: (x: string[]) => void;
const promise = new Promise<string[]>(r => (resolve = r));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
process.exitCode = 1;
const { AsyncLocalStorage } = require("async_hooks");
const assert = require("assert");

// `timeout.refresh()` on a timer whose callback already ran reactivates it and,
// like Node's `initAsyncResource`, re-captures the async context active at the
// `refresh()` call site. A still-pending timer keeps its creation context.
const als = new AsyncLocalStorage();

async function main() {
const seen = [];
let onFire;
const fired = () => new Promise(resolve => (onFire = resolve));

let t;
let refreshFromInsideCallback = false;
const callback = () => {
seen.push(als.getStore() ?? null);
if (refreshFromInsideCallback) {
refreshFromInsideCallback = false;
// The callback is still running, so the timer is not destroyed yet and
// this refresh() must not re-bind it (Node keeps the existing context too).
assert.strictEqual(t._destroyed, false);
als.run("inside", () => t.refresh());
}
onFire();
};

let wait = fired();
als.run("creator", () => {
t = setTimeout(callback, 1);
});
// Refreshing a timer that has not fired yet does not re-bind it.
als.run("early", () => t.refresh());
await wait;

// The callback already ran, so refresh() re-captures the caller's context.
assert.strictEqual(t._destroyed, true);
wait = fired();
als.run("refresher", () => t.refresh());
await wait;

// Outside of any context, refresh() drops the previously captured context.
assert.strictEqual(t._destroyed, true);
wait = fired();
t.refresh();
await wait;

// And a later refresh() binds the (previously unbound) callback again. That fire
// refreshes itself from inside its own callback, which must keep the same context.
assert.strictEqual(t._destroyed, true);
refreshFromInsideCallback = true;
wait = fired();
als.run("again", () => t.refresh());
await wait;

wait = fired();
await wait;

assert.deepStrictEqual(seen, ["creator", "refresher", null, "again", "again"]);
}

main().then(
() => process.exit(0),
err => {
console.error("FAIL: timeout.refresh() async context", err);
process.exit(1);
},
);
Loading