diff --git a/packages/react-native/React/CoreModules/RCTTiming.mm b/packages/react-native/React/CoreModules/RCTTiming.mm index d77719a4b5d6..1957b54280bf 100644 --- a/packages/react-native/React/CoreModules/RCTTiming.mm +++ b/packages/react-native/React/CoreModules/RCTTiming.mm @@ -7,12 +7,16 @@ #import "RCTTiming.h" +#include +#include + #import #import #import #import static const NSTimeInterval kMinimumSleepInterval = 1; +static const NSTimeInterval kNoTimerTarget = INFINITY; // These timing contants should be kept in sync with the ones in `JSTimers.js`. // The duration of a frame. This assumes that we want to run at 60 fps. @@ -20,9 +24,15 @@ // The minimum time left in a frame to trigger the idle callback. static const NSTimeInterval kIdleCallbackFrameDeadline = 0.001; +static NSTimeInterval RCTMonotonicTime() +{ + // Apple libc++ uses CLOCK_MONOTONIC_RAW, which includes time spent asleep. + return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); +} + @interface _RCTTimer : NSObject -@property (nonatomic, strong, readonly) NSDate *target; +@property (nonatomic, assign, readonly) NSTimeInterval target; @property (nonatomic, assign, readonly) BOOL repeats; @property (nonatomic, copy, readonly) NSNumber *callbackID; @property (nonatomic, assign, readonly) NSTimeInterval interval; @@ -40,7 +50,7 @@ - (instancetype)initWithCallbackID:(NSNumber *)callbackID _interval = interval; _repeats = repeats; _callbackID = callbackID; - _target = [NSDate dateWithTimeIntervalSinceNow:targetTime]; + _target = RCTMonotonicTime() + targetTime; } return self; } @@ -48,18 +58,15 @@ - (instancetype)initWithCallbackID:(NSNumber *)callbackID /** * Returns `YES` if we should invoke the JS callback. */ -- (BOOL)shouldFire:(NSDate *)now +- (BOOL)shouldFire:(NSTimeInterval)now { - if (_target && [_target timeIntervalSinceDate:now] <= 0) { - return YES; - } - return NO; + return _target <= now; } - (void)reschedule { // The JS Timers will do fine grained calculating of expired timeouts. - _target = [NSDate dateWithTimeIntervalSinceNow:_interval]; + _target = RCTMonotonicTime() + _interval; } @end @@ -92,6 +99,7 @@ - (void)timerDidFire @implementation RCTTiming { NSMutableDictionary *_timers; NSTimer *_sleepTimer; + NSTimeInterval _sleepTimerTarget; BOOL _sendIdleEvents; BOOL _inBackground; id _timingDelegate; @@ -226,15 +234,15 @@ - (BOOL)hasPendingTimers - (void)didUpdateFrame:(RCTFrameUpdate *)update { - NSDate *nextScheduledTarget = [NSDate distantFuture]; + NSTimeInterval nextScheduledTarget = kNoTimerTarget; NSMutableArray<_RCTTimer *> *timersToCall = [NSMutableArray new]; - NSDate *now = [NSDate date]; // compare all the timers to the same base time + NSTimeInterval now = RCTMonotonicTime(); @synchronized(_timers) { for (_RCTTimer *timer in _timers.allValues) { if ([timer shouldFire:now]) { [timersToCall addObject:timer]; - } else { - nextScheduledTarget = [nextScheduledTarget earlierDate:timer.target]; + } else if (timer.target < nextScheduledTarget) { + nextScheduledTarget = timer.target; } } } @@ -242,7 +250,13 @@ - (void)didUpdateFrame:(RCTFrameUpdate *)update // Call timers that need to be called if (timersToCall.count > 0) { NSArray *sortedTimers = [[timersToCall sortedArrayUsingComparator:^(_RCTTimer *a, _RCTTimer *b) { - return [a.target compare:b.target]; + if (a.target < b.target) { + return NSOrderedAscending; + } + if (a.target > b.target) { + return NSOrderedDescending; + } + return NSOrderedSame; }] valueForKey:@"callbackID"]; [_timingDelegate callTimers:sortedTimers]; } @@ -250,7 +264,9 @@ - (void)didUpdateFrame:(RCTFrameUpdate *)update for (_RCTTimer *timer in timersToCall) { if (timer.repeats) { [timer reschedule]; - nextScheduledTarget = [nextScheduledTarget earlierDate:timer.target]; + if (timer.target < nextScheduledTarget) { + nextScheduledTarget = timer.target; + } } else { @synchronized(_timers) { [_timers removeObjectForKey:timer.callbackID]; @@ -275,7 +291,7 @@ - (void)didUpdateFrame:(RCTFrameUpdate *)update timerCount = _timers.count; } if (_inBackground) { - if (timerCount != 0u) { + if (nextScheduledTarget != kNoTimerTarget) { [self scheduleSleepTimer:nextScheduledTarget]; } } else if (!_sendIdleEvents && timersToCall.count == 0) { @@ -285,26 +301,30 @@ - (void)didUpdateFrame:(RCTFrameUpdate *)update _paused = YES; } // If the next timer is more than 1 second out, pause and schedule an NSTimer; - else if ([nextScheduledTarget timeIntervalSinceNow] > kMinimumSleepInterval) { + else if ( + nextScheduledTarget != kNoTimerTarget && nextScheduledTarget - RCTMonotonicTime() > kMinimumSleepInterval) { [self scheduleSleepTimer:nextScheduledTarget]; _paused = YES; } } } -- (void)scheduleSleepTimer:(NSDate *)sleepTarget +- (void)scheduleSleepTimer:(NSTimeInterval)sleepTarget { @synchronized(self) { + NSTimeInterval delay = MAX(sleepTarget - RCTMonotonicTime(), 0); if ((_sleepTimer == nil) || !_sleepTimer.valid) { - _sleepTimer = [[NSTimer alloc] initWithFireDate:sleepTarget - interval:0 - target:[_RCTTimingProxy proxyWithTarget:self] - selector:@selector(timerDidFire) - userInfo:nil - repeats:NO]; + _sleepTimer = [NSTimer timerWithTimeInterval:delay + target:[_RCTTimingProxy proxyWithTarget:self] + selector:@selector(timerDidFire) + userInfo:nil + repeats:NO]; + _sleepTimerTarget = sleepTarget; [[NSRunLoop currentRunLoop] addTimer:_sleepTimer forMode:NSDefaultRunLoopMode]; - } else { - _sleepTimer.fireDate = [_sleepTimer.fireDate earlierDate:sleepTarget]; + } else if (sleepTarget <= _sleepTimerTarget) { + // Convert to a date only when setting the run loop's wakeup. + _sleepTimer.fireDate = [NSDate dateWithTimeIntervalSinceNow:delay]; + _sleepTimerTarget = sleepTarget; } } } @@ -347,7 +367,7 @@ - (void)createTimerForNextFrame:(nonnull NSNumber *)callbackID if (_inBackground) { [self scheduleSleepTimer:timer.target]; } else if (_paused) { - if ([timer.target timeIntervalSinceNow] > kMinimumSleepInterval) { + if (timer.target - RCTMonotonicTime() > kMinimumSleepInterval) { [self scheduleSleepTimer:timer.target]; } else { [self startTimers]; diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index adc9a277d087..aa320ff89753 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 602160B9478DAFF2BAB56595 /* RCTUtilsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D5D13FB1445C5C19639921CC /* RCTUtilsTests.m */; }; 79B29C2E2E607A99007612A5 /* SceneDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 79B29C2D2E607A99007612A5 /* SceneDelegate.mm */; }; 7F806D4D4D97AD6403558B35 /* RCTStatusBarAppearanceTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 67B0814814F7C07E196BC295 /* RCTStatusBarAppearanceTests.mm */; }; + A8717727467541429C262C63 /* RCTTimingClockChangeTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = A8717727467541429C262C64 /* RCTTimingClockChangeTests.mm */; }; 8145AE06241172D900A3F8DA /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 8145AE05241172D900A3F8DA /* LaunchScreen.storyboard */; }; 832F45BB2A8A6E1F0097B4E6 /* SwiftTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832F45BA2A8A6E1F0097B4E6 /* SwiftTest.swift */; }; A975CA6C2C05EADF0043F72A /* RCTNetworkTaskTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A975CA6B2C05EADE0043F72A /* RCTNetworkTaskTests.m */; }; @@ -89,6 +90,7 @@ 51BC9297B6C3163C14532020 /* Pods-RNTester.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTester.release.xcconfig"; path = "Target Support Files/Pods-RNTester/Pods-RNTester.release.xcconfig"; sourceTree = ""; }; 5C60EB1B226440DB0018C04F /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = RNTester/AppDelegate.mm; sourceTree = ""; }; 67B0814814F7C07E196BC295 /* RCTStatusBarAppearanceTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTStatusBarAppearanceTests.mm; sourceTree = ""; }; + A8717727467541429C262C64 /* RCTTimingClockChangeTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTTimingClockChangeTests.mm; sourceTree = ""; }; 79B29C2C2E607A99007612A5 /* SceneDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SceneDelegate.h; path = RNTester/SceneDelegate.h; sourceTree = ""; }; 79B29C2D2E607A99007612A5 /* SceneDelegate.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = SceneDelegate.mm; path = RNTester/SceneDelegate.mm; sourceTree = ""; }; 8145AE05241172D900A3F8DA /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = RNTester/LaunchScreen.storyboard; sourceTree = ""; }; @@ -318,6 +320,7 @@ D5D13FB1445C5C19639921CC /* RCTUtilsTests.m */, E7DB20B322B2BAA4005AC45F /* RNTesterUnitTestsBundle.js */, 67B0814814F7C07E196BC295 /* RCTStatusBarAppearanceTests.mm */, + A8717727467541429C262C64 /* RCTTimingClockChangeTests.mm */, ); path = RNTesterUnitTests; sourceTree = ""; @@ -637,6 +640,7 @@ E7DB20D822B2BAA6005AC45F /* RCTJSONTests.m in Sources */, 602160B9478DAFF2BAB56595 /* RCTUtilsTests.m in Sources */, 7F806D4D4D97AD6403558B35 /* RCTStatusBarAppearanceTests.mm in Sources */, + A8717727467541429C262C63 /* RCTTimingClockChangeTests.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/packages/rn-tester/RNTesterUnitTests/RCTTimingClockChangeTests.mm b/packages/rn-tester/RNTesterUnitTests/RCTTimingClockChangeTests.mm new file mode 100644 index 000000000000..763ef73a0a44 --- /dev/null +++ b/packages/rn-tester/RNTesterUnitTests/RCTTimingClockChangeTests.mm @@ -0,0 +1,118 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import +#import + +#import + +static constexpr NSTimeInterval kClockJump = 60 * 60; +static constexpr NSTimeInterval kTimerDelay = 0.05; +static thread_local NSTimeInterval wallClockOffset; + +static Method dateMethod() +{ + return class_getClassMethod(NSDate.class, @selector(date)); +} + +static auto originalDateImplementation() +{ + static const auto implementation = method_getImplementation(dateMethod()); + return implementation; +} + +static NSDate *dateWithClockOffset(id receiver, SEL selector) +{ + auto originalDate = reinterpret_cast(originalDateImplementation()); + NSDate *now = originalDate(receiver, selector); + return wallClockOffset == 0 ? now : [now dateByAddingTimeInterval:wallClockOffset]; +} + +@interface RCTTimingClockChangeTests : XCTestCase +@end + +@implementation RCTTimingClockChangeTests { + RCTTiming *_timing; + NSMutableArray *_callbacks; +} + +- (void)setUp +{ + [super setUp]; + _callbacks = [NSMutableArray new]; + _timing = [[RCTTiming alloc] initWithDelegate:self]; +} + +- (void)tearDown +{ + [_timing invalidate]; + _timing = nil; + [super tearDown]; +} + +- (void)callTimers:(NSArray *)timers +{ + [_callbacks addObjectsFromArray:timers]; +} + +- (void)immediatelyCallTimer:(NSNumber *)callbackID +{ + [_callbacks addObject:callbackID]; +} + +- (void)callIdleCallbacks:(NSNumber *)absoluteFrameStartMS +{ +} + +- (void)updateFrameWithClockOffset:(NSTimeInterval)offset +{ + // Simulate a clock change after scheduling. Only this thread sees the + // shifted time, and only while checking which timers are due. + IMP original = originalDateImplementation(); + wallClockOffset = offset; + method_setImplementation(dateMethod(), reinterpret_cast(dateWithClockOffset)); + @try { + [_timing didUpdateFrame:nil]; + } @finally { + method_setImplementation(dateMethod(), original); + wallClockOffset = 0; + } +} + +- (void)testClockMovingForwardDoesNotFirePendingTimeoutEarly +{ + [_timing createTimerForNextFrame:@1 duration:60 jsSchedulingTime:nil repeats:NO]; + + [self updateFrameWithClockOffset:kClockJump]; + + XCTAssertEqual(_callbacks.count, 0u); +} + +- (void)testClockMovingBackwardDoesNotDelayDueTimeout +{ + [_timing createTimerForNextFrame:@1 duration:kTimerDelay jsSchedulingTime:nil repeats:NO]; + [NSThread sleepForTimeInterval:2 * kTimerDelay]; + + [self updateFrameWithClockOffset:-kClockJump]; + + XCTAssertEqualObjects(_callbacks, (@[ @1 ])); +} + +- (void)testClockMovingBackwardDoesNotDelayRescheduledInterval +{ + [_timing createTimerForNextFrame:@1 duration:kTimerDelay jsSchedulingTime:nil repeats:YES]; + [NSThread sleepForTimeInterval:2 * kTimerDelay]; + [_timing didUpdateFrame:nil]; + XCTAssertEqualObjects(_callbacks, (@[ @1 ])); + + [NSThread sleepForTimeInterval:2 * kTimerDelay]; + [self updateFrameWithClockOffset:-kClockJump]; + + XCTAssertEqualObjects(_callbacks, (@[ @1, @1 ])); +} + +@end