Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sigill-bun-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Fall back to the bundled Bun runtime when the native prebuilt exits with SIGILL on older Linux CPUs.
22 changes: 16 additions & 6 deletions bin/hunk.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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() {
Expand Down Expand Up @@ -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()
Expand Down
162 changes: 162 additions & 0 deletions test/cli/entrypoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,67 @@ function git(cwd: string, ...args: string[]) {
}
}

function hostBinaryCandidate() {
const platformMap: Record<string, string> = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap: Record<string, string> = {
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"], {
Expand Down Expand Up @@ -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(),
Expand Down