@@ -2442,6 +2442,107 @@ - (void)testBackgroundTimeCheckLoopStoresTimeRemainingInLocalVariable {
24422442 [MPApplication_PRIVATE setMockApplication: nil ];
24432443}
24442444
2445+ - (void )testForegroundHandlerStopsBackgroundTimeRemainingCalls {
2446+ // Verify that when the app returns to foreground (simulated via
2447+ // cancelBackgroundTimeCheckLoop + endBackgroundTask, the same
2448+ // sequence as handleApplicationWillEnterForeground), the loop
2449+ // stops calling backgroundTimeRemaining. This is the scenario
2450+ // where the TOCTOU race was observed (2ms window in production).
2451+
2452+ __block NSInteger backgroundTimeRemainingCallCount = 0 ;
2453+
2454+ id mockApplication = OCMClassMock ([UIApplication class ]);
2455+ OCMStub ([mockApplication applicationState ]).andReturn (UIApplicationStateBackground);
2456+ OCMStub ([mockApplication backgroundTimeRemaining ]).andDo (^(NSInvocation *invocation) {
2457+ backgroundTimeRemainingCallCount++;
2458+ NSTimeInterval remaining = 25.0 ;
2459+ [invocation setReturnValue: &remaining];
2460+ });
2461+ OCMStub ([mockApplication beginBackgroundTaskWithExpirationHandler: OCMOCK_ANY]).andReturn ((UIBackgroundTaskIdentifier)42 );
2462+ OCMStub ([mockApplication endBackgroundTask: (UIBackgroundTaskIdentifier)42 ]);
2463+
2464+ [MPApplication_PRIVATE setMockApplication: mockApplication];
2465+
2466+ // Start the background task
2467+ XCTestExpectation *taskStarted = [self expectationWithDescription: @" Background task started" ];
2468+ [self .backendController beginBackgroundTask ];
2469+ dispatch_async (dispatch_get_main_queue (), ^{
2470+ [taskStarted fulfill ];
2471+ });
2472+ [self waitForExpectations: @[taskStarted] timeout: 2.0 ];
2473+
2474+ // Start the background time check loop
2475+ dispatch_async (messageQueue, ^{
2476+ [self .backendController beginBackgroundTimeCheckLoop ];
2477+ });
2478+
2479+ // Let the loop run a few iterations
2480+ [[NSRunLoop mainRunLoop ] runUntilDate: [NSDate dateWithTimeIntervalSinceNow: 3.0 ]];
2481+ NSInteger callsBeforeForeground = backgroundTimeRemainingCallCount;
2482+ XCTAssertGreaterThan (callsBeforeForeground, 0 , @" Loop should have called backgroundTimeRemaining at least once" );
2483+
2484+ // Simulate the foreground handler (same sequence as handleApplicationWillEnterForeground)
2485+ [self .backendController cancelBackgroundTimeCheckLoop ];
2486+ [self .backendController endBackgroundTask ];
2487+ [[NSRunLoop mainRunLoop ] runUntilDate: [NSDate dateWithTimeIntervalSinceNow: 0.5 ]];
2488+
2489+ // Record calls and wait to confirm no new calls arrive
2490+ NSInteger callsAtForeground = backgroundTimeRemainingCallCount;
2491+ [[NSRunLoop mainRunLoop ] runUntilDate: [NSDate dateWithTimeIntervalSinceNow: 3.0 ]];
2492+ NSInteger callsAfterForeground = backgroundTimeRemainingCallCount;
2493+
2494+ XCTAssertEqual (callsAfterForeground, callsAtForeground,
2495+ @" backgroundTimeRemaining should not be called after foreground handler. "
2496+ " Calls at foreground: %ld , calls after waiting: %ld " ,
2497+ (long )callsAtForeground, (long )callsAfterForeground);
2498+
2499+ XCTAssertEqual (self.backendController .backgroundCheckQueue .operationCount , 0 ,
2500+ @" Background check queue should have no running operations after foreground" );
2501+
2502+ [self .backendController cancelBackgroundTimeCheckLoop ];
2503+ [MPApplication_PRIVATE setMockApplication: nil ];
2504+ }
2505+
2506+ - (void )testBackgroundTimeRemainingIsAccessedOnMainThread {
2507+ __block BOOL allAccessesOnMainThread = YES ;
2508+
2509+ id mockApplication = OCMClassMock ([UIApplication class ]);
2510+
2511+ __block NSInteger stateCallCount = 0 ;
2512+ OCMStub ([mockApplication applicationState ]).andDo (^(NSInvocation *invocation) {
2513+ UIApplicationState state = (stateCallCount < 3 )
2514+ ? UIApplicationStateBackground
2515+ : UIApplicationStateActive;
2516+ stateCallCount++;
2517+ [invocation setReturnValue: &state];
2518+ });
2519+
2520+ OCMStub ([mockApplication backgroundTimeRemaining ]).andDo (^(NSInvocation *invocation) {
2521+ if (![NSThread isMainThread ]) {
2522+ allAccessesOnMainThread = NO ;
2523+ }
2524+ NSTimeInterval remaining = 25.0 ;
2525+ [invocation setReturnValue: &remaining];
2526+ });
2527+ OCMStub ([mockApplication beginBackgroundTaskWithExpirationHandler: OCMOCK_ANY]).andReturn ((UIBackgroundTaskIdentifier)42 );
2528+ OCMStub ([mockApplication endBackgroundTask: (UIBackgroundTaskIdentifier)42 ]);
2529+
2530+ [MPApplication_PRIVATE setMockApplication: mockApplication];
2531+
2532+ dispatch_async (messageQueue, ^{
2533+ [self .backendController beginBackgroundTimeCheckLoop ];
2534+ });
2535+
2536+ [[NSRunLoop mainRunLoop ] runUntilDate: [NSDate dateWithTimeIntervalSinceNow: 5.0 ]];
2537+
2538+ XCTAssertTrue (allAccessesOnMainThread,
2539+ @" backgroundTimeRemaining must be accessed on the main thread "
2540+ " to prevent XPC race conditions during app suspension" );
2541+
2542+ [self .backendController cancelBackgroundTimeCheckLoop ];
2543+ [MPApplication_PRIVATE setMockApplication: nil ];
2544+ }
2545+
24452546- (void )testEndSessionIfTimedOutDispatchesToMessageQueue {
24462547 // Verify that endSessionIfTimedOut called from a non-message-queue thread
24472548 // does not mutate session properties directly on that thread, but instead
0 commit comments