diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 5fc2a3cec3..6c69a80cb1 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -5245,7 +5245,15 @@ 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. An async generator method answers its + // generator object, which its signature already returns. + result = + 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}`; 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..10927e49b2 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -6723,14 +6723,22 @@ 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. An + // async generator method's slot keeps its generator object. + const ret: IrType = + overridden.sig.gen || 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/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/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/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/6419-untyped-async-package/main.ts b/tests/corpus/6419-untyped-async-package/main.ts new file mode 100644 index 0000000000..9ec10f7c50 --- /dev/null +++ b/tests/corpus/6419-untyped-async-package/main.ts @@ -0,0 +1,38 @@ +// Untyped async methods of a package, reached through the package's own +// untyped callers and prototype reflection, and overridden by a typed +// 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; + 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 listIssues(schema, ["p", 5, "q"])); + 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..ed0c82579f --- /dev/null +++ b/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.d.ts @@ -0,0 +1,17 @@ +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; + 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 new file mode 100644 index 0000000000..04935ff29e --- /dev/null +++ b/tests/corpus/6419-untyped-async-package/node_modules/token-pool/index.js @@ -0,0 +1,123 @@ +"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 *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; + 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; +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; +} +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" } 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" }