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.
@@ -29,10 +29,35 @@ final class NativeCheckOrchestrator {
29
29
  // Raw response cache for the JS side to reuse (§10.3), scoped to the
30
30
  // exact logical request and native config that produced it.
31
31
  static final String KEY_RESP_CACHE = "nativeCheckResp";
32
+ // Set when a round starts, cleared when it ends (§11.4). Residue on the
33
+ // next launch means the previous process died mid-round (a crash rescue
34
+ // was truncated): that launch resumes immediately instead of waiting 5s.
35
+ static final String KEY_ROUND_INCOMPLETE = "nativeCheckIncomplete";
32
36
  private static final int MAX_CHECK_HTTP_ATTEMPTS = 8;
33
37
  private static final long DOWNLOAD_PHASE_TIMEOUT_SECONDS = 600;
34
38
 
35
39
  private static final AtomicBoolean scheduled = new AtomicBoolean(false);
40
+ // One round per process, whoever starts it first — the delayed cold-start
41
+ // thread or the crash-rescue thread (§11.3). roundDone lets the rescue
42
+ // wait for an in-flight round instead of racing it.
43
+ private static final AtomicBoolean roundStarted = new AtomicBoolean(false);
44
+ private static final CountDownLatch roundDone = new CountDownLatch(1);
45
+ private static volatile boolean roundCompleted = false;
46
+ // Flipped the moment a crash is being held. JS is dead from that point
47
+ // on, so there is no second decision maker: the round force-activates
48
+ // whatever it downloads (§11.3).
49
+ private static volatile boolean crashRescueActive = false;
50
+ // A version this process downloaded but left for JS to activate. If the
51
+ // process then crashes, JS will never activate it — the crash handler
52
+ // activates it directly (bounded local work, no network). The generation
53
+ // is the one the round committed under: a reset that lands afterwards
54
+ // bumps it, and the late activation must lose to that reset exactly like
55
+ // the round itself would. Written generation-first, read hash-first, so
56
+ // a torn read can only see a stricter (newer) generation.
57
+ private static volatile String unactivatedHash;
58
+ private static volatile long unactivatedGeneration;
59
+ private static volatile UpdateContext sContext;
60
+ private static volatile String sLaunchRolledBackVersion;
36
61
 
37
62
  private NativeCheckOrchestrator() {
38
63
  }
@@ -44,14 +69,24 @@ final class NativeCheckOrchestrator {
44
69
  if (!scheduled.compareAndSet(false, true)) {
45
70
  return;
46
71
  }
72
+ sContext = context;
73
+ sLaunchRolledBackVersion = launchRolledBackVersion;
74
+ // The crash-hold rescue shares the orchestrator's rollout gate: no
75
+ // persisted config, no handler (§11.3).
76
+ if (context.getKv(KEY_CONFIG) != null) {
77
+ CrashRescue.install();
78
+ }
47
79
  Thread thread = new Thread(new Runnable() {
48
80
  @Override
49
81
  public void run() {
50
82
  try {
51
83
  // Keep the check away from the cold-start critical path
52
- // (§7 R5) — its result targets the NEXT launch anyway.
53
- Thread.sleep(5000);
54
- runOnce(context, launchRolledBackVersion);
84
+ // (§7 R5) — unless the previous process died mid-round,
85
+ // in which case every launch second counts (§11.4).
86
+ if (context.getKv(KEY_ROUND_INCOMPLETE) == null) {
87
+ Thread.sleep(5000);
88
+ }
89
+ startRound(0);
55
90
  } catch (Throwable e) {
56
91
  // The rescue path must never take the app down with it.
57
92
  Log.w(UpdateContext.TAG, "native check failed: " + e);
@@ -63,9 +98,94 @@ final class NativeCheckOrchestrator {
63
98
  thread.start();
64
99
  }
65
100
 
101
+ static boolean isRoundInFlight() {
102
+ return roundStarted.get() && !roundCompleted;
103
+ }
104
+
105
+ /**
106
+ * Runs the process's single round on the calling thread if nobody has
107
+ * started it yet. deadlineNanos > 0 (crash rescue) caps every HTTP call
108
+ * and download phase to the remaining budget.
109
+ */
110
+ private static void startRound(long deadlineNanos) {
111
+ if (!roundStarted.compareAndSet(false, true)) {
112
+ return;
113
+ }
114
+ try {
115
+ runOnce(sContext, sLaunchRolledBackVersion, deadlineNanos);
116
+ } catch (Throwable e) {
117
+ Log.w(UpdateContext.TAG, "native check failed: " + e);
118
+ } finally {
119
+ roundCompleted = true;
120
+ roundDone.countDown();
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Crash-rescue entry (§11.3), called from the handler's worker thread
126
+ * while the uncaught-exception handler holds the dying process. Ensures
127
+ * this process's round runs to completion within the budget, then
128
+ * activates a downloaded-but-unactivated version if one exists — the
129
+ * last chance before the process is gone.
130
+ */
131
+ static void runRescue(long deadlineNanos) {
132
+ UpdateContext context = sContext;
133
+ if (context == null) {
134
+ return;
135
+ }
136
+ crashRescueActive = true;
137
+ startRound(deadlineNanos);
138
+ if (!roundCompleted) {
139
+ long remainingNanos = deadlineNanos - System.nanoTime();
140
+ if (remainingNanos > 0) {
141
+ try {
142
+ roundDone.await(remainingNanos, TimeUnit.NANOSECONDS);
143
+ } catch (InterruptedException e) {
144
+ Thread.currentThread().interrupt();
145
+ return;
146
+ }
147
+ }
148
+ }
149
+ activatePendingVersion(context);
150
+ }
151
+
152
+ // The alert-strategy variant of the §10.7 hole: the round downloaded a
153
+ // fix but deferred activation to JS, and JS is now dead. Activation is
154
+ // local and bounded (a state switch under the commit lock).
155
+ private static void activatePendingVersion(UpdateContext context) {
156
+ String hash = unactivatedHash;
157
+ if (hash == null) {
158
+ return;
159
+ }
160
+ String hashInfoJson = null;
161
+ String existingInfo = context.getKv("hash_" + hash);
162
+ if (existingInfo != null) {
163
+ try {
164
+ JSONObject info = new JSONObject(existingInfo);
165
+ info.put("crashRescue", true);
166
+ hashInfoJson = info.toString();
167
+ } catch (JSONException ignored) {
168
+ }
169
+ }
170
+ try {
171
+ if (context.commitNativeCheckResult(
172
+ unactivatedGeneration, hash, hashInfoJson, true, null)) {
173
+ unactivatedHash = null;
174
+ Log.i(UpdateContext.TAG,
175
+ "crash rescue: activated downloaded version " + hash);
176
+ } else {
177
+ Log.i(UpdateContext.TAG,
178
+ "crash rescue: reset since download, dropping activation");
179
+ }
180
+ } catch (Exception e) {
181
+ Log.w(UpdateContext.TAG, "crash rescue: activation failed: " + e);
182
+ }
183
+ }
184
+
66
185
  private static void runOnce(
67
186
  UpdateContext context,
68
- String launchRolledBackVersion
187
+ String launchRolledBackVersion,
188
+ long deadlineNanos
69
189
  ) throws JSONException {
70
190
  // Sampled before any IO: a reset landing while this round runs must
71
191
  // win over the round's decision.
@@ -89,6 +209,27 @@ final class NativeCheckOrchestrator {
89
209
  if (appKey.isEmpty()) {
90
210
  return;
91
211
  }
212
+ // From here on the round does real work: leave the breadcrumb that
213
+ // the next launch reads to skip its 5s delay if we die mid-round.
214
+ context.setKv(KEY_ROUND_INCOMPLETE, "1");
215
+ try {
216
+ runConfiguredRound(
217
+ context, launchRolledBackVersion, deadlineNanos,
218
+ resetGeneration, configJson, config, appKey);
219
+ } finally {
220
+ context.removeKv(KEY_ROUND_INCOMPLETE);
221
+ }
222
+ }
223
+
224
+ private static void runConfiguredRound(
225
+ UpdateContext context,
226
+ String launchRolledBackVersion,
227
+ long deadlineNanos,
228
+ long resetGeneration,
229
+ String configJson,
230
+ JSONObject config,
231
+ String appKey
232
+ ) throws JSONException {
92
233
  String packageVersion = config.optString(
93
234
  "packageVersion", context.getPackageVersion());
94
235
  if (packageVersion.isEmpty()) {
@@ -141,7 +282,7 @@ final class NativeCheckOrchestrator {
141
282
  return;
142
283
  }
143
284
 
144
- String responseText = runCheckRequest(config, appKey, body);
285
+ String responseText = runCheckRequest(config, appKey, body, deadlineNanos);
145
286
  if (responseText == null) {
146
287
  Log.i(UpdateContext.TAG,
147
288
  "native check: no endpoint reachable, giving up until next launch");
@@ -173,7 +314,8 @@ final class NativeCheckOrchestrator {
173
314
  boolean downloaded = context.hasCompletedVersion(hash);
174
315
  if (!downloaded) {
175
316
  downloaded = performAttempts(
176
- context, decision.optJSONArray("attempts"), hash, currentVersion);
317
+ context, decision.optJSONArray("attempts"), hash, currentVersion,
318
+ deadlineNanos);
177
319
  }
178
320
  if (!downloaded) {
179
321
  // The native attempt has finished, so JS may safely reuse the
@@ -197,12 +339,25 @@ final class NativeCheckOrchestrator {
197
339
  hashInfo.put(key, value);
198
340
  }
199
341
  }
342
+ // A forceBoot activation is the brick-rescue path: mark it in the
343
+ // persisted info so JS can report force_boot_rescue when this
344
+ // version survives to markSuccess. Only the server-sent directive
345
+ // counts — a silent-strategy activation is ordinary delivery.
346
+ JSONObject infoConfig = info.optJSONObject("config");
347
+ if (infoConfig != null && infoConfig.optBoolean("forceBoot", false)) {
348
+ hashInfo.put("forceBootRescue", true);
349
+ }
350
+ if (crashRescueActive) {
351
+ hashInfo.put("crashRescue", true);
352
+ }
200
353
  hashInfoJson = hashInfo.toString();
201
354
  }
202
355
  // Silent strategies or a server-marked forceBoot version (per-version
203
356
  // remote override — the brick rescue) activate for the next launch;
204
- // otherwise activation stays with the JS side.
205
- boolean activate = decision.optBoolean("activate", false);
357
+ // otherwise activation stays with the JS side. Unless a crash is
358
+ // being held: JS is dead, deferring to it would leave the fix on
359
+ // disk forever (§11.3).
360
+ boolean activate = decision.optBoolean("activate", false) || crashRescueActive;
206
361
  boolean committed;
207
362
  try {
208
363
  committed = context.commitNativeCheckResult(
@@ -218,9 +373,14 @@ final class NativeCheckOrchestrator {
218
373
  if (!committed) {
219
374
  Log.i(UpdateContext.TAG, "native check: reset during round, dropping result");
220
375
  } else if (activate) {
376
+ unactivatedHash = null;
221
377
  Log.i(UpdateContext.TAG,
222
378
  "native check: downloaded " + hash + " and set for next launch");
223
379
  } else {
380
+ // Remembered so a crash later in this process can still activate
381
+ // it (activatePendingVersion) — JS never will.
382
+ unactivatedGeneration = resetGeneration;
383
+ unactivatedHash = hash;
224
384
  Log.i(UpdateContext.TAG,
225
385
  "native check: downloaded " + hash + ", activation left to JS");
226
386
  }
@@ -246,7 +406,7 @@ final class NativeCheckOrchestrator {
246
406
  .callTimeout(15, TimeUnit.SECONDS)
247
407
  .build();
248
408
 
249
- private static String httpRequest(String url, String postBody) {
409
+ private static String httpRequest(String url, String postBody, long deadlineNanos) {
250
410
  try {
251
411
  Request.Builder builder =
252
412
  new Request.Builder().url(url).header("Accept", "application/json");
@@ -254,7 +414,22 @@ final class NativeCheckOrchestrator {
254
414
  builder.post(RequestBody.create(
255
415
  postBody, MediaType.parse("application/json; charset=utf-8")));
256
416
  }
257
- try (Response response = httpClient.newCall(builder.build()).execute()) {
417
+ OkHttpClient client = httpClient;
418
+ if (deadlineNanos > 0) {
419
+ // Crash-rescue budget: never let a single request outlive the
420
+ // handler's hold window.
421
+ long remainingMillis = TimeUnit.NANOSECONDS.toMillis(
422
+ deadlineNanos - System.nanoTime());
423
+ if (remainingMillis <= 0) {
424
+ return null;
425
+ }
426
+ if (remainingMillis < 15000) {
427
+ client = httpClient.newBuilder()
428
+ .callTimeout(remainingMillis, TimeUnit.MILLISECONDS)
429
+ .build();
430
+ }
431
+ }
432
+ try (Response response = client.newCall(builder.build()).execute()) {
258
433
  if (!response.isSuccessful() || response.body() == null) {
259
434
  return null;
260
435
  }
@@ -291,7 +466,9 @@ final class NativeCheckOrchestrator {
291
466
  * already-tried) for one more round. No hedged race on purpose — this
292
467
  * path is latency-insensitive.
293
468
  */
294
- private static String runCheckRequest(JSONObject config, String appKey, String body) {
469
+ private static String runCheckRequest(
470
+ JSONObject config, String appKey, String body, long deadlineNanos
471
+ ) {
295
472
  JSONArray endpoints = config.optJSONArray("endpoints");
296
473
  String orderedJson = NativeUpdateFlow.orderEndpointCandidates(
297
474
  endpoints == null ? "[]" : endpoints.toString(), Math.random());
@@ -311,7 +488,8 @@ final class NativeCheckOrchestrator {
311
488
  if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) {
312
489
  return null;
313
490
  }
314
- String response = httpRequest(base + "/checkUpdate/" + appKey, body);
491
+ String response = httpRequest(
492
+ base + "/checkUpdate/" + appKey, body, deadlineNanos);
315
493
  if (isValidCheckResponse(response)) {
316
494
  return response;
317
495
  }
@@ -328,7 +506,7 @@ final class NativeCheckOrchestrator {
328
506
  if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) {
329
507
  return null;
330
508
  }
331
- String listText = httpRequest(listUrl, null);
509
+ String listText = httpRequest(listUrl, null, deadlineNanos);
332
510
  if (listText == null) {
333
511
  continue;
334
512
  }
@@ -347,7 +525,8 @@ final class NativeCheckOrchestrator {
347
525
  return null;
348
526
  }
349
527
  tried.add(base);
350
- String response = httpRequest(base + "/checkUpdate/" + appKey, body);
528
+ String response = httpRequest(
529
+ base + "/checkUpdate/" + appKey, body, deadlineNanos);
351
530
  if (isValidCheckResponse(response)) {
352
531
  return response;
353
532
  }
@@ -358,14 +537,23 @@ final class NativeCheckOrchestrator {
358
537
  return null;
359
538
  }
360
539
 
540
+ private static long capToRescueBudget(long phaseDeadlineNanos, long rescueDeadlineNanos) {
541
+ if (rescueDeadlineNanos <= 0) {
542
+ return phaseDeadlineNanos;
543
+ }
544
+ return Math.min(phaseDeadlineNanos, rescueDeadlineNanos);
545
+ }
546
+
361
547
  private static boolean performAttempts(
362
- UpdateContext context, JSONArray attempts, String hash, String originHash
548
+ UpdateContext context, JSONArray attempts, String hash, String originHash,
549
+ long rescueDeadlineNanos
363
550
  ) {
364
551
  if (attempts == null) {
365
552
  return false;
366
553
  }
367
- final long incrementalDeadlineNanos = System.nanoTime()
368
- + TimeUnit.SECONDS.toNanos(DOWNLOAD_PHASE_TIMEOUT_SECONDS);
554
+ final long incrementalDeadlineNanos = capToRescueBudget(
555
+ System.nanoTime() + TimeUnit.SECONDS.toNanos(DOWNLOAD_PHASE_TIMEOUT_SECONDS),
556
+ rescueDeadlineNanos);
369
557
  long fullDeadlineNanos = 0;
370
558
  for (int i = 0; i < attempts.length(); i++) {
371
559
  JSONObject attempt = attempts.optJSONObject(i);
@@ -381,8 +569,10 @@ final class NativeCheckOrchestrator {
381
569
  if (isFullAttempt && fullDeadlineNanos == 0) {
382
570
  // Incremental failures must not consume the last-resort full
383
571
  // download's budget. Each phase gets one bounded 10min window.
384
- fullDeadlineNanos = System.nanoTime()
385
- + TimeUnit.SECONDS.toNanos(DOWNLOAD_PHASE_TIMEOUT_SECONDS);
572
+ fullDeadlineNanos = capToRescueBudget(
573
+ System.nanoTime()
574
+ + TimeUnit.SECONDS.toNanos(DOWNLOAD_PHASE_TIMEOUT_SECONDS),
575
+ rescueDeadlineNanos);
386
576
  }
387
577
  final long deadlineNanos = isFullAttempt
388
578
  ? fullDeadlineNanos : incrementalDeadlineNanos;
@@ -425,6 +425,12 @@ public class UpdateContext {
425
425
  return sp.getString(key, null);
426
426
  }
427
427
 
428
+ void removeKv(String key) {
429
+ SharedPreferences.Editor editor = sp.edit();
430
+ editor.remove(key);
431
+ persistEditor(editor, "remove key " + key);
432
+ }
433
+
428
434
  public String getCurrentVersion() {
429
435
  return sp.getString("currentVersion", null);
430
436
  }