From 0d35bc4be58d23270cf1358d924077cb7850638f Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Wed, 10 Jun 2026 08:35:32 +0900 Subject: [PATCH 1/3] fix(cli): detect musl correctly under Bun and Alpine Bun's process.report has no glibcVersionRuntime and an empty sharedObjects list, and musl's ldd rejects --version (it prints "musl libc" to stderr and exits non-zero), so the launcher fell through to the glibc default on Alpine and selected the gnu binary. Detection now also checks Bun's release.sourceUrl build flavor, the musl dynamic loader at /lib/ld-musl-*.so.1, and parses stdout/stderr captured from the failed ldd probe. A new TOKSCALE_LIBC=musl|gnu env var forces the result when detection cannot. Fixes #697 Constraint: launcher must stay dependency-free and work under both Node and Bun Rejected: parsing ldd stderr alone | Bun never reaches ldd when spawn shims differ; loader-file check is sturdier and runs first Confidence: high Scope-risk: narrow Not-tested: arm64 musl images (logic is arch-independent; verified x64 musl/glibc via Docker) --- README.md | 2 ++ packages/cli/src/index.ts | 34 +++++++++++++++++++++++++++---- scripts/test-package-launchers.sh | 22 ++++++++++++++++++-- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 133781c8d..71bae89cc 100644 --- a/README.md +++ b/README.md @@ -1173,6 +1173,8 @@ cd packages/core && bun run bench | Windows | x86_64 | ✅ Supported | | Windows | aarch64 | ✅ Supported | +On Linux, the launcher detects glibc vs musl automatically (via `process.report`, the musl dynamic loader at `/lib/ld-musl-*.so.1`, and `ldd`). If detection ever picks the wrong flavor — e.g. in minimal containers — set `TOKSCALE_LIBC=musl` (or `TOKSCALE_LIBC=gnu`) to force it. + ### Windows Support Tokscale fully supports Windows. The TUI and CLI work the same as on macOS/Linux. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 63fd71754..2e46b5f72 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { spawnSync, execSync } from "node:child_process"; -import { existsSync, realpathSync } from "node:fs"; +import { existsSync, readdirSync, realpathSync } from "node:fs"; import { resolve, join, basename } from "node:path"; import { fileURLToPath } from "node:url"; @@ -25,9 +25,16 @@ const workspaceRoot = resolve(scopeDir, ".."); type LibcKind = "gnu" | "musl"; function detectLibcKind(): LibcKind { + const override = process.env.TOKSCALE_LIBC?.trim().toLowerCase(); + if (override === "musl") return "musl"; + if (override === "gnu" || override === "glibc") return "gnu"; + const report = process.report?.getReport?.() as | { - header?: { glibcVersionRuntime?: string }; + header?: { + glibcVersionRuntime?: string; + release?: { sourceUrl?: string }; + }; sharedObjects?: string[]; } | undefined; @@ -43,14 +50,33 @@ function detectLibcKind(): LibcKind { return "musl"; } + // Bun reports neither glibcVersionRuntime nor sharedObjects, but its + // release.sourceUrl names the build flavor (e.g. bun-linux-x64-musl-baseline.zip). + if (report?.header?.release?.sourceUrl?.toLowerCase().includes("musl")) { + return "musl"; + } + + // musl distros (Alpine, Void-musl, ...) ship the loader as /lib/ld-musl-.so.1. + try { + if (readdirSync("/lib").some((entry) => entry.startsWith("ld-musl-"))) { + return "musl"; + } + } catch { + // /lib unreadable or missing; fall through to ldd probing. + } + try { const output = execSync("ldd --version", { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], }).toLowerCase(); return output.includes("musl") ? "musl" : "gnu"; - } catch { - return "gnu"; + } catch (error) { + // musl's ldd rejects --version: it prints "musl libc" to stderr and + // exits non-zero, so the answer is in the error, not the output. + const { stdout, stderr } = (error ?? {}) as { stdout?: unknown; stderr?: unknown }; + const combined = `${stdout ?? ""}\n${stderr ?? ""}`.toLowerCase(); + return combined.includes("musl") ? "musl" : "gnu"; } } diff --git a/scripts/test-package-launchers.sh b/scripts/test-package-launchers.sh index c809c80be..56a966fd2 100755 --- a/scripts/test-package-launchers.sh +++ b/scripts/test-package-launchers.sh @@ -36,12 +36,18 @@ esac PLATFORM_PACKAGE="$(node --input-type=module <<'NODE' import { execSync } from "node:child_process"; +import { readdirSync } from "node:fs"; +// Keep in sync with detectLibcKind() in packages/cli/src/index.ts. function detectLibcKind() { if (process.platform !== "linux") { return null; } + const override = process.env.TOKSCALE_LIBC?.trim().toLowerCase(); + if (override === "musl") return "musl"; + if (override === "gnu" || override === "glibc") return "gnu"; + const report = process.report?.getReport?.(); if (report?.header?.glibcVersionRuntime) { return "gnu"; @@ -54,14 +60,26 @@ function detectLibcKind() { return "musl"; } + if (report?.header?.release?.sourceUrl?.toLowerCase().includes("musl")) { + return "musl"; + } + + try { + if (readdirSync("/lib").some((entry) => entry.startsWith("ld-musl-"))) { + return "musl"; + } + } catch {} + try { const output = execSync("ldd --version", { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], }).toLowerCase(); return output.includes("musl") ? "musl" : "gnu"; - } catch { - throw new Error("Unable to determine Linux libc kind for launcher smoke tests"); + } catch (error) { + // musl's ldd prints "musl libc" to stderr and exits non-zero on --version. + const combined = `${error?.stdout ?? ""}\n${error?.stderr ?? ""}`.toLowerCase(); + return combined.includes("musl") ? "musl" : "gnu"; } } From 0a9fcbb2f260dd608a35e668a7d8aaffe53cac5b Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Wed, 10 Jun 2026 08:43:35 +0900 Subject: [PATCH 2/3] fix(cli): don't treat a coexisting musl loader as the host libc The loader-file check ran before the ldd probe, so a glibc host with the musl package installed (e.g. Debian with musl-tools) was detected as musl under Bun, where process.report is inconclusive. The ldd probe now runs first and short-circuits on explicit musl/glibc/gnu mentions; the loader scan is a last resort that prefers the glibc loader when both are present, since musl can coexist on glibc hosts but not the reverse. Constraint: Bun's process.report cannot distinguish the host libc on glibc systems Rejected: keeping loader check first and excluding known-coexistence paths | any allowlist of paths is fragile across distros; probe order fixes the class of problem Confidence: high Scope-risk: narrow Not-tested: glibc hosts where ldd --version mentions neither glibc nor gnu (falls through to loader scan, which prefers ld-linux) --- packages/cli/src/index.ts | 38 ++++++++++++++++++++++--------- scripts/test-package-launchers.sh | 29 ++++++++++++++++------- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2e46b5f72..3b2a64039 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -56,28 +56,44 @@ function detectLibcKind(): LibcKind { return "musl"; } - // musl distros (Alpine, Void-musl, ...) ship the loader as /lib/ld-musl-.so.1. - try { - if (readdirSync("/lib").some((entry) => entry.startsWith("ld-musl-"))) { - return "musl"; - } - } catch { - // /lib unreadable or missing; fall through to ldd probing. - } - try { const output = execSync("ldd --version", { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], }).toLowerCase(); - return output.includes("musl") ? "musl" : "gnu"; + if (output.includes("musl")) return "musl"; + if (output.includes("glibc") || output.includes("gnu")) return "gnu"; } catch (error) { // musl's ldd rejects --version: it prints "musl libc" to stderr and // exits non-zero, so the answer is in the error, not the output. const { stdout, stderr } = (error ?? {}) as { stdout?: unknown; stderr?: unknown }; const combined = `${stdout ?? ""}\n${stderr ?? ""}`.toLowerCase(); - return combined.includes("musl") ? "musl" : "gnu"; + if (combined.includes("musl")) return "musl"; + if (combined.includes("glibc") || combined.includes("gnu")) return "gnu"; + } + + // ldd missing or inconclusive: look for dynamic loaders. The glibc loader + // wins when both exist - a musl loader can coexist on glibc hosts (e.g. + // Debian with the musl package installed), but not the reverse. + if (loaderPresent("ld-linux-")) return "gnu"; + if (loaderPresent("ld-musl-")) return "musl"; + + return "gnu"; +} + +// Glibc ships ld-linux-*.so.* in /lib64 (or /lib on some arches); musl +// distros (Alpine, Void-musl, ...) ship /lib/ld-musl-.so.1. +function loaderPresent(prefix: string): boolean { + for (const dir of ["/lib", "/lib64"]) { + try { + if (readdirSync(dir).some((entry) => entry.startsWith(prefix))) { + return true; + } + } catch { + // Directory unreadable or missing; try the next one. + } } + return false; } function resolveTargetPackageName(): string | null { diff --git a/scripts/test-package-launchers.sh b/scripts/test-package-launchers.sh index 56a966fd2..cb9e81952 100755 --- a/scripts/test-package-launchers.sh +++ b/scripts/test-package-launchers.sh @@ -64,23 +64,36 @@ function detectLibcKind() { return "musl"; } - try { - if (readdirSync("/lib").some((entry) => entry.startsWith("ld-musl-"))) { - return "musl"; - } - } catch {} - try { const output = execSync("ldd --version", { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], }).toLowerCase(); - return output.includes("musl") ? "musl" : "gnu"; + if (output.includes("musl")) return "musl"; + if (output.includes("glibc") || output.includes("gnu")) return "gnu"; } catch (error) { // musl's ldd prints "musl libc" to stderr and exits non-zero on --version. const combined = `${error?.stdout ?? ""}\n${error?.stderr ?? ""}`.toLowerCase(); - return combined.includes("musl") ? "musl" : "gnu"; + if (combined.includes("musl")) return "musl"; + if (combined.includes("glibc") || combined.includes("gnu")) return "gnu"; } + + // ldd missing or inconclusive: look for dynamic loaders. The glibc loader + // wins when both exist (e.g. Debian with the musl package installed). + const loaderPresent = (prefix) => { + for (const dir of ["/lib", "/lib64"]) { + try { + if (readdirSync(dir).some((entry) => entry.startsWith(prefix))) { + return true; + } + } catch {} + } + return false; + }; + if (loaderPresent("ld-linux-")) return "gnu"; + if (loaderPresent("ld-musl-")) return "musl"; + + return "gnu"; } const arch = process.arch; From 570c851a9a6df96212e6c48bd897668286d5c612 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Wed, 10 Jun 2026 09:00:24 +0900 Subject: [PATCH 3/3] fix(cli): break loader-coexistence ties with the distro marker Preferring the glibc loader unconditionally mis-detects Alpine with gcompat installed (it ships an ld-linux-* stub) as gnu in the rare case the loader scan is reached. A lone loader still wins outright; when both loaders exist, /etc/alpine-release decides, which resolves both coexistence directions (Debian+musl package -> gnu, Alpine+gcompat -> musl). Constraint: the loader scan only runs when process.report and ldd are both inconclusive Rejected: parsing /etc/os-release for musl-based distro IDs | more moving parts for the same rare branch; alpine-release covers the dominant musl distro Confidence: high Scope-risk: narrow Not-tested: non-Alpine musl distros with glibc compat stubs and no ldd (still resolve gnu; TOKSCALE_LIBC=musl covers them) --- packages/cli/src/index.ts | 15 ++++++++++----- scripts/test-package-launchers.sh | 15 ++++++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3b2a64039..afcce3361 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -72,11 +72,16 @@ function detectLibcKind(): LibcKind { if (combined.includes("glibc") || combined.includes("gnu")) return "gnu"; } - // ldd missing or inconclusive: look for dynamic loaders. The glibc loader - // wins when both exist - a musl loader can coexist on glibc hosts (e.g. - // Debian with the musl package installed), but not the reverse. - if (loaderPresent("ld-linux-")) return "gnu"; - if (loaderPresent("ld-musl-")) return "musl"; + // ldd missing or inconclusive: look for dynamic loaders. Either loader + // can coexist with the other's libc (Debian's musl package installs + // ld-musl-*; Alpine's gcompat installs ld-linux-*), so when both are + // present, let the distro break the tie. + const hasGnuLoader = loaderPresent("ld-linux-"); + const hasMuslLoader = loaderPresent("ld-musl-"); + if (hasGnuLoader !== hasMuslLoader) return hasMuslLoader ? "musl" : "gnu"; + if (hasGnuLoader && hasMuslLoader) { + return existsSync("/etc/alpine-release") ? "musl" : "gnu"; + } return "gnu"; } diff --git a/scripts/test-package-launchers.sh b/scripts/test-package-launchers.sh index cb9e81952..a9eaa2671 100755 --- a/scripts/test-package-launchers.sh +++ b/scripts/test-package-launchers.sh @@ -36,7 +36,7 @@ esac PLATFORM_PACKAGE="$(node --input-type=module <<'NODE' import { execSync } from "node:child_process"; -import { readdirSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; // Keep in sync with detectLibcKind() in packages/cli/src/index.ts. function detectLibcKind() { @@ -78,8 +78,9 @@ function detectLibcKind() { if (combined.includes("glibc") || combined.includes("gnu")) return "gnu"; } - // ldd missing or inconclusive: look for dynamic loaders. The glibc loader - // wins when both exist (e.g. Debian with the musl package installed). + // ldd missing or inconclusive: look for dynamic loaders. Either loader can + // coexist with the other's libc (Debian's musl package installs ld-musl-*; + // Alpine's gcompat installs ld-linux-*), so the distro breaks ties. const loaderPresent = (prefix) => { for (const dir of ["/lib", "/lib64"]) { try { @@ -90,8 +91,12 @@ function detectLibcKind() { } return false; }; - if (loaderPresent("ld-linux-")) return "gnu"; - if (loaderPresent("ld-musl-")) return "musl"; + const hasGnuLoader = loaderPresent("ld-linux-"); + const hasMuslLoader = loaderPresent("ld-musl-"); + if (hasGnuLoader !== hasMuslLoader) return hasMuslLoader ? "musl" : "gnu"; + if (hasGnuLoader && hasMuslLoader) { + return existsSync("/etc/alpine-release") ? "musl" : "gnu"; + } return "gnu"; }