Skip to content

Commit 26aad6f

Browse files
committed
Run the musl binary on musl hosts in npm wrapper
Yarn classic ignores the `libc` field, so on Alpine it installs both the -gnu and the -musl platform package. The npm wrapper always took the first package that resolved (-gnu), whose glibc binary cannot start on musl, and then exited 1 without printing anything. Every socket-patch command failed silently in yarn classic projects on Alpine and in node:*-alpine CI images. The wrapper now detects the host libc (Node's runtime report, then the musl loader probe scripts/install.sh uses) and tries the -musl package first on musl. If a binary cannot be spawned it tries the next installed candidate, and if none can run it prints the spawn error instead of exiting silently. Fixes #974 Assisted-by: Claude Code:claude-opus-5-5
1 parent cc5e5a5 commit 26aad6f

2 files changed

Lines changed: 209 additions & 25 deletions

File tree

‎npm/socket-patch/bin/socket-patch‎

Lines changed: 75 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env node
22
const { spawnSync } = require("child_process");
3+
const fs = require("fs");
34
const path = require("path");
45

56
const PLATFORMS = {
@@ -15,32 +16,82 @@ const PLATFORMS = {
1516
"android arm64": ["@socketsecurity/socket-patch-android-arm64"],
1617
};
1718

18-
const key = `${process.platform} ${process.arch}`;
19-
const candidates = PLATFORMS[key];
20-
if (!candidates) {
21-
console.error(`Unsupported platform: ${key}`);
22-
process.exit(1);
23-
}
24-
25-
const exe = process.platform === "win32" ? "socket-patch.exe" : "socket-patch";
26-
let binPath;
27-
for (const pkg of candidates) {
19+
// Installers that ignore the `libc` field (yarn classic) install both the
20+
// -gnu and the -musl package on Linux, so the order above alone would run
21+
// the glibc binary on musl hosts (Alpine), where it cannot start.
22+
function detectLibc({
23+
platform = process.platform,
24+
getReport = () => process.report.getReport(),
25+
listDir = (dir) => fs.readdirSync(dir),
26+
} = {}) {
27+
if (platform !== "linux") return null;
28+
try {
29+
// Node reports the runtime glibc version only when it runs on glibc.
30+
if (getReport().header.glibcVersionRuntime) return "glibc";
31+
} catch {}
32+
// Same probe as scripts/install.sh: musl's dynamic loader.
2833
try {
29-
const pkgDir = path.dirname(require.resolve(`${pkg}/package.json`));
30-
binPath = path.join(pkgDir, exe);
31-
break;
34+
if (listDir("/lib").some((name) => name.startsWith("ld-musl-"))) return "musl";
3235
} catch {}
36+
return null;
3337
}
34-
if (!binPath) {
35-
// Fallback: try local bin directory (for development or bundled installs)
36-
const localBin = process.platform === "win32"
37-
? `socket-patch-${key.replace(" ", "-")}.exe`
38-
: `socket-patch-${key.replace(" ", "-")}`;
39-
binPath = path.join(__dirname, localBin);
38+
39+
function orderCandidates(candidates, libc) {
40+
if (libc !== "musl") return candidates;
41+
const musl = candidates.filter((pkg) => pkg.endsWith("-musl"));
42+
return [...musl, ...candidates.filter((pkg) => !pkg.endsWith("-musl"))];
4043
}
4144

42-
const result = spawnSync(binPath, process.argv.slice(2), {
43-
stdio: "inherit",
44-
env: process.env,
45-
});
46-
process.exit(result.status ?? 1);
45+
// Spawn errors that mean "this binary cannot run here", so the next
46+
// installed candidate is worth trying.
47+
const UNRUNNABLE = new Set(["ENOENT", "EACCES", "ENOEXEC"]);
48+
49+
function runFirstUsable(binPaths, args, {
50+
spawn = (bin, argv) => spawnSync(bin, argv, { stdio: "inherit", env: process.env }),
51+
log = (msg) => console.error(msg),
52+
} = {}) {
53+
let lastError;
54+
for (const bin of binPaths) {
55+
const result = spawn(bin, args);
56+
if (!result.error) return result.status ?? 1;
57+
lastError = { bin, error: result.error };
58+
if (!UNRUNNABLE.has(result.error.code)) break;
59+
}
60+
if (lastError) {
61+
log(`socket-patch: failed to run ${lastError.bin}: ${lastError.error.message}`);
62+
}
63+
return 1;
64+
}
65+
66+
function main() {
67+
const key = `${process.platform} ${process.arch}`;
68+
const candidates = PLATFORMS[key];
69+
if (!candidates) {
70+
console.error(`Unsupported platform: ${key}`);
71+
process.exit(1);
72+
}
73+
74+
const exe = process.platform === "win32" ? "socket-patch.exe" : "socket-patch";
75+
const binPaths = [];
76+
for (const pkg of orderCandidates(candidates, detectLibc())) {
77+
try {
78+
const pkgDir = path.dirname(require.resolve(`${pkg}/package.json`));
79+
binPaths.push(path.join(pkgDir, exe));
80+
} catch {}
81+
}
82+
if (binPaths.length === 0) {
83+
// Fallback: try local bin directory (for development or bundled installs)
84+
const localBin = process.platform === "win32"
85+
? `socket-patch-${key.replace(" ", "-")}.exe`
86+
: `socket-patch-${key.replace(" ", "-")}`;
87+
binPaths.push(path.join(__dirname, localBin));
88+
}
89+
90+
process.exit(runFirstUsable(binPaths, process.argv.slice(2)));
91+
}
92+
93+
if (require.main === module) {
94+
main();
95+
} else {
96+
module.exports = { PLATFORMS, detectLibc, orderCandidates, runFirstUsable };
97+
}

‎npm/socket-patch/bin/socket-patch.test.mjs‎

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, it } from "node:test";
22
import assert from "node:assert/strict";
3-
import { readFileSync } from "node:fs";
3+
import { spawnSync } from "node:child_process";
4+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5+
import { createRequire } from "node:module";
6+
import { tmpdir } from "node:os";
47
import { fileURLToPath } from "node:url";
58
import { dirname, join } from "node:path";
69

@@ -71,3 +74,133 @@ describe("npm platform dispatch", () => {
7174
}
7275
});
7376
});
77+
78+
// Regression tests for #974: installers that ignore `libc` (yarn classic)
79+
// install both the -gnu and -musl packages, so the wrapper must pick the
80+
// one that can run on this host and must never exit silently.
81+
82+
const wrapperPath = join(__dirname, "socket-patch");
83+
const wrapper = createRequire(import.meta.url)(wrapperPath);
84+
85+
describe("npm wrapper libc selection (#974)", () => {
86+
it("does not run the CLI when required as a module", () => {
87+
assert.equal(typeof wrapper.orderCandidates, "function");
88+
assert.equal(typeof wrapper.detectLibc, "function");
89+
assert.equal(typeof wrapper.runFirstUsable, "function");
90+
});
91+
92+
it("detects musl when the Node runtime reports no glibc", () => {
93+
const libc = wrapper.detectLibc({
94+
platform: "linux",
95+
getReport: () => ({ header: {} }),
96+
listDir: () => ["ld-musl-x86_64.so.1", "libc.musl-x86_64.so.1"],
97+
});
98+
assert.equal(libc, "musl");
99+
});
100+
101+
it("detects glibc from the runtime report even if a musl loader exists", () => {
102+
const libc = wrapper.detectLibc({
103+
platform: "linux",
104+
getReport: () => ({ header: { glibcVersionRuntime: "2.36" } }),
105+
listDir: () => ["ld-musl-x86_64.so.1"],
106+
});
107+
assert.equal(libc, "glibc");
108+
});
109+
110+
it("returns null off Linux", () => {
111+
assert.equal(
112+
wrapper.detectLibc({
113+
platform: "darwin",
114+
getReport: () => ({ header: {} }),
115+
listDir: () => [],
116+
}),
117+
null,
118+
);
119+
});
120+
121+
for (const key of ["linux x64", "linux arm64", "linux arm", "linux ia32"]) {
122+
it(`prefers the musl package on a musl host (${key})`, () => {
123+
const ordered = wrapper.orderCandidates(PLATFORMS[key], "musl");
124+
assert.match(ordered[0], /-musl$/);
125+
assert.match(ordered[1], /-gnu$/);
126+
});
127+
128+
it(`keeps gnu first on a glibc host (${key})`, () => {
129+
assert.deepEqual(wrapper.orderCandidates(PLATFORMS[key], "glibc"), PLATFORMS[key]);
130+
});
131+
}
132+
133+
it("falls back to the next binary when the first cannot be spawned", () => {
134+
const calls = [];
135+
const enoent = Object.assign(new Error("spawnSync gnu ENOENT"), { code: "ENOENT" });
136+
const status = wrapper.runFirstUsable(["/gnu", "/musl"], ["--version"], {
137+
spawn: (bin) => {
138+
calls.push(bin);
139+
return bin === "/gnu" ? { status: null, error: enoent } : { status: 0 };
140+
},
141+
log: () => {},
142+
});
143+
assert.equal(status, 0);
144+
assert.deepEqual(calls, ["/gnu", "/musl"]);
145+
});
146+
147+
it("prints why it failed instead of exiting silently", () => {
148+
const logs = [];
149+
const enoent = Object.assign(new Error("spawnSync /gnu ENOENT"), { code: "ENOENT" });
150+
const status = wrapper.runFirstUsable(["/gnu"], [], {
151+
spawn: () => ({ status: null, error: enoent }),
152+
log: (msg) => logs.push(msg),
153+
});
154+
assert.equal(status, 1);
155+
assert.equal(logs.length, 1);
156+
assert.match(logs[0], /\/gnu/);
157+
assert.match(logs[0], /ENOENT/);
158+
});
159+
160+
it("propagates the exit status of a binary that ran", () => {
161+
const status = wrapper.runFirstUsable(["/gnu", "/musl"], [], {
162+
spawn: () => ({ status: 3 }),
163+
log: () => {},
164+
});
165+
assert.equal(status, 3);
166+
});
167+
168+
// End to end: a node_modules tree like yarn classic leaves on Alpine,
169+
// with both platform packages installed and the gnu binary unable to
170+
// start. The wrapper must run the musl binary instead of exiting 1
171+
// with no output.
172+
it(
173+
"runs the musl binary when the gnu one cannot start (yarn classic layout)",
174+
{ skip: process.platform !== "linux" || !PLATFORMS[`linux ${process.arch}`] },
175+
() => {
176+
const root = mkdtempSync(join(tmpdir(), "sp-wrapper-"));
177+
try {
178+
const scope = join(root, "node_modules", "@socketsecurity");
179+
const binDir = join(scope, "socket-patch", "bin");
180+
mkdirSync(binDir, { recursive: true });
181+
writeFileSync(join(binDir, "socket-patch"), readFileSync(wrapperPath));
182+
for (const pkg of PLATFORMS[`linux ${process.arch}`]) {
183+
const dir = join(root, "node_modules", pkg);
184+
mkdirSync(dir, { recursive: true });
185+
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: pkg, version: "0.0.0" }));
186+
const exe = join(dir, "socket-patch");
187+
if (pkg.endsWith("-gnu")) {
188+
// A binary whose ELF interpreter is missing fails exactly like
189+
// a glibc binary on musl: spawn reports ENOENT.
190+
writeFileSync(exe, "#!/nonexistent/ld-linux.so.2\n");
191+
} else {
192+
writeFileSync(exe, "#!/bin/sh\necho \"musl-binary $*\"\n");
193+
}
194+
chmodSync(exe, 0o755);
195+
}
196+
const result = spawnSync(process.execPath, [join(binDir, "socket-patch"), "--version"], {
197+
encoding: "utf8",
198+
});
199+
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
200+
assert.equal(result.stdout.trim(), "musl-binary --version");
201+
} finally {
202+
rmSync(root, { recursive: true, force: true });
203+
}
204+
},
205+
);
206+
});

0 commit comments

Comments
 (0)