Fix frame rate limiting blocking forever when the clock cannot observe sleep - #2248
Open
simon112 wants to merge 2 commits into
Open
Fix frame rate limiting blocking forever when the clock cannot observe sleep#2248simon112 wants to merge 2 commits into
simon112 wants to merge 2 commits into
Conversation
simon112
force-pushed
the
fix/frame-rate-limit-stalled-clock
branch
4 times, most recently
from
August 9, 2026 15:38
ad67aaa to
5e84e93
Compare
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
force-pushed
the
fix/frame-rate-limit-stalled-clock
branch
from
August 9, 2026 16:18
5e84e93 to
74b1c4e
Compare
…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
force-pushed
the
fix/frame-rate-limit-stalled-clock
branch
from
August 9, 2026 17:26
74b1c4e to
575e02b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
(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_sleepsleeps 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, customget_timeon a virtualized timeline),ecs_progress()blocks forever as soon as a target FPS is set.World_progress_w_stalled_clock_w_target_fpsin 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_fpsdocs):get_timeandsleepmust 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:
Argument validation
ecs_set_target_fpsnow 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 typeecs_ftime_tis defined to, including the integer configuration in the custom build tests. In release builds the check compiles out and is backstopped byecs_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 tointis 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_sleepfnever forwards an unrepresentable duration to the OS sleep.Verified with the full
test/coreandtest/addonssuites 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