react-native-update 10.51.1 → 10.52.1
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.
- package/NATIVE_CHECKUPDATE_DESIGN.md +117 -0
- package/NATIVE_CHECK_FOLLOWUPS.md +149 -0
- package/README-CN.md +2 -0
- package/README.md +2 -0
- package/android/src/main/java/cn/reactnative/modules/update/CrashRescue.java +115 -0
- package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +259 -26
- package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +209 -19
- package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +6 -0
- package/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +14 -0
- package/harmony/pushy/src/main/ets/DownloadTask.ts +381 -72
- package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +32 -2
- package/harmony/pushy/src/main/ets/UpdateContext.ts +5 -0
- package/harmony/pushy.har +0 -0
- package/ios/RCTPushy/RCTPushy.mm +308 -25
- package/ios/RCTPushy/RCTPushyDownloader.mm +361 -74
- package/package.json +1 -1
- package/src/client.ts +8 -0
- package/src/type.ts +6 -0
|
@@ -420,3 +420,120 @@ afterDownload)`:本地 silent 策略 或 响应标记 forceBoot 即激活。
|
|
|
420
420
|
`binding.config.forceBoot` 合成进下发 config(灰度与全量两分支),绑定列表
|
|
421
421
|
接口补 select config;pushy-admin / cresc-admin 发布菜单加"全量+强制启动
|
|
422
422
|
(救砖)",已绑定行显示⚡标记 + 切换项(重发同绑定翻转标记)。
|
|
423
|
+
|
|
424
|
+
## 11. 崩溃时刻救援 + 断点续传(10.52.0,补"启动即崩"的架构缺口)
|
|
425
|
+
|
|
426
|
+
### 11.1 缺口(2026-08-12 Android 真机实测,见 NATIVE_CHECK_FOLLOWUPS.md)
|
|
427
|
+
|
|
428
|
+
§10 的编排器救得了"markSuccess 后 10s 才崩"的砖(原生 commit 领先崩溃点
|
|
429
|
+
567ms),救不了"启动 230ms 即崩"的砖:5s 延迟 + 检查/下载本身 ~4.4s,而 JS
|
|
430
|
+
崩溃杀掉整个进程,该轮进度全部丢弃——重启多少次都不收敛。
|
|
431
|
+
|
|
432
|
+
根因不是延迟参数,是**时序**:编排器的救援窗口永远排在崩溃点之后。修复原则
|
|
433
|
+
只有两条,满足其一即可——把救援挪到 JS 执行之前,或放进不跑 JS 的执行上下
|
|
434
|
+
文。本节方案属于前者的极限形式:**崩溃发生的那一刻,进程还活着,JS 已永远
|
|
435
|
+
不会再跑——这是天然的、无误判的救援窗口。**
|
|
436
|
+
|
|
437
|
+
### 11.2 选型:crash-handler hold,弃用启动阻塞与本地熔断
|
|
438
|
+
|
|
439
|
+
- **主机制:uncaught handler 扣住进程完成救援。** 崩溃本身就是触发信号,
|
|
440
|
+
确定性 100%:不需要崩溃环检测(marker/退出原因/计数阈值)那套状态机,也
|
|
441
|
+
没有"误判阻塞健康用户启动"的脚枪。首次崩溃当场救,修复在垂死会话末尾
|
|
442
|
+
commit,下次启动零阻塞直接进修复版——比任何检测方案快一个启动身位。
|
|
443
|
+
- **启动阻塞式检查:否决。** 它必须依赖检测态(至少崩 2-3 次才敢升级),
|
|
444
|
+
误判代价是惩罚健康启动;iOS 还要与 watchdog 抢预算。crash-handler 在
|
|
445
|
+
触发确定性、生效速度、健康路径成本三个维度全面占优。
|
|
446
|
+
- **本地熔断(Phase 2/Rung 1,自动 reset 回内置包):本版不做**(维护者
|
|
447
|
+
2026-08-12 裁定)。取舍记录:熔断能自治救"坏热更包本身"类砖,但需要
|
|
448
|
+
检测态且有"多计数毁好版本"的历史教训(§7、Phase 2 评审);crash-rescue
|
|
449
|
+
+ 服务端重绑同时覆盖"坏包"与"毒化存储"两类砖(重绑即远程回滚,§10.7),
|
|
450
|
+
单一机制更简。代价是救援依赖服务端动作(运营重绑/发修复版),无网或无
|
|
451
|
+
人值守时坏包砖不会自愈——接受,写进产品口径。
|
|
452
|
+
- **崩溃处理器与 crash reporter 的关系:链式委托,不是抢钩子。** 保存前任
|
|
453
|
+
handler、救援结束必须调用它——Sentry/Crashlytics/Bugly 全都遵守同一惯
|
|
454
|
+
例,两个安装顺序方向都成立。残余风险:个别 reporter 拿到控制权后不回调
|
|
455
|
+
前任直接终止,救援被截胡——接受,靠遥测观察。
|
|
456
|
+
|
|
457
|
+
### 11.3 handler 机制规格(Android/iOS 一致)
|
|
458
|
+
|
|
459
|
+
- **安装点** = 编排器锚点:Android `getBundleUrl`(schedule 时)、iOS
|
|
460
|
+
`+bundleURL`(scheduleFromColdStart 时),严格早于任何 JS 执行,而 JS 崩
|
|
461
|
+
溃必然晚于 bundle 加载,覆盖天然完整。与编排器同门控:`#if !DEBUG` /
|
|
462
|
+
`BuildConfig.DEBUG`,且落盘 config 存在才安装(灰度开关语义一致)。
|
|
463
|
+
- **触发门控**(handler 内,全部本地判断):进程存活 < 60s,**或**当前有
|
|
464
|
+
在途检查轮(语义是"把没跑完的轮次收尾",覆盖实测里 5~9.4s 之间被打断
|
|
465
|
+
的窗口)。中途随机崩溃(uptime ≥ 60s 且无在途轮)不触发,不拖慢正常崩
|
|
466
|
+
溃 UX。本轮已完成 → 直接放行(无 hold)。
|
|
467
|
+
- **预算按崩溃线程分档**:主线程崩溃 3.5s(Android ANR ~5s / iOS watchdog
|
|
468
|
+
约束),其余线程 10s(RN release 的 JS fatal 重抛在 mqt/exception 线程,
|
|
469
|
+
是常态)。
|
|
470
|
+
- **死锁免疫靠结构,不靠协作**:救援工作跑在**新线程**上,handler 只
|
|
471
|
+
latch/semaphore 带超时等待。即使救援线程死锁(如某线程带着状态锁崩了、
|
|
472
|
+
ObjC uncaught 路径不跑 @finally),handler 超时后照常委托前任 → 进程终
|
|
473
|
+
止,卡死线程陪葬。handler 自身 try-catch-all + once 守卫,第二个线程再
|
|
474
|
+
崩不等待、直接委托(多崩场景宁可少救,绝不挂死)。
|
|
475
|
+
- **救援轮语义 = "确保本进程有一轮跑完"**:在途 → 等其完成(至多预算);
|
|
476
|
+
未开跑 → 立即跑一轮受限轮(HTTP 超时与下载 deadline 全部被剩余预算钳
|
|
477
|
+
制);已跑完 → 返回。与延迟线程的竞态由 roundStarted CAS 收敛为单轮。
|
|
478
|
+
- **救援轮强制激活**:`activate = decision.activate || rescue`。JS 已死,
|
|
479
|
+
没有第二决策者;alert 类策略下不强制激活,修复版就永远躺在磁盘上(§10.7
|
|
480
|
+
同一个洞的 handler 变体)。守卫不变:本机 rolledBack 黑名单仍赢过一切、
|
|
481
|
+
first_time 崩溃保护对激活版本仍生效、服务端 fromHash==toHash 天然防循
|
|
482
|
+
环。hashInfo 追加 `crashRescue: true`(与 forceBootRescue 对称,供 JS
|
|
483
|
+
在该版本活到 markSuccess 时上报遥测)。
|
|
484
|
+
- **覆盖边界(产品口径)**:只逮 exception 形态的崩溃(JS fatal 的原生重
|
|
485
|
+
抛)。native signal(SIGSEGV)、ANR、OOM、watchdog 杀逃逸——与威胁模型
|
|
486
|
+
匹配(热更砖的崩溃源就是 JS bundle),但不得在文案里过度承诺。
|
|
487
|
+
|
|
488
|
+
### 11.4 断点续传(所有 rung 的地基)
|
|
489
|
+
|
|
490
|
+
每次启动的进度必须**单调累积**,handler 窗口被截断才有意义。三端同一规格:
|
|
491
|
+
|
|
492
|
+
- **sidecar**:`<archive>.resume`(JSON:url/etag/lastModified/total),
|
|
493
|
+
下载响应头到达后写入。归档与 sidecar 同生共死:解压成功删归档时一并删;
|
|
494
|
+
patch 阶段失败(downloadPhaseCompleted 后)两者都删——坏归档绝不能变成
|
|
495
|
+
永久毒药;**有归档无 sidecar = 不可信,删掉重来**。
|
|
496
|
+
- **续传请求**:归档存在且 sidecar 匹配 url 时,`Range: bytes=<size>-`,
|
|
497
|
+
有验证器(etag/lastModified)则带 `If-Range`。响应 206 → 校验
|
|
498
|
+
Content-Range 起点后追加;200 → 服务端不支持/文件已变,截断重写并更新
|
|
499
|
+
sidecar;416 → size == sidecar.total 视为下载阶段已完成直接进解压(覆
|
|
500
|
+
盖"下载完、解压前死"的窗口),否则删两者本次内重发一次全量请求。
|
|
501
|
+
- **完整性**:无逐字节 hash 可校(版本 hash 不是归档 hash),依赖三层:
|
|
502
|
+
If-Range 验证器防混拼、最终长度对账、解压/patch 本身的结构校验;任何一
|
|
503
|
+
层失败走"删两者重来",自愈。
|
|
504
|
+
- **进度事件**:received 含续传偏移量,total 取 Content-Range 总长。
|
|
505
|
+
- **round-incomplete 标记**:runOnce 入口落盘 KV 标记、finally 清除。进程
|
|
506
|
+
死于轮中 → 标记残留 → **下次启动编排器跳过 5s 延迟立即(异步)续传**。
|
|
507
|
+
这不是阻塞、没有误判成本,是"上次没救完"最诚实的信号,让被截断的
|
|
508
|
+
handler 窗口在后续启动快速收敛。
|
|
509
|
+
|
|
510
|
+
### 11.5 分端落地
|
|
511
|
+
|
|
512
|
+
- **Android**:`Thread.setDefaultUncaughtExceptionHandler` 链式;uptime 用
|
|
513
|
+
`Process.getStartElapsedRealtime()`;OkHttp/DownloadTask 全程可用(Java
|
|
514
|
+
monitor 随异常解栈释放,无带锁死线程问题,但结构性预算仍兜底)。
|
|
515
|
+
- **iOS**:`NSSetUncaughtExceptionHandler` 链式;RCTFatal 在 release 下
|
|
516
|
+
raise NSException → uncaught → 进入本 handler;NSURLSession/信号量模式
|
|
517
|
+
照旧可用。下载器从 NSURLSessionDownloadTask 改为 data task 流式追加写
|
|
518
|
+
(downloadTask 的临时文件失败即丢,存不下 partial,是续传的硬前提)。
|
|
519
|
+
- **Harmony:不需要 crash hold——RNOH 的进程模型天然免疫(2026-08-13 模
|
|
520
|
+
拟器实证)。** 未捕获的 JS 错误在 RNOH release 下只记 #RNOH_JS 错误日志,
|
|
521
|
+
**不杀进程**:Hermes 跑在 RNOH 自管线程,异常不会重抛到 ArkTS 主线程触发
|
|
522
|
+
errorManager。ets 编排器跑在 ArkTS 主线程事件循环上,JS 死后照常完成整
|
|
523
|
+
轮(实测:+1s JS 崩溃,+5s 轮次检查→下载→forceBoot 激活,重启进修复
|
|
524
|
+
版)。因此 Android/iOS 的"救援窗口排在崩溃点之后"问题在鸿蒙不存在,
|
|
525
|
+
§10 原机制即完整覆盖 crash-on-launch 砖,且没有预算压力(600s 下载窗口
|
|
526
|
+
完整可用)。续传 + 零延迟标记仍有价值(设备重启/系统杀进程等场景)。
|
|
527
|
+
原 errorManager/appRecovery 的 spike 论述作废留档:当时的前提("JS 崩溃
|
|
528
|
+
杀进程")被实证推翻。
|
|
529
|
+
注意两个鸿蒙特有的工程事实:PushyTurboModule.cpp 的 C++ 方法表是 RNOH
|
|
530
|
+
给 JS 的唯一桥,**新增 spec 方法必须同步注册**(10.50~10.52.0 漏注册了
|
|
531
|
+
syncNativeConfig 等四个方法,原生检测因此从未生效,10.52.1 修复);鸿蒙
|
|
532
|
+
模拟器是 QEMU 虚机,app 内 127.0.0.1 不通宿主,人工验证需 `hdc rport`。
|
|
533
|
+
|
|
534
|
+
### 11.6 验证
|
|
535
|
+
|
|
536
|
+
三端语法/编译校验照旧(javac / clang -fsyntax-only / DevEco tsc),无 C++
|
|
537
|
+
改动不触发 .so 重编。发版 10.52.0 后按 2026-08-12 同一实验复测:markSuccess
|
|
538
|
+
→ 落盘标记 → 每次启动 ~230ms 即崩,验证 handler 窗口完成 check+download+
|
|
539
|
+
commit、下次启动进修复版,时序记录回 NATIVE_CHECK_FOLLOWUPS.md。
|
|
@@ -363,3 +363,152 @@ strict、77 金标向量 + ASan/UBSan、`.so` 符号与 16KB 对齐、iOS clang
|
|
|
363
363
|
- [ ] 服务端已推送未激活:pushy 主机 `install-npm-release.sh` +
|
|
364
364
|
`service-cli.sh restart all`(验 serverVersion);cresc 跑
|
|
365
365
|
`update-cloudrun.sh`;两个 admin 走各自 CI
|
|
366
|
+
|
|
367
|
+
## 2026-08-12 救砖实测:能救什么、救不了什么(Android 真机 + 生产服务端)
|
|
368
|
+
|
|
369
|
+
两轮实测,同一套 forceBoot 通道,结论截然相反,差别只在崩溃时刻。
|
|
370
|
+
|
|
371
|
+
**能救(已验证)**:markSuccess 之后 10s 才崩的砖。
|
|
372
|
+
时间线:proc_start → 9.4s 原生检测 commit `set for next launch` → 10.0s JS 崩溃 →
|
|
373
|
+
下次启动进入修复版并 markSuccess。救援仅领先崩溃点 567ms。
|
|
374
|
+
|
|
375
|
+
**救不了(已验证)**:markSuccess 后写 storage 标记、下次启动读到标记即崩的砖
|
|
376
|
+
(用户提出的理想场景,崩溃点约 proc_start+230ms)。连续 4 次冷启动,原生检测
|
|
377
|
+
一次都没能走到 commit,设备停留在 "keeps stopping"。
|
|
378
|
+
|
|
379
|
+
**根因(不是延迟参数问题)**:5s 固定延迟只是其一,检查+下载本身实测还需 ~4.4s。
|
|
380
|
+
即使把延迟归零也远超 230ms。更本质的是原生检测线程与 JS 同进程,JS 崩溃时
|
|
381
|
+
进程整体被杀,该轮下载进度全部丢弃,下次启动从零开始——无论重启多少次都无法收敛。
|
|
382
|
+
|
|
383
|
+
**可行方向(未实施,需三端评估)**:
|
|
384
|
+
1. 下载断点续传 + 跨启动累积:每次启动补几百毫秒,多次启动后凑齐并激活(治本,改动最大)
|
|
385
|
+
2. 原生检测移入独立进程:JS 崩溃不再连坐(Android 可行;iOS 无此模型)
|
|
386
|
+
3. 上次启动异常检测 → 本次启动同步阻塞式检查(牺牲启动速度换救援,需谨慎)
|
|
387
|
+
|
|
388
|
+
在此之前,产品口径应明确:forceBoot 救的是"能活过启动阶段、之后才出问题"的砖,
|
|
389
|
+
不覆盖"启动即刻崩"的场景。文档与卖点文案不应过度承诺。
|
|
390
|
+
|
|
391
|
+
## 2026-08-12 缺口闭环(10.52.0):crash-handler hold + 断点续传
|
|
392
|
+
|
|
393
|
+
上面三个方向重新评审后全部改判,最终方案见设计文档 §11:
|
|
394
|
+
|
|
395
|
+
- **主机制 = 崩溃时刻扣留进程**(维护者提出):uncaught handler 里 hold 住
|
|
396
|
+
垂死进程数秒完成检查+下载。崩溃即信号,零误判、零检测态、首崩即救,
|
|
397
|
+
且修复在垂死会话末尾 commit,下次启动零阻塞直接进修复版。方向 2(独立
|
|
398
|
+
进程)与方向 3(启动阻塞)被它整体取代;方向 1(续传)保留为地基。
|
|
399
|
+
- **断点续传三端落地**:sidecar(url/验证器/总长)+ Range/If-Range,
|
|
400
|
+
206/200/416 全处理;归档与 sidecar 同生共死;下载阶段失败保留 partial,
|
|
401
|
+
patch 阶段失败判定归档有毒连 sidecar 一起删。
|
|
402
|
+
- **round-incomplete 标记**:轮次开始落盘、结束清除;残留 = 上个进程死于
|
|
403
|
+
轮中,下次启动跳过 5s 延迟立即续传。
|
|
404
|
+
- **救援轮强制激活** + hashInfo `crashRescue` 标记(与 forceBootRescue 对
|
|
405
|
+
称);本机 rolledBack 黑名单与 first_time 保护语义不变。
|
|
406
|
+
- **边界**:只逮 exception 形态崩溃(native signal/ANR/OOM 逃逸);鸿蒙本
|
|
407
|
+
版无 crash hold(errorManager 同步回调驱动不了 ArkTS 异步 IO),只有续
|
|
408
|
+
传+零延迟。
|
|
409
|
+
|
|
410
|
+
- [x] 10.52.0 发版后按本节上方同一实验复测 crash-on-launch 砖(见下)
|
|
411
|
+
|
|
412
|
+
### 2026-08-12 实测:crash-on-launch 砖被救回(Android 真机 emu + 生产服务端)
|
|
413
|
+
|
|
414
|
+
10.52.0 发布到 npm,testHotUpdate 升级并出 release APK(rnu 10.52.0)。
|
|
415
|
+
复刻 crash-on-launch 砖:砖包活过一次启动并 markSuccess(first_time 保护
|
|
416
|
+
解除),之后每次启动读到标记即在 JS 线程抛错。fix 版本绑定到同一原生版本
|
|
417
|
+
1.87.1,砖包磁盘上的 fix 预下载被删除,逼救援在崩溃窗口内完整走
|
|
418
|
+
检查+下载+commit。
|
|
419
|
+
|
|
420
|
+
冷启动时序(同一进程 PID 11498):
|
|
421
|
+
|
|
422
|
+
```
|
|
423
|
+
22:59:52.365 JS 线程抛 BRICK(mqt_v_native),进程存活 122ms
|
|
424
|
+
22:59:52.367 crash rescue: holding process for up to 10000ms (uptime 122ms)
|
|
425
|
+
22:59:56.645 native check: downloaded -IxhPnHy(fix) and set for next launch
|
|
426
|
+
22:59:56.651 前任 handler 接管,进程被 SIG9 终止
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
崩溃到 commit 全程 **4.28s**,落在 10s(后台线程)预算内。下次冷启动直接
|
|
430
|
+
进 fix 版本(hot-fix-1052),不再崩溃;fix 立即把标记 disarm。
|
|
431
|
+
|
|
432
|
+
遥测闭环:fix 的 currentVersionInfo 带 `crashRescue: true`,markSuccess 后
|
|
433
|
+
JS 紧跟着上报一条 `crashRescue` 回执(logcat 实测 23:00:16.180 markSuccess
|
|
434
|
+
→ 23:00:16.181 crashRescue)。
|
|
435
|
+
|
|
436
|
+
对比 2026-08-12 上午同一台设备的旧结论:230ms 砖连续 4 次冷启动不收敛。
|
|
437
|
+
10.52.0 下**一次崩溃即救回**——崩溃本身触发 hold,进度不再随进程死亡丢弃。
|
|
438
|
+
鸿蒙真机复测待补(只有续传+零延迟无 hold)。
|
|
439
|
+
|
|
440
|
+
### 2026-08-12 iOS 模拟器实测:同一场景救回(Release-iphonesimulator + 本地服务端)
|
|
441
|
+
|
|
442
|
+
e2e 基建复用:本地 rnu 副本刷到 10.52.0 源码 → pod install → Release 模拟器
|
|
443
|
+
构建(e2e-ios 的常态形态,`#if !DEBUG` 门控激活);Detox 逮不住 JS 起不来的
|
|
444
|
+
app,绕开它直接 simctl + 文件系统/崩溃报告观察。实验脚手架已入库:
|
|
445
|
+
`e2e/entry.brick.ts` / `e2e/entry.fix.ts` / `scripts/ios-rescue-server.ts`
|
|
446
|
+
(端口 31337,链:base→brick(带 forceBoot 投放)→fix(**刻意不带 forceBoot**))。
|
|
447
|
+
|
|
448
|
+
砖状态与 Android 同构:brick 经 forceBoot 装上、首启健康 markSuccess
|
|
449
|
+
(isFirstTime 消费、isFirstLoadOK=true)、8s 后 arm 标记;磁盘上预下载的
|
|
450
|
+
fix 版本目录删除,逼救援窗口真下载。
|
|
451
|
+
|
|
452
|
+
armed 冷启动结果(procLaunch 23:24:44.233,崩溃报告 .ips 为证):
|
|
453
|
+
|
|
454
|
+
- JS fatal 重抛在 **com.meta.react.turbomodulemanager.queue**(后台线程 →
|
|
455
|
+
10s 预算档;此前对"JS 崩落在哪个线程"的分析在 bridgeless 下得到证实)
|
|
456
|
+
- 救援窗口内完成整轮:服务端记录到 checkUpdate(hash=brick)→ 下发 fix →
|
|
457
|
+
**fix.ppk 真实下载** → commit;本地服务端下全程 <0.9s(captureTime
|
|
458
|
+
23:24:45.156,远低于预算——4.4s 是生产网络的数字)
|
|
459
|
+
- **强制激活的独立证据**:fix 响应无 forceBoot、app 配置
|
|
460
|
+
checkStrategy:null/afterDownload:none——常规路径绝不会激活,而
|
|
461
|
+
currentVersion 已切到 fix 且 hashInfo 带 `crashRescue:true`,只能来自
|
|
462
|
+
crashRescueActive
|
|
463
|
+
- 链式礼仪:hold 结束后前任 handler 照常执行,SIGABRT 崩溃报告正常生成
|
|
464
|
+
(崩溃上报不丢)
|
|
465
|
+
- 下次启动进 fix、disarm 标记、markSuccess
|
|
466
|
+
|
|
467
|
+
**主线程 3.5s 预算档不做人工实测,理由记录**:iOS 上落主线程的 RCTFatal 只有
|
|
468
|
+
bundle 加载失败一族(RCTInstance 先 RCTExecuteOnMainQueue 再 RCTFatal),而
|
|
469
|
+
加载失败的包永远跑不到 markSuccess,一律被既有 first_time 回滚接住,不构成
|
|
470
|
+
真实砖形态;JS 运行时错误(砖的实际形态)实证落在后台线程。另:主线程档
|
|
471
|
+
3.5s 是按 Android ANR 定的,iOS watchdog 上限 ~20s,未来可放宽。
|
|
472
|
+
|
|
473
|
+
**iOS 覆盖边界补记**:若宿主 app 调用了 `RCTSetFatalHandler`,RCTFatal 走
|
|
474
|
+
handler 分支不再抛异常,crash-rescue 不会触发(Android 无对应抢占点)。
|
|
475
|
+
文档口径需含此项。
|
|
476
|
+
|
|
477
|
+
## 2026-08-13 鸿蒙模拟器实测:两个实证 + 一个 10.51 以来的功能性断裂
|
|
478
|
+
|
|
479
|
+
DevEco api20 模拟器(HarmonyOS 6.0 / arm64)+ harmony_use_pushy(RN 0.72 +
|
|
480
|
+
RNOH 0.72.96)+ 本地 e2e 服务端。探针脚手架入库:
|
|
481
|
+
`harmony_use_pushy/e2e/entry.crashprobe.ts`(+1s 未捕获 JS 错误)、
|
|
482
|
+
`entry.diag.ts`(TM 方法表诊断)。
|
|
483
|
+
|
|
484
|
+
**实证一(缺口 0 的分叉答案):RNOH 未捕获 JS 错误不杀进程。** +1s 抛错后
|
|
485
|
+
进程存活 40s+ 无恙,错误只落 #RNOH_JS/#RNOH_ARK 日志。Hermes 在 RNOH 自管
|
|
486
|
+
线程上,异常不重抛 ArkTS 主线程,errorManager 根本不参与。因此**鸿蒙不需要
|
|
487
|
+
crash hold**:ets 编排器在 JS 死后照常跑完整轮。此前 §11.5 的
|
|
488
|
+
errorManager/appRecovery 论述前提不成立,已改写。
|
|
489
|
+
|
|
490
|
+
**实证二(端到端救砖):** crashprobe 作基座(每次启动 JS +1s 崩),服务端
|
|
491
|
+
forceBoot 下发 v1:+5s 轮次照常检查→下载 v1.ppk→激活(currentVersion 写
|
|
492
|
+
入),重启直接运行 e2e-full-v1,砖消失。JS 崩溃对轮次零影响,无预算压力。
|
|
493
|
+
|
|
494
|
+
**功能性断裂(已修,severity 高):`PushyTurboModule.cpp` 的 C++ 方法表漏
|
|
495
|
+
注册 10.50+ 的全部四个新方法**(syncNativeConfig / getNativeCheckCache /
|
|
496
|
+
getBundleHash / resetToPackagedBundle)。RNOH 的 ArkTS TM 只有经 C++
|
|
497
|
+
methodMap 注册的方法才对 JS 可见,缺失 = JS 侧 undefined → feature-detect
|
|
498
|
+
判"老原生"静默跳过 → **原生冷启动检测在鸿蒙自 10.51.0 从未生效**
|
|
499
|
+
(nativeConfig 从未落盘,ets 编排器+本次的续传/零延迟全是死代码);
|
|
500
|
+
resetToPackagedBundle(10.50 Phase 1)在鸿蒙 JS 侧也一直不可用。诊断链:
|
|
501
|
+
健康启动 preferences 无 nativeConfig → entry.diag 打 typeof=undefined +
|
|
502
|
+
keys 只有 14 个老方法 → cpp 方法表对不上 ets 类。
|
|
503
|
+
|
|
504
|
+
**为什么 CI 没逮住**:harmony e2e 套件 testMatch 只含 `e2e/harmony/**`
|
|
505
|
+
(smoke + local-update),native-check.test.ts 从未在鸿蒙执行;而 JS 的
|
|
506
|
+
feature-detect 把缺失当"老原生"优雅跳过——这是[[no-graceful-degradation]]
|
|
507
|
+
教训的又一实例:兼容旧原生的降级路径把新原生的接线断裂藏了三个版本。
|
|
508
|
+
|
|
509
|
+
- [x] cpp 方法表补注册(含"新增 spec 方法必须同步注册"的醒目注释)
|
|
510
|
+
- [ ] harmony e2e 补 native-check 场景(driver 是 hdc+uitest 非 detox,
|
|
511
|
+
需移植;顺带把 `hdc rport` 写进用例基建——模拟器 127.0.0.1 不通宿主)
|
|
512
|
+
- [ ] 发版 10.52.1(鸿蒙 bridging 修复;Android/iOS 无变化)
|
|
513
|
+
- [ ] Logger.ts isDebug 硬编码 false,轮次全程无日志可观测,排障全靠副作用
|
|
514
|
+
(preferences/服务端流量);考虑 error 级关键节点或可配置开关
|
package/README-CN.md
CHANGED
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
- **不阻塞启动**:延迟数秒、跑在后台线程,成果在**下次启动**生效。
|
|
40
40
|
- **是否自动激活取决于你的配置**:`updateStrategy` 为 `silentAndNow` / `silentAndLater` 且未关闭自动检查(`checkStrategy` 不为 `null`)时,原生侧才会把下载好的版本设为下次启动生效;其余情况只下载,激活权仍在 JS。
|
|
41
41
|
- **救砖指令**:控制台可按版本标记「强制启动」,被标记的版本无视上述策略直接在下次启动生效——这是把已被坏版本卡死的设备捞回来的手段。设备本地的崩溃回滚保护仍然优先,已回滚过的版本不会被再装回去。
|
|
42
|
+
- **崩溃时刻救援**(10.52.0 起,Android 与 iOS):应用在启动阶段死于未捕获的 JS 错误时,SDK 会短暂扣住垂死的进程(数秒、有硬上限),把检查与下载做完——即使版本每次启动零点几秒就崩,也能被换掉。该窗口内下载到的修复版一律设为下次启动生效(JS 已经没有机会做决策了)。崩溃上报不受影响:SDK 以链式方式保存并在结束后调用原有的崩溃处理器。不覆盖:原生(非 JS)崩溃、ANR、OOM 击杀,以及 iOS 上安装了自定义 `RCTSetFatalHandler` 的应用(那时 React Native 不再抛出本机制拦截的异常)。
|
|
43
|
+
- **断点续传**(10.52.0 起):更新下载在进程死亡后保留进度、下次以 HTTP Range 续传,反复的短命启动也能单调累积进度;紧随中断轮次的下一次启动会跳过延迟立即续传。鸿蒙不需要崩溃时刻扣留:RNOH 下未捕获的 JS 错误不会杀死进程,JS 启动即崩时原生检测照常完成(需 10.52.1——此前版本因桥接缺陷,原生检测在鸿蒙上从未生效)。
|
|
42
44
|
- **可以关闭**:`disableNativeCheck: true`。关闭后每次冷启动少一次后台请求,代价是**放弃上述自愈能力**——被坏热更卡死的设备将无法自动恢复。仅在这次请求本身构成问题时(流量/耗电预算、隐私清单申报、需用户同意后才可联网)才建议关闭。
|
|
43
45
|
|
|
44
46
|
### Diff 算法对比
|
package/README.md
CHANGED
|
@@ -37,6 +37,8 @@ What to know:
|
|
|
37
37
|
- **It never blocks startup**: it is delayed by a few seconds, runs off the main thread, and its result takes effect on the *next* launch.
|
|
38
38
|
- **Whether it activates depends on your configuration**: only with `updateStrategy` set to `silentAndNow` / `silentAndLater` *and* automatic checks left on (`checkStrategy` not `null`) will the native side mark a downloaded version for the next launch. Otherwise it downloads and leaves activation to JS.
|
|
39
39
|
- **Rescue directive**: the dashboard can mark a version "force boot", which activates on the next launch regardless of the strategies above — this is how a fleet stuck on a broken version is recovered. The device-local crash-rollback guard still wins: a version this device already rolled back from is never reinstalled.
|
|
40
|
+
- **Crash-moment rescue** (since 10.52.0, Android & iOS): when the app dies of an uncaught JS error during startup, the SDK briefly holds the dying process (a few seconds, bounded) to finish the check and download — so even a version that crashes a fraction of a second into every launch gets replaced. In that window the downloaded fix is always activated for the next launch, since JS is no longer around to decide. Crash reporters keep working: the SDK chains the previous crash handler and always hands the crash over afterwards. Not covered: native (non-JS) crashes, ANRs, OOM kills, and — on iOS — apps that install a custom `RCTSetFatalHandler` (React Native then no longer raises the exception this rescue intercepts).
|
|
41
|
+
- **Resumable downloads** (since 10.52.0): update downloads survive process death and resume from where they stopped (HTTP Range), so repeated short-lived launches still make monotonic progress; a launch that follows an interrupted round skips the startup delay and resumes immediately. HarmonyOS does not need the crash-moment hold: an uncaught JS error does not kill the process there, so the native check completes normally even when JS dies on launch (requires 10.52.1 — earlier versions never armed the native check on HarmonyOS due to a bridging bug).
|
|
40
42
|
- **It can be turned off**: `disableNativeCheck: true` removes one background request per cold start, at the cost of **giving up the recovery above** — a device bricked by a bad update can no longer heal itself. Choose it only when that request is itself the problem (traffic/battery budgets, privacy manifests, consent-gated networking).
|
|
41
43
|
|
|
42
44
|
## Diff Algorithm Comparison
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
package cn.reactnative.modules.update;
|
|
2
|
+
|
|
3
|
+
import android.os.Looper;
|
|
4
|
+
import android.os.Process;
|
|
5
|
+
import android.os.SystemClock;
|
|
6
|
+
import android.util.Log;
|
|
7
|
+
import java.util.concurrent.CountDownLatch;
|
|
8
|
+
import java.util.concurrent.TimeUnit;
|
|
9
|
+
import java.util.concurrent.atomic.AtomicBoolean;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Crash-moment brick rescue (NATIVE_CHECKUPDATE_DESIGN §11): when the app is
|
|
13
|
+
* dying of an uncaught exception during startup, the process is still alive
|
|
14
|
+
* and JS will never run again — a natural, false-positive-free window to
|
|
15
|
+
* finish the cold-start update check before the crash dialog appears.
|
|
16
|
+
*
|
|
17
|
+
* Etiquette: the previous default handler (crash reporters chain the same
|
|
18
|
+
* way) is always invoked afterwards, whether the rescue ran, timed out or
|
|
19
|
+
* threw. The rescue itself runs on a worker thread and the handler only
|
|
20
|
+
* waits with a hard timeout, so even a deadlocked rescue (a thread that died
|
|
21
|
+
* holding a lock) can only delay the process death, never prevent it.
|
|
22
|
+
*/
|
|
23
|
+
final class CrashRescue {
|
|
24
|
+
private static final AtomicBoolean installed = new AtomicBoolean(false);
|
|
25
|
+
private static final AtomicBoolean rescueAttempted = new AtomicBoolean(false);
|
|
26
|
+
// ≈ process start: install() runs during the first bundle resolution.
|
|
27
|
+
// Deliberately not Process.getStartElapsedRealtime(), which is API 24+
|
|
28
|
+
// while the module's minSdk floor is lower — a NoSuchMethodError here
|
|
29
|
+
// would silently disable the whole rescue on those devices.
|
|
30
|
+
private static volatile long installedAtElapsedRealtime;
|
|
31
|
+
private static final long TRIGGER_UPTIME_MILLIS = 60000;
|
|
32
|
+
private static final long BUDGET_BACKGROUND_THREAD_MILLIS = 10000;
|
|
33
|
+
// A held main thread stops input dispatch; stay under the ~5s ANR window.
|
|
34
|
+
private static final long BUDGET_MAIN_THREAD_MILLIS = 3500;
|
|
35
|
+
|
|
36
|
+
private CrashRescue() {
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static void install() {
|
|
40
|
+
if (!installed.compareAndSet(false, true)) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
installedAtElapsedRealtime = SystemClock.elapsedRealtime();
|
|
44
|
+
final Thread.UncaughtExceptionHandler previous =
|
|
45
|
+
Thread.getDefaultUncaughtExceptionHandler();
|
|
46
|
+
Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
|
|
47
|
+
@Override
|
|
48
|
+
public void uncaughtException(Thread thread, Throwable error) {
|
|
49
|
+
try {
|
|
50
|
+
maybeHoldForRescue(thread);
|
|
51
|
+
} catch (Throwable ignored) {
|
|
52
|
+
// The dying process owes the previous handler its turn no
|
|
53
|
+
// matter what the rescue did.
|
|
54
|
+
}
|
|
55
|
+
if (previous != null) {
|
|
56
|
+
previous.uncaughtException(thread, error);
|
|
57
|
+
} else {
|
|
58
|
+
// Mirror RuntimeInit's default behaviour so the process
|
|
59
|
+
// still dies when no other handler was registered.
|
|
60
|
+
Process.killProcess(Process.myPid());
|
|
61
|
+
System.exit(10);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private static void maybeHoldForRescue(Thread crashedThread) {
|
|
68
|
+
// Once per process; a second crashing thread passes straight through
|
|
69
|
+
// instead of waiting behind the first (§11.3: prefer under-rescuing
|
|
70
|
+
// over wedging the teardown).
|
|
71
|
+
if (!rescueAttempted.compareAndSet(false, true)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
long uptimeMillis =
|
|
75
|
+
SystemClock.elapsedRealtime() - installedAtElapsedRealtime;
|
|
76
|
+
boolean roundInFlight = NativeCheckOrchestrator.isRoundInFlight();
|
|
77
|
+
// Early crashes are the brick signature; a crash with an in-flight
|
|
78
|
+
// round is worth finishing regardless of uptime. Everything else is
|
|
79
|
+
// an ordinary crash whose UX must not be delayed.
|
|
80
|
+
if (uptimeMillis >= TRIGGER_UPTIME_MILLIS && !roundInFlight) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
boolean onMainThread = crashedThread == Looper.getMainLooper().getThread();
|
|
84
|
+
long budgetMillis = onMainThread
|
|
85
|
+
? BUDGET_MAIN_THREAD_MILLIS
|
|
86
|
+
: BUDGET_BACKGROUND_THREAD_MILLIS;
|
|
87
|
+
final long deadlineNanos =
|
|
88
|
+
System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(budgetMillis);
|
|
89
|
+
Log.i(UpdateContext.TAG, "crash rescue: holding process for up to "
|
|
90
|
+
+ budgetMillis + "ms (uptime " + uptimeMillis + "ms)");
|
|
91
|
+
|
|
92
|
+
final CountDownLatch done = new CountDownLatch(1);
|
|
93
|
+
Thread worker = new Thread(new Runnable() {
|
|
94
|
+
@Override
|
|
95
|
+
public void run() {
|
|
96
|
+
try {
|
|
97
|
+
NativeCheckOrchestrator.runRescue(deadlineNanos);
|
|
98
|
+
} catch (Throwable e) {
|
|
99
|
+
Log.w(UpdateContext.TAG, "crash rescue failed: " + e);
|
|
100
|
+
} finally {
|
|
101
|
+
done.countDown();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}, "pushy-crash-rescue");
|
|
105
|
+
worker.setDaemon(true);
|
|
106
|
+
worker.start();
|
|
107
|
+
try {
|
|
108
|
+
if (!done.await(budgetMillis, TimeUnit.MILLISECONDS)) {
|
|
109
|
+
Log.w(UpdateContext.TAG, "crash rescue: budget exhausted, letting go");
|
|
110
|
+
}
|
|
111
|
+
} catch (InterruptedException e) {
|
|
112
|
+
Thread.currentThread().interrupt();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|