pulse-updates 1.2.1 → 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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-updates",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "OTA updates for React Native - lightweight alternative to expo-updates",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -1174,20 +1174,36 @@ export function findLatestGeneratedAndroidCapabilitySource(projectRoot = process
1174
1174
  if (!fs.existsSync(buildRoot)) return null;
1175
1175
 
1176
1176
  const candidates = [];
1177
+ const variantOf = (candidatePath) => {
1178
+ const normal = candidatePath.replaceAll('\\', '/');
1179
+ return normal.match(/\/react\/([^/]+)\/index\.android\.bundle/)?.[1]
1180
+ || normal.match(/\/intermediates\/assets\/([^/]+)\//)?.[1]
1181
+ || null;
1182
+ };
1177
1183
  const visit = (directory) => {
1178
1184
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
1179
1185
  const fullPath = path.join(directory, entry.name);
1180
1186
  if (entry.isDirectory()) visit(fullPath);
1181
1187
  else if (entry.isFile() && entry.name === 'index.android.bundle.packager.map') {
1182
- candidates.push({ path: fullPath, modifiedAt: fs.statSync(fullPath).mtimeMs });
1188
+ candidates.push({ path: fullPath, modifiedAt: fs.statSync(fullPath).mtimeMs, map: true, variant: variantOf(fullPath) });
1183
1189
  } else if (entry.isFile() && entry.name === 'index.android.bundle' && !isHermesBytecode(fullPath)) {
1184
- candidates.push({ path: fullPath, modifiedAt: fs.statSync(fullPath).mtimeMs });
1190
+ candidates.push({ path: fullPath, modifiedAt: fs.statSync(fullPath).mtimeMs, map: false, variant: variantOf(fullPath) });
1185
1191
  }
1186
1192
  }
1187
1193
  };
1188
1194
  visit(buildRoot);
1189
1195
  candidates.sort((a, b) => b.modifiedAt - a.modifiedAt);
1190
- return candidates[0]?.path || null;
1196
+ const latest = candidates[0];
1197
+ if (!latest) return null;
1198
+
1199
+ // Gradle writes the minified bundle after its packager map. Choosing only by mtime therefore
1200
+ // selects the bundle, where R8/Metro have erased the canonical native binding call sites, and
1201
+ // reports a dangerous empty capability set. For the newest build variant, the packager map is
1202
+ // authoritative because it retains every original module in sourcesContent.
1203
+ const matchingMap = latest.variant
1204
+ ? candidates.find((candidate) => candidate.map && candidate.variant === latest.variant)
1205
+ : null;
1206
+ return matchingMap?.path || latest.path;
1191
1207
  }
1192
1208
 
1193
1209