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