Skip to content

Commit 0b528ab

Browse files
fix: make an interrupted React-Core-prebuilt swap recoverable
replace-rncore-version.js wrote .last_build_configuration only after it had already replaced React.xcframework. A build cancelled between the two steps left the marker naming a flavor that was no longer on disk, and when no marker was present at all the script assumed the on-disk flavor was Debug. Either way the next build for that configuration took the "no need to replace" path and linked against the other configuration's core, which fails with undefined C++ symbols. Nothing corrected the state afterwards, including a clean, because the pod directory survives it. The marker is now written before the framework is touched, holding a sentinel that is not a valid configuration, so a run that finds it knows the previous swap did not finish and replaces again. The fresh install path also records the configuration it assumed instead of leaving that state implicit, which is sound because a swap can no longer leave the marker missing.
1 parent 1bb746c commit 0b528ab

2 files changed

Lines changed: 107 additions & 2 deletions

File tree

packages/react-native/scripts/__tests__/replace-rncore-version-test.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ const path = require('node:path');
1919
const VERSION = '0.87.0-test';
2020
const SLICE = 'ios-arm64_x86_64-simulator';
2121
const BINARY = path.join(SLICE, 'React.framework', 'React');
22+
const SCRIPT = require.resolve('../replace-rncore-version.js');
23+
const MARKER = path.join('React-Core-prebuilt', '.last_build_configuration');
24+
25+
// Runs the script the way the "[RNCore] Replace React Native Core for the right
26+
// configuration" build phase does, from Pods/ and through the CLI entry point.
27+
function runScriptPhase(podsRoot, configuration) {
28+
return execFileSync(
29+
process.execPath,
30+
[SCRIPT, '-c', configuration, '-r', VERSION, '-p', podsRoot],
31+
{cwd: podsRoot, encoding: 'utf8'},
32+
);
33+
}
2234

2335
function writeFile(filePath, contents) {
2436
fs.mkdirSync(path.dirname(filePath), {recursive: true});
@@ -119,6 +131,73 @@ describe('replaceRNCoreConfiguration', () => {
119131
expect(fs.readFileSync(expoModuleMap, 'utf8')).toBe('module React {}\n');
120132
});
121133

134+
// Regression tests for #57598. The marker used to be written only after the
135+
// framework had already been replaced, so a build cancelled in between left it
136+
// naming a flavor that was no longer on disk. Every later build for that
137+
// flavor then took the "nothing to do" path and linked against the other
138+
// configuration's core, which fails with undefined C++ symbols and which a
139+
// clean does not undo because the pod directory survives it.
140+
describe('marker bookkeeping', () => {
141+
const marker = () => path.join(podsRoot, MARKER);
142+
const binary = () =>
143+
fs.readFileSync(path.join(pod, 'React.xcframework', BINARY), 'utf8');
144+
145+
it('records the configuration when it skips a fresh install', () => {
146+
// `pod install` leaves the debug flavor and no marker, so a Debug build
147+
// has nothing to swap. It still has to write down what is on disk,
148+
// otherwise the state stays implicit and stays unverifiable.
149+
expect(fs.existsSync(marker())).toBe(false);
150+
151+
runScriptPhase(podsRoot, 'Debug');
152+
153+
expect(fs.readFileSync(marker(), 'utf8')).toBe('Debug');
154+
expect(binary()).toBe('binary-Debug');
155+
});
156+
157+
it('invalidates the marker before it touches the framework', () => {
158+
fs.writeFileSync(marker(), 'Debug');
159+
// Drop the tarball so the Release run fails once it is already under way,
160+
// standing in for a build cancelled part way through the swap.
161+
fs.rmSync(
162+
path.join(
163+
podsRoot,
164+
'ReactNativeCore-artifacts',
165+
`reactnative-core-${VERSION.toLowerCase()}-release.tar.gz`,
166+
),
167+
);
168+
169+
expect(() => runScriptPhase(podsRoot, 'Release')).toThrow();
170+
171+
// The marker must no longer claim Debug: the framework may already have
172+
// been swapped, and a Debug build that trusts it would silently skip.
173+
expect(fs.readFileSync(marker(), 'utf8')).not.toBe('Debug');
174+
});
175+
176+
it('replaces the framework when the marker shows an unfinished swap', () => {
177+
buildTarball(podsRoot, 'Debug');
178+
// A swap that was interrupted: the Release flavor is on disk and the
179+
// marker never got its final value.
180+
replaceRNCoreConfiguration('Release', VERSION, podsRoot);
181+
fs.writeFileSync(marker(), 'in-progress');
182+
expect(binary()).toBe('binary-Release');
183+
184+
runScriptPhase(podsRoot, 'Debug');
185+
186+
expect(binary()).toBe('binary-Debug');
187+
expect(fs.readFileSync(marker(), 'utf8')).toBe('Debug');
188+
});
189+
190+
it('still skips when the marker already matches the configuration', () => {
191+
fs.writeFileSync(marker(), 'Release');
192+
const before = binary();
193+
194+
const output = runScriptPhase(podsRoot, 'Release');
195+
196+
expect(output).toContain('No need to replace React-Core-prebuilt');
197+
expect(binary()).toBe(before);
198+
});
199+
});
200+
122201
it('fails when the tarball has no React.xcframework', () => {
123202
const stage = fs.mkdtempSync(path.join(podsRoot, 'stage-bad-'));
124203
writeFile(path.join(stage, 'unrelated.txt'), 'nope');

packages/react-native/scripts/replace-rncore-version.js

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ const yargs = require('yargs');
1818

1919
const LAST_BUILD_FILENAME = 'React-Core-prebuilt/.last_build_configuration';
2020

21+
// Stored in LAST_BUILD_FILENAME while the framework on disk is being swapped.
22+
// It is not a valid configuration, so a run that finds it knows the previous
23+
// swap did not finish and the flavor on disk cannot be trusted.
24+
const REPLACEMENT_IN_PROGRESS = 'in-progress';
25+
2126
function validateBuildConfiguration(configuration /*: string */) {
2227
if (!['Debug', 'Release'].includes(configuration)) {
2328
throw new Error(`Invalid configuration ${configuration}`);
@@ -42,13 +47,24 @@ function shouldReplaceRnCoreConfiguration(configuration /*: string */) {
4247
);
4348
return false;
4449
}
50+
// Anything else, including REPLACEMENT_IN_PROGRESS left by a swap that was
51+
// cancelled or killed, means the flavor on disk is not the requested one or
52+
// is unknown. Replace it.
53+
return true;
4554
}
4655

47-
// Assumption: if there is no stored last build, we assume that it was build for debug.
48-
if (!fileExists && configuration === 'Debug') {
56+
// No marker means `pod install` has just laid the pod down and nothing has
57+
// swapped it since, because a swap records REPLACEMENT_IN_PROGRESS before it
58+
// touches the framework. The podspec source is always the debug tarball (see
59+
// resolve_podspec_source in scripts/cocoapods/rncore.rb), so the flavor on
60+
// disk is Debug and a Debug build has nothing to do.
61+
if (configuration === 'Debug') {
4962
console.log(
5063
'No previous build detected, but Debug Configuration. No need to replace React-Core-prebuilt',
5164
);
65+
// Record the assumption rather than leaving it implicit, so the state is
66+
// readable and a later run never has to make it again.
67+
updateLastBuildConfiguration(configuration);
5268
return false;
5369
}
5470

@@ -130,6 +146,10 @@ function updateLastBuildConfiguration(configuration /*: string */) {
130146
fs.writeFileSync(LAST_BUILD_FILENAME, configuration);
131147
}
132148

149+
function markReplacementInProgress() /*: void */ {
150+
fs.writeFileSync(LAST_BUILD_FILENAME, REPLACEMENT_IN_PROGRESS);
151+
}
152+
133153
function main(
134154
configuration /*: string */,
135155
version /*: string */,
@@ -142,6 +162,12 @@ function main(
142162
return;
143163
}
144164

165+
// Invalidate the marker before the framework is touched. A build cancelled
166+
// between the swap and the update used to leave the marker naming a flavor
167+
// that was no longer on disk, and every later build for that flavor then took
168+
// the skip path and linked against the wrong core, which a clean does not
169+
// undo. Recording the swap first makes an interrupted run recoverable.
170+
markReplacementInProgress();
145171
replaceRNCoreConfiguration(configuration, version, podsRoot);
146172
updateLastBuildConfiguration(configuration);
147173
console.log('Done replacing React Native prebuilt');

0 commit comments

Comments
 (0)