From 22921311a812b12b0a4a668da4f0c25faae388bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Tue, 10 Nov 2026 14:29:48 +0100 Subject: [PATCH] Throttle iOS download progress events by time The RCTFrameUpdateObserver throttle never runs on the New Architecture, and f8171a1 worked around that by sending an event per network chunk, even when JS didn't ask for progress. Replace both with a 100 ms throttle in CodePushCore that never holds back the completed event. When the last chunk doesn't bring receivedBytes up to totalBytes (no Content-Length, e.g. gzip, or a mismatched one), the download handler now sends a final event with totalBytes set to the received size. Before, a response without Content-Length ended on totalBytes -1, so JS never saw receivedBytes == totalBytes. Co-Authored-By: Claude Opus 5.5 --- ios/CodePush/CodePush.m | 78 ++++--------------- ios/CodePush/CodePushDownloadHandler.m | 17 ++-- .../Download/ProgressThrottle.swift | 34 ++++++++ .../ProgressThrottleTests.swift | 77 ++++++++++++++++++ 4 files changed, 137 insertions(+), 69 deletions(-) create mode 100644 ios/CodePushCore/Sources/CodePushCore/Download/ProgressThrottle.swift create mode 100644 ios/CodePushCore/Tests/CodePushCoreTests/ProgressThrottleTests.swift diff --git a/ios/CodePush/CodePush.m b/ios/CodePush/CodePush.m index e9225f02..2d5840a9 100644 --- a/ios/CodePush/CodePush.m +++ b/ios/CodePush/CodePush.m @@ -18,7 +18,15 @@ #import "CodePush.h" #import "CodePushErrorUtils.h" -@interface CodePush () +#if SWIFT_PACKAGE +@import CodePushCore; +#elif __has_include() +#import +#else +#import "CodePush-Swift.h" +#endif + +@interface CodePush () @end @implementation CodePush { @@ -29,11 +37,6 @@ @implementation CodePush { CodePushInstallMode _installMode; NSTimer *_appSuspendTimer; - // Used to coordinate the dispatching of download progress events to JS. - long long _latestExpectedContentLength; - long long _latestReceivedConentLength; - BOOL _didUpdateProgress; - BOOL _allowed; BOOL _restartInProgress; NSMutableArray *_restartQueue; @@ -291,18 +294,6 @@ + (void)setUsingTestConfiguration:(BOOL)shouldUseTestConfiguration #pragma mark - Private API methods @synthesize methodQueue = _methodQueue; -@synthesize pauseCallback = _pauseCallback; -@synthesize paused = _paused; - -- (void)setPaused:(BOOL)paused -{ - if (_paused != paused) { - _paused = paused; - if (_pauseCallback) { - _pauseCallback(); - } - } -} /* * This method is used to clear updates that are installed @@ -361,15 +352,9 @@ - (void)dealloc [[NSNotificationCenter defaultCenter] removeObserver:self]; } -- (void)dispatchDownloadProgressEvent { - // Notify the script-side about the progress - [self sendEventWithName:DownloadProgressEvent - body:@{ - @"totalBytes" : [NSNumber - numberWithLongLong:_latestExpectedContentLength], - @"receivedBytes" : [NSNumber - numberWithLongLong:_latestReceivedConentLength] - }]; +- (void)dispatchDownloadProgressEvent:(long long)expectedBytes receivedBytes:(long long)receivedBytes { + [self sendEventWithName:DownloadProgressEvent + body:@{ @"totalBytes" : @(expectedBytes), @"receivedBytes" : @(receivedBytes) }]; } /* @@ -521,7 +506,6 @@ - (void)initializeUpdateAfterRestart #ifdef DEBUG [self clearDebugUpdates]; #endif - self.paused = YES; NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults]; NSDictionary *pendingUpdate = [preferences objectForKey:PendingUpdateKey]; if (pendingUpdate) { @@ -875,12 +859,9 @@ -(void)loadBundleOnTick:(NSTimer *)timer { forKey:BinaryBundleDateKey]; } - if (notifyProgress) { - // Set up and unpause the frame observer so that it can emit - // progress events every frame if the progress is updated. - _didUpdateProgress = NO; - self.paused = NO; - } + // The network reports progress once per chunk, which can be hundreds of times per second. + // All download callbacks run on _methodQueue, so the throttle needs no locking. + CPProgressThrottle *progressThrottle = notifyProgress ? [CPProgressThrottle new] : nil; NSString * publicKey = [[CodePushConfig current] publicKey]; @@ -891,18 +872,9 @@ -(void)loadBundleOnTick:(NSTimer *)timer { operationQueue:_methodQueue // The download is progressing forward progressCallback:^(long long expectedContentLength, long long receivedContentLength) { - // Update the download progress so that the frame observer can notify the JS side - _latestExpectedContentLength = expectedContentLength; - _latestReceivedConentLength = receivedContentLength; - _didUpdateProgress = YES; - - // If the download is completed, stop observing frame - // updates and synchronously send the last event. - if (expectedContentLength == receivedContentLength) { - _didUpdateProgress = NO; - self.paused = YES; + if ([progressThrottle shouldSendExpectedBytes:expectedContentLength receivedBytes:receivedContentLength]) { + [self dispatchDownloadProgressEvent:expectedContentLength receivedBytes:receivedContentLength]; } - [self dispatchDownloadProgressEvent]; } // The download completed doneCallback:^{ @@ -922,10 +894,6 @@ -(void)loadBundleOnTick:(NSTimer *)timer { if ([CodePushErrorUtils isCodePushError:err]) { [[self class] saveFailedUpdate:mutableUpdatePackage]; } - - // Stop observing frame updates if the download fails. - _didUpdateProgress = NO; - self.paused = YES; reject([NSString stringWithFormat: @"%lu", (long)err.code], err.localizedDescription, err); }]; } @@ -1278,16 +1246,4 @@ - (void)restartAppInternal:(BOOL)onlyIfUpdateIsPending [CodePushTelemetryManager saveStatusReportForRetry:statusReport]; } -#pragma mark - RCTFrameUpdateObserver Methods - -- (void)didUpdateFrame:(RCTFrameUpdate *)update -{ - if (!_didUpdateProgress) { - return; - } - - [self dispatchDownloadProgressEvent]; - _didUpdateProgress = NO; -} - @end diff --git a/ios/CodePush/CodePushDownloadHandler.m b/ios/CodePush/CodePushDownloadHandler.m index 34400430..2c26e63b 100644 --- a/ios/CodePush/CodePushDownloadHandler.m +++ b/ios/CodePush/CodePushDownloadHandler.m @@ -30,14 +30,9 @@ - (void)download:(NSString *)url { NSURLConnection *connection = [[NSURLConnection alloc] initWithRequest:request delegate:self startImmediately:NO]; - if ([NSOperationQueue instancesRespondToSelector:@selector(setUnderlyingQueue:)]) { - NSOperationQueue *delegateQueue = [NSOperationQueue new]; - delegateQueue.underlyingQueue = self.operationQueue; - [connection setDelegateQueue:delegateQueue]; - } else { - [connection scheduleInRunLoop:[NSRunLoop mainRunLoop] - forMode:NSDefaultRunLoopMode]; - } + NSOperationQueue *delegateQueue = [NSOperationQueue new]; + delegateQueue.underlyingQueue = self.operationQueue; + [connection setDelegateQueue:delegateQueue]; [connection start]; } @@ -125,6 +120,12 @@ - (void)connectionDidFinishLoading:(NSURLConnection *)connection { // We should have received all of the bytes if this is called. assert(self.receivedContentLength == self.expectedContentLength); } + // Progress consumers rely on the last event having received == expected to send it unthrottled. + // Asserts are compiled out in release, so a mismatched Content-Length also lands here. + if (self.receivedContentLength != self.expectedContentLength) { + self.expectedContentLength = self.receivedContentLength; + self.progressCallback(self.expectedContentLength, self.receivedContentLength); + } BOOL isZip = _header[0] == 'P' && _header[1] == 'K' && _header[2] == 3 && _header[3] == 4; CPLog(@"Downloaded update package. httpStatus=%ld bytes=%lld zip=%@", (long)self.statusCode, self.receivedContentLength, isZip ? @"true" : @"false"); diff --git a/ios/CodePushCore/Sources/CodePushCore/Download/ProgressThrottle.swift b/ios/CodePushCore/Sources/CodePushCore/Download/ProgressThrottle.swift new file mode 100644 index 00000000..a5a47685 --- /dev/null +++ b/ios/CodePushCore/Sources/CodePushCore/Download/ProgressThrottle.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Limits how often download progress reaches JS. +/// +/// One instance tracks one download. It isn't thread-safe: call it from one serial queue. +@objc(CPProgressThrottle) +public final class ProgressThrottle: NSObject { + private let interval: TimeInterval + private let now: () -> TimeInterval + private var lastSendTime: TimeInterval? + + init(interval: TimeInterval, now: @escaping () -> TimeInterval) { + self.interval = interval + self.now = now + } + + @objc public override convenience init() { + // Not systemUptime: that's a required-reason API, and the throttle doesn't need a monotonic clock. + self.init(interval: 0.1, now: { Date().timeIntervalSinceReferenceDate }) + } + + /// The downloader's last event always has `receivedBytes == expectedBytes`, so it's never held + /// back. The download's done callback only runs after unzipping and verifying the update. + @objc(shouldSendExpectedBytes:receivedBytes:) + public func shouldSend(expectedBytes: Int64, receivedBytes: Int64) -> Bool { + let time = now() + // The wall clock can jump backwards. Sending restarts the interval instead of stalling. + if receivedBytes != expectedBytes, let lastSendTime, (0..