From 276f9f9fc468d0d5987ac729763bc73a99d904ae Mon Sep 17 00:00:00 2001 From: Revopush Date: Fri, 9 Oct 2026 00:30:37 +0300 Subject: [PATCH 1/2] Skip -base-bytecode when Hermes bytecode versions differ hermesc aborts with "Wrong bytecode version. Expected N but got M" when the base release bundle was compiled by a different Hermes bytecode version (e.g. v96 base vs v98 compiler after a React Native upgrade). Read the version from the base bundle's HBC header and from `hermesc -version`, and drop -base-bytecode with a warning when they differ, cannot be detected, or the base is not Hermes bytecode. Also stop the hermes and compose-source-maps close handlers after rejecting, reject on hermes spawn errors, and time out the version probe. Co-Authored-By: Claude Opus 5.5 --- script/react-native-utils.ts | 77 +++++++++++++++++++++++++++++++-- test/hermes-bytecode.ts | 82 ++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 test/hermes-bytecode.ts diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index ceeb652..102520b 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -71,6 +71,67 @@ export async function takeHermesBaseBytecode( return baseReleaseBundle; } +// Hermes BytecodeFileHeader: uint64 magic, uint32 version (little-endian), stable since Hermes v0.1 +const HERMES_BYTECODE_MAGIC = BigInt("0x1F1903C103BC1FC6"); + +export function readHermesBytecodeVersion(bundlePath: string): number | null { + const header = Buffer.alloc(12); + const fd = fs.openSync(bundlePath, "r"); + let bytesRead: number; + try { + bytesRead = fs.readSync(fd, header, 0, header.length, 0); + } finally { + fs.closeSync(fd); + } + + if (bytesRead < header.length || header.readBigUInt64LE(0) !== HERMES_BYTECODE_MAGIC) { + return null; + } + return header.readUInt32LE(8); +} + +export function parseHermesCompilerBytecodeVersion(versionOutput: string): number | null { + const match = /HBC bytecode version:\s*(\d+)/.exec(versionOutput); + return match ? parseInt(match[1], 10) : null; +} + +function getHermesCompilerBytecodeVersion(hermesCommand: string): number | null { + const result = childProcess.spawnSync(hermesCommand, ["-version"], { encoding: "utf8", timeout: 30000 }); + if (result.error) { + return null; + } + return parseHermesCompilerBytecodeVersion(`${result.stdout}\n${result.stderr}`); +} + +// hermesc -base-bytecode aborts on a base of another bytecode version; the base is only a diff optimization +export function resolveHermesBaseBytecode(baseBytecode: string, compilerBytecodeVersion: number | null): string | null { + const baseBytecodeVersion = readHermesBytecodeVersion(baseBytecode); + if (baseBytecodeVersion === null) { + log(chalk.yellow("\nBase release bundle is not Hermes bytecode, skipping -base-bytecode.\n")); + return null; + } + + if (compilerBytecodeVersion === null) { + log(chalk.yellow("\nCould not detect the Hermes compiler bytecode version, skipping -base-bytecode.\n")); + return null; + } + + if (baseBytecodeVersion !== compilerBytecodeVersion) { + log( + chalk.yellow( + `\nWarning: the base release bundle was compiled to Hermes bytecode v${baseBytecodeVersion}, ` + + `but the local Hermes compiler produces v${compilerBytecodeVersion}. Skipping -base-bytecode.\n` + + `The Hermes runtime only loads bytecode of its own version, so binaries built with ` + + `bytecode v${baseBytecodeVersion} will not be able to run this update. ` + + `If React Native / Hermes was upgraded, ship a new binary and target this release at its version.\n` + ) + ); + return null; + } + + return baseBytecode; +} + export async function runHermesEmitBinaryCommand( command: cli.IReleaseReactCommand, bundleName: string, @@ -102,16 +163,21 @@ export async function runHermesEmitBinaryCommand( hermesArgs.push("-output-source-map"); } - if (baseBytecode) { - hermesArgs.push("-base-bytecode", baseBytecode); + const hermesCommand = await getHermesCommand(gradleFile); + + const resolvedBaseBytecode = + baseBytecode && resolveHermesBaseBytecode(baseBytecode, getHermesCompilerBytecodeVersion(hermesCommand)); + if (resolvedBaseBytecode) { + hermesArgs.push("-base-bytecode", resolvedBaseBytecode); } console.log(chalk.cyan("Converting JS bundle to byte code via Hermes, running command:\n")); - const hermesCommand = await getHermesCommand(gradleFile); const hermesProcess = childProcess.spawn(hermesCommand, hermesArgs); console.log(`${hermesCommand} ${hermesArgs.join(" ")}`); return new Promise((resolve, reject) => { + hermesProcess.on("error", reject); + hermesProcess.stdout.on("data", (data: Buffer) => { console.log(data.toString().trim()); }); @@ -123,6 +189,7 @@ export async function runHermesEmitBinaryCommand( hermesProcess.on("close", (exitCode: number, signal: string) => { if (exitCode !== 0) { reject(new Error(`"hermes" command failed (exitCode=${exitCode}, signal=${signal}).`)); + return; } // Copy HBC bundle to overwrite JS bundle const source = path.join(outputFolder, bundleName + ".hbc"); @@ -131,11 +198,13 @@ export async function runHermesEmitBinaryCommand( if (err) { console.error(err); reject(new Error(`Copying file ${source} to ${destination} failed. "hermes" previously exited with code ${exitCode}.`)); + return; } fs.unlink(source, (err) => { if (err) { console.error(err); reject(err); + return; } resolve(null as void); }); @@ -202,6 +271,7 @@ export async function runHermesEmitBinaryCommand( composeSourceMapsProcess.on("close", (exitCode: number, signal: string) => { if (exitCode !== 0) { reject(new Error(`"compose-source-maps" command failed (exitCode=${exitCode}, signal=${signal}).`)); + return; } // Delete the HBC sourceMap, otherwise it will be included in 'code-push' bundle as well @@ -209,6 +279,7 @@ export async function runHermesEmitBinaryCommand( if (err) { console.error(err); reject(err); + return; } resolve(null); diff --git a/test/hermes-bytecode.ts b/test/hermes-bytecode.ts new file mode 100644 index 0000000..7c05592 --- /dev/null +++ b/test/hermes-bytecode.ts @@ -0,0 +1,82 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as sinon from "sinon"; +import * as cmdexec from "../script/command-executor"; +import { + parseHermesCompilerBytecodeVersion, + readHermesBytecodeVersion, + resolveHermesBaseBytecode, +} from "../script/react-native-utils"; + +function createHermesBundle(filePath: string, version: number): string { + const header = Buffer.alloc(128); + header.writeBigUInt64LE(BigInt("0x1F1903C103BC1FC6"), 0); + header.writeUInt32LE(version, 8); + fs.writeFileSync(filePath, header); + return filePath; +} + +describe("Hermes base bytecode", () => { + let testDirectory: string; + let sandbox: sinon.SinonSandbox; + let log: sinon.SinonStub; + + beforeEach(() => { + testDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "revopush-hermes-")); + sandbox = sinon.createSandbox(); + log = sandbox.stub(cmdexec, "log"); + }); + + afterEach(() => { + sandbox.restore(); + fs.rmSync(testDirectory, { recursive: true, force: true }); + }); + + it("reads the bytecode version from a Hermes bundle header", () => { + const bundle = createHermesBundle(path.join(testDirectory, "index.android.bundle"), 96); + assert.strictEqual(readHermesBytecodeVersion(bundle), 96); + }); + + it("returns null for a plain JS bundle", () => { + const bundle = path.join(testDirectory, "index.android.bundle"); + fs.writeFileSync(bundle, "var __BUNDLE_START_TIME__=this.nativePerformanceNow?nativePerformanceNow():Date.now();"); + assert.strictEqual(readHermesBytecodeVersion(bundle), null); + }); + + it("returns null for a file shorter than the header", () => { + const bundle = path.join(testDirectory, "index.android.bundle"); + fs.writeFileSync(bundle, Buffer.from([0xc6, 0x1f])); + assert.strictEqual(readHermesBytecodeVersion(bundle), null); + }); + + it("parses the bytecode version from hermesc -version output", () => { + const output = "Hermes JavaScript compiler.\n Hermes release version: 250829098.0.1\n HBC bytecode version: 98\n\n Features:\n"; + assert.strictEqual(parseHermesCompilerBytecodeVersion(output), 98); + assert.strictEqual(parseHermesCompilerBytecodeVersion("unknown option"), null); + }); + + it("keeps the base bytecode when versions match", () => { + const bundle = createHermesBundle(path.join(testDirectory, "index.android.bundle"), 96); + assert.strictEqual(resolveHermesBaseBytecode(bundle, 96), bundle); + sinon.assert.notCalled(log); + }); + + it("skips the base bytecode and warns when versions differ", () => { + const bundle = createHermesBundle(path.join(testDirectory, "index.android.bundle"), 96); + assert.strictEqual(resolveHermesBaseBytecode(bundle, 98), null); + sinon.assert.calledWithMatch(log, sinon.match(/v96.*v98/s)); + }); + + it("skips the base bytecode when the compiler version is unknown", () => { + const bundle = createHermesBundle(path.join(testDirectory, "index.android.bundle"), 96); + assert.strictEqual(resolveHermesBaseBytecode(bundle, null), null); + }); + + it("skips the base bytecode when the base is not Hermes bytecode", () => { + const bundle = path.join(testDirectory, "index.android.bundle"); + fs.writeFileSync(bundle, "__d(function(){});"); + assert.strictEqual(resolveHermesBaseBytecode(bundle, 96), null); + }); +}); From 8ac9c0d63a5e50bbebf7a282b58d6b76b905b67a Mon Sep 17 00:00:00 2001 From: Revopush Date: Fri, 9 Oct 2026 01:02:27 +0300 Subject: [PATCH 2/2] Detect hermesc bytecode version from emitted bytecode Compile an empty input to stdout with `hermesc -emit-binary -` and read the version from the emitted header instead of parsing `hermesc -version` text, so the base bundle and the compiler are checked the same way. Shorten the version mismatch warning. Co-Authored-By: Claude Opus 5.5 --- script/react-native-utils.ts | 39 ++++++----------------- test/hermes-bytecode.ts | 61 ++++++++++++++++-------------------- 2 files changed, 37 insertions(+), 63 deletions(-) diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index 102520b..6a963c1 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -74,38 +74,22 @@ export async function takeHermesBaseBytecode( // Hermes BytecodeFileHeader: uint64 magic, uint32 version (little-endian), stable since Hermes v0.1 const HERMES_BYTECODE_MAGIC = BigInt("0x1F1903C103BC1FC6"); -export function readHermesBytecodeVersion(bundlePath: string): number | null { - const header = Buffer.alloc(12); - const fd = fs.openSync(bundlePath, "r"); - let bytesRead: number; - try { - bytesRead = fs.readSync(fd, header, 0, header.length, 0); - } finally { - fs.closeSync(fd); - } - - if (bytesRead < header.length || header.readBigUInt64LE(0) !== HERMES_BYTECODE_MAGIC) { +function getHermesBytecodeVersion(bytecode: Buffer): number | null { + if (bytecode.length < 12 || bytecode.readBigUInt64LE(0) !== HERMES_BYTECODE_MAGIC) { return null; } - return header.readUInt32LE(8); + return bytecode.readUInt32LE(8); } -export function parseHermesCompilerBytecodeVersion(versionOutput: string): number | null { - const match = /HBC bytecode version:\s*(\d+)/.exec(versionOutput); - return match ? parseInt(match[1], 10) : null; -} - -function getHermesCompilerBytecodeVersion(hermesCommand: string): number | null { - const result = childProcess.spawnSync(hermesCommand, ["-version"], { encoding: "utf8", timeout: 30000 }); - if (result.error) { - return null; - } - return parseHermesCompilerBytecodeVersion(`${result.stdout}\n${result.stderr}`); +// Compile an empty input to stdout and read the version from the emitted header +export function getHermesCompilerBytecodeVersion(hermesCommand: string): number | null { + const result = childProcess.spawnSync(hermesCommand, ["-emit-binary", "-"], { input: "", timeout: 30000 }); + return result.status === 0 ? getHermesBytecodeVersion(result.stdout) : null; } // hermesc -base-bytecode aborts on a base of another bytecode version; the base is only a diff optimization export function resolveHermesBaseBytecode(baseBytecode: string, compilerBytecodeVersion: number | null): string | null { - const baseBytecodeVersion = readHermesBytecodeVersion(baseBytecode); + const baseBytecodeVersion = getHermesBytecodeVersion(fs.readFileSync(baseBytecode)); if (baseBytecodeVersion === null) { log(chalk.yellow("\nBase release bundle is not Hermes bytecode, skipping -base-bytecode.\n")); return null; @@ -119,11 +103,8 @@ export function resolveHermesBaseBytecode(baseBytecode: string, compilerBytecode if (baseBytecodeVersion !== compilerBytecodeVersion) { log( chalk.yellow( - `\nWarning: the base release bundle was compiled to Hermes bytecode v${baseBytecodeVersion}, ` + - `but the local Hermes compiler produces v${compilerBytecodeVersion}. Skipping -base-bytecode.\n` + - `The Hermes runtime only loads bytecode of its own version, so binaries built with ` + - `bytecode v${baseBytecodeVersion} will not be able to run this update. ` + - `If React Native / Hermes was upgraded, ship a new binary and target this release at its version.\n` + `\nWarning: base release is Hermes bytecode v${baseBytecodeVersion}, local compiler produces v${compilerBytecodeVersion}. ` + + `Skipping -base-bytecode. Binaries on v${baseBytecodeVersion} can't run this update.\n` ) ); return null; diff --git a/test/hermes-bytecode.ts b/test/hermes-bytecode.ts index 7c05592..6d89758 100644 --- a/test/hermes-bytecode.ts +++ b/test/hermes-bytecode.ts @@ -4,18 +4,13 @@ import * as os from "os"; import * as path from "path"; import * as sinon from "sinon"; import * as cmdexec from "../script/command-executor"; -import { - parseHermesCompilerBytecodeVersion, - readHermesBytecodeVersion, - resolveHermesBaseBytecode, -} from "../script/react-native-utils"; +import { getHermesCompilerBytecodeVersion, resolveHermesBaseBytecode } from "../script/react-native-utils"; -function createHermesBundle(filePath: string, version: number): string { +function hermesHeader(version: number): Buffer { const header = Buffer.alloc(128); header.writeBigUInt64LE(BigInt("0x1F1903C103BC1FC6"), 0); header.writeUInt32LE(version, 8); - fs.writeFileSync(filePath, header); - return filePath; + return header; } describe("Hermes base bytecode", () => { @@ -23,6 +18,18 @@ describe("Hermes base bytecode", () => { let sandbox: sinon.SinonSandbox; let log: sinon.SinonStub; + function createBundle(contents: Buffer | string): string { + const bundle = path.join(testDirectory, "index.android.bundle"); + fs.writeFileSync(bundle, contents); + return bundle; + } + + function createCompiler(script: string): string { + const compiler = path.join(testDirectory, "hermesc"); + fs.writeFileSync(compiler, `#!/usr/bin/env node\n${script}\n`, { mode: 0o755 }); + return compiler; + } + beforeEach(() => { testDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "revopush-hermes-")); sandbox = sinon.createSandbox(); @@ -34,49 +41,35 @@ describe("Hermes base bytecode", () => { fs.rmSync(testDirectory, { recursive: true, force: true }); }); - it("reads the bytecode version from a Hermes bundle header", () => { - const bundle = createHermesBundle(path.join(testDirectory, "index.android.bundle"), 96); - assert.strictEqual(readHermesBytecodeVersion(bundle), 96); - }); - - it("returns null for a plain JS bundle", () => { - const bundle = path.join(testDirectory, "index.android.bundle"); - fs.writeFileSync(bundle, "var __BUNDLE_START_TIME__=this.nativePerformanceNow?nativePerformanceNow():Date.now();"); - assert.strictEqual(readHermesBytecodeVersion(bundle), null); + it("reads the compiler bytecode version from the bytecode it emits", () => { + const compiler = createCompiler(`process.stdout.write(Buffer.from("${hermesHeader(98).toString("hex")}", "hex"));`); + assert.strictEqual(getHermesCompilerBytecodeVersion(compiler), 98); }); - it("returns null for a file shorter than the header", () => { - const bundle = path.join(testDirectory, "index.android.bundle"); - fs.writeFileSync(bundle, Buffer.from([0xc6, 0x1f])); - assert.strictEqual(readHermesBytecodeVersion(bundle), null); - }); - - it("parses the bytecode version from hermesc -version output", () => { - const output = "Hermes JavaScript compiler.\n Hermes release version: 250829098.0.1\n HBC bytecode version: 98\n\n Features:\n"; - assert.strictEqual(parseHermesCompilerBytecodeVersion(output), 98); - assert.strictEqual(parseHermesCompilerBytecodeVersion("unknown option"), null); + it("returns null when the compiler fails or emits something other than Hermes bytecode", () => { + assert.strictEqual(getHermesCompilerBytecodeVersion(createCompiler("process.exit(1);")), null); + assert.strictEqual(getHermesCompilerBytecodeVersion(createCompiler(`process.stdout.write("var a;");`)), null); + assert.strictEqual(getHermesCompilerBytecodeVersion(path.join(testDirectory, "missing-hermesc")), null); }); it("keeps the base bytecode when versions match", () => { - const bundle = createHermesBundle(path.join(testDirectory, "index.android.bundle"), 96); + const bundle = createBundle(hermesHeader(96)); assert.strictEqual(resolveHermesBaseBytecode(bundle, 96), bundle); sinon.assert.notCalled(log); }); it("skips the base bytecode and warns when versions differ", () => { - const bundle = createHermesBundle(path.join(testDirectory, "index.android.bundle"), 96); + const bundle = createBundle(hermesHeader(96)); assert.strictEqual(resolveHermesBaseBytecode(bundle, 98), null); sinon.assert.calledWithMatch(log, sinon.match(/v96.*v98/s)); }); it("skips the base bytecode when the compiler version is unknown", () => { - const bundle = createHermesBundle(path.join(testDirectory, "index.android.bundle"), 96); - assert.strictEqual(resolveHermesBaseBytecode(bundle, null), null); + assert.strictEqual(resolveHermesBaseBytecode(createBundle(hermesHeader(96)), null), null); }); it("skips the base bytecode when the base is not Hermes bytecode", () => { - const bundle = path.join(testDirectory, "index.android.bundle"); - fs.writeFileSync(bundle, "__d(function(){});"); - assert.strictEqual(resolveHermesBaseBytecode(bundle, 96), null); + assert.strictEqual(resolveHermesBaseBytecode(createBundle("__d(function(){});"), 96), null); + assert.strictEqual(resolveHermesBaseBytecode(createBundle(Buffer.from([0xc6, 0x1f])), 96), null); }); });