Mark emitted functions sanitize_address only in sanitized builds

The sanitized lane's -fsanitize=address link needs the attribute to
instrument the emitted functions. Without a sanitizer pass it is not
inert: LLVM does not shrink-wrap functions that carry it, so every call
saved and restored all of a function's callee-saved registers on entry,
including on paths that return before any call, and it blocks
speculative loads. The LLVM helper already dropped it for builds that
import runtime bitcode (speed); release builds kept it.

- The new sanitizeAttributes option (default true for direct emission)
  is set from --sanitize wherever objectAudit is.
- Without it and without a target CPU level, attribute group #0 holds a
  string attribute LLVM ignores, since a group cannot be empty.
This commit is contained in:
Chris Tate
2026-10-25 15:10:32 -05:00
parent 8d5c85448f
commit aead30fc26
8 changed files with 45 additions and 13 deletions
+3 -1
View File
@@ -50,9 +50,11 @@ test.runIf(process.platform === "linux" && process.arch === "x64")(
const expected = `${(await execFileAsync(process.execPath, [entry])).stdout}`;
const baseline = await build([]);
expect(baseline).not.toContain('"target-cpu"');
// Only --sanitize builds mark the functions for ASan instrumentation.
expect(baseline).not.toContain("sanitize_address");
expect(await build(["--target-cpu=baseline"])).toBe(baseline);
const v3 = await build(["--target-cpu=x86-64-v3"]);
expect(v3).toMatch(/^attributes #0 = \{ sanitize_address "target-cpu"="x86-64-v3" \}$/m);
expect(v3).toMatch(/^attributes #0 = \{ "target-cpu"="x86-64-v3" \}$/m);
if (runsV3) expect((await execFileAsync(outPath)).stdout).toBe(expected);
// The bootstrap's cached route builds without a level.
expect(await build([], true)).toBe(baseline);
@@ -196,15 +196,17 @@ function readRange(dir: string, range: number): { result: ChunkResult; texts: st
/** The attribute groups of the emitted functions: #0 for every function,
* #1 for coroutine bodies and #2 for out-of-line record clones. Each carries
* the build's `--target-cpu` level. */
* the build's `--target-cpu` level, and `sanitize_address` in sanitized
* builds. A group needs one attribute: without either, #0 holds a string
* attribute that LLVM ignores. */
function functionAttributes(emitter: LlEmitter, hasNoInlineRecordClone: boolean): string[] {
const cpu = targetCpuAttribute(emitter.targetCpu);
const asan = emitter.sanitizeAttributes ? " sanitize_address" : "";
const base = `${asan}${cpu}` || ` "scriptc-function"`;
return [
`attributes #0 = { sanitize_address${cpu} }`,
...(emitter.usesCoroutines
? [`attributes #1 = { sanitize_address presplitcoroutine${cpu} }`]
: []),
...(hasNoInlineRecordClone ? [`attributes #2 = { noinline sanitize_address${cpu} }`] : []),
`attributes #0 = {${base} }`,
...(emitter.usesCoroutines ? [`attributes #1 = {${asan} presplitcoroutine${cpu} }`] : []),
...(hasNoInlineRecordClone ? [`attributes #2 = { noinline${asan}${cpu} }`] : []),
];
}
@@ -1179,9 +1181,9 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
);
for (const line of emitter.inlineRcTail(flushedDecls)) out.push(line);
out.push(
// sanitize_address is inert under the plain pipeline; the sanitized
// lane's -fsanitize=address link activates instrumentation over the
// emitted functions too (the runtime TUs get theirs from clang).
// The sanitized lane's -fsanitize=address link instruments the
// functions marked sanitize_address (the runtime TUs get theirs from
// clang).
...functionAttributes(emitter, hasNoInlineRecordClone),
``,
);
@@ -211,6 +211,19 @@ test("a target CPU level reaches every function attribute group and nothing else
expect(emitLlvmModule(module, { targetCpu: "baseline" })).toBe(plain);
});
test("only sanitized builds mark the emitted functions sanitize_address", () => {
const module = moduleFor([call()]);
const sanitized = emitLlvmModule(module);
expect(emitLlvmModule(module, { sanitizeAttributes: true })).toBe(sanitized);
expect(sanitized).toMatch(/^attributes #0 = \{ sanitize_address \}$/m);
const plain = emitLlvmModule(module, { sanitizeAttributes: false });
expect(plain).not.toContain("sanitize_address");
// A group needs an attribute: an ignored string attribute stands in.
expect(plain).toMatch(/^attributes #0 = \{ "scriptc-function" \}$/m);
const v3 = emitLlvmModule(module, { sanitizeAttributes: false, targetCpu: "x86-64-v3" });
expect(v3).toMatch(/^attributes #0 = \{ "target-cpu"="x86-64-v3" \}$/m);
});
test("inline RC inlines every fast path at speed and keeps box releases as calls in release", () => {
for (const boxed of [false, true]) {
const module = moduleFor(Array.from({ length: 4 }, call), { boxed });
@@ -511,6 +511,12 @@ export interface LlvmTargetOptions {
inlineRc?: InlineRcMode;
/** Define the inline RC fast-path helpers `noinline` (sanitized builds). */
rcHelpersNoinline?: boolean;
/** Mark every emitted function `sanitize_address`, which the sanitized
* lane's `-fsanitize=address` link needs to instrument it. Without a
* sanitizer pass the attribute still turns off shrink-wrapping and
* speculative loads, so plain builds pass false. Defaults to true for
* direct emission. */
sanitizeAttributes?: boolean;
/** Define `scr_detached_exit` so the Linux runtime hands the address-space
* teardown after `process.exit` to a helper process (`--detached-exit`).
* Executables only; the runtime's weak default keeps it off. */
@@ -1269,6 +1275,8 @@ export class LlEmitter {
readonly runtimeAbiMarker: boolean;
readonly detachedExit: boolean;
readonly targetCpu: TargetCpu | undefined;
/** Emitted functions carry `sanitize_address` (LlvmTargetOptions). */
readonly sanitizeAttributes: boolean;
readonly wasmBoundsChecks: boolean;
/** Interned string literals: UTF-8 text → { symbol, byte length } —
* first-use order, the runtime ABI’s determinism discipline. */
@@ -1971,6 +1979,7 @@ export class LlEmitter {
this.runtimeAbiMarker = options.runtimeAbiMarker === true;
this.detachedExit = options.detachedExit === true;
this.targetCpu = options.targetCpu;
this.sanitizeAttributes = options.sanitizeAttributes !== false;
this.wasmBoundsChecks =
options.wasmBoundsChecks === true ||
wasmBoundsChecks({
+3 -2
View File
@@ -216,8 +216,9 @@ export interface RegionHooks {
classDefinitions(cls: IrClassDef, sizeOf: string): string[];
}
/** Every emitted function/helper carries #0 = { sanitize_address } — see
* the emitter header. */
/** Every emitted function/helper carries attribute group #0: the build's
* target CPU level, and `sanitize_address` in sanitized builds (see
* emit-module.ts functionAttributes). */
export const FN_ATTRS = "#0";
export { computeTraced } from "../cycle-analysis.js";
+3
View File
@@ -854,6 +854,7 @@ async function prepareExecutableInput(
// the pending flag, since their link may not add an unwinder.
unwindAbi: buildPlatform === "linux" && outputKind !== "obj",
objectAudit: opts.sanitize === true,
sanitizeAttributes: opts.sanitize === true,
lowRegion: lowRegionTarget(buildPlatform, opts.sanitize === true),
...(opts.sanitize === true ? { wasmBoundsChecks: true } : {}),
...inlineRcOption(opts.optimization, opts.sanitize === true),
@@ -925,6 +926,7 @@ async function prepareExecutableInput(
runtimeAbiMarker: useRuntimePack,
unwindAbi: buildPlatform === "linux",
objectAudit: opts.sanitize === true,
sanitizeAttributes: opts.sanitize === true,
lowRegion: lowRegionTarget(buildPlatform, opts.sanitize === true),
...(opts.sanitize === true ? { wasmBoundsChecks: true } : {}),
...inlineRcOption(opts.optimization, opts.sanitize === true),
@@ -2357,6 +2359,7 @@ async function compileLibraryTracked(
pointerBits: buildPlatform === "wasi" ? 32 : 64,
wasi: buildPlatform === "wasi",
objectAudit: opts.sanitize === true,
sanitizeAttributes: opts.sanitize === true,
...inlineRcOption(profile.optimization, opts.sanitize === true),
});
timing("llvm-emit", {
+2
View File
@@ -431,6 +431,7 @@ export function prepareNativeExecutable(
(outputKind === "exe" || outputKind === "llvm" || outputKind === "asm") &&
toolchain.target.platform === "linux",
objectAudit: options.sanitize === true,
sanitizeAttributes: options.sanitize === true,
lowRegion: lowRegionTarget(toolchain.target.platform, options.sanitize === true),
functionWorkers: emitWorkers(),
forkPrimitives: emitForkPrimitives(),
@@ -545,6 +546,7 @@ export function prepareNativeLibrary(
pointerBits: toolchain.target.pointerBits,
wasi: toolchain.target.platform === "wasi",
objectAudit: options.sanitize === true,
sanitizeAttributes: options.sanitize === true,
...inlineRcOption(profile.optimization, options.sanitize === true),
});
timing("llvm-emit");
@@ -50,7 +50,7 @@ console.log(sum);
expect(definition(llvm, "sc_f_range")).not.toContain("@scr_coro_yield_f64(");
expect(llvm).not.toContain("@scr_gen_take_out_f64(");
expect(llvm).toContain("%ScrGenHead = type { i64, ptr, ptr, i32, %ScrGenSlot }");
expect(llvm).toContain("attributes #1 = { sanitize_address presplitcoroutine }");
expect(llvm).toContain("attributes #1 = { presplitcoroutine }");
expect(definition(llvm, "sc_gs_range")).toContain("call ptr @scr_gen_new_stackless(");
expect(llvm).not.toContain("@scr_gen_yield_f64(");
});