react-native-update 10.50.0 → 10.51.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.
Files changed (50) hide show
  1. package/NATIVE_CHECKUPDATE_DESIGN.md +251 -21
  2. package/NATIVE_CHECK_FOLLOWUPS.md +365 -0
  3. package/README-CN.md +14 -1
  4. package/README.md +14 -1
  5. package/android/build.gradle +10 -0
  6. package/android/jni/Android.mk +5 -1
  7. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  8. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  9. package/android/lib/x86/librnupdate.so +0 -0
  10. package/android/lib/x86_64/librnupdate.so +0 -0
  11. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +76 -21
  12. package/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +3 -0
  13. package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +455 -0
  14. package/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java +23 -0
  15. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +160 -42
  16. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +44 -0
  17. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
  18. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
  19. package/cpp/update_flow_core/flow_json.cpp +522 -0
  20. package/cpp/update_flow_core/flow_json.h +103 -0
  21. package/cpp/update_flow_core/tests/flow_vectors.json +1401 -0
  22. package/cpp/update_flow_core/tests/update_flow_core_test.cpp +317 -0
  23. package/cpp/update_flow_core/update_flow_core.cpp +301 -0
  24. package/cpp/update_flow_core/update_flow_core.h +77 -0
  25. package/cpp/update_flow_core/update_flow_jni.cpp +121 -0
  26. package/harmony/pushy/src/main/cpp/CMakeLists.txt +7 -0
  27. package/harmony/pushy/src/main/cpp/pushy.cpp +103 -1
  28. package/harmony/pushy/src/main/ets/DownloadTask.ts +48 -9
  29. package/harmony/pushy/src/main/ets/DownloadTaskParams.ts +3 -0
  30. package/harmony/pushy/src/main/ets/MonotonicClock.ts +13 -0
  31. package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +500 -0
  32. package/harmony/pushy/src/main/ets/NativePatchCore.ts +14 -0
  33. package/harmony/pushy/src/main/ets/PathUtils.ts +21 -0
  34. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +22 -0
  35. package/harmony/pushy/src/main/ets/UpdateContext.ts +148 -69
  36. package/harmony/pushy.har +0 -0
  37. package/ios/RCTPushy/RCTPushy.mm +779 -61
  38. package/ios/RCTPushy/RCTPushyDownloader.h +1 -0
  39. package/ios/RCTPushy/RCTPushyDownloader.mm +4 -3
  40. package/package.json +3 -1
  41. package/react-native-update.podspec +2 -0
  42. package/src/NativePushy.ts +14 -0
  43. package/src/client.ts +341 -118
  44. package/src/endpoint.ts +7 -29
  45. package/src/provider.tsx +38 -2
  46. package/src/type.ts +26 -0
  47. package/src/updateFlowCore.ts +320 -0
  48. package/src/utils.ts +1 -12
  49. package/src/isInRollout.ts +0 -87
  50. package/src/resolveCheckResult.ts +0 -30
@@ -0,0 +1,365 @@
1
+ # 原生冷启动检测:遗留改进项
2
+
3
+ > 来源:`agent/harden-native-check-update` 分支两轮评审
4
+ > (f679e11 初评 10 项 → 181952a 修 4 缓解 1;181952a 复评又出 10 项,
5
+ > 其中 6 项为修复自身引入,已并入下文)。
6
+ > 本文最初只记录开放项。2026-08-10 已完成代码项复核与修复;下文保留
7
+ > 原始问题描述用于追溯,处理结论以紧随其后的状态表为准。发版动作仍保留在
8
+ > 文末清单中,不因代码合入而自动视为完成。
9
+
10
+ ## 2026-08-10 处理结论
11
+
12
+ | 项目 | 结论 | 落地方式 |
13
+ |---|---|---|
14
+ | P1 Android/Harmony 重复下载 | 已修复 | 任务真正开始时复查 `.pushy-complete` + bundle;失败清理检测到完整安装时不删目录。iOS 注册表同时升级为按 hash + artifact type 合流/排队 |
15
+ | P2 缓存 `ts` 锚点 | 已修复 | 三端在 check 响应到达时捕获时间,下载、补丁和激活结束后仍写该时间 |
16
+ | P2 下载轮次 deadline | 已修复 | 三端统一为 diff/pdiff 共享 600s、full 独享 600s;发起前检查绝对 deadline,下载任务在真正开跑时按剩余预算设置 whole-call timeout |
17
+ | P2 JS 配置回退竞态 | 已修复 | 在途写入期间仍记录与 `synced` 相同的最新期望值;B 完成后会继续把 A 写回;删除同步 throw 路径的无效递归 |
18
+ | P2 iOS 合流语义 | 已修复 | 同类型下载共享结果并向所有 join 者广播进度;不同类型按 hash 排队,避免把 diff 的失败错误归给 full |
19
+ | P3 `.pushy-complete` 迁移 | 接受一次性重下 | 不把仅有 bundle 的旧目录推断为完整安装,避免把半解压目录误标为成功;10.51.0 发布说明仍需明确这一流量成本 |
20
+ | P3 状态解析异常不调度 | 已修复 | 三端启动解析改为 finally 调度;正常路径保留最终 rollback 快照,异常路径使用空快照 |
21
+ | P3 无 hash 死弹窗 | 已修复 | Provider 将无 hash 的 update 降级为开发者日志/遥测与 `upToDate`,不再展示不可执行的确认按钮 |
22
+ | P3 `noArtifact` 静默 | 已修复 | 保持终端用户无感,同时恢复 `errorUpdate` 报告并携带目标 hash |
23
+ | P4 bundleHash 窗口 | 接受并记录 | `readNativeCheckCache` 已注明首启预取未完成时有意 cache miss,不为 hash 阻塞检查 |
24
+ | P4 缓存冗余解析 | 已修复 | 直接传入现成 `fetchBody`/native config 对象,只解析缓存中的字符串一侧 |
25
+ | P4 endpoint 斜杠重复 | 已修复 | 三端首轮请求也先查规范化后的 `tried` 集合,重复项不计入 8 次上限 |
26
+ | P4 Harmony 整请求超时 | 已修复 | check HTTP 增加 15s whole-call cap;更新下载增加绝对 deadline 并在超时后销毁请求 |
27
+
28
+ 代码验证基线:JS 完整回归 173 项、Biome/TypeScript/Harmony strict 类型检查、
29
+ 77 项 flow core ASan/UBSan、29 项 patch core、Harmony debug HAR、Android
30
+ Release Java 编译、iOS Release simulator 静态库构建均通过。
31
+
32
+ ---
33
+
34
+ ## 2026-08-10 第三轮评审(283dfd4)开放项
35
+
36
+ 上表 13 项的修复经复评确认全部属实;以下为修复自身引入/暴露的新开放项
37
+ (详情与逐条修法见评审面板)。
38
+
39
+ 复核后处理结论:
40
+
41
+ | 项 | 结论 | 落地方式 |
42
+ |---|---|---|
43
+ | 1 iOS joiner 预算 | 已修复 | 注册表记录 owner 的单调时钟 deadline;预算更长的 waiter 观察当前进度但 deferred,owner 成功时由完成标记立即命中,失败时以自己的完整预算重启 |
44
+ | 2 Harmony 外层时限 | 已修复 | `performAttempts` 用绝对单调 deadline 包住排队、HTTP、解压和 hpatch 的完整 Promise;底层串行任务即使晚结束也不再阻止编排器落响应缓存 |
45
+ | 3 壁钟 deadline | 已修复并修正文档结论 | iOS 改用 `systemUptime`,Harmony 改用 `systemDateTime.getUptime`;full 预算进入 full 阶段才创建,因此原文“增量阶段校时会同时耗尽尚未创建的 full 预算”不成立 |
46
+ | 4 Android full 判定 | 已修复 | 与分发逻辑及 iOS/Harmony 一致,非 diff/pdiff 统一视为 full;上游当前只生成三种合法类型,此项属于防御性收口 |
47
+ | 5 坏发布遥测膨胀 | 已修复 | 缺 hash 与 noArtifact 共用按 appKey/reason/hash 的进程内去重,保留一次服务端可见的坏发布信号 |
48
+ | 6 有 hash 无产物弹窗 | 已修复 | Provider 在展示/静默下载前复用 `decideDownload`,noArtifact 降级为 `upToDate` 与一次开发者遥测 |
49
+ | 7 deferred UX/deadline | 已修复 | deferred waiter 订阅当前同 hash 进度;旧 deadline 在重新注册前校验,过期的编排器请求不会成为 owner 或结算后来的 JS 请求 |
50
+ | 8 owner-only 进度事件 | 不采纳 | 支持路径在 JS 已按 hash 维持单一原生监听;常见 join 是无监听器的冷启动 engine + JS bridge。限制为 owner 发事件会在 engine 先成为 owner 时丢失 JS 进度 |
51
+ | 9 iOS 完成判定重复 | 已修复 | 抽取 `PushyHasCompletedVersionAtPath`,预检与冷启动编排器共用同一 bundle+marker 判定 |
52
+
53
+ **P2(三端时限模型的二阶问题,建议一并收口)**
54
+ 1. **iOS 合流者继承 owner 剩余预算**:JS 同 hash 同类型合流到编排器下载时,
55
+ 共享会话带的是编排器所剩阶段预算(可能只剩几十秒),其超时会结算全部
56
+ 合流者——修改前 JS 独立下载固定 600s 的不变量对合流路径失效。
57
+ 2. **Harmony await 无外层时限**:统一预算只管住 HTTP 流;解压/hpatch 卡死
58
+ 或串行链被长任务占用时 `await context.downloadX` 永不返回,救援轮次挂满
59
+ 进程生命周期,响应缓存也不落盘。修法:performAttempts 层对每个 await 包
60
+ Promise.race 绝对 deadline。
61
+ 3. **壁钟 vs 单调钟**:iOS/Harmony 的 deadline 锚壁钟(epoch/Date.now),
62
+ Android 锚 nanoTime——首次联网触发 NTP 前跳(恰是救援检测时刻)会让
63
+ iOS/Harmony 全部预算(含 full 保底)瞬时过期。修法:iOS 用
64
+ systemUptime/mach 时基,Harmony 用相对定时器。
65
+
66
+ **P3**
67
+ 4. **Android 按字面 "full" 判保底预算**,分发 else 分支却把任意非 diff/pdiff
68
+ 类型当 full 下载;iOS/Harmony 用排除法——三端对"谁享有 full 保底"不再
69
+ 一致,Android 改排除法即齐。
70
+ 5. **noArtifact 遥测重复膨胀**:映射到 download_fail 聚合且每次
71
+ downloadUpdate 调用都上报(静默策略下每个检查周期一条),需按 hash
72
+ 会话内去重或换不入聚合的事件类型。
73
+ 6. **有 hash 无产物的死弹窗变体**:provider 只降级了无 hash 的 update;
74
+ 有 hash 但无任何产物 URL 的坏发布仍弹确认框、确认后静默。
75
+ 7. **iOS deferred 的 UX 与陈旧 deadline**:异类型 deferred 请求无进度、
76
+ 延迟开始(UI 冻结);重启时携带编排器的旧 deadlineAt,排队期间过期则
77
+ 瞬时失败并连带结算 JS 合流者。JS 发起的请求不应携带编排器 deadline。
78
+
79
+ **P4**
80
+ 8. **iOS 合流者进度块重复发事件**(N 合流者 = N 倍 RN 事件,事件发送应只由
81
+ owner 承担)。
82
+ 9. **完成判定(bundle+marker)在 iOS 两处手写**,抽 PushyHasCompletedVersion
83
+ helper 防漂移。
84
+
85
+
86
+ ---
87
+
88
+ ## P1 — Android/Harmony 重复下载竞态,失败路径可删除已安装版本
89
+
90
+ **现状**:iOS 在 f679e11 引入了进程级在途下载注册表
91
+ (`PushyRegisterDownload`/`PushyFinishDownload`),同一 hash 的并发下载合流。
92
+ Android 与 Harmony 只有任务串行化,没有按 hash 去重,也不保护已完成目录。
93
+
94
+ **风险**:JS 自动下载版本 H 进行中(单线程 executor 排队),原生检测 5 秒后
95
+ `hasCompletedVersion(H)` 为 false,再排入第二个 H 的下载任务。JS 任务完成、
96
+ 写入 `.pushy-complete` 并 `setNeedUpdate(H)` 后,排队的重复任务重新下载 H
97
+ 且中途失败(断网)时,`cleanUpAfterFailure` 删除 `rootDir/H`(含
98
+ `index.bundlejs` 与标记)。下次冷启动解析到 H 但 bundle 缺失 → 回滚——
99
+ **已成功安装的更新被静默丢失**。
100
+
101
+ **建议修法**(三选一,或组合):
102
+ 1. 移植 iOS 的按 hash 在途注册表(Android 用
103
+ `ConcurrentHashMap<String, List<Listener>>`,Harmony 用模块级 Map);
104
+ 2. 更小的止血:`cleanUpAfterFailure` 里若目标目录已有
105
+ `.pushy-complete` 标记则不删(失败的是重复下载,不是这份安装);
106
+ 3. DownloadTask 开跑前再查一次 `hasCompletedVersion(hash)`,已完成即直接
107
+ 走成功回调。
108
+ 方案 2+3 组合改动最小且互补;方案 1 语义最完整(还能省一次重复下载)。
109
+
110
+ ---
111
+
112
+ ## P2 — 响应缓存的 `ts` 锚在持久化时刻,而非响应到达时刻
113
+
114
+ **现状**:`persistResponseCache` 在原生下载/激活全部结束后才落盘,`ts` 取
115
+ 当时时间。181952a 给 Android 加了整轮 600s deadline,把最坏偏移压到
116
+ ~12 分钟,但锚点本身未改;iOS 仍是逐 URL 600s、Harmony 依赖下载任务自身
117
+ 超时,无整轮上限。
118
+
119
+ **风险**:版本发布几分钟后被运营撤下/回滚时,一个"响应早已过时但 ts 很新"
120
+ 的缓存会让 JS 在 120s 窗口内把已撤回的响应当新鲜结果复用,继续下载/激活
121
+ 已撤回版本。
122
+
123
+ **建议修法**:响应到达时捕获 `responseAtSeconds`,作为参数传入
124
+ `persistResponseCache` 写进 `ts`(三端各一行改动,JS 读侧无需变)。
125
+
126
+ **复评补充(181952a 引入/暴露)——下载轮次时限应作为一个整体设计统一三端**:
127
+ 1. 整轮 600s deadline 只落了 Android;iOS 仍逐 URL 600s(上界
128
+ attempts×urls×600s,可达 90 分钟),Harmony 完全没有(慢滴 CDN 在每个
129
+ 60 秒窗口内返回至少一个字节即可绕过下载任务的不活动看门狗,轮次可跑数小时);
130
+ 2. Android 的 deadline 在下一个下载**已发起后**才检查——超时轮次仍会
131
+ 多启动一个孤儿下载,浪费带宽且可能与 JS 重试并发写版本文件
132
+ (修法:发起前先查);
133
+ 3. 整轮预算会被 diff/pdiff 的失败耗尽,挤压救砖最后手段 full 的时间
134
+ (原先 full 独享 600s)——建议 full 单独保底额度。
135
+
136
+ ---
137
+
138
+ ## P2 — JS 配置同步的回退竞态(181952a 引入)
139
+
140
+ **现状**:`syncNativeConfig` 的去重把当前配置与**最后一次已完成**的写入
141
+ (`syncedNativeConfigJson`)比较。
142
+
143
+ **风险**:已同步 A → setOptions 改为 B(写入在途)→ B 完成前又回退为 A:
144
+ 回退因等于 `synced`(仍是 A)被直接丢弃,随后 B 完成、`synced=B`、pending
145
+ 为空——原生 KV 永久持有错误的策略/endpoints(如 app 已切回 alert 却按
146
+ silent 激活),直到未来某次不同值的 setOptions 才被纠正。
147
+
148
+ **建议修法**(改动极小):去重时把在途/待写值一并纳入比较,或写入完成回调里
149
+ 与"最新期望值"复核不符则重新入队。顺手删掉 `flushNativeConfig` 同步抛
150
+ catch 里的死代码递归"重试"(pending 已清空,必然早退,误导维护者)。
151
+
152
+ ---
153
+
154
+ ## P2 — iOS 合流下载的两个语义代价
155
+
156
+ **现状**:JS 的下载调用合流到冷启动引擎的在途下载后:
157
+ 1. **收不到进度事件**——事件只由持有下载的实例发出,引擎实例
158
+ `hasListeners=NO`,用户看到进度条冻在 0;
159
+ 2. **继承异类 attempt 的失败**——引擎的 diff 尝试 404 时,JS 本可成功的
160
+ full 下载调用被判失败(JS 策略链会继续下一策略+重试回退,最终多能成功,
161
+ 但单次调用的失败归因是错的)。
162
+
163
+ **建议修法**:注册表回调列表旁存进度回调,引擎下载的 NSURLSession 进度
164
+ 透传给所有 join 者(解决 1);join 时携带请求类型,类型不同不合流、改为
165
+ 排队串行(解决 2,代价是偶发的一次串行等待)。若认为 JS 策略链的自愈已
166
+ 足够,可显式拍板只修 1。
167
+
168
+ ---
169
+
170
+ ## P3 — `.pushy-complete` 无迁移,存量已下载版本升级后重下一次
171
+
172
+ **现状**:旧版本库下载的版本目录没有标记文件;升级到新库后
173
+ `hasCompletedVersion` 判 false,原生检测整包重下一遍(下载完成后写标记,
174
+ 自愈,每台受影响设备一次)。
175
+
176
+ **风险**:一次性流量成本,仅影响"已下载未激活"(alert 类策略)的存量设备。
177
+
178
+ **建议**:二选一显式拍板——(a) 接受成本,在 CHANGELOG 注明;(b) 加迁移:
179
+ 首次运行时对"有 `index.bundlejs` 且 mtime 早于本次库安装"的目录补写标记
180
+ (无法区分半解压目录,有误判风险,故 (a) 可能更稳)。
181
+
182
+ ---
183
+
184
+ ## P3 — 启动状态解析抛异常时,救援检测永不调度
185
+
186
+ **现状**:f679e11 把 `schedule` 从 `getBundleUrl`/`+bundleURL` 顶部移到
187
+ 各出口(为携带回滚快照),但若 `runStateCore`/回滚循环自身抛异常,所有
188
+ 出口都到不了。
189
+
190
+ **风险**:持久化状态损坏(恰是救援机制存在的场景)时,救援检测在后续所有
191
+ 启动中都不运行。
192
+
193
+ **建议修法**:状态解析段包 try/finally,finally 里以"空快照"调度
194
+ (rolledBackVersion 传 null 即可,宁可少守卫不可不调度——与"判定失败不
195
+ 计数"同一取舍方向);三端同改。
196
+
197
+ ---
198
+
199
+ ## P3 — 无 hash 的灰度条目从"静默忽略"变成"死弹窗"(181952a 语义变更的副作用)
200
+
201
+ **现状**:`resolveCheckResult` 的非空 hash 守卫移除了旧的
202
+ `undefined === undefined` 等价:服务端误配出无 hash 的灰度条目时,内置包
203
+ 设备(currentVersion 为空)从静默 upToDate 变为返回 `update:true` 且无
204
+ hash——已被再生成的金标向量固化。
205
+
206
+ **风险**:alert 策略下每次检查都弹"发现新版本",点确认后 `decideDownload`
207
+ 因 `!hash` 判 noUpdate——死弹窗,按钮无效,每次检查重现。
208
+
209
+ **建议修法**:若新语义有意(暴露服务端错配),在 provider 层把无 hash 的
210
+ update 降级为日志 + 遥测而非弹窗;若无意,恢复"无 hash 条目不视为可更新"。
211
+ 与下一条(noArtifact)同属"坏发布该以遥测暴露而非 UI 弹窗/静默"。
212
+
213
+ ---
214
+
215
+ ## P3 — `noArtifact` 分支静默返回,丢失 errorUpdate 遥测(181952a 引入)
216
+
217
+ **现状**:decideDownload 新增的 noArtifact 拒绝让 `downloadUpdate` 无日志、
218
+ 无错误事件、无遥测地返回;旧的空 attempts 路径会上报
219
+ `{type:'errorUpdate'}`。
220
+
221
+ **风险**:服务端发出 update:true 有 hash 但无任何产物 URL 的坏发布,从
222
+ "控制台可见"退化为"客户端静默无事发生",坏发布隐形。
223
+
224
+ **建议修法**:noArtifact 分支恢复 report({type:'errorUpdate', ...}),保持
225
+ 用户无感但平台可见。
226
+
227
+ ---
228
+
229
+ ## P4 — bundleHash 未就绪窗口的请求指纹失配(记录即可)
230
+
231
+ **现状**:JS 预取 bundleHash 未 settle 时构造的请求体缺 `bundleHash` 键,
232
+ 与原生缓存的请求(原生同步计算,总带该键)键集不同 → 指纹不命中,走网络。
233
+ 窗口极窄(通常仅首启,且该时刻缓存多半尚不存在);`overridePackageVersion`
234
+ 的失配已在 181952a 通过配置携带 `packageVersion` 修复。
235
+
236
+ **建议**:在 `readNativeCheckCache` 注释里写明这是已知且有意的失配面,
237
+ 不做代码改动。
238
+
239
+ ---
240
+
241
+ ## P4 — `readNativeCheckCache` 冗余解析(cleanup)
242
+
243
+ **现状**:调用方刚 stringify 的请求体被立即 parse 回来做结构比较,外加对
244
+ 当前 config JSON 的一次重复解析——每次 release 路径 checkUpdate 的固定
245
+ 开销。
246
+
247
+ **建议**:把现成的 `fetchBody` 对象传入,只 parse `entry.request` 一侧。
248
+
249
+ ---
250
+
251
+ ## P4 — 若干小项(复评新增)
252
+
253
+ - **斜杠变体 endpoint 重复请求**:尾斜杠归一化发生在 C++ 去重之后,且首轮
254
+ 循环只 add 不查 `tried`——`https://u.example.com` 与 `…com/` 会对同一
255
+ URL POST 两次,还白占 8 次 HTTP 上限中的两次。修法:归一化提前进纯层
256
+ (`orderEndpointCandidates` 前),或首轮也查 `tried`。
257
+ - **Harmony 检查请求缺整请求封顶**:只有 connect/read 超时,没有 Android
258
+ `callTimeout(15s)` / iOS 超时取消的对应物,慢滴响应可拖长每次尝试。
259
+
260
+ ---
261
+
262
+ ## 2026-08-11 第四轮评审(60f5fd2..5f5d0cb)开放项
263
+
264
+ 上一轮 9 项闭环全部属实(第 8 项"owner-only 进度事件"维护者明确不采纳,理由
265
+ 成立)。以下为仍开放项;**前三条建议合入前处理**,其余可随小版本。
266
+
267
+ ### 合并阻断 —— 已于 2026-08-11 修复(见下方"修复说明")
268
+
269
+ #### 原始问题描述
270
+
271
+ 1. **原生检测无视 `checkStrategy`,并能撞销 `resetToPackagedBundle`**
272
+ (CI 已红)。`getNativeConfig` 只用 `updateStrategy` 折算 `afterDownload`,
273
+ 从不读 `checkStrategy`;三端也没有 reset↔检测 的任何联动(无 generation、
274
+ 无取消,reset 也不清 `nativeCheckResp`)。e2e app 明写 `checkStrategy: null`
275
+ 却仍被原生自动下载+激活;`resetToPackagedBundle` 可被在飞的检测撤销。
276
+ **证据**:e2e-ios 在 `0f4651e` 与 `5f5d0cb` 两次运行均挂在 `beforeEach`
277
+ 的 `bundleLabel: BINARY_BASE`,且两次挂的是不同用例(竞态签名);失败态
278
+ `currentHash: e2e-full-v1` 恰是"reset 后从零检查会拿到的第一个版本";
279
+ master 上 e2e-ios 为绿 → 本分支引入的回归。本分支新增的
280
+ `hasCompletedVersion` 快路径(版本已落盘则跳过下载直接 switchVersion)
281
+ 把竞态窗口从"下载完"压到"5 秒后瞬间",是这轮才炸的原因。
282
+ **修法**:`afterDownload` 计算纳入 `checkStrategy`(为 null 时降为
283
+ `'none'`——只下载不激活,`forceBoot` 仍可救砖);`resetToPackagedBundle`
284
+ bump 进程级 generation,编排器在 `switchVersion`/落缓存前比对,变了就
285
+ 放弃,并清掉响应缓存。
286
+
287
+ 2. **iOS 同类型 join 分支实际不可达**。`deadlineUptime > ownerDeadline` 是
288
+ 严格大于,而 JS 发起的请求总晚于 owner 的计算时刻,因此永远走 deferred:
289
+ P1"一个 hash 共享一次下载"的意图失效,退化为串行重下;CDN 黑洞时用户
290
+ 弹窗可转近 20 分钟(改前是合流后一次失败并回退下一候选 URL)。修法:比较
291
+ 加容忍阈值,或 JS 发起的请求不参与 deadline 比较。
292
+
293
+ 3. **Harmony 用 `TimeType.STARTUP`**(计深度睡眠),而 iOS `systemUptime` /
294
+ Android `nanoTime` 睡眠时停走——"三端统一单调钟"不成立。锁屏休眠数分钟
295
+ 即让预算过期、救援中止,同网络的另两端能续传完成。一行改
296
+ `TimeType.ACTIVE`。
297
+
298
+ #### 修复说明(2026-08-11)
299
+
300
+ 1. **checkStrategy + reset 竞态**:`getNativeConfig` 的 `afterDownload` 现在
301
+ 要求 `checkStrategy != null` —— 关掉自动检查的应用不会再被塞一次它没要
302
+ 过的版本切换;检测本身照跑(救砖能力不变),`forceBoot` 仍可激活。另加
303
+ **reset 代数守卫**:`resetToPackagedBundle` 递增进程级计数并清掉响应缓存,
304
+ 编排器在开跑前采样、在激活与落缓存前比对,不一致即整轮丢弃。三端同构
305
+ (iOS `std::atomic<uint64_t>`、Android `AtomicLong`、Harmony 静态计数)。
306
+ 2. **iOS join 分支不可达**:比较从"绝对 deadline 严格大于"改为"剩余预算",
307
+ 仅当 owner 剩余不足新来者的一半时才 defer —— 正常情况(JS 晚几秒发起)
308
+ 恢复合流,只有真正濒临耗尽的 owner 才让位。
309
+ 3. **Harmony 单调钟**:`TimeType.STARTUP` → `TimeType.ACTIVE`,与 iOS
310
+ `systemUptime` / Android `nanoTime` 的"睡眠时停走"语义对齐。
311
+
312
+ **补强(2026-08-11,CodeRabbit 复核后)**:初版守卫是 compare-and-act,且
313
+ `hash_<hash>` 元信息写在守卫之前——reset 若落在"比对通过"与"写入"之间仍
314
+ 可被覆盖。现改为**一次原子提交**:版本元信息 + 激活 + 响应缓存合并为
315
+ `commitNativeCheckResult(expectedGeneration, ...)`,内部先复核代数再落全部
316
+ 写入;`resetToPackagedBundle` 与之互斥并**先失效代数再清状态**。互斥手段按
317
+ 端选取:iOS 复用既有 `PushyWithStateLock`(为此把 switchVersion 拆出无锁核
318
+ `PushySwitchVersionLocked`,避免不可重入死锁)、Android 用共享
319
+ `commitLock`、Harmony 是 ArkTS 单线程且提交内无 await,天然原子(已注释说
320
+ 明)。三处落点(元信息/激活/缓存)与三个调用点(nothing-to-do、未下载、成功)
321
+ 全部走同一入口。
322
+
323
+ 验证:JS 178 项(新增 2 项 checkStrategy 折算用例)、Biome/tsc/DevEco
324
+ strict、77 金标向量 + ASan/UBSan、`.so` 符号与 16KB 对齐、iOS clang
325
+ (DEBUG=0/1)、Android javac(main+oldarch)、OHOS 工具链语法。本轮不触
326
+ `cpp/`,无需重生成向量或重编 `.so`。
327
+
328
+ ### 后续小版本
329
+
330
+ 4. Harmony 超时只放弃编排器的 `await`,卡死的 DownloadTask 仍占 `taskChain`,
331
+ 后续 attempt 只是排队并空烧自己的新鲜预算(有效 full 产物 + 完整预算都在
332
+ 却仍救不回)。修法:超时真正取消底层任务,或救援轮次用独立链。
333
+ 5. iOS/Harmony 的 `switchVersion` 仍可激活半完成安装(Android 本轮加了无标记
334
+ 拒绝守卫,三端 parity 分歧)。
335
+ 6. iOS deferred 异类型 waiter 订阅了 owner 的进度流,收到的是另一种产物的
336
+ 字节数(进度条先到 100% 再回 0%)。修法:只向同类型合流者广播。
337
+ 7. 缺 hash 守卫新加的 `!info.expired` 让 `{expired:true, update:true}` 且无
338
+ hash 的畸形响应既不降级也不上报,却仍把 `update:true` 发给业务侧。
339
+ 8. `getNativeConfigJson() ?? '{"disabled":true}'` 会把瞬时为空的
340
+ `appKey`/`server.main` 变成持久 disabled 写入,覆盖上一份可用配置并长期
341
+ 关掉救砖能力。修法:仅在确实不可用(web/旧原生)时写 disabled。
342
+ 9. `noArtifact` 上报位置从 `client.downloadUpdate`(实际尝试下载)移到
343
+ `provider.checkUpdate`(只要检查就发),映射到服务端 `download_fail` 聚合
344
+ 后会把一次坏发布放大成全量设备级的健康下降。
345
+ 10. 两处 cleanup:Android `switchVersion` 重写了同文件已有的
346
+ `hasCompletedVersion` 谓词;provider 为一个是非问题重跑完整
347
+ `decideDownload`(分配 URL 计划后丢弃)。
348
+
349
+ ### 累计
350
+
351
+ 四轮共 40 条发现:27 修复、2 显式接受、1 不采纳、10 开放(其中 3 条建议合入
352
+ 前处理)。
353
+
354
+ ---
355
+
356
+ ## 发版清单(非代码缺陷,勿遗漏)
357
+
358
+ - [ ] e2e:坏 bundle → 原生拉修复版 → 下次启动复活的端到端用例
359
+ (沿用 Example/e2etest 基建;harmony 走 hdc/uitest 链路)
360
+ - [ ] README / README-CN / CHANGELOG:原生冷启动检测、forceBoot、
361
+ 绑定依赖硬校验(RN 版本一致、rnu 不降级)
362
+ - [ ] 客户端 10.51.0 发版(admin 的 forceBoot 门槛与之对齐)
363
+ - [ ] 服务端已推送未激活:pushy 主机 `install-npm-release.sh` +
364
+ `service-cli.sh restart all`(验 serverVersion);cresc 跑
365
+ `update-cloudrun.sh`;两个 admin 走各自 CI
package/README-CN.md CHANGED
@@ -26,7 +26,20 @@
26
26
  6. 后台自带**数据统计**(版本分布、更新成功率等)、**灰度发布**(按比例逐步放量)与**版本健康度监控**,无需自建数据链路即可掌控每次发布。
27
27
  7. 支持崩溃回滚,安全可靠,结合健康度监控可及时发现并止损问题版本。
28
28
  8. meta 信息及开放 API,提供更高扩展性。
29
- 9. 提供付费的专人技术支持。
29
+ 9. 提供 **MCP 服务**:把热更新服务接进 Claude Desktop、IDE 或自建 Agent,用自然语言排查"这台设备为什么没收到更新",并可与 GitHub、Sentry、CI 等工具组合定位问题。全程只读、按应用授权([Pushy 文档](https://pushy.reactnative.cn/docs/mcp) / [Cresc 文档](https://cresc.dev/docs/mcp))。
30
+ 10. **原生冷启动自愈**:即使热更版本坏到 JS 完全跑不起来(白屏、启动即崩),设备也能在下次启动时由原生侧自动拉到修复版——不需要用户重装,也不需要你发新的应用商店版本(详见下方[原生冷启动检测](#原生冷启动检测))。
31
+ 11. 提供付费的专人技术支持。
32
+
33
+ ### 原生冷启动检测
34
+
35
+ 自 10.51.0 起,每次冷启动后数秒会在后台线程执行一次**不依赖 app bundle** 的更新检查(下载、打补丁与状态切换全部在原生侧完成)。它的存在只为一件事:**当前热更版本坏到 JS 起不来时,仍有一条能把修复版拉下来的通路**——常规更新流程仍由应用内的 JS 负责,检查结果会被 JS 侧复用,不会重复请求。
36
+
37
+ 几个需要知道的点:
38
+
39
+ - **不阻塞启动**:延迟数秒、跑在后台线程,成果在**下次启动**生效。
40
+ - **是否自动激活取决于你的配置**:`updateStrategy` 为 `silentAndNow` / `silentAndLater` 且未关闭自动检查(`checkStrategy` 不为 `null`)时,原生侧才会把下载好的版本设为下次启动生效;其余情况只下载,激活权仍在 JS。
41
+ - **救砖指令**:控制台可按版本标记「强制启动」,被标记的版本无视上述策略直接在下次启动生效——这是把已被坏版本卡死的设备捞回来的手段。设备本地的崩溃回滚保护仍然优先,已回滚过的版本不会被再装回去。
42
+ - **可以关闭**:`disableNativeCheck: true`。关闭后每次冷启动少一次后台请求,代价是**放弃上述自愈能力**——被坏热更卡死的设备将无法自动恢复。仅在这次请求本身构成问题时(流量/耗电预算、隐私清单申报、需用户同意后才可联网)才建议关闭。
30
43
 
31
44
  ### Diff 算法对比
32
45
 
package/README.md CHANGED
@@ -24,7 +24,20 @@ See the docs:
24
24
  6. The dashboard ships with **built-in analytics** (version distribution, update success rate), **staged (gray) rollouts** by percentage, and **release health monitoring** — no need to build your own data pipeline to keep every release under control.
25
25
  7. Built-in crash rollback keeps updates safe and reliable, and health monitoring helps you catch and stop a bad release early.
26
26
  8. Meta information and open APIs make the system more extensible.
27
- 9. Paid technical support is available.
27
+ 9. An **MCP server** lets you connect the update service to Claude Desktop, an IDE or your own agent, ask in plain language why a device never received an update, and investigate alongside GitHub, Sentry or CI. Everything is read-only and scoped per app ([Cresc docs](https://cresc.dev/docs/mcp) / [Pushy docs](https://pushy.reactnative.cn/docs/mcp)).
28
+ 10. **Native cold-start recovery**: even when an update is broken badly enough that JS never runs (white screen, crash on launch), the device pulls the fixed version on its next launch from the native side — no reinstall, no app-store release (see [Native cold-start check](#native-cold-start-check)).
29
+ 11. Paid technical support is available.
30
+
31
+ ## Native cold-start check
32
+
33
+ Since 10.51.0 every cold start runs one background update check a few seconds after launch that **does not depend on the app bundle** — the request, the download, the patch and the version switch all happen natively. It exists for exactly one reason: **when the running update is broken enough that JS never starts, something still has to be able to fetch the fix.** Normal updates remain the JS flow's job; the JS check reuses this result instead of issuing its own request.
34
+
35
+ What to know:
36
+
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
+ - **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
+ - **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
+ - **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).
28
41
 
29
42
  ## Diff Algorithm Comparison
30
43
 
@@ -154,6 +154,16 @@ android {
154
154
  }
155
155
  }
156
156
 
157
+ // AGP 9 disables the resValues feature for libraries by default; the
158
+ // pushy_build_time resource below needs it back on. The block exists
159
+ // since AGP 4, so guard like supportsNamespace() does for old projects.
160
+ def agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0].toInteger()
161
+ if (agpMajor >= 4) {
162
+ buildFeatures {
163
+ resValues true
164
+ }
165
+ }
166
+
157
167
  buildTypes {
158
168
  release {
159
169
  resValue("string", "pushy_build_time", "${minutesSinceEpoch}")
@@ -10,7 +10,8 @@ LOCAL_C_INCLUDES := \
10
10
  $(LOCAL_PATH)/HDiffPatch \
11
11
  $(LOCAL_PATH)/HDiffPatch/libHDiffPatch/HPatch \
12
12
  $(LOCAL_PATH)/lzma/C \
13
- $(LOCAL_PATH)/../../cpp/patch_core
13
+ $(LOCAL_PATH)/../../cpp/patch_core \
14
+ $(LOCAL_PATH)/../../cpp/update_flow_core
14
15
 
15
16
  Hdp_Files := \
16
17
  hpatch.c \
@@ -28,6 +29,9 @@ LOCAL_SRC_FILES := \
28
29
  ../../cpp/patch_core/patch_core_android.cpp \
29
30
  ../../cpp/patch_core/state_core.cpp \
30
31
  ../../cpp/patch_core/update_core_android.cpp \
32
+ ../../cpp/update_flow_core/flow_json.cpp \
33
+ ../../cpp/update_flow_core/update_flow_core.cpp \
34
+ ../../cpp/update_flow_core/update_flow_jni.cpp \
31
35
  $(Hdp_Files)
32
36
 
33
37
  include $(BUILD_SHARED_LIBRARY)
Binary file
Binary file
@@ -107,14 +107,29 @@ class DownloadTask implements Runnable {
107
107
  this.hash = params.hash;
108
108
  String url = params.url;
109
109
  File writePath = params.targetFile;
110
- UpdateFileUtils.ensureParentDirectory(writePath);
111
110
  Request request = new Request.Builder().url(url).build();
112
111
 
112
+ OkHttpClient requestClient = HTTP_CLIENT;
113
+ if (params.deadlineNanos > 0) {
114
+ long remainingNanos = params.deadlineNanos - System.nanoTime();
115
+ if (remainingNanos <= 0) {
116
+ throw new IOException("Download deadline expired before start");
117
+ }
118
+ long remainingMillis = Math.max(
119
+ 1L,
120
+ java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(remainingNanos)
121
+ );
122
+ requestClient = HTTP_CLIENT.newBuilder()
123
+ .callTimeout(remainingMillis, java.util.concurrent.TimeUnit.MILLISECONDS)
124
+ .build();
125
+ }
126
+
127
+ UpdateFileUtils.ensureParentDirectory(writePath);
113
128
  if (writePath.exists() && !writePath.delete()) {
114
129
  throw new IOException("Failed to replace existing file: " + writePath);
115
130
  }
116
131
 
117
- try (Response response = HTTP_CLIENT.newCall(request).execute()) {
132
+ try (Response response = requestClient.newCall(request).execute()) {
118
133
  if (!response.isSuccessful()) {
119
134
  throw new IOException("Server error: " + response.code() + " " + response.message());
120
135
  }
@@ -418,32 +433,53 @@ class DownloadTask implements Runnable {
418
433
  || taskType == DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK;
419
434
  }
420
435
 
436
+ private boolean hasCompletedPatchDirectory() {
437
+ return params.unzipDirectory != null
438
+ && new File(params.unzipDirectory, "index.bundlejs").isFile()
439
+ && new File(
440
+ params.unzipDirectory,
441
+ UpdateContext.VERSION_COMPLETE_FILE
442
+ ).isFile();
443
+ }
444
+
421
445
  @Override
422
446
  public void run() {
423
447
  int taskType = params.type;
448
+ final boolean alreadyCompleted = isPatchTask(taskType)
449
+ && hasCompletedPatchDirectory();
424
450
  try {
425
- switch (taskType) {
426
- case DownloadTaskParams.TASK_TYPE_PATCH_FULL:
427
- doFullPatch();
428
- break;
429
- case DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK:
430
- doPatchFromApk();
431
- break;
432
- case DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK:
433
- doPatchFromPpk();
434
- break;
435
- case DownloadTaskParams.TASK_TYPE_CLEANUP:
436
- doCleanUp();
437
- break;
438
- case DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD:
439
- downloadFile();
440
- break;
441
- default:
442
- break;
451
+ if (alreadyCompleted) {
452
+ Log.i(UpdateContext.TAG,
453
+ "download task: version " + params.hash + " already completed");
454
+ } else {
455
+ switch (taskType) {
456
+ case DownloadTaskParams.TASK_TYPE_PATCH_FULL:
457
+ doFullPatch();
458
+ break;
459
+ case DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK:
460
+ doPatchFromApk();
461
+ break;
462
+ case DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK:
463
+ doPatchFromPpk();
464
+ break;
465
+ case DownloadTaskParams.TASK_TYPE_CLEANUP:
466
+ doCleanUp();
467
+ break;
468
+ case DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD:
469
+ downloadFile();
470
+ break;
471
+ default:
472
+ break;
473
+ }
443
474
  }
444
475
  } catch (Throwable error) {
445
476
  Log.e(UpdateContext.TAG, "download task failed", error);
446
- cleanUpAfterFailure(taskType);
477
+ // A duplicate task must never delete a version completed by an
478
+ // earlier queued task. The marker + bundle pair is the ownership
479
+ // handoff: once present, this failure did not create that install.
480
+ if (!hasCompletedPatchDirectory()) {
481
+ cleanUpAfterFailure(taskType);
482
+ }
447
483
 
448
484
  if (params.listener != null) {
449
485
  // A patch task that failed after its artifact was fully
@@ -462,6 +498,25 @@ class DownloadTask implements Runnable {
462
498
  return;
463
499
  }
464
500
 
501
+ if (isPatchTask(taskType) && !alreadyCompleted) {
502
+ try {
503
+ File marker = new File(
504
+ params.unzipDirectory,
505
+ UpdateContext.VERSION_COMPLETE_FILE
506
+ );
507
+ if (!marker.createNewFile() && !marker.isFile()) {
508
+ throw new IOException("Failed to mark completed update: " + marker);
509
+ }
510
+ } catch (Throwable error) {
511
+ Log.e(UpdateContext.TAG, "failed to mark completed update", error);
512
+ cleanUpAfterFailure(taskType);
513
+ if (params.listener != null) {
514
+ params.listener.onDownloadFailed(error);
515
+ }
516
+ return;
517
+ }
518
+ }
519
+
465
520
  // The task itself succeeded. Run the completion callback outside the
466
521
  // try/catch above so an exception thrown by the callback (e.g. a
467
522
  // FileProvider misconfiguration during installApk) is not mistaken for
@@ -20,6 +20,9 @@ class DownloadTaskParams {
20
20
  String originHash;
21
21
  // TASK_TYPE_CLEANUP only: entries younger than this survive; 0 = delete all
22
22
  int maxAgeDays = 3;
23
+ // Absolute System.nanoTime deadline for orchestrated cold-start downloads;
24
+ // 0 keeps the normal public API's 10-minute per-call timeout.
25
+ long deadlineNanos;
23
26
  File targetFile;
24
27
  File unzipDirectory;
25
28
  File originDirectory;