Skip to content

Commit 8bfa9dd

Browse files
fairyswordmeta-codesync[bot]
authored andcommitted
Fix Hermes Debug builds after installing another pod (#59159)
Summary: A successful Release build followed by adding a new CocoaPods dependency can cause the next Debug build to fail with undefined Hermes debugger symbols, including `CDPDebugAPI::create` and `CDPAgent::create`. The Release build installs the Release Hermes binary and records `Release` in `Pods/hermes-engine/.last_build_configuration`. When a subsequent `pod install` installs another pod, CocoaPods cleans the existing Hermes pod. The `Pre-built` subspec does not preserve the configuration marker, so the marker is removed while the Release framework remains on disk. On the next Debug build, `replace_hermes_version.js` receives the correct `Debug` configuration but assumes that a missing marker means Hermes is already Debug. It skips replacement, leaving Debug React Native code to link against a Release Hermes binary that does not export the required debugger symbols. This change: - Adds `.last_build_configuration` to the `Pre-built` subspec's `preserve_paths`, so installing another pod does not discard the configuration recorded for the existing Hermes binary. - Removes the assumption that a missing marker implies Debug. If the marker is absent, the replacement script extracts the requested configuration and writes its marker. - Adds regression coverage for missing markers, configuration switches, fresh installations, and the existing matching-configuration fast path. A matching marker still skips extraction. A fresh Debug installation without a marker now performs one initial extraction; subsequent builds with the same recorded configuration continue to skip it. ## Changelog: [IOS] [FIXED] - Fix Hermes Debug link failures after installing another pod following a Release build. Pull Request resolved: #59159 Test Plan: ### Environment The failure was reproduced in RNTester on the unmodified main baseline `27e77216ef2557ccbf31a01067a44dc1dd2ec846`, using: - Xcode 27.0 (`27A266a`), iPhoneOS SDK, arm64. - Ruby 3.3.11, Bundler 2.5.22, CocoaPods 1.15.2. - Node.js 22.22.3, prebuilt Hermes `260318099.0.2`, and `hermes-compiler` `260318099.0.4` selected by the repository's installation hook. - Default RNTester CocoaPods integration, with `USE_FRAMEWORKS` unset. Both the unmodified and patched versions were tested through complete Debug → Release → add a pod → Debug sequences. Each sequence used the same workspace and DerivedData directory throughout; the patched sequence started with fresh Pods and a separate empty DerivedData directory. JavaScript bundling was enabled. Signing was disabled with `CODE_SIGNING_ALLOWED=NO`; the app was not launched on a device. ### Reproduction on the unmodified baseline 1. Start with a fresh checkout of the baseline above, without Masonry in RNTester's Podfile. Prepare the repository and RNTester dependencies from the repository root: ```bash yarn install yarn --cwd packages/react-native-codegen build cd packages/rn-tester bundle install bundle exec pod update hermes-engine --no-repo-update ``` On this specific baseline, the checked-in RNTester lockfile references an older Hermes version than the local podspec. The final command aligns that dependency once, matching the repository's RNTester CI setup. This is environment preparation; the dependency installation that triggers the bug below uses ordinary `pod install`. 2. Define the build command in the same terminal. The directory is inside RNTester and is shared by Debug and Release: ```bash REPRO_DIR="$PWD/build/hermes-marker-repro-$(date +%Y%m%d-%H%M%S)" mkdir -p "$REPRO_DIR" build_rntester() { xcodebuild \ -workspace RNTesterPods.xcworkspace \ -scheme RNTester \ -configuration "$1" \ -sdk iphoneos \ -destination 'generic/platform=iOS' \ -derivedDataPath "$REPRO_DIR/DerivedData" \ -jobs 6 \ CODE_SIGNING_ALLOWED=NO \ build } ``` 3. Build Debug to establish a working baseline: ```bash build_rntester Debug ``` Result: `BUILD SUCCEEDED`. 4. Build Release to simulate a release build: ```bash build_rntester Release ``` Result: `BUILD SUCCEEDED`. The normal Xcode build phase switches Hermes to Release and writes the `Release` marker. 5. Add a new third-party dependency inside the existing RNTester target in `packages/rn-tester/Podfile`: ```ruby target 'RNTester' do pods('RNTester') pod 'Masonry', '1.1.0' end ``` Masonry is a published pod used only to trigger a normal new-pod installation in this reproduction. You can replace it with any other compatible pod that is not already installed in the project. 6. Install the dependency, retaining the existing Pods and DerivedData directories: ```bash bundle exec pod install ``` Result: installation succeeds and reports `Installing Masonry (1.1.0)`. The Hermes configuration marker is now absent, but the installed arm64 Hermes binary still matches the Release archive. 7. Build Debug again: ```bash build_rntester Debug ``` Result: `BUILD FAILED`, with `xcodebuild` exiting with code 65. The failing link is RNTester's `RNTester.debug.dylib`; unresolved references originate from `libReact-hermes.a`. The Hermes build phase reports: ```text No previous build detected, but Debug Configuration. No need to replace Hermes engine ``` The linker reports undefined arm64 symbols, including: ```text Reviewed By: fabriziocucci Differential Revision: D125847848 Pulled By: cipolleschi fbshipit-source-id: 7a9cc0e9fbadfcc67c62a4f734b459acc303bd35
1 parent 27e7721 commit 8bfa9dd

3 files changed

Lines changed: 123 additions & 9 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @format
8+
* @noflow
9+
*/
10+
11+
'use strict';
12+
13+
const {execFileSync} = require('node:child_process');
14+
const fs = require('node:fs');
15+
const os = require('node:os');
16+
const path = require('node:path');
17+
18+
const SCRIPT =
19+
require.resolve('../../sdks/hermes-engine/utils/replace_hermes_version.js');
20+
const VERSION = '0.0.0-test';
21+
const BINARY =
22+
'destroot/Library/Frameworks/universal/hermesvm.xcframework/ios-arm64/hermesvm.framework/hermesvm';
23+
24+
function writeFile(filePath, contents) {
25+
fs.mkdirSync(path.dirname(filePath), {recursive: true});
26+
fs.writeFileSync(filePath, contents);
27+
}
28+
29+
describe('Hermes configuration replacement', () => {
30+
let podsRoot;
31+
let binary;
32+
let marker;
33+
34+
beforeEach(() => {
35+
podsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-config-test-'));
36+
binary = path.join(podsRoot, 'hermes-engine', BINARY);
37+
marker = path.join(podsRoot, 'hermes-engine/.last_build_configuration');
38+
});
39+
40+
afterEach(() => {
41+
fs.rmSync(podsRoot, {recursive: true, force: true});
42+
});
43+
44+
function buildTarball(configuration) {
45+
const stage = path.join(podsRoot, 'stage');
46+
writeFile(path.join(stage, BINARY), configuration);
47+
const artifacts = path.join(podsRoot, 'hermes-engine-artifacts');
48+
fs.mkdirSync(artifacts, {recursive: true});
49+
execFileSync('tar', [
50+
'-czf',
51+
path.join(
52+
artifacts,
53+
`hermes-ios-${VERSION}-${configuration.toLowerCase()}.tar.gz`,
54+
),
55+
'-C',
56+
stage,
57+
'.',
58+
]);
59+
}
60+
61+
function runScript(configuration) {
62+
// Match the podspec's build phase: run the CLI from the Pods directory.
63+
execFileSync(
64+
process.execPath,
65+
[SCRIPT, '-c', configuration, '-r', VERSION, '-p', podsRoot],
66+
{cwd: podsRoot, encoding: 'utf8'},
67+
);
68+
}
69+
70+
it.each([
71+
['Debug', 'Release'],
72+
['Release', 'Debug'],
73+
])('restores %s when the marker is missing and %s remains', (target, old) => {
74+
writeFile(binary, old);
75+
buildTarball(target);
76+
77+
runScript(target);
78+
79+
expect(fs.readFileSync(binary, 'utf8')).toBe(target);
80+
expect(fs.readFileSync(marker, 'utf8')).toBe(target);
81+
});
82+
83+
it.each(['Debug', 'Release'])(
84+
'keeps a recorded %s configuration without requiring a tarball',
85+
configuration => {
86+
writeFile(binary, configuration);
87+
writeFile(marker, configuration);
88+
89+
runScript(configuration);
90+
91+
expect(fs.readFileSync(binary, 'utf8')).toBe(configuration);
92+
expect(fs.readFileSync(marker, 'utf8')).toBe(configuration);
93+
},
94+
);
95+
96+
it.each([
97+
['Debug', 'Release'],
98+
['Release', 'Debug'],
99+
])('switches to %s when %s is recorded', (target, old) => {
100+
writeFile(binary, old);
101+
writeFile(marker, old);
102+
buildTarball(target);
103+
104+
runScript(target);
105+
106+
expect(fs.readFileSync(binary, 'utf8')).toBe(target);
107+
expect(fs.readFileSync(marker, 'utf8')).toBe(target);
108+
});
109+
110+
it('records Debug after restoring a fresh install with no marker', () => {
111+
writeFile(binary, 'Debug');
112+
buildTarball('Debug');
113+
114+
runScript('Debug');
115+
116+
expect(fs.readFileSync(binary, 'utf8')).toBe('Debug');
117+
expect(fs.readFileSync(marker, 'utf8')).toBe('Debug');
118+
});
119+
});

‎packages/react-native/sdks/hermes-engine/hermes-engine.podspec‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ Pod::Spec.new do |spec|
5656
if HermesEngineSourceType::isPrebuilt(source_type) then
5757

5858
spec.subspec 'Pre-built' do |ss|
59-
ss.preserve_paths = ["destroot/bin/*"].concat(["**/*.{h,c,cpp}"])
59+
# Keep the configuration marker when CocoaPods cleans an unchanged pod.
60+
ss.preserve_paths = ["destroot/bin/*", ".last_build_configuration"].concat(["**/*.{h,c,cpp}"])
6061
ss.source_files = ["destroot/include/hermes/**/*.h", "destroot/include/jsi/hermes.h"]
6162
ss.header_mappings_dir = "destroot/include"
6263
ss.ios.vendored_frameworks = "destroot/Library/Frameworks/universal/hermesvm.xcframework"

‎packages/react-native/sdks/hermes-engine/utils/replace_hermes_version.js‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,8 @@ function shouldReplaceHermesConfiguration(configuration) {
4242
}
4343
}
4444

45-
// Assumption: if there is no stored last build, we assume that it was build for debug.
46-
if (!fileExists && configuration === 'Debug') {
47-
console.log(
48-
'No previous build detected, but Debug Configuration. No need to replace Hermes engine',
49-
);
50-
return false;
51-
}
52-
45+
// The binary may outlive its marker, so a missing marker does not imply Debug.
46+
// Restore the requested configuration instead of assuming what is installed.
5347
return true;
5448
}
5549

0 commit comments

Comments
 (0)