[SPIKE] settled(): await settledness instead of polling for it - #1574
[SPIKE] settled(): await settledness instead of polling for it#1574NullVoxPopuli-ai-agent wants to merge 4 commits into
Conversation
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>
|
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
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. |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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:
beginAsynccreates a promise for that operationendAsyncresolves it and deletes it from the pending map, so the map only ever holds work in flightwaitersSettled()composes the promises of what's currently tracked, via a new optionalsettled()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.
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>
87b0e61 to
c253113
Compare
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>
c253113 to
39b0024
Compare
| * | ||
| * @private | ||
| */ | ||
| function macrotask(): Promise<void> { |
There was a problem hiding this comment.
delete this function
There was a problem hiding this comment.
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.
| */ | ||
| const FALLBACK_MS = waitersSettled === null ? 10 : 50; | ||
|
|
||
| function fallbackTick(): Promise<void> { |
There was a problem hiding this comment.
delete this function
There was a problem hiding this comment.
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.
| const renderWaiter = buildWaiter('@ember/test-helpers:render'); | ||
| let renderWaiterToken: unknown = null; | ||
|
|
||
| const usesRenderWaiter = (() => { |
There was a problem hiding this comment.
why all of this condition? we don't care, right? because we can just await renderSettled()
There was a problem hiding this comment.
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.)
a4f7c59 to
1b2ef58
Compare
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>
1b2ef58 to
4da40d2
Compare
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 pollingisSettledon a timer cadence and awaits the settledness promises instead:Rendering is bridged into a test waiter, so on builds that report render edges (
_onRenderSettledChange) a pending render is counted inhasPendingWaitersand is named in waiter debug output instead of being an anonymousisRenderPendingboolean.Why each piece survived review
Three things look deletable and aren't; each was measured after trying the deletion:
settled()reduced to a bareawait Promise.all([renderSettled(), waitersSettled()]), this repo's suite goes 553/553 → 5 failures. Run loop timers, legacyEmber.Test.registerWaitercallbacks, the request counter, andWaiters withoutsettled()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).waitUntilimposed this implicitly.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 fromhasRunLoopand runloop-less builds otherwise have nothing for.The helper functions those pieces lived in are gone — the logic is inline with its reasoning.
Validation
waitersSettledis absent and the fallback drives).🤖 Generated with Claude Code