Skip to content

Commit 17508de

Browse files
committed
Implement clamped catchup strategy, allow timers to detect how many intervals behind they are
1 parent 4ea3e95 commit 17508de

2 files changed

Lines changed: 122 additions & 16 deletions

File tree

VM/src/llltimers.cpp

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616
enum TimerDataIndex {
1717
TIMER_HANDLER = 1,
1818
TIMER_INTERVAL = 2,
19-
TIMER_NEXT_RUN = 3,
20-
TIMER_LEN = TIMER_NEXT_RUN,
19+
TIMER_NEXT_RUN = 3, // Actual time to fire (may be clamped for catch-up)
20+
TIMER_LOGICAL_SCHEDULE = 4, // Logical schedule time (never clamped, always += interval)
21+
TIMER_LEN = TIMER_LOGICAL_SCHEDULE,
2122
};
2223

2324
// Timer event wrapper for LLEvents integration
@@ -132,13 +133,16 @@ static int lltimers_on(lua_State *L)
132133
int old_len = lua_objlen(L, -1);
133134

134135
// Create timer data table
135-
lua_createtable(L, 3, 0);
136+
lua_createtable(L, 4, 0);
136137
lua_pushvalue(L, 3);
137138
lua_rawseti(L, -2, TIMER_HANDLER);
138-
lua_pushnumber(L, current_time + seconds);
139-
lua_rawseti(L, -2, TIMER_NEXT_RUN);
140139
lua_pushnumber(L, seconds);
141140
lua_rawseti(L, -2, TIMER_INTERVAL);
141+
double next_run_time = current_time + seconds;
142+
lua_pushnumber(L, next_run_time);
143+
lua_rawseti(L, -2, TIMER_NEXT_RUN);
144+
lua_pushnumber(L, next_run_time);
145+
lua_rawseti(L, -2, TIMER_LOGICAL_SCHEDULE);
142146

143147
LUAU_ASSERT(lua_objlen(L, -1) == TIMER_LEN);
144148

@@ -183,13 +187,16 @@ static int lltimers_once(lua_State *L)
183187
int old_len = lua_objlen(L, -1);
184188

185189
// Create timer data table
186-
lua_createtable(L, 3, 0);
190+
lua_createtable(L, 4, 0);
187191
lua_pushvalue(L, 3);
188192
lua_rawseti(L, -2, TIMER_HANDLER);
189193
lua_pushnil(L);
190194
lua_rawseti(L, -2, TIMER_INTERVAL);
191-
lua_pushnumber(L, current_time + seconds);
195+
double next_run_time = current_time + seconds;
196+
lua_pushnumber(L, next_run_time);
192197
lua_rawseti(L, -2, TIMER_NEXT_RUN);
198+
lua_pushnumber(L, next_run_time);
199+
lua_rawseti(L, -2, TIMER_LOGICAL_SCHEDULE);
193200
LUAU_ASSERT(lua_objlen(L, -1) == TIMER_LEN);
194201

195202
// Add to the timers array
@@ -503,6 +510,15 @@ static int lltimers_tick_cont(lua_State *L, [[maybe_unused]]int status)
503510
double next_run = lua_tonumber(L, -1);
504511
lua_pop(L, 1);
505512

513+
// Get the logical schedule time (what we'll pass to the handler)
514+
// We read this BEFORE updating it so the handler gets the current value
515+
lua_rawgeti(L, CURRENT_TIMER, TIMER_LOGICAL_SCHEDULE);
516+
double logical_schedule = lua_tonumber(L, -1);
517+
lua_pop(L, 1);
518+
519+
// Save the original value - this is what the handler should receive
520+
double handler_scheduled_time = logical_schedule;
521+
506522
// Verify nextRun is a reasonable number
507523
LUAU_ASSERT(next_run >= 0.0);
508524

@@ -555,15 +571,61 @@ static int lltimers_tick_cont(lua_State *L, [[maybe_unused]]int status)
555571
}
556572
else
557573
{
558-
// Schedule its next run using absolute scheduling
574+
// Schedule its next run using absolute scheduling with clamped catch-up
559575
// (next = previous_scheduled_time + interval)
560-
// This prevents drift and ensures the timer maintains its rhythm
561-
// regardless of execution delays.
576+
// This prevents drift and ensures the timer maintains its rhythm.
577+
// However, if the timer is very late (> 2 seconds), skip ahead to prevent
578+
// excessive catch-up iterations that could bog down the system.
579+
//
580+
// We maintain two schedules:
581+
// - TIMER_NEXT_RUN: May be clamped to prevent catch-up storms
582+
// - TIMER_LOGICAL_SCHEDULE: Never clamped, always += interval
583+
// This lets handlers know their true delay from the logical schedule.
584+
//
562585
// Note that we do this BEFORE the timer is ever run.
563586
// This ensures that handler runtime has no effect on
564587
// when the handler will be invoked next.
565-
lua_pushnumber(L, next_run + interval);
588+
const double MAX_CATCHUP_TIME = 2.0;
589+
double next_scheduled = next_run + interval;
590+
double new_next_run;
591+
bool did_clamp = false;
592+
593+
// Check if the next scheduled time would still be >2s behind current time
594+
if (start_time - next_scheduled > MAX_CATCHUP_TIME)
595+
{
596+
// Skip ahead to next interval after current time
597+
// This prevents spiral of death from excessive catch-up
598+
double time_behind = start_time - next_run;
599+
double intervals_to_skip = ceil(time_behind / interval);
600+
new_next_run = next_run + (intervals_to_skip * interval);
601+
did_clamp = true;
602+
}
603+
else
604+
{
605+
// Normal absolute scheduling - maintain rhythm
606+
new_next_run = next_scheduled;
607+
}
608+
609+
// Update actual next run time (may be clamped)
610+
lua_pushnumber(L, new_next_run);
566611
lua_rawseti(L, CURRENT_TIMER, TIMER_NEXT_RUN);
612+
613+
// Update logical schedule
614+
// When clamping, sync to new_next_run (reset to new reality)
615+
// When not clamping, increment normally (logical_schedule + interval)
616+
if (did_clamp)
617+
{
618+
// Sync logical schedule when clamping - we're giving up on catch-up
619+
// so reset the logical schedule to match the new reality.
620+
// This ensures handlers see the initial delay (current fire), then return to normal.
621+
lua_pushnumber(L, new_next_run);
622+
}
623+
else
624+
{
625+
// Normal increment - maintain rhythm
626+
lua_pushnumber(L, logical_schedule + interval);
627+
}
628+
lua_rawseti(L, CURRENT_TIMER, TIMER_LOGICAL_SCHEDULE);
567629
}
568630

569631
// Get the handler from the timer and call it
@@ -582,10 +644,20 @@ static int lltimers_tick_cont(lua_State *L, [[maybe_unused]]int status)
582644

583645
// No pcall(), errors bubble up to the global error handler!
584646
lua_pushvalue(L, HANDLER_FUNC);
585-
// Include when it was scheduled to run as an arg, allowing callees to do a diff between
647+
// Include when it was scheduled to run as first arg, allowing callees to do a diff between
586648
// scheduled and actual time.
587-
lua_pushnumber(L, next_run);
588-
lua_call(L, 1, 0);
649+
// We pass the saved handler_scheduled_time (the original logical schedule) so handlers
650+
// can detect delays. When clamping occurs, the handler still receives the ORIGINAL
651+
// scheduled time (when it was supposed to run), not the synced time.
652+
lua_pushnumber(L, handler_scheduled_time);
653+
// Include the interval as second arg, enabling handlers to calculate missed intervals.
654+
// For once() timers, this will be nil. For on() timers, it's the interval.
655+
if (is_one_shot) {
656+
lua_pushnil(L);
657+
} else {
658+
lua_pushnumber(L, interval);
659+
}
660+
lua_call(L, 2, 0);
589661

590662
if (L->status == LUA_YIELD || L->status == LUA_BREAK)
591663
{

tests/conformance/lltimers.lua

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ end
2020
-- Test basic on() functionality
2121
setclock(0.0)
2222
local on_count = 0
23-
local on_handler = LLTimers:on(0.1, function()
23+
local on_handler = LLTimers:on(0.1, function(scheduled_time, interval)
2424
on_count += 1
25+
assert(interval == 0.1, "on() timer should receive interval")
2526
end)
2627

2728
assert(typeof(on_handler) == "function")
@@ -50,8 +51,9 @@ LLTimers:off(on_handler)
5051
-- Test once() functionality
5152
setclock(1.0)
5253
local once_count = 0
53-
local once_handler = LLTimers:once(0.1, function()
54+
local once_handler = LLTimers:once(0.1, function(scheduled_time, interval)
5455
once_count += 1
56+
assert(interval == nil, "once() timer should receive nil interval")
5557
end)
5658

5759
incrementclock(0.1) -- Should fire the once handler
@@ -520,4 +522,36 @@ assert(math.abs(repeat_scheduled_times[1] - 35.5) < 0.001)
520522
assert(math.abs(repeat_scheduled_times[2] - 36.0) < 0.001)
521523
assert(math.abs(repeat_scheduled_times[3] - 36.5) < 0.001)
522524

525+
-- Test clamped catch-up: timers >2s late skip ahead instead of rapid-firing
526+
setclock(40.0)
527+
local catchup_fires = 0
528+
local catchup_scheduled_times = {}
529+
local catchup_handler = LLTimers:on(0.1, function(scheduled_time)
530+
catchup_fires += 1
531+
table.insert(catchup_scheduled_times, scheduled_time)
532+
end)
533+
534+
-- Make timer VERY late (4.9 seconds, exceeds 2-second threshold)
535+
setclock(45.0)
536+
LLEvents:_handleEvent('timer')
537+
538+
-- Should fire ONCE per _handleEvent call, not 49 times
539+
assert(catchup_fires == 1, "Timer should fire once per handleEvent call")
540+
541+
-- scheduled_time parameter shows when it WAS scheduled (40.1), not when it got rescheduled to
542+
assert(math.abs(catchup_scheduled_times[1] - 40.1) < 0.001, "First fire shows original schedule time")
543+
544+
-- Fire again to verify logical schedule syncs when clamping
545+
setclock(45.1)
546+
LLEvents:_handleEvent('timer')
547+
assert(catchup_fires == 2, "Should fire again on next handleEvent")
548+
-- When we clamp, we sync the logical schedule to the new reality
549+
-- This means handlers see the initial delay (first fire), then return to normal
550+
assert(catchup_scheduled_times[2] > 44.9, "Second fire shows synced schedule (~45.0)")
551+
assert(catchup_scheduled_times[2] < 45.2, "Second fire shows synced schedule (~45.0)")
552+
-- Handler sees normal delay now: getclock() - scheduled_time = 45.1 - 45.0 = ~0.1s
553+
554+
-- Clean up
555+
LLTimers:off(catchup_handler)
556+
523557
return "OK"

0 commit comments

Comments
 (0)