Skip to content

[SPIKE] settled(): await settledness instead of polling for it - #1574

Draft
NullVoxPopuli-ai-agent wants to merge 4 commits into
emberjs:masterfrom
NullVoxPopuli-ai-agent:settled-awaits-render-settled
Draft

[SPIKE] settled(): await settledness instead of polling for it#1574
NullVoxPopuli-ai-agent wants to merge 4 commits into
emberjs:masterfrom
NullVoxPopuli-ai-agent:settled-awaits-render-settled

Conversation

@NullVoxPopuli-ai-agent

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

Copy link
Copy Markdown

Exploration for the RFC 957 test story (context: emberjs/ember.js#21520, the runloop-less scheduler spike). Draft — for signal and discussion, not to merge as-is. Depends on emberjs/ember-test-waiters#525 for waitersSettled (CI here will not typecheck until that lands and releases).

What changes

settled() stops polling isSettled on a timer cadence and awaits the settledness promises instead:

for (;;) {
  await Promise.race([
    Promise.all([renderSettled(), waitersSettled()]),
    new Promise((resolve) => setTimeout(resolve, 50)),
  ]);
  await new Promise((resolve) => setTimeout(resolve, 0));
  if (isSettled()) return;
}

Rendering is bridged into a test waiter, so on builds that report render edges (_onRenderSettledChange) a pending render is counted in hasPendingWaiters and is named in waiter debug output instead of being an anonymous isRenderPending boolean.

Why each piece survived review

Three things look deletable and aren't; each was measured after trying the deletion:

  • the 50ms race — with settled() reduced to a bare await Promise.all([renderSettled(), waitersSettled()]), this repo's suite goes 553/553 → 5 failures. Run loop timers, legacy Ember.Test.registerWaiter callbacks, the request counter, and Waiters without settled() cannot announce completion. 50ms rather than 10 so it loses the race to a frame-paced render tick (at 10ms it decided 30 of 117 iterations; at 50ms, 1 of 92 — the promises decide in practice).
  • the 0ms yield — quiet must be observed from a macrotask; already-queued task sources (worker messages, zero-delay timers) register waiters right after a microtask-context check says settled. ~1-in-4 flake without it on a worker-based compile suite. waitUntil imposed this implicitly.
  • the render bridge — deleting it takes limber's tutorial suite from 71/71 to 10+ 20s timeouts. renderSettled() is one-shot ("is the work I asked about done?"); isSettled() is checked afterwards and needs a current render signal, which classic builds get from hasRunLoop and runloop-less builds otherwise have nothing for.

The helper functions those pieces lived in are gone — the logic is inline with its reasoning.

Validation

🤖 Generated with Claude Code

NullVoxPopuli-ai-agent and others added 2 commits August 9, 2026 17:01
The render condition of settledness is event-driven: renderSettled()
resolves once rendering has completed, so settled() awaits it and only
re-checks the poll-only conditions (test waiters, pending requests,
transitions) in a loop.

Quiet is confirmed from a macrotask before resolving: task sources that
are already queued (worker messages, zero-delay timers) may re-register
waiters or dirty tracked state, and an observation made in microtask
context wins the race against them and settles early. The previous
waitUntil-based implementation imposed this boundary implicitly by
scheduling every check via setTimeout.

getSettledState() takes isRenderPending from '@ember/renderer' when the
build provides it (the synchronous probe the previous TODO wished for,
present on runloop-less ember builds) and falls back to backburner's
autorun instance otherwise. _backburner itself is read off the module
namespace so ember builds that no longer export it degrade to 'nothing
pending' instead of a build-time missing-export error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rendering reports its pending/complete edges on builds that schedule
without the runloop (_onRenderSettledChange). Translating those edges
into an @ember/test-waiters waiter folds rendering into the same
push-based settledness protocol as every other async source: no render
clause in isSettled() (hardwired false there to avoid double-counting
what the waiter already reports), and a render that never completes is
named in test-waiter debug output rather than showing up as an
anonymous 'isRenderPending' boolean.

Runloop-driven builds are unchanged: they keep inferring pending
renders from backburner's autorun instance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Author

Pushed a second iteration that goes further, and I think lands somewhere better: rendering becomes a test waiter.

Rather than the framework exposing a pollable isRenderPending() that isSettled() has to read as its own clause, the renderer reports the edges of its work (_onRenderSettledChange: true when rendering or destruction becomes outstanding, false when complete), and this addon bridges those edges into buildWaiter('@ember/test-helpers:render'). Consequences:

  • isSettled() has no render-specific clause at all on such builds — a pending render is already counted in hasPendingWaiters (the old clause is hardwired false to avoid double-counting).
  • A render that never completes is reported by name in test-waiter debug output, instead of as an anonymous isRenderPending: true boolean.
  • Settledness becomes one push-based protocol rather than 'waiters, plus a separately-polled render flag'.

Runloop-driven builds are untouched — they still infer pending renders from backburner's autorun instance.

Validation: this repo's test-app 553/553 on stock ember; against the runloop-less spike build, limber's full app+addon chrome matrix green and 7/7 repeat runs on the suite that originally exposed the macrotask-confirm flake.

Comment thread addon/src/settled.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

bad bot. this is not what I meant.

I meant in the test-waiters package, when we go beginAsync, we can have that be internally new Promise, and then endAsync resolves the promise.

test-waiters can have a waitersSettled() function which sees if all known promises are resolved (tho, if they resolve, I would also assume they remove themselves from the structure that waitersSettled() would look at)

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.

Understood, and you're right — I built the aggregate version (one module-level deferred that resolved when hasPendingWaiters() went false), not per-operation promises. Redone as you described, in emberjs/ember-test-waiters#525:

  • beginAsync creates a promise for that operation
  • endAsync resolves it and deletes it from the pending map, so the map only ever holds work in flight
  • waitersSettled() composes the promises of what's currently tracked, via a new optional settled() on each waiter

Your design is also strictly better than mine for a reason I hadn't considered: my version called hasPendingWaiters() on every endAsync, which walks every registered waiter and builds debugInfo arrays for the pending ones. Per-operation promises do no scanning at all — endAsync resolves one deferred.

One case the interface forced a decision on: a Waiter implemented directly against the interface has no settled() to compose. Rather than reporting a quiet it can't verify, waitersSettled() returns a never-resolving promise while such a waiter is pending, so the caller's fallback tick drives and pull-only waiters keep working.

Re-validated: test-waiters base-tests 56/56; this repo's test-app 553/553 both against a linked build of the new API and against published test-waiters (degraded to the fallback); limber's full chrome matrix green with 7/7 repeat runs against the runloop-less spike.

NullVoxPopuli-ai-agent added a commit to NullVoxPopuli-ai-agent/limber that referenced this pull request Aug 10, 2026
Reworks the settledness patches per review on
emberjs/ember-test-helpers#1574: @ember/test-waiters creates a promise
per operation in beginAsync, endAsync resolves it and drops it from the
pending map, and waitersSettled() composes the promises of whatever is
still in flight. @ember/test-helpers' settled() awaits that alongside
renderSettled().

The fallback tick stays for waiters that cannot announce completion
(pull-only Waiter implementations -- waitersSettled returns a
never-resolving promise while one is pending -- plus legacy waiters and
request counters). It is 50ms rather than 10 so it loses the race to a
frame-paced render tick; at 10ms it decided 30 of 117 iterations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent
NullVoxPopuli-ai-agent force-pushed the settled-awaits-render-settled branch from 87b0e61 to c253113 Compare August 10, 2026 04:56
settled() races the settledness promises -- renderSettled() and
waitersSettled() -- against a fallback tick, rather than polling
isSettled on a timer cadence. Rendering is bridged into a test waiter
(above), so both halves resolve from the same protocol; measured 91 of
92 loop iterations decided by the promises in an app whose suite
exercises workers, rendering and compilation.

The fallback tick covers what cannot announce completion: pull-only
Waiter implementations, legacy Ember.Test.registerWaiter callbacks, and
request counters. It is 50ms when waiters announce, because it has to
lose the race to a frame-paced render tick -- at 10ms it decided 30 of
117 iterations and cost an extra pass each time. Against test-waiters
versions without waitersSettled it is the only clock, so it stays at
the 10ms cadence the previous waitUntil-based implementation used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent
NullVoxPopuli-ai-agent force-pushed the settled-awaits-render-settled branch from c253113 to 39b0024 Compare August 10, 2026 05:19
Comment thread addon/src/settled.ts Outdated
*
* @private
*/
function macrotask(): Promise<void> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

delete this function

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.

Function deleted and inlined too. The 0ms yield it did stays, for the same measured reason as before: quiet observed in microtask context beats already-queued task sources (worker messages, zero-delay timers) that are about to register waiters or dirty state, and settled() returns early. That flaked ~1-in-4 runs on limber's worker-based compile suite until the yield was there; waitUntil had been imposing the same boundary implicitly by scheduling every check through setTimeout.

Comment thread addon/src/settled.ts Outdated
*/
const FALLBACK_MS = waitersSettled === null ? 10 : 50;

function fallbackTick(): Promise<void> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

delete this function

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.

Function deleted and inlined — but the 50ms timer itself has to stay, and I have data rather than an opinion on that now.

I first built exactly the minimal version (settled() = await Promise.all([renderSettled(), waitersSettled()]), no race, no loop, no yield). This repo's own suite goes from 553/553 to 5 failures:

not ok 9  - rerender real-world: using rerender to test loading states works
not ok 11 - Global error: calling set on destroyed object … internalValue = async value
not ok 12 - settled real-world scenarios: it works when async exists in `init`
not ok 13 - Global error: calling set on destroyed object … internalValue = async value
not ok 14 - settled real-world scenarios: rerender - it basically works

The reason is that not everything announces completion: run loop timers (hasPendingTimers), legacy Ember.Test.registerWaiter callbacks, the AJAX request counter, and any Waiter implemented against the interface without settled(). settled() returns while those are pending, and the destroyed-object assertions are the fallout.

With the timer inlined and the loop kept: 553/553.

Comment thread addon/src/settled.ts Outdated
const renderWaiter = buildWaiter('@ember/test-helpers:render');
let renderWaiterToken: unknown = null;

const usesRenderWaiter = (() => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why all of this condition? we don't care, right? because we can just await renderSettled()

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.

settled() does just await renderSettled() — that part is unconditional now. But I tried deleting the bridge entirely and it doesn't hold up, so I'd rather show you the A/B than quietly keep it.

Same limber tutorial suite, runloop-less ember build, only difference is this block:

result
bridge present 71/71 pass
bridge deleted 10+ chapters fail, each at the 20s test timeout

Reproducible, both directions, re-run to confirm.

The reason the awaited promise isn't sufficient: renderSettled() answers "has the work that was outstanding when I asked finished?" — one-shot. isSettled() answers "is anything outstanding right now?", and it is checked after the await, past a macrotask yield in which new dirt can land. Without the bridge, isSettled() has no render signal at all on runloop-less builds (hasRunLoop is false — there's no backburner), so a render that starts during that window is invisible and the loop exits with rendering pending. On classic builds hasRunLoop covers exactly this, which is why the gap only shows up on the runloop-less side.

So the bridge isn't a second settledness mechanism — it's how isSettled() keeps the render signal it already has on classic builds, expressed through the waiter protocol instead of a new isRenderPending() API. That framing is also what lets it be deleted the day rendering-pending is folded into waiters upstream in ember itself.

(I haven't traced the tutorial hang to the exact line inside its compile pipeline — what I can say confidently is the A/B above and that the missing sync signal is the only behavioural difference.)

@NullVoxPopuli-ai-agent
NullVoxPopuli-ai-agent force-pushed the settled-awaits-render-settled branch from a4f7c59 to 1b2ef58 Compare August 10, 2026 05:58
Deletes the fallbackTick / waitersQuiet / macrotask helpers per review
and inlines what they did, with the reasoning next to the code.

Both timers survive because removing them regresses this repo's own
suite: with settled() reduced to a bare await of renderSettled() and
waitersSettled(), 5 tests fail (async started in init, rerender
loading states, and the destroyed-object assertions that follow from
returning early). Run loop timers, legacy Ember.Test.registerWaiter
callbacks, request counters and Waiter implementations without
settled() cannot announce completion, so the race still needs a
fallback, and quiet still has to be observed from a macrotask.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent
NullVoxPopuli-ai-agent force-pushed the settled-awaits-render-settled branch from 1b2ef58 to 4da40d2 Compare August 10, 2026 05:58
@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent changed the title [SPIKE] settled() awaits renderSettled() instead of polling the render half [SPIKE] settled(): await settledness instead of polling for it Aug 10, 2026
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