From b691bc3913bca1ef6d6b5f4cf19052ed981f8219 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Thu, 15 Oct 2026 08:28:04 +0800 Subject: [PATCH 1/2] fix(native): catch C++ exceptions at the HarmonyOS NAPI and iOS entry points CODE_AUDIT 4.4: Android already turns a C++ exception from the shared cores (std::bad_alloc, std::length_error) into a Java exception at every JNI entry. HarmonyOS let it unwind into NAPI, and iOS only caught NSException, which a C++ exception passes straight through. Moving the round driver into C++ widens that surface, so close it first. HarmonyOS: every exported callback runs through Guarded<>, which throws a JS Error instead; async work catches on the worker thread and rejects the promise with the exception's message, and a throwing settle rejects too. iOS: the entry points (RN methods, +bundleURL, the host configure / prepareBundle API, the download, unzip, digest and cleanup GCD blocks, the unzip guard's delegate callbacks, crash hold and the round dispatch) catch `...` instead of NSException, with the reason from PushyCaughtReason. A launch whose state cannot be resolved now falls back to the packaged bundle and still schedules the native round. A download that throws after it registered settles every waiter through its completion. The purge-restore harness gains cpp_exception_boundary: the reason of each exception kind, the guard, the bundleURL fallback, and the state lock released after a throw. Co-Authored-By: Claude Opus 5.5 --- CODE_AUDIT.md | 5 +- harmony/pushy/src/main/cpp/pushy.cpp | 97 +++++-- ios/RCTPushy/RCTPushy.mm | 71 +++-- ios/RCTPushy/RCTPushyConfiguration.mm | 9 +- ios/RCTPushy/RCTPushyInstaller.mm | 271 +++++++++++------- ios/RCTPushy/RCTPushyInternal.h | 9 + ios/RCTPushy/RCTPushyOrchestrator.mm | 22 +- ios/RCTPushy/RCTPushyState.mm | 30 ++ scripts/tests/ios-purge-restore/extract.py | 4 +- .../ios-purge-restore/purge_restore_test.mm | 53 +++- 10 files changed, 409 insertions(+), 162 deletions(-) diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index 1b49ad6b..78a4cef9 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -16,7 +16,7 @@ > | 2.13 / 单测建议 | 部分:两份 `UpdateModule.java`、`buildToolsVersion`;若干纯函数未覆盖 | > | 3.10 | 部分:构建脚本仍写 pod 源码目录 | > | 3.13 / 3.14 | 部分 / 未做:`RCTPushy.mm` 拆分、纯决策下沉 C++ + XCTest | -> | 4.4 | 部分:Android 已兜住;鸿蒙 NAPI 与 iOS 入口无 C++ catch | +> | 4.4 | 部分:三端入口均已兜住;`Application.mk` 的 `-fno-unwind-tables` 与 `-fexceptions` 并用待确认 | > | 4.12 | 部分:`buildCheckFingerprint` 无 C++ 移植 | > | §4 P4 | 开放:鸿蒙 NAPI 结果对象省略空串字段(Android 设 null) | > | 5.7 | 部分:RCP 重定向控制(HMS-only)未做 | @@ -133,7 +133,8 @@ **4.4(P2)STL 分配抛出的 C++ 异常穿越 JNI/NAPI/ObjC 边界**(部分修复) Android 已兜住:`Application.mk` 改 `-fexceptions`,每个 JNI 入口经 `jni_util::GuardJni` / catch-all 转成 Java 异常或 null。 -剩余:鸿蒙 `harmony/pushy/src/main/cpp/pushy.cpp` 的 NAPI 回调无 C++ `try/catch`,`bad_alloc/length_error` 会 unwind 进 NAPI;iOS `.mm` 只有 `@try/@catch(NSException*)`,抓不到 C++ 异常。4.5 的输入上限压低了可达性,但入口仍应 `catch (...)` 转成干净错误。另:`Application.mk` 仍带 `-fno-unwind-tables -fno-asynchronous-unwind-tables`,宜确认与 `-fexceptions` 同时使用时 unwind 正常。 +鸿蒙已兜住(2026-10):`pushy.cpp` 每个导出回调经 `Guarded<>` 把 C++ 异常转成 JS Error,async work 的 execute(工作线程)与 settle 各自 catch,promise 以异常信息 reject。iOS 已兜住(2026-10):所有入口(RN 导出方法、`+bundleURL`、host configure/prepareBundle、下载/解压/清理/摘要的 GCD 块、解压守卫委托、crash hold、轮次调度)改为 `@catch (...)`(ObjC++ 下能接住 C++ 异常),原因经 `PushyCaughtReason` 取 `what()`;`+bundleURL` 解析抛错时回退内置包并照常调度原生轮次;`scripts/test-ios-purge-restore.sh` 的 `cpp_exception_boundary` 用例锁定(含状态锁释放)。 +剩余:`Application.mk` 仍带 `-fno-unwind-tables -fno-asynchronous-unwind-tables`,宜确认与 `-fexceptions` 同时使用时 unwind 正常。 **4.12(P3)对等性:`buildCheckFingerprint` 无 C++ 移植**(部分修复) `IsMirrorRetryableCode` 已移植并进向量,`paths:null` 已有向量。`buildCheckFingerprint`(`src/updateFlowCore.ts`)仍只有 TS 实现,各原生编排器各自重实现了等价的去重逻辑——对等性框架抓不到这类漂移。 diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index b198618f..d0677063 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -558,8 +559,25 @@ struct AsyncCall { W data; void (*execute)(W&) = nullptr; void (*settle)(napi_env, napi_deferred, const W&) = nullptr; + // Set when execute threw: the promise rejects with it instead of settling + // from half-written results. + bool threw = false; + std::string failure; }; +// The message of the exception being handled, without throwing again. +void RecordFailure(std::string* failure) noexcept { + try { + throw; + } catch (const std::exception& error) { + try { + *failure = error.what(); + } catch (...) { + } + } catch (...) { + } +} + // Runs execute(data) on a libuv worker thread and returns a Promise that // settle(env, deferred, data) settles back on the JS thread. Every path // settles the promise exactly once and frees the call: a creation or queue @@ -591,15 +609,30 @@ napi_value QueueWork( nullptr, resource_name, [](napi_env, void* raw) { + // A worker thread: an exception escaping here terminates the + // process (CODE_AUDIT 4.4). auto* c = static_cast*>(raw); - c->execute(c->data); + try { + c->execute(c->data); + } catch (...) { + c->threw = true; + RecordFailure(&c->failure); + } }, [](napi_env cb_env, napi_status status, void* raw) { auto* c = static_cast*>(raw); if (status != napi_ok) { RejectDeferredWithMessage(cb_env, c->deferred, "async work aborted"); + } else if (c->threw) { + RejectDeferredWithMessage( + cb_env, c->deferred, + c->failure.empty() ? "native error" : c->failure.c_str()); } else { - c->settle(cb_env, c->deferred, c->data); + try { + c->settle(cb_env, c->deferred, c->data); + } catch (...) { + RejectDeferredWithMessage(cb_env, c->deferred, "native error"); + } } napi_delete_async_work(cb_env, c->work); delete c; @@ -1144,6 +1177,22 @@ napi_value CheckExtractedDirectory(napi_env env, napi_callback_info info) { }); } +// Every exported callback runs through Guarded: a C++ exception from the +// shared cores (std::bad_alloc, std::length_error) must not unwind into NAPI, +// so it becomes a JS Error instead (CODE_AUDIT 4.4). +template +napi_value Guarded(napi_env env, napi_callback_info info) { + try { + return Callback(env, info); + } catch (...) { + std::string failure; + RecordFailure(&failure); + napi_throw_error(env, nullptr, + failure.empty() ? "native error" : failure.c_str()); + } + return nullptr; +} + bool ExportFunction( napi_env env, napi_value exports, @@ -1381,28 +1430,28 @@ static napi_value FlowNormalizeConfiguration(napi_env env, } napi_value Init(napi_env env, napi_value exports) { - if (!ExportFunction(env, exports, "syncStateWithBinaryVersion", SyncStateWithBinaryVersion) || - !ExportFunction(env, exports, "runStateCore", RunStateCore) || - !ExportFunction(env, exports, "applyArchiveDelta", ApplyArchiveDelta) || - !ExportFunction(env, exports, "cleanupOldEntries", CleanupOldEntries) || - !ExportFunction(env, exports, "sha256HexFileAsync", Sha256HexFileAsync) || - !ExportFunction(env, exports, "sha256HexRawFdAsync", Sha256HexRawFdAsync) || - !ExportFunction(env, exports, "isInstallComplete", InstallRecordIsComplete) || - !ExportFunction(env, exports, "recordedBundleSha256", InstallRecordBundleSha256) || - !ExportFunction(env, exports, "sealInstallRecord", SealInstallRecord) || - !ExportFunction(env, exports, "promoteStaging", PromoteStaging) || - !ExportFunction(env, exports, "checkActivation", CheckActivation) || - !ExportFunction(env, exports, "checkUncompressedSize", CheckUncompressedSize) || - !ExportFunction(env, exports, "planFreeSpaceProbe", PlanFreeSpaceProbe) || - !ExportFunction(env, exports, "checkExtractedDirectory", CheckExtractedDirectory) || - !ExportFunction(env, exports, "crc32", Crc32) || - !ExportFunction(env, exports, "getSupportedDiffVersion", GetSupportedDiffVersion) || - !ExportFunction(env, exports, "prepareRound", FlowPrepareRound) || - !ExportFunction(env, exports, "nextQueryStep", FlowNextQueryStep) || - !ExportFunction(env, exports, "handleResponse", FlowHandleResponse) || - !ExportFunction(env, exports, "configsEquivalent", FlowConfigsEquivalent) || - !ExportFunction(env, exports, "runnableConfig", FlowRunnableConfig) || - !ExportFunction(env, exports, "normalizeConfiguration", FlowNormalizeConfiguration)) { + if (!ExportFunction(env, exports, "syncStateWithBinaryVersion", Guarded) || + !ExportFunction(env, exports, "runStateCore", Guarded) || + !ExportFunction(env, exports, "applyArchiveDelta", Guarded) || + !ExportFunction(env, exports, "cleanupOldEntries", Guarded) || + !ExportFunction(env, exports, "sha256HexFileAsync", Guarded) || + !ExportFunction(env, exports, "sha256HexRawFdAsync", Guarded) || + !ExportFunction(env, exports, "isInstallComplete", Guarded) || + !ExportFunction(env, exports, "recordedBundleSha256", Guarded) || + !ExportFunction(env, exports, "sealInstallRecord", Guarded) || + !ExportFunction(env, exports, "promoteStaging", Guarded) || + !ExportFunction(env, exports, "checkActivation", Guarded) || + !ExportFunction(env, exports, "checkUncompressedSize", Guarded) || + !ExportFunction(env, exports, "planFreeSpaceProbe", Guarded) || + !ExportFunction(env, exports, "checkExtractedDirectory", Guarded) || + !ExportFunction(env, exports, "crc32", Guarded) || + !ExportFunction(env, exports, "getSupportedDiffVersion", Guarded) || + !ExportFunction(env, exports, "prepareRound", Guarded) || + !ExportFunction(env, exports, "nextQueryStep", Guarded) || + !ExportFunction(env, exports, "handleResponse", Guarded) || + !ExportFunction(env, exports, "configsEquivalent", Guarded) || + !ExportFunction(env, exports, "runnableConfig", Guarded) || + !ExportFunction(env, exports, "normalizeConfiguration", Guarded)) { return nullptr; } return exports; diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index e89d47e8..935bec8c 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -41,10 +41,11 @@ static void PushyTriggerReload(NSString *reason) { }); } -// A promise rejection for an exception caught around a state transition. -static void PushyRejectException(RCTPromiseRejectBlock reject, const char *code, - NSException *exception, NSString *fallback) { - PushyRejectError(reject, PushyErrorWithCode(code, exception.reason ?: fallback)); +// A promise rejection for an exception caught (`@catch (...)`) around a +// state transition. +static void PushyRejectCaught(RCTPromiseRejectBlock reject, const char *code, + NSString *fallback) { + PushyRejectError(reject, PushyErrorWithCode(code, PushyCaughtReason(fallback))); } // Host preparation runs off the caller's thread; the completion is delivered @@ -61,8 +62,9 @@ static void PushyRunHostPreparation(NSDictionary *(^work)(void), NSDictionary *result; @try { result = work(); - } @catch (NSException *exception) { - RCTLogWarn(@"RCTPushy -- native host sync failed: %@", exception.reason); + } @catch (...) { + RCTLogWarn(@"RCTPushy -- native host sync failed: %@", + PushyCaughtReason(@"unknown exception")); result = PushyHostResult(@"failed", @"internal_error"); } if (completion != nil) { @@ -101,11 +103,20 @@ + (NSURL *)bundleURL } #endif return resolvedURL ?: [RCTPushy binaryBundleURL]; + } @catch (...) { + // An exception here would end every launch the same way; the + // packaged bundle always loads, and the round scheduled below can + // still repair the state. + RCTLogError(@"RCTPushy -- cannot resolve the launch bundle: %@", + PushyCaughtReason(@"unknown exception")); + return [RCTPushy binaryBundleURL]; } @finally { // State corruption is exactly when the rescue path matters most. If // resolution throws before a snapshot exists, nil safely omits only // this launch's rollback guard instead of disabling the check. - [RCTPushyOrchestrator scheduleFromColdStart:launchRolledBackVersion]; + PushyGuard(@"native sync schedule", ^{ + [RCTPushyOrchestrator scheduleFromColdStart:launchRolledBackVersion]; + }); } } @@ -122,9 +133,9 @@ + (void)configure:(NSDictionary *)options if (config != nil) { @try { [RCTPushyOrchestrator persistConfiguration:config]; - } @catch (NSException *exception) { + } @catch (...) { failure = PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, - exception.reason ?: @"Native configuration failed"); + PushyCaughtReason(@"Native configuration failed")); } } if (completion != nil) { @@ -158,7 +169,9 @@ + (BOOL)requiresMainQueueSetup - (NSDictionary *)constantsToExport { NSMutableDictionary *ret = [NSMutableDictionary new]; - PushyWithStateLock(^{ + // Whatever was gathered before a failure is still returned: throwing out + // of getConstants takes the app down. + PushyGuard(@"constants", ^{ PushyWithStateLock(^{ NSUserDefaults *defaults = PushyDefaults(); ret[@"downloadRootDir"] = [RCTPushy downloadDir]; @@ -194,7 +207,7 @@ - (NSDictionary *)constantsToExport [defaults removeObjectForKey:KeyPackageUpdatedMarked()]; [RCTPushyInstaller clearInvalidFiles]; } - }); + }); }); return ret; } @@ -233,9 +246,9 @@ - (NSDictionary *)constantsToExport @try { [RCTPushyOrchestrator persistConfiguration:config]; resolve(@true); - } @catch (NSException *exception) { - PushyRejectException(reject, pushy::error_codes::kFileOperationFailed, exception, - @"Native configuration failed"); + } @catch (...) { + PushyRejectCaught(reject, pushy::error_codes::kFileOperationFailed, + @"Native configuration failed"); } } @@ -366,7 +379,11 @@ - (NSDictionary *)constantsToExport resolve(@""); #else dispatch_async(PushyFileQueue(), ^{ - resolve(PushyBundleHashSync(YES)); + __block NSString *hash = @""; + PushyGuard(@"bundle hash", ^{ + hash = PushyBundleHashSync(YES); + }); + resolve(hash); }); #endif } @@ -390,9 +407,8 @@ - (NSDictionary *)constantsToExport } PushyApplyStateToDefaults(defaults, result.state); }); - } @catch (NSException *exception) { - PushyRejectException(reject, pushy::error_codes::kMarkSuccessFailed, exception, - @"markSuccess failed"); + } @catch (...) { + PushyRejectCaught(reject, pushy::error_codes::kMarkSuccessFailed, @"markSuccess failed"); return; } @@ -441,8 +457,8 @@ - (NSDictionary *)constantsToExport [defaults removeObjectForKey:keyNativeCheckCache]; ignoreRollback = false; }); - } @catch (NSException *exception) { - PushyRejectException(reject, pushy::error_codes::kResetFailed, exception, @"reset failed"); + } @catch (...) { + PushyRejectCaught(reject, pushy::error_codes::kResetFailed, @"reset failed"); return; } @@ -453,11 +469,16 @@ - (NSDictionary *)constantsToExport // promise settles here, after the cleanup, so a failed wipe reaches // JS as RESET_FAILED (Android parity) instead of a warning nobody // aggregates. - pushy::delta::Status status = pushy::delta::CleanupOldEntries( - PushyToStdString([RCTPushy downloadDir]), - std::vector{PushyToStdString(keepVersion)}, - 0 - ); + pushy::delta::Status status = pushy::delta::Status::Ok(); + try { + status = pushy::delta::CleanupOldEntries( + PushyToStdString([RCTPushy downloadDir]), + std::vector{PushyToStdString(keepVersion)}, + 0 + ); + } catch (const std::exception &error) { + status = pushy::delta::Status::Error(error.what()); + } if (!status.ok) { RCTLogWarn(@"Pushy reset cleanup error: %s", status.message.c_str()); PushyRejectError(reject, PushyErrorWithCode( diff --git a/ios/RCTPushy/RCTPushyConfiguration.mm b/ios/RCTPushy/RCTPushyConfiguration.mm index 652120fc..444ebd06 100644 --- a/ios/RCTPushy/RCTPushyConfiguration.mm +++ b/ios/RCTPushy/RCTPushyConfiguration.mm @@ -27,7 +27,14 @@ } std::string config; std::string message; - if (flowcore::NormalizeConfiguration(optionsJson, &config, &message)) { + bool normalized = false; + try { + normalized = flowcore::NormalizeConfiguration(optionsJson, &config, &message); + } catch (const std::exception &failure) { + // Called on the host's thread (CODE_AUDIT 4.4). + message = std::string("Invalid native configuration: ") + failure.what(); + } + if (normalized) { NSString *result = [[NSString alloc] initWithBytes:config.data() length:config.size() encoding:NSUTF8StringEncoding]; diff --git a/ios/RCTPushy/RCTPushyInstaller.mm b/ios/RCTPushy/RCTPushyInstaller.mm index 7c01a3fc..29a163c3 100644 --- a/ios/RCTPushy/RCTPushyInstaller.mm +++ b/ios/RCTPushy/RCTPushyInstaller.mm @@ -109,8 +109,23 @@ - (void)recordStatus:(const pushy::delta::Status &)status { } } +// The delegate runs inside SSZipArchive's C extraction loop, which an +// exception must not unwind through (CODE_AUDIT 4.4): a check that throws is +// a violation like any other. +- (void)check:(void (NS_NOESCAPE ^)(void))block { + @try { + block(); + } @catch (...) { + if (self.violation == nil) { + self.violation = PushyCaughtReason(@"archive check failed"); + } + } +} + - (void)zipArchiveWillUnzipArchiveAtPath:(NSString *)path zipInfo:(unz_global_info)zipInfo { - [self recordStatus:pushy::archive_limits::CheckEntryCount((long long)zipInfo.number_entry)]; + [self check:^{ + [self recordStatus:pushy::archive_limits::CheckEntryCount((long long)zipInfo.number_entry)]; + }]; } - (BOOL)zipArchiveShouldUnzipFileAtIndex:(NSInteger)fileIndex @@ -123,9 +138,11 @@ - (BOOL)zipArchiveShouldUnzipFileAtIndex:(NSInteger)fileIndex // Unix mode bits travel in the high 16 bits of the external attributes // (the same field SSZipArchive keys its own symlink branch on). BOOL symlink = ((fileInfo.external_fa >> 16) & S_IFMT) == S_IFLNK; - [self recordStatus:_accumulator->Accept((long long)fileInfo.uncompressed_size, - (long long)fileInfo.compressed_size, - symlink, std::string())]; + [self check:^{ + [self recordStatus:self->_accumulator->Accept((long long)fileInfo.uncompressed_size, + (long long)fileInfo.compressed_size, + symlink, std::string())]; + }]; return self.violation == nil; } @@ -152,9 +169,11 @@ - (void)zipArchiveDidUnzipFileAtIndex:(NSInteger)fileIndex } NSString *name = [[components subarrayWithRange:NSMakeRange(base, components.count - base)] componentsJoinedByString:@"/"]; - if (pushy::archive_limits::IsReservedEntryName(PushyToStdString(name))) { - self.violation = [NSString stringWithFormat:@"archive contains reserved entry %@", name]; - } + [self check:^{ + if (pushy::archive_limits::IsReservedEntryName(PushyToStdString(name))) { + self.violation = [NSString stringWithFormat:@"archive contains reserved entry %@", name]; + } + }]; } @end @@ -326,6 +345,22 @@ + (void)fetch:(PushyType)type options:(NSDictionary *)options progress:(void (^)(long long, long long))progress callback:(void (^)(NSError *error))callback +{ + // The caller is an RN method or the native round; neither survives an + // exception (CODE_AUDIT 4.4). Once the download is registered, failures + // settle every waiter through its completion instead. + @try { + [self fetchUnguarded:type options:options progress:progress callback:callback]; + } @catch (...) { + callback(PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, + PushyCaughtReason(ERROR_FILE_OPERATION))); + } +} + ++ (void)fetchUnguarded:(PushyType)type + options:(NSDictionary *)options + progress:(void (^)(long long, long long))progress + callback:(void (^)(NSError *error))callback { NSString *updateUrl = PushyOptionString(options, PushyOptionUrlKey()); NSString *hash = PushyOptionString(options, @"hash"); @@ -394,7 +429,13 @@ + (void)fetch:(PushyType)type // to the download root itself. void (^completion)(NSError *) = ^(NSError *error) { dispatch_async(PushyFileQueue(), ^{ - NSError *finalError = error ?: PushyPromoteStagingDir(hash, artifactSha256); + __block NSError *finalError = error; + if (finalError == nil && !PushyGuard(@"promote", ^{ + finalError = PushyPromoteStagingDir(hash, artifactSha256); + })) { + finalError = PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, + ERROR_FILE_OPERATION); + } if (finalError != nil) { [[NSFileManager defaultManager] removeItemAtPath:PushyStagingDir(hash) error:nil]; } @@ -408,26 +449,36 @@ + (void)fetch:(PushyType)type completion(deadlineExpired()); return; } - [RCTPushyDownloader download:updateUrl - savePath:zipFilePath - timeoutInterval:timeoutSeconds - progressHandler:^(long long receivedBytes, long long totalBytes) { - PushyReportDownloadProgress(hash, receivedBytes, totalBytes); - } completionHandler:^(NSString *path, NSError *error) { - if (error != nil) { - completion(error); - return; - } - dispatch_async(PushyFileQueue(), ^{ - artifactSha256 = PushyFromStdString( - pushy::digest::Sha256File(PushyToStdString(zipFilePath))); - }); - [self unzipDownloadedPackage:zipFilePath - hash:hash - type:type - originHash:originHash - callback:completion]; - }]; + // Registered: every waiter now settles through `completion`. + @try { + [RCTPushyDownloader download:updateUrl + savePath:zipFilePath + timeoutInterval:timeoutSeconds + progressHandler:^(long long receivedBytes, long long totalBytes) { + PushyReportDownloadProgress(hash, receivedBytes, totalBytes); + } completionHandler:^(NSString *path, NSError *error) { + if (error != nil) { + completion(error); + return; + } + dispatch_async(PushyFileQueue(), ^{ + // A digest that throws is recorded as none, like an unreadable + // archive. + PushyGuard(@"archive digest", ^{ + artifactSha256 = PushyFromStdString( + pushy::digest::Sha256File(PushyToStdString(zipFilePath))); + }); + }); + [self unzipDownloadedPackage:zipFilePath + hash:hash + type:type + originHash:originHash + callback:completion]; + }]; + } @catch (...) { + completion(PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, + PushyCaughtReason(ERROR_FILE_OPERATION))); + } } + (void)unzipDownloadedPackage:(NSString *)zipFilePath @@ -448,11 +499,11 @@ + (void)unzipDownloadedPackage:(NSString *)zipFilePath } @try { [self finishDownloadedPackage:hash type:type originHash:originHash callback:callback]; - } @catch (NSException *exception) { + } @catch (...) { // An uncaught exception in a GCD block is fatal (and would // trip the crash rescue); a bad package is a PATCH_FAILED. callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - exception.reason ?: @"delta failed")); + PushyCaughtReason(@"delta failed"))); } }); }]; @@ -536,83 +587,105 @@ + (void)unzipFileAtPath:(NSString *)path completionHandler:(void (^)(NSError *error))completionHandler { dispatch_async(PushyFileQueue(), ^{ - NSFileManager *fileManager = [NSFileManager defaultManager]; - if ([fileManager fileExistsAtPath:destination]) { - [fileManager removeItemAtPath:destination error:nil]; - } - - // Resource caps before the first byte is extracted - // (cpp/patch_core/archive_limits.h). SSZipArchive only exposes the - // entry count up front (zipArchiveWillUnzipArchiveAtPath:zipInfo:), - // not the uncompressed total, so the disk check uses a 2x-archive - // heuristic; the guard enforces the exact caps entry by entry. - long long archiveBytes = RCTPushyFileSize(path); - NSError *preflight = nil; - if (archiveBytes > pushy::archive_limits::kMaxArchiveBytes) { - preflight = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - [NSString stringWithFormat:@"archive too large: %lld bytes", archiveBytes]); - } else { - preflight = PushyEnsureFreeSpace(destination, MAX(0LL, archiveBytes) * 2); - } - if (preflight != nil) { + @try { + [self unzipFileUnguardedAtPath:path + toDestination:destination + completionHandler:completionHandler]; + } @catch (...) { + // The archive limits run inside the extraction (CODE_AUDIT 4.4). RCTPushyDiscardArchive(path); - completionHandler(preflight); - return; + completionHandler(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + PushyCaughtReason(@"unzip failed"))); } - - PushyUnzipGuard *guard = [[PushyUnzipGuard alloc] initWithArchiveBytes:archiveBytes - destination:destination]; - // preserveAttributes:NO — the archive's mode/mtime bits have no use - // here and are one less thing a hostile package controls. - [SSZipArchive unzipFileAtPath:path - toDestination:destination - preserveAttributes:NO - overwrite:YES - nestedZipLevel:0 - password:nil - error:nil - delegate:guard - progressHandler:nil - completionHandler:^(NSString *archivePath, BOOL succeeded, NSError *error) { - // The resume sidecar dies with its archive (§11.4): whether the - // unzip consumed it or classified it as poisoned, nothing must - // survive to vouch for bytes that are gone. - RCTPushyDiscardArchive(archivePath); - - NSError *unzipError = error; - if (guard.violation != nil) { - unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, guard.violation); - } else if (!succeeded && unzipError == nil) { - unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"unzip failed"); - } else if (unzipError != nil && unzipError.userInfo[RCTPushyErrorCodeKey] == nil) { - // SSZipArchive's own NSError (corrupt zip, bad magic, ...) has - // no stable code; without one, fetchPackage's fallback would - // classify it as DOWNLOAD_FAILED even though the download - // succeeded — keep the classification deterministic. - unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - unzipError.localizedDescription ?: @"unzip failed"); - } - completionHandler(unzipError); - }]; }); } ++ (void)unzipFileUnguardedAtPath:(NSString *)path + toDestination:(NSString *)destination + completionHandler:(void (^)(NSError *error))completionHandler +{ + NSFileManager *fileManager = [NSFileManager defaultManager]; + if ([fileManager fileExistsAtPath:destination]) { + [fileManager removeItemAtPath:destination error:nil]; + } + + // Resource caps before the first byte is extracted + // (cpp/patch_core/archive_limits.h). SSZipArchive only exposes the + // entry count up front (zipArchiveWillUnzipArchiveAtPath:zipInfo:), + // not the uncompressed total, so the disk check uses a 2x-archive + // heuristic; the guard enforces the exact caps entry by entry. + long long archiveBytes = RCTPushyFileSize(path); + NSError *preflight = nil; + if (archiveBytes > pushy::archive_limits::kMaxArchiveBytes) { + preflight = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + [NSString stringWithFormat:@"archive too large: %lld bytes", archiveBytes]); + } else { + preflight = PushyEnsureFreeSpace(destination, MAX(0LL, archiveBytes) * 2); + } + if (preflight != nil) { + RCTPushyDiscardArchive(path); + completionHandler(preflight); + return; + } + + PushyUnzipGuard *guard = [[PushyUnzipGuard alloc] initWithArchiveBytes:archiveBytes + destination:destination]; + // preserveAttributes:NO — the archive's mode/mtime bits have no use + // here and are one less thing a hostile package controls. + [SSZipArchive unzipFileAtPath:path + toDestination:destination + preserveAttributes:NO + overwrite:YES + nestedZipLevel:0 + password:nil + error:nil + delegate:guard + progressHandler:nil + completionHandler:^(NSString *archivePath, BOOL succeeded, NSError *error) { + // The resume sidecar dies with its archive (§11.4): whether the + // unzip consumed it or classified it as poisoned, nothing must + // survive to vouch for bytes that are gone. + RCTPushyDiscardArchive(archivePath); + + NSError *unzipError = error; + if (guard.violation != nil) { + unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, guard.violation); + } else if (!succeeded && unzipError == nil) { + unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"unzip failed"); + } else if (unzipError != nil && unzipError.userInfo[RCTPushyErrorCodeKey] == nil) { + // SSZipArchive's own NSError (corrupt zip, bad magic, ...) has + // no stable code; without one, fetchPackage's fallback would + // classify it as DOWNLOAD_FAILED even though the download + // succeeded — keep the classification deterministic. + unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + unzipError.localizedDescription ?: @"unzip failed"); + } + completionHandler(unzipError); + }]; +} + + (BOOL)switchVersion:(NSString *)hash error:(NSError **)error { // Same gate as Android and Harmony (install_record::CheckActivation); see // PushyVerifyActivation. PushyVerifiedActivation verified; - __block NSError *switchError = PushyVerifyActivation(hash, &verified); - if (switchError == nil) { - PushyWithStateLock(^{ - if (!PushyVerifiedActivationUnchangedLocked(verified)) { - switchError = PushyErrorWithCode( - pushy::error_codes::kSwitchVersionFailed, - [NSString stringWithFormat:@"Bundle version %@ changed while switching.", hash]); - return; - } - PushySwitchVersionLocked(hash); - }); + __block NSError *switchError = nil; + @try { + switchError = PushyVerifyActivation(hash, &verified); + if (switchError == nil) { + PushyWithStateLock(^{ + if (!PushyVerifiedActivationUnchangedLocked(verified)) { + switchError = PushyErrorWithCode( + pushy::error_codes::kSwitchVersionFailed, + [NSString stringWithFormat:@"Bundle version %@ changed while switching.", hash]); + return; + } + PushySwitchVersionLocked(hash); + }); + } + } @catch (...) { + switchError = PushyErrorWithCode(pushy::error_codes::kSwitchVersionFailed, + PushyCaughtReason(@"switch failed")); } if (switchError != nil) { if (error != NULL) { @@ -625,7 +698,7 @@ + (BOOL)switchVersion:(NSString *)hash error:(NSError **)error + (void)clearInvalidFiles { - dispatch_async(PushyFileQueue(), ^{ + dispatch_async(PushyFileQueue(), ^{ PushyGuard(@"cleanup", ^{ // Snapshot the state under the lock, but run the (slow) filesystem // cleanup outside of it so state operations are not blocked. __block pushy::state::State state; @@ -650,7 +723,7 @@ + (void)clearInvalidFiles if (!status.ok) { RCTLogWarn(@"Pushy cleanup error: %s", status.message.c_str()); } - }); + }); }); } @end diff --git a/ios/RCTPushy/RCTPushyInternal.h b/ios/RCTPushy/RCTPushyInternal.h index 21bb0249..a5d0257c 100644 --- a/ios/RCTPushy/RCTPushyInternal.h +++ b/ios/RCTPushy/RCTPushyInternal.h @@ -60,6 +60,15 @@ PUSHY_HIDDEN NSDictionary *PushyHostResult(NSString *status, NSString *reason, PUSHY_HIDDEN NSTimeInterval PushyMonotonicNow(void); PUSHY_HIDDEN NSUserDefaults *PushyDefaults(void); PUSHY_HIDDEN void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)); +// Inside `@catch (...)`: the reason of what was caught — an NSException's +// reason or a C++ exception's what() — or `fallback` when it has none. The +// shared cores can throw std::bad_alloc / std::length_error, which +// `@catch (NSException *)` lets through; an entry point (RN method, host +// API, GCD block, launch path) therefore catches `...` (CODE_AUDIT 4.4). +PUSHY_HIDDEN NSString *PushyCaughtReason(NSString *fallback); +// Runs `block` for an entry point that has nobody to report to: whatever it +// throws is logged under `where` and swallowed. NO when it threw. +PUSHY_HIDDEN BOOL PushyGuard(NSString *where, void (NS_NOESCAPE ^block)(void)); PUSHY_HIDDEN std::string PushyToStdString(NSString *value); PUSHY_HIDDEN NSString *PushyFromStdString(const std::string &value); PUSHY_HIDDEN NSString *PushyOptionString(NSDictionary *options, NSString *key); diff --git a/ios/RCTPushy/RCTPushyOrchestrator.mm b/ios/RCTPushy/RCTPushyOrchestrator.mm index a7a927d3..931ca007 100644 --- a/ios/RCTPushy/RCTPushyOrchestrator.mm +++ b/ios/RCTPushy/RCTPushyOrchestrator.mm @@ -184,8 +184,8 @@ static void PushyMaybeHoldProcess(void) { dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ @try { [RCTPushyOrchestrator runHoldRoundWithDeadline:deadline]; - } @catch (NSException *exception) { - NSLog(@"RCTPushy -- crash hold failed: %@", exception.reason); + } @catch (...) { + NSLog(@"RCTPushy -- crash hold failed: %@", PushyCaughtReason(@"unknown exception")); } dispatch_semaphore_signal(done); }); @@ -202,7 +202,7 @@ static void PushyMaybeHoldProcess(void) { static void PushyCrashHoldExceptionHandler(NSException *exception) { @try { PushyMaybeHoldProcess(); - } @catch (NSException *inner) { + } @catch (...) { // The dying process owes the previous handler its turn no matter // what the rescue did. } @@ -405,7 +405,7 @@ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { int64_t delaySeconds = [defaults objectForKey:keyNativeCheckIncomplete] != nil ? 0 : 5; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delaySeconds * NSEC_PER_SEC), - dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ PushyGuard(@"native sync", ^{ if ([RCTPushyOrchestrator hasJsRound]) { // Not consuming the round: a later crash rescue may still // need it. @@ -413,7 +413,7 @@ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { return; } [RCTPushyOrchestrator startRoundWithDeadline:0]; - }); + }); }); return true; }(); (void)scheduled; @@ -459,7 +459,9 @@ + (BOOL)restorePurgedLaunch:(NSString *)purgedVersion NSTimeInterval deadline = PushyMonotonicNow() + budget; dispatch_semaphore_t done = dispatch_semaphore_create(0); dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ - [self startRoundWithDeadline:deadline]; + PushyGuard(@"purge restore", ^{ + [self startRoundWithDeadline:deadline]; + }); dispatch_semaphore_signal(done); }); BOOL timedOut = !PushyWaitSeconds(done, budget); @@ -503,7 +505,9 @@ + (NSDictionary *)prepareBundleWithin:(NSTimeInterval)timeout { // wait below can be bounded. if ([self claimRound]) { dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ - [self runClaimedRoundWithDeadline:0]; + PushyGuard(@"native sync", ^{ + [self runClaimedRoundWithDeadline:0]; + }); }); } // A group is broadcast-style. Sharing the rescue semaphore would let one @@ -574,9 +578,9 @@ + (BOOL)claimRound { + (void)runClaimedRoundWithDeadline:(NSTimeInterval)deadlineUptime { @try { [self runOnce:pushyLaunchRolledBackForHold deadline:deadlineUptime]; - } @catch (NSException *exception) { + } @catch (...) { // The rescue path must never take the app down with it. - RCTLogWarn(@"RCTPushy -- native sync crashed: %@", exception.reason); + RCTLogWarn(@"RCTPushy -- native sync crashed: %@", PushyCaughtReason(@"unknown exception")); pushyHostRoundResult = PushyHostResult(@"failed", @"internal_error"); } @finally { pushyRoundCompleted.store(true); diff --git a/ios/RCTPushy/RCTPushyState.mm b/ios/RCTPushy/RCTPushyState.mm index e01c70ae..b63fc98d 100644 --- a/ios/RCTPushy/RCTPushyState.mm +++ b/ios/RCTPushy/RCTPushyState.mm @@ -2,6 +2,8 @@ // defaults mapping, the string/error/JSON helpers every other file uses, and // the embedded bundle's hash. #import "RCTPushyInternal.h" + +#include #include "../../cpp/patch_core/digest.h" #import @@ -85,6 +87,34 @@ PUSHY_HIDDEN void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) { } } +PUSHY_HIDDEN NSString *PushyCaughtReason(NSString *fallback) { + std::exception_ptr caught = std::current_exception(); + if (caught == nullptr) { + return fallback; + } + try { + @try { + std::rethrow_exception(caught); + } @catch (NSException *exception) { + return exception.reason ?: fallback; + } + } catch (const std::exception &error) { + return [NSString stringWithUTF8String:error.what()] ?: fallback; + } catch (...) { + } + return fallback; +} + +PUSHY_HIDDEN BOOL PushyGuard(NSString *where, void (NS_NOESCAPE ^block)(void)) { + @try { + block(); + return YES; + } @catch (...) { + NSLog(@"RCTPushy -- %@ failed: %@", where, PushyCaughtReason(@"unknown exception")); + return NO; + } +} + PUSHY_HIDDEN std::string PushyToStdString(NSString *value) { if (value == nil) { return std::string(); diff --git a/scripts/tests/ios-purge-restore/extract.py b/scripts/tests/ios-purge-restore/extract.py index 2c8ef64e..d6b6cfa8 100644 --- a/scripts/tests/ios-purge-restore/extract.py +++ b/scripts/tests/ios-purge-restore/extract.py @@ -92,8 +92,10 @@ def declaration(source: str, name: str) -> tuple[int, str]: 'pushyHostRoundResult', 'kPushyPurgeRestoreBudget', 'kPushyPurgeRestoreMinimumBudget', ] HELPERS = [ + # The exception boundary every entry point uses (CODE_AUDIT 4.4). + 'PushyCaughtReason', 'PushyGuard', 'KeyPackageUpdatedMarked', 'PushyWithStateLock', 'PushyToStdString', 'PushyFromStdString', - 'PushyJsonDictionary', 'PushyJsonString', 'PushyRejectException', 'PushyFileQueue', + 'PushyJsonDictionary', 'PushyJsonString', 'PushyRejectCaught', 'PushyFileQueue', 'PushySetNullableString', 'PushyHashInfoKey', 'PushyBinaryIdentityValue', 'PushyStateFromDefaults', 'PushyApplyStateToDefaults', 'PushySwitchVersionLocked', # Canonical config equality (contract C1): the binding over diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index 888f33fa..e86e7418 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -5,6 +5,7 @@ #import #import #import +#include #include #include #include @@ -74,6 +75,14 @@ static void Await(dispatch_semaphore_t gate, const char *description) { static BOOL networkPath; static NSTimeInterval ExpectedBudget(void) { return 12 - processElapsed; } static NSString *root; +// Makes every defaults read throw a C++ exception, as the shared cores can +// (std::bad_alloc / std::length_error) while the state lock is held. +static bool throwOnRead = false; +static void MaybeThrow(void) { + if (throwOnRead) { + throw std::length_error("state too large"); + } +} // In-memory collaborator, deliberately NOT an NSUserDefaults subclass: inherited // accessors could otherwise reach real preferences without ObserveWrite(). Only @@ -106,12 +115,14 @@ - (instancetype)init { } - (void)setObject:(id)value forKey:(NSString *)key { ObserveWrite(); _values[key] = value; } - (void)removeObjectForKey:(NSString *)key { ObserveWrite(); [_values removeObjectForKey:key]; } -- (id)objectForKey:(NSString *)key { return _values[key]; } +- (id)objectForKey:(NSString *)key { MaybeThrow(); return _values[key]; } - (NSString *)stringForKey:(NSString *)key { + MaybeThrow(); id value = _values[key]; return [value isKindOfClass:NSString.class] ? value : nil; } - (NSDictionary *)dictionaryForKey:(NSString *)key { + MaybeThrow(); id value = _values[key]; return [value isKindOfClass:NSDictionary.class] ? value : nil; } @@ -728,6 +739,45 @@ static void RoundCommitVerifies(void) { Expect([[NSFileManager defaultManager] removeItemAtPath:root error:NULL], "remove fixture files"); } +// CODE_AUDIT 4.4: a C++ exception from the shared cores is caught at the +// entry point, with its reason, and never leaves the state lock held. The +// launch falls back to the packaged bundle and still schedules the round. +static void CppExceptionBoundary(void) { + SetUp(Order::ResetFirst); + @try { + throw std::length_error("too long"); + } @catch (...) { + Expect([PushyCaughtReason(@"fallback") isEqualToString:@"too long"], + "a C++ exception's what() is the reason"); + } + @try { + [NSException raise:@"X" format:@"objc reason"]; + } @catch (...) { + Expect([PushyCaughtReason(@"fallback") isEqualToString:@"objc reason"], + "an NSException's reason is the reason"); + } + @try { + throw 7; + } @catch (...) { + Expect([PushyCaughtReason(@"fallback") isEqualToString:@"fallback"], + "anything else falls back"); + } + Expect(PushyGuard(@"test", ^{}), "a block that returns passes the guard"); + Expect(!PushyGuard(@"test", ^{ throw std::bad_alloc(); }), "a C++ exception is caught"); + + throwOnRead = true; + NSURL *url = [RCTPushy bundleURL]; + throwOnRead = false; + Expect([url isEqual:[RCTPushy binaryBundleURL]], + "a launch whose state cannot be read uses the packaged bundle"); + Expect(scheduleCount == 1, "the round is still scheduled"); + // Re-acquiring would abort if the throw had left the lock held. + __block bool reacquired = false; + PushyWithStateLock(^{ reacquired = true; }); + Expect(reacquired, "the state lock is released"); + Expect([[NSFileManager defaultManager] removeItemAtPath:root error:NULL], "remove fixture files"); +} + int main(int argc, char **argv) { @autoreleasepool { // These intentional failures run in separate processes. Erasing the @@ -761,6 +811,7 @@ int main(int argc, char **argv) { {"install_record_activation", InstallRecordActivation}, {"switch_version_records", SwitchVersionRecords}, {"round_commit_verifies", RoundCommitVerifies}, + {"cpp_exception_boundary", CppExceptionBoundary}, }; int ran = 0; for (const auto &test : cases) { From 5528b421612c1ad171c56c7a43e7c08c2855371f Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Thu, 15 Oct 2026 08:37:57 +0800 Subject: [PATCH 2/2] fix(ios): settle a registered download whatever throws after registration Everything after PushyRegisterDownload now sits inside the try that settles through completion (the side-effect-free setup moved above the registration), so a throw there cannot leave the registry entry and joined waiters behind. Co-Authored-By: Claude Opus 5.5 --- ios/RCTPushy/RCTPushyInstaller.mm | 32 ++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/ios/RCTPushy/RCTPushyInstaller.mm b/ios/RCTPushy/RCTPushyInstaller.mm index 29a163c3..8b7ca21a 100644 --- a/ios/RCTPushy/RCTPushyInstaller.mm +++ b/ios/RCTPushy/RCTPushyInstaller.mm @@ -347,8 +347,9 @@ + (void)fetch:(PushyType)type callback:(void (^)(NSError *error))callback { // The caller is an RN method or the native round; neither survives an - // exception (CODE_AUDIT 4.4). Once the download is registered, failures - // settle every waiter through its completion instead. + // exception (CODE_AUDIT 4.4). This catch only covers the checks before + // the download registers; after that, failures settle every waiter + // through its completion instead. @try { [self fetchUnguarded:type options:options progress:progress callback:callback]; } @catch (...) { @@ -403,15 +404,6 @@ + (void)fetchUnguarded:(PushyType)type void (^deferredStart)(void) = ^{ [self fetch:type options:options progress:progress callback:callback]; }; - PushyDownloadRegistration registration = PushyRegisterDownload( - hash, type, deadlineUptime, callback, progress, deferredStart); - if (registration != PushyDownloadRegistrationOwner) { - RCTLogInfo( - @"RCTPushy -- %@ in-flight download for %@", - registration == PushyDownloadRegistrationJoined ? @"join" : @"defer", - hash); - return; - } NSString *zipFilePath = [dir stringByAppendingPathComponent: [hash stringByAppendingString:PushyArchiveExtension(type)]]; @@ -443,14 +435,24 @@ + (void)fetchUnguarded:(PushyType)type }); }; - RCTLogInfo(@"RCTPushy -- download file %@", updateUrl); - NSTimeInterval timeoutSeconds = deadlineUptime - PushyMonotonicNow(); - if (timeoutSeconds <= 0) { - completion(deadlineExpired()); + PushyDownloadRegistration registration = PushyRegisterDownload( + hash, type, deadlineUptime, callback, progress, deferredStart); + if (registration != PushyDownloadRegistrationOwner) { + RCTLogInfo( + @"RCTPushy -- %@ in-flight download for %@", + registration == PushyDownloadRegistrationJoined ? @"join" : @"defer", + hash); return; } + // Registered: every waiter now settles through `completion`. @try { + RCTLogInfo(@"RCTPushy -- download file %@", updateUrl); + NSTimeInterval timeoutSeconds = deadlineUptime - PushyMonotonicNow(); + if (timeoutSeconds <= 0) { + completion(deadlineExpired()); + return; + } [RCTPushyDownloader download:updateUrl savePath:zipFilePath timeoutInterval:timeoutSeconds