1616enum 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 {
0 commit comments