Skip to content

Fix ecs_progress blocking when the clock does not advance between frames - #2247

Open
simon112 wants to merge 1 commit into
SanderMertens:masterfrom
simon112:fix/frame-stalled-clock
Open

Fix ecs_progress blocking when the clock does not advance between frames#2247
simon112 wants to merge 1 commit into
SanderMertens:masterfrom
simon112:fix/frame-stalled-clock

Conversation

@simon112

@simon112 simon112 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

(AI-authored fix; verification described at the bottom.)

Problem

flecs_start_measure_frame retries measurement in a loop until the clock returns a nonzero delta ("Keep trying while delta_time is zero"). Two consequences:

  • On a clock that is too coarse to measure a short frame (a browser with reduced timer precision, a coarse OS tick), every frame that finishes within one clock quantum busy-spins reading the clock until the quantum passes: frame times get pinned to the clock quantum, with a core burned doing it.
  • On a clock that only advances when the host steps it in between frames (deterministic simulations, replay harnesses, any custom get_time on a virtualized timeline), the loop never exits and ecs_progress() blocks forever.

The loop dates from f1164f4 (2019). At the time, a zero measurement risked losing the elapsed time, so retrying was the conservative choice. Measurement has since come to preserve the frame start time across a zero reading (ecs_time_measure writes back a value-identical timestamp when the clock did not advance), which is what makes it safe to stop retrying.

Fix

A zero measurement now reports a minimal nonzero delta (ECS_FRAME_MIN_DELTA_TIME, 1e-9) instead of measuring again. Since the frame start anchor is preserved, the elapsed time is credited in full to the frame in which the clock next advances: summed deltas still equal actual clock movement (the new coarse-clock test asserts conservation both ways), and the only distortion is the 1e-9 per stalled frame itself. The first frame that reports the minimum logs a warning, once per world: a rate computed by dividing by the minimum is a plausible looking number rather than the inf a zero would produce, so the loudness that a zero delta would have provided moves into the log instead.

Why an epsilon and not a fabricated fallback such as 1/60: a fabricated delta advances simulation time that did not pass, which on a fixed-timestep simulation against a stepped clock injects unbounded invented time. The epsilon keeps world time within 1e-9 per frame of what the clock actually reports. Callers that divide by delta_time still never see zero, and a tiny delta is not a new hazard class for systems: upstream already has tests asserting delta_time == 0 paths (time_scale 0, OnStart).

Two adjacent fixes ride along:

  • The first-frame branch is now selected by a world flag (EcsWorldFrameStartTimeSet) instead of testing the frame start time against {0,0}, which pinned a simulated clock that legitimately starts at zero to the first-frame branch forever.
  • The world summary computed fps as 1.0 / delta_time_raw, which is inf before the first measured frame (not representable in JSON on the REST endpoint) and a rate in the billions on stalled frames. It now reports 0 when no measurable frame time exists, and the windowed fps gauge in the stats addon applies the same threshold, so a stats window averaging at or below the minimum delta also reports 0 rather than the reciprocal of the minimum.

Tests

World_progress_w_stalled_clock, World_progress_w_stalled_clock_at_zero, World_progress_w_coarse_clock (conservation with upper and lower bounds, plus clock read-count bounds so a measurement path that spins or gives up fails rather than passes), and Stats_world_summary_fps_w_stalled_clock. The stalled-clock tests hang on current master by construction.

Note: with a target FPS configured, a stalled clock still blocks in flecs_insert_sleep. That is a separate defect in the frame rate limiting loop with its own fix and tests, submitted as a follow-up PR (based on this branch); its tests would hang this PR's CI, which is why the fps-variant tests live there.

Verified with the full test/core and test/addons suites on Linux, in debug and sanitize builds. The new World tests also pass on Windows/MSVC. distr/ regenerated from a clean tree.

🤖 Generated with Claude Code

Frame time measurement retried in a loop until the clock returned a
nonzero delta. On a clock that is too coarse to measure a short frame
(a browser with reduced timer precision, a coarse OS tick), every frame
that finishes within one clock quantum busy-spins reading the clock
until the quantum passes. On a clock that only advances when the host
steps it in between frames (emscripten without asyncify, simulated
clocks) the loop never exits and ecs_progress blocks forever.

A zero measurement now reports a minimal nonzero delta
(ECS_FRAME_MIN_DELTA_TIME) rather than measuring again until the clock
moves. Because the frame start time is written back value-identical when
the clock did not advance, the elapsed time is credited in full to the
frame in which the clock next advances, so no time is lost or invented
beyond the reported minimum. Reporting a nonzero delta also keeps every
existing divisor of delta_time safe. A delta of zero is already
observable today (time_scale 0, OnStart systems), so this changes no
contract for consumers. The first frame that reports the minimum logs a
warning, once per world, so a rate that was computed by dividing by it
can be traced to the clock rather than debugged as a spike.

The first-frame branch is now selected by a world flag instead of
testing the frame start time against zero, which pinned a simulated
clock that starts at 0 to the first-frame branch forever.

The world summary computed fps as 1.0 / delta_time_raw, which is inf
before the first measured frame (not representable in JSON on the REST
endpoint) and a rate in the billions on frames where the clock did not
advance. It now reports 0 when no measurable frame time exists, and the
windowed fps gauge in the stats addon applies the same threshold: a
stats window averaging at or below the minimum delta reports 0 instead
of the reciprocal of the minimum.

Tests cover a stalled clock, a stalled clock that reads 0, the warning
firing exactly once across repeated stalled frames, and a coarse clock,
including that measured deltas add up to how far the clock actually
moved; the stalled clock stats test pins the summary and the gauge
together.

Note: with a target FPS configured a stalled clock still blocks, in the
frame rate limiting loop of flecs_insert_sleep. That is a separate
defect with its own fix and tests, submitted as a follow-up PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@simon112
simon112 force-pushed the fix/frame-stalled-clock branch from 466a3de to c7c3d0e Compare August 9, 2026 16:16
@simon112

simon112 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

[Edit: below being written by AI, as was everything else in this thread except this edit, the WASM application also being vibecoded by me (but AI sessions in different folders treat each other as separate projects), permission being from me.]

For the record, a field signature of this wedge from a downstream WASM application (shared with permission), in case anyone else is trying to match a report against it:

  • 100% CPU, flat heap, no allocation; the main thread never returns from ecs_progress.
  • Their watchdog caught it as clock_probes=374359949 against alloc_probes=8821 over the stall, roughly 37M clock reads per second with the clock standing still.
  • The Firefox profile shows a single unbroken multi-second block under the frame tick with the OS time function beneath it, which they misread for a while as a profiler artifact.

The host was Emscripten, where performance.now() resolution clamping makes equal consecutive reads routine, so this matches the stock-WASM path described in the PR: no custom clock is needed to hit it.

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.

1 participant