diff --git a/Cargo.toml b/Cargo.toml index 1eab353fb52f..37d6cf1071d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -201,11 +201,11 @@ overflow-checks = false # back to "warn" (their priority 0 beats the group's -1) where a warning level # is intentional. warnings = { level = "deny", priority = -1 } -# `bun_asan` / `bun_debug` / `socket_fault_injection` are set via RUSTFLAGS -# (`--cfg=...` + `--check-cfg=cfg(...)`) by scripts/build/rust.ts; register -# them here so a plain `cargo build` / `cargo check` (without those flags) -# doesn't warn. -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(bun_asan)', 'cfg(bun_debug)', 'cfg(socket_fault_injection)'] } +# `bun_asan` / `bun_debug` / `bun_tinycc` / `socket_fault_injection` are set +# via RUSTFLAGS (`--cfg=...` + `--check-cfg=cfg(...)`) by scripts/build/rust.ts; +# register them here so a plain `cargo build` / `cargo check` (without those +# flags) doesn't warn. +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(bun_asan)', 'cfg(bun_debug)', 'cfg(bun_tinycc)', 'cfg(socket_fault_injection)'] } # link.exe unconditionally prints "Creating library X.dll.lib and object # X.dll.exp" to stdout when linking each proc-macro DLL on Windows hosts; # there is no linker flag to suppress it. The lint already exempts itself diff --git a/scripts/build/buildOptionsRs.ts b/scripts/build/buildOptionsRs.ts index 0ea2c440a5be..5fa686029555 100644 --- a/scripts/build/buildOptionsRs.ts +++ b/scripts/build/buildOptionsRs.ts @@ -12,10 +12,12 @@ * keeps the mtime stable so a reconfigure with the same sha doesn't * recompile `bun_core` and its dependents. * - * Target-dependent constants (`ENABLE_TINYCC`, `ENABLE_ASAN`, `ENABLE_LOGS`) - * stay as `cfg!()` expressions inside the generated file rather than literals - * so a `cargo check --target ` against the same generated file - * still evaluates them per-target. + * `ENABLE_LOGS` / `ENABLE_ASAN` / `ENABLE_TINYCC` are emitted as `cfg!()` of + * the `--cfg` that `rust.ts` derives from the same `Config` field, not as + * literals: each pairs with `#[cfg]`-gated code (the ASAN allocator, the + * `tcc_*` externs in `bun_tcc_sys`) that can only key on the cfg, so reading + * the cfg keeps constant and code in agreement in every invocation, including + * a bare `cargo check` that reads this file with no RUSTFLAGS at all. * * Written at configure time alongside `depVersionsHeader.ts` / * `cargo-config.ts` — it's a constant manifest, not a build edge. @@ -59,17 +61,14 @@ export function generateBuildOptionsRs(cfg: Config): string { `pub const BASE_PATH: &[u8] = ${rbstr(cfg.cwd)};`, `pub const CODEGEN_PATH: &[u8] = ${rbstr(cfg.codegenDir)};`, "", - "// Target/profile-derived — kept as `cfg!()` so cross-target", - "// `cargo check` evaluates per-triple. Values agree with `Config`:", - "// rust.ts sets `--cfg=bun_debug` ⇔ `cfg.debug`, `--cfg=bun_asan` ⇔", - "// `cfg.asan`, and `cfg.tinycc`'s default (config.ts) is the negation", - "// of this predicate.", + "// Each of these reads the `--cfg` that scripts/build/rust.ts", + "// (cargoBuildInvocation) sets from the matching `Config` field, so it", + "// agrees with the `#[cfg]`-gated code it pairs with; rust.ts sets", + "// `--cfg=bun_debug` ⇔ `cfg.debug`, `--cfg=bun_asan` ⇔ `cfg.asan`,", + "// `--cfg=bun_tinycc` ⇔ `cfg.tinycc`. All false under bare `cargo check`.", "pub const ENABLE_LOGS: bool = cfg!(bun_debug);", "pub const ENABLE_ASAN: bool = cfg!(bun_asan);", - "pub const ENABLE_TINYCC: bool = !cfg!(any(", - ` target_os = "android",`, - ` target_os = "freebsd",`, - "));", + "pub const ENABLE_TINYCC: bool = cfg!(bun_tinycc);", "", ]; diff --git a/scripts/build/rust.ts b/scripts/build/rust.ts index 8107ad99d9b3..717690a05c13 100644 --- a/scripts/build/rust.ts +++ b/scripts/build/rust.ts @@ -536,6 +536,17 @@ export function cargoBuildInvocation(cfg: Config): CargoInvocation { if (cfg.socketFaultInjection) { rustflags.push("--cfg=socket_fault_injection"); } + // `bun_tinycc`: libtcc is in the link (deps/tinycc.ts is `enabled` by the + // same `cfg.tinycc`). Same contract as above: `bun_tcc_sys` declares the + // `tcc_*` externs under this cfg and defines link-satisfying stubs without + // it, and `build_options::ENABLE_TINYCC` (the runtime gate that makes + // bun:ffi's cc() throw "not available in this build") is `cfg!(bun_tinycc)`, + // so the option reaches every Rust consumer through this one flag. Bare + // `cargo check` / clippy / miri (no rustflags) get the stubs. + rustflags.push("--check-cfg=cfg(bun_tinycc)"); + if (cfg.tinycc) { + rustflags.push("--cfg=bun_tinycc"); + } // Drop `#[track_caller]` source-location capture in release. Every // `Option::unwrap`/`slice[i]`/`RefCell::borrow` etc. otherwise emits a // `&'static core::panic::Location` (file/line/col) plus the file-path string diff --git a/src/bun_core/env.rs b/src/bun_core/env.rs index 9f4c52ef8c2f..ab47248dfe0a 100644 --- a/src/bun_core/env.rs +++ b/src/bun_core/env.rs @@ -68,6 +68,10 @@ pub const BASE_PATH: &[u8] = build_options::BASE_PATH; pub const ENABLE_LOGS: bool = build_options::ENABLE_LOGS; pub const ENABLE_ASAN: bool = build_options::ENABLE_ASAN; pub const ENABLE_FUZZILLI: bool = build_options::ENABLE_FUZZILLI; +/// Whether libtcc is linked into this build: `cfg!(bun_tinycc)`, which +/// `scripts/build/rust.ts` sets from the build's `tinycc` option (off by default +/// on Android and FreeBSD). `bun_tcc_sys` swaps its `tcc_*` externs for stubs on +/// the same cfg, so bun:ffi's cc() must early-return while this is `false`. pub const ENABLE_TINYCC: bool = build_options::ENABLE_TINYCC; // TYPE_ONLY: bun_semver::Version moves to bun_core (move-in pass). diff --git a/src/tcc_sys/tcc.rs b/src/tcc_sys/tcc.rs index 9e2d2841f141..154795ab006f 100644 --- a/src/tcc_sys/tcc.rs +++ b/src/tcc_sys/tcc.rs @@ -11,34 +11,31 @@ pub type TCCErrorFunc = Option = unsafe extern "C" fn(ctx: *mut Ctx, msg: *const c_char); -// `libtcc.a` is only built where `cfg.tinycc` is true (`scripts/build/config.ts`): -// not Android, not FreeBSD (the vendored fork doesn't support those targets). -// On those platforms these `extern "C"` decls would be undefined at link: -// `bun_runtime::ffi::ffi_body::{Source::add, -// CompileC::compile}` are reachable from `extern "C"` JS bindings and the -// monomorphized refs land in `libbun_rust.a` regardless of any -// `if !ENABLE_TINYCC { return }` runtime guard. Swap the `extern` block for -// stub *definitions* on those targets so the link resolves; the gated Rust -// callers never reach them at runtime (they early-return with "not available -// in this build"), and the `unreachable!()` makes any future gate regression -// loud rather than silently UB. -// -// Keep this predicate in sync with `cfg.tinycc` in `scripts/build/config.ts` -// and `ENABLE_TINYCC` in `scripts/build/buildOptionsRs.ts`. +// `cfg(bun_tinycc)` is set by `scripts/build/rust.ts` exactly when the build +// links libtcc (`cfg.tinycc`: the `--tinycc` option, off by default on Android +// and FreeBSD, which the vendored fork doesn't support). Without libtcc these +// `extern "C"` decls would be undefined at link: `bun_runtime::ffi::ffi_body:: +// {Source::add, CompileC::compile}` are reachable from `extern "C"` JS +// bindings and the monomorphized refs land in `libbun_rust.a` regardless of +// any `if !ENABLE_TINYCC { return }` runtime guard. So a build without libtcc +// gets stub *definitions* instead; the gated Rust callers never reach them at +// runtime (`ENABLE_TINYCC` is `cfg!(bun_tinycc)`, so they early-return with +// "not available in this build"), and the `unreachable!()` makes any future +// gate regression loud rather than silently UB. macro_rules! tcc_externs { ($($(#[$attr:meta])* fn $name:ident($($arg:ident: $ty:ty),* $(,)?) $(-> $ret:ty)?;)*) => { - #[cfg(not(any(target_os = "android", target_os = "freebsd")))] + #[cfg(bun_tinycc)] unsafe extern "C" { $($(#[$attr])* fn $name($($arg: $ty),*) $(-> $ret)?;)* } $( - #[cfg(any(target_os = "android", target_os = "freebsd"))] + #[cfg(not(bun_tinycc))] #[allow(unused_variables, clippy::missing_safety_doc)] unsafe extern "C" fn $name($($arg: $ty),*) $(-> $ret)? { unreachable!(concat!( stringify!($name), - " called but TinyCC is disabled on this target — keep the ", - "ENABLE_TINYCC early-returns in bun_runtime::ffi in sync with this stub" + " called but this build has no TinyCC; keep the ENABLE_TINYCC ", + "early-returns in bun_runtime::ffi in sync with this stub" )); } )* diff --git a/test/internal/source-lints/build-rust.test.ts b/test/internal/source-lints/build-rust.test.ts index 0633be79bdc4..c2cded06484b 100644 --- a/test/internal/source-lints/build-rust.test.ts +++ b/test/internal/source-lints/build-rust.test.ts @@ -10,11 +10,16 @@ * import), so it is read as text. * - The rustflags put the Rust half of the binary on the CPU baseline the C++ * half is compiled for (`cpuTargetFlags` in scripts/build/flags.ts). + * - The `tinycc` option decides whether libtcc is linked, so the Rust side + * (the `tcc_*` externs and the `ENABLE_TINYCC` runtime gate) has to follow + * the same option, through a cfg the rustflags carry. */ import { describe, expect, test } from "bun:test"; +import { tempDir } from "harness"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { generateBuildOptionsRs } from "../../../scripts/build/buildOptionsRs.ts"; import { resolveConfig, type Abi, @@ -180,3 +185,114 @@ describe("CPU baseline", () => { expect(cpuFlags(withAbi(linuxX64, "android"))).toEqual(["-Ctarget-cpu=nehalem"]); }); }); + +describe("tinycc option", () => { + // `cfg.tinycc` (`--tinycc=on|off`; config.ts defaults it off on Android and + // FreeBSD) is what puts libtcc into the link (scripts/build/deps/tinycc.ts). + // Two things on the Rust side have to agree with it: src/tcc_sys/tcc.rs + // declares the `tcc_*` symbols as externs (undefined at link time when + // libtcc isn't built) or defines stubs for them, and + // `bun_core::Environment::ENABLE_TINYCC` is the runtime gate behind + // bun:ffi's "cc() is not available in this build". `#[cfg]` can't read a + // const, so the option reaches Rust as a cfg in the rustflags and both of + // them key on it. These tests pin that chain; each link of it used to carry + // its own copy of the platform list instead, which left `--tinycc=off` + // (or any change to the list in config.ts) unknown to the Rust half of the + // build. + + const linux: PartialConfig = { os: "linux", arch: "x64", abi: "gnu", linuxSysroot: "/fake" }; + + /** Resolve a config whose generated files land in `scratch`. */ + function configure(partial: PartialConfig, scratch: string): Config { + return resolve({ buildDir: scratch, ...partial }); + } + + function rustflags(cfg: Config): string[] { + return cargoBuildInvocation(cfg).env.CARGO_ENCODED_RUSTFLAGS!.split("\x1f"); + } + + /** The cfg that `ENABLE_TINYCC` reads in the build_options.rs generated for `cfg`. */ + function enableTinyccCfg(cfg: Config): string { + const source = readFileSync(generateBuildOptionsRs(cfg), "utf8"); + const match = /^pub const ENABLE_TINYCC: bool = cfg!\((\w+)\);$/m.exec(source); + if (!match) { + const actual = /^pub const ENABLE_TINYCC: bool = .*$/m.exec(source)?.[0] ?? "(no ENABLE_TINYCC constant)"; + throw new Error(`ENABLE_TINYCC in build_options.rs must be one cfg!() that rust.ts sets, got: ${actual}`); + } + return match[1]!; + } + + /** The cfg's name, as a default linux configure spells it. */ + function cfgName(): string { + using scratch = tempDir("build-rust-tinycc", {}); + return enableTinyccCfg(configure(linux, String(scratch))); + } + + /** What `ENABLE_TINYCC` evaluates to in the cargo build rust.ts emits for `cfg`. */ + function rustEnableTinycc(cfg: Config): boolean { + const name = enableTinyccCfg(cfg); + const flags = rustflags(cfg); + // Declared whether or not it is set: rustc's unexpected_cfgs lint fires + // on the builds that leave an undeclared cfg unset. + expect(flags).toContain(`--check-cfg=cfg(${name})`); + return flags.includes(`--cfg=${name}`); + } + + const cases: { name: string; partial: PartialConfig; tinycc: boolean }[] = [ + { name: "linux", partial: linux, tinycc: true }, + { name: "linux --tinycc=off", partial: { ...linux, tinycc: false }, tinycc: false }, + { name: "debug --tinycc=off", partial: { ...linux, buildType: "Debug", tinycc: false }, tinycc: false }, + { name: "windows", partial: { os: "windows", arch: "x64", winsysroot: "/fake" }, tinycc: true }, + // config.ts's platform exclusions arrive through the option like everything else. + { name: "freebsd", partial: { os: "freebsd", arch: "x64", freebsdSysroot: "/fake" }, tinycc: false }, + { + name: "freebsd --tinycc=on", + partial: { os: "freebsd", arch: "x64", freebsdSysroot: "/fake", tinycc: true }, + tinycc: true, + }, + ]; + + for (const { name, partial, tinycc } of cases) { + test(`${name}: the Rust build sees tinycc=${tinycc}`, () => { + using scratch = tempDir("build-rust-tinycc", {}); + const cfg = configure(partial, String(scratch)); + // The option itself resolves as documented; under test is whether the + // Rust build gets the value the dep graph acts on. + expect(cfg.tinycc).toBe(tinycc); + expect(rustEnableTinycc(cfg)).toBe(tinycc); + }); + } + + test("the cfg is registered in Cargo.toml for bare cargo", () => { + // `cargo check` / clippy / miri run without rust.ts's rustflags, so the + // `--check-cfg` there doesn't reach them. + const unexpectedCfgs = /^unexpected_cfgs\s*=.*$/m.exec(readFileSync(join(repoRoot, "Cargo.toml"), "utf8"))?.[0]; + expect(unexpectedCfgs).toContain(`'cfg(${cfgName()})'`); + }); + + test("bun_tcc_sys declares the tcc_* externs under the cfg and stubs them without it", () => { + const name = cfgName(); + const source = readFileSync(join(repoRoot, "src", "tcc_sys", "tcc.rs"), "utf8").replace(/\/\/[^\n]*/g, ""); + const macro = /^macro_rules! tcc_externs \{\n([\s\S]*?)^\}/m.exec(source); + if (!macro) throw new Error("macro_rules! tcc_externs not found in src/tcc_sys/tcc.rs"); + const body = macro[1]!; + + expect(body).toMatch(new RegExp(String.raw`#\[cfg\(${name}\)\]\s*unsafe extern "C" \{`)); + expect(body).toMatch(new RegExp(String.raw`#\[cfg\(not\(${name}\)\)\][^{]*\bunsafe extern "C" fn `)); + // A platform predicate here would be a second copy of the list in + // config.ts, which is exactly what the cfg replaces. + expect(body).not.toMatch(/\btarget_os\b|\btarget_arch\b|\btarget_env\b/); + + // Every tcc_* symbol the link has to satisfy goes through that macro: a + // `fn tcc_*` declared anywhere else would be outside the cfg. + const strays: string[] = []; + for (const rel of new Bun.Glob("src/**/*.rs").scanSync({ cwd: repoRoot })) { + const file = rel.replaceAll("\\", "/"); + if (file === "src/tcc_sys/tcc.rs") continue; + // Searched as bytes: decoding ~1500 files to strings is what makes this + // slow under an ASAN build. + if (readFileSync(join(repoRoot, rel)).includes("fn tcc_")) strays.push(file); + } + expect(strays).toEqual([]); + }); +});