react-native-update 10.51.1 → 10.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,6 +47,10 @@ static NSString *const keyNativeConfig = @"REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY
47
47
  // Raw response cache written by the native cold-start check for the JS side
48
48
  // to reuse (§10.3), scoped to the request and config that produced it.
49
49
  static NSString *const keyNativeCheckCache = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY";
50
+ // Set when a native check round starts, cleared when it ends (§11.4). Residue
51
+ // on the next launch means the previous process died mid-round (a crash
52
+ // rescue was truncated): that launch resumes immediately instead of waiting.
53
+ static NSString *const keyNativeCheckIncomplete = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY";
50
54
  static NSString *const PushyErrorDomain = @"cn.reactnative.pushy";
51
55
 
52
56
  // file def
@@ -471,7 +475,9 @@ static void PushySwitchVersionLocked(NSString *hash) {
471
475
  // launch. Decisions come from cpp/update_flow_core; this class is IO glue.
472
476
  @interface RCTPushyOrchestrator : NSObject
473
477
  + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion;
474
- + (void)runOnce:(NSString *)launchRolledBackVersion;
478
+ + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime;
479
+ + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime;
480
+ + (void)runRescueWithDeadline:(NSTimeInterval)deadlineUptime;
475
481
  + (BOOL)commitRoundWithGeneration:(uint64_t)generation
476
482
  hashInfo:(NSDictionary *)hashInfoEntry
477
483
  activate:(NSString *)hashToActivate
@@ -481,6 +487,102 @@ static void PushySwitchVersionLocked(NSString *hash) {
481
487
  responseAt:(long long)responseAtSeconds;
482
488
  @end
483
489
 
490
+ // One round per process, whoever starts it first — the delayed cold-start
491
+ // path or the crash-rescue thread (§11.3). The semaphore lets the rescue
492
+ // wait out an in-flight round instead of racing it.
493
+ static std::atomic<bool> pushyRoundStarted{false};
494
+ static std::atomic<bool> pushyRoundCompleted{false};
495
+ // Flipped the moment a crash is being held. JS is dead from that point on,
496
+ // so there is no second decision maker: the round force-activates whatever
497
+ // it downloads (§11.3).
498
+ static std::atomic<bool> pushyCrashRescueActive{false};
499
+ static std::atomic<bool> pushyRescueAttempted{false};
500
+ static dispatch_semaphore_t pushyRoundDone;
501
+ static NSString *pushyLaunchRolledBackForRescue = nil;
502
+ // A version this process downloaded but left for JS to activate. If the
503
+ // process then crashes, JS will never activate it — the crash handler
504
+ // activates it directly (bounded local work, no network). The generation is
505
+ // the one the round committed under: a reset that lands afterwards bumps it,
506
+ // and the late activation must lose to that reset exactly like the round
507
+ // itself would. Guarded by @synchronized (RCTPushyOrchestrator class).
508
+ static NSString *pushyUnactivatedHash = nil;
509
+ static uint64_t pushyUnactivatedGeneration = 0;
510
+ static NSUncaughtExceptionHandler *pushyPreviousExceptionHandler = NULL;
511
+ // ≈ process start: scheduleFromColdStart runs during the first bundleURL.
512
+ static NSTimeInterval pushyProcessAnchorUptime = 0;
513
+
514
+ static const NSTimeInterval kPushyRescueTriggerUptime = 60;
515
+ static const NSTimeInterval kPushyRescueBudgetBackgroundThread = 10;
516
+ // A held main thread freezes UI teardown; stay well under the watchdog.
517
+ static const NSTimeInterval kPushyRescueBudgetMainThread = 3.5;
518
+
519
+ static void PushyMaybeHoldForRescue(void) {
520
+ // Once per process; a second crashing thread passes straight through
521
+ // instead of waiting behind the first (§11.3: prefer under-rescuing over
522
+ // wedging the teardown).
523
+ bool expected = false;
524
+ if (!pushyRescueAttempted.compare_exchange_strong(expected, true)) {
525
+ return;
526
+ }
527
+ NSTimeInterval uptime = PushyMonotonicNow() - pushyProcessAnchorUptime;
528
+ bool roundInFlight = pushyRoundStarted.load() && !pushyRoundCompleted.load();
529
+ // Early crashes are the brick signature; a crash with an in-flight round
530
+ // is worth finishing regardless of uptime. Everything else is an ordinary
531
+ // crash whose UX must not be delayed.
532
+ if (uptime >= kPushyRescueTriggerUptime && !roundInFlight) {
533
+ return;
534
+ }
535
+ NSTimeInterval budget = [NSThread isMainThread]
536
+ ? kPushyRescueBudgetMainThread
537
+ : kPushyRescueBudgetBackgroundThread;
538
+ NSTimeInterval deadline = PushyMonotonicNow() + budget;
539
+ NSLog(@"RCTPushy -- crash rescue: holding process for up to %.1fs "
540
+ @"(uptime %.1fs)", budget, uptime);
541
+
542
+ // The rescue runs on its own queue and the dying thread only waits with a
543
+ // hard timeout: even a deadlocked rescue (a thread that died holding a
544
+ // lock — uncaught ObjC exceptions skip @finally) can only delay the
545
+ // process death, never prevent it.
546
+ dispatch_semaphore_t done = dispatch_semaphore_create(0);
547
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
548
+ @try {
549
+ [RCTPushyOrchestrator runRescueWithDeadline:deadline];
550
+ } @catch (NSException *exception) {
551
+ NSLog(@"RCTPushy -- crash rescue failed: %@", exception.reason);
552
+ }
553
+ dispatch_semaphore_signal(done);
554
+ });
555
+ if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW,
556
+ (int64_t)(budget * NSEC_PER_SEC))) != 0) {
557
+ NSLog(@"RCTPushy -- crash rescue: budget exhausted, letting go");
558
+ }
559
+ }
560
+
561
+ // Crash-moment brick rescue (§11): when the app is dying of an uncaught
562
+ // exception during startup (RCTFatal raises NSException in release), the
563
+ // process is still alive and JS will never run again — a natural,
564
+ // false-positive-free window to finish the cold-start check. The previous
565
+ // handler (crash reporters chain the same way) always runs afterwards.
566
+ static void PushyCrashRescueExceptionHandler(NSException *exception) {
567
+ @try {
568
+ PushyMaybeHoldForRescue();
569
+ } @catch (NSException *inner) {
570
+ // The dying process owes the previous handler its turn no matter
571
+ // what the rescue did.
572
+ }
573
+ if (pushyPreviousExceptionHandler != NULL) {
574
+ pushyPreviousExceptionHandler(exception);
575
+ }
576
+ }
577
+
578
+ static void PushyInstallCrashRescueHandler(void) {
579
+ static dispatch_once_t onceToken;
580
+ dispatch_once(&onceToken, ^{
581
+ pushyPreviousExceptionHandler = NSGetUncaughtExceptionHandler();
582
+ NSSetUncaughtExceptionHandler(&PushyCrashRescueExceptionHandler);
583
+ });
584
+ }
585
+
484
586
  // Shared by the getBundleHash RCT method and the native cold-start check.
485
587
  // Returns @"" when unknown; blocking (sha256 of the embedded bundle on first
486
588
  // call per install, cached afterwards) — call off the main thread.
@@ -1300,6 +1402,11 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1300
1402
  progressHandler:nil
1301
1403
  completionHandler:^(NSString *archivePath, BOOL succeeded, NSError *error) {
1302
1404
  [fileManager removeItemAtPath:archivePath error:nil];
1405
+ // The resume sidecar dies with its archive (§11.4): whether the
1406
+ // unzip consumed it or classified it as poisoned, nothing must
1407
+ // survive to vouch for bytes that are gone.
1408
+ [fileManager removeItemAtPath:[archivePath stringByAppendingString:@".resume"]
1409
+ error:nil];
1303
1410
  if (completionHandler == nil) {
1304
1411
  return;
1305
1412
  }
@@ -1471,21 +1578,115 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1471
1578
  #if !DEBUG
1472
1579
  // Once per process; a few seconds of delay keeps the check away from the
1473
1580
  // cold-start critical path (§7 R5) — its result targets the NEXT launch.
1581
+ // Unless the previous process died mid-round (residual incomplete
1582
+ // marker), in which case every launch second counts (§11.4).
1474
1583
  static dispatch_once_t onceToken;
1475
1584
  dispatch_once(&onceToken, ^{
1476
- dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)),
1585
+ pushyRoundDone = dispatch_semaphore_create(0);
1586
+ pushyProcessAnchorUptime = PushyMonotonicNow();
1587
+ pushyLaunchRolledBackForRescue = [launchRolledBackVersion copy];
1588
+ NSUserDefaults *defaults = PushyDefaults();
1589
+ // The crash-hold rescue shares the orchestrator's rollout gate: no
1590
+ // persisted config, no handler (§11.3).
1591
+ if ([defaults stringForKey:keyNativeConfig].length > 0) {
1592
+ PushyInstallCrashRescueHandler();
1593
+ }
1594
+ int64_t delaySeconds =
1595
+ [defaults objectForKey:keyNativeCheckIncomplete] != nil ? 0 : 5;
1596
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delaySeconds * NSEC_PER_SEC),
1477
1597
  dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
1478
- @try {
1479
- [self runOnce:launchRolledBackVersion];
1480
- } @catch (NSException *exception) {
1481
- // The rescue path must never take the app down with it.
1482
- RCTLogWarn(@"RCTPushy -- native check crashed: %@", exception.reason);
1483
- }
1598
+ [self startRoundWithDeadline:0];
1484
1599
  });
1485
1600
  });
1486
1601
  #endif
1487
1602
  }
1488
1603
 
1604
+ // Runs the process's single round on the calling thread if nobody has
1605
+ // started it yet. deadlineUptime > 0 (crash rescue) caps every HTTP call and
1606
+ // download phase to the remaining budget.
1607
+ + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime {
1608
+ bool expected = false;
1609
+ if (!pushyRoundStarted.compare_exchange_strong(expected, true)) {
1610
+ return;
1611
+ }
1612
+ @try {
1613
+ [self runOnce:pushyLaunchRolledBackForRescue deadline:deadlineUptime];
1614
+ } @catch (NSException *exception) {
1615
+ // The rescue path must never take the app down with it.
1616
+ RCTLogWarn(@"RCTPushy -- native check crashed: %@", exception.reason);
1617
+ } @finally {
1618
+ pushyRoundCompleted.store(true);
1619
+ dispatch_semaphore_signal(pushyRoundDone);
1620
+ }
1621
+ }
1622
+
1623
+ // Crash-rescue entry (§11.3), called from the handler's worker queue while
1624
+ // the uncaught-exception handler holds the dying process. Ensures this
1625
+ // process's round runs to completion within the budget, then activates a
1626
+ // downloaded-but-unactivated version if one exists — the last chance before
1627
+ // the process is gone.
1628
+ + (void)runRescueWithDeadline:(NSTimeInterval)deadlineUptime {
1629
+ pushyCrashRescueActive.store(true);
1630
+ [self startRoundWithDeadline:deadlineUptime];
1631
+ if (!pushyRoundCompleted.load()) {
1632
+ NSTimeInterval remaining = deadlineUptime - PushyMonotonicNow();
1633
+ if (remaining > 0) {
1634
+ dispatch_semaphore_wait(pushyRoundDone, dispatch_time(DISPATCH_TIME_NOW,
1635
+ (int64_t)(remaining * NSEC_PER_SEC)));
1636
+ }
1637
+ }
1638
+ [self activatePendingVersion];
1639
+ }
1640
+
1641
+ // The alert-strategy variant of the §10.7 hole: the round downloaded a fix
1642
+ // but deferred activation to JS, and JS is now dead. Activation is local and
1643
+ // bounded (a state switch under the commit lock).
1644
+ + (void)activatePendingVersion {
1645
+ NSString *hash = nil;
1646
+ uint64_t generation = 0;
1647
+ @synchronized (self) {
1648
+ hash = pushyUnactivatedHash;
1649
+ generation = pushyUnactivatedGeneration;
1650
+ }
1651
+ if (hash == nil) {
1652
+ return;
1653
+ }
1654
+ // Unlike Android's switchVersion, PushySwitchVersionLocked does not
1655
+ // validate the version directory; never point the next launch at bytes
1656
+ // that are gone (e.g. wiped by a reset whose generation bump we would
1657
+ // catch below anyway, or by cleanup).
1658
+ NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash];
1659
+ if (!PushyHasCompletedVersionAtPath(versionDir)) {
1660
+ NSLog(@"RCTPushy -- crash rescue: version %@ no longer on disk, dropping activation", hash);
1661
+ return;
1662
+ }
1663
+ NSDictionary *hashInfoEntry = nil;
1664
+ NSString *existingInfo = [PushyDefaults() stringForKey:PushyHashInfoKey(hash)];
1665
+ NSData *existingData = [existingInfo dataUsingEncoding:NSUTF8StringEncoding];
1666
+ id parsed = existingData == nil ? nil :
1667
+ [NSJSONSerialization JSONObjectWithData:existingData options:0 error:nil];
1668
+ if ([parsed isKindOfClass:[NSDictionary class]]) {
1669
+ NSMutableDictionary *merged = [parsed mutableCopy];
1670
+ merged[@"crashRescue"] = @YES;
1671
+ hashInfoEntry = @{@"hash": hash, @"info": merged};
1672
+ }
1673
+ BOOL committed = [self commitRoundWithGeneration:generation
1674
+ hashInfo:hashInfoEntry
1675
+ activate:hash
1676
+ responseText:nil
1677
+ request:nil
1678
+ config:nil
1679
+ responseAt:0];
1680
+ if (committed) {
1681
+ @synchronized (self) {
1682
+ pushyUnactivatedHash = nil;
1683
+ }
1684
+ NSLog(@"RCTPushy -- crash rescue: activated downloaded version %@", hash);
1685
+ } else {
1686
+ NSLog(@"RCTPushy -- crash rescue: reset since download, dropping activation");
1687
+ }
1688
+ }
1689
+
1489
1690
  // A bare module instance drives the existing download/patch pipeline without
1490
1691
  // a bridge: the file queue is process-global and progress events are gated on
1491
1692
  // hasListeners (never set without a bridge).
@@ -1498,7 +1699,7 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1498
1699
  return engine;
1499
1700
  }
1500
1701
 
1501
- + (void)runOnce:(NSString *)launchRolledBackVersion {
1702
+ + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime {
1502
1703
  // Sampled before any IO: resetToPackagedBundle bumps it, and a reset that
1503
1704
  // lands while this round is running must win over the round's decision.
1504
1705
  const uint64_t resetGeneration = pushyResetGeneration.load();
@@ -1518,6 +1719,28 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1518
1719
  if (appKey.length == 0) {
1519
1720
  return;
1520
1721
  }
1722
+ // From here on the round does real work: leave the breadcrumb that the
1723
+ // next launch reads to skip its 5s delay if we die mid-round (§11.4).
1724
+ [defaults setObject:@YES forKey:keyNativeCheckIncomplete];
1725
+ @try {
1726
+ [self runConfiguredRound:config
1727
+ configJson:configJson
1728
+ appKey:appKey
1729
+ launchRolledBackVersion:launchRolledBackVersion
1730
+ resetGeneration:resetGeneration
1731
+ deadline:deadlineUptime];
1732
+ } @finally {
1733
+ [defaults removeObjectForKey:keyNativeCheckIncomplete];
1734
+ }
1735
+ }
1736
+
1737
+ + (void)runConfiguredRound:(const flowjson::Value &)config
1738
+ configJson:(NSString *)configJson
1739
+ appKey:(NSString *)appKey
1740
+ launchRolledBackVersion:(NSString *)launchRolledBackVersion
1741
+ resetGeneration:(uint64_t)resetGeneration
1742
+ deadline:(NSTimeInterval)deadlineUptime {
1743
+ NSUserDefaults *defaults = PushyDefaults();
1521
1744
  NSString *packageVersion =
1522
1745
  PushyFromStdString(config.Get("packageVersion").AsString());
1523
1746
  if (packageVersion.length == 0) {
@@ -1573,7 +1796,10 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1573
1796
  return;
1574
1797
  }
1575
1798
 
1576
- NSString *responseText = [self runCheckRequest:config appKey:appKey body:body];
1799
+ NSString *responseText = [self runCheckRequest:config
1800
+ appKey:appKey
1801
+ body:body
1802
+ deadline:deadlineUptime];
1577
1803
  if (responseText == nil) {
1578
1804
  RCTLogInfo(@"RCTPushy -- native check: no endpoint reachable, giving up until next launch");
1579
1805
  return;
@@ -1605,7 +1831,8 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1605
1831
  if (!downloaded) {
1606
1832
  downloaded = [self performAttempts:decision.Get("attempts")
1607
1833
  hash:hash
1608
- originHash:currentVersion];
1834
+ originHash:currentVersion
1835
+ deadline:deadlineUptime];
1609
1836
  }
1610
1837
  if (!downloaded) {
1611
1838
  [self commitRoundWithGeneration:resetGeneration
@@ -1627,10 +1854,22 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1627
1854
  versionInfo[@(key)] = PushyFromStdString(info.Get(key).AsString()) ?: @"";
1628
1855
  }
1629
1856
  }
1857
+ // A forceBoot activation is the brick-rescue path: mark it in the
1858
+ // persisted info so JS can report force_boot_rescue when this version
1859
+ // survives to markSuccess. Only the server-sent directive counts — a
1860
+ // silent-strategy activation is ordinary delivery.
1861
+ if (info.Get("config").Get("forceBoot").Truthy()) {
1862
+ versionInfo[@"forceBootRescue"] = @YES;
1863
+ }
1864
+ if (pushyCrashRescueActive.load()) {
1865
+ versionInfo[@"crashRescue"] = @YES;
1866
+ }
1630
1867
  // Silent strategies or a server-marked forceBoot version (per-version
1631
1868
  // remote override — the brick rescue) activate for the next launch;
1632
- // otherwise activation stays with the JS side (§6/§10.1).
1633
- BOOL activate = decision.Get("activate").Truthy();
1869
+ // otherwise activation stays with the JS side (§6/§10.1). Unless a crash
1870
+ // is being held: JS is dead, deferring to it would leave the fix on disk
1871
+ // forever (§11.3).
1872
+ BOOL activate = decision.Get("activate").Truthy() || pushyCrashRescueActive.load();
1634
1873
  BOOL committed = [self commitRoundWithGeneration:resetGeneration
1635
1874
  hashInfo:@{@"hash": hash, @"info": versionInfo}
1636
1875
  activate:activate ? hash : nil
@@ -1641,8 +1880,17 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1641
1880
  if (!committed) {
1642
1881
  RCTLogInfo(@"RCTPushy -- native check: reset during round, dropping result");
1643
1882
  } else if (activate) {
1883
+ @synchronized (self) {
1884
+ pushyUnactivatedHash = nil;
1885
+ }
1644
1886
  RCTLogInfo(@"RCTPushy -- native check: downloaded %@ and set for next launch", hash);
1645
1887
  } else {
1888
+ // Remembered so a crash later in this process can still activate it
1889
+ // (activatePendingVersion) — JS never will.
1890
+ @synchronized (self) {
1891
+ pushyUnactivatedHash = [hash copy];
1892
+ pushyUnactivatedGeneration = resetGeneration;
1893
+ }
1646
1894
  RCTLogInfo(@"RCTPushy -- native check: downloaded %@, activation left to JS", hash);
1647
1895
  }
1648
1896
  }
@@ -1659,13 +1907,18 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1659
1907
  request:(NSString *)requestBody
1660
1908
  config:(NSString *)configJson
1661
1909
  responseAt:(long long)responseAtSeconds {
1662
- NSDictionary *cacheEntry = @{
1663
- @"ts": @(responseAtSeconds),
1664
- @"body": responseText,
1665
- @"request": requestBody,
1666
- @"config": configJson,
1667
- };
1668
- NSData *cacheData = [NSJSONSerialization dataWithJSONObject:cacheEntry options:0 error:nil];
1910
+ // responseText is nil for the crash handler's activation-only commit
1911
+ // (activatePendingVersion): no round ran, so there is no cache to write.
1912
+ NSData *cacheData = nil;
1913
+ if (responseText != nil) {
1914
+ NSDictionary *cacheEntry = @{
1915
+ @"ts": @(responseAtSeconds),
1916
+ @"body": responseText,
1917
+ @"request": requestBody,
1918
+ @"config": configJson,
1919
+ };
1920
+ cacheData = [NSJSONSerialization dataWithJSONObject:cacheEntry options:0 error:nil];
1921
+ }
1669
1922
  __block BOOL committed = NO;
1670
1923
  PushyWithStateLock(^{
1671
1924
  if (pushyResetGeneration.load() != generation) {
@@ -1697,9 +1950,19 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1697
1950
  // time with its own timeout; after the configured round fails, queryUrls
1698
1951
  // discovery merges remote candidates (excluding the already-tried) for one
1699
1952
  // more round. No hedged race on purpose — this path is latency-insensitive.
1953
+ // Per-request timeout, capped to the crash-rescue budget when one is active.
1954
+ // <= 0 means the budget is gone and the round must stop issuing requests.
1955
+ static NSTimeInterval PushyCheckRequestTimeout(NSTimeInterval deadlineUptime) {
1956
+ if (deadlineUptime <= 0) {
1957
+ return 10;
1958
+ }
1959
+ return MIN(10, deadlineUptime - PushyMonotonicNow());
1960
+ }
1961
+
1700
1962
  + (NSString *)runCheckRequest:(const flowjson::Value &)config
1701
1963
  appKey:(NSString *)appKey
1702
- body:(NSString *)body {
1964
+ body:(NSString *)body
1965
+ deadline:(NSTimeInterval)deadlineUptime {
1703
1966
  double sample = arc4random() / 4294967296.0;
1704
1967
  flowjson::Value ordered =
1705
1968
  updateflow::OrderEndpointCandidates(config.Get("endpoints"), sample);
@@ -1716,9 +1979,13 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1716
1979
  return nil;
1717
1980
  }
1718
1981
  [tried addObject:base];
1982
+ NSTimeInterval timeout = PushyCheckRequestTimeout(deadlineUptime);
1983
+ if (timeout <= 0) {
1984
+ return nil;
1985
+ }
1719
1986
  NSString *response = PushyHttpRequest(
1720
1987
  [NSString stringWithFormat:@"%@/checkUpdate/%@", base, appKey],
1721
- @"POST", body, 10);
1988
+ @"POST", body, timeout);
1722
1989
  if (PushyIsValidCheckResponse(response)) {
1723
1990
  return response;
1724
1991
  }
@@ -1731,7 +1998,11 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1731
1998
  if (httpAttempts++ >= maxHttpAttempts) {
1732
1999
  return nil;
1733
2000
  }
1734
- NSString *listText = PushyHttpRequest(listUrl, @"GET", nil, 10);
2001
+ NSTimeInterval listTimeout = PushyCheckRequestTimeout(deadlineUptime);
2002
+ if (listTimeout <= 0) {
2003
+ return nil;
2004
+ }
2005
+ NSString *listText = PushyHttpRequest(listUrl, @"GET", nil, listTimeout);
1735
2006
  if (listText == nil) {
1736
2007
  continue;
1737
2008
  }
@@ -1750,9 +2021,13 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1750
2021
  return nil;
1751
2022
  }
1752
2023
  [tried addObject:base];
2024
+ NSTimeInterval timeout = PushyCheckRequestTimeout(deadlineUptime);
2025
+ if (timeout <= 0) {
2026
+ return nil;
2027
+ }
1753
2028
  NSString *response = PushyHttpRequest(
1754
2029
  [NSString stringWithFormat:@"%@/checkUpdate/%@", base, appKey],
1755
- @"POST", body, 10);
2030
+ @"POST", body, timeout);
1756
2031
  if (PushyIsValidCheckResponse(response)) {
1757
2032
  return response;
1758
2033
  }
@@ -1764,9 +2039,14 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1764
2039
 
1765
2040
  + (BOOL)performAttempts:(const flowjson::Value &)attempts
1766
2041
  hash:(NSString *)hash
1767
- originHash:(NSString *)originHash {
2042
+ originHash:(NSString *)originHash
2043
+ deadline:(NSTimeInterval)rescueDeadline {
1768
2044
  RCTPushy *engine = [self engine];
2045
+ // Crash-rescue budget caps every phase; 0 keeps the normal 10min windows.
1769
2046
  NSTimeInterval incrementalDeadline = PushyMonotonicNow() + 600;
2047
+ if (rescueDeadline > 0) {
2048
+ incrementalDeadline = MIN(incrementalDeadline, rescueDeadline);
2049
+ }
1770
2050
  NSTimeInterval fullDeadline = 0;
1771
2051
  for (const auto &attempt : attempts.elements()) {
1772
2052
  const std::string &type = attempt.Get("type").AsString();
@@ -1780,6 +2060,9 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1780
2060
  if (isFullAttempt && fullDeadline == 0) {
1781
2061
  // diff/pdiff cannot starve the last-resort full download.
1782
2062
  fullDeadline = PushyMonotonicNow() + 600;
2063
+ if (rescueDeadline > 0) {
2064
+ fullDeadline = MIN(fullDeadline, rescueDeadline);
2065
+ }
1783
2066
  }
1784
2067
  NSTimeInterval deadline = isFullAttempt ? fullDeadline : incrementalDeadline;
1785
2068
  for (const auto &urlValue : attempt.Get("urls").elements()) {