A test that waits for a deadline somebody guessed is waiting for the wrong thing, so it waits for the editor (#93)
game-maker / suite (push) Successful in 6m48s

The suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.

The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.

Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.

564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.

Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
This commit is contained in:
admin committed 2026-09-16 08:08:10 +02:00
1 parent c990dbcb26
commit 7e5003b590
41 files changed
+362 -732

No files matched your search

+8 -1
View File
@@ -2,7 +2,14 @@ export { Widget, Box, type Size } from './Widget';
export { HStack, VStack, Spacer, Stack, type StackOptions } from './layout/Stack';
export { Theme, textStyle, type TextStyleToken } from './Theme';
export { initUI, ui, type UIContext } from './UIContext';
export { initRenderLoop, isRendering, renderCount, requestRender, resetRenderLoop } from './render';
export {
afterFrames,
initRenderLoop,
isRendering,
renderCount,
requestRender,
resetRenderLoop,
} from './render';
export { drawBand, drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw';
export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math';
export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag';
+38
View File
@@ -37,6 +37,39 @@ export function renderCount(): number {
return rendered;
}
/** Resolved once the next frame has been drawn; see `afterFrames`. */
let frameWaiters: Array<() => void> = [];
/**
* Resolves once `count` frames have actually been drawn – "the editor has caught up with what I
* just did", said as a fact instead of as a deadline. The checks wait on this rather than on a
* guessed number of milliseconds, which is both faster (a frame is ~16 ms) and truer.
*
* It wakes the loop itself, because an idle editor draws nothing and a promise waiting for a
* frame that nobody asked for never settles. `timeoutMs` is the other half of that promise: a
* wedged renderer must fail the check it belongs to, not hang the whole run.
*/
export function afterFrames(count = 1, timeoutMs = 2000): Promise<void> {
return new Promise((resolve) => {
let left = Math.max(1, count);
let done = false;
const finish = (): void => {
if (done) return;
done = true;
clearTimeout(timer);
resolve();
};
const timer = setTimeout(finish, timeoutMs);
const tick = (): void => {
if (done) return;
if (--left <= 0) finish();
else frameWaiters.push(tick);
};
frameWaiters.push(tick);
requestRender();
});
}
/**
* Takes the renderer off the ticker and drives it from `requestRender()` instead. Added at the
* ticker's lowest priority, so everything that may request a frame has run for this tick already.
@@ -49,6 +82,10 @@ export function initRenderLoop(app: Application): void {
if (!isRendering()) return;
rendered += 1;
app.render();
// Taken first: a waiter that queues itself again must land in the next round, not this one.
const waiters = frameWaiters;
frameWaiters = [];
for (const w of waiters) w();
},
undefined,
UPDATE_PRIORITY.LOW,
@@ -60,4 +97,5 @@ export function initRenderLoop(app: Application): void {
export function resetRenderLoop(): void {
deadline = 0;
rendered = 0;
frameWaiters = [];
}