From aa19f948c5f3d5b5f9a4d916715ab7f842bc27e0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 23:19:15 -0500 Subject: [PATCH 1/2] Settle async generator completions on Node's microtask turns An async generator that fell off its end, with a return type other than void, settled its last next() one microtask turn after Node, so for await loops over it also ended a turn late. The appended implicit return now marks the completion as settled, like a bare `return;`. return() at a suspended yield ran the generator's finally blocks before awaiting the return value. The request now awaits the value first and resumes the body on the next turn, and the completion that follows settles without a further turn, also when a finally block yields or awaits first. An explicit `return x` reached during that unwind still takes its own turn to await x. Array.fromAsync accepts typed async generator sources: each next() result is awaited and its value used as is, a throwing or rejecting mapping function closes the generator, and a rejected next() rejects the result on the turn Node rejects it. --- .../language-features/async-iteration.mdx | 2 +- packages/compiler/src/backend/llvm/emitter.ts | 6 + .../lowering/containers/array-from-async.ts | 203 +++++++++++++++--- .../src/frontend/lowering/function-returns.ts | 8 +- .../src/frontend/lowering/lower-generators.ts | 2 +- .../5904-async-generator-completion.ts.json | 6 + .../5905-array-from-async-generator.ts.json | 6 + ...5906-array-from-async-generator-js.js.json | 6 + packages/runtime/src/scr_async.c | 69 +++++- packages/runtime/src/scr_generator.h | 2 + .../corpus/5904-async-generator-completion.ts | 188 ++++++++++++++++ .../corpus/5905-array-from-async-generator.ts | 61 ++++++ .../5906-array-from-async-generator-js.js | 60 ++++++ tests/harness/corpus-costs.json | 6 + 14 files changed, 590 insertions(+), 35 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5904-async-generator-completion.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5905-array-from-async-generator.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5906-array-from-async-generator-js.js.json create mode 100644 tests/corpus/5904-async-generator-completion.ts create mode 100644 tests/corpus/5905-array-from-async-generator.ts create mode 100644 tests/corpus/5906-array-from-async-generator-js.js diff --git a/docs/content/limitations/language-features/async-iteration.mdx b/docs/content/limitations/language-features/async-iteration.mdx index ee570f17d..f18377299 100644 --- a/docs/content/limitations/language-features/async-iteration.mdx +++ b/docs/content/limitations/language-features/async-iteration.mdx @@ -1 +1 @@ -- **Async iteration:** Async `yield*` delegation, stored Web or Node iterator handles, and general structural async-iterator objects are unsupported. Typed async generators support lazy execution, `await`, direct `yield`, queued `.next()`/`.return()`/`.throw()` requests, and `for await`. `for await` also accepts Node `Readable` streams, Web `ReadableStream` values, and compiled class iterators whose zero-parameter `next()` returns a promise of a `{ value, done? }` record. Abrupt completion runs cleanup, including literal `destroyOnReturn` and `preventCancel` options. The `node:timers/promises` `setInterval(delay, value)` iterator compiles without AbortSignal options. `Array.fromAsync` accepts arrays of values or of promises with an optional mapping function; async generator sources, a `thisArg`, and arrays that mix values and promises are unsupported. +- **Async iteration:** Async `yield*` delegation, stored Web or Node iterator handles, and general structural async-iterator objects are unsupported. Typed async generators support lazy execution, `await`, direct `yield`, queued `.next()`/`.return()`/`.throw()` requests, and `for await`. `for await` also accepts Node `Readable` streams, Web `ReadableStream` values, and compiled class iterators whose zero-parameter `next()` returns a promise of a `{ value, done? }` record. Abrupt completion runs cleanup, including literal `destroyOnReturn` and `preventCancel` options. The `node:timers/promises` `setInterval(delay, value)` iterator compiles without AbortSignal options. `Array.fromAsync` accepts arrays of values or of promises and typed async generators, with an optional mapping function; a `thisArg` and arrays that mix values and promises are unsupported. diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 2c3a71457..e2b1102c1 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -8919,6 +8919,12 @@ export class LlEmitter { // own turn is the await (nothing runs in between). const genSettles = this.currentAsyncGenerator && (s.awaited === true || this.finallyStack.length > 0); + // A completion turn that awaits x replaces a return() request's + // already-taken await. + if (this.currentAsyncGenerator && !genSettles) { + this.declare(`declare void @scr_async_gen_return_awaits()`); + B.line(`call void @scr_async_gen_return_awaits()`); + } if (this.currentAsyncGenerator && s.awaited !== true && this.finallyStack.length > 0) { if (this.wasi) this.emitWasiSuspend(null); else { diff --git a/packages/compiler/src/frontend/lowering/containers/array-from-async.ts b/packages/compiler/src/frontend/lowering/containers/array-from-async.ts index 43e8b4959..db60cdf1b 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-from-async.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-from-async.ts @@ -17,9 +17,10 @@ import { locOf } from "../../program.js"; import { arrayValueRead, arrayValueStore, arrayValueType } from "../array-values.js"; import { newFnCtx } from "../function-context.js"; import { lowerArrayCallback } from "./callback-arguments.js"; +import { extractIteratorValue, resultRecordOf, type GenType } from "../lower-generators.js"; -/** `Array.fromAsync(source, mapFn?)` over an array, as an async helper - * with the spec's loop: +/** `Array.fromAsync(source, mapFn?)` over an array or an async generator, + * as an async helper with the spec's loop: * * const out = []; let k = 0; * for await (const v of source) out.push(mapFn ? await mapFn(v, k++) : v); @@ -29,9 +30,10 @@ import { lowerArrayCallback } from "./callback-arguments.js"; * awaited (a promise element through its promise, any other value as one * resolved promise), and the iterator result is awaited again, which the * helper spells as the same microtask hops the `for await` array loop - * takes. A throwing or rejecting mapFn, and a rejected element, close the - * iterator before the returned promise rejects. Null when this is not an - * `Array.fromAsync` call. */ + * takes. An async generator is driven directly: each next() result is + * awaited once and its value is used as is. A throwing or rejecting mapFn, + * and a rejected array element, close the iterator before the returned + * promise rejects. Null when this is not an `Array.fromAsync` call. */ export function lowerArrayFromAsyncCall( lowerer: Lowerer, call: ts.CallExpression, @@ -54,23 +56,18 @@ export function lowerArrayFromAsyncCall( lowerer.badType(call, lowerer.typeOf(call)); const outT = resultT.inner; const source = lowerer.lowerExpr(args[0]!); - if (source.type.kind !== "array") { - if (source.type.kind === "generator" && source.type.async) { - // An async generator's completion settles one microtask later here - // than in Node, which would reorder the returned promise. - lowerer.noLowering( - "Array.fromAsync over an async generator", - args[0]!, - "collect its values with for await", - ); - } + let step: SourceStep; + if (source.type.kind === "array") { + step = arraySourceStep(lowerer, args[0]!, source.type.elem); + } else if (source.type.kind === "generator" && source.type.async) { + step = generatorSourceStep(lowerer, args[0]!, source.type); + } else { lowerer.noLowering( `Array.fromAsync over a value of type '${lowerer.checker.typeToString(lowerer.typeOf(args[0]!))}'`, args[0]!, - "an array is the supported source", + "an array or an async generator is the supported source", ); } - const step = arraySourceStep(lowerer, args[0]!, source.type.elem); let mapper: (IrExpr & { type: IrType & { kind: "func" } }) | null = null; if (args[1]) { const callback = lowerArrayCallback(lowerer, args[1], [step.valueT], outT); @@ -207,19 +204,173 @@ function nonThenable(lowerer: Lowerer, type: IrType): boolean { ); } +interface SourceStep { + /** The value each step hands to the mapping function. */ + valueT: IrType; + loop(source: IrExpr, append: (value: IrExpr) => IrStmt[], loc: SrcLoc): IrStmt[]; +} + +/** The async generator loop: each next() result is awaited, `done` ends + * the loop, and the value goes to the mapping function as is (no further + * await, unlike the async-from-sync array form). A mapping function that + * throws or rejects closes the generator by awaiting return(); a failure + * of that close is dropped, so the mapping error rejects the result. A + * rejected next() does not close the generator and rejects the result on + * the turn Node rejects it. */ +function generatorSourceStep(lowerer: Lowerer, node: ts.Expression, genT: GenType): SourceStep { + if (genT.yieldT.kind === "void") { + lowerer.noLowering("Array.fromAsync over an async generator that never yields", node); + } + if (genT.nextT.kind !== "undefinedT" && genT.nextT.kind !== "dyn") { + lowerer.noLowering( + `Array.fromAsync over an async generator whose yields expect .next(value) ('${lowerer.fmt(genT.nextT)}')`, + node, + ); + } + const recT = resultRecordOf(lowerer, genT); + const promiseT: IrType = { kind: "promise", inner: recT }; + const fieldT = lowerer.shapes.get(recT.shapeId)!.fields.find((f) => f.name === "value")!.type; + return { + valueT: genT.yieldT, + loop(source, append, loc) { + const g = lowerer.declareHiddenLocal("%fromAsyncGenerator", genT); + const needsClose = lowerer.declareHiddenLocal("%fromAsyncNeedsClose", BOOL); + needsClose.mutable = true; + const p = lowerer.declareHiddenLocal("%fromAsyncNext", promiseT); + const r = lowerer.declareHiddenLocal("%fromAsyncResult", recT); + r.mutable = true; + const nextError = lowerer.declareHiddenLocal("%fromAsyncNextError", CAUGHT); + const closeError = lowerer.declareHiddenLocal("%fromAsyncCloseError", CAUGHT); + const gRef = varRef(g.id, genT, loc); + const rRef = varRef(r.id, recT, loc); + const read: IrExpr = { + kind: "recordGet", + obj: rRef, + shapeId: recT.shapeId, + field: "value", + type: fieldT, + loc, + }; + const value = extractIteratorValue(lowerer, genT.yieldT, fieldT, read, loc); + if (!value) { + lowerer.noLowering( + `Array.fromAsync over an async generator yielding '${lowerer.fmt(genT.yieldT)}'`, + node, + ); + } + const setClose = (on: boolean): IrStmt => ({ + kind: "assign", + localId: needsClose.id, + value: boolLit(on, loc), + loc, + }); + const loop: IrStmt = { + kind: "while", + cond: boolLit(true, loc), + body: [ + { + kind: "varDecl", + localId: p.id, + init: { kind: "genResume", mode: "next", gen: gRef, arg: null, type: promiseT, loc }, + loc, + }, + { kind: "varDecl", localId: r.id, init: null, loc }, + // A rejected next() rejects the result two reactions later, as + // the rejected array element does. + { + kind: "tryCatch", + tryBody: [ + { + kind: "assign", + localId: r.id, + value: { kind: "awaitExpr", value: varRef(p.id, promiseT, loc), type: recT, loc }, + loc, + }, + ], + catchBody: [hop(loc), hop(loc), { kind: "rethrow", localId: nextError.id, loc }], + catchLocalId: nextError.id, + finallyBody: null, + loc, + }, + { + kind: "if", + cond: { + kind: "recordGet", + obj: rRef, + shapeId: recT.shapeId, + field: "done", + type: BOOL, + loc, + }, + then: [{ kind: "break", loc }], + else_: null, + loc, + }, + setClose(true), + ...append(value!), + setClose(false), + ], + loc, + }; + const stmts: IrStmt[] = [ + { kind: "varDecl", localId: g.id, init: source, loc }, + { kind: "varDecl", localId: needsClose.id, init: boolLit(false, loc), loc }, + { + kind: "tryCatch", + tryBody: [loop], + catchBody: null, + catchLocalId: null, + finallyBody: [ + { + kind: "if", + cond: varRef(needsClose.id, BOOL, loc), + then: [closeGenerator(gRef, promiseT, recT, closeError.id, loc)], + else_: null, + loc, + }, + ], + loc, + }, + ]; + return stmts; + }, + }; +} + +/** Await `gen.return()`, dropping a failure of the close. */ +function closeGenerator( + gen: IrExpr, + promiseT: IrType, + recT: IrType, + errorLocalId: string, + loc: SrcLoc, +): IrStmt { + return { + kind: "tryCatch", + tryBody: [ + { + kind: "exprStmt", + expr: { + kind: "awaitExpr", + value: { kind: "genResume", mode: "return", gen, arg: null, type: promiseT, loc }, + type: recT, + loc, + }, + loc, + }, + ], + catchBody: [], + catchLocalId: errorLocalId, + finallyBody: null, + loc, + }; +} + /** The `for await` array loop (lowerForOf's async-from-sync form): two * hops per element that is not a promise, the element's own await plus * one hop for a promise element, two hops before the final done result, * and one hop to close the iterator when the body completes abruptly. */ -function arraySourceStep( - lowerer: Lowerer, - node: ts.Expression, - elem: IrType, -): { - /** The awaited value each element hands to the mapping function. */ - valueT: IrType; - loop(source: IrExpr, append: (value: IrExpr) => IrStmt[], loc: SrcLoc): IrStmt[]; -} { +function arraySourceStep(lowerer: Lowerer, node: ts.Expression, elem: IrType): SourceStep { const yieldedT = arrayValueType(lowerer, elem); let promiseTag: number | null = null; let valueT = yieldedT; diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index 38c728027..d3c5eb1ba 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -59,6 +59,11 @@ export function appendImplicitUndefinedReturn( ): void { if (bodyReturn.kind === "void") return; if (!bodyMayFallThrough(body)) return; + // An async generator that falls off its end completes without awaiting + // anything: its request settles as the body returns, where `return x` + // takes a turn to await x. + let settled = false; + if (lowerer.ctx.isAsync === true && lowerer.ctx.generator) settled = true; // A DYN body that can complete without returning (a JS function whose // guarded return may not run — mustSucceed's `if (typeof fn === // 'function') return fn.apply(...)`): JS completes with undefined — @@ -67,6 +72,7 @@ export function appendImplicitUndefinedReturn( body.push({ kind: "return", value: dynUndefinedExpr(loc), + ...(settled ? { awaited: true as const } : {}), loc, }); return; @@ -74,7 +80,7 @@ export function appendImplicitUndefinedReturn( if (bodyReturn.kind === "union") { const value = lowerer.wrappedUndefined(bodyReturn, loc); if (value) { - body.push({ kind: "return", value, loc }); + body.push({ kind: "return", value, ...(settled ? { awaited: true as const } : {}), loc }); return; } // no undefined arm: the trap below stands in, exactly like non-unions diff --git a/packages/compiler/src/frontend/lowering/lower-generators.ts b/packages/compiler/src/frontend/lowering/lower-generators.ts index c61920c60..2eda4c4ce 100644 --- a/packages/compiler/src/frontend/lowering/lower-generators.ts +++ b/packages/compiler/src/frontend/lowering/lower-generators.ts @@ -32,7 +32,7 @@ export type GenType = IrType & { kind: "generator" }; /** The interned IteratorResult record of a generator type (never null for * a MAPPED generator — mapType required it to intern). */ -function resultRecordOf(lowerer: Lowerer, genT: GenType): IrType & { kind: "record" } { +export function resultRecordOf(lowerer: Lowerer, genT: GenType): IrType & { kind: "record" } { const rec = genResultRecord(genT.yieldT, genT.retT, lowerer.shapes, lowerer.unions); if (!rec) throw new InternalCompilerError("lowerer bug: mapped generator without a result record"); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5904-async-generator-completion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5904-async-generator-completion.ts.json new file mode 100644 index 000000000..dd2338a5b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5904-async-generator-completion.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5904-async-generator-completion.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5905-array-from-async-generator.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5905-array-from-async-generator.ts.json new file mode 100644 index 000000000..dd9276926 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5905-array-from-async-generator.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5905-array-from-async-generator.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5906-array-from-async-generator-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5906-array-from-async-generator-js.js.json new file mode 100644 index 000000000..fc7977463 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5906-array-from-async-generator-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5906-array-from-async-generator-js.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 46175cf82..e6a18072d 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -4860,6 +4860,13 @@ struct ScrGen { ScrExcCell async_error; bool settle_queued; bool success_hop_done; + /* A return() request at a yield: the queued job resumes the body after + * the return value's await (return_queued), and the body's completion, + * on that request or a later one, settles without another turn + * (return_awaited). An explicit `return x` during the unwind clears it, + * since that return takes its own turn to await x. */ + bool return_queued; + bool return_awaited; #ifdef SCR_WORKERS bool context_abandoned; #endif @@ -5261,6 +5268,8 @@ void scr_coro_yield_ref(ScrFiber *self, void *v, void (*release)(void *)) { scr_gen_slot_ref(&self->gen->out, v, release); } +void scr_async_gen_return_awaits(void) { scr_gen_self()->return_awaited = false; } + void scr_async_gen_hop_done(void) { ScrGen *g = scr_gen_self(); if (!g->is_async) { @@ -5540,6 +5549,21 @@ static void scr_async_gen_capture_caller_error(ScrGen *g) { if (cell->kind != SCR_EXC_NONE) scr_gen_exc_move(&g->async_error, cell); } +/* Whether the body's latest stop settles its request without a further + * turn: a yield whose await already supplied the value, or a completion + * after a return() request's await. */ +static bool scr_async_gen_settles_now(ScrGen *g) { + if (g->success_hop_done) { + g->success_hop_done = false; + return true; + } + if (g->return_awaited && g->state == SCR_GEN_DONE) { + g->return_awaited = false; + return true; + } + return false; +} + static void scr_async_gen_run_active(ScrGen *g, ScrAsyncGenRequest *req) { const bool was_done = g->state == SCR_GEN_DONE; if (req->mode == SCR_AG_NEXT) { @@ -5551,6 +5575,13 @@ static void scr_async_gen_run_active(ScrGen *g, ScrAsyncGenRequest *req) { scr_gen_slot_reset(&g->ret); g->ret = req->arg; memset(&req->arg, 0, sizeof req->arg); + if (g->state == SCR_GEN_SUSPENDED) { + /* At a yield, AsyncGeneratorYield awaits the return value before + * the body resumes, so finally blocks run one turn later. */ + g->return_queued = true; + scr_async_gen_schedule_settle(g); + return; + } scr_gen_resume_return(g); } else { scr_gen_exc_move(scr_exc_current_cell(), &req->thrown); @@ -5565,18 +5596,39 @@ static void scr_async_gen_run_active(ScrGen *g, ScrAsyncGenRequest *req) { if (g->state == SCR_GEN_AWAITING) return; /* next() on an already-completed generator produces its undefined done - * result immediately. Yield, body return, and return(value) all pass - * through AsyncGeneratorYield's successful Await step. */ + * result immediately. A yield and return(value) on an unstarted or + * completed generator pass through an Await step; a body that falls off + * its end, or whose `return x` already awaited x, marks its completion + * as settled. */ if (was_done && req->mode == SCR_AG_NEXT) { scr_async_gen_finish_active(g, false); - } else if (g->success_hop_done) { - g->success_hop_done = false; + } else if (scr_async_gen_settles_now(g)) { scr_async_gen_finish_active(g, false); } else { scr_async_gen_schedule_settle(g); } } +/* The return() request's await has taken its turn: resume the body with + * the return completion. */ +static void scr_async_gen_resume_returning(ScrGen *g) { + g->return_queued = false; + g->return_awaited = true; + scr_gen_resume_return(g); + scr_async_gen_capture_caller_error(g); + if (g->async_error.kind != SCR_EXC_NONE) { + scr_async_gen_finish_active(g, true); + } else if (g->state == SCR_GEN_AWAITING) { + return; + } else if (scr_async_gen_settles_now(g)) { + scr_async_gen_finish_active(g, false); + } else { + scr_async_gen_schedule_settle(g); + return; + } + scr_async_gen_start_queued(g); +} + static void scr_async_gen_start_queued(ScrGen *g) { /* Keep the object alive while an immediately-settled request releases * its own retain and the loop inspects the next queue node. */ @@ -5687,6 +5739,12 @@ static void scr_async_gen_job(ScrGen *g) { fputs("scriptc: internal error: invalid async generator settlement job\n", stderr); abort(); } + if (g->return_queued) { + scr_gen_retain(g); + scr_async_gen_resume_returning(g); + scr_gen_release(g); + return; + } scr_async_gen_finish_active(g, false); scr_async_gen_start_queued(g); } @@ -5704,8 +5762,7 @@ static void scr_async_gen_resume_ready(ScrGen *g) { scr_async_gen_finish_active(g, true); scr_async_gen_start_queued(g); } else if (g->state != SCR_GEN_AWAITING) { - if (g->success_hop_done) { - g->success_hop_done = false; + if (scr_async_gen_settles_now(g)) { scr_async_gen_finish_active(g, false); scr_async_gen_start_queued(g); } else { diff --git a/packages/runtime/src/scr_generator.h b/packages/runtime/src/scr_generator.h index f528b8b8f..2c2c0e953 100644 --- a/packages/runtime/src/scr_generator.h +++ b/packages/runtime/src/scr_generator.h @@ -133,6 +133,8 @@ void scr_gen_yield_f64(double v); void scr_gen_yield_bool(bool v); void scr_gen_yield_ref(void *v, void (*release)(void *)); /* moves */ void scr_async_gen_hop_done(void); +/* An async generator's `return x` whose completion turn awaits x. */ +void scr_async_gen_return_awaits(void); void scr_gen_out_f64(ScrGen *g, double v); /* trampoline completion stores */ void scr_gen_out_bool(ScrGen *g, bool v); void scr_gen_out_ref(ScrGen *g, void *v, void (*release)(void *)); /* moves */ diff --git a/tests/corpus/5904-async-generator-completion.ts b/tests/corpus/5904-async-generator-completion.ts new file mode 100644 index 000000000..478aae6e9 --- /dev/null +++ b/tests/corpus/5904-async-generator-completion.ts @@ -0,0 +1,188 @@ +// Async generator completion against a microtask ticker: falling off the end +// and bare returns settle as the body completes, `return x` takes a turn to +// await x, return() at a yield awaits its value before finally blocks run, +// and for await loops end on the turn Node ends them. Node.js prints the +// same. +const log: string[] = []; +async function ticker(n: number): Promise { + for (let i = 0; i < n; i++) { + log.push("t" + i); + await null; + } +} +async function run(name: string, body: () => Promise): Promise { + log.length = 0; + const tick = ticker(14); + await body(); + await tick; + console.log(name + ": " + log.join(" ")); +} +class Source { + readonly items: number[]; + constructor(items: number[]) { + this.items = items; + } + async *values(): AsyncGenerator { + for (const i of this.items) yield i; + } +} +async function main(): Promise { + await run("fall off", async () => { + async function* g(): AsyncGenerator { yield 1; } + const it = g(); + void it.next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + await it.next().then((r) => log.push(`b:${String(r.value)}/${r.done}`)); + }); + await run("return value", async () => { + async function* g(): AsyncGenerator { yield 1; return 5; } + const it = g(); + void it.next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + await it.next().then((r) => log.push(`b:${String(r.value)}/${r.done}`)); + }); + await run("bare return", async () => { + async function* g(): AsyncGenerator { yield 1; return; } + const it = g(); + void it.next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + await it.next().then((r) => log.push(`b:${String(r.value)}/${r.done}`)); + }); + await run("empty body", async () => { + async function* g(): AsyncGenerator {} + await g().next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + }); + await run("after done", async () => { + async function* g(): AsyncGenerator {} + const it = g(); + await it.next(); + log.push("mark"); + await it.next().then((r) => log.push(`b:${String(r.value)}/${r.done}`)); + }); + await run("return() at yield", async () => { + async function* g(): AsyncGenerator { try { yield 1; } finally { log.push("finally"); } return 0; } + const it = g(); + await it.next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + await it.return(7).then((r) => log.push(`r:${String(r.value)}/${r.done}`)); + }); + await run("return() before start", async () => { + async function* g(): AsyncGenerator { yield 1; return 0; } + await g().return(3).then((r) => log.push(`r:${String(r.value)}/${r.done}`)); + }); + await run("throw at end", async () => { + async function* g(): AsyncGenerator { yield 1; throw new Error("x"); } + const it = g(); + void it.next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + await it.next().then((r) => log.push(`b:${String(r.value)}/${r.done}`), () => log.push("rejected")); + }); + await run("await before end", async () => { + async function* g(): AsyncGenerator { yield 1; await null; } + const it = g(); + void it.next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + await it.next().then((r) => log.push(`b:${String(r.value)}/${r.done}`)); + }); + await run("for await", async () => { + async function* g(): AsyncGenerator { yield 1; yield 2; } + for await (const v of g()) log.push("v" + v); + log.push("loop done"); + }); + await run("for await break", async () => { + async function* g(): AsyncGenerator { try { yield 1; yield 2; } finally { log.push("cleanup"); } } + for await (const v of g()) { log.push("v" + v); break; } + log.push("loop done"); + }); + await run("queued", async () => { + async function* g(): AsyncGenerator { yield 1; } + const it = g(); + void it.next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + void it.next().then((r) => log.push(`b:${String(r.value)}/${r.done}`)); + await it.next().then((r) => log.push(`c:${String(r.value)}/${r.done}`)); + }); + + await run("yield in finally", async () => { + async function* g(): AsyncGenerator { try { yield 1; } finally { yield 9; log.push("after"); } return 0; } + const it = g(); + await it.next(); + void it.return(7).then((r) => log.push(`r:${r.value}/${r.done}`)); + await it.next().then((r) => log.push(`n:${r.value}/${r.done}`)); + }); + await run("await in finally", async () => { + async function* g(): AsyncGenerator { try { yield 1; } finally { await null; log.push("cleaned"); } return 0; } + const it = g(); + await it.next(); + await it.return(4).then((r) => log.push(`r:${r.value}/${r.done}`)); + }); + await run("throw at yield", async () => { + async function* g(): AsyncGenerator { try { yield 1; } catch { log.push("caught"); } } + const it = g(); + await it.next(); + await it.throw(new Error("x")).then((r) => log.push(`t:${r.value}/${r.done}`)); + }); + await run("return through finally", async () => { + async function* g(): AsyncGenerator { try { yield 1; return 5; } finally { log.push("fin"); } } + const it = g(); + void it.next().then((r) => log.push(`a:${r.value}/${r.done}`)); + await it.next().then((r) => log.push(`b:${r.value}/${r.done}`)); + }); + await run("return promise", async () => { + async function* g(): AsyncGenerator { yield 1; return Promise.resolve(3); } + const it = g(); + void it.next().then((r) => log.push(`a:${r.value}/${r.done}`)); + await it.next().then((r) => log.push(`b:${r.value}/${r.done}`)); + }); + await run("two returns", async () => { + async function* g(): AsyncGenerator { try { yield 1; } finally { log.push("fin"); } return 0; } + const it = g(); + await it.next(); + void it.return(1).then((r) => log.push(`r1:${r.value}/${r.done}`)); + await it.return(2).then((r) => log.push(`r2:${r.value}/${r.done}`)); + }); + await run("return after done", async () => { + async function* g(): AsyncGenerator { return 1; } + const it = g(); + await it.next(); + log.push("mark"); + await it.return(8).then((r) => log.push(`r:${r.value}/${r.done}`)); + }); + await run("nested loops break", async () => { + async function* g(n: number): AsyncGenerator { try { for (let i = 0; i < n; i++) yield i; } finally { log.push("close" + n); } } + for await (const a of g(2)) { + for await (const b of g(3)) { log.push(`${a}${b}`); if (b === 1) break; } + } + log.push("done"); + }); + await run("strings", async () => { + async function* g(): AsyncGenerator { yield "a"; yield "b"; } + const parts: string[] = []; + for await (const s of g()) parts.push(s); + log.push(parts.join("")); + }); + + await run("finally return override", async () => { + async function* g(): AsyncGenerator { try { yield 1; } finally { log.push("fin"); return 42; } } + const it = g(); + await it.next(); + await it.return(7).then((r) => log.push(`r:${r.value}/${r.done}`)); + }); + await run("class method", async () => { + const parts: number[] = []; + for await (const v of new Source([1, 2, 3]).values()) parts.push(v); + log.push(parts.join(",")); + }); + await run("return while awaiting", async () => { + async function* g(): AsyncGenerator { try { await null; await null; yield 1; } finally { log.push("fin"); } return 0; } + const it = g(); + void it.next().then((r) => log.push(`n:${r.value}/${r.done}`)); + await it.return(5).then((r) => log.push(`r:${r.value}/${r.done}`)); + }); + await run("throw after done", async () => { + async function* g(): AsyncGenerator {} + const it = g(); + await it.next(); + await it.throw(new Error("late")).then(() => log.push("ok"), (e: unknown) => log.push("rejected " + (e as Error).message)); + }); + await run("rejection inside", async () => { + async function* g(): AsyncGenerator { yield 1; await Promise.reject(new Error("inner")); } + const it = g(); + void it.next().then((r) => log.push(`a:${r.value}/${r.done}`)); + await it.next().then(() => log.push("ok"), (e: unknown) => log.push("rejected " + (e as Error).message)); + }); +} +void main(); diff --git a/tests/corpus/5905-array-from-async-generator.ts b/tests/corpus/5905-array-from-async-generator.ts new file mode 100644 index 000000000..f95fd5327 --- /dev/null +++ b/tests/corpus/5905-array-from-async-generator.ts @@ -0,0 +1,61 @@ +// Array.fromAsync over async generators: plain, mapped, async-mapped, a +// throwing or rejecting mapping function (which closes the generator), a +// generator that throws, an empty generator, and two calls at once, +// interleaved with a microtask ticker. Node.js prints the same. +const log: string[] = []; +async function ticker(n: number): Promise { + for (let i = 0; i < n; i++) { + log.push("t" + i); + await null; + } +} +async function* nums(n: number): AsyncGenerator { + try { + for (let i = 0; i < n; i++) { + log.push("yield " + i); + yield i; + } + } finally { + log.push("gen finally"); + } +} +async function* failing(): AsyncGenerator { + yield 1; + throw new Error("gen failure"); +} +async function report(label: string, job: Promise): Promise { + await job.then( + (a) => log.push(label + " [" + a.join(",") + "]"), + (e: unknown) => log.push(label + " rejected " + (e as Error).message), + ); +} +async function run(name: string, body: () => Promise): Promise { + log.length = 0; + const tick = ticker(20); + await body(); + await tick; + console.log(name + ": " + log.join(" ")); +} +async function main(): Promise { + await run("plain", () => report("plain", Array.fromAsync(nums(3)))); + await run("map", () => report("map", Array.fromAsync(nums(3), (x: number, i: number) => x * 10 + i))); + await run("async map", () => report("async map", Array.fromAsync(nums(2), async (x: number) => x + 1))); + await run("map throws", () => + report("map throws", Array.fromAsync(nums(3), (x: number) => { + if (x === 1) throw new Error("map failure"); + return x; + })), + ); + await run("map rejects", () => + report("map rejects", Array.fromAsync(nums(3), async (x: number) => { + if (x === 1) throw new Error("async map failure"); + return x; + })), + ); + await run("generator throws", () => report("generator throws", Array.fromAsync(failing()))); + await run("empty", () => report("empty", Array.fromAsync(nums(0)))); + await run("two at once", async () => { + await Promise.all([report("a", Array.fromAsync(nums(2))), report("b", Array.fromAsync(nums(1)))]); + }); +} +void main(); diff --git a/tests/corpus/5906-array-from-async-generator-js.js b/tests/corpus/5906-array-from-async-generator-js.js new file mode 100644 index 000000000..fa4d22147 --- /dev/null +++ b/tests/corpus/5906-array-from-async-generator-js.js @@ -0,0 +1,60 @@ +// Array.fromAsync over async generators from JavaScript, with and without a +// mapping function, interleaved with a microtask ticker. Node.js prints the +// same. +const log = []; +async function ticker(n ) { + for (let i = 0; i < n; i++) { + log.push("t" + i); + await null; + } +} +async function* nums(n ) { + try { + for (let i = 0; i < n; i++) { + log.push("yield " + i); + yield i; + } + } finally { + log.push("gen finally"); + } +} +async function* failing() { + yield 1; + throw new Error("gen failure"); +} +async function report(label , job ) { + await job.then( + (a) => log.push(label + " [" + a.join(",") + "]"), + (e ) => log.push(label + " rejected " + (e ).message), + ); +} +async function run(name , body ) { + log.length = 0; + const tick = ticker(20); + await body(); + await tick; + console.log(name + ": " + log.join(" ")); +} +async function main() { + await run("plain", () => report("plain", Array.fromAsync(nums(3)))); + await run("map", () => report("map", Array.fromAsync(nums(3), (x , i ) => x * 10 + i))); + await run("async map", () => report("async map", Array.fromAsync(nums(2), async (x ) => x + 1))); + await run("map throws", () => + report("map throws", Array.fromAsync(nums(3), (x ) => { + if (x === 1) throw new Error("map failure"); + return x; + })), + ); + await run("map rejects", () => + report("map rejects", Array.fromAsync(nums(3), async (x ) => { + if (x === 1) throw new Error("async map failure"); + return x; + })), + ); + await run("generator throws", () => report("generator throws", Array.fromAsync(failing()))); + await run("empty", () => report("empty", Array.fromAsync(nums(0)))); + await run("two at once", async () => { + await Promise.all([report("a", Array.fromAsync(nums(2))), report("b", Array.fromAsync(nums(1)))]); + }); +} +void main(); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 6d1199cce..4aaa891ad 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1841,6 +1841,9 @@ "5648-dns-lookup-forms.ts": 2992, "5649-dns-service-servers.ts": 2712, "5650-dns-lookup-errors-untyped.cjs": 2822, + "5904-async-generator-completion.ts": 2950, + "5905-array-from-async-generator.ts": 2750, + "5906-array-from-async-generator-js.js": 2950, "5950-tuple-predicate-methods.ts": 5990, "5951-result-never-widening.ts": 4710, "5955-any-promise-methods.js": 3940, @@ -4472,6 +4475,9 @@ "5648-dns-lookup-forms.ts": 3416, "5649-dns-service-servers.ts": 4916, "5650-dns-lookup-errors-untyped.cjs": 3889, + "5904-async-generator-completion.ts": 3800, + "5905-array-from-async-generator.ts": 3500, + "5906-array-from-async-generator-js.js": 3750, "5950-tuple-predicate-methods.ts": 7790, "5951-result-never-widening.ts": 6120, "5955-any-promise-methods.js": 5120, From 6f9acfd5f8bc0cf0337078b13b4034a43c254171 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 00:08:20 -0500 Subject: [PATCH 2/2] Read fields of a class instance asserted to a wider union A class instance that a type assertion widens to a union, such as (concrete as A | B).value, still lowers as that class's instance. The shared-property lowering now reads the field or calls the getter on the known class instead of refusing the receiver for not being a tagged union. Closes #49 Co-authored-by: Patrick Wozniak <4481570+patlux@users.noreply.github.com> --- .../src/frontend/lowering/lower-exprs.ts | 22 +++++++++ .../5958-asserted-union-receiver.ts.json | 6 +++ tests/corpus/5958-asserted-union-receiver.ts | 47 +++++++++++++++++++ tests/harness/corpus-costs.json | 2 + 4 files changed, 77 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5958-asserted-union-receiver.ts.json create mode 100644 tests/corpus/5958-asserted-union-receiver.ts diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 77676f8f2..1a9169afc 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -14001,6 +14001,28 @@ export function lowerUnionProperty( } return null; } + // A class instance the checker sees through a wider union (`(concrete + // as A | B).value`): the value is that class's instance, so read its + // field directly. + if (value.type.kind === "object") { + const className = value.type.className; + const info = lowerer.classes.get(className) ?? null; + const field = expr.name.text; + const fieldType = info?.fields.get(field); + if (fieldType !== undefined) + return lowerer.fieldGetExpr( + { container: "class", obj: value, className, field, fieldType }, + locOf(expr), + expr, + ); + const getter = lowerer.findMethodOn(info, `get:${field}`); + if (getter) + return lowerer.fieldGetExpr( + { container: "accessor", obj: value, className, field, fieldType: getter.sig.ret }, + locOf(expr), + expr, + ); + } if (value.type.kind !== "union") { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5958-asserted-union-receiver.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5958-asserted-union-receiver.ts.json new file mode 100644 index 000000000..d294caa65 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5958-asserted-union-receiver.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5958-asserted-union-receiver.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5958-asserted-union-receiver.ts b/tests/corpus/5958-asserted-union-receiver.ts new file mode 100644 index 000000000..23a591827 --- /dev/null +++ b/tests/corpus/5958-asserted-union-receiver.ts @@ -0,0 +1,47 @@ +// A class instance asserted to a wider union (`(concrete as A | B).value`) +// keeps its class: field and getter reads go to the known class. +class A { + value: number; + label = "a"; + constructor(value: number) { + this.value = value; + } + describe(): string { + return `A(${this.value})`; + } + get doubled(): number { + return this.value * 2; + } +} +class B { + value: number; + flag = true; + constructor(value: number) { + this.value = value; + } + describe(): string { + return `B(${this.value})`; + } + get doubled(): number { + return this.value * 3; + } +} +class C extends A { + override get doubled(): number { + return this.value * 10; + } +} +type Rec = { value: boolean }; + +const concrete = new A(41); +console.log((concrete as A | B).value + 1, (concrete as A | B).doubled); +console.log((new C(5) as A | B).value, (new C(5) as A | B).doubled, (concrete as A | B).describe()); +console.log((concrete as A | Rec).value, ((concrete as A | B) as A | B).value); +const maybe = (concrete as A | B | undefined)?.value; +console.log(maybe); +function show(x: A): string { + return String((x as A | B).value) + (x satisfies A as A | B).doubled; +} +console.log(show(concrete), show(new C(2))); +const widened = concrete as A | B; +console.log(widened.value, widened instanceof A); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 097ebf723..4df728d5c 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1854,6 +1854,7 @@ "5951-result-never-widening.ts": 4710, "5955-any-promise-methods.js": 3940, "5956-erased-generic-members.ts": 6730, + "5958-asserted-union-receiver.ts": 2430, "5970-net-connect-host-name.ts": 2400, "5971-buffer-chunks-to-string.ts": 2700, "5972-net-set-encoding-listeners.ts": 2550, @@ -4494,6 +4495,7 @@ "5951-result-never-widening.ts": 6120, "5955-any-promise-methods.js": 5120, "5956-erased-generic-members.ts": 8750, + "5958-asserted-union-receiver.ts": 3160, "5970-net-connect-host-name.ts": 2800, "5971-buffer-chunks-to-string.ts": 3100, "5972-net-set-encoding-listeners.ts": 3000,