diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 7c2a9596652..0d989595ec8 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -136,8 +136,8 @@ ultimate source of truth. - [x] Optional property access and optional calls. - [x] Function/tool calls and spread arguments. - [x] Sequence expressions (the comma operator). -- [x] `await` for CodeMode promises; a plain value passes through unchanged, though every `await` still defers its - continuation one reaction turn. +- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every + `await` still defers its continuation one reaction turn. - [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. - [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`. - [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`. @@ -152,7 +152,8 @@ ultimate source of truth. ## Promises and tools - [x] Tool calls start eagerly and return supervised, run-once CodeMode promises. -- [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program. +- [x] Direct `await`, repeated awaits, and recursive thenable assimilation when a promise or thenable is returned from + a function/program. - [x] `Promise.resolve` and `Promise.reject`. - [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over finite collections, custom synchronous iterators, and synchronous generators containing promises and plain values. @@ -178,11 +179,14 @@ ultimate source of truth. the catch-normalized reasons in input order, and empty input rejects with an empty `AggregateError`. - [x] `new Promise((resolve, reject) => ...)`: the executor runs synchronously and receives first-class resolve/reject callables that settle the promise exactly once (they may escape the executor and settle later); an executor - throw rejects unless the promise already settled, resolving with a promise adopts it, and resolving with the - promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are accepted, including - `.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot cross the data - boundary. -- [ ] Thenable assimilation; objects with a callable `then` field remain plain data. + throw rejects unless the promise already settled, resolving with a promise or callable thenable adopts it, and + resolving with the promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are + accepted, including `.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot + cross the data boundary. +- [x] Recursive assimilation of objects with an own callable `then` field across `Promise.resolve`, combinators, + constructors, reactions, `finally`, `await`, and async returns. Thenable methods run deferred, receive + first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields + and a JavaScript `this` receiver remain outside the supported object/function model. - [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the last definition supplied for a canonical path wins. - [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys. diff --git a/packages/codemode/src/interpreter/promises.ts b/packages/codemode/src/interpreter/promises.ts index 8f5fe2bdd42..26b6824e993 100644 --- a/packages/codemode/src/interpreter/promises.ts +++ b/packages/codemode/src/interpreter/promises.ts @@ -87,6 +87,51 @@ export class PromiseRuntime { export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError => new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError") +export const resolvePromiseValue = ( + runner: CallbackRunner, + value: unknown, + node: AstNode, + own?: { promise?: CodeModePromise }, +): Effect.Effect => { + if (own?.promise !== undefined && value === own.promise) return Effect.fail(selfResolutionError(node)) + if (value instanceof CodeModePromise) return runner.settlePromise(value) + if (value === null || typeof value !== "object" || !Object.hasOwn(value, "then")) return Effect.succeed(value) + const then = (value as SafeObject).then + if (typeofValue(then) !== "function") return Effect.succeed(value) + + return Effect.gen(function* () { + // Promise resolution invokes a thenable's method in a later job. + yield* Effect.yieldNow + const deferred = Deferred.makeUnsafe() + const resolve = new PromiseCapabilityFunction((result) => { + Deferred.doneUnsafe(deferred, Exit.succeed(result)) + }) + const reject = new PromiseCapabilityFunction((reason) => { + Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(reason))) + }) + const executed = yield* Effect.exit(runner.invokeCallable(then, [resolve, reject], node)) + if (!Exit.isSuccess(executed)) { + if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause) + Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause))) + } + return yield* resolvePromiseValue(runner, yield* Deferred.await(deferred), node, own) + }) +} + +export const resolvePromise = ( + runner: CallbackRunner, + promises: PromiseRuntime, + value: unknown, + node: AstNode, +): Effect.Effect => { + if (value instanceof CodeModePromise) return Effect.succeed(value) + const box: { promise?: CodeModePromise } = {} + return Effect.map(promises.create(resolvePromiseValue(runner, value, node, box)), (promise) => { + box.promise = promise + return promise + }) +} + export const invokePromiseMethod = ( runner: CallbackRunner & SyncIteratorRunner, promises: PromiseRuntime, @@ -95,8 +140,7 @@ export const invokePromiseMethod = ( node: AstNode, ): Effect.Effect => { if (ref.name === "resolve") { - const value = args[0] - return value instanceof CodeModePromise ? Effect.succeed(value) : promises.create(Effect.succeed(value)) + return resolvePromise(runner, promises, args[0], node) } if (ref.name === "reject") { return promises.create(Effect.fail(new ProgramThrow(args[0]))) @@ -111,20 +155,19 @@ export const invokePromiseMethod = ( node, ).as("TypeError") } - const items: Array = [] + const items: Array = [] while (true) { const step = yield* cursor.next if (step.done) break - items.push(step.value) - if (step.value instanceof CodeModePromise) promises.markObserved(step.value) + const item = yield* resolvePromise(runner, promises, step.value, node) + promises.markObserved(item) + items.push(item) } if (ref.name === "all") { return yield* settleAfterTurn( Effect.all( - items.map((item) => - item instanceof CodeModePromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item), - ), + items.map((item) => Effect.flatten(promises.await(item))), { concurrency: "unbounded" }, ), ) @@ -132,7 +175,7 @@ export const invokePromiseMethod = ( if (ref.name === "allSettled") { const outcomes: Array = [] for (const item of items) { - const exit = item instanceof CodeModePromise ? yield* promises.await(item) : Exit.succeed(item) + const exit = yield* promises.await(item) if (Exit.isSuccess(exit)) { outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value })) continue @@ -155,24 +198,14 @@ export const invokePromiseMethod = ( node, ) } - return yield* settleAfterTurn( - Effect.flatten( - Effect.raceAll( - items.map((item) => - item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)), - ), - ), - ), - ) + return yield* settleAfterTurn(Effect.flatten(Effect.raceAll(items.map((item) => promises.await(item))))) } const flipped = items.map((item) => - item instanceof CodeModePromise - ? Effect.flatMap(promises.await(item), (exit) => { - if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value)) - if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause) - return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause))) - }) - : Effect.fail(new PromiseAnyFulfilled(item)), + Effect.flatMap(promises.await(item), (exit) => { + if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value)) + if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause) + return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause))) + }), ) return yield* settleAfterTurn( Effect.all(flipped, { concurrency: "unbounded" }).pipe( @@ -219,15 +252,11 @@ export const constructPromise = ( } return Effect.gen(function* () { const deferred = Deferred.makeUnsafe() - const box: { own?: CodeModePromise } = {} + const box: { promise?: CodeModePromise } = {} const promise = yield* promises.create( - Effect.flatMap(Deferred.await(deferred), (value) => { - if (!(value instanceof CodeModePromise)) return Effect.succeed(value) - if (value === box.own) return Effect.fail(selfResolutionError(node)) - return runner.settlePromise(value) - }), + Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, box)), ) - box.own = promise + box.promise = promise const resolve = new PromiseCapabilityFunction((value) => { Deferred.doneUnsafe(deferred, Exit.succeed(value)) }) @@ -283,19 +312,17 @@ const chainReaction = ( method: string, node: AstNode, ): Effect.Effect => { - const box: { derived?: CodeModePromise } = {} + const box: { promise?: CodeModePromise } = {} const body = Effect.gen(function* () { const exit = yield* reactionExit(promises, source) const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected if (handler === undefined) return yield* exit const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause)) const result = yield* applyCollectionCallback(runner, handler, method, node)([input]) - if (result === box.derived) return yield* Effect.fail(selfResolutionError(node)) - if (result instanceof CodeModePromise) return yield* runner.settlePromise(result) - return result + return yield* resolvePromiseValue(runner, result, node, box) }) return Effect.map(promises.create(body), (derived) => { - box.derived = derived + box.promise = derived return derived }) } @@ -313,7 +340,13 @@ const chainFinally = ( const exit = yield* reactionExit(promises, source) if (cleanup !== undefined) { const result = yield* applyCollectionCallback(runner, cleanup, method, node)([]) - if (result instanceof CodeModePromise) yield* runner.settlePromise(result) + const intermediate = yield* promises.create( + Effect.gen(function* () { + yield* runner.settlePromise(yield* resolvePromise(runner, promises, result, node)) + return yield* exit + }), + ) + return yield* runner.settlePromise(intermediate) } return yield* exit }), diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 1d4e0306651..3af385d5130 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -57,7 +57,8 @@ import { invokePromiseInstanceMethod, invokePromiseMethod, PromiseRuntime, - selfResolutionError, + resolvePromise, + resolvePromiseValue, } from "./promises.js" import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js" import { ScopeStack } from "./scope.js" @@ -266,6 +267,8 @@ type GeneratorState = { available?: Deferred.Deferred } +const promiseResolutionNode: AstNode = { type: "PromiseResolution" } + export class Interpreter { private scopes: ScopeStack private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect @@ -356,7 +359,7 @@ export class Interpreter { } // The implicit async body adopts returned promises before copy-out. - if (value instanceof CodeModePromise) value = yield* self.settlePromise(value) + value = yield* resolvePromiseValue(self.runner, value, program) return value }).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) } @@ -778,8 +781,10 @@ export class Interpreter { ) } - private awaitValue(value: unknown): Effect.Effect { - return value instanceof CodeModePromise ? this.settlePromise(value) : Effect.as(Effect.yieldNow, value) + private awaitValue(value: unknown, node: AstNode = promiseResolutionNode): Effect.Effect { + return Effect.flatMap(resolvePromise(this.runner, this.promises, value, node), (promise) => + this.settlePromise(promise), + ) } private awaitAsyncFromSyncValue( @@ -1387,9 +1392,8 @@ export class Interpreter { return this.evaluateUpdateExpression(node) case "AwaitExpression": { // Await always suspends, including for plain values. - const self = this return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => - value instanceof CodeModePromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value), + this.awaitValue(value, node), ) } case "YieldExpression": @@ -2102,18 +2106,12 @@ export class Interpreter { }) if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async)) if (!fn.async) return run - // The initial yield assigns `box.own` before the body can self-resolve. - const box: { own?: CodeModePromise } = {} + // The initial yield assigns the promise before the body can self-resolve. + const box: { promise?: CodeModePromise } = {} return Effect.map( - this.createPromise( - Effect.flatMap(run, (value) => { - if (!(value instanceof CodeModePromise)) return Effect.succeed(value) - if (value === box.own) return Effect.fail(selfResolutionError()) - return invocation.settlePromise(value) - }), - ), + this.createPromise(Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runner, value, fn.body, box))), (promise) => { - box.own = promise + box.promise = promise return promise }, ) diff --git a/packages/codemode/test/promise-test262.test.ts b/packages/codemode/test/promise-test262.test.ts index 0dc57e7b75a..d2b60659b2d 100644 --- a/packages/codemode/test/promise-test262.test.ts +++ b/packages/codemode/test/promise-test262.test.ts @@ -946,7 +946,7 @@ describe("Test262 expected Promise conformance", () => { ).toBe("TypeError") }) - test.failing("Promise.resolve recursively assimilates callable thenables", async () => { + test("Promise.resolve recursively assimilates callable thenables", async () => { // Source: test/built-ins/Promise/resolve/resolve-thenable.js expect( await value(` @@ -958,7 +958,7 @@ describe("Test262 expected Promise conformance", () => { ).toBe(true) }) - test.failing("Promise combinators assimilate callable thenable inputs", async () => { + test("Promise combinators assimilate callable thenable inputs", async () => { // Sources: // test/built-ins/Promise/all/reject-immed.js // test/built-ins/Promise/all/reject-ignored-immed.js @@ -988,7 +988,7 @@ describe("Test262 expected Promise conformance", () => { ]) }) - test.failing("await assimilates callable thenables", async () => { + test("await assimilates callable thenables", async () => { // Source: test/language/expressions/await/await-awaits-thenables.js expect( await value(` @@ -998,7 +998,7 @@ describe("Test262 expected Promise conformance", () => { ).toBe(42) }) - test.failing("await rejects when a callable thenable throws", async () => { + test("await rejects when a callable thenable throws", async () => { // Source: test/language/expressions/await/await-awaits-thenables-that-throw.js expect( await value(` @@ -1013,6 +1013,94 @@ describe("Test262 expected Promise conformance", () => { `), ).toBe(true) }) + + test("thenable resolution is deferred and settles only once", async () => { + // Sources: + // test/built-ins/Promise/resolve/S25.Promise_resolve_foreign_thenable_2.js + // test/built-ins/Promise/exception-after-resolve-in-thenable-job.js + expect( + await value(` + const sequence = [] + const thenable = { + then: (resolve, reject) => { + sequence.push("then") + resolve(1) + reject(2) + throw 3 + } + } + const promise = Promise.resolve(thenable) + sequence.push("after resolve") + const result = await promise + sequence.push("after await") + return [result, sequence] + `), + ).toEqual([1, ["after resolve", "then", "after await"]]) + }) + + test("the first thenable rejection wins over later resolution and throws", async () => { + // Source: test/built-ins/Promise/exception-after-resolve-in-thenable-job.js + expect( + await value(` + const thenable = { + then: (resolve, reject) => { + reject("first") + resolve("second") + throw "third" + } + } + try { + await thenable + return "fulfilled" + } catch (reason) { + return reason + } + `), + ).toBe("first") + }) + + test("constructors, reactions, finally, and async returns assimilate thenables", async () => { + // Sources: + // test/built-ins/Promise/resolve-thenable-immed.js + // test/built-ins/Promise/prototype/then/resolve-settled-fulfilled-thenable.js + // test/built-ins/Promise/prototype/finally/resolved-observable-then-calls.js + const thenable = (value: string) => `({ then: (resolve) => resolve(${JSON.stringify(value)}) })` + expect( + await value(` + const fromAsync = async () => ${thenable("async")} + const cleanup = [] + return await Promise.all([ + new Promise((resolve) => resolve(${thenable("constructor")})), + Promise.resolve().then(() => ${thenable("reaction")}), + Promise.resolve("kept").finally(() => { + cleanup.push("ran") + return ${thenable("ignored")} + }), + fromAsync(), + ]).then((values) => [values, cleanup]) + `), + ).toEqual([["constructor", "reaction", "kept", "async"], ["ran"]]) + }) + + test("finally settles after its cleanup thenable reactions", async () => { + // Source: test/built-ins/Promise/prototype/finally/resolved-observable-then-calls-PromiseResolve.js + expect( + await value(` + const sequence = [] + const cleanup = { then: (resolve) => { sequence.push("then"); resolve() } } + const result = Promise.resolve("kept").finally(() => cleanup) + result.then(() => sequence.push("finally")) + Promise.resolve() + .then(() => sequence.push("tick1")) + .then(() => sequence.push("tick2")) + .then(() => sequence.push("tick3")) + .then(() => sequence.push("tick4")) + await result + sequence.push("await") + return sequence + `), + ).toEqual(["tick1", "then", "tick2", "tick3", "tick4", "finally", "await"]) + }) }) describe("Test262 Promise.any", () => {