Skip to content

fix(harmony): safeguard binary version sync against background-thread resource loading failures#591

Closed
sunnylqm wants to merge 2 commits into
masterfrom
fix/harmony-background-thread-safe
Closed

fix(harmony): safeguard binary version sync against background-thread resource loading failures#591
sunnylqm wants to merge 2 commits into
masterfrom
fix/harmony-background-thread-safe

Conversation

@sunnylqm

@sunnylqm sunnylqm commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

This PR implements a safety guard and caching layer for binary version synchronization to prevent update state wipes during soft reloads:

Background & Issue

During a soft reload, the React Native container initializes its TurboModules (including Pushy) on a background JS thread. The UpdateContext constructor calls syncStateWithBinaryVersion() which tries to read meta.json synchronously via resourceManager.getRawFileContentSync('meta.json').
In HarmonyOS, synchronous resource manager APIs are not thread-safe and throw exceptions if called from non-UI threads. The exception is silently caught, returning an empty string "". Because this empty string doesn't match the previously stored build time, the system assumes the binary has been updated and clears the entire update Preferences.

Solution

  1. Safety Guard: Added a check at the top of syncStateWithBinaryVersion():
    if (!packageVersion || !buildTime) {
      return;
    }
    If the current version/build time cannot be read (e.g. background thread loading failures), it immediately returns early instead of triggering a false-positive mismatch and wiping preferences.
  2. Static Memory Cache: Retained the caching of packageVersion and buildTime inside static class fields during the first resolution (which always runs on the Main/UI thread in the bundle provider).

Unit tests pass successfully.

Summary by CodeRabbit

  • New Features

    • Updated the app to support newer Expo, React Native, and related library versions.
    • Added required build-time configuration so iOS builds can include the needed native integration.
  • Bug Fixes

    • Improved app startup data handling by reusing cached version/build information.
    • Added better handling when build-time metadata is missing or unreadable.
    • Prevented sync steps from running when required version details are unavailable.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds app.plugin.js, an Expo config plugin that injects #import <React/RCTBridge.h> into the iOS bridging header. The example app's app.json wires in this plugin and its dependencies are bumped to Expo SDK 54 / React Native 0.81. Harmony's UpdateContext gains memoized getPackageVersion/getBuildTime with error logging and an early-return guard in syncStateWithBinaryVersion. The uuid field is removed from the bootup status log.

Changes

iOS Bridging Header Expo Plugin + Example App

Layer / File(s) Summary
app.plugin.js implementation
app.plugin.js
requireConfigPlugins() loads @expo/config-plugins with a process.cwd() fallback; withPushyRCTBridgeImport uses withDangerousMod to read the iOS bridging header and append #import <React/RCTBridge.h> if not already present.
Example app wiring and dependency bump
Example/expoUsePushy/app.json, Example/expoUsePushy/package.json
app.json adds the with-rct-bridge-bridging-header plugin entry; package.json bumps Expo SDK to ^54.0.0, React Native to 0.81.5, React to 19.1.0, and related tooling.

Harmony UpdateContext and core cleanup

Layer / File(s) Summary
UpdateContext memoization and guard
harmony/pushy/src/main/ets/UpdateContext.ts
Adds cachedPackageVersion and cachedBuildTime static fields; getPackageVersion() and getBuildTime() return cached values on repeat calls and log errors instead of silently swallowing them; syncStateWithBinaryVersion() returns early when either argument is falsy.
Remove uuid from bootup log
src/core.ts
Removes the uuid property from the log('bootup status', ...) payload.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐇 A header gets patched, the bridge finds its way,
Versions all bumped for a bright SDK day.
The cache hops in fast, no re-reading in sight,
And uuid hops out — it was never quite right.
With carrots and configs, the build feels just right! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main HarmonyOS fix to binary version sync and the background-thread resource loading failure.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/harmony-background-thread-safe

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

app.plugin.js

Oops! Something went wrong! :(

ESLint: 8.57.1

Error: .eslintrc.js » @react-native/eslint-config#overrides[4]:
Environment key "jest/globals" is unknown

at /node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2079:23
at Array.forEach (<anonymous>)
at ConfigValidator.validateEnvironment (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2073:34)
at ConfigValidator.validateConfigArray (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2223:18)
at CascadingConfigArrayFactory._finalizeConfigArray (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3985:23)
at CascadingConfigArrayFactory.getConfigArrayForFile (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3791:21)
at FileEnumerator._iterateFilesWithFile (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli-engine/file-enumerator.js:368:43)
at FileEnumerator._iterateFiles (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli-engine/file-enumerator.js:349:25)
at FileEnumerator.iterateFiles (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli-engine/file-enumerator.js:299:59)
at iterateFiles.next (<anonymous>)
harmony/pushy/src/main/ets/UpdateContext.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

Error: .eslintrc.js » @react-native/eslint-config#overrides[4]:
Environment key "jest/globals" is unknown

at /node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2079:23
at Array.forEach (<anonymous>)
at ConfigValidator.validateEnvironment (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2073:34)
at ConfigValidator.validateConfigArray (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2223:18)
at CascadingConfigArrayFactory._finalizeConfigArray (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3985:23)
at CascadingConfigArrayFactory.getConfigArrayForFile (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3791:21)
at FileEnumerator._iterateFilesWithFile (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli-engine/file-enumerator.js:368:43)
at FileEnumerator._iterateFiles (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli-engine/file-enumerator.js:349:25)
at FileEnumerator.iterateFiles (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli-engine/file-enumerator.js:299:59)
at iterateFiles.next (<anonymous>)

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sunnylqm sunnylqm closed this Jun 30, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app.plugin.js`:
- Around line 29-36: Guard the bridging header handling in app.plugin.js so the
iOS prebuild does not fail when `${projectName}-Bridging-Header.h` is missing.
In the logic around `fs.readFileSync(bridgingHeaderPath, "utf8")`, add a check
for file existence first (or create the file if that is the intended behavior)
before reading or writing. Keep the import-injection behavior tied to
`RCT_BRIDGE_IMPORT`, but skip gracefully when the bridging header is absent.
- Around line 4-14: The Expo plugin entry is pointing to a missing local module,
so prebuild cannot resolve the plugin. Update the app.json plugin reference to
an existing plugin module or add the expected file under the project’s plugins
directory, and make sure the plugin exports from app.plugin.js are actually
reachable by the configured entry path. Use the app.plugin.js and
withDangerousMod symbols to locate the plugin wiring.

In `@harmony/pushy/src/main/ets/UpdateContext.ts`:
- Around line 68-73: The fallback in UpdateContext.getBundleVersion is using the
sentinel string 'Unknown', which prevents the new empty-version guard from
detecting a failed bundle lookup. Change the cached package version assignment
so a missing or empty bundleInfo.versionName resolves to an empty string
instead, and keep the logic in getBundleVersion and syncStateWithBinaryVersion
aligned so the latter only runs when a real binary version is available.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 894f49f5-d953-4042-9c26-84d064a58dbe

📥 Commits

Reviewing files that changed from the base of the PR and between 009dc5a and 403ddb3.

⛔ Files ignored due to path filters (1)
  • Example/expoUsePushy/bun.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • Example/expoUsePushy/app.json
  • Example/expoUsePushy/package.json
  • app.plugin.js
  • harmony/pushy/src/main/ets/UpdateContext.ts
  • src/core.ts
💤 Files with no reviewable changes (1)
  • src/core.ts

Comment thread app.plugin.js
Comment on lines +4 to +14
function requireConfigPlugins() {
try {
return require("@expo/config-plugins");
} catch {
return require(require.resolve("@expo/config-plugins", {
paths: [process.cwd()],
}));
}
}

const { withDangerousMod } = requireConfigPlugins();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the plugin file referenced by app.json
fd -uu -t f 'with-rct-bridge-bridging-header'
fd -uu -t f 'app.plugin.js'

Repository: reactnativecn/react-native-update

Length of output: 185


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the example config and plugin entrypoint references
git ls-files 'Example/expoUsePushy/*' 'Example/expoUsePushy/**/*' 'app.plugin.js'

printf '\n--- app.json ---\n'
sed -n '1,220p' Example/expoUsePushy/app.json

printf '\n--- app.plugin.js ---\n'
cat -n app.plugin.js

printf '\n--- plugin path search ---\n'
fd -uu -t f 'with-rct-bridge-bridging-header' Example/expoUsePushy . || true
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' '"\.\/plugins\/with-rct-bridge-bridging-header"|with-rct-bridge-bridging-header|app\.plugin\.js' Example/expoUsePushy .

Repository: reactnativecn/react-native-update

Length of output: 3194


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- example package.json ---'
sed -n '1,220p' Example/expoUsePushy/package.json

printf '\n%s\n' '--- references to app.plugin.js / with-rct-bridge-bridging-header ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' 'app\.plugin\.js|with-rct-bridge-bridging-header|plugins/\./plugins|plugins"' Example/expoUsePushy .

printf '\n%s\n' '--- files named plugins or plugin entrypoints ---'
fd -uu -t f 'app.plugin.js' .
fd -uu -t d 'plugins' Example/expoUsePushy .

Repository: reactnativecn/react-native-update

Length of output: 50389


🌐 Web query:

Expo config plugin relative path app.plugin.js app.json plugins "./plugins/with-rct-bridge-bridging-header" resolution

💡 Result:

In Expo, local config plugins referenced by a relative path in your app.json (e.g., "./plugins/with-rct-bridge-bridging-header") are resolved relative to the project root [1]. When you specify a string path in the plugins array, Expo CLI resolves it using Node's module resolution system [1]. For a local file like "./plugins/with-rct-bridge-bridging-header", Expo will attempt to load the file by appending supported extensions if necessary (such as.js,.ts,.cjs,.mjs, etc., following the latest resolution logic) [2]. Key points regarding this resolution: 1. Local vs. Module Resolution: If the string does not start with a relative path indicator (./ or../) or an absolute path, Expo treats it as an npm package [1]. Using./ ensures it is treated as a local file relative to your project's root directory [1]. 2. Supported Formats: As of May 2026, Expo has aligned config plugin resolution with standard app config resolution, supporting a wider range of formats including.js,.ts,.cjs,.mjs,.cts, and.mts [2]. This allows you to write local plugins in TypeScript or modern JavaScript without requiring manual pre-transpilation in some workflows [2]. 3. Plugin Structure: While npm packages are expected to export the plugin via an app.plugin.js file (or the main field in package.json) [3][4], a local file referenced directly by path is loaded as a module [1]. The exported function from that file will be treated as the config plugin [5][1]. 4. Troubleshooting: If your plugin fails to load, ensure the file exists at the path specified relative to the project root and that it exports the plugin function correctly (usually as the default export or via module.exports) [5][1]. Recent updates have improved error reporting for plugin resolution failures [2].

Citations:


Point the Expo plugin entry at an existing module
Example/expoUsePushy/app.json points to "./plugins/with-rct-bridge-bridging-header", but there’s no matching module under Example/expoUsePushy/plugins/, and the root app.plugin.js won’t be loaded from that entry. Expo resolves local plugins relative to the project root, so prebuild will fail unless the path is corrected or the plugin file is added.

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 7-9: Avoid require with non-literal values
Context: require(require.resolve("@expo/config-plugins", {
paths: [process.cwd()],
}))
Note: [CWE-829] Inclusion of Functionality from Untrusted Control Sphere (dynamic require).

(detect-non-literal-require)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.plugin.js` around lines 4 - 14, The Expo plugin entry is pointing to a
missing local module, so prebuild cannot resolve the plugin. Update the app.json
plugin reference to an existing plugin module or add the expected file under the
project’s plugins directory, and make sure the plugin exports from app.plugin.js
are actually reachable by the configured entry path. Use the app.plugin.js and
withDangerousMod symbols to locate the plugin wiring.

Comment thread app.plugin.js
Comment on lines +29 to +36
const contents = fs.readFileSync(bridgingHeaderPath, "utf8");

if (!contents.includes(RCT_BRIDGE_IMPORT)) {
fs.writeFileSync(
bridgingHeaderPath,
`${contents.trimEnd()}\n\n${RCT_BRIDGE_IMPORT}\n`
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard against a missing bridging header before reading.

fs.readFileSync will throw an opaque ENOENT if ${projectName}-Bridging-Header.h doesn't exist (e.g. depending on prebuild ordering or a project without one), failing the entire iOS prebuild. Consider skipping (or creating the file) when it's absent.

🛡️ Proposed guard
+      if (!fs.existsSync(bridgingHeaderPath)) {
+        return config;
+      }
+
       const contents = fs.readFileSync(bridgingHeaderPath, "utf8");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const contents = fs.readFileSync(bridgingHeaderPath, "utf8");
if (!contents.includes(RCT_BRIDGE_IMPORT)) {
fs.writeFileSync(
bridgingHeaderPath,
`${contents.trimEnd()}\n\n${RCT_BRIDGE_IMPORT}\n`
);
}
if (!fs.existsSync(bridgingHeaderPath)) {
return config;
}
const contents = fs.readFileSync(bridgingHeaderPath, "utf8");
if (!contents.includes(RCT_BRIDGE_IMPORT)) {
fs.writeFileSync(
bridgingHeaderPath,
`${contents.trimEnd()}\n\n${RCT_BRIDGE_IMPORT}\n`
);
}
🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 31-34: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use.
Context: fs.writeFileSync(
bridgingHeaderPath,
${contents.trimEnd()}\n\n${RCT_BRIDGE_IMPORT}\n
)
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(detect-non-literal-fs-filename)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.plugin.js` around lines 29 - 36, Guard the bridging header handling in
app.plugin.js so the iOS prebuild does not fail when
`${projectName}-Bridging-Header.h` is missing. In the logic around
`fs.readFileSync(bridgingHeaderPath, "utf8")`, add a check for file existence
first (or create the file if that is the intended behavior) before reading or
writing. Keep the import-injection behavior tied to `RCT_BRIDGE_IMPORT`, but
skip gracefully when the bridging header is absent.

Comment on lines 68 to +73
try {
const bundleInfo = bundleManager.getBundleInfoForSelfSync(
this.getBundleFlags(),
);
return bundleInfo?.versionName || 'Unknown';
UpdateContext.cachedPackageVersion = bundleInfo?.versionName || 'Unknown';
return UpdateContext.cachedPackageVersion;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Return an empty package version instead of 'Unknown'.

Line 72 turns a failed/empty versionName lookup into a truthy sentinel, so the new guard at Lines 259-261 will not fire. That still lets syncStateWithBinaryVersion() run with a synthetic binary version and can clear persisted update state on a false mismatch.

Proposed fix
     try {
       const bundleInfo = bundleManager.getBundleInfoForSelfSync(
         this.getBundleFlags(),
       );
-      UpdateContext.cachedPackageVersion = bundleInfo?.versionName || 'Unknown';
+      UpdateContext.cachedPackageVersion = bundleInfo?.versionName || '';
       return UpdateContext.cachedPackageVersion;
     } catch (error) {
       console.error('Failed to get bundle info:', error);
       return '';
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const bundleInfo = bundleManager.getBundleInfoForSelfSync(
this.getBundleFlags(),
);
return bundleInfo?.versionName || 'Unknown';
UpdateContext.cachedPackageVersion = bundleInfo?.versionName || 'Unknown';
return UpdateContext.cachedPackageVersion;
try {
const bundleInfo = bundleManager.getBundleInfoForSelfSync(
this.getBundleFlags(),
);
UpdateContext.cachedPackageVersion = bundleInfo?.versionName || '';
return UpdateContext.cachedPackageVersion;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@harmony/pushy/src/main/ets/UpdateContext.ts` around lines 68 - 73, The
fallback in UpdateContext.getBundleVersion is using the sentinel string
'Unknown', which prevents the new empty-version guard from detecting a failed
bundle lookup. Change the cached package version assignment so a missing or
empty bundleInfo.versionName resolves to an empty string instead, and keep the
logic in getBundleVersion and syncStateWithBinaryVersion aligned so the latter
only runs when a real binary version is available.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant