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
21 changes: 17 additions & 4 deletions src/jsc/bindings/AsyncStackTrace.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ static void collectAsyncStackFramesFromPromise(JSC::VM& vm, JSC::JSCell* owner,
return generator;
};

// A finally() reaction's context is a JSSlimPromiseReaction record; its promise() is the promise finally() returned.
auto finallyResultPromise = [&](JSC::JSValue context) -> JSC::JSPromise* {
JSC::JSSlimPromiseReaction* record = nullptr;
JSC::JSPromise* result = nullptr;
if (dynamicCastValue(context, &record))
dynamicCastValue(record->promise(), &result);
return result;
};

// Walk reaction->context → generator. If context is not a generator (e.g.
// thenable-chain from `return promise` without await inside an async
// function), follow reaction->promise() to the next promise in the chain.
Expand All @@ -67,10 +76,16 @@ static void collectAsyncStackFramesFromPromise(JSC::VM& vm, JSC::JSCell* owner,
for (unsigned hops = 0; p && hops < 32; hops++) {
if (p->status() != JSC::JSPromise::Status::Pending)
return nullptr;
// The context of the inline reaction, or of the head of the list.
JSC::JSValue context = p->asyncStackTraceContext();
if (auto* generator = unwrapGeneratorFromContext(context))
return generator;
if (auto* next = finallyResultPromise(context)) {
p = next;
continue;
}
switch (p->inlineReactionKind()) {
case JSC::JSPromise::InlineReactionKind::InternalMicrotask: {
if (auto* generator = unwrapGeneratorFromContext(p->inlineReactionContext()))
return generator;
// No generator in the context. For the resolve-with-promise fast
// path (`return promise` without await inside an async function),
// the reaction's cell payload is the outer promise being resolved —
Expand All @@ -94,8 +109,6 @@ static void collectAsyncStackFramesFromPromise(JSC::VM& vm, JSC::JSCell* owner,
auto* reaction = dynamicDowncast<JSC::JSPromiseReaction>(p->payloadCell());
Comment thread
robobun marked this conversation as resolved.
Resolved
if (!reaction)
return nullptr;
if (auto* generator = unwrapGeneratorFromContext(JSC::JSPromiseReaction::tryGetContext(reaction)))
return generator;
// No generator in context — follow the thenable chain to the
// promise this reaction resolves/rejects.
if (!dynamicCastValue(reaction->promise(), &p))
Expand Down
56 changes: 56 additions & 0 deletions test/js/node/fs/promises.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AsyncLocalStorage } from "async_hooks";
import { tempDir, tempDirWithFiles } from "harness";
import { join } from "path";
const assert = require("assert");
Expand Down Expand Up @@ -243,6 +244,61 @@ test("errors from fs.promises include async stack frames", async () => {
expect(caught.stack).toContain("at async level1");
});

describe.each([
["no async context", fn => fn()],
["inside AsyncLocalStorage.run()", fn => new AsyncLocalStorage().run({}, fn)],
])("fs.promises async stack through finally(), %s", (_, run) => {
const missing = "/nonexistent-path/does-not-exist.txt";

// The names of the leading `at async` frames, down to this helper.
async function asyncFramesOf(fn) {
try {
await run(fn);
} catch (e) {
expect(e.code).toBe("ENOENT");
const names = String(e.stack)
.split("\n")
.filter(line => line.includes("at async "))
.map(line => line.trim().split(" ")[2]);
return names.slice(0, names.indexOf("asyncFramesOf") + 1);
}
expect.unreachable();
}

test("the promise finally() was called on rejects", async () => {
async function finallyOnRejected() {
await readFile(missing).finally(() => {});
}
expect(await asyncFramesOf(finallyOnRejected)).toEqual(["finallyOnRejected", "asyncFramesOf"]);
});

test("the promise the callback returned rejects", async () => {
async function finallyReturnsRejected() {
await Promise.resolve().finally(() => readFile(missing));
}
expect(await asyncFramesOf(finallyReturnsRejected)).toEqual(["finallyReturnsRejected", "asyncFramesOf"]);
});

test("finally() further up the await chain", async () => {
async function inner() {
await readFile(missing);
}
async function finallyMidChain() {
await inner().finally(() => {});
}
expect(await asyncFramesOf(finallyMidChain)).toEqual(["inner", "finallyMidChain", "asyncFramesOf"]);
});

test("finally() on a promise that already has a reaction", async () => {
async function finallyAfterCatch() {
const promise = readFile(missing);
promise.catch(() => {});
await promise.finally(() => {});
}
expect(await asyncFramesOf(finallyAfterCatch)).toEqual(["finallyAfterCatch", "asyncFramesOf"]);
});
});

test("fs.promises async stack through Promise subclass", async () => {
class MyPromise extends Promise {}

Expand Down