diff --git a/api-test.c b/api-test.c index 2d5f304e2..c2822bf76 100644 --- a/api-test.c +++ b/api-test.c @@ -2228,8 +2228,77 @@ static void new_context_low_memory(void) assert(ok < all); // ...but not all } +static JSValue async_stack_failing_job(JSContext *ctx, int argc, JSValueConst *argv) +{ + return JS_ThrowTypeError(ctx, "nested job failure"); +} + +static JSValue async_stack_pump(JSContext *ctx, JSValueConst this_val, + int argc, JSValueConst *argv) +{ + JSContext *job_ctx = NULL; + bool failure = JS_ToBool(ctx, argv[0]); + /* Queue the failure behind the inner callback, before it enqueues more jobs. */ + if (!failure) + assert(JS_EnqueueJob(ctx, async_stack_failing_job, 0, NULL) == 0); + int ret = JS_ExecutePendingJob(JS_GetRuntime(ctx), &job_ctx); + assert(job_ctx == ctx); + assert(ret == (failure ? -1 : 1)); + if (failure) + JS_FreeValue(ctx, JS_GetException(ctx)); + return JS_UNDEFINED; +} + +static void async_stack_reentrant_jobs(void) +{ + JSRuntime *rt = new_runtime(); + JSContext *ctx = JS_NewContext(rt), *job_ctx; + JSValue global = JS_GetGlobalObject(ctx), promise, result; + int ret, jobs = 0; + + assert(JS_SetPropertyStr(ctx, global, "pump", + JS_NewCFunction(ctx, async_stack_pump, "pump", 1)) >= 0); + JS_FreeValue(ctx, global); + promise = eval(ctx, + "Error.prepareStackTrace = (_, frames) => frames;" + "const snapshots = [];" + "function capture() {" + " return new Error().stack" + " .filter(f => f.isAsync() && f.getPromiseIndex() === null)" + " .map(f => f.getFunctionName()).join(',');" + "}" + "const outer = Promise.resolve().then(function outerCallback() {" + " snapshots.push(capture());" + " pump(false);" + " snapshots.push(capture());" + " pump(true);" + " snapshots.push(capture());" + "});" + "const inner = Promise.resolve().then(function innerCallback() {" + " snapshots.push(capture());" + "});" + "async function outerWait() { await outer; }" + "async function innerWait() { await inner; }" + "Promise.all([outerWait(), innerWait()]);"); + assert(JS_IsPromise(promise)); + while ((ret = JS_ExecutePendingJob(rt, &job_ctx)) != 0) { + assert(ret == 1); + assert(++jobs < 20); + } + assert(JS_PromiseState(ctx, promise) == JS_PROMISE_FULFILLED); + JS_FreeValue(ctx, promise); + result = eval(ctx, + "snapshots.join('|') === 'outerWait|innerWait|outerWait|outerWait' &&" + "capture() === ''"); + assert(JS_IsBool(result) && JS_ToBool(ctx, result)); + JS_FreeValue(ctx, result); + JS_FreeContext(ctx); + JS_FreeRuntime(rt); +} + int main(void) { + async_stack_reentrant_jobs(); cfunctions(); sync_call(); async_call(); diff --git a/quickjs.c b/quickjs.c index ed65ce420..6152c7e0d 100644 --- a/quickjs.c +++ b/quickjs.c @@ -378,6 +378,7 @@ struct JSRuntime { bool in_free; struct JSStackFrame *current_stack_frame; + struct JSJobEntry *current_job; /* borrowed while executing the job */ JSInterruptHandler *interrupt_handler; void *interrupt_opaque; @@ -1213,6 +1214,9 @@ typedef struct JSCallSiteData { JSValue func_name; bool native; bool constructor; + bool is_async; + bool is_promise_all; + int promise_index; int line_num; int col_num; } JSCallSiteData; @@ -2526,7 +2530,7 @@ JSContext *JS_GetPendingJobContext(JSRuntime *rt) int JS_ExecutePendingJob(JSRuntime *rt, JSContext **pctx) { JSContext *ctx; - JSJobEntry *e; + JSJobEntry *e, *prev_job; JSValue res; int i, ret; @@ -2539,7 +2543,10 @@ int JS_ExecutePendingJob(JSRuntime *rt, JSContext **pctx) e = list_entry(rt->job_list.next, JSJobEntry, link); list_del(&e->link); ctx = e->ctx; + prev_job = rt->current_job; + rt->current_job = e; res = e->job_func(e->ctx, e->argc, vc(e->argv)); + rt->current_job = prev_job; for(i = 0; i < e->argc; i++) JS_FreeValue(ctx, e->argv[i]); if (JS_IsException(res)) @@ -8185,6 +8192,10 @@ static bool can_store_error_stack(JSValueConst obj) #define JS_BACKTRACE_FLAG_SINGLE_LEVEL (1 << 1) #define JS_BACKTRACE_FLAG_FILTER_FUNC (1 << 2) +static JSStackFrame *js_async_stack_frame(JSContext *ctx, JSStackFrame *sf, + JSJobEntry *job, JSCallSiteData *csd, + DynBuf *dbuf, uint32_t *count, int limit); + /* if filename != NULL, an additional level is added with the filename and line number information (used for parse error). */ static void build_backtrace(JSContext *ctx, JSValueConst error_val, @@ -8192,6 +8203,9 @@ static void build_backtrace(JSContext *ctx, JSValueConst error_val, int line_num, int col_num, int backtrace_flags) { JSStackFrame *sf, *sf_start; + JSStackFrame *async_sf = NULL; + JSJobEntry *job = ctx->rt->current_job; + bool is_async = false; JSValue stack, prepare, saved_exception, error_obj; DynBuf dbuf; const char *func_name_str; @@ -8275,10 +8289,10 @@ static void build_backtrace(JSContext *ctx, JSValueConst error_val, } } - for (sf = sf_start; sf != NULL && i < stack_trace_limit; sf = sf->prev_frame) { + for (sf = sf_start; sf != NULL && i < stack_trace_limit;) { if (backtrace_flags & JS_BACKTRACE_FLAG_SKIP_FIRST_LEVEL) { backtrace_flags &= ~JS_BACKTRACE_FLAG_SKIP_FIRST_LEVEL; - continue; + goto next; } p = JS_VALUE_GET_OBJ(sf->cur_func); @@ -8292,6 +8306,7 @@ static void build_backtrace(JSContext *ctx, JSValueConst error_val, if (has_prepare) { js_new_callsite_data(ctx, &csd[i], sf); + csd[i].is_async = is_async; } else { /* func_name_str is UTF-8 encoded if needed */ func_name_str = get_func_name(ctx, sf->cur_func); @@ -8299,7 +8314,7 @@ static void build_backtrace(JSContext *ctx, JSValueConst error_val, str1 = ""; else str1 = func_name_str; - dbuf_printf(&dbuf, " at %s", str1); + dbuf_printf(&dbuf, " at %s%s", is_async ? "async " : "", str1); JS_FreeCString(ctx, func_name_str); if (b && sf->cur_pc) { @@ -8330,6 +8345,21 @@ static void build_backtrace(JSContext *ctx, JSValueConst error_val, /* stop backtrace if JS_EVAL_FLAG_BACKTRACE_BARRIER was used */ if (backtrace_barrier) break; + next: + if (!is_async) { + /* Start with the outermost coroutine already on the native stack. */ + if (js_class_has_bytecode(JS_VALUE_GET_OBJ(sf->cur_func)->class_id) && + sf->cur_gc_obj) + async_sf = sf; + sf = sf->prev_frame; + if (sf) + continue; + sf = async_sf; + is_async = true; + } + sf = js_async_stack_frame(ctx, sf, job, has_prepare ? csd : NULL, + has_prepare ? NULL : &dbuf, &i, stack_trace_limit); + job = NULL; } done: if (has_prepare) { @@ -55531,6 +55561,119 @@ typedef struct JSPromiseReactionData { JSValue handler; } JSPromiseReactionData; +static JSValue promise_reaction_job(JSContext *ctx, int argc, JSValueConst *argv); +static JSValue js_promise_all_resolve_element(JSContext *ctx, JSValueConst this_val, + int argc, JSValueConst *argv, + int magic, JSValueConst *func_data); + +/* Return a borrowed promise without invoking user-defined resolving functions. */ +static JSValue js_resolving_function_promise(JSValueConst func) +{ + JSPromiseFunctionData *fd = JS_GetOpaque(func, JS_CLASS_PROMISE_RESOLVE_FUNCTION); + if (!fd) + fd = JS_GetOpaque(func, JS_CLASS_PROMISE_REJECT_FUNCTION); + return fd ? fd->promise : JS_UNDEFINED; +} + +/* Follow existing promise links without retaining frames or running JS. */ +static JSStackFrame *js_async_stack_frame(JSContext *ctx, JSStackFrame *sf, + JSJobEntry *job, JSCallSiteData *csd, + DynBuf *dbuf, uint32_t *count, int limit) +{ + JSAsyncFunctionData *af; + JSAsyncGeneratorData *ag; + JSPromiseData *pd; + JSPromiseReactionData *rd; + JSCFunctionDataRecord *cd; + JSObject *p; + JSValue promise = JS_UNDEFINED; + struct list_head *head; + int depth; + + /* A .then() callback or thenable may have no running async function. */ + if (job) { + if (job->job_func == promise_reaction_job) + promise = js_resolving_function_promise(job->argv[0]); + else if (job->job_func == js_promise_resolve_thenable_job) + promise = job->argv[0]; + if (!JS_IsUndefined(promise)) + goto follow; + } + if (!sf || !sf->cur_gc_obj) + return NULL; + if (JS_GC_TYPE(sf->cur_gc_obj) == JS_GC_OBJ_TYPE_ASYNC_FUNCTION) { + af = (JSAsyncFunctionData *)sf->cur_gc_obj; + promise = js_resolving_function_promise(af->resolving_funcs[0]); + } else { + p = (JSObject *)sf->cur_gc_obj; + if (p->class_id != JS_CLASS_ASYNC_GENERATOR) + return NULL; + ag = p->u.async_generator_data; + if (list_empty(&ag->queue)) + return NULL; + promise = list_entry(ag->queue.next, JSAsyncGeneratorRequest, link)->promise; + } + follow: + /* Bound traversal through forwarding links, which can form cycles. */ + for (depth = 0; depth < 1024 && *count < limit; depth++) { + pd = JS_GetOpaque(promise, JS_CLASS_PROMISE); + if (!pd || pd->promise_state != JS_PROMISE_PENDING) + return NULL; + head = &pd->promise_reactions[0]; + /* A shared promise has no unambiguous async caller. */ + if (list_empty(head) || head->next->next != head) + return NULL; + rd = list_entry(head->next, JSPromiseReactionData, link); + af = JS_GetOpaque(rd->handler, JS_CLASS_ASYNC_FUNCTION_RESOLVE); + if (af && af->is_active && af->func_state.frame.cur_sp) + return &af->func_state.frame; + cd = JS_GetOpaque(rd->handler, JS_CLASS_C_FUNCTION_DATA); + if (cd && cd->func == js_async_generator_resolve_function) { + ag = JS_GetOpaque(cd->data[0], JS_CLASS_ASYNC_GENERATOR); + if (ag && ag->state == JS_ASYNC_GENERATOR_STATE_EXECUTING && + ag->func_state.frame.cur_sp) + return &ag->func_state.frame; + } + /* any stores its element handler on the rejection reaction. */ + if ((!cd || cd->func != js_promise_all_resolve_element) && + !list_empty(&pd->promise_reactions[1])) { + JSPromiseReactionData *reject = list_entry(pd->promise_reactions[1].next, + JSPromiseReactionData, link); + cd = JS_GetOpaque(reject->handler, JS_CLASS_C_FUNCTION_DATA); + } + if (cd && cd->func == js_promise_all_resolve_element) { + static const char * const names[] = { "all", "allSettled", "any" }; + int kind = cd->magic & 3; + int index = JS_VALUE_GET_INT(cd->data[1]); + if (csd) { + csd[*count] = (JSCallSiteData) { + .filename = JS_NULL, + .func = JS_UNDEFINED, + .func_name = JS_NewString(ctx, names[kind]), + .is_async = true, + .is_promise_all = kind == 0, + .promise_index = index, + .line_num = -1, + .col_num = -1, + }; + if (JS_IsException(csd[*count].func_name)) + csd[*count].func_name = JS_NULL; + } else { + dbuf_printf(dbuf, " at async Promise.%s (index %d)\n", + names[kind], index); + } + (*count)++; + promise = js_resolving_function_promise(cd->data[3]); + } else { + /* Promise adoption and any/race use the resolving function directly. */ + promise = js_resolving_function_promise(rd->handler); + if (JS_IsUndefined(promise)) + promise = js_resolving_function_promise(rd->resolving_funcs[0]); + } + } + return NULL; +} + JSPromiseStateEnum JS_PromiseState(JSContext *ctx, JSValueConst promise) { JSPromiseData *s = JS_GetOpaque(promise, JS_CLASS_PROMISE); @@ -63534,6 +63677,9 @@ static void js_new_callsite_data(JSContext *ctx, JSCallSiteData *csd, JSStackFra JSObject *p; csd->constructor = sf->is_constructor; + csd->is_async = false; + csd->is_promise_all = false; + csd->promise_index = -1; csd->func = js_dup(sf->cur_func); /* func_name_str is UTF-8 encoded if needed */ func_name_str = get_func_name(ctx, sf->cur_func); @@ -63574,6 +63720,9 @@ static void js_new_callsite_data2(JSContext *ctx, JSCallSiteData *csd, const cha csd->func_name = JS_NULL; csd->native = false; csd->constructor = false; + csd->is_async = false; + csd->is_promise_all = false; + csd->promise_index = -1; csd->line_num = line_num; csd->col_num = col_num; /* filename is UTF-8 encoded if needed (original argument to __JS_EvalInternal()) */ @@ -63609,6 +63758,22 @@ static JSValue js_callsite_isconstructor(JSContext *ctx, JSValueConst this_val, return js_bool(csd->constructor); } +static JSValue js_callsite_isasync(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) +{ + JSCallSiteData *csd = JS_GetOpaque2(ctx, this_val, JS_CLASS_CALL_SITE); + if (!csd) + return JS_EXCEPTION; + return js_bool(csd->is_async); +} + +static JSValue js_callsite_ispromiseall(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) +{ + JSCallSiteData *csd = JS_GetOpaque2(ctx, this_val, JS_CLASS_CALL_SITE); + if (!csd) + return JS_EXCEPTION; + return js_bool(csd->is_promise_all); +} + static JSValue js_callsite_getnumber(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv, int magic) { JSCallSiteData *csd = JS_GetOpaque2(ctx, this_val, JS_CLASS_CALL_SITE); @@ -63624,6 +63789,9 @@ static JSValue js_callsite_getnumber(JSContext *ctx, JSValueConst this_val, int static const JSCFunctionListEntry js_callsite_proto_funcs[] = { JS_CFUNC_DEF("isNative", 0, js_callsite_isnative), JS_CFUNC_DEF("isConstructor", 0, js_callsite_isconstructor), + JS_CFUNC_DEF("isAsync", 0, js_callsite_isasync), + JS_CFUNC_DEF("isPromiseAll", 0, js_callsite_ispromiseall), + JS_CFUNC_MAGIC_DEF("getPromiseIndex", 0, js_callsite_getnumber, offsetof(JSCallSiteData, promise_index)), JS_CFUNC_MAGIC_DEF("getFileName", 0, js_callsite_getfield, offsetof(JSCallSiteData, filename)), JS_CFUNC_MAGIC_DEF("getFunction", 0, js_callsite_getfield, offsetof(JSCallSiteData, func)), JS_CFUNC_MAGIC_DEF("getFunctionName", 0, js_callsite_getfield, offsetof(JSCallSiteData, func_name)), diff --git a/tests/test_async_stack.js b/tests/test_async_stack.js new file mode 100644 index 000000000..2a37387e9 --- /dev/null +++ b/tests/test_async_stack.js @@ -0,0 +1,147 @@ +import { assert, assertArrayEquals } from "./assert.js"; + +async function leaf(capture = false) { + await 0; + const error = capture ? {} : new Error("boom"); + if (capture) + Error.captureStackTrace(error); + throw Object.freeze(error); +} + +async function middle(capture) { + await leaf(capture); +} + +async function outer(capture) { + await middle(capture); +} + +async function caught(promise) { + try { + await promise; + } catch (error) { + return error; + } + throw new Error("expected rejection"); +} + +function check(frames, names) { + frames = frames.filter(f => !f.isNative()); + assertArrayEquals(frames.slice(0, names.length).map(f => f.getFunctionName()), names); + for (let i = 0; i < names.length; i++) { + assert(frames[i].isAsync(), i !== 0); + assert(!frames[i].isNative()); + assert(!frames[i].isConstructor()); + assert(frames[i].getFileName().endsWith("test_async_stack.js")); + assert(frames[i].getLineNumber() > 0); + assert(frames[i].getColumnNumber() > 0); + } +} + +let calls = 0; +Error.prepareStackTrace = (_, frames) => { + calls++; + return Object.freeze(frames); +}; +let error = await caught(outer()); +check(error.stack, ["leaf", "middle", "outer"]); +assert(error.stack[1].getFunction(), middle); +assert(error.stack[2].getFunction(), outer); +assert(error.stack[1].getLineNumber(), 12); +assert(error.stack[2].getLineNumber(), 16); +assert(calls, 1); + +// Capturing on a plain object must work before any rejection is propagated. +check((await caught(outer(true))).stack, ["leaf", "middle", "outer"]); +assert(calls, 2, "capture on plain object"); + +// Rethrowing an existing error must not modify its original stack. +const stack = error.stack; +let cleaned = false; +async function rethrow() { + try { + await 0; + throw error; + } catch (caught) { + await 0; + throw caught; + } finally { + await 0; + cleaned = true; + } +} +assert(await caught(rethrow()) === error); +assert(error.stack, stack); +assert(cleaned); + +// Callers that have not suspended are already on the synchronous stack. +async function immediate() { throw new Error("sync"); } +async function synchronous() { await immediate(); } +const sync = (await caught(synchronous())).stack; +assert(sync.filter(f => f.getFunctionName() === "synchronous").length, 1); +assert(!sync.find(f => f.getFunctionName() === "synchronous").isAsync()); + +// Follow both an async generator's await and its consumer's next() promise. +async function* generator() { await leaf(); } +async function consumer() { await generator().next(); } +check((await caught(consumer())).stack, ["leaf", "generator", "consumer"]); +async function* throwingGenerator() { yield 1; await 0; throw new Error("generator"); } +let received = 0; +async function iterate() { for await (const value of throwingGenerator()) received += value; } +check((await caught(iterate())).stack, ["throwingGenerator", "iterate"]); +assert(received, 1); +// A completed generator's return() handler must not expose its freed frame. +async function* empty() {} +const completed = empty(); +await completed.next(); +assert((await caught(completed.return(leaf()))).stack[0].getFunctionName(), "leaf"); + +// A promise with multiple consumers has no single async caller. +const shared = leaf(); +async function waiter() { await shared; } +const errors = await Promise.all([caught(waiter()), caught(waiter())]); +assert(errors[0], errors[1]); +assert(errors[0].stack.length, 1); + +// Stack limits apply to synchronous and asynchronous frames together. +for (const limit of [0, 1, 2]) { + Error.stackTraceLimit = limit; + assert((await caught(outer())).stack.length, limit); +} +async function recursive(n) { + await 0; + if (n) return await recursive(n - 1); + throw new Error("deep"); +} +Error.stackTraceLimit = Infinity; +const deepError = await caught(recursive(100)); +assert(deepError.message, "deep"); +const deep = deepError.stack; +assert(deep.length, 64); +assert(deep.slice(1).every(f => f.isAsync())); + +Error.prepareStackTrace = undefined; +Error.stackTraceLimit = 10; +const text = (await caught(outer())).stack; +assert(text.includes(" at leaf (")); +assert(text.includes(" at async middle (")); +assert(text.includes(" at async outer (")); +Error.stackTraceLimit = 1; +assert(!(await caught(outer())).stack.includes("at async")); +Error.stackTraceLimit = 10; + +// Propagation must not invoke user-defined stack accessors. +error = new Error("custom"); +let reads = 0; +Object.defineProperty(error, "stack", { get() { reads++; return "custom"; } }); +assert(await caught(rethrow()), error); +assert(reads, 0); + +// Discarded structured frames must not keep a closure/error cycle alive. +Error.prepareStackTrace = () => "custom"; +(function () { + let retained; + function capture() { retained = new Error("cycle"); } + capture(); +})(); +Error.prepareStackTrace = undefined; diff --git a/tests/test_async_stack_common.js b/tests/test_async_stack_common.js new file mode 100644 index 000000000..272d1ae63 --- /dev/null +++ b/tests/test_async_stack_common.js @@ -0,0 +1,70 @@ +import { assert, assertArrayEquals } from "./assert.js"; + +// Keep these common scenarios runnable in both QuickJS and V8/Node. +Error.prepareStackTrace = (_, frames) => frames; + +async function caught(promise) { + try { + await promise; + } catch (error) { + return error; + } + throw new Error("expected rejection"); +} + +function check(error, expected) { + const frames = error.stack.filter(frame => !frame.isNative()); + assertArrayEquals( + frames.slice(0, expected.length).map(frame => + [frame.getFunctionName(), frame.isAsync()].join(":")), + expected.map(frame => frame.join(":"))); +} + +// Validation after several awaits retains both synchronous and async method frames. +function validate() { throw new Error("invalid response"); } +class Repository { + async query() { + await Promise.resolve(); + await new Promise(resolve => Promise.resolve().then(resolve)); + validate(); + } + async findUser() { return await this.query(); } +} +async function handleRequest() { await new Repository().findUser(); } +const invalid = await caught(handleRequest()); +assert(invalid.message, "invalid response"); +check(invalid, [ + ["validate", false], + ["query", false], + ["findUser", true], + ["handleRequest", true], +]); + +// Capture a plain object's stack before throwing, filtering out the helper. +function capture() { + const target = {}; + Error.captureStackTrace(target, capture); + return target; +} +async function readConfig() { + await 0; + return capture(); +} +async function start() { return await readConfig(); } +check(await start(), [["readConfig", false], ["start", true]]); + +// Concurrent tasks retain their own direct-await chains, without mixing frames. +async function task(id) { + await 0; + await 0; + throw new Error(String(id)); +} +async function worker(id) { await task(id); } +const errors = await Promise.all([caught(worker(1)), caught(worker(2))]); +assert(errors[0] !== errors[1]); +for (let i = 0; i < errors.length; i++) { + assert(errors[i].message, String(i + 1)); + check(errors[i], [["task", false], ["worker", true]]); +} + +Error.prepareStackTrace = undefined; diff --git a/tests/test_async_stack_cycles.js b/tests/test_async_stack_cycles.js new file mode 100644 index 000000000..319371296 --- /dev/null +++ b/tests/test_async_stack_cycles.js @@ -0,0 +1,28 @@ +import { assert } from "./assert.js"; + +// Create a pending promise-adoption cycle. Capture inside the second adoption +// job, after both forwarding links exist, so reconstruction must bound its walk. +for (const structured of [false, true]) { + Error.prepareStackTrace = structured ? (_, frames) => frames : undefined; + let resolveP, resolveQ, captured; + const p = new Promise(resolve => { resolveP = resolve; }); + const q = new Promise(resolve => { resolveQ = resolve; }); + p.then = function linkAndCapture(...args) { + const result = Promise.prototype.then.apply(this, args); + captured = new Error("cycle").stack; + return result; + }; + resolveP(q); + resolveQ(p); + await 0; + if (structured) { + assert(Array.isArray(captured)); + assert(captured[0].getFunctionName(), "linkAndCapture"); + assert(!captured.some(frame => frame.isAsync())); + } else { + assert(typeof captured, "string"); + assert(captured.includes("at linkAndCapture (")); + assert(!captured.includes("at async")); + } +} +Error.prepareStackTrace = undefined; diff --git a/tests/test_async_stack_promises.js b/tests/test_async_stack_promises.js new file mode 100644 index 000000000..2af1a6614 --- /dev/null +++ b/tests/test_async_stack_promises.js @@ -0,0 +1,159 @@ +import { assert, assertArrayEquals } from "./assert.js"; + +const prepare = (_, frames) => frames; +Error.prepareStackTrace = prepare; + +async function caught(promise) { + try { + await promise; + } catch (error) { + return error instanceof AggregateError ? error.errors[0] : error; + } + throw new Error("expected rejection"); +} + +async function fail() { + await 0; + throw new Error("failed"); +} + +async function all() { await Promise.all([Promise.resolve(1), fail()]); } +async function any() { await Promise.any([fail(), fail()]); } +async function allSettled() { + const results = await Promise.allSettled([Promise.resolve(1), fail()]); + throw results[1].reason; +} +async function nested() { await Promise.all([Promise.any([fail()])]); } +async function chained() { await fail().then(value => value); } +async function longChain() { + let promise = fail(); + for (let i = 0; i < 100; i++) + promise = promise.then(value => value); + await promise; +} +async function callback() { + await Promise.resolve().then(function onFulfilled() { + throw new Error("failed"); + }); +} +async function rejection() { + await Promise.reject(1).catch(function onRejected() { + throw new Error("failed"); + }); +} +async function cleanup() { + await Promise.resolve().finally(function onFinally() { + throw new Error("failed"); + }); +} +async function thenable() { + await { then: function resolveThenable() { throw new Error("failed"); } }; +} +async function delayedFail() { + await 0; + await 0; + throw new Error("failed"); +} +async function adopted() { return delayedFail(); } +async function adoption() { await adopted(); } +async function callbackAsync() { + await Promise.resolve().then(async function insideCallback() { + throw new Error("failed"); + }); +} + +async function check(run, origin, combinators = []) { + const error = await caught(run()); + assert(error instanceof Error, true, run.name); + assert(error.message, "failed", run.name); + const synthetic = error.stack.filter(frame => frame.getPromiseIndex() !== null); + assertArrayEquals(synthetic.map(frame => + [frame.getFunctionName(), frame.getPromiseIndex()].join(":")), combinators); + for (const frame of synthetic) { + assert(frame.isAsync()); + assert(!frame.isNative()); + assert(!frame.isConstructor()); + assert(frame.isPromiseAll(), frame.getFunctionName() === "all"); + assert(frame.getFileName(), null); + assert(frame.getLineNumber(), null); + assert(frame.getColumnNumber(), null); + } + // Check real JavaScript frames separately from synthetic combinator frames. + const frames = error.stack.filter(frame => !frame.isNative() && frame.getFileName() && + frame.getPromiseIndex() === null); + assert(frames.slice(0, 3).map(frame => frame.getFunctionName()).join(","), + [origin, run.name, "caught"].join(","), run.name); + assertArrayEquals(frames.slice(0, 3).map(frame => frame.isAsync()), + [false, true, true]); + for (const frame of frames.slice(0, 3)) { + assert(frame.getFileName().endsWith("test_async_stack_promises.js")); + assert(frame.getLineNumber() > 0); + assert(frame.getColumnNumber() > 0); + } + // Exercise the same cases with QuickJS's default string formatting. + Error.prepareStackTrace = undefined; + const text = (await caught(run())).stack; + assert(text.includes("at async " + run.name + " ("), true, run.name); + for (const combinator of combinators) { + const [name, index] = combinator.split(":"); + assert(text.includes("at async Promise." + name + " (index " + index + ")")); + } + Error.prepareStackTrace = prepare; +} + +await check(all, "fail", ["all:1"]); +await check(any, "fail", ["any:0"]); +await check(allSettled, "fail", ["allSettled:1"]); +await check(nested, "fail", ["any:0", "all:0"]); +await check(chained, "fail"); +await check(longChain, "fail"); +await check(callback, "onFulfilled"); +await check(rejection, "onRejected"); +await check(cleanup, "onFinally"); +await check(thenable, "resolveThenable"); +await check(adoption, "delayedFail"); +await check(callbackAsync, "insideCallback"); + +// User-defined promise species must not be called by stack reconstruction. +let resolvingCalls = 0; +class CustomPromise extends Promise { + constructor(executor) { + super((resolve, reject) => executor( + value => { resolvingCalls++; resolve(value); }, reject)); + } +} +let captured; +await CustomPromise.resolve().then(() => { + const before = resolvingCalls; + captured = new Error("custom").stack; + assert(resolvingCalls, before); +}); +assert(Array.isArray(captured)); + +// An unrelated microtask must not inherit the preceding promise job's callers. +await new Promise(resolve => { + Promise.resolve().then(() => { + queueMicrotask(function unrelated() { + captured = new Error("microtask").stack; + resolve(); + }); + }); +}); +assert(!captured.some(frame => frame.isAsync())); + +for (const limit of [0, 1, 2, 3]) { + Error.stackTraceLimit = limit; + const error = await caught(all()); + assert(error instanceof Error); + assert(error.stack.length, limit); + if (limit >= 2) { + assert(error.stack[1].isPromiseAll()); + assert(error.stack[1].getPromiseIndex(), 1); + } + if (limit >= 3) { + assert(error.stack[2].getPromiseIndex(), null); + assert(!error.stack[2].isPromiseAll()); + } +} +Error.stackTraceLimit = 10; +Error.prepareStackTrace = undefined;