Skip to content

Commit f629edc

Browse files
committed
Remove catch-up behavior of LLTimers:every()
This was bug-prone and hard to reason about. Switch to behavior more closely matching the existing behavior of `llSetTimerEvent()`'s internal scheduling logic. Fixes #37
1 parent ff8e868 commit f629edc

2 files changed

Lines changed: 61 additions & 103 deletions

File tree

VM/src/llltimers.cpp

Lines changed: 40 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
enum TimerDataIndex {
2020
TIMER_HANDLER = 1,
2121
TIMER_INTERVAL = 2,
22-
TIMER_NEXT_RUN = 3, // Actual time to fire (may be clamped for catch-up)
23-
TIMER_LOGICAL_SCHEDULE = 4, // Logical schedule time (never clamped, always += interval)
22+
TIMER_NEXT_RUN = 3, // Time at which this timer is next eligible to fire
23+
TIMER_LOGICAL_SCHEDULE = 4, // Historical slot: kept for serialization stability, mirrors TIMER_NEXT_RUN
2424
TIMER_LEN = TIMER_LOGICAL_SCHEDULE,
2525
};
2626

@@ -554,20 +554,15 @@ DEFINE_YIELDABLE(lltimers_tick, 0)
554554
continue;
555555
}
556556

557-
// Get the nextRun time from the timer
557+
// Get the nextRun time from the timer. We read this BEFORE updating
558+
// it so we can pass the pre-update value to the handler as its
559+
// "scheduled time" (the earliest instant this tick was allowed to
560+
// fire), letting handlers compute `now - scheduled_time` to see
561+
// how much extra delay there was beyond the minimum interval.
558562
lua_rawgeti(L, CURRENT_TIMER, TIMER_NEXT_RUN);
559563
double next_run = lua_tonumber(L, -1);
560564
lua_pop(L, 1);
561565

562-
// Get the logical schedule time (what we'll pass to the handler)
563-
// We read this BEFORE updating it so the handler gets the current value
564-
lua_rawgeti(L, CURRENT_TIMER, TIMER_LOGICAL_SCHEDULE);
565-
double logical_schedule = lua_tonumber(L, -1);
566-
lua_pop(L, 1);
567-
568-
// Save the original value - this is what the handler should receive
569-
double handler_scheduled_time = logical_schedule;
570-
571566
// Verify nextRun is a reasonable number
572567
LUAU_ASSERT(next_run >= 0.0);
573568

@@ -611,75 +606,43 @@ DEFINE_YIELDABLE(lltimers_tick, 0)
611606
}
612607
else
613608
{
614-
// Schedule its next run using absolute scheduling with clamped catch-up
615-
// (next = previous_scheduled_time + interval)
616-
// This prevents drift and ensures the timer maintains its rhythm.
617-
// However, if the timer is very late (> 2 seconds), skip ahead to prevent
618-
// excessive catch-up iterations that could bog down the system.
619-
//
620-
// We maintain two schedules:
621-
// - TIMER_NEXT_RUN: May be clamped to prevent catch-up storms
622-
// - TIMER_LOGICAL_SCHEDULE: Never clamped, always += interval
623-
// This lets handlers know their true delay from the logical schedule.
609+
// Schedule the next run. The default is cadence-preserving
610+
// absolute scheduling (previous_next_run + interval), so a
611+
// healthy 6s timer stays near 6s/12s/18s/... without drift.
612+
// But if that target is already in the past, meaning we
613+
// would need to fire again immediately to "catch up" on ticks
614+
// missed while the script was descheduled.
624615
//
625-
// Note that we do this BEFORE the timer is ever run.
626-
// This ensures that handler runtime has no effect on
627-
// when the handler will be invoked next.
628-
bool did_clamp = false;
629-
630-
// Correctly handles scheduling an event for _at least immediately after_
631-
// the current time, even if addition of a tiny interval underflows.
632-
// For interval=0: use start_time to prevent re-trigger without clock advance
633-
// For other intervals: use next_run to maintain rhythm
634-
double next_scheduled = std::max(
635-
std::nextafter(interval == 0.0 ? start_time : next_run, INFINITY),
636-
(interval != 0.0) ? next_run + interval : 0.0
637-
);
638-
double new_next_run = next_scheduled;
639-
640-
// Catchup logic with overflow protection
641-
constexpr double MAX_CATCHUP_TIME = 2.0;
642-
if (interval > 0 && start_time - next_scheduled > MAX_CATCHUP_TIME)
616+
// Note: we compute this BEFORE invoking the handler so that
617+
// handler runtime has no effect on when it is next invoked.
618+
double new_next_run;
619+
if (interval == 0.0)
643620
{
644-
// Skip ahead to next interval after current time
645-
// This prevents spiral of death from excessive catch-up
646-
double time_behind = start_time - next_run;
647-
double intervals_to_skip = std::ceil(time_behind / interval);
648-
649-
if (!std::isfinite(intervals_to_skip))
650-
{
651-
// Division overflowed to infinity - interval is extremely small, treat as ASAP
652-
new_next_run = std::nextafter(start_time, INFINITY);
653-
}
654-
else
621+
// It should run again ASAP (but not immediately)!
622+
new_next_run = std::nextafter(start_time, INFINITY);
623+
}
624+
else
625+
{
626+
new_next_run = next_run + interval;
627+
if (new_next_run <= start_time)
655628
{
656-
new_next_run = next_run + (intervals_to_skip * interval);
629+
// Would need to catch up, don't. Reset cadence instead.
630+
new_next_run = start_time + interval;
631+
if (new_next_run <= start_time)
632+
{
633+
// Can happen with very small magnitude intervals, just
634+
// schedule for the next representable time after start_time.
635+
new_next_run = std::nextafter(start_time, INFINITY);
636+
}
657637
}
658-
// Ensure next tick is at least one full interval from now
659-
// This prevents "fast ticks" when waking up close to a catchup boundary
660-
new_next_run = std::max(new_next_run, start_time + interval);
661-
did_clamp = true;
662638
}
663639

664-
// Update actual next run time (may be clamped)
665640
lua_pushnumber(L, new_next_run);
666641
lua_rawseti(L, CURRENT_TIMER, TIMER_NEXT_RUN);
667642

668-
// Update logical schedule
669-
// When clamping, sync to new_next_run (reset to new reality)
670-
// When not clamping, increment normally (logical_schedule + interval)
671-
if (did_clamp)
672-
{
673-
// Sync logical schedule when clamping - we're giving up on catch-up
674-
// so reset the logical schedule to match the new reality.
675-
// This ensures handlers see the initial delay (current fire), then return to normal.
676-
lua_pushnumber(L, new_next_run);
677-
}
678-
else
679-
{
680-
// For interval=0, use next_scheduled since logical_schedule + 0 never changes
681-
lua_pushnumber(L, interval == 0.0 ? next_scheduled : logical_schedule + interval);
682-
}
643+
// TIMER_LOGICAL_SCHEDULE is a historical slot, we keep it here
644+
// so we don't muck up existing states.
645+
lua_pushnumber(L, new_next_run);
683646
lua_rawseti(L, CURRENT_TIMER, TIMER_LOGICAL_SCHEDULE);
684647
}
685648

@@ -694,11 +657,11 @@ DEFINE_YIELDABLE(lltimers_tick, 0)
694657
// No pcall(), errors bubble up to the global error handler!
695658
lua_pushvalue(L, HANDLER_FUNC);
696659
// Include when it was scheduled to run as first arg, allowing callees to do a diff between
697-
// scheduled and actual time.
698-
// We pass the saved handler_scheduled_time (the original logical schedule) so handlers
699-
// can detect delays. When clamping occurs, the handler still receives the ORIGINAL
700-
// scheduled time (when it was supposed to run), not the synced time.
701-
lua_pushnumber(L, handler_scheduled_time);
660+
// scheduled and actual time. We pass the pre-update next_run value, i.e. the earliest
661+
// instant this tick was allowed to fire (previous_fire + interval for repeating timers,
662+
// or create_time + interval for the first tick / a one-shot). `now - scheduled_time`
663+
// gives the extra delay beyond the minimum interval.
664+
lua_pushnumber(L, next_run);
702665
// Include the interval as second arg, enabling handlers to calculate missed intervals.
703666
// For once() timers, this will be nil. For on() timers, it's the interval.
704667
if (is_one_shot) {

tests/conformance/lltimers.lua

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,10 @@ end
168168
LLTimers:_tick()
169169
assert(zero_continuous_count == 5, "Zero-interval timer should fire 5 times")
170170

171-
-- Now advance past what would be the 2-second clamping threshold for interval>0 timers
172-
-- For interval=0: uses nextafter(next_run), no catchup needed
171+
-- Now jump far ahead to exercise the large-delay path
172+
-- For interval=0: uses nextafter(start_time), no catchup path
173173
-- This used to trigger division by zero: ceil(time_behind / 0)
174-
setclock(53.0) -- More than 2 seconds later
174+
setclock(53.0) -- Far ahead
175175
LLTimers:_tick()
176176
assert(zero_continuous_count == 6, "Zero-interval timer should continue after large time jump")
177177

@@ -192,7 +192,7 @@ for i, expected_time in expected_times do
192192
)
193193
end
194194

195-
-- Test very small non-zero intervals use nextafter (no clamping)
195+
-- Test very small non-zero intervals that underflow against the clock magnitude
196196
setclock(60.0)
197197
local tiny_count = 0
198198
local tiny_scheduled_times = {}
@@ -203,30 +203,28 @@ end)
203203

204204
-- Fire several times with small time advances
205205
-- Interval 1e-308 is so small that next_run + interval underflows to next_run
206-
-- So nextafter(next_run) is used instead, ensuring forward progress
206+
-- So the reset branch runs every tick, with next_run driven by the clock
207207
for i = 1, 5 do
208208
incrementclock(0.01)
209209
LLTimers:_tick()
210210
end
211211
assert(tiny_count == 5, "Tiny interval timer should fire multiple times")
212212

213-
-- Now test catchup overflow scenario
214-
-- Advance >2 seconds, which would cause ceil(time_behind / 1e-308) to overflow
215-
-- The implementation should detect overflow and use nextafter(start_time) instead
216-
setclock(63.0) -- More than 2 seconds later
213+
-- Large time jump: same reset path as a normal late tick.
214+
setclock(63.0)
217215
LLTimers:_tick()
218-
assert(tiny_count == 6, "Tiny interval timer should handle catchup overflow")
216+
assert(tiny_count == 6, "Tiny interval timer should fire after large time jump")
219217

220-
-- Verify it continues working after overflow
218+
-- Verify it continues working after the jump
221219
incrementclock(0.01)
222220
LLTimers:_tick()
223-
assert(tiny_count == 7, "Tiny interval timer should continue after overflow")
221+
assert(tiny_count == 7, "Tiny interval timer should continue after jump")
224222

225223
LLTimers:off(tiny_handler)
226224

227225
-- Verify scheduled times match expected values
228-
-- Tiny intervals stay at 60.0 due to underflow (60.0 + 1e-308 = 60.0), then catch-up syncs to 63.0
229-
local tiny_expected_times = {60.0, 60.0, 60.0, 60.0, 60.0, 60.0, 63.0}
226+
-- Tiny interval underflows per-tick, scheduled_time steps with the clock via the reset branch
227+
local tiny_expected_times = {60.0, 60.011, 60.022, 60.033, 60.044, 60.055, 63.0}
230228
for i, expected_time in tiny_expected_times do
231229
local actual_time = tiny_scheduled_times[i]
232230
assert(
@@ -648,7 +646,7 @@ assert(math.abs(repeat_scheduled_times[1] - 35.5) < 0.001)
648646
assert(math.abs(repeat_scheduled_times[2] - 36.0) < 0.001)
649647
assert(math.abs(repeat_scheduled_times[3] - 36.5) < 0.001)
650648

651-
-- Test clamped catch-up: timers >2s late skip ahead instead of rapid-firing
649+
-- Test no-catchup behavior: a very-late timer fires once, then resets cadence
652650
setclock(40.0)
653651
local catchup_fires = 0
654652
local catchup_scheduled_times = {}
@@ -657,7 +655,7 @@ local catchup_handler = LLTimers:every(0.1, function(scheduled_time)
657655
table.insert(catchup_scheduled_times, scheduled_time)
658656
end)
659657

660-
-- Make timer VERY late (4.9 seconds, exceeds 2-second threshold)
658+
-- Make timer VERY late (4.9 seconds)
661659
setclock(45.0)
662660
LLEvents:_handleEvent('timer')
663661

@@ -667,22 +665,19 @@ assert(catchup_fires == 1, "Timer should fire once per handleEvent call")
667665
-- scheduled_time parameter shows when it WAS scheduled (40.1), not when it got rescheduled to
668666
assert(math.abs(catchup_scheduled_times[1] - 40.1) < 0.001, "First fire shows original schedule time")
669667

670-
-- Fire again to verify logical schedule syncs when clamping
668+
-- Fire again to verify the cadence was reset (not resumed from the old schedule)
671669
setclock(45.1)
672670
LLEvents:_handleEvent('timer')
673671
assert(catchup_fires == 2, "Should fire again on next handleEvent")
674-
-- When we clamp, we sync the logical schedule to the new reality
675-
-- This means handlers see the initial delay (first fire), then return to normal
676-
assert(catchup_scheduled_times[2] > 44.9, "Second fire shows synced schedule (~45.0)")
677-
assert(catchup_scheduled_times[2] < 45.2, "Second fire shows synced schedule (~45.0)")
678-
-- Handler sees normal delay now: getclock() - scheduled_time = 45.1 - 45.0 = ~0.1s
672+
-- Handler now sees the reset schedule (~45.1), not the ~40.2 it would see under old catchup
673+
assert(catchup_scheduled_times[2] > 44.9, "Second fire shows reset schedule (~45.1)")
674+
assert(catchup_scheduled_times[2] < 45.2, "Second fire shows reset schedule (~45.1)")
675+
-- Handler sees normal delay now: getclock() - scheduled_time = 45.1 - 45.1 = ~0s
679676

680677
-- Clean up
681678
LLTimers:off(catchup_handler)
682679

683-
-- Test minimum interval guarantee after catchup
684-
-- After waking from a long sleep near a catchup boundary, the SECOND tick should
685-
-- never fire faster than the specified interval. Regression test for "overcorrection" bug.
680+
-- Test the "never sooner than interval" guarantee after a long delay.
686681
setclock(0.0)
687682
local min_interval_fires = 0
688683
local min_interval_times = {}
@@ -692,7 +687,7 @@ local min_interval_handler = LLTimers:every(1.0, function(scheduled_time)
692687
end)
693688

694689
-- Timer created at T=0, first scheduled for T=1.0
695-
-- Simulate script disable/re-enable: jump forward 5.5s (past 2s catchup threshold)
690+
-- Simulate script disable/re-enable: jump forward 5.5s (well past next_run)
696691
setclock(5.5)
697692
LLEvents:_handleEvent('timer')
698693
assert(min_interval_fires == 1, "First tick should fire immediately after wake")

0 commit comments

Comments
 (0)