From 01017cd813b9075313c7235f92ceda1ef901e4b2 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Thu, 15 Oct 2026 00:35:43 +0800 Subject: [PATCH 1/5] test(cpp): compile the shared cores with each platform's release flags The unit tests build cpp/patch_core and cpp/update_flow_core with the host c++ (on CI g++/libstdc++), C++17, RTTI and asserts on. No shipped binary is built that way: Android uses -fno-rtti -fexceptions, iOS compiles with the C++20 React Native selects, and every release defines NDEBUG. A construct only one platform rejects first failed in that platform's build. scripts/test-core-platform-flags.sh syntax-checks every core source with the three release flag sets under -Werror, preferring clang (the compiler of every shipped binary). test.yml's C++ job runs it; `bun run test:core-flags` locally. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 5 +++ package.json | 1 + scripts/test-core-platform-flags.sh | 55 +++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100755 scripts/test-core-platform-flags.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d0bd193..0509908d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -110,6 +110,11 @@ jobs: - name: Run patch_core unit tests (sanitized) run: SANITIZE=1 ./scripts/test-patch-core.sh + # The tests above build the cores one way; the shipped binaries build + # them with three other flag sets (no RTTI on Android, C++20 on iOS). + - name: Shared cores compile with every platform's release flags + run: ./scripts/test-core-platform-flags.sh + # Short bounded fuzz pass over the two untrusted-input parsers # (flow_json, hbc_transform_wire). Skips with a notice when clang or # libFuzzer is unavailable on the runner. diff --git a/package.json b/package.json index fb1df360..bb5c94df 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "test": "bun test src/__tests__", "test:patch-core": "./scripts/test-patch-core.sh", "test:flow-core": "./scripts/test-update-flow-core.sh", + "test:core-flags": "./scripts/test-core-platform-flags.sh", "generate:flow-vectors": "bun scripts/generate-flow-vectors.ts", "build:harmony-har": "node scripts/build-harmony-har.js", "build:harmony-e2e": "bash Example/e2etest/scripts/build-harmony-e2e.sh", diff --git a/scripts/test-core-platform-flags.sh b/scripts/test-core-platform-flags.sh new file mode 100755 index 00000000..ebcbe8fb --- /dev/null +++ b/scripts/test-core-platform-flags.sh @@ -0,0 +1,55 @@ +#!/bin/sh +# Compiles every shared core source (cpp/patch_core, cpp/update_flow_core) +# with each platform's release flags, warnings as errors, syntax only. The +# unit tests build the cores one way (host c++, C++17, RTTI, asserts on); +# the shipped binaries build them three other ways, so a construct only one +# platform rejects (RTTI under Android's -fno-rtti, C++20 changes under the +# iOS standard RN selects, code that only warns with NDEBUG) would otherwise +# first fail in that platform's build. +# +# Keep each set in step with its build: +# android android/jni/Application.mk (APP_CPPFLAGS) +# ios react-native-update.podspec (Core compiler_flags; the C++ +# standard is React Native's, C++20 since RN 0.73) +# harmony harmony/pushy/src/main/cpp/CMakeLists.txt (release) +# Flag variables must split into compiler arguments. +# shellcheck disable=SC2086 +set -eu + +# Intentional one-command CDPATH assignment. +# shellcheck disable=SC1007 +ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/.tmp/core-platform-flags" +mkdir -p "$BUILD_DIR" + +# CORE_WARNINGS (-Werror). +# shellcheck source-path=SCRIPTDIR source=lib/cpp-test-common.sh +. "$ROOT_DIR/scripts/lib/cpp-test-common.sh" + +# Every shipped binary is built with clang; prefer it over the host c++. +CXX="${CXX:-}" +if [ -z "$CXX" ]; then + if command -v clang++ >/dev/null 2>&1; then CXX=clang++; else CXX=c++; fi +fi + +failed=0 +check() { + platform="$1" + flags="$2" + for source in "$ROOT_DIR"/cpp/patch_core/*.cpp "$ROOT_DIR"/cpp/update_flow_core/*.cpp; do + if ! "$CXX" -fsyntax-only $CORE_WARNINGS $flags "$source"; then + echo "[FAIL] $platform ($flags): ${source#"$ROOT_DIR"/}" + failed=1 + fi + done + echo "[done] $platform: $flags" +} + +check android "-std=c++17 -fexceptions -fno-rtti -DNDEBUG -fvisibility=hidden -fvisibility-inlines-hidden" +check ios "-std=c++20 -DNDEBUG -fvisibility=hidden -fvisibility-inlines-hidden" +check harmony "-std=c++17 -DNDEBUG -fvisibility=hidden -fvisibility-inlines-hidden" + +if [ "$failed" != 0 ]; then + exit 1 +fi +echo "shared cores compile with every platform's release flags ($CXX)" From 550ee5236994b3d6813ed32612758a78a5f46973 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Thu, 15 Oct 2026 00:43:21 +0800 Subject: [PATCH 2/5] ci(test): run the core flag check when a platform's flag file changes Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0509908d..35d22065 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,6 +39,9 @@ jobs: - '.gitmodules' - 'cpp/**' - 'android/jni/**' + # The release flag sets test-core-platform-flags.sh mirrors. + - 'react-native-update.podspec' + - 'harmony/pushy/src/main/cpp/CMakeLists.txt' - 'scripts/test-*' - 'scripts/lib/**' # TS reference the C++ golden vectors are generated from. From 4b691970a18e35a87893645f661f69e998c8f9e8 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Thu, 15 Oct 2026 14:07:19 +0800 Subject: [PATCH 3/5] fix(ios): resolve resetToPackagedBundle once the state is reset Android and HarmonyOS settle the reset promise once the reset state is persisted and delete the downloaded versions best-effort afterwards; iOS waited for that cleanup and rejected with RESET_FAILED when it failed (its comment claimed Android parity). Align iOS with the other two: the reset is complete once the state is (the next launch resolves to the packaged bundle), and a directory a failed delete leaves behind is unreferenced, so the regular cleanup removes it later. Waiting instead would have put Android's and Harmony's reset behind an in-flight download on their serial queues. RESET_FAILED now means only that the reset state could not be persisted (error_codes.h, the client's doc comment); the purge-restore test asserts the new contract. Co-Authored-By: Claude Opus 5.5 --- cpp/patch_core/error_codes.h | 6 ++++-- ios/RCTPushy/RCTPushy.mm | 18 +++++++++--------- .../ios-purge-restore/purge_restore_test.mm | 2 +- src/client.ts | 9 +++++---- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/cpp/patch_core/error_codes.h b/cpp/patch_core/error_codes.h index 33bd8013..cfe2369f 100644 --- a/cpp/patch_core/error_codes.h +++ b/cpp/patch_core/error_codes.h @@ -38,8 +38,10 @@ constexpr const char* kSwitchVersionFailed = "SWITCH_VERSION_FAILED"; constexpr const char* kMarkSuccessFailed = "MARK_SUCCESS_FAILED"; // reloadUpdate / restartApp failed. constexpr const char* kRestartFailed = "RESTART_FAILED"; -// resetToPackagedBundle failed (state wipe or cleanup could not run), or the -// installed native module predates the method (JS-layer detection). +// resetToPackagedBundle could not persist the reset state, or the installed +// native module predates the method (JS-layer detection). Deleting the +// downloaded versions afterwards is best-effort on every platform and never +// reports this code: the reset is complete once the state is. constexpr const char* kResetFailed = "RESET_FAILED"; // Stored or provided hash info is not a valid JSON object. constexpr const char* kInvalidHashInfo = "INVALID_HASH_INFO"; diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index 935bec8c..6aba9379 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -462,13 +462,18 @@ - (NSDictionary *)constantsToExport return; } + // The reset is done once the state is: the next launch resolves to the + // packaged bundle. Settle now, as Android and Harmony do; deleting the + // downloaded versions is best-effort below and never delays the caller + // (it queues behind any unzip/patch on the file queue). A directory a + // failed delete leaves behind is no longer referenced by any state, so + // the regular cleanup removes it later. + resolve(@true); + dispatch_async(PushyFileQueue(), ^{ // maxAgeDays=0: remove every downloaded entry except the running // version's directory (cleaned up by the next regular cleanup). The - // state was just wiped, so that is the only name left to keep. The - // promise settles here, after the cleanup, so a failed wipe reaches - // JS as RESET_FAILED (Android parity) instead of a warning nobody - // aggregates. + // state was just wiped, so that is the only name left to keep. pushy::delta::Status status = pushy::delta::Status::Ok(); try { status = pushy::delta::CleanupOldEntries( @@ -481,12 +486,7 @@ - (NSDictionary *)constantsToExport } if (!status.ok) { RCTLogWarn(@"Pushy reset cleanup error: %s", status.message.c_str()); - PushyRejectError(reject, PushyErrorWithCode( - pushy::error_codes::kResetFailed, - [NSString stringWithUTF8String:status.message.c_str()] ?: @"reset cleanup failed")); - return; } - resolve(@true); }); } diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index e86e7418..0fdc2aee 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -464,7 +464,7 @@ static void CommitBeforeSignal(void) { static void ResetOrdering(Order selected) { SetUp(selected); NSURL *url = [RCTPushy bundleURL]; - Expect(resetResolved, "reset promise must resolve after its file queue work"); + Expect(resetResolved, "reset promise must resolve once the state is reset"); Expect(pushyResetGeneration.load() == 1 && requestGeneration == 0, "reset must invalidate the captured generation"); if (selected == Order::ResetFirst) { Expect(!roundCommitted && !roundActivated, "stale generation must reject ALL commit writes"); diff --git a/src/client.ts b/src/client.ts index 53882c14..8dbb6d9f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1710,10 +1710,11 @@ export class Pushy { } }; /** - * Reset to the bundle packaged in the binary: wipes every downloaded update - * and the whole update state on the native side, so the app loads the - * built-in bundle on the next launch (or immediately with - * `{ restart: true }`). The client uuid is preserved. + * Reset to the bundle packaged in the binary: wipes the whole update state + * on the native side, so the app loads the built-in bundle on the next + * launch (or immediately with `{ restart: true }`), and deletes the + * downloaded updates in the background (best-effort; the promise does not + * wait for it). The client uuid is preserved. * * Returns whether the reset actually happened. Like the other update-flow * APIs it never throws by default — failures land in lastError/onError with From 2de6fef285bf8dd9d9b8e6ca5c11a7554eb2ec34 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 16 Oct 2026 13:19:34 +0800 Subject: [PATCH 4/5] test(ios): pin that reset resolves even when its cleanup fails The cleanup stub now fails: the reset promise must still resolve, and the cleanup must still run. A reset that waited for the delete again rejects here (checked by restoring the previous behavior: reset_wins fails). Co-Authored-By: Claude Opus 5.5 --- .../tests/ios-purge-restore/purge_restore_test.mm | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index 0fdc2aee..02b285c2 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -69,6 +69,8 @@ static void Await(dispatch_semaphore_t gate, const char *description) { static uint64_t requestGeneration; static BOOL roundCommitted, roundActivated; static BOOL resetResolved; +// Signalled by the reset's (failing) cleanup stub below. +static dispatch_semaphore_t cleanupRan; static int scheduleCount; static int64_t waitBudget; static NSTimeInterval processElapsed; @@ -168,10 +170,14 @@ static void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) { // Filesystem cleanup is not under test. Keep the real declaration/return type; // the real reset body (including its asynchronous completion) still executes. namespace pushy { namespace delta { +// The reset's best-effort delete fails here on purpose: the reset is complete +// once the state is, so its promise must resolve regardless (a reset that +// still waited for the delete would reject, failing the test's rejecter). Status CleanupOldEntries(const std::string&, const std::vector&, int max_age_days, std::time_t) { Expect(max_age_days == 0, "reset requests a full cleanup"); - return {true, ""}; + Signal(cleanupRan); + return {false, "injected cleanup failure"}; } }} @@ -346,6 +352,7 @@ static void SetUp(Order selected) { workerSettled = dispatch_semaphore_create(0); resetAttempted = dispatch_semaphore_create(0); resetFinished = dispatch_semaphore_create(0); + cleanupRan = dispatch_semaphore_create(0); roundCommitted = NO; roundActivated = YES; // rejected commits must explicitly overwrite this resetResolved = NO; @@ -475,6 +482,9 @@ static void ResetOrdering(Order selected) { Expect(Value(PushyHashInfoKey(@"B")) == nil, "reset must leave no version metadata"); Expect(Value(keyNativeCheckCache) == nil, "reset must leave no response cache"); Expect([Value(keyUuid) isEqual:@"installation-uuid"], "reset must preserve install identity"); + // The failed delete ran after the promise had already resolved, and did + // not turn it into a rejection. + Await(cleanupRan, "reset still deletes the downloaded versions"); Finish(); } From f094bc4c02bb4922fa8e0e1b7895cd5ff98ca81c Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 16 Oct 2026 23:30:12 +0800 Subject: [PATCH 5/5] build(contract): carry the new RESET_FAILED meaning in the contract source error_codes.h is generated from scripts/contract.ts since #683; the reset contract comment this PR changed by hand belongs in the source, or the generator's --check fails lint once this lands. Co-Authored-By: Claude Opus 5.5 --- scripts/contract.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/contract.ts b/scripts/contract.ts index 7fc9f290..071ca87d 100644 --- a/scripts/contract.ts +++ b/scripts/contract.ts @@ -64,8 +64,10 @@ export const errorCodes: ErrorCode[] = [ name: 'ResetFailed', value: 'RESET_FAILED', doc: - 'resetToPackagedBundle failed (state wipe or cleanup could not run), or the ' + - 'installed native module predates the method (JS-layer detection).', + 'resetToPackagedBundle could not persist the reset state, or the installed ' + + 'native module predates the method (JS-layer detection). Deleting the ' + + 'downloaded versions afterwards is best-effort on every platform and never ' + + 'reports this code: the reset is complete once the state is.', }, { name: 'InvalidHashInfo',