Skip to content

[SPIKE] waitersSettled(): await waiter completion instead of polling for it - #525

Draft
NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:masterfrom
NullVoxPopuli-ai-agent:wait-for-waiters
Draft

[SPIKE] waitersSettled(): await waiter completion instead of polling for it#525
NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:masterfrom
NullVoxPopuli-ai-agent:wait-for-waiters

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Aug 10, 2026

Copy link
Copy Markdown

Companion to emberjs/ember-test-helpers#1574, exploring the RFC 957 test story (context: emberjs/ember.js#21520). Draft for discussion.

The idea

Waiters already know the moment each operation completes, but consumers can only find out by polling hasPendingWaiters() on a timer. So:

  • beginAsync creates a promise for that operation,
  • endAsync resolves it and drops it from the pending map — which therefore only ever holds work still in flight,
  • waitersSettled() composes the promises of whatever is currently tracked,
  • and Waiter gains an optional settled() so each waiter answers for its own operations.

NoopTestWaiter (production) resolves immediately; nothing changes for non-test builds.

Two honest limits, both reflected in the API

Operations begun after the call are not included. Settling can start more work, so waitersSettled() is a snapshot, and a caller that needs a true fixpoint re-checks. (The companion settled() loops.)

A Waiter implemented directly against the interface may not implement settled() — its waitUntil is pull-only and nothing obliges it to announce anything. Rather than claiming a quiet it cannot verify, waitersSettled() returns a never-resolving promise while such a waiter is pending, so callers race it against their own fallback tick and the pull-only waiter still works.

Does it actually do the work?

Measured, not assumed. Instrumenting the companion settled() to record which side of the race won, in an app whose suite exercises workers, rendering, and compilation: 91 of 92 iterations decided by the promises, 1 by the fallback.

That measurement also surfaced a sharp edge worth recording: the fallback tick must be slower than a frame. At 10ms it beat frame-paced render ticks to the race and decided 30 of 117 iterations, costing an extra loop pass each time; at 50ms, 1 of 92.

Validation

  • This repo's base-tests app: 56/56.
  • @ember/test-helpers test-app against a linked build of this branch: 553/553; against published @ember/test-waiters (no waitersSettled, degraded to the fallback tick): 553/553.
  • Against the runloop-less ember spike: limber's full chrome matrix green, 7/7 repeat runs on the suite that exposed the macrotask-confirm flake.

🤖 Generated with Claude Code

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent changed the title [SPIKE] waitForWaiters(): await waiter completion instead of polling for it [SPIKE] waitersSettled(): await waiter completion instead of polling for it Aug 10, 2026
Comment thread addon/src/build-waiter.ts Outdated
* `endAsync` that ends it and dropped at the same time -- so this only
* ever holds operations still in flight.
*/
private pendingOperations = new Map<T, { promise: Promise<void>; resolve: () => void }>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be a Set containing only promises.

Then,

settled can still Promise.all(the set's values)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the collection now holds only promises. Each one carries its own resolver (promise.resolve = resolve at creation), so there are no {promise, resolve} wrapper objects and settled() reads promises directly.

One deviation I want to flag rather than hide: it's a Map<Token, ResolvablePromise> and not a Set, because endAsync(token) has to settle that operation, which needs a lookup key. values() is the set of promises, so settled() is Promise.all(this.pendingPromises.values()). A literal Set<Promise> would need a second token→promise structure alongside it. Happy to switch if you'd rather have the Set and eat the extra map.

Comment thread addon/src/build-waiter.ts Outdated
}

settled(): Promise<void> {
const operations = [...this.pendingOperations.values()].map((operation) => operation.promise);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

never ever ever array splat

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Promise.all takes any iterable, so it's now Promise.all(this.pendingPromises.values()) — the splat and the .map are both gone.

Comment thread addon/src/build-waiter.ts Outdated
}

settled(): Promise<void> {
return Promise.resolve();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why? this is pointless, no?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — removed. NoopTestWaiter never calls register(), so it's never in getWaiters() and waitersSettled() would never reach it. settled() is optional on the interface, so nothing needs the stub.

Comment thread addon/src/build-waiter.ts Outdated
}

settled(): Promise<void> {
return Promise.all(this.pendingPromises.values()).then(() => {});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did you add this then? its unneeded

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. It was only there to make the signature Promise<void> instead of Promise<void[]> — a pointless allocation and extra microtask for a value nobody reads. settled() and waitersSettled() now return Promise<unknown> and hand back Promise.all(...) directly.

Waiters already know when their operations complete, but consumers can
only find out by polling. beginAsync now creates a promise for the
operation, endAsync resolves it and drops it (so what is held is only
work in flight), and waitersSettled() composes what is currently
tracked. Each promise carries its own resolver, so the collection holds
nothing but promises and settled() hands them straight to Promise.all.

Operations begun after the call are not included -- settling can start
more work, so callers needing a true fixpoint re-check. A Waiter
implemented directly against the interface may not implement the new
optional settled(); waitersSettled() returns a never-resolving promise
while such a waiter is pending rather than claiming quiet it cannot
verify, so callers race it against their own fallback tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NullVoxPopuli-ai-agent added a commit to NullVoxPopuli-ai-agent/limber that referenced this pull request Aug 10, 2026
Review follow-ups from emberjs/ember-test-waiters#525: each pending
operation's promise carries its own resolver, so the map holds nothing
but promises and settled() passes them straight to Promise.all -- no
array splat, no wrapper objects, no pointless .then.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants