Skip to content

Commit 70e0f22

Browse files
sunnylqmclaude
andcommitted
docs: iOS simulator crash-rescue verified; record boundaries + scaffolding
Same armed-brick scenario as the Android live test, driven with simctl (Detox cannot attach to an app whose JS never boots). Confirmed on Release-iphonesimulator against a local server: the JS fatal lands on the TurboModule queue (background thread, 10s budget), the hold runs a full round with a real re-download, and the fix is activated without any forceBoot and with afterDownload=none — evidence the activation came from the crash window itself (hashInfo carries crashRescue:true). The previous handler still runs: a normal SIGABRT crash report is produced. Crash-to-commit took <0.9s on localhost. The main-thread 3.5s branch is deliberately not staged: main-thread RCTFatal only comes from bundle-load failures, which can never reach markSuccess and are always caught by the existing first_time rollback. New documented boundary: an app installing RCTSetFatalHandler bypasses the exception path on iOS and the rescue will not trigger. Experiment scaffolding checked in for reproducibility: entry.brick.ts, entry.fix.ts, scripts/ios-rescue-server.ts (not part of jest suites). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 349d847 commit 70e0f22

6 files changed

Lines changed: 186 additions & 3 deletions

File tree

Example/e2etest/e2e/entry.brick.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// §11 crash-rescue experiment (not part of the regular e2e suites): the first
2+
// healthy launch arms a flag well after markSuccess; every later launch reads
3+
// it and dies a few hundred ms in.
4+
import { PushyModule } from 'react-native-update';
5+
import { LOCAL_UPDATE_LABELS } from './localUpdateConfig.ts';
6+
7+
const bundleLabelGlobal = globalThis as typeof globalThis & {
8+
__RNU_E2E_BUNDLE_LABEL?: string;
9+
};
10+
11+
bundleLabelGlobal.__RNU_E2E_BUNDLE_LABEL = `${LOCAL_UPDATE_LABELS.base}_BRICK`;
12+
13+
PushyModule.getLocalHashInfo('brickflag').then((v: string) => {
14+
if (v && v.includes('armed') && !v.includes('disarmed')) {
15+
setTimeout(() => {
16+
throw new Error('BRICK: crash on launch');
17+
}, 0);
18+
} else {
19+
setTimeout(() => {
20+
PushyModule.setLocalHashInfo(
21+
'brickflag',
22+
JSON.stringify({ state: 'armed' })
23+
);
24+
console.warn('brick armed for next launch');
25+
}, 8000);
26+
}
27+
});
28+
29+
require('../index');

Example/e2etest/e2e/entry.fix.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// §11 crash-rescue experiment fix bundle: disarm the brick flag immediately.
2+
import { PushyModule } from 'react-native-update';
3+
import { LOCAL_UPDATE_LABELS } from './localUpdateConfig.ts';
4+
5+
const bundleLabelGlobal = globalThis as typeof globalThis & {
6+
__RNU_E2E_BUNDLE_LABEL?: string;
7+
};
8+
9+
bundleLabelGlobal.__RNU_E2E_BUNDLE_LABEL = `${LOCAL_UPDATE_LABELS.base}_FIXED`;
10+
11+
PushyModule.setLocalHashInfo(
12+
'brickflag',
13+
JSON.stringify({ state: 'disarmed' })
14+
);
15+
16+
require('../index');
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#!/usr/bin/env bun
2+
// One-off server for the §11 iOS crash-rescue experiment (not part of the
3+
// regular e2e suites). Update chain: no hash → brick; brick → fix. The /state
4+
// endpoint reports what was served, so the driver can assert the sequence.
5+
6+
import * as fs from 'node:fs';
7+
import * as path from 'node:path';
8+
import { fileURLToPath } from 'node:url';
9+
10+
declare const Bun: any;
11+
12+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
13+
const artifactsDir = path.resolve(moduleDir, '../.e2e-artifacts/ios-rescue');
14+
// The app hardcodes this port (localUpdateConfig LOCAL_UPDATE_PORT).
15+
const port = 31337;
16+
17+
const BRICK_HASH = 'e2e-rescue-brick';
18+
const FIX_HASH = 'e2e-rescue-fix';
19+
20+
const served: string[] = [];
21+
22+
function json(body: unknown, status = 200) {
23+
return new Response(JSON.stringify(body), {
24+
status,
25+
headers: {
26+
'Content-Type': 'application/json; charset=utf-8',
27+
'Cache-Control': 'no-store',
28+
},
29+
});
30+
}
31+
32+
const server = Bun.serve({
33+
port,
34+
hostname: '0.0.0.0',
35+
async fetch(request: Request) {
36+
const url = new URL(request.url);
37+
38+
if (url.pathname === '/state') {
39+
return json({ served });
40+
}
41+
42+
if (url.pathname.startsWith('/checkUpdate/')) {
43+
const payload = (await request.json().catch(() => ({}))) as {
44+
hash?: unknown;
45+
};
46+
const currentHash = typeof payload.hash === 'string' ? payload.hash : '';
47+
const assetBasePath = `${url.origin}/artifacts`;
48+
let response: Record<string, unknown>;
49+
if (!currentHash) {
50+
response = {
51+
update: true,
52+
name: 'rescue-brick',
53+
hash: BRICK_HASH,
54+
description: 'brick: crashes on launch after arming',
55+
paths: [assetBasePath],
56+
full: 'brick.ppk',
57+
// Deliver the brick via forceBoot (the app runs checkStrategy:null /
58+
// afterDownload:none, so nothing else would activate it). The FIX
59+
// response deliberately has no forceBoot: its activation must come
60+
// from the crash-rescue window itself.
61+
config: { forceBoot: true },
62+
};
63+
} else if (currentHash === BRICK_HASH) {
64+
response = {
65+
update: true,
66+
name: 'rescue-fix',
67+
hash: FIX_HASH,
68+
description: 'fix: disarms the brick flag',
69+
paths: [assetBasePath],
70+
full: 'fix.ppk',
71+
};
72+
} else {
73+
response = { upToDate: true };
74+
}
75+
served.push(`${currentHash || '(base)'} -> ${JSON.stringify(response.hash ?? 'upToDate')}`);
76+
console.log(`[checkUpdate] hash=${currentHash || '(base)'} ->`, response.hash ?? 'upToDate');
77+
return json(response);
78+
}
79+
80+
if (url.pathname.startsWith('/artifacts/')) {
81+
const name = path.basename(url.pathname);
82+
const filePath = path.join(artifactsDir, name);
83+
if (!fs.existsSync(filePath)) {
84+
return new Response('not found', { status: 404 });
85+
}
86+
console.log(`[artifact] ${name}`);
87+
const file = Bun.file(filePath);
88+
return new Response(request.method === 'HEAD' ? null : file, {
89+
headers: {
90+
'Content-Type': 'application/octet-stream',
91+
'Content-Length': String(file.size),
92+
'Cache-Control': 'no-store',
93+
},
94+
});
95+
}
96+
97+
return new Response('not found', { status: 404 });
98+
},
99+
});
100+
101+
console.log(`ios rescue server listening on ${server.hostname}:${server.port}`);

NATIVE_CHECK_FOLLOWUPS.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,4 +435,41 @@ JS 紧跟着上报一条 `crashRescue` 回执(logcat 实测 23:00:16.180 markSuc
435435

436436
对比 2026-08-12 上午同一台设备的旧结论:230ms 砖连续 4 次冷启动不收敛。
437437
10.52.0 下**一次崩溃即救回**——崩溃本身触发 hold,进度不再随进程死亡丢弃。
438-
iOS/鸿蒙真机复测待补(iOS 机制同构,鸿蒙只有续传+零延迟无 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+
文档口径需含此项。

README-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
- **不阻塞启动**:延迟数秒、跑在后台线程,成果在**下次启动**生效。
4040
- **是否自动激活取决于你的配置**`updateStrategy``silentAndNow` / `silentAndLater` 且未关闭自动检查(`checkStrategy` 不为 `null`)时,原生侧才会把下载好的版本设为下次启动生效;其余情况只下载,激活权仍在 JS。
4141
- **救砖指令**:控制台可按版本标记「强制启动」,被标记的版本无视上述策略直接在下次启动生效——这是把已被坏版本卡死的设备捞回来的手段。设备本地的崩溃回滚保护仍然优先,已回滚过的版本不会被再装回去。
42-
- **崩溃时刻救援**(10.52.0 起,Android 与 iOS):应用在启动阶段死于未捕获的 JS 错误时,SDK 会短暂扣住垂死的进程(数秒、有硬上限),把检查与下载做完——即使版本每次启动零点几秒就崩,也能被换掉。该窗口内下载到的修复版一律设为下次启动生效(JS 已经没有机会做决策了)。崩溃上报不受影响:SDK 以链式方式保存并在结束后调用原有的崩溃处理器。不覆盖:原生(非 JS)崩溃、ANR、OOM 击杀。
42+
- **崩溃时刻救援**(10.52.0 起,Android 与 iOS):应用在启动阶段死于未捕获的 JS 错误时,SDK 会短暂扣住垂死的进程(数秒、有硬上限),把检查与下载做完——即使版本每次启动零点几秒就崩,也能被换掉。该窗口内下载到的修复版一律设为下次启动生效(JS 已经没有机会做决策了)。崩溃上报不受影响:SDK 以链式方式保存并在结束后调用原有的崩溃处理器。不覆盖:原生(非 JS)崩溃、ANR、OOM 击杀,以及 iOS 上安装了自定义 `RCTSetFatalHandler` 的应用(那时 React Native 不再抛出本机制拦截的异常)
4343
- **断点续传**(10.52.0 起):更新下载在进程死亡后保留进度、下次以 HTTP Range 续传,反复的短命启动也能单调累积进度;紧随中断轮次的下一次启动会跳过延迟立即续传。鸿蒙暂无崩溃时刻扣留,但续传与立即重试同样生效。
4444
- **可以关闭**`disableNativeCheck: true`。关闭后每次冷启动少一次后台请求,代价是**放弃上述自愈能力**——被坏热更卡死的设备将无法自动恢复。仅在这次请求本身构成问题时(流量/耗电预算、隐私清单申报、需用户同意后才可联网)才建议关闭。
4545

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ What to know:
3737
- **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.
3838
- **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.
3939
- **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 and OOM kills.
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).
4141
- **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. On HarmonyOS the crash-moment hold is not available yet, but resume + immediate retry apply.
4242
- **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).
4343

0 commit comments

Comments
 (0)