Skip to content

Commit ab867b6

Browse files
sunnylqmclaude
andcommitted
fix(ios): stop a late purge restore from activating behind the fallback
When +bundleURL's wait for the tvOS purged-version restore timed out, the launch fell back to the packaged bundle but the round kept its activation right: its commit only checked the reset generation. A check response can outlast the round deadline (the request timeout is an idle timeout and the wait backstop is timeout + 5s), and a version already complete on disk skips the download phase, so the round could switch the persisted state to that version while the packaged bundle was launching β€” JS could then read a currentVersion that is not the bundle it runs. The restore now owns an explicit window, guarded by the state lock every activation commits under. +bundleURL closes it when its wait ends (timeout or not) and always resolves the launch bundle again: a commit either landed before the close and launches, or sees the window closed and leaves the version to JS (a held crash may still activate it). purgeRestore is marked under the same lock, only on an activation that takes over the launch. The wait is now exactly the 12s budget. Also document that rescueSource reports the highest-priority marker when several apply, with a test for forceBootRescue + purgeRestore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4343a95 commit ab867b6

3 files changed

Lines changed: 82 additions & 30 deletions

File tree

β€Žios/RCTPushy/RCTPushy.mmβ€Ž

Lines changed: 73 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -754,7 +754,8 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation
754754
responseText:(NSString *)responseText
755755
request:(NSString *)requestBody
756756
config:(NSString *)configJson
757-
responseAt:(long long)responseAtSeconds;
757+
responseAt:(long long)responseAtSeconds
758+
activated:(BOOL *)activatedOut;
758759
@end
759760

760761
// One round per process, whoever starts it first β€” the delayed cold-start
@@ -767,9 +768,15 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation
767768
// it downloads (Β§11.3).
768769
static std::atomic<bool> pushyCrashRescueActive{false};
769770
static std::atomic<bool> pushyRescueAttempted{false};
770-
// Set when a launch blocks on reinstalling a version tvOS purged: the round
771-
// activates what it downloads, JS has not started to decide.
771+
// Set when this process's round is the tvOS purged-version restore.
772772
static std::atomic<bool> pushyPurgeRestoreActive{false};
773+
// True while +bundleURL waits for that restore. Only then may the round
774+
// activate what it downloads on its own (JS has not started to decide), since
775+
// the launch resolves the bundle again afterwards. Closed once the wait ends,
776+
// timeout or not; guarded by the state lock that every activation commits
777+
// under, so a commit lands either before the close (and launches) or after it
778+
// (and is left to JS) β€” never selected behind the packaged bundle's back.
779+
static bool pushyPurgeRestoreWindowOpen = false;
773780
static dispatch_semaphore_t pushyRoundDone;
774781
static NSString *pushyLaunchRolledBackForRescue = nil;
775782
// A version this process downloaded but left for JS to activate. If the
@@ -2374,12 +2381,13 @@ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion {
23742381

23752382
#if TARGET_OS_TV && !DEBUG
23762383
// Runs this process's round before the first bundle loads, blocking the
2377-
// caller for at most kPushyPurgeRestoreBudget, and activates whatever it
2378-
// installs. The state no longer has a current version, so the server answers
2379-
// with the latest version for the packaged bundle. Returns YES when the
2380-
// round finished in time β€” the caller then resolves the launch bundle again.
2381-
// The round stops at the same deadline, keeping a partial download for the
2382-
// resumable retry of the next check; the launch then uses the packaged bundle.
2384+
// caller for at most kPushyPurgeRestoreBudget. The state no longer has a
2385+
// current version, so the server answers with the latest version for the
2386+
// packaged bundle, and the round activates it while the restore window is
2387+
// open. Returns NO when no round can run; otherwise the caller must resolve
2388+
// the launch bundle again: it launches whatever was activated before the
2389+
// window closed, or the packaged bundle. The round stops at the same
2390+
// deadline, keeping a partial download for the next check to resume.
23832391
+ (BOOL)restorePurgedLaunch:(NSString *)purgedVersion
23842392
rolledBack:(NSString *)launchRolledBackVersion {
23852393
[self prepareProcess:launchRolledBackVersion];
@@ -2388,23 +2396,31 @@ + (BOOL)restorePurgedLaunch:(NSString *)purgedVersion
23882396
@"configured; launching the packaged bundle", purgedVersion);
23892397
return NO;
23902398
}
2399+
PushyWithStateLock(^{
2400+
pushyPurgeRestoreWindowOpen = true;
2401+
});
23912402
pushyPurgeRestoreActive.store(true);
23922403
NSTimeInterval deadline = PushyMonotonicNow() + kPushyPurgeRestoreBudget;
23932404
dispatch_semaphore_t done = dispatch_semaphore_create(0);
23942405
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
23952406
[self startRoundWithDeadline:deadline];
23962407
dispatch_semaphore_signal(done);
23972408
});
2398-
// The round honours the deadline itself; the extra second only covers
2399-
// its last commit.
2400-
if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW,
2401-
(int64_t)((kPushyPurgeRestoreBudget + 1) * NSEC_PER_SEC))) != 0) {
2402-
RCTLogWarn(@"RCTPushy -- purged version restore timed out; launching the packaged bundle");
2403-
return NO;
2409+
BOOL timedOut = dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW,
2410+
(int64_t)(kPushyPurgeRestoreBudget * NSEC_PER_SEC))) != 0;
2411+
// The round's deadline does not bound a slow response (the request
2412+
// timeout is an idle timeout) nor a version already on disk, so the close
2413+
// is what stops a late activation β€” not the deadline.
2414+
PushyWithStateLock(^{
2415+
pushyPurgeRestoreWindowOpen = false;
2416+
});
2417+
if (timedOut) {
2418+
RCTLogWarn(@"RCTPushy -- purged version %@ restore timed out", purgedVersion);
2419+
} else {
2420+
NSDictionary *result = pushyHostRoundResult;
2421+
RCTLogInfo(@"RCTPushy -- purged version %@ restore: %@ %@ %@", purgedVersion,
2422+
result[@"status"], result[@"reason"], result[@"hash"]);
24042423
}
2405-
NSDictionary *result = pushyHostRoundResult;
2406-
RCTLogInfo(@"RCTPushy -- purged version %@ restore: %@ %@ %@", purgedVersion,
2407-
result[@"status"], result[@"reason"], result[@"hash"]);
24082424
return YES;
24092425
}
24102426
#endif
@@ -2555,7 +2571,8 @@ + (void)activatePendingVersion {
25552571
responseText:nil
25562572
request:nil
25572573
config:nil
2558-
responseAt:0];
2574+
responseAt:0
2575+
activated:NULL];
25592576
if (committed) {
25602577
@synchronized (self) {
25612578
pushyUnactivatedHash = nil;
@@ -2705,7 +2722,8 @@ + (void)runConfiguredRound:(const flowjson::Value &)config
27052722
responseText:responseText
27062723
request:body
27072724
config:configJson
2708-
responseAt:responseAtSeconds];
2725+
responseAt:responseAtSeconds
2726+
activated:NULL];
27092727
pushyHostRoundResult = committed
27102728
? PushyHostResult(@"noUpdate", PushyFromStdString(decision.Get("reason").AsString()), nil, NO)
27112729
: PushyHostResult(@"cancelled", @"reset", nil, NO);
@@ -2734,7 +2752,8 @@ + (void)runConfiguredRound:(const flowjson::Value &)config
27342752
responseText:responseText
27352753
request:body
27362754
config:configJson
2737-
responseAt:responseAtSeconds];
2755+
responseAt:responseAtSeconds
2756+
activated:NULL];
27382757
pushyHostRoundResult = committed
27392758
? PushyHostResult(@"failed", @"download_failed", nil, NO)
27402759
: PushyHostResult(@"cancelled", @"reset", nil, NO);
@@ -2760,24 +2779,25 @@ + (void)runConfiguredRound:(const flowjson::Value &)config
27602779
if (pushyCrashRescueActive.load()) {
27612780
versionInfo[@"crashRescue"] = @YES;
27622781
}
2763-
if (pushyPurgeRestoreActive.load()) {
2764-
versionInfo[@"purgeRestore"] = @YES;
2765-
}
27662782
// Silent strategies or a server-marked forceBoot version (per-version
27672783
// remote override β€” the brick rescue) activate for the next launch;
27682784
// otherwise activation stays with the JS side (Β§6/Β§10.1). Unless a crash
27692785
// is being held: JS is dead, deferring to it would leave the fix on disk
27702786
// forever (Β§11.3). Or the launch is blocked on reinstalling a version
2771-
// tvOS purged: JS has not started, and the whole point is to boot it.
2787+
// tvOS purged: JS has not started, and the whole point is to boot it β€”
2788+
// commitRoundWithGeneration drops that one once the restore window closed.
27722789
BOOL activate = decision.Get("activate").Truthy() || pushyCrashRescueActive.load()
27732790
|| pushyPurgeRestoreActive.load();
2791+
BOOL activated = NO;
27742792
BOOL committed = [self commitRoundWithGeneration:resetGeneration
27752793
hashInfo:@{@"hash": hash, @"info": versionInfo}
27762794
activate:activate ? hash : nil
27772795
responseText:responseText
27782796
request:body
27792797
config:configJson
2780-
responseAt:responseAtSeconds];
2798+
responseAt:responseAtSeconds
2799+
activated:&activated];
2800+
activate = activated;
27812801
if (!committed) {
27822802
RCTLogInfo(@"RCTPushy -- native check: reset during round, dropping result");
27832803
} else if (activate) {
@@ -2810,7 +2830,8 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation
28102830
responseText:(NSString *)responseText
28112831
request:(NSString *)requestBody
28122832
config:(NSString *)configJson
2813-
responseAt:(long long)responseAtSeconds {
2833+
responseAt:(long long)responseAtSeconds
2834+
activated:(BOOL *)activatedOut {
28142835
// responseText is nil for the crash handler's activation-only commit
28152836
// (activatePendingVersion): no round ran, so there is no cache to write.
28162837
NSData *cacheData = nil;
@@ -2824,29 +2845,51 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation
28242845
cacheData = [NSJSONSerialization dataWithJSONObject:cacheEntry options:0 error:nil];
28252846
}
28262847
__block BOOL committed = NO;
2848+
__block BOOL activated = NO;
28272849
PushyWithStateLock(^{
28282850
if (pushyResetGeneration.load() != generation) {
28292851
return;
28302852
}
2853+
NSString *activation = hashToActivate;
2854+
NSDictionary *info = hashInfoEntry[@"info"];
2855+
// The tvOS purged-version restore round (a round commit, not the
2856+
// crash handler's activation-only one): it may take over this launch
2857+
// only while +bundleURL still waits for it; after that the version is
2858+
// left to JS unless a crash is being held (JS is dead then).
2859+
if (responseText != nil && pushyPurgeRestoreActive.load()) {
2860+
if (pushyPurgeRestoreWindowOpen) {
2861+
if (activation != nil && info != nil) {
2862+
NSMutableDictionary *marked = [info mutableCopy];
2863+
marked[@"purgeRestore"] = @YES;
2864+
info = marked;
2865+
}
2866+
} else if (!pushyCrashRescueActive.load()) {
2867+
activation = nil;
2868+
}
2869+
}
28312870
NSUserDefaults *defaults = PushyDefaults();
28322871
if (hashInfoEntry != nil) {
2833-
NSData *infoData = [NSJSONSerialization dataWithJSONObject:hashInfoEntry[@"info"]
2872+
NSData *infoData = [NSJSONSerialization dataWithJSONObject:info
28342873
options:0
28352874
error:nil];
28362875
if (infoData != nil) {
28372876
[defaults setObject:[[NSString alloc] initWithData:infoData encoding:NSUTF8StringEncoding]
28382877
forKey:PushyHashInfoKey(hashInfoEntry[@"hash"])];
28392878
}
28402879
}
2841-
if (hashToActivate != nil) {
2842-
PushySwitchVersionLocked(hashToActivate);
2880+
if (activation != nil) {
2881+
PushySwitchVersionLocked(activation);
2882+
activated = YES;
28432883
}
28442884
if (cacheData != nil) {
28452885
[defaults setObject:[[NSString alloc] initWithData:cacheData encoding:NSUTF8StringEncoding]
28462886
forKey:keyNativeCheckCache];
28472887
}
28482888
committed = YES;
28492889
});
2890+
if (activatedOut != NULL) {
2891+
*activatedOut = activated;
2892+
}
28502893
return committed;
28512894
}
28522895

β€Žsrc/__tests__/metadata.test.tsβ€Ž

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ describe('getUpdateMetadata', () => {
6161
mockCore({ currentVersionInfo: { purgeRestore: true } });
6262
const restored = await importFreshMetadata('meta-purge-restore');
6363
expect(restored.getUpdateMetadata().rescueSource).toBe('purgeRestore');
64+
65+
// One activation can carry several markers; the highest-priority wins.
66+
mockCore({
67+
currentVersionInfo: { forceBootRescue: true, purgeRestore: true },
68+
});
69+
const both = await importFreshMetadata('meta-forceboot-purge');
70+
expect(both.getUpdateMetadata().rescueSource).toBe('forceBoot');
6471
});
6572

6673
test('tolerates the embedded bundle and a rollback launch', async () => {

β€Žsrc/metadata.tsβ€Ž

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export interface UpdateMetadata {
4848
* 'forceBoot' β€” the server's per-version override applied by the native
4949
* cold-start check; 'crashRescue' β€” activated by the crash-time rescue;
5050
* 'purgeRestore' β€” reinstalled at launch after tvOS purged the cache.
51+
* Several can apply to one activation (a purge restore of a forceBoot
52+
* version); this reports the highest-priority one, in the order listed.
5153
*/
5254
rescueSource: 'forceBoot' | 'crashRescue' | 'purgeRestore' | null;
5355
/** Stable per-install client id (gray-release bucketing key). */

0 commit comments

Comments
Β (0)