From 8f1d214dcb91721a8a5003ef6315dbcf7308cfc6 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Fri, 18 Sep 2026 18:29:23 +0200 Subject: [PATCH] Repair stale staged headers in the iOS prebuild The prebuild stages headers into .build/headers as hard links, and skipped any target that already existed. A hard link shares an inode, so in-place edits propagate, but git checkout renames a new file over the source and gives it a new inode. The staged link then serves the old contents forever and setup never repairs it, so the build compiles against headers from whichever commit was checked out when the link was first created. The staging function now compares the staged file against its source and relinks it when the inodes differ. Targets a pass has already claimed are left alone, so sources that flatten onto the same target keep resolving to the first one visited, as before. Co-Authored-By: Claude Opus 5 (1M context) --- .../ios-prebuild/__tests__/setup-test.js | 296 ++++++++++++++++++ .../scripts/ios-prebuild/setup.js | 165 ++++++---- 2 files changed, 398 insertions(+), 63 deletions(-) create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/setup-test.js diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/setup-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/setup-test.js new file mode 100644 index 000000000000..a0a66f27721d --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/setup-test.js @@ -0,0 +1,296 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const {createHeaderLinker} = require('../setup'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +describe('createHeaderLinker', () => { + let tmp /*: string */ = ''; + let root /*: string */ = ''; + let linksFolder /*: string */ = ''; + let log /*: JestMockFn<[string], void> */ = jest.fn(); + let stage /*: (fromPath: string, includePath?: ?string) => void */ = () => {}; + + // Each pass gets its own linker, the way a prebuild run does. + const newPass = () => { + log = jest.fn(); + return createHeaderLinker(root, linksFolder, log); + }; + + const write = (relPath /*: string */, contents /*: string */) => { + const file = path.join(root, relPath); + fs.mkdirSync(path.dirname(file), {recursive: true}); + fs.writeFileSync(file, contents); + return file; + }; + + const staged = (relPath /*: string */) => path.join(linksFolder, relPath); + + const linkedMessage = (fromPath /*: string */, includePath /*: string */) => + `Linked ${fromPath} → ${path.relative(root, staged(includePath))}`; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'header-links-test-')); + root = path.join(tmp, 'source'); + linksFolder = path.join(tmp, 'headers'); + fs.mkdirSync(root, {recursive: true}); + fs.mkdirSync(linksFolder, {recursive: true}); + stage = newPass(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + it('hard links a header into the staging folder', () => { + const source = write('React/Base/RCTUtils.h', '// original\n'); + + stage('React/Base', 'React'); + + expect(fs.readFileSync(staged('React/RCTUtils.h'), 'utf8')).toBe( + '// original\n', + ); + expect(fs.statSync(staged('React/RCTUtils.h')).ino).toBe( + fs.statSync(source).ino, + ); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith(linkedMessage('React/Base', 'React')); + }); + + it('repairs a staged link whose source was replaced by a new inode', () => { + write('React/Base/RCTUtils.h', '// june\n'); + stage('React/Base', 'React'); + + // git checkout writes a new file and renames it over the old one, so the + // source gets a new inode and the staged link keeps serving the old one. + const replacement = path.join(tmp, 'RCTUtils.h.new'); + fs.writeFileSync(replacement, '// august\n'); + const source = path.join(root, 'React/Base/RCTUtils.h'); + fs.renameSync(replacement, source); + + stage = newPass(); + stage('React/Base', 'React'); + + expect(fs.readFileSync(staged('React/RCTUtils.h'), 'utf8')).toBe( + '// august\n', + ); + expect(fs.statSync(staged('React/RCTUtils.h')).ino).toBe( + fs.statSync(source).ino, + ); + expect(log).toHaveBeenCalledWith(linkedMessage('React/Base', 'React')); + }); + + it('does not relink an up-to-date link on a later pass', () => { + write('React/Base/RCTUtils.h', '// original\n'); + stage('React/Base', 'React'); + + const linkSync = jest.spyOn(fs, 'linkSync'); + const unlinkSync = jest.spyOn(fs, 'unlinkSync'); + + stage = newPass(); + stage('React/Base', 'React'); + + expect(linkSync).not.toHaveBeenCalled(); + expect(unlinkSync).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it('keeps the header staged first when two sources collide on one target', () => { + const umbrella = write('callinvoker/React/CallInvoker.h', '// umbrella\n'); + write('callinvoker/ReactCommon/CallInvoker.h', '// interface\n'); + + stage('callinvoker/React', 'ReactCommon'); + stage('callinvoker/ReactCommon', 'ReactCommon'); + + expect(fs.readFileSync(staged('ReactCommon/CallInvoker.h'), 'utf8')).toBe( + '// umbrella\n', + ); + expect(fs.statSync(staged('ReactCommon/CallInvoker.h')).ino).toBe( + fs.statSync(umbrella).ino, + ); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith( + linkedMessage('callinvoker/React', 'ReactCommon'), + ); + + const linkSync = jest.spyOn(fs, 'linkSync'); + const unlinkSync = jest.spyOn(fs, 'unlinkSync'); + + stage = newPass(); + stage('callinvoker/React', 'ReactCommon'); + stage('callinvoker/ReactCommon', 'ReactCommon'); + + expect(linkSync).not.toHaveBeenCalled(); + expect(unlinkSync).not.toHaveBeenCalled(); + expect(fs.readFileSync(staged('ReactCommon/CallInvoker.h'), 'utf8')).toBe( + '// umbrella\n', + ); + }); + + it('keeps the header visited first when one pass recurses into colliding siblings', () => { + write('callinvoker/React/CallInvoker.h', '// umbrella\n'); + write('callinvoker/ReactCommon/CallInvoker.h', '// interface\n'); + + // Read the traversal order rather than assume it: the invariant is that the + // first sibling visited wins, not that a particular sibling wins. + const [firstVisited] = fs + .readdirSync(path.join(root, 'callinvoker'), {withFileTypes: true}) + .filter(dirent => dirent.isDirectory()) + .map(dirent => String(dirent.name)); + const winner = path.join( + root, + 'callinvoker', + firstVisited, + 'CallInvoker.h', + ); + + stage('callinvoker', 'ReactCommon'); + + expect(fs.readFileSync(staged('ReactCommon/CallInvoker.h'), 'utf8')).toBe( + fs.readFileSync(winner, 'utf8'), + ); + expect(fs.statSync(staged('ReactCommon/CallInvoker.h')).ino).toBe( + fs.statSync(winner).ino, + ); + }); + + it('replaces a staged symlink that points at the source file', () => { + const source = write('React/Base/RCTUtils.h', '// original\n'); + fs.mkdirSync(staged('React'), {recursive: true}); + // A symlink resolves to the source inode but does not share it, so it would + // not track in-place edits the way the staging tree assumes. + fs.symlinkSync(source, staged('React/RCTUtils.h')); + + stage('React/Base', 'React'); + + expect(fs.lstatSync(staged('React/RCTUtils.h')).isSymbolicLink()).toBe( + false, + ); + expect(fs.statSync(staged('React/RCTUtils.h')).ino).toBe( + fs.statSync(source).ino, + ); + }); + + it('stages the remaining headers when one file fails to link', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + write('React/Base/RCTUtils.h', ''); + write('React/Base/RCTConversions.h', ''); + + const unlinkable = staged('React/RCTUtils.h'); + const realLinkSync = fs.linkSync; + jest.spyOn(fs, 'linkSync').mockImplementation((sourceFile, targetFile) => { + if (targetFile === unlinkable) { + throw new Error('Operation not permitted'); + } + realLinkSync(sourceFile, targetFile); + }); + + expect(() => stage('React/Base', 'React')).not.toThrow(); + + expect(fs.readdirSync(staged('React'))).toEqual(['RCTConversions.h']); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining(`Failed to create link for`), + ); + }); + + it('replaces a staged copy that does not share the source inode', () => { + const source = write('React/Base/RCTUtils.h', '// original\n'); + fs.mkdirSync(staged('React'), {recursive: true}); + fs.writeFileSync(staged('React/RCTUtils.h'), '// a copy, not a link\n'); + + stage('React/Base', 'React'); + + expect(fs.statSync(staged('React/RCTUtils.h')).ino).toBe( + fs.statSync(source).ino, + ); + }); + + it('replaces a staged symlink instead of following it', () => { + const source = write('React/Base/RCTUtils.h', '// original\n'); + const elsewhere = write('elsewhere/RCTUtils.h', '// elsewhere\n'); + fs.mkdirSync(staged('React'), {recursive: true}); + fs.symlinkSync(elsewhere, staged('React/RCTUtils.h')); + + stage('React/Base', 'React'); + + expect(fs.lstatSync(staged('React/RCTUtils.h')).isSymbolicLink()).toBe( + false, + ); + expect(fs.statSync(staged('React/RCTUtils.h')).ino).toBe( + fs.statSync(source).ino, + ); + expect(fs.readFileSync(elsewhere, 'utf8')).toBe('// elsewhere\n'); + }); + + it('stages only header files', () => { + write('React/Base/RCTUtils.h', ''); + write('React/Base/RCTConversions.hpp', ''); + write('React/Base/RCTUtils.m', ''); + write('React/Base/RCTUtils.cpp', ''); + write('React/Base/BUCK.txt', ''); + + stage('React/Base', 'React'); + + expect(fs.readdirSync(staged('React')).sort()).toEqual([ + 'RCTConversions.hpp', + 'RCTUtils.h', + ]); + }); + + it('skips files of a folder without headers but still recurses into it', () => { + write('React/README.md', ''); + write('React/Base/RCTUtils.h', ''); + + stage('React', 'React'); + + expect(fs.readdirSync(staged('React'))).toEqual(['RCTUtils.h']); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith(linkedMessage('React/Base', 'React')); + }); + + it('does not log a pass that staged nothing', () => { + write('React/Base/README.md', ''); + + stage('React/Base', 'React'); + + expect(log).not.toHaveBeenCalled(); + }); + + it('flattens nested subfolders under the same include path', () => { + write('React/Base/RCTUtils.h', ''); + write('React/Base/Surface/RCTSurface.h', ''); + + stage('React/Base', 'React'); + + expect(fs.readdirSync(staged('React')).sort()).toEqual([ + 'RCTSurface.h', + 'RCTUtils.h', + ]); + }); + + it.each(['__tests__', 'tests', 'platform'])( + 'does not stage headers from a %s subfolder', + folder => { + write('React/Base/RCTUtils.h', ''); + write(`React/Base/${folder}/RCTUtilsTests.h`, ''); + + stage('React/Base', 'React'); + + expect(fs.readdirSync(staged('React'))).toEqual(['RCTUtils.h']); + }, + ); +}); diff --git a/packages/react-native/scripts/ios-prebuild/setup.js b/packages/react-native/scripts/ios-prebuild/setup.js index afd019566408..37269d05e2ba 100644 --- a/packages/react-native/scripts/ios-prebuild/setup.js +++ b/packages/react-native/scripts/ios-prebuild/setup.js @@ -38,69 +38,7 @@ async function setup( const linksFolder = path.resolve(buildFolder, 'headers'); createFolderIfNotExists(linksFolder); - /** - * Creates a hard link from one path to another. For each subfolder - * in the source path, it creates a link in the target path with an - * underscore prefix. - */ - const link = (fromPath /*:string*/, includePath /*:?string*/) => { - const source = path.resolve(root, fromPath); - const target = path.resolve(linksFolder, includePath ?? fromPath); - - createFolderIfNotExists(target); - - let linkedFiles = 0; - - // get subfolders in source - make sure we only copy folders with header files - const entries = fs.readdirSync(source, {withFileTypes: true}); - if ( - entries.some( - dirent => - dirent.isFile() && - (String(dirent.name).endsWith('.h') || - String(dirent.name).endsWith('.hpp')), - ) - ) { - // Create link for all header files (*.h, *.hpp) in the source directory - entries.forEach(entry => { - const entryName = String(entry.name); - if (entry.isFile() && /\.(h|hpp)$/.test(entryName)) { - const sourceFile = path.join(source, entryName); - const targetFile = path.join(target, entryName); - // Skip if the file already exists - if (fs.existsSync(targetFile)) { - return; - } - try { - fs.linkSync(sourceFile, targetFile); - linkedFiles++; - } catch (e) { - console.error( - `Failed to create link for ${sourceFile} to ${targetFile}: ${e}`, - ); - } - } - }); - } - - if (linkedFiles > 0) { - prebuildLog( - `Linked ${path.relative(root, source)} → ${path.relative(root, target)}`, - ); - } - - const subfolders = entries - .filter(dirent => dirent.isDirectory()) - .filter(dirent => dirent.name !== '__tests__') - .filter(dirent => dirent.name !== 'tests') - .filter(dirent => dirent.name !== 'platform') - .map(dirent => dirent.name); - - // Create links for subfolders - subfolders.forEach(folder => { - link(path.join(fromPath, String(folder)), includePath); - }); - }; + const link = createHeaderLinker(root, linksFolder, prebuildLog); // HERMES ARTIFACTS await prepareHermesArtifactsAsync(currentVersion, buildType); @@ -199,6 +137,107 @@ async function setup( link('.build/codegen/build/generated/ios', 'ReactCodegen'); } +function lstatSyncIfExists(filePath /*: string */) /*: ?fs.Stats */ { + try { + return fs.lstatSync(filePath); + } catch (e) { + if (e.code === 'ENOENT') { + return null; + } + throw e; + } +} + +/** + * Builds the staging function for one prebuild pass: it hard links the header + * files of a source folder into the prebuild's flat include tree, recursing + * into subfolders. A folder without any header file directly in it contributes + * nothing but is still traversed. + */ +function createHeaderLinker( + root /*: string */, + linksFolder /*: string */, + log /*: (message: string) => void */, +) /*: (fromPath: string, includePath?: ?string) => void */ { + // Several call sites flatten distinct source folders onto one target folder, + // and a few of those collide on a basename. The first source staged in a pass + // owns the target; the compiler has been seeing that header all along. + const claimed /*: Set */ = new Set(); + + const linkFolder = ( + fromPath /*: string */, + includePath /*: ?string */, + ) /*: void */ => { + const source = path.resolve(root, fromPath); + const target = path.resolve(linksFolder, includePath ?? fromPath); + + createFolderIfNotExists(target); + + let linkedFiles = 0; + + const entries = fs.readdirSync(source, {withFileTypes: true}); + if ( + entries.some( + dirent => dirent.isFile() && /\.(h|hpp)$/.test(String(dirent.name)), + ) + ) { + entries.forEach(entry => { + const entryName = String(entry.name); + if (entry.isFile() && /\.(h|hpp)$/.test(entryName)) { + const sourceFile = path.join(source, entryName); + const targetFile = path.join(target, entryName); + if (claimed.has(targetFile)) { + return; + } + try { + // `git checkout` replaces a header by renaming a new file over it, + // so the source gets a new inode and a link staged earlier keeps + // serving the old contents. Anything that is not the source inode + // is stale. + const staged = lstatSyncIfExists(targetFile); + if (staged != null) { + const sourceStat = fs.statSync(sourceFile); + if ( + staged.dev === sourceStat.dev && + staged.ino === sourceStat.ino + ) { + claimed.add(targetFile); + return; + } + fs.unlinkSync(targetFile); + } + fs.linkSync(sourceFile, targetFile); + claimed.add(targetFile); + linkedFiles++; + } catch (e) { + console.error( + `Failed to create link for ${sourceFile} to ${targetFile}: ${e}`, + ); + } + } + }); + } + + if (linkedFiles > 0) { + log( + `Linked ${path.relative(root, source)} → ${path.relative(root, target)}`, + ); + } + + entries + .filter(dirent => dirent.isDirectory()) + .filter(dirent => dirent.name !== '__tests__') + .filter(dirent => dirent.name !== 'tests') + .filter(dirent => dirent.name !== 'platform') + .forEach(dirent => { + linkFolder(path.join(fromPath, String(dirent.name)), includePath); + }); + }; + + return linkFolder; +} + module.exports = { + createHeaderLinker, setup, };