pulse-updates 1.2.2 → 1.2.3
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.
|
@@ -680,6 +680,20 @@ class PulseController private constructor() {
|
|
|
680
680
|
// Re-select the best update before reloading
|
|
681
681
|
launchBestUpdate { success ->
|
|
682
682
|
pulseLog(TAG, "Reload: launchBestUpdate success=$success")
|
|
683
|
+
if (!success) {
|
|
684
|
+
// Same fallback the cold-start path has always had, and for the same reason: with no
|
|
685
|
+
// launchable update left, launchAssetFile still points at whatever was running, so the
|
|
686
|
+
// host reload below would come straight back up on the bundle we are trying to leave.
|
|
687
|
+
//
|
|
688
|
+
// That is not hypothetical. Measured on a Pixel 6a (2026-08-26): an update that marks
|
|
689
|
+
// ITSELF unusable (Bastion's Layer-0 canary) marked itself FAILED, which emptied the
|
|
690
|
+
// launchable list, launchBestUpdate failed, and the app reloaded into the same broken
|
|
691
|
+
// bundle 29 times in a row — a splash screen that never resolves, which is worse than
|
|
692
|
+
// the problem it was trying to escape. Falling back to the bundle inside the APK gives
|
|
693
|
+
// the reload something good to land on.
|
|
694
|
+
pulseLogWarn(TAG, "Reload: no launchable update, falling back to embedded")
|
|
695
|
+
launchEmbedded()
|
|
696
|
+
}
|
|
683
697
|
callback(success)
|
|
684
698
|
}
|
|
685
699
|
}
|
|
@@ -698,10 +712,27 @@ class PulseController private constructor() {
|
|
|
698
712
|
runReaper()
|
|
699
713
|
}
|
|
700
714
|
|
|
715
|
+
/**
|
|
716
|
+
* Report that THIS update is not usable, from JS.
|
|
717
|
+
*
|
|
718
|
+
* It used to only bump the failure counter, which nothing reads at selection time:
|
|
719
|
+
* launchableUpdates() filters on `status IN ('ready','embedded')`, so the update stayed
|
|
720
|
+
* selectable and the next launch booted the very bundle the caller had just declared broken.
|
|
721
|
+
* The API said "trigger rollback on next start" and did not. Now it marks the update FAILED —
|
|
722
|
+
* the same thing the native crash handler does — which is what actually takes it out of the
|
|
723
|
+
* running and falls back to the previous good update, or to the embedded one.
|
|
724
|
+
*
|
|
725
|
+
* The embedded bundle is never demoted: it is the floor, and excluding it would leave the app
|
|
726
|
+
* with nothing to launch.
|
|
727
|
+
*/
|
|
701
728
|
fun reportLaunchFailure(reason: String) {
|
|
702
729
|
launchedUpdate?.let { update ->
|
|
703
730
|
try {
|
|
704
731
|
database?.recordFailedLaunch(update.updateId)
|
|
732
|
+
if (!update.isEmbedded) {
|
|
733
|
+
database?.setStatus(update.updateId, PulseUpdateStatus.FAILED)
|
|
734
|
+
pulseLogWarn(TAG, "reportLaunchFailure: marked ${update.updateId} FAILED ($reason)")
|
|
735
|
+
}
|
|
705
736
|
} catch (e: Exception) {
|
|
706
737
|
pulseLogWarn(TAG, "Failed to record launch failure: ${e.message}")
|
|
707
738
|
}
|
|
@@ -713,7 +713,16 @@ public final class PulseController {
|
|
|
713
713
|
/// Reload the app with the new update
|
|
714
714
|
public func reload(completion: ((Bool) -> Void)? = nil) {
|
|
715
715
|
// Re-select the best update before reloading
|
|
716
|
-
launchBestUpdate { success in
|
|
716
|
+
launchBestUpdate { [weak self] success in
|
|
717
|
+
if !success {
|
|
718
|
+
// Same fallback the cold-start path has, and for the same reason: with no launchable
|
|
719
|
+
// update left, launchAssetUrl still points at whatever is running, so the reload comes
|
|
720
|
+
// straight back up on the bundle we are trying to leave. Measured on Android (Pixel 6a,
|
|
721
|
+
// 2026-08-26): an update that marked ITSELF unusable emptied the launchable list and the
|
|
722
|
+
// app reloaded into the same broken bundle 29 times — a splash that never resolves.
|
|
723
|
+
pulseLog("Reload: no launchable update, falling back to embedded")
|
|
724
|
+
self?.launchEmbedded()
|
|
725
|
+
}
|
|
717
726
|
// Signal React Native to reload with the new bundle
|
|
718
727
|
NotificationCenter.default.post(name: NSNotification.Name("PulseUpdatesReload"), object: nil)
|
|
719
728
|
completion?(success)
|
|
@@ -769,6 +778,23 @@ public final class PulseController {
|
|
|
769
778
|
errorRecovery?.handle(error: error)
|
|
770
779
|
}
|
|
771
780
|
|
|
781
|
+
/// Mark THIS update unusable, from JS.
|
|
782
|
+
///
|
|
783
|
+
/// The recovery pipeline only marks a failure when content never appeared, and even then it just
|
|
784
|
+
/// bumps the counter — which nothing reads when picking what to launch (the selection filters on
|
|
785
|
+
/// status). So an update the app itself declares broken stayed selectable and came back on the
|
|
786
|
+
/// next start. Flipping the status is what actually takes it out of the running.
|
|
787
|
+
///
|
|
788
|
+
/// The embedded bundle is never demoted: it is the floor, and excluding it would leave nothing
|
|
789
|
+
/// to launch.
|
|
790
|
+
public func markLaunchFailed(_ reason: String) {
|
|
791
|
+
guard let update = launchedUpdate, !update.isEmbedded else { return }
|
|
792
|
+
try? database?.recordFailedLaunch(updateId: update.updateId)
|
|
793
|
+
try? database?.setStatus(.failed, forUpdateId: update.updateId)
|
|
794
|
+
reportEvent("launch_failure", updateId: update.updateId)
|
|
795
|
+
pulseLog("markLaunchFailed: marked \(update.updateId) failed (\(reason))")
|
|
796
|
+
}
|
|
797
|
+
|
|
772
798
|
/// Report a fatal exception for error recovery handling
|
|
773
799
|
public func reportException(_ exception: NSException) {
|
|
774
800
|
errorRecovery?.handle(exception: exception)
|
|
@@ -219,6 +219,10 @@ public class PulseUpdates: RCTEventEmitter {
|
|
|
219
219
|
func reportLaunchFailure(_ reason: String,
|
|
220
220
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
221
221
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
222
|
+
// Both halves: the recovery pipeline still sees the error (it drives its own policy), and the
|
|
223
|
+
// update is marked FAILED so the next selection actually skips it — which is what this API
|
|
224
|
+
// has always claimed to do.
|
|
225
|
+
PulseController.shared.markLaunchFailed(reason)
|
|
222
226
|
let error = NSError(domain: "PulseUpdates", code: -1, userInfo: [NSLocalizedDescriptionKey: reason])
|
|
223
227
|
PulseController.shared.reportError(error)
|
|
224
228
|
resolve(nil)
|