Skip to content

Coverage candidates discovered while implementing Generator semantics in an ECMAScript companion language #5131

Description

@Lcfvs

Hi,

While implementing typed Generator and AsyncGenerator transformations in ETJS, an ECMAScript companion language, successive ETJS prototypes effectively acted as small independent implementations of these semantics.

This exposed a few observable distinctions that ordinary input/output tests did not detect. Some prototypes produced the correct final values and done flags while still differing in request consumption, abrupt-completion handling, Promise assimilation, or job ordering.

ECMA-262 is the authority for this work. Engine behavior was used only as a cross-check after deriving the expected behavior from the specification.

I audited the relevant Test262 areas at commit 419d3e0. The audit showed that many of the paths I initially suspected already have strong coverage, especially around:

  • .return() and .throw() through catch and finally;
  • yield inside finally;
  • delegated method lookup, call order, receiver and arguments;
  • missing yield* methods and iterator closing;
  • Async-from-Sync adaptation;
  • ordinary thenable assimilation;
  • several Promise-job ordering cases;
  • prototype and incompatible-receiver behavior.

For example:

Rather than duplicate that coverage, I reduced the remaining candidates to the combinations below.

1. A queued AsyncGenerator next(value) whose value is never consumed

An AsyncGenerator may terminate while processing an earlier request. Any later normal requests are then drained without resuming the generator body, and their completion values are replaced with undefined.

This distinction matters:

a request contains a value
does not imply
the generator body receives that value

Minimal example:

let release;
const gate = new Promise(resolve => {
  release = resolve;
});

const received = [];

async function* source() {
  const value = yield "ready";
  received.push(value);
  await gate;
}

const iterator = source();

await iterator.next();

const consumed = iterator.next("consumed");
const drained = iterator.next("not consumed");

release();

console.log(await consumed);
// { value: undefined, done: true }

console.log(await drained);
// { value: undefined, done: true }

console.log(received);
// ["consumed"]

The value "not consumed" exists in the request queue but never becomes a generator resumption value.

This follows from AsyncGeneratorDrainQueue: when draining a normal completion, its value is replaced with undefined.

The existing request-queue ordering test verifies FIFO resolution, but I did not find, in the audited Test262 revision, a direct test using distinguishable request values and observing which values actually reach the body.

2. A resumed value that directly produces done: true

The converse shortcut is also unsafe:

result.done === true
does not imply
no value was delivered to the generator body

For example:

async function* source() {
  return yield "ready";
}

const iterator = source();

console.log(await iterator.next());
// { value: "ready", done: false }

console.log(await iterator.next(42));
// { value: 42, done: true }

The second request delivers 42 to the suspended yield, and that same resumption immediately causes terminal completion.

Test262 already contains the corresponding synchronous case in GeneratorPrototype/next/return-yield-expr.js. I did not find a direct AsyncGenerator counterpart in the audited revision.

3. The value passed to the first next(value) is not a yield-resumption value

The first normal resumption starts the generator body. Its value is not the result of a suspended yield.

A direct synchronous case would be:

function* source() {
  return yield "ready";
}

const iterator = source();

console.log(iterator.next("ignored"));
// { value: "ready", done: false }

console.log(iterator.next("received"));
// { value: "received", done: true }

The same distinction applies to AsyncGenerator:

async function* source() {
  return yield "ready";
}

const iterator = source();

console.log(await iterator.next("ignored"));
// { value: "ready", done: false }

console.log(await iterator.next("received"));
// { value: "received", done: true }

This is elementary once stated explicitly, but it was a useful discriminator for transformations that validated or instrumented every next(value) at request admission time.

I did not find, in the audited Test262 revision, direct tests isolating the ignored initial value for both generator kinds.

4. AsyncGenerator operations use intrinsic %Promise%, not the mutable global binding

AsyncGenerator operations create and assimilate Promises through intrinsic specification operations. They should not consult the current globalThis.Promise binding or the public Promise.resolve method.

One direct case is the constructor of the request Promise:

const NativePromise = Promise;

try {
  globalThis.Promise = class PoisonPromise extends NativePromise {};

  const request = (async function* () {
    yield 1;
  })().next();

  console.log(Object.getPrototypeOf(request) === NativePromise.prototype);
  // true
} finally {
  globalThis.Promise = NativePromise;
}

A related case exercises internal PromiseResolve without consulting the public method:

const originalResolve = Promise.resolve;

try {
  Promise.resolve = function() {
    throw new Error("public Promise.resolve was consulted");
  };

  const thenable = {
    then(resolve) {
      resolve("ok");
    }
  };

  const request = (async function* () {})().return(thenable);

  console.log(await request);
  // { value: "ok", done: true }
} finally {
  Promise.resolve = originalResolve;
}

These cases are based on the use of %Promise% and abstract PromiseResolve in operations such as AsyncGenerator.prototype.next and AsyncGeneratorAwaitReturn.

In the audited Test262 revision, I found tests asserting that these operations return Promises, but not direct coverage under replacement of the global constructor binding or poisoning of its public resolve property.

5. Same-realm Promise versus cross-realm Promise or Promise subclass

Ordinary thenable assimilation is already well covered. A narrower remaining distinction is that a fulfilled Promise from the current realm can follow a shorter PromiseResolve path than:

  • a Promise from another realm;
  • an instance of a Promise subclass;
  • an arbitrary thenable.

Those categories can produce the same final value while differing observably in Promise-job ordering.

A Test262-shaped reproduction could use $262.createRealm():

const otherRealm = $262.createRealm().global;
const foreignPromise = otherRealm.Promise.resolve(1);

class SubPromise extends Promise {}

async function trace(value) {
  const events = [];

  async function* source() {
    events.push("body");
    yield value;
  }

  const request = source().next().then(() => {
    events.push("fulfilled");
  });

  Promise.resolve()
    .then(() => events.push("tick 1"))
    .then(() => events.push("tick 2"))
    .then(() => events.push("tick 3"));

  await request;
  await Promise.resolve();

  return events;
}

With a fulfilled same-realm native Promise:

await trace(Promise.resolve(1));

the observed sequence is:

["body", "tick 1", "fulfilled", "tick 2", "tick 3"]

With either:

await trace(foreignPromise);
await trace(SubPromise.resolve(1));

the observed sequence is:

["body", "tick 1", "tick 2", "tick 3", "fulfilled"]

The final IteratorResult is equivalent, but the assimilation route is not.

In the audited Test262 revision, I found extensive tests for ordinary thenables and several job-ordering paths, but not this direct same-realm versus cross-realm/subclass comparison in the AsyncGenerator tests.

Secondary standalone candidate: frozen generator objects

A native generator’s execution state lives in internal slots and does not require adding or changing own properties on the generator object.

Consequently, freezing the object should not prevent its native operations from progressing:

function* source() {
  yield 1;
  return 2;
}

const iterator = source();
Object.freeze(iterator);

console.log(iterator.next());
// { value: 1, done: false }

console.log(iterator.next());
// { value: 2, done: true }

The equivalent AsyncGenerator case should also remain operational.

I would submit this separately because it concerns object extensibility and internal-slot behavior rather than request queuing.

Separate Promise.race candidate

One additional distinction emerged from the same investigation, but it is independent of generators and would belong in a separate issue or pull request.

Given a sentinel in second position, an already fulfilled same-realm native Promise wins:

const sentinel = Symbol("pending");

console.log(
  await Promise.race([
    Promise.resolve("native"),
    sentinel
  ])
);
// "native"

A synchronously resolving thenable in the same array position does not:

console.log(
  await Promise.race([
    {
      then(resolve) {
        resolve("thenable");
      }
    },
    sentinel
  ])
);
// Symbol(pending)

The sentinel also wins against an already fulfilled cross-realm Promise or Promise-subclass instance:

const otherRealm = $262.createRealm().global;

class SubPromise extends Promise {}

await Promise.race([
  otherRealm.Promise.resolve("foreign"),
  sentinel
]);
// sentinel

await Promise.race([
  SubPromise.resolve("subclass"),
  sentinel
]);
// sentinel

With these inputs in this order, the reaction associated with the already-fulfilled same-realm Promise is enqueued early enough to win the race; that does not generalize to every Promise-like value. Thenable assimilation introduces additional jobs.

The existing Promise.race tests cover resolved-Promise ordering and thenable assimilation separately. I did not find this direct contrast in the audited revision.

Possible contribution structure

If these additions are considered useful, they seem naturally separable into a few small, focused Test262 contributions:

  1. AsyncGenerator request consumption and terminal resumption;
  2. intrinsic Promise and cross-realm/subclass behavior;
  3. frozen/non-extensible generator objects;
  4. the independent Promise.race distinction.

Each observable behavior could be placed in its own test file, following the current Test262 authoring guidelines.

I am primarily sharing the findings and minimized cases here so they can be evaluated by the Test262 maintainers. If any of these candidates are confirmed as useful gaps and further input from the ETJS investigation would help, I can provide additional technical context on request.

The broader motivation is collaborative: Test262 helps companion languages and transformations prove their ECMAScript fidelity, while those independent implementations can act as downstream stress tests and contribute minimized findings back to the shared conformance oracle.

Thanks for maintaining that oracle.

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