Skip to content

Fix frame rate limiting blocking forever when the clock cannot observe sleep - #2248

Open
simon112 wants to merge 2 commits into
SanderMertens:masterfrom
simon112:fix/frame-rate-limit-stalled-clock
Open

Fix frame rate limiting blocking forever when the clock cannot observe sleep#2248
simon112 wants to merge 2 commits into
SanderMertens:masterfrom
simon112:fix/frame-rate-limit-stalled-clock

Conversation

@simon112

@simon112 simon112 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

(AI-authored fix; verification described at the bottom. Builds on #2247, whose branch this one is based on: only the second commit is new here, and the first must land first.)

Problem

The frame rate limiting loop in flecs_insert_sleep sleeps a fixed interval and remeasures until the frame reaches the target time. When the clock does not advance during a sleep, the loop never exits: on a clock that is only stepped by the host in between frames (deterministic simulations, replay harnesses, custom get_time on a virtualized timeline), ecs_progress() blocks forever as soon as a target FPS is set. World_progress_w_stalled_clock_w_target_fps in this PR hangs on current master (and on #2247 alone, which fixes the measurement half only). A clock too coarse to observe an individual sleep does exit, but only by spinning through many sleep and measure calls per clock tick.

Fix

The contract this PR implements (now stated in the ecs_set_target_fps docs): get_time and sleep must agree about the passage of time. That is deliberately a coherence requirement rather than an assumption that the clock tracks wall time: both functions may run on a virtualized timeline of any speed and pacing still works, because the keeping-up test compares clock advance against the interval just slept rather than against wall time; an application that replaces one of the two must replace the other consistently. Partial disagreement degrades gracefully instead of blocking, in two bands. A clock that observes at least a quarter of each interval slept is classified as keeping up and reaches the target, to within the half interval the loop stops at, and pays the disagreement in blocking time: covering a frame period of clock time at a quarter speed costs four periods of real sleep, and the quarter threshold is what bounds that. Below the threshold the clock is classified as not keeping up and the stall budget ends the frame. The quarter-boundary tests pin both bands (17/64 of each sleep observed reaches the target and asks for 3.6 periods of sleep; 15/64 stops at 62% of the target with the budget spent).

Mechanically:

  • The clock counts as keeping up when it advances by at least a quarter of the interval just slept. Requiring only "any advance" is not enough: a clock that ticks a nanosecond per read would count as keeping up on every iteration while making no progress toward the target.
  • When the clock is not keeping up, the sleep interval escalates (doubles). On a coarse clock the interval eventually crosses a clock tick, so pacing keeps working at reduced granularity. When the clock catches up, the interval resets, since leaving it escalated overshoots the target for the rest of the frame.
  • A stall budget of one frame period, which never refills within a frame, terminates the frame against a clock that never keeps up. It deliberately never refills: a budget that resets whenever the clock advances a little lets clock jitter replay the escalation chain indefinitely, when the interval clamp near the budget boundary gets small enough for jitter to register as keeping up. The dither-clock tests pin this by bounding total requested sleep per frame.
  • Behind the budget is a ceiling on the number of times one frame may sleep, which no clock reaches. An interval that keeps up advances the measured delta by a quarter of itself while the exit condition needs only eight initial intervals of advance, and an interval that does not keep up is charged to the budget, so both branches are bounded independently of what the clock does. Giving a model of the clock complete freedom to answer each read with whichever value prolongs the loop, and searching that game tree exhaustively, the worst case is 36 iterations and 4.75 frame periods of requested sleep, or 41 iterations if the clock may also run backwards. The ceiling is 128.

Argument validation

ecs_set_target_fps now rejects targets that are not zero or within 1e-9 to 1e9. The frame time is the reciprocal of the target, so a subnormal target yields an infinite frame time and sleep interval, and the value can arrive from outside the application through the REST world summary endpoint. A range check rather than a classification macro keeps the frame time finite and within [1e-9, 1e9] by construction, rejects NaN and infinity, and compiles for any type ecs_ftime_t is defined to, including the integer configuration in the custom build tests. In release builds the check compiles out and is backstopped by ecs_sleepf, which now refuses durations it cannot convert to the seconds and nanoseconds the OS sleep API takes: converting a value of 2^31 seconds or more to int is undefined behavior, and the same comparison rejects NaN. A refused sleep returns immediately, is measured as the clock not keeping up, and rate limiting is skipped for that frame instead of waiting.

Tests

Fourteen new tests: stalled clock with target FPS (real and no-op sleep, with bounds on how much sleep may be requested); pacing accuracy against a sleep-driven simulated clock at three work levels, deterministic to microseconds since the fake sleep advances the fake clock by exactly the requested interval; time conservation on a coarse clock with target FPS, including clock read-count bounds that fail if escalation is removed or pacing gives up on the first stall; clocks advancing just above and just below the keeping-up fraction, which pin the quarter constant and both bands of the contract; dither and host-stepped clocks bounding total per-frame sleep; abort tests for the fps checks; and a test that ecs_sleepf never forwards an unrepresentable duration to the OS sleep.

Verified with the full test/core and test/addons suites on Linux, in debug and sanitize builds. The new clock and pacing tests also pass on Windows/MSVC; since the fake clock and fake sleep drive all timing, they are deterministic across platforms. distr/ regenerated from a clean tree.

🤖 Generated with Claude Code

@simon112
simon112 force-pushed the fix/frame-rate-limit-stalled-clock branch 4 times, most recently from ad67aaa to 5e84e93 Compare August 9, 2026 15:38
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-rate-limit-stalled-clock branch from 5e84e93 to 74b1c4e Compare August 9, 2026 16:18
…e sleep

The frame rate limiting loop in flecs_insert_sleep slept a fixed
interval and remeasured until the frame reached the target time. When
the clock does not advance during a sleep, the loop never exits: a clock
that is only stepped by the host in between frames (emscripten without
asyncify, simulated clocks) blocks ecs_progress forever as soon as a
target FPS is set. A clock too coarse to observe an individual sleep
does exit, but only by spinning through many sleep and measure calls per
clock tick.

Sleep intervals now escalate when the clock is not keeping up. The clock
counts as keeping up when it advances by at least a quarter of the
interval just slept, which measures whether get_time and sleep agree
about the passage of time rather than how fast the clock runs, so a host
that virtualizes both consistently still paces correctly at any speed.
On a coarse clock, escalation grows the interval until it crosses a
clock tick, which keeps pacing working at reduced granularity. On a
clock that does not advance at all, a stall budget of one frame period
that never refills within a frame ends it. The budget not refilling
matters, because a budget that resets whenever the clock advances a
little lets clock jitter replay the escalation chain indefinitely on a
clock that advances just enough to be counted once per chain. Backing
the budget is a ceiling on the number of times a frame may sleep, which
no clock reaches: an interval that keeps up advances the measured delta
by a quarter of itself while the exit condition needs only eight initial
intervals of advance, and one that does not keep up is charged to the
budget. Giving the clock complete freedom to answer each read with
whichever value prolongs the loop, and searching that exhaustively, the
worst case is 36 iterations and 4.75 frame periods of requested sleep,
or 41 iterations if the clock may also run backwards. The ceiling is
128. The ecs_set_target_fps documentation states the resulting contract:
a clock that observes at least a quarter of each interval reaches the
target and pays the disagreement in blocking time, and one below that
has the budget end its frame.

ecs_set_target_fps also rejects targets that are not zero or within
1e-9 to 1e9. The frame time is the reciprocal of the target, so a
subnormal target yields an infinite frame time and sleep interval, and
the value can arrive from outside the application through the REST world
summary endpoint; the range keeps the frame time finite and within
[1e-9, 1e9] by construction, rejects NaN through the comparison, and
compiles for any type ecs_ftime_t is defined to, including the integer
configuration in the custom build tests. In release builds the check
compiles out and is backstopped by ecs_sleepf, which now refuses
durations it cannot convert to the seconds and nanoseconds the OS sleep
API takes: converting a value of 2^31 seconds or more to int is
undefined behavior, and the same comparison rejects NaN. A refused sleep
returns immediately, is measured as the clock not keeping up, and rate
limiting is skipped for the frame instead of waiting.

Tests cover a stalled clock with a target FPS set, with both real and
no-op sleep functions; pacing accuracy against a sleep-driven simulated
clock at three work levels; time conservation on a coarse clock with a
target FPS; clocks just above and just below the keeping-up threshold,
which pin that the first reaches the target and the second stops where
the budget runs out; dither and host-stepped clocks, which bound the
total sleep requested per frame; the fps argument checks; and that ecs_sleepf never forwards
an unrepresentable duration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@simon112
simon112 force-pushed the fix/frame-rate-limit-stalled-clock branch from 74b1c4e to 575e02b Compare August 9, 2026 17:26
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