From eef68a7a97449c466714d3c3262bf7568baf9f98 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 09:41:28 -0700 Subject: [PATCH 1/3] Answer promises from thunked and dispatched untyped async methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An untyped JavaScript async method whose result the compiler keeps as `dyn` (its storage passes through untyped helpers) has a `dyn` return on its signature: the promise's payload, not the promise. Two call paths took that as the call's own type and failed the IR check ("call … type dyn != return promise"): - thunkAsyncSlot's `$own` adapter, made when a TypeScript override of such a method keeps the inherited slot (an async-sema `acquire` override). The slot's ABI is now the body's promise. - `%dyn.class.call` dispatch from untyped callers (zod's `parseAsync` and `safeParseAsync` through checked-dynamic receivers), which now answers the promise. Prototype data slots also skip method values that have no checked- dynamic form (an async method's promise-returning signature) instead of building an invalid slot. From the int/bc round-5 build of the build-container entry. Co-Authored-By: Claude Opus 5.5 --- .../lowering/class-dynamic-dispatch.ts | 7 +- .../src/frontend/lowering/class-prototypes.ts | 6 +- .../src/frontend/lowering/lower-classes.ts | 9 +- .../6419-untyped-async-package/main.ts.json | 6 + .../corpus/6419-untyped-async-package/main.ts | 36 ++++++ .../node_modules/token-pool/index.d.ts | 15 +++ .../node_modules/token-pool/index.js | 110 ++++++++++++++++++ .../node_modules/token-pool/package.json | 1 + 8 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6419-untyped-async-package/main.ts.json create mode 100644 tests/corpus/6419-untyped-async-package/main.ts create mode 100644 tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.d.ts create mode 100644 tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.js create mode 100644 tests/corpus/6419-untyped-async-package/node_modules/token-pool/package.json diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 5fc2a3cec3..d845bfc541 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -5245,7 +5245,12 @@ export class ClassDynamicDispatch { if (methodInfo) { owner = methodInfo.declarer; params = methodInfo.sig.params; - result = methodInfo.sig.ret; + // An untyped async method's `dyn` result is its promise's payload; the + // call answers the promise. + result = + methodInfo.sig.async === true && methodInfo.sig.ret.kind !== "promise" + ? { kind: "promise", inner: methodInfo.sig.ret } + : methodInfo.sig.ret; callee = `%${owner.def.name}.${method}`; virtual = lowerer.overrideBelow(owner, method) || methodInfo.sig.abstract === true; } else if ( diff --git a/packages/compiler/src/frontend/lowering/class-prototypes.ts b/packages/compiler/src/frontend/lowering/class-prototypes.ts index 6c6c7811dd..7b4b58ae36 100644 --- a/packages/compiler/src/frontend/lowering/class-prototypes.ts +++ b/packages/compiler/src/frontend/lowering/class-prototypes.ts @@ -189,12 +189,16 @@ export function classPrototypeData( continue; const compiled = classMethodValue(lowerer, access, info, method, locOf(access)); if (!compiled) continue; + // A method value without a checked-dynamic form (an async method's + // promise-returning signature) gets no data slot. + const boxedValue = lowerer.coerceToExpected(compiled, DYN); + if (boxedValue.type.kind !== "dyn") continue; const descriptor: IrExpr = { kind: "dynObjLit", fields: [ { key: { kind: "strLit", value: "value", type: STRING, loc }, - value: lowerer.coerceToExpected(compiled, DYN), + value: boxedValue, }, { key: { kind: "strLit", value: "writable", type: STRING, loc }, diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 182a73f14e..a6dd6e3ddd 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -6723,14 +6723,21 @@ function thunkAsyncSlot( ): void { if (overridden.sig.async !== true) return; const declarer = overridden.declarer; + // The slot answers the body's promise: an untyped async method's `dyn` + // result is the promise's payload, so the slot's ABI is that promise. + const ret: IrType = + overridden.sig.ret.kind === "promise" + ? overridden.sig.ret + : { kind: "promise", inner: overridden.sig.ret }; declarer.returnAdapters ??= new Map(); if (!declarer.returnAdapters.has(mName)) { declarer.returnAdapters.set(mName, { params: [...overridden.sig.params], - ret: overridden.sig.ret, + ret, async: true, }); } + overridden.sig.ret = ret; delete overridden.sig.async; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6419-untyped-async-package/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6419-untyped-async-package/main.ts.json new file mode 100644 index 0000000000..ab4ee5819a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6419-untyped-async-package/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6419-untyped-async-package/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6419-untyped-async-package/main.ts b/tests/corpus/6419-untyped-async-package/main.ts new file mode 100644 index 0000000000..e118adba43 --- /dev/null +++ b/tests/corpus/6419-untyped-async-package/main.ts @@ -0,0 +1,36 @@ +// Untyped async methods of a package, reached through the package's own +// untyped callers and prototype reflection, and overridden by a typed +// subclass. +import { Pool, Schema, limiter, prototypeNames, validateAll } from "token-pool"; + +class CountingPool extends Pool { + acquired = 0; + async acquire(): Promise { + await super.acquire(); + this.acquired++; + } +} + +async function main(): Promise { + const pool = new CountingPool(1); + await pool.acquire(); + const waiting = pool.acquire(); + console.log("waiting", pool.nrWaiting()); + pool.release("t"); + await waiting; + console.log("acquired", pool.acquired, pool.nrWaiting()); + const base = new Pool(1); + console.log("token", await base.acquire()); + const run = limiter(1); + console.log(await Promise.all([run(async () => 1), run(async () => 2)])); + const schema = new Schema((value) => typeof value === "number"); + console.log(await validateAll(schema, [1, "x", 3])); + console.log(await schema.parseAsync(4)); + try { + await schema.parseAsync("y"); + } catch (e) { + console.log("rejected", e instanceof Error ? e.message : String(e)); + } + console.log(prototypeNames()); +} +main(); diff --git a/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.d.ts b/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.d.ts new file mode 100644 index 0000000000..a7a8459ac4 --- /dev/null +++ b/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.d.ts @@ -0,0 +1,15 @@ +export declare class Pool { + constructor(size: number); + tryAcquire(): any; + acquire(): Promise; + release(token?: any): void; + nrWaiting(): number; +} +export declare function limiter(size: number): (work: () => Promise) => Promise; +export declare class Schema { + constructor(check: (value: unknown) => boolean); + safeParseAsync(data: unknown): Promise; + parseAsync(data: unknown): Promise; +} +export declare function validateAll(schema: Schema, values: unknown[]): Promise; +export declare function prototypeNames(): string; diff --git a/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.js b/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.js new file mode 100644 index 0000000000..9140e50af3 --- /dev/null +++ b/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.js @@ -0,0 +1,110 @@ +"use strict"; +// An untyped token pool and schema in the shape of a semaphore and a +// validation library: async methods whose results come from storage that +// passes through untyped helpers. +Object.defineProperty(exports, "__esModule", { value: true }); +function moveItems(src, srcIndex, dst, dstIndex, len) { + for (let j = 0; j < len; ++j) { + dst[j + dstIndex] = src[j + srcIndex]; + src[j + srcIndex] = void 0; + } +} +class Queue { + constructor() { + this._length = 0; + this.arr = []; + } + push(item) { + if (this._length > 64) moveItems(this.arr, 1, this.arr, 0, this._length - 1); + this.arr[this._length] = item; + this._length = this._length + 1; + } + pop() { + const length = this._length; + if (length === 0) return void 0; + const ret = this.arr[length - 1]; + this.arr[length - 1] = void 0; + this._length = length - 1; + return ret; + } + shift() { + const length = this._length; + if (length === 0) return void 0; + const ret = this.arr[0]; + for (let i = 1; i < length; i++) this.arr[i - 1] = this.arr[i]; + this.arr[length - 1] = void 0; + this._length = length - 1; + return ret; + } + get length() { + return this._length; + } +} +class Pool { + constructor(size) { + this.free = new Queue(); + this.waiting = new Queue(); + for (let i = 0; i < size; i++) this.free.push("token" + i); + } + tryAcquire() { + return this.free.pop(); + } + async acquire() { + const token = this.tryAcquire(); + if (token !== void 0) return token; + return new Promise((resolve) => { + this.waiting.push(resolve); + }); + } + release(token) { + const next = this.waiting.shift(); + if (next) next(token); + else this.free.push(token); + } + nrWaiting() { + return this.waiting.length; + } +} +exports.Pool = Pool; +function limiter(size) { + const pool = new Pool(size); + return async function limited(work) { + const token = await pool.acquire(); + try { + return await work(); + } finally { + pool.release(token); + } + }; +} +exports.limiter = limiter; +class Schema { + constructor(check) { + this.check = check; + this.results = new Queue(); + } + async safeParseAsync(data) { + await null; + this.results.push(this.check(data) ? { success: true, data } : { success: false, issue: "invalid " + data }); + return this.results.pop(); + } + async parseAsync(data) { + const result = await this.safeParseAsync(data); + if (result.success) return result.data; + throw new Error(result.issue); + } +} +exports.Schema = Schema; +async function validateAll(schema, values) { + const out = []; + for (const value of values) { + const result = await schema.safeParseAsync(value); + out.push(result.success ? "ok " + result.data : result.issue); + } + return out.join(", "); +} +exports.validateAll = validateAll; +function prototypeNames() { + return typeof Schema.prototype.parseAsync + " " + typeof Schema.prototype.safeParseAsync; +} +exports.prototypeNames = prototypeNames; diff --git a/tests/corpus/6419-untyped-async-package/node_modules/token-pool/package.json b/tests/corpus/6419-untyped-async-package/node_modules/token-pool/package.json new file mode 100644 index 0000000000..994de07b2a --- /dev/null +++ b/tests/corpus/6419-untyped-async-package/node_modules/token-pool/package.json @@ -0,0 +1 @@ +{ "name": "token-pool", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } From 60f2823faf89804225095a06d54dd5a45d713ce4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 13:22:05 -0700 Subject: [PATCH 2/3] Box methods answering a result or a promise of it as dynamic values A method whose result is either a status record or a promise of one (a parse step that hands off to a static async merge helper in async mode) has a union result such as `Promise | A | B`. The dynamic conversion accepted promise arms in a union only when every other arm was JSON-safe, so records holding dynamic, Map or Set members were rejected even though the composite converter boxes them. Such methods had no checked-dynamic form: no prototype data slot, and reads through untyped code answered undefined ("this._parse is not a function" in a validation library's object and record schemas). The union conversion now also accepts composite-boxable arms; the union converter already boxes each arm through its own converter. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/ir/ir.ts | 4 + .../main.ts.json | 6 + .../6592-promise-armed-method-values/main.ts | 27 ++++ .../node_modules/status-schema/index.d.ts | 18 +++ .../node_modules/status-schema/index.js | 118 ++++++++++++++++++ .../node_modules/status-schema/package.json | 1 + 6 files changed, 174 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6592-promise-armed-method-values/main.ts.json create mode 100644 tests/corpus/6592-promise-armed-method-values/main.ts create mode 100644 tests/corpus/6592-promise-armed-method-values/node_modules/status-schema/index.d.ts create mode 100644 tests/corpus/6592-promise-armed-method-values/node_modules/status-schema/index.js create mode 100644 tests/corpus/6592-promise-armed-method-values/node_modules/status-schema/package.json diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 4c5bbd183f..f71b8de2c2 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -4214,6 +4214,10 @@ export function canConvertToDyn( (a) => a.kind === "undefinedT" || isJsonSafeType(a, getRecord, getUnion) || + // Composite arms (records holding dyn, Map or Set members) box + // through the walker beside the promise arm: a method answering + // either its result or a promise of it. + canBoxDynComposite(a, getRecord, getUnion, visiting) || isDynTypedRefType(a) || a.kind === "classval" || DYN_HANDLE_KINDS.has(a.kind) || diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6592-promise-armed-method-values/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6592-promise-armed-method-values/main.ts.json new file mode 100644 index 0000000000..1fb3f21bc8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6592-promise-armed-method-values/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6592-promise-armed-method-values/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6592-promise-armed-method-values/main.ts b/tests/corpus/6592-promise-armed-method-values/main.ts new file mode 100644 index 0000000000..379c56ef92 --- /dev/null +++ b/tests/corpus/6592-promise-armed-method-values/main.ts @@ -0,0 +1,27 @@ +// Parse steps that answer either a status record or a promise of one keep +// their method values: reads through untyped code, detached calls, and the +// per-instance rebinding of the base constructor all see the function. +import { map, number, object, record, set, stepDetached, stepKinds } from "status-schema"; +async function main(): Promise { + const shaped = object({ count: number(), size: number() }); + const counts = record(number()); + const byName = map(number()); + const seen = set(number()); + console.log(stepKinds(shaped), "|", stepKinds(counts), "|", stepKinds(byName), "|", stepKinds(seen)); + console.log(shaped.safeParse({ count: 1, size: 2 }).success, shaped.safeParse({ count: "x", size: 2 }).success); + console.log(JSON.stringify(shaped.parse({ count: 3, size: 4 }))); + console.log(JSON.stringify(await shaped.parseAsync({ count: 5, size: 6 })), (await shaped.spa(7)).success); + console.log(counts.safeParse({ a: 1, b: 2 }).success, (await counts.safeParseAsync({ a: "x" })).success); + console.log(byName.safeParse(new Map([["a", 1]])).success, (await byName.safeParseAsync(new Map([["b", "x"]]))).success); + console.log(seen.safeParse(new Set([1, 2])).success, (await seen.spa(new Set([3]))).success); + console.log(await stepDetached(shaped, { count: 1, size: 1 }), "|", await stepDetached(counts, { a: "x" })); + console.log(await stepDetached(byName, new Map([["c", 2]])), "|", await stepDetached(seen, [1])); + try { + await counts.parseAsync(null); + } catch (e) { + console.log("rejected", e instanceof Error ? e.message : String(e)); + } + const nested = object({ inner: record(number()), tags: set(number()) }); + console.log(JSON.stringify(await nested.parseAsync({ inner: { q: 1 }, tags: new Set([1]) }))); +} +main(); diff --git a/tests/corpus/6592-promise-armed-method-values/node_modules/status-schema/index.d.ts b/tests/corpus/6592-promise-armed-method-values/node_modules/status-schema/index.d.ts new file mode 100644 index 0000000000..6607bf0c7e --- /dev/null +++ b/tests/corpus/6592-promise-armed-method-values/node_modules/status-schema/index.d.ts @@ -0,0 +1,18 @@ +export interface SafeResult { + success: boolean; + data?: unknown; +} +export declare class Schema { + safeParse(data: unknown): SafeResult; + parse(data: unknown): unknown; + safeParseAsync(data: unknown): Promise; + spa(data: unknown): Promise; + parseAsync(data: unknown): Promise; +} +export declare function number(): Schema; +export declare function object(shape: { [key: string]: Schema }): Schema; +export declare function record(valueType: Schema): Schema; +export declare function map(valueType: Schema): Schema; +export declare function set(valueType: Schema): Schema; +export declare function stepKinds(schema: Schema): string; +export declare function stepDetached(schema: Schema, data: unknown): Promise; diff --git a/tests/corpus/6592-promise-armed-method-values/node_modules/status-schema/index.js b/tests/corpus/6592-promise-armed-method-values/node_modules/status-schema/index.js new file mode 100644 index 0000000000..4fb5ae4600 --- /dev/null +++ b/tests/corpus/6592-promise-armed-method-values/node_modules/status-schema/index.js @@ -0,0 +1,118 @@ +// A schema hierarchy in the shape of a validation library's: each parse +// step answers a status record or, in async mode, a promise of one built +// by a static async merge helper. The base constructor rebinds its +// methods per instance, async ones included. +export const INVALID = Object.freeze({ status: "aborted" }); +export class Status { + static async mergeAsync(pairs) { + const settled = []; + for (const pair of pairs) { + settled.push({ key: pair.key, value: await pair.value }); + } + return Status.mergeSync(settled); + } + static mergeSync(pairs) { + const value = {}; + for (const pair of pairs) { + if (pair.value.status === "aborted") return INVALID; + value[pair.key] = pair.value.value; + } + return { status: "valid", value }; + } +} +export class Schema { + constructor(def) { + this.spa = this.safeParseAsync; + this._def = def; + this.parse = this.parse.bind(this); + this.safeParse = this.safeParse.bind(this); + this.parseAsync = this.parseAsync.bind(this); + this.safeParseAsync = this.safeParseAsync.bind(this); + this.spa = this.spa.bind(this); + } + safeParse(data) { + const result = this._parse({ data, async: false }); + if (result instanceof Promise) throw new Error("async parse step in safeParse"); + return result.status === "valid" ? { success: true, data: result.value } : { success: false }; + } + parse(data) { + const result = this.safeParse(data); + if (result.success) return result.data; + throw new Error(this._def.name + ": invalid input"); + } + async safeParseAsync(data) { + const result = await this._parse({ data, async: true }); + return result.status === "valid" ? { success: true, data: result.value } : { success: false }; + } + async parseAsync(data) { + const result = await this.safeParseAsync(data); + if (result.success) return result.data; + throw new Error(this._def.name + ": invalid input"); + } +} +export class NumberSchema extends Schema { + _parse(input) { + return typeof input.data === "number" ? { status: "valid", value: input.data } : INVALID; + } +} +export class ObjectSchema extends Schema { + _parse(input) { + if (input.data === null || typeof input.data !== "object") return INVALID; + const pairs = []; + for (const key of Object.keys(this._def.shape)) { + pairs.push({ key, value: this._def.shape[key]._parse({ data: input.data[key], async: input.async }) }); + } + return input.async ? Status.mergeAsync(pairs) : Status.mergeSync(pairs); + } +} +export class RecordSchema extends Schema { + _parse(input) { + if (input.data === null || typeof input.data !== "object") return INVALID; + const pairs = []; + for (const key in input.data) { + pairs.push({ key, value: this._def.valueType._parse({ data: input.data[key], async: input.async }) }); + } + return input.async ? Status.mergeAsync(pairs) : Status.mergeSync(pairs); + } +} +export class MapSchema extends Schema { + _parse(input) { + if (!(input.data instanceof Map)) return INVALID; + const finish = () => { + const out = new Map(); + for (const [k, v] of input.data) { + if (this._def.valueType._parse({ data: v, async: false }).status !== "valid") return INVALID; + out.set(k, v); + } + return { status: "valid", value: out }; + }; + return input.async ? Promise.resolve().then(finish) : finish(); + } +} +export class SetSchema extends Schema { + _parse(input) { + if (!(input.data instanceof Set)) return INVALID; + const finish = () => { + const out = new Set(); + for (const v of input.data) { + if (this._def.valueType._parse({ data: v, async: false }).status !== "valid") return INVALID; + out.add(v); + } + return { status: "valid", value: out }; + }; + return input.async ? Promise.resolve().then(finish) : finish(); + } +} +export const number = () => new NumberSchema({ name: "number" }); +export const object = (shape) => new ObjectSchema({ name: "object", shape }); +export const record = (valueType) => new RecordSchema({ name: "record", valueType }); +export const map = (valueType) => new MapSchema({ name: "map", valueType }); +export const set = (valueType) => new SetSchema({ name: "set", valueType }); +// Untyped readers of the parse step's method value. +export const stepKinds = (schema) => typeof schema._parse + " " + typeof Object.getPrototypeOf(schema)._parse; +export const stepDetached = async (schema, data) => { + const step = schema._parse; + const sync = step.call(schema, { data, async: false }); + const later = await step.call(schema, { data, async: true }); + return sync.status + " " + later.status; +}; diff --git a/tests/corpus/6592-promise-armed-method-values/node_modules/status-schema/package.json b/tests/corpus/6592-promise-armed-method-values/node_modules/status-schema/package.json new file mode 100644 index 0000000000..1160994b13 --- /dev/null +++ b/tests/corpus/6592-promise-armed-method-values/node_modules/status-schema/package.json @@ -0,0 +1 @@ +{ "name": "status-schema", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" } From c9c19d8fdc76ddbd4855ce2cd6e58d545331a3b9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 15:39:16 -0500 Subject: [PATCH 3/3] Keep the generator result of untyped async generator methods - `%dyn.class.call` dispatch answers a promise for an untyped async method whose signature keeps the promise's payload. An async generator method also has the async flag, but its signature already returns the generator object, so the call now keeps that type. Before, a call from untyped code (`schema.issues(values)` over an `async *issues` method) failed IR validation with "type promise != return generator". - thunkAsyncSlot keeps an async generator method's slot type the same way. - Corpus 6419 adds an async generator method that the package's own untyped helper iterates with `next()`. --- .../src/frontend/lowering/class-dynamic-dispatch.ts | 7 +++++-- .../compiler/src/frontend/lowering/lower-classes.ts | 5 +++-- tests/corpus/6419-untyped-async-package/main.ts | 6 ++++-- .../node_modules/token-pool/index.d.ts | 2 ++ .../node_modules/token-pool/index.js | 13 +++++++++++++ 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index d845bfc541..6c69a80cb1 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -5246,9 +5246,12 @@ export class ClassDynamicDispatch { owner = methodInfo.declarer; params = methodInfo.sig.params; // An untyped async method's `dyn` result is its promise's payload; the - // call answers the promise. + // call answers the promise. An async generator method answers its + // generator object, which its signature already returns. result = - methodInfo.sig.async === true && methodInfo.sig.ret.kind !== "promise" + methodInfo.sig.async === true && + !methodInfo.sig.gen && + methodInfo.sig.ret.kind !== "promise" ? { kind: "promise", inner: methodInfo.sig.ret } : methodInfo.sig.ret; callee = `%${owner.def.name}.${method}`; diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index a6dd6e3ddd..10927e49b2 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -6724,9 +6724,10 @@ function thunkAsyncSlot( if (overridden.sig.async !== true) return; const declarer = overridden.declarer; // The slot answers the body's promise: an untyped async method's `dyn` - // result is the promise's payload, so the slot's ABI is that promise. + // result is the promise's payload, so the slot's ABI is that promise. An + // async generator method's slot keeps its generator object. const ret: IrType = - overridden.sig.ret.kind === "promise" + overridden.sig.gen || overridden.sig.ret.kind === "promise" ? overridden.sig.ret : { kind: "promise", inner: overridden.sig.ret }; declarer.returnAdapters ??= new Map(); diff --git a/tests/corpus/6419-untyped-async-package/main.ts b/tests/corpus/6419-untyped-async-package/main.ts index e118adba43..9ec10f7c50 100644 --- a/tests/corpus/6419-untyped-async-package/main.ts +++ b/tests/corpus/6419-untyped-async-package/main.ts @@ -1,7 +1,8 @@ // Untyped async methods of a package, reached through the package's own // untyped callers and prototype reflection, and overridden by a typed -// subclass. -import { Pool, Schema, limiter, prototypeNames, validateAll } from "token-pool"; +// subclass. An async generator method called from the package answers its +// generator object. +import { Pool, Schema, limiter, listIssues, prototypeNames, validateAll } from "token-pool"; class CountingPool extends Pool { acquired = 0; @@ -25,6 +26,7 @@ async function main(): Promise { console.log(await Promise.all([run(async () => 1), run(async () => 2)])); const schema = new Schema((value) => typeof value === "number"); console.log(await validateAll(schema, [1, "x", 3])); + console.log(await listIssues(schema, ["p", 5, "q"])); console.log(await schema.parseAsync(4)); try { await schema.parseAsync("y"); diff --git a/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.d.ts b/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.d.ts index a7a8459ac4..ed0c82579f 100644 --- a/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.d.ts +++ b/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.d.ts @@ -10,6 +10,8 @@ export declare class Schema { constructor(check: (value: unknown) => boolean); safeParseAsync(data: unknown): Promise; parseAsync(data: unknown): Promise; + issues(values: unknown[]): AsyncGenerator; } export declare function validateAll(schema: Schema, values: unknown[]): Promise; +export declare function listIssues(schema: Schema, values: unknown[]): Promise; export declare function prototypeNames(): string; diff --git a/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.js b/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.js index 9140e50af3..04935ff29e 100644 --- a/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.js +++ b/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.js @@ -88,6 +88,12 @@ class Schema { this.results.push(this.check(data) ? { success: true, data } : { success: false, issue: "invalid " + data }); return this.results.pop(); } + async *issues(values) { + for (const value of values) { + const result = await this.safeParseAsync(value); + if (!result.success) yield result.issue; + } + } async parseAsync(data) { const result = await this.safeParseAsync(data); if (result.success) return result.data; @@ -104,6 +110,13 @@ async function validateAll(schema, values) { return out.join(", "); } exports.validateAll = validateAll; +async function listIssues(schema, values) { + const out = []; + const it = schema.issues(values); + for (let step = await it.next(); !step.done; step = await it.next()) out.push(step.value); + return out.join(", "); +} +exports.listIssues = listIssues; function prototypeNames() { return typeof Schema.prototype.parseAsync + " " + typeof Schema.prototype.safeParseAsync; }