Add an opt-in --static flag for Linux executables (#2398)

This commit is contained in:
Chris Tate
2026-10-27 04:31:35 -05:00
committed by GitHub
18 changed files with 493 additions and 13 deletions
+7
View File
@@ -151,6 +151,9 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
<dt><code>--strip</code></dt>
<dd>Remove symbol and debug payload when linking an executable, including cross-compiled targets. This flag preserves code optimization.</dd>
<dt><code>--static</code></dt>
<dd>On Linux, link the C library and the other system libraries into the executable. The executable starts without the dynamic loader, which shortens its startup, and doesn't depend on the run host's glibc version. glibc executables link as static PIE, are about 1 MB larger, and need glibc's static libraries on the build machine (<code>libc6-dev</code> on Debian and Ubuntu, <code>glibc-static</code> on Fedora); they link only for the build machine's own glibc target. A program whose executable includes host, user, or group name lookups, which <code>dns.lookup</code>, <code>net</code> and <code>http</code> connections, <code>fetch</code>, and <code>os.userInfo()</code> use, is refused with <code>SC3002</code>: a static glibc executable loads the NSS modules for those lookups from the run host, which glibc supports only for the glibc version it was linked with. Build such a program without <code>--static</code>, or for an <code>&lt;arch&gt;-linux-musl</code> target. musl executables are always linked statically, and accept the flag. Valid only for executable builds.</dd>
<dt><code>--optimization &lt;release|dev|speed&gt;</code></dt>
<dd>Select native optimization for executable, assembly, or object output. The default is <code>release</code> (<code>-O2</code>), which emits string retains and the retains and releases of dynamic values inline. <code>dev</code> uses <code>-O0</code> and emits source locations for TypeScript and JavaScript breakpoints and stack frames. LLVM builds include source variable names and native storage descriptions; see <a href="/docs/limitations#native-debugging">Native debugging</a> for the supported representations. macOS executable builds also produce an adjacent <code>.dSYM</code> bundle. <code>speed</code> is <code>-O2</code> plus optimizations that trade binary size and build time for run-time speed: it emits the remaining reference counting inline and, on x86-64, starts every runtime function on a cache-line boundary. Expect larger executables and longer cold builds than <code>release</code>; how much faster a program runs depends on how much of its time is spent in the runtime. Where a runtime pack has no speed runtime, <code>speed</code> links the release runtime and keeps inline reference counting. Selecting <code>speed</code> never changes what <code>release</code> builds produce, and the two never share cached artifacts. Library profiles accept <code>"optimization": "speed"</code> for inline reference counting. In executables large enough to be divided into partitions, <code>dev</code> compiles each partition on its own, and <code>release</code> and <code>speed</code> add a step that lets partitions import small functions from each other; see <a href="/docs/how-it-works#build-parallelism">Build parallelism</a>.</dd>
@@ -373,6 +376,10 @@ For source breakpoints in Xcode or LLDB, build with <code>--optimization=dev</co
<td><code>scriptc run</code> on <code>wasm32-wasi</code></td>
<td>Node.js 24 or later on <code>PATH</code></td>
</tr>
<tr>
<td><code>--static</code> on glibc Linux</td>
<td>glibc's static libraries (<code>libc6-dev</code> on Debian and Ubuntu, <code>glibc-static</code> on Fedora)</td>
</tr>
<tr>
<td><code>--sanitize</code></td>
<td>A C compiler and LLVM toolchain</td>
+1 -1
View File
@@ -49,7 +49,7 @@ $ file fib-linux
fib-linux: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 2.0.0, with debug_info, not stripped
```
`x86_64-linux-gnu.2.36` works the same way. For Alpine and other musl-based systems, use `aarch64-linux-musl` or `x86_64-linux-musl`; these produce statically linked executables.
`x86_64-linux-gnu.2.36` works the same way. For Alpine and other musl-based systems, use `aarch64-linux-musl` or `x86_64-linux-musl`; these produce statically linked executables. On a glibc-based Linux machine, [`--static`](/docs/cli#options) links a static executable for the machine's own target.
Linux executables use the native Linux implementations of the event loop (epoll), servers, TLS (with the system's CA bundle), and `fs.watch`, and they are tested against Node.js on Linux, including Alpine.
+2
View File
@@ -144,6 +144,7 @@ async function tryFastPath(): Promise<number | null> {
backend: "llvm",
...(optimization === "dev" || optimization === "speed" ? { optimization } : {}),
...(values.strip ? { strip: true as const } : {}),
...(values.static ? { staticLink: true as const } : {}),
...(values["detached-exit"] ? { detachedExit: true as const } : {}),
...(values["large-pages"] ? { largePages: true as const } : {}),
...(values["cpu-dispatch"] === true ? { cpuDispatch: true as const } : {}),
@@ -229,6 +230,7 @@ async function coverageFastPath(
(failOn !== undefined && failOn !== "blockers" && failOn !== "divergences") ||
values.emit !== undefined ||
values.strip ||
values.static ||
values["detached-exit"] ||
values["large-pages"] ||
values["target-cpu"] !== undefined ||
+2
View File
@@ -46,6 +46,8 @@ describe("output option compatibility", () => {
[{ emit: "llvm", windowsSubsystem: "gui" }, /windows-subsystem/],
[{ emit: "obj", strip: true }, /strip/],
[{ emit: "llvm", strip: true }, /strip/],
[{ emit: "obj", staticLink: true }, /--static/],
[{ emit: "llvm", staticLink: true }, /--static/],
[{ emit: "obj", detachedExit: true }, /detached-exit/],
[{ emit: "llvm", detachedExit: true }, /detached-exit/],
[{ emit: "obj", largePages: true }, /large-pages/],
+138
View File
@@ -0,0 +1,138 @@
import { execFile, execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, isAbsolute, join } from "node:path";
import { promisify } from "node:util";
import { expect, test } from "vitest";
const execFileAsync = promisify(execFile);
const require = createRequire(import.meta.url);
const repoRoot = join(import.meta.dirname, "../../..");
const cliEntry = join(repoRoot, "packages/cli/src/main.ts");
const bootstrapEntry = join(repoRoot, "packages/cli/dist/bootstrap.js");
const tsxLoader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs");
const glibcHost =
process.platform === "linux" &&
typeof (process.report?.getReport() as { header?: { glibcVersionRuntime?: unknown } })?.header
?.glibcVersionRuntime === "string";
/** Whether the host's clang finds glibc's static C library. */
function hostHasStaticLibc(): boolean {
try {
const path = execFileSync("clang", ["-print-file-name=libc.a"], { encoding: "utf8" }).trim();
return isAbsolute(path) && existsSync(path);
} catch {
return false;
}
}
/** The ELF type and whether the executable names a program interpreter. */
async function elfLinkage(path: string): Promise<{ type: number; interpreter: boolean }> {
const bytes = await readFile(path);
const type = bytes.readUInt16LE(16);
const phoff = Number(bytes.readBigUInt64LE(32));
const phentsize = bytes.readUInt16LE(54);
const phnum = bytes.readUInt16LE(56);
let interpreter = false;
for (let i = 0; i < phnum; i++) {
if (bytes.readUInt32LE(phoff + i * phentsize) === 3 /* PT_INTERP */) interpreter = true;
}
return { type, interpreter };
}
async function withProject<T>(run: (dir: string, env: NodeJS.ProcessEnv) => Promise<T>) {
const dir = await mkdtemp(
join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-static-"),
);
try {
return await run(dir, { ...process.env, SCRIPTC_CACHE_DIR: join(dir, "cache") });
} finally {
await rm(dir, { recursive: true, force: true });
}
}
function cli(args: string[], env: NodeJS.ProcessEnv, bootstrap = false) {
return execFileAsync(
process.execPath,
[...(bootstrap ? [bootstrapEntry] : ["--import", tsxLoader, cliEntry]), ...args],
{ env, maxBuffer: 4 * 1024 * 1024 },
);
}
test.skipIf(!glibcHost || !hostHasStaticLibc())(
"--static links a glibc executable as static PIE without mixing cache variants",
async () => {
await withProject(async (dir, env) => {
const entry = join(dir, "main.ts");
const outPath = join(dir, "program");
// The NSS module file name in a string of the program's own is no lookup.
await writeFile(
entry,
'console.log("static-ok", [1, 2, 3].map((n) => n * 2).join(","), "/lib/libnss_files.so.2");\n',
);
const build = async (staticLink: boolean, bootstrap = false) => {
await cli(
["build", entry, "-o", outPath, ...(staticLink ? ["--static"] : [])],
env,
bootstrap,
);
expect((await execFileAsync(outPath)).stdout).toBe(
"static-ok 2,4,6 /lib/libnss_files.so.2\n",
);
return elfLinkage(outPath);
};
// ET_DYN with a program interpreter: the default dynamic PIE link.
expect(await build(false)).toEqual({ type: 3, interpreter: true });
// ET_DYN without one: static PIE.
expect(await build(true)).toEqual({ type: 3, interpreter: false });
expect(await build(true, true)).toEqual({ type: 3, interpreter: false });
expect(await build(false, true)).toEqual({ type: 3, interpreter: true });
});
},
180_000,
);
test.skipIf(!glibcHost || !hostHasStaticLibc())(
"--static refuses a glibc executable that includes user name lookups",
async () => {
await withProject(async (dir, env) => {
const entry = join(dir, "main.ts");
const outPath = join(dir, "program");
await writeFile(
entry,
'import { userInfo } from "node:os";\nconsole.log(userInfo().username);\n',
);
const failure = await cli(["build", entry, "-o", outPath, "--static"], env).then(
() => null,
(error: { code?: number; stderr?: string }) => error,
);
expect(failure?.code).toBe(1);
expect(failure?.stderr).toContain("error SC3002: --static cannot link this program");
expect(existsSync(outPath)).toBe(false);
// The same program links without --static.
await cli(["build", entry, "-o", outPath], env);
expect((await execFileAsync(outPath)).stdout).not.toBe("");
});
},
180_000,
);
test.skipIf(process.platform === "linux")(
"--static is refused for executables that do not target Linux",
async () => {
await withProject(async (dir, env) => {
const entry = join(dir, "main.ts");
await writeFile(entry, 'console.log("x");\n');
const failure = await cli(["build", entry, "-o", join(dir, "program"), "--static"], env).then(
() => null,
(error: { code?: number; stderr?: string }) => error,
);
expect(failure?.code).toBe(1);
expect(failure?.stderr).toContain("--static is only supported for Linux executables");
});
},
180_000,
);
@@ -2,6 +2,7 @@
import type { NativeOptimization } from "./optimization.js";
import {
executableOptimizationLinkerArgs,
executableStaticLinkerArgs,
executableStripLinkerArgs,
windowsSubsystemLinkerArgs,
type NativeTargetSpec,
@@ -28,6 +29,8 @@ export function executableLinkInputs(options: {
runtimeSystemLibraries: readonly string[];
optimization: NativeOptimization;
strip?: boolean;
/** Link the C library and the other system libraries statically (`--static`). */
staticLink?: boolean;
windowsSubsystem?: WindowsSubsystem;
/** Instrumented for profile-guided optimization. */
profileGenerate?: boolean;
@@ -65,6 +68,7 @@ export function executableLinkInputs(options: {
),
...executableOptimizationLinkerArgs(options.target.platform, options.optimization),
...executableStripLinkerArgs(options.target.platform, options.strip ?? false),
...executableStaticLinkerArgs(options.target, options.staticLink ?? false),
...windowsSubsystemLinkerArgs(options.target.platform, options.windowsSubsystem),
...profileGenerateLinkerArgs(options.profileGenerate ?? false),
],
@@ -28,6 +28,8 @@ export interface NativeLinkPlan {
programObjectDependencies: NativeArtifactDependency[];
runtimePack: RuntimePackSelection;
darwinDebugSymbols?: boolean;
/** Linked with `--static`: the linked executable is checked before use. */
staticLink?: boolean;
}
export async function createNativeLinkPlan(options: {
@@ -39,6 +41,7 @@ export async function createNativeLinkPlan(options: {
ffi: FfiProfile | null;
optimization: NativeOptimization;
strip?: boolean;
staticLink?: boolean;
windowsSubsystem?: WindowsSubsystem;
profileGenerate?: boolean;
programObjectDependencies?: readonly NativeArtifactDependency[];
@@ -70,6 +73,7 @@ export async function createNativeLinkPlan(options: {
runtimeSystemLibraries: runtimePack.systemLibraries,
optimization: options.optimization,
...(options.strip === undefined ? {} : { strip: options.strip }),
...(options.staticLink === true ? { staticLink: true } : {}),
...(options.windowsSubsystem === undefined
? {}
: { windowsSubsystem: options.windowsSubsystem }),
@@ -78,5 +82,6 @@ export async function createNativeLinkPlan(options: {
dependencyPaths: [...runtimePack.dependencyPaths, ...(options.ffi?.libraries ?? [])],
programObjectDependencies: [...(options.programObjectDependencies ?? [])],
runtimePack,
...(options.staticLink === true ? { staticLink: true } : {}),
};
}
+18 -3
View File
@@ -7,11 +7,13 @@
import { execFile } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { existsSync, realpathSync, statSync } from "node:fs";
import { mkdtemp, rename, rm, stat } from "node:fs/promises";
import { mkdtemp, readFile, rename, rm, stat } from "node:fs/promises";
import { delimiter } from "node:path";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import { promisify } from "node:util";
import { CcCompileError, subprocessFailureDetail } from "./native/process.js";
import { NativeCodegenError } from "./native-codegen-core.js";
import { staticExecutableRefusal, staticLinkFailureNote, staticLinkMapArgs } from "./targets.js";
import { nativeArtifactDependenciesStillMatch } from "./native/artifact-stamps.js";
import { parseLinkTraceFiles } from "./native/linker-fingerprint.js";
import {
@@ -238,6 +240,8 @@ export async function linkNativeExecutable(
join(dirname(plan.outputPath), ".scriptc-runtime-pack-link-"),
);
const privateOut = join(privateOutRoot, basename(plan.outputPath));
const linkMap = join(privateOutRoot, "link.map");
const mapArgs = staticLinkMapArgs(plan.target, plan.staticLink === true, linkMap);
let stagedRoot: string | null = null;
try {
const staged = await stageRuntimePackArtifacts(plan.runtimePack);
@@ -250,6 +254,7 @@ export async function linkNativeExecutable(
...(explicitLinker === undefined ? plan.target.defaultLinkerArgs : []),
...(withLld ? lld : []),
...plan.driverFlags,
...mapArgs,
...(withLld ? inputs : inputsWithoutLld(plan.target.platform, inputs)),
...systemLibraryLinkerArgs(plan.target, plan.systemLibraries),
"-o",
@@ -293,6 +298,10 @@ export async function linkNativeExecutable(
});
const output = await stat(privateOut);
if (!output.isFile() || output.size === 0) throw new Error("linker produced no executable");
if (mapArgs.length !== 0) {
const refusal = staticExecutableRefusal(plan.target, true, await readFile(linkMap, "utf8"));
if (refusal !== null) throw new NativeCodegenError("SC3002", refusal);
}
if (plan.darwinDebugSymbols) {
await createDarwinDebugSymbols(privateOut);
await installDarwinDebugSymbols(await readDarwinDebugSymbols(privateOut), plan.outputPath);
@@ -312,12 +321,18 @@ export async function linkNativeExecutable(
await options.onArtifactReady({ dependencies: preLinkDependencies }).catch(() => undefined);
}
} catch (error) {
if (error instanceof CcCompileError || error instanceof RuntimePackError) throw error;
if (
error instanceof CcCompileError ||
error instanceof RuntimePackError ||
error instanceof NativeCodegenError
)
throw error;
const detail = subprocessFailureDetail(error);
throw new CcCompileError(
linker,
detail,
`${linker} failed linking ${basename(plan.outputPath)} from the precompiled runtime pack.\n${detail}`,
`${linker} failed linking ${basename(plan.outputPath)} from the precompiled runtime pack.\n${detail}` +
staticLinkFailureNote(plan.target, plan.staticLink === true),
);
} finally {
await Promise.all([
@@ -21,7 +21,12 @@ import {
platformLinkerSupportsPersistentCache,
resolvePlatformLinker,
} from "./linker.js";
import { MACOS_ARM64_TARGET, WINDOWS_X64_MSVC_TARGET, type NativeTargetSpec } from "./targets.js";
import {
LINUX_X64_GNU_TARGET,
MACOS_ARM64_TARGET,
WINDOWS_X64_MSVC_TARGET,
type NativeTargetSpec,
} from "./targets.js";
const VERSION = compilerReleaseVersion();
@@ -242,6 +247,25 @@ describe("runtime pack manifests", () => {
expect(guiPlan.driverFlags).toEqual([...defaultPlan.driverFlags, "-Wl,--subsystem,windows"]);
});
test("Linux runtime-pack links are static PIE only when requested", async () => {
const { packagePath, root } = await fixture(LINUX_X64_GNU_TARGET);
const options = {
target: LINUX_X64_GNU_TARGET,
programObject: join(root, "program.o"),
outPath: join(root, "program"),
features: BASE,
ffi: null,
optimization: "release" as const,
resolver: () => packagePath,
};
const defaultPlan = await createNativeLinkPlan(options);
const staticPlan = await createNativeLinkPlan({ ...options, staticLink: true });
expect(defaultPlan.driverFlags).not.toContain("-static-pie");
expect(defaultPlan.staticLink).toBeUndefined();
expect(staticPlan.driverFlags).toEqual([...defaultPlan.driverFlags, "-static-pie"]);
expect(staticPlan.staticLink).toBe(true);
});
test("selection chooses the most-specific variant and feature archive", async () => {
const { packagePath } = await fixture();
const resolver = () => packagePath;
@@ -13,9 +13,15 @@ import {
WASM32_WASI_TARGET,
WINDOWS_X64_MSVC_TARGET,
executableOptimizationLinkerArgs,
executableStaticLinkerArgs,
ffiExtendsNarrowIntegers,
nativeCodegenTarget,
nativeCodegenTargetRefusal,
selectNativeTarget,
staticExecutableRefusal,
staticLinkFailureNote,
staticLinkMapArgs,
staticLinkRefusal,
windowsSubsystemLinkerArgs,
} from "./targets.js";
@@ -232,4 +238,96 @@ describe("native code-generation targets", () => {
"unknown Windows subsystem",
);
});
test("links host glibc executables as static PIE only for --static", () => {
expect(executableStaticLinkerArgs(LINUX_X64_GNU_TARGET, false)).toEqual([]);
expect(executableStaticLinkerArgs(LINUX_X64_GNU_TARGET, true)).toEqual(["-static-pie"]);
expect(executableStaticLinkerArgs(LINUX_ARM64_GNU_TARGET, true)).toEqual(["-static-pie"]);
// musl executables are static already.
expect(executableStaticLinkerArgs(LINUX_X64_MUSL_TARGET, true)).toEqual([]);
expect(executableStaticLinkerArgs(MACOS_ARM64_TARGET, false)).toEqual([]);
expect(() => executableStaticLinkerArgs(MACOS_ARM64_TARGET, true)).toThrow(
"only supported for Linux executables",
);
});
test("refuses --static where glibc cannot be linked statically", () => {
const request = { staticLink: true, outputKind: "exe", sanitize: false };
expect(staticLinkRefusal({ ...request, staticLink: undefined, target: null })).toBeNull();
expect(staticLinkRefusal({ ...request, target: LINUX_X64_GNU_TARGET })).toBeNull();
expect(staticLinkRefusal({ ...request, target: LINUX_X64_MUSL_TARGET })).toBeNull();
expect(staticLinkRefusal({ ...request, outputKind: "obj", target: LINUX_X64_GNU_TARGET })).toBe(
"--static is only supported for executable output",
);
expect(staticLinkRefusal({ ...request, sanitize: true, target: LINUX_X64_GNU_TARGET })).toMatch(
"cannot be combined with --sanitize",
);
for (const target of [MACOS_ARM64_TARGET, WINDOWS_X64_MSVC_TARGET, WASM32_WASI_TARGET, null])
expect(staticLinkRefusal({ ...request, target })).toBe(
"--static is only supported for Linux executables",
);
// zig links cross-compiled glibc targets, and links glibc dynamically.
const cross = selectNativeTarget("aarch64-linux-gnu", LINUX_X64_GNU_TARGET, "linux")!;
expect(staticLinkRefusal({ ...request, target: cross })).toMatch("<arch>-linux-musl");
const crossMusl = selectNativeTarget("aarch64-linux-musl", LINUX_X64_GNU_TARGET, "linux")!;
expect(staticLinkRefusal({ ...request, target: crossMusl })).toBeNull();
});
test("refuses static glibc executables whose link map keeps the NSS loader", () => {
const libc = "/lib/x86_64-linux-gnu/libc.a";
// lld lists the input sections it kept.
const lld = (member: string) =>
[
" VMA LMA Size Align Out In Symbol",
` 11e1a0 11e1a0 640 16 ${libc}(${member}):(.text)`,
` 11e7e0 11e7e0 38 1 ${libc}(${member}):(.rodata)`,
].join("\n");
const lldPlain = [
" VMA LMA Size Align Out In Symbol",
` 2c62c0 2c62c0 a10 16 ${libc}(dl-open.o):(.text)`,
].join("\n");
// GNU ld lists discarded sections first, and sections relaxation emptied with size 0.
const bfd = (text: boolean) =>
[
"Discarded input sections",
"",
` .text 0x0000000000000000 0x640 ${libc}(nss_module.o)`,
"",
"Linker script and memory map",
"",
` .eh_frame 0x00000000000db750 0x0 ${libc}(nss_module.o)`,
" 0x1b8 (size before relaxing)",
...(text ? [` .text 0x00000000000a81b0 0x640 ${libc}(nsswitch.o)`] : []),
].join("\n");
const refusal =
/^--static cannot link this program: its executable includes host, user or group name lookups/;
expect(staticExecutableRefusal(LINUX_X64_GNU_TARGET, true, lld("nss_module.o"))).toMatch(
refusal,
);
expect(staticExecutableRefusal(LINUX_X64_GNU_TARGET, true, lld("nsswitch.o"))).toMatch(refusal);
expect(staticExecutableRefusal(LINUX_X64_GNU_TARGET, true, bfd(true))).toMatch(refusal);
expect(staticExecutableRefusal(LINUX_X64_GNU_TARGET, true, bfd(false))).toBeNull();
expect(staticExecutableRefusal(LINUX_X64_GNU_TARGET, true, lldPlain)).toBeNull();
// A program's own string never counts.
expect(staticExecutableRefusal(LINUX_X64_GNU_TARGET, true, "libnss_files.so.2")).toBeNull();
expect(staticExecutableRefusal(LINUX_X64_GNU_TARGET, false, lld("nss_module.o"))).toBeNull();
expect(staticExecutableRefusal(LINUX_X64_MUSL_TARGET, true, lld("nss_module.o"))).toBeNull();
});
test("writes a link map only for --static glibc links", () => {
expect(staticLinkMapArgs(LINUX_X64_GNU_TARGET, true, "/stage/link.map")).toEqual([
"-Xlinker",
"-Map",
"-Xlinker",
"/stage/link.map",
]);
expect(staticLinkMapArgs(LINUX_X64_GNU_TARGET, false, "/stage/link.map")).toEqual([]);
expect(staticLinkMapArgs(LINUX_X64_MUSL_TARGET, true, "/stage/link.map")).toEqual([]);
});
test("notes glibc's static libraries on failed --static links", () => {
expect(staticLinkFailureNote(LINUX_X64_GNU_TARGET, true)).toMatch("libc.a");
expect(staticLinkFailureNote(LINUX_X64_GNU_TARGET, false)).toBe("");
expect(staticLinkFailureNote(LINUX_X64_MUSL_TARGET, true)).toBe("");
});
});
+90
View File
@@ -91,6 +91,96 @@ export function executableStripLinkerArgs(platform: string, strip: boolean): str
}
}
/** Why a `--static` request cannot be linked, or null when it can. A glibc
* executable links statically only through the host's clang driver: zig,
* which links cross-compiled glibc targets, links glibc dynamically. */
export function staticLinkRefusal(options: {
staticLink: boolean | undefined;
outputKind: string;
sanitize: boolean;
target: Pick<NativeTargetSpec, "name" | "platform" | "defaultLinker"> | null;
}): string | null {
if (options.staticLink !== true) return null;
if (options.outputKind !== "exe") return "--static is only supported for executable output";
if (options.sanitize)
return "--static cannot be combined with --sanitize; AddressSanitizer requires a dynamically linked executable";
const target = options.target;
if (target === null || target.platform !== "linux")
return "--static is only supported for Linux executables";
if (!target.name.endsWith("-musl") && target.defaultLinker === "zig")
return "--static links glibc from the build host and is only supported for the host's own glibc target; use an <arch>-linux-musl target to cross-compile a static executable";
return null;
}
/** Link the C library and the other system libraries into the executable.
* glibc executables link as static PIE, so the kernel still loads them at a
* random address; musl executables are always linked statically. */
export function executableStaticLinkerArgs(
target: Pick<NativeTargetSpec, "name" | "platform" | "defaultLinker">,
staticLink: boolean,
): string[] {
if (!staticLink) return [];
const refusal = staticLinkRefusal({ staticLink, outputKind: "exe", sanitize: false, target });
if (refusal !== null) throw new Error(refusal);
return target.name.endsWith("-musl") ? [] : ["-static-pie"];
}
/** The link map a `--static` glibc link writes to `path`, which
* staticExecutableRefusal reads. `-Xlinker` keeps the path one argument
* through the driver and into the linker's job. */
export function staticLinkMapArgs(
target: Pick<NativeTargetSpec, "name">,
staticLink: boolean,
path: string,
): string[] {
return staticLink && !target.name.endsWith("-musl") ? ["-Xlinker", "-Map", "-Xlinker", path] : [];
}
/** The libc.a members of glibc's NSS loader: nss_module.o (glibc 2.33 and
* later) and nsswitch.o. */
const GLIBC_NSS_LOADER_MEMBER = /libc\.a\((?:nss_module|nsswitch)\.o\)/;
/** Whether a link map line places code from an archive member in the
* output: lld lists the input sections it kept (`… libc.a(m.o):(.text)`),
* GNU ld each input section with its size (` .text 0x… 0x… …libc.a(m.o)`),
* and lists sections that relaxation emptied with size 0. */
function keepsText(line: string): boolean {
return /\):\(\.text\)\s*$/.test(line) || /^\s*\.text\s+0x[0-9a-f]+\s+0x0*[1-9a-f]/.test(line);
}
/** Why a linked `--static` executable is refused, or null. A static glibc
* executable answers host, user and group lookups with glibc's built-in
* `files` and `dns` sources, and loads every other module the run host's
* nsswitch.conf names from that host, which glibc supports only for the
* glibc version the executable was linked with. `linkMap` is the link's
* map (staticLinkMapArgs): the executable has the NSS loader when the map
* places the loader's code in it. GNU ld lists the input sections that
* garbage collection discarded before its memory map. */
export function staticExecutableRefusal(
target: Pick<NativeTargetSpec, "name">,
staticLink: boolean,
linkMap: string,
): string | null {
if (!staticLink || target.name.endsWith("-musl")) return null;
const memoryMap = linkMap.indexOf("Linker script and memory map");
const kept = memoryMap === -1 ? linkMap : linkMap.slice(memoryMap);
const loader = kept
.split("\n")
.some((line) => GLIBC_NSS_LOADER_MEMBER.test(line) && keepsText(line));
if (!loader) return null;
return "--static cannot link this program: its executable includes host, user or group name lookups (which dns.lookup, net and http connections, fetch and os.userInfo use), and a statically linked glibc executable loads the NSS modules for those lookups from the run host, which glibc supports only for the glibc version it was linked with. Build without --static, or use an <arch>-linux-musl target";
}
/** The note a failed `--static` glibc link adds to the linker's error. */
export function staticLinkFailureNote(
target: Pick<NativeTargetSpec, "name">,
staticLink: boolean,
): string {
return staticLink && !target.name.endsWith("-musl")
? "\n--static links glibc's static libraries (libc.a, libm.a), which the libc6-dev package installs on Debian and Ubuntu and glibc-static on Fedora"
: "";
}
export interface NativeTargetSpec {
/** Stable scriptc-facing identity used in cache keys and diagnostics. */
name: NativeTargetName;
+9 -1
View File
@@ -115,6 +115,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
values.ffi !== undefined ||
values.profile !== undefined ||
values.strip ||
values.static ||
values["windows-subsystem"] !== undefined ||
values["detached-exit"] ||
values["large-pages"] ||
@@ -176,6 +177,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
values.ffi !== undefined ||
values.profile !== undefined ||
values.strip ||
values.static ||
values.sanitize ||
values["windows-subsystem"] !== undefined ||
values["detached-exit"] ||
@@ -269,6 +271,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
values["fail-on"] !== undefined ||
values.optimization !== undefined ||
values.strip ||
values.static ||
values.ffi !== undefined ||
values["windows-subsystem"] !== undefined ||
values["detached-exit"] ||
@@ -284,7 +287,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
hasNodeIslandFlags(values)
) {
fail(
"scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--large-pages/--target-cpu/--cpu-dispatch/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only",
"scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--static/--windows-subsystem/--detached-exit/--large-pages/--target-cpu/--cpu-dispatch/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only",
);
}
const profilePath = resolve(profileArg);
@@ -327,6 +330,9 @@ async function main(args: string[], host: CliHost): Promise<number> {
if (command === "coverage" && values.strip) {
fail(`--strip is a build/run option\n\n${USAGE}`);
}
if (command === "coverage" && values.static) {
fail(`--static is a build/run option\n\n${USAGE}`);
}
if (command === "coverage" && values["target-cpu"] !== undefined) {
fail(`--target-cpu is a build/run option\n\n${USAGE}`);
}
@@ -515,6 +521,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
sanitize: values.sanitize,
...(values.optimization === undefined ? {} : { optimization: values.optimization }),
strip: values.strip,
staticLink: values.static,
detachedExit: values["detached-exit"],
largePages: values["large-pages"],
...(values["cpu-dispatch"] === undefined ? {} : { cpuDispatch: values["cpu-dispatch"] }),
@@ -635,6 +642,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
...(backend !== undefined ? { backend } : {}),
...(optimization !== undefined ? { optimization } : {}),
...(values.strip ? { strip: true } : {}),
...(values.static ? { staticLink: true } : {}),
...(windowsSubsystem !== undefined ? { windowsSubsystem } : {}),
...(values["detached-exit"] ? { detachedExit: true } : {}),
...(values["large-pages"] ? { largePages: true } : {}),
@@ -9,6 +9,7 @@ export interface OutputOptionValues {
sanitize: boolean;
optimization?: string;
strip?: boolean;
staticLink?: boolean;
windowsSubsystem?: string;
detachedExit?: boolean;
largePages?: boolean;
@@ -74,6 +75,9 @@ export function resolveOutputOptions(
if (values.strip && emit !== "exe") {
return { ok: false, message: `--strip is only supported with --emit=exe` };
}
if (values.staticLink && emit !== "exe") {
return { ok: false, message: `--static is only supported with --emit=exe` };
}
if (values.detachedExit && emit !== "exe") {
return { ok: false, message: `--detached-exit is only supported with --emit=exe` };
}
+6
View File
@@ -56,6 +56,11 @@ Options:
counting, cache-line-aligned runtime code on x86-64)
--strip remove symbol/debug payload from the linked executable
for smaller builds (opt in; --emit=exe only)
--static Linux: link the C library into the executable, which
then starts without the dynamic loader. glibc builds
need its static libraries (libc.a) on the build host
and refuse executables that include host, user or
group name lookups (opt in; --emit=exe only)
--windows-subsystem <console|gui>
Windows executable subsystem (default: console). gui
prevents Windows from opening a console window
@@ -176,6 +181,7 @@ export const CLI_OPTIONS = {
backend: { type: "string" },
optimization: { type: "string" },
strip: { type: "boolean", default: false },
static: { type: "boolean", default: false },
"windows-subsystem": { type: "string" },
"detached-exit": { type: "boolean", default: false },
"large-pages": { type: "boolean", default: false },
+5
View File
@@ -31,6 +31,11 @@ export interface CompileBaseOptions {
optimization?: NativeOptimization;
/** Remove symbol/debug payload from an executable at link time. */
strip?: boolean;
/** Link the C library and the other system libraries into the executable
* (`--static`). glibc executables link as static PIE and need the C
* library's static archives on the build host; musl executables are always
* static. Linux executables only. */
staticLink?: boolean;
/** Windows PE executable subsystem. Console is the default; GUI suppresses
* automatic console-window creation. Only valid for Windows executables. */
windowsSubsystem?: WindowsSubsystem;
@@ -148,6 +148,8 @@ export interface EarlyExecutableCacheOptions {
optimization?: "dev" | "speed";
/** Omitted retains the unstripped executable's historical cache key. */
strip?: true;
/** Omitted unless --static is set. */
staticLink?: true;
/** Omitted for the default Windows console subsystem. */
windowsSubsystem?: "gui";
/** Omitted unless --detached-exit is set. */
@@ -317,6 +319,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string {
options.backend,
...optimizationKeyParts(options.optimization),
...(options.strip ? ["strip"] : []),
...(options.staticLink ? ["static-link"] : []),
...(options.windowsSubsystem === "gui" ? ["windows-subsystem-gui"] : []),
...(options.detachedExit ? ["detached-exit"] : []),
...(options.largePages ? ["large-pages"] : []),
@@ -364,6 +367,7 @@ function routeKey(options: Omit<EarlyExecutableRouteOptions, "nativeEnvironment"
.update("\0");
for (const part of optimizationKeyParts(options.optimization)) hash.update(`${part}\0`);
if (options.strip) hash.update("strip\0");
if (options.staticLink) hash.update("static-link\0");
if (options.windowsSubsystem === "gui") hash.update("windows-subsystem-gui\0");
if (options.detachedExit) hash.update("detached-exit\0");
if (options.largePages) hash.update("large-pages\0");
+39 -1
View File
@@ -101,7 +101,11 @@ import {
nativeProgramPartitions,
} from "./backend/native-codegen-core.js";
import { privateSiblingPath } from "./backend/build-cache.js";
import { nativeCodegenTarget, nativeCodegenTargetRefusal } from "./backend/targets.js";
import {
nativeCodegenTarget,
nativeCodegenTargetRefusal,
staticLinkRefusal,
} from "./backend/targets.js";
import { windowsSubsystemLinkerArgs, type WindowsSubsystem } from "./backend/targets.js";
import { createNativeLinkInfo } from "./backend/native-link-info.js";
import { RuntimePackError } from "./backend/runtime-pack.js";
@@ -606,6 +610,7 @@ async function compileExecutableNative(
ffi: FfiProfile | null,
windowsSubsystem?: WindowsSubsystem,
strip?: boolean,
staticLink = false,
programSplit: ReturnType<typeof splitLlvmProgram> = null,
programObjectDependencies: readonly NativeArtifactDependency[] = [],
onArtifactReady?: NonNullable<Parameters<typeof compileExternalC>[0]["onArtifactReady"]>,
@@ -627,6 +632,7 @@ async function compileExecutableNative(
ffi,
optimization: features.optimization ?? "release",
...(strip ? { strip: true } : {}),
...(staticLink ? { staticLink: true } : {}),
...(windowsSubsystem === undefined ? {} : { windowsSubsystem }),
...(profileGenerate ? { profileGenerate: true } : {}),
programObjectDependencies,
@@ -652,6 +658,8 @@ async function compileExecutableNative(
: null);
if (programIsObject)
throw new InternalCompilerError("program objects must link against a precompiled runtime pack");
if (staticLink)
throw new InternalCompilerError("--static executables link against a precompiled runtime pack");
await compileExternalC({
...(llvmVisiblePath === undefined || llvmVisiblePath === llvmPath
? { cPath: llvmPath }
@@ -1633,6 +1641,19 @@ async function compileTracked(
sourceTexts: new Map(),
};
}
const staticRefusal = staticLinkRefusal({
staticLink: opts.staticLink,
outputKind,
sanitize: opts.sanitize ?? false,
target: nativeCodegenTarget(),
});
if (staticRefusal !== null) {
return {
ok: false,
diagnostics: [nativeCodegenDiag("SC3002", staticRefusal, entryPath)],
sourceTexts: new Map(),
};
}
try {
windowsSubsystemLinkerArgs(buildPlatform, opts.windowsSubsystem);
} catch (err) {
@@ -1687,6 +1708,7 @@ async function compileTracked(
backend: "llvm",
...optimizationField(opts.optimization),
...(opts.strip ? { strip: true as const } : {}),
...(opts.staticLink === true ? { staticLink: true as const } : {}),
...(opts.windowsSubsystem === "gui" ? { windowsSubsystem: "gui" as const } : {}),
...(opts.detachedExit === true ? { detachedExit: true as const } : {}),
...(opts.largePages === true ? { largePages: true as const } : {}),
@@ -1835,6 +1857,7 @@ async function compileTracked(
ffi,
opts.windowsSubsystem,
opts.strip,
opts.staticLink === true,
null,
nativeProgramObject?.dependencies,
opts.nativeProgramObject === true
@@ -1876,6 +1899,13 @@ async function compileTracked(
sourceTexts: new Map(),
};
}
if (err instanceof NativeCodegenError) {
return {
ok: false,
diagnostics: [nativeCodegenDiag(err.diagnosticCode, err.message, entryPath)],
sourceTexts: new Map(),
};
}
throw err;
} finally {
if (nativeProgramObject !== null) {
@@ -1974,6 +2004,7 @@ async function compileTracked(
ffi,
opts.windowsSubsystem,
opts.strip,
opts.staticLink === true,
programSplit,
nativeProgramObject?.dependencies,
opts.nativeProgramObject === true
@@ -2023,6 +2054,13 @@ async function compileTracked(
sourceTexts,
};
}
if (err instanceof NativeCodegenError) {
return {
ok: false,
diagnostics: [nativeCodegenDiag(err.diagnosticCode, err.message, entryPath)],
sourceTexts,
};
}
throw err;
} finally {
if (nativeProgramObject !== null) {
+36 -6
View File
@@ -76,6 +76,12 @@ import { inputsWithoutLld, lldLinkerArgs } from "../backend/lld.js";
import { openNativeExecutableCache } from "./executable-cache.js";
import { LinkerJobs } from "./linker-job.js";
import { nativeFileIdentity } from "./file-identity.js";
import {
staticExecutableRefusal,
staticLinkFailureNote,
staticLinkMapArgs,
staticLinkRefusal,
} from "../backend/targets.js";
/** The first partition keeps the whole-object key used before partitioning. */
function partitionCacheKey(key: string, index: number): string {
@@ -440,6 +446,13 @@ export class NativeCompiler {
platform: target.platform,
});
if (profileRefusal !== null) throw new NativeCodegenError("SC3002", profileRefusal);
const staticRefusal = staticLinkRefusal({
staticLink: options.staticLink,
outputKind,
sanitize: options.sanitize ?? false,
target,
});
if (staticRefusal !== null) throw new NativeCodegenError("SC3002", staticRefusal);
const profile = nativeProfile(options);
let ffi: FfiProfile | null = null;
if (options.ffiProfilePath !== undefined) {
@@ -607,6 +620,7 @@ export class NativeCompiler {
runtimeSystemLibraries: runtime.systemLibraries,
optimization,
strip: options.strip ?? false,
staticLink: options.staticLink ?? false,
...(options.windowsSubsystem === undefined
? {}
: { windowsSubsystem: options.windowsSubsystem }),
@@ -614,10 +628,13 @@ export class NativeCompiler {
});
// Linux links with lld when the driver finds it.
const lld = options.sanitize ? [] : lldLinkerArgs(target.platform, toolchain.linker);
const linkMap = join(stage, "link.map");
const mapArgs = staticLinkMapArgs(target, options.staticLink === true, linkMap);
const linkArgsWith = (withLld: boolean): string[] => [
...(options.sanitize ? ["-fsanitize=address"] : toolchain.linkerArgs),
...(withLld ? lld : []),
...plan.driverFlags,
...mapArgs,
...(withLld ? plan.inputs : inputsWithoutLld(target.platform, plan.inputs)),
...systemLibraryLinkerArgs(target, plan.systemLibraries),
"-o",
@@ -626,7 +643,10 @@ export class NativeCompiler {
const linkArgs = linkArgsWith(lld.length !== 0);
// Host Linux links with lld run the linker job the driver would.
const jobs = LinkerJobs.open(this.cache, toolchain, lld, options.sanitize ?? false);
const job = jobs?.link([...plan.inputs, stagedOutput], stage);
const job = jobs?.link(
[...plan.inputs, stagedOutput, ...(mapArgs.length === 0 ? [] : [linkMap])],
stage,
);
const cacheable = executableCache?.trace(linkArgs, stage, job) ?? false;
timing("link-inputs");
// A successful dependency trace already produced this executable.
@@ -634,16 +654,26 @@ export class NativeCompiler {
if (!cacheable && (job?.run(linkArgs, true) ?? null) === null) {
const driver = options.sanitize ? sanitizerDriver(toolchain) : toolchain.linker;
try {
runNativeTool(driver, linkArgs);
try {
runNativeTool(driver, linkArgs);
} catch (error) {
// A failed link with lld runs again with the driver's default
// linker, whose error (if any) is the one reported.
if (lld.length === 0) throw error;
runNativeTool(driver, linkArgsWith(false));
}
} catch (error) {
// A failed link with lld runs again with the driver's default
// linker, whose error (if any) is the one reported.
if (lld.length === 0) throw error;
runNativeTool(driver, linkArgsWith(false));
const note = staticLinkFailureNote(target, options.staticLink === true);
if (note === "") throw error;
throw new Error(`${error instanceof Error ? error.message : String(error)}${note}`);
}
}
timing("native-link");
requireNativeArtifact(stagedOutput);
if (mapArgs.length !== 0) {
const refusal = staticExecutableRefusal(target, true, readFileSync(linkMap, "utf8"));
if (refusal !== null) throw new NativeCodegenError("SC3002", refusal);
}
if (target.platform === "darwin" && optimization === "dev" && !options.strip) {
runNativeTool(toolchain.dsymutil, [stagedOutput, "-o", stagedOutput + ".dSYM"]);
}