Skip to content

Commit ad67aaa

Browse files
Simonclaude
andcommitted
Fix frame rate limiting blocking forever when the clock cannot observe 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, two bounds end the frame: a stall budget of one frame period that never refills within a frame, and an unconditional cap of two frame periods on the total sleep requested per frame. 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. The cap is denominated in requested sleep while pacing is measured on the clock, so a clock that observes less than about half of each sleep ends its frame proportionally short of the target rather than sleeping longer. The ecs_set_target_fps documentation states this contract, including the shortfall. 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 also pin that the cap ends a frame that cannot reach the target; 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>
1 parent a61eb53 commit ad67aaa

9 files changed

Lines changed: 691 additions & 11 deletions

File tree

distr/flecs.c

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13362,7 +13362,11 @@ ecs_time_t ecs_time_sub(
1336213362
void ecs_sleepf(
1336313363
double t)
1336413364
{
13365-
if (t > 0) {
13365+
/* Only sleep for a duration that can be represented. Converting a value of
13366+
* 2^31 seconds or more to int is undefined behavior (out-of-range
13367+
* conversions produce platform-dependent garbage durations), and the
13368+
* comparison also rejects NaN. */
13369+
if (t > 0 && t <= (double)INT32_MAX) {
1336613370
int sec = (int)t;
1336713371
int nsec = (int)((t - sec) * 1000000000);
1336813372
ecs_os_sleep(sec, nsec);
@@ -24373,15 +24377,97 @@ static ecs_ftime_t flecs_insert_sleep(
2437324377
sleep_time = 0;
2437424378
delta_time = (ecs_ftime_t)ecs_time_measure(&now);
2437524379
} else {
24380+
/* Sleep interval while the clock keeps up, the largest delta time
24381+
* measured so far, interval slept while the clock did not keep up, and
24382+
* interval slept for this frame in total. */
24383+
ecs_ftime_t initial_sleep_time = sleep_time;
24384+
ecs_ftime_t max_delta_time = delta_time;
24385+
ecs_ftime_t stalled_sleep = 0;
24386+
ecs_ftime_t total_sleep = 0;
24387+
2437624388
do {
24389+
/* Hard bound on how long a single frame may spend sleeping, no
24390+
* matter what the classification below makes of the clock. The
24391+
* stall budget is the semantic bound; this one is unconditional, so
24392+
* that no interaction between the two can exceed what the frame API
24393+
* documents. */
24394+
ecs_ftime_t sleep_cap =
24395+
(target_delta_time * (ecs_ftime_t)2.0) - total_sleep;
24396+
if (sleep_cap <= 0) {
24397+
break;
24398+
}
24399+
if (sleep_time > sleep_cap) {
24400+
sleep_time = sleep_cap;
24401+
}
24402+
2437724403
/* Only call sleep when sleep_time is not 0. On some platforms, even
2437824404
* a sleep with a timeout of 0 can cause stutter. */
2437924405
if (ECS_NEQZERO(sleep_time)) {
2438024406
ecs_sleepf((double)sleep_time);
24407+
total_sleep += sleep_time;
2438124408
}
2438224409

2438324410
now = start;
2438424411
delta_time = (ecs_ftime_t)ecs_time_measure(&now);
24412+
24413+
/* The clock only counts as keeping up if it advanced by a
24414+
* meaningful fraction of the interval that was just slept. Testing
24415+
* for any advance would let a clock that ticks a nanosecond per
24416+
* read reset the budget below forever while making no progress
24417+
* towards the target. The ratio measures whether clock and sleep
24418+
* agree about the passage of time, not how fast the clock runs, so
24419+
* a host that virtualizes both consistently paces correctly. */
24420+
if ((delta_time - max_delta_time) >=
24421+
(sleep_time / (ecs_ftime_t)4.0))
24422+
{
24423+
max_delta_time = delta_time;
24424+
24425+
/* Restore the interval. The loop's exit condition is expressed
24426+
* in terms of it, so leaving it escalated would overshoot the
24427+
* target frame time for the rest of the frame. */
24428+
sleep_time = initial_sleep_time;
24429+
} else {
24430+
/* The clock did not keep up: it is either too coarse to
24431+
* measure an interval this small, or not running at all (a
24432+
* simulated clock that a host advances in between frames). A
24433+
* longer sleep eventually crosses a tick on a coarse clock; a
24434+
* stopped clock never advances however long is slept. What
24435+
* accumulates is therefore the requested interval, not time
24436+
* measured by the clock: neither the clock nor a replaced sleep
24437+
* function need make progress. A sleep that never returns is a
24438+
* broken OS API, out of scope here. */
24439+
stalled_sleep += sleep_time;
24440+
24441+
/* Never spend more than one frame's worth of sleeping on a
24442+
* clock that is not keeping up. The budget is per frame and
24443+
* never refills: an interval that the clock did keep up with
24444+
* restores the sleep interval, but not this. Refilling it lets
24445+
* a clock that advances just enough to be counted, once per
24446+
* escalation chain, replay that chain for as long as the frame
24447+
* lasts. */
24448+
ecs_ftime_t sleep_budget = target_delta_time - stalled_sleep;
24449+
if (sleep_budget <= 0) {
24450+
break;
24451+
}
24452+
24453+
if (ECS_EQZERO(sleep_time)) {
24454+
/* Doubling zero stays zero, so seed the escalation with a
24455+
* known nonzero interval (the initial one can be zero, when
24456+
* the frame took exactly the target time). A negative zero
24457+
* isn't matched by the bitwise test, but it never reaches
24458+
* here: an advance of zero is not below a threshold of
24459+
* negative zero, so the branch above claims it and restores
24460+
* the interval. A frame that took exactly the target time
24461+
* exits on the condition below. */
24462+
sleep_time = target_delta_time / (ecs_ftime_t)8.0;
24463+
} else {
24464+
sleep_time *= (ecs_ftime_t)2.0;
24465+
}
24466+
24467+
if (sleep_time > sleep_budget) {
24468+
sleep_time = sleep_budget;
24469+
}
24470+
}
2438524471
} while ((target_delta_time - delta_time) >
2438624472
(sleep_time / (ecs_ftime_t)2.0));
2438724473
}
@@ -24598,6 +24684,18 @@ void ecs_set_target_fps(
2459824684
flecs_poly_assert(world, ecs_world_t);
2459924685
ecs_check(ecs_os_has_time(), ECS_MISSING_OS_API, NULL);
2460024686

24687+
/* Frame time is the reciprocal of the target, so targets outside of this
24688+
* range yield a frame time that is infinite (the reciprocal of a subnormal
24689+
* target overflows) or otherwise meaningless. The comparisons also reject
24690+
* NaN, and compile for any type ecs_ftime_t is defined to. In release
24691+
* builds this check is compiled out, and is backstopped by ecs_sleepf
24692+
* refusing a duration it cannot represent: the sleep returns immediately,
24693+
* the clock is measured as not keeping up, and frame rate limiting is
24694+
* skipped for the frame instead of waiting. */
24695+
ecs_check(ECS_EQZERO(fps) || (fps >= (ecs_ftime_t)1e-9 &&
24696+
fps <= (ecs_ftime_t)1e9),
24697+
ECS_INVALID_PARAMETER, "fps must be zero or in the range 1e-9 to 1e9");
24698+
2460124699
ecs_measure_frame_time(world, true);
2460224700
world->info.target_fps = fps;
2460324701
error:

distr/flecs.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13171,6 +13171,22 @@ void ecs_measure_system_time(
1317113171
* Note that ecs_progress() only sleeps if there is time left in the frame. Both
1317213172
* time spent in Flecs and time spent outside of Flecs are taken into account.
1317313173
*
13174+
* Frame rate limiting requires the OS API get_time and sleep functions to agree
13175+
* about the passage of time. Both may run on a virtualized timeline of any
13176+
* speed, but an application that replaces one of them must replace the other
13177+
* consistently. The total sleep requested for a single frame is capped at two
13178+
* frame periods, so a frame never waits for the clock indefinitely. The cap is
13179+
* measured in requested sleep, so a clock that observes less than about half
13180+
* of each sleep ends the frame short of the target, in proportion to the
13181+
* disagreement. If clock and sleep disagree by more than roughly a factor of
13182+
* four (for example a clock that is only stepped by a host in between frames,
13183+
* while sleeping does take time) frame rate limiting requests about one frame
13184+
* period worth of sleep and is then skipped for the remainder of that frame.
13185+
* If the clock is too coarse to observe an individual sleep, sleep intervals
13186+
* are enlarged until it can, which makes pacing coarser but keeps it working.
13187+
* An application that computes its target from untrusted input should range
13188+
* check it before passing it in.
13189+
*
1317413190
* @param world The world.
1317513191
* @param fps The target FPS.
1317613192
*/

distr/flecs_no_addons.c

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11380,7 +11380,11 @@ ecs_time_t ecs_time_sub(
1138011380
void ecs_sleepf(
1138111381
double t)
1138211382
{
11383-
if (t > 0) {
11383+
/* Only sleep for a duration that can be represented. Converting a value of
11384+
* 2^31 seconds or more to int is undefined behavior (out-of-range
11385+
* conversions produce platform-dependent garbage durations), and the
11386+
* comparison also rejects NaN. */
11387+
if (t > 0 && t <= (double)INT32_MAX) {
1138411388
int sec = (int)t;
1138511389
int nsec = (int)((t - sec) * 1000000000);
1138611390
ecs_os_sleep(sec, nsec);

include/flecs/addons/frame.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ void ecs_measure_system_time(
138138
* Note that ecs_progress() only sleeps if there is time left in the frame. Both
139139
* time spent in Flecs and time spent outside of Flecs are taken into account.
140140
*
141+
* Frame rate limiting requires the OS API get_time and sleep functions to agree
142+
* about the passage of time. Both may run on a virtualized timeline of any
143+
* speed, but an application that replaces one of them must replace the other
144+
* consistently. The total sleep requested for a single frame is capped at two
145+
* frame periods, so a frame never waits for the clock indefinitely. The cap is
146+
* measured in requested sleep, so a clock that observes less than about half
147+
* of each sleep ends the frame short of the target, in proportion to the
148+
* disagreement. If clock and sleep disagree by more than roughly a factor of
149+
* four (for example a clock that is only stepped by a host in between frames,
150+
* while sleeping does take time) frame rate limiting requests about one frame
151+
* period worth of sleep and is then skipped for the remainder of that frame.
152+
* If the clock is too coarse to observe an individual sleep, sleep intervals
153+
* are enlarged until it can, which makes pacing coarser but keeps it working.
154+
* An application that computes its target from untrusted input should range
155+
* check it before passing it in.
156+
*
141157
* @param world The world.
142158
* @param fps The target FPS.
143159
*/

src/addons/frame.c

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,97 @@ static ecs_ftime_t flecs_insert_sleep(
3636
sleep_time = 0;
3737
delta_time = (ecs_ftime_t)ecs_time_measure(&now);
3838
} else {
39+
/* Sleep interval while the clock keeps up, the largest delta time
40+
* measured so far, interval slept while the clock did not keep up, and
41+
* interval slept for this frame in total. */
42+
ecs_ftime_t initial_sleep_time = sleep_time;
43+
ecs_ftime_t max_delta_time = delta_time;
44+
ecs_ftime_t stalled_sleep = 0;
45+
ecs_ftime_t total_sleep = 0;
46+
3947
do {
48+
/* Hard bound on how long a single frame may spend sleeping, no
49+
* matter what the classification below makes of the clock. The
50+
* stall budget is the semantic bound; this one is unconditional, so
51+
* that no interaction between the two can exceed what the frame API
52+
* documents. */
53+
ecs_ftime_t sleep_cap =
54+
(target_delta_time * (ecs_ftime_t)2.0) - total_sleep;
55+
if (sleep_cap <= 0) {
56+
break;
57+
}
58+
if (sleep_time > sleep_cap) {
59+
sleep_time = sleep_cap;
60+
}
61+
4062
/* Only call sleep when sleep_time is not 0. On some platforms, even
4163
* a sleep with a timeout of 0 can cause stutter. */
4264
if (ECS_NEQZERO(sleep_time)) {
4365
ecs_sleepf((double)sleep_time);
66+
total_sleep += sleep_time;
4467
}
4568

4669
now = start;
4770
delta_time = (ecs_ftime_t)ecs_time_measure(&now);
71+
72+
/* The clock only counts as keeping up if it advanced by a
73+
* meaningful fraction of the interval that was just slept. Testing
74+
* for any advance would let a clock that ticks a nanosecond per
75+
* read reset the budget below forever while making no progress
76+
* towards the target. The ratio measures whether clock and sleep
77+
* agree about the passage of time, not how fast the clock runs, so
78+
* a host that virtualizes both consistently paces correctly. */
79+
if ((delta_time - max_delta_time) >=
80+
(sleep_time / (ecs_ftime_t)4.0))
81+
{
82+
max_delta_time = delta_time;
83+
84+
/* Restore the interval. The loop's exit condition is expressed
85+
* in terms of it, so leaving it escalated would overshoot the
86+
* target frame time for the rest of the frame. */
87+
sleep_time = initial_sleep_time;
88+
} else {
89+
/* The clock did not keep up: it is either too coarse to
90+
* measure an interval this small, or not running at all (a
91+
* simulated clock that a host advances in between frames). A
92+
* longer sleep eventually crosses a tick on a coarse clock; a
93+
* stopped clock never advances however long is slept. What
94+
* accumulates is therefore the requested interval, not time
95+
* measured by the clock: neither the clock nor a replaced sleep
96+
* function need make progress. A sleep that never returns is a
97+
* broken OS API, out of scope here. */
98+
stalled_sleep += sleep_time;
99+
100+
/* Never spend more than one frame's worth of sleeping on a
101+
* clock that is not keeping up. The budget is per frame and
102+
* never refills: an interval that the clock did keep up with
103+
* restores the sleep interval, but not this. Refilling it lets
104+
* a clock that advances just enough to be counted, once per
105+
* escalation chain, replay that chain for as long as the frame
106+
* lasts. */
107+
ecs_ftime_t sleep_budget = target_delta_time - stalled_sleep;
108+
if (sleep_budget <= 0) {
109+
break;
110+
}
111+
112+
if (ECS_EQZERO(sleep_time)) {
113+
/* Doubling zero stays zero, so seed the escalation with a
114+
* known nonzero interval (the initial one can be zero, when
115+
* the frame took exactly the target time). A negative zero
116+
* isn't matched by the bitwise test, but it never reaches
117+
* here: an advance of zero is not below a threshold of
118+
* negative zero, so the branch above claims it and restores
119+
* the interval. A frame that took exactly the target time
120+
* exits on the condition below. */
121+
sleep_time = target_delta_time / (ecs_ftime_t)8.0;
122+
} else {
123+
sleep_time *= (ecs_ftime_t)2.0;
124+
}
125+
126+
if (sleep_time > sleep_budget) {
127+
sleep_time = sleep_budget;
128+
}
129+
}
48130
} while ((target_delta_time - delta_time) >
49131
(sleep_time / (ecs_ftime_t)2.0));
50132
}
@@ -261,6 +343,18 @@ void ecs_set_target_fps(
261343
flecs_poly_assert(world, ecs_world_t);
262344
ecs_check(ecs_os_has_time(), ECS_MISSING_OS_API, NULL);
263345

346+
/* Frame time is the reciprocal of the target, so targets outside of this
347+
* range yield a frame time that is infinite (the reciprocal of a subnormal
348+
* target overflows) or otherwise meaningless. The comparisons also reject
349+
* NaN, and compile for any type ecs_ftime_t is defined to. In release
350+
* builds this check is compiled out, and is backstopped by ecs_sleepf
351+
* refusing a duration it cannot represent: the sleep returns immediately,
352+
* the clock is measured as not keeping up, and frame rate limiting is
353+
* skipped for the frame instead of waiting. */
354+
ecs_check(ECS_EQZERO(fps) || (fps >= (ecs_ftime_t)1e-9 &&
355+
fps <= (ecs_ftime_t)1e9),
356+
ECS_INVALID_PARAMETER, "fps must be zero or in the range 1e-9 to 1e9");
357+
264358
ecs_measure_frame_time(world, true);
265359
world->info.target_fps = fps;
266360
error:

src/misc.c

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,11 @@ ecs_time_t ecs_time_sub(
8585
void ecs_sleepf(
8686
double t)
8787
{
88-
if (t > 0) {
88+
/* Only sleep for a duration that can be represented. Converting a value of
89+
* 2^31 seconds or more to int is undefined behavior (out-of-range
90+
* conversions produce platform-dependent garbage durations), and the
91+
* comparison also rejects NaN. */
92+
if (t > 0 && t <= (double)INT32_MAX) {
8993
int sec = (int)t;
9094
int nsec = (int)((t - sec) * 1000000000);
9195
ecs_os_sleep(sec, nsec);

test/core/project.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2790,8 +2790,22 @@
27902790
"get_delta_time",
27912791
"get_delta_time_auto",
27922792
"progress_w_stalled_clock",
2793+
"progress_w_stalled_clock_w_target_fps",
2794+
"progress_w_stalled_clock_w_noop_sleep",
27932795
"progress_w_stalled_clock_at_zero",
27942796
"progress_w_coarse_clock",
2797+
"progress_w_coarse_clock_w_target_fps",
2798+
"progress_w_sleep_driven_clock",
2799+
"progress_w_sleep_driven_clock_w_work",
2800+
"progress_w_sleep_driven_clock_w_slow_frame",
2801+
"progress_w_clock_above_keeping_up",
2802+
"progress_w_clock_below_keeping_up",
2803+
"progress_w_dither_clock",
2804+
"progress_w_dither_clock_only",
2805+
"progress_w_stepped_clock",
2806+
"sleepf_w_unrepresentable_duration",
2807+
"set_target_fps_w_negative",
2808+
"set_target_fps_w_subnormal",
27952809
"recreate_world",
27962810
"recreate_world_w_component",
27972811
"no_threading",

0 commit comments

Comments
 (0)