From 2e46b143fcc11b88571c9e8a9c456bde1cbca01a Mon Sep 17 00:00:00 2001 From: Haoqian Li Date: Fri, 26 Jun 2026 09:33:23 +0800 Subject: [PATCH] fix(npm): fall back after prebuilt SIGILL --- .changeset/sigill-bun-fallback.md | 5 + bin/hunk.cjs | 22 ++-- test/cli/entrypoint.test.ts | 162 ++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 .changeset/sigill-bun-fallback.md diff --git a/.changeset/sigill-bun-fallback.md b/.changeset/sigill-bun-fallback.md new file mode 100644 index 00000000..cabd5b3e --- /dev/null +++ b/.changeset/sigill-bun-fallback.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Fall back to the bundled Bun runtime when the native prebuilt exits with SIGILL on older Linux CPUs. diff --git a/bin/hunk.cjs b/bin/hunk.cjs index 19e94359..7d8a81c6 100755 --- a/bin/hunk.cjs +++ b/bin/hunk.cjs @@ -25,7 +25,7 @@ function ensureExecutable(target) { } } -function run(target, args) { +function run(target, args, options = {}) { ensureExecutable(target); const result = childProcess.spawnSync(target, args, { stdio: "inherit", @@ -34,10 +34,20 @@ function run(target, args) { if (result.error) { console.error(result.error.message); - process.exit(1); + return { status: 1 }; + } + + if (options.fallbackOnSigill && result.signal === "SIGILL") { + return null; } - process.exit(typeof result.status === "number" ? result.status : 1); + return { status: typeof result.status === "number" ? result.status : 1 }; +} + +function exitIfHandled(result) { + if (result) { + process.exit(result.status); + } } function hostCandidates() { @@ -116,19 +126,19 @@ if (forwardedArgs.length === 2 && forwardedArgs[0] === "skill" && forwardedArgs[ const overrideBinary = process.env.HUNK_BIN_PATH; if (overrideBinary) { - run(overrideBinary, forwardedArgs); + exitIfHandled(run(overrideBinary, forwardedArgs)); } const scriptDir = path.dirname(fs.realpathSync(__filename)); const prebuiltBinary = findInstalledBinary(scriptDir); if (prebuiltBinary) { - run(prebuiltBinary, forwardedArgs); + exitIfHandled(run(prebuiltBinary, forwardedArgs, { fallbackOnSigill: true })); } const bunBinary = bundledBunRuntime(); if (bunBinary) { const entrypoint = path.join(__dirname, "..", "dist", "npm", "main.js"); - run(bunBinary, [entrypoint, ...forwardedArgs]); + exitIfHandled(run(bunBinary, [entrypoint, ...forwardedArgs])); } const printablePackages = hostCandidates() diff --git a/test/cli/entrypoint.test.ts b/test/cli/entrypoint.test.ts index d72e0c29..782fb248 100644 --- a/test/cli/entrypoint.test.ts +++ b/test/cli/entrypoint.test.ts @@ -18,6 +18,67 @@ function git(cwd: string, ...args: string[]) { } } +function hostBinaryCandidate() { + const platformMap: Record = { + darwin: "darwin", + linux: "linux", + win32: "windows", + }; + const archMap: Record = { + x64: "x64", + arm64: "arm64", + }; + + const platform = platformMap[process.platform] || process.platform; + const arch = archMap[process.arch] || process.arch; + const binary = platform === "windows" ? "hunk.exe" : "hunk"; + + if (platform === "darwin") { + if (arch === "arm64") return { packageName: "hunkdiff-darwin-arm64", binary }; + if (arch === "x64") return { packageName: "hunkdiff-darwin-x64", binary }; + } + + if (platform === "linux") { + if (arch === "arm64") return { packageName: "hunkdiff-linux-arm64", binary }; + if (arch === "x64") return { packageName: "hunkdiff-linux-x64", binary }; + } + + if (platform === "windows") { + if (arch === "x64") return { packageName: "hunkdiff-windows-x64", binary }; + } + + return null; +} + +function writeExecutable(path: string, contents: string) { + writeFileSync(path, contents, { mode: 0o755 }); +} + +function createWrapperFixture(tempDir: string, prebuiltScript: string, bunScript: string) { + const candidate = hostBinaryCandidate(); + if (!candidate) { + throw new Error(`Unsupported host for wrapper fixture: ${process.platform} ${process.arch}`); + } + + const tempBinDir = join(tempDir, "bin"); + const tempWrapperPath = join(tempBinDir, "hunk.cjs"); + mkdirSync(tempBinDir, { recursive: true }); + copyFileSync(join(process.cwd(), "bin", "hunk.cjs"), tempWrapperPath); + + const prebuiltBinDir = join(tempDir, "node_modules", candidate.packageName, "bin"); + mkdirSync(prebuiltBinDir, { recursive: true }); + writeExecutable(join(prebuiltBinDir, candidate.binary), prebuiltScript); + + const bunBinDir = join(tempDir, "node_modules", "bun", "bin"); + mkdirSync(bunBinDir, { recursive: true }); + writeExecutable(join(bunBinDir, "bun.exe"), bunScript); + + mkdirSync(join(tempDir, "dist", "npm"), { recursive: true }); + writeFileSync(join(tempDir, "dist", "npm", "main.js"), ""); + + return tempWrapperPath; +} + describe("CLI entrypoint contracts", () => { test("bare hunk prints standard help without terminal takeover sequences", () => { const proc = Bun.spawnSync(["bun", "run", "src/main.tsx"], { @@ -204,6 +265,107 @@ describe("CLI entrypoint contracts", () => { } }); + test("bin wrapper falls back to bundled Bun when the prebuilt binary exits with SIGILL", () => { + if (process.platform === "win32") { + return; + } + + const tempDir = mkdtempSync(join(tmpdir(), "hunk-wrapper-sigill-")); + + try { + const tempWrapperPath = createWrapperFixture( + tempDir, + "#!/bin/sh\nkill -ILL $$\n", + "#!/bin/sh\nshift\nprintf 'fallback:%s\\n' \"$*\"\n", + ); + + const proc = Bun.spawnSync(["node", tempWrapperPath, "--version"], { + cwd: tempDir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + }); + + const stdout = Buffer.from(proc.stdout).toString("utf8"); + const stderr = Buffer.from(proc.stderr).toString("utf8"); + + expect(proc.exitCode).toBe(0); + expect(stdout).toBe("fallback:--version\n"); + expect(stderr).toBe(""); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("bin wrapper preserves normal prebuilt exit status instead of falling back", () => { + if (process.platform === "win32") { + return; + } + + const tempDir = mkdtempSync(join(tmpdir(), "hunk-wrapper-prebuilt-exit-")); + + try { + const tempWrapperPath = createWrapperFixture( + tempDir, + "#!/bin/sh\nprintf 'native\\n'\nexit 7\n", + "#!/bin/sh\nprintf 'fallback\\n'\n", + ); + + const proc = Bun.spawnSync(["node", tempWrapperPath, "--version"], { + cwd: tempDir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + }); + + const stdout = Buffer.from(proc.stdout).toString("utf8"); + const stderr = Buffer.from(proc.stderr).toString("utf8"); + + expect(proc.exitCode).toBe(7); + expect(stdout).toBe("native\n"); + expect(stderr).toBe(""); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("bin wrapper does not fall back when the explicit override binary exits with SIGILL", () => { + if (process.platform === "win32") { + return; + } + + const tempDir = mkdtempSync(join(tmpdir(), "hunk-wrapper-override-sigill-")); + + try { + const tempWrapperPath = createWrapperFixture( + tempDir, + "#!/bin/sh\nprintf 'native\\n'\n", + "#!/bin/sh\nprintf 'fallback\\n'\n", + ); + const overrideBinary = join(tempDir, "override-hunk"); + writeExecutable(overrideBinary, "#!/bin/sh\nkill -ILL $$\n"); + + const proc = Bun.spawnSync(["node", tempWrapperPath, "--version"], { + cwd: tempDir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, HUNK_BIN_PATH: overrideBinary }, + }); + + const stdout = Buffer.from(proc.stdout).toString("utf8"); + const stderr = Buffer.from(proc.stderr).toString("utf8"); + + expect(proc.exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toBe(""); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + test("general pager mode falls back to plain text for non-diff stdin", () => { const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "pager"], { cwd: process.cwd(),