Skip to content

runtime: make Promise combinators consume iterables and reject non-iterables #2822

Description

@andrewtdiz

Summary

Promise.all, Promise.race, Promise.allSettled, and Promise.any should accept any iterable and reject with a TypeError when the argument is not iterable. Perry currently lowers these combinators as array-only calls by unboxing the first argument as an ArrayHeader*; missing arguments return a numeric stub instead of a rejected promise.

Expected Node behavior

Local Node v25.9.0 probe:

await Promise.all(new Set([Promise.resolve(1), 2]));
// [1, 2]

await Promise.allSettled(new Set([Promise.resolve(1), Promise.reject("x")]));
// [{ status: "fulfilled", value: 1 }, { status: "rejected", reason: "x" }]

await Promise.race(new Set([Promise.resolve("a")]));
// "a"

await Promise.any(new Set([Promise.reject("x"), Promise.resolve("b")]));
// "b"

await Promise.all(1);
// rejects TypeError: number 1 is not iterable

await Promise.any([]).catch(e => [e.name, e.errors.length]);
// ["AggregateError", 0]

The same iterable/non-iterable contract applies when the argument is omitted, because undefined is not iterable.

Current Perry implementation

Source evidence from current tree:

  • crates/perry-codegen/src/lower_call/console_promise.rs handles Promise.all / race / allSettled / any in one branch by lowering only args[0], unboxing it with unbox_to_i64, and calling js_promise_all, js_promise_race, js_promise_all_settled, or js_promise_any with that one pointer.
  • The same lowering branch returns double_literal(0.0) when args.is_empty(), so Promise.all() / Promise.race() / Promise.allSettled() / Promise.any() do not return the rejected Promise Node exposes for undefined input.
  • crates/perry-runtime/src/promise/combinators.rs::js_promise_all() documents and accepts promises_arr: *const ArrayHeader; js_promise_race, js_promise_all_settled, and js_promise_any have the same array-pointer shape.
  • These helpers treat a null array pointer as an empty input path: all / allSettled resolve with [], race returns pending, and any builds the empty rejection path. Node should reject non-iterable inputs with TypeError, not silently reinterpret them as empty arrays.

Suggested test surface

Add parity tests for iterable inputs and invalid inputs:

assert.deepEqual(await Promise.all(new Set([Promise.resolve(1), 2])), [1, 2]);

const settled = await Promise.allSettled(new Set([Promise.resolve(1), Promise.reject("x")]));
assert.deepEqual(settled.map(r => r.status), ["fulfilled", "rejected"]);

assert.equal(await Promise.race(new Set([Promise.resolve("a")])), "a");
assert.equal(await Promise.any(new Set([Promise.reject("x"), Promise.resolve("b")])), "b");

await assert.rejects(() => Promise.all(1 as any), TypeError);
await assert.rejects(() => Promise.all(undefined as any), TypeError);
await assert.rejects(() => Promise.allSettled(1 as any), TypeError);
await assert.rejects(() => Promise.race(1 as any), TypeError);
await assert.rejects(() => Promise.any(1 as any), TypeError);

Keep the existing Promise.any([]) AggregateError behavior covered as a control.

Scope / non-goals

This is not the closed missing-feature work from #266 / #268; the combinator helpers now exist. This issue tracks the remaining input contract: iterable consumption, omitted/non-iterable rejection, and avoiding array-pointer reinterpretation for non-array receivers.

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions