From aead30fc2635a67d62764fbd1a61147cec16e592 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 15:10:32 -0500 Subject: [PATCH] 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. --- packages/cli/test/target-cpu.test.ts | 4 +++- .../compiler/src/backend/llvm/emit-module.ts | 20 ++++++++++--------- .../compiler/src/backend/llvm/emitter.test.ts | 13 ++++++++++++ packages/compiler/src/backend/llvm/emitter.ts | 9 +++++++++ packages/compiler/src/backend/llvm/shapes.ts | 5 +++-- packages/compiler/src/index.ts | 3 +++ packages/compiler/src/native/prepare.ts | 2 ++ .../compiler/test/generator-emission.test.ts | 2 +- 8 files changed, 45 insertions(+), 13 deletions(-) diff --git a/packages/cli/test/target-cpu.test.ts b/packages/cli/test/target-cpu.test.ts index ca5243564..5bc6717c3 100644 --- a/packages/cli/test/target-cpu.test.ts +++ b/packages/cli/test/target-cpu.test.ts @@ -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); diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index af00ebc2c..8145429f1 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -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), ``, ); diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index 876476cc1..880685bf2 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -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 }); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 5569599ee..f1f1fa7d6 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -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({ diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 7498235b0..6d03db222 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -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"; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index ffd56a1bf..11b0f6d13 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -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", { diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 11b8f9dd1..749027b80 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -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"); diff --git a/packages/compiler/test/generator-emission.test.ts b/packages/compiler/test/generator-emission.test.ts index 8c43925ea..47e5f16c5 100644 --- a/packages/compiler/test/generator-emission.test.ts +++ b/packages/compiler/test/generator-emission.test.ts @@ -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("); });