mach: Add TSAN support (#37195)

Add ThreadSanitizer support to mach (`--with-tsan`).

This refactors the current infrastructure to support `--with-asan` to a
more generic `Santiizer`.
If the need for adding multiple sanitizers at once (e.g. address + leak
sanitizer) arises, we can easily change the `SanitizerKind` enum to a
`enum.Flag` instead.

Testing: Manually run `./mach build --with-tsan` and `./mach run
--with-tsan` (on macos) and observe TSAN reporting data races.

Closes: #14869

---------

Signed-off-by: Jonathan Schwender <schwenderjonathan@gmail.com>
This commit is contained in:
Jonathan Schwender 2025-06-13 14:26:57 +02:00 committed by GitHub
parent b8738074d1
commit 6ad6542aac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 173 additions and 82 deletions

View file

@ -17,7 +17,7 @@ import subprocess
import sys import sys
from time import time from time import time
from typing import Optional, List from typing import Optional, List, Dict
import notifypy import notifypy
@ -37,6 +37,8 @@ from servo.command_base import BuildType, CommandBase, call, check_call
from servo.gstreamer import windows_dlls, windows_plugins, package_gstreamer_dylibs from servo.gstreamer import windows_dlls, windows_plugins, package_gstreamer_dylibs
from servo.platform.build_target import BuildTarget from servo.platform.build_target import BuildTarget
from python.servo.platform.build_target import SanitizerKind
SUPPORTED_ASAN_TARGETS = [ SUPPORTED_ASAN_TARGETS = [
"aarch64-apple-darwin", "aarch64-apple-darwin",
"aarch64-unknown-linux-gnu", "aarch64-unknown-linux-gnu",
@ -45,6 +47,13 @@ SUPPORTED_ASAN_TARGETS = [
"x86_64-unknown-linux-gnu", "x86_64-unknown-linux-gnu",
] ]
SUPPORTED_TSAN_TARGETS = [
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
]
def get_rustc_llvm_version() -> Optional[List[int]]: def get_rustc_llvm_version() -> Optional[List[int]]:
"""Determine the LLVM version of `rustc` and return it as a List[major, minor, patch, ...] """Determine the LLVM version of `rustc` and return it as a List[major, minor, patch, ...]
@ -91,7 +100,7 @@ class MachCommands(CommandBase):
no_package=False, no_package=False,
verbose=False, verbose=False,
very_verbose=False, very_verbose=False,
with_asan=False, sanitizer: SanitizerKind = SanitizerKind.NONE,
flavor=None, flavor=None,
**kwargs, **kwargs,
): ):
@ -110,8 +119,7 @@ class MachCommands(CommandBase):
opts += ["-v"] opts += ["-v"]
if very_verbose: if very_verbose:
opts += ["-vv"] opts += ["-vv"]
if with_asan: self.config["build"]["sanitizer"] = sanitizer
self.config["build"]["with_asan"] = True
env = self.build_env() env = self.build_env()
self.ensure_bootstrapped() self.ensure_bootstrapped()
@ -120,58 +128,8 @@ class MachCommands(CommandBase):
host = servo.platform.host_triple() host = servo.platform.host_triple()
target_triple = self.target.triple() target_triple = self.target.triple()
if with_asan: if sanitizer.is_some():
if target_triple not in SUPPORTED_ASAN_TARGETS: self.build_sanitizer_env(env, opts, kwargs, target_triple, sanitizer)
print(
"AddressSanitizer is currently not supported on this platform\n",
"See https://doc.rust-lang.org/beta/unstable-book/compiler-flags/sanitizer.html",
)
sys.exit(1)
# do not use crown (clashes with different rust version)
env["RUSTC"] = "rustc"
# Enable usage of unstable rust flags
env["RUSTC_BOOTSTRAP"] = "1"
# Enable asan
env["RUSTFLAGS"] = env.get("RUSTFLAGS", "") + " -Zsanitizer=address"
opts += ["-Zbuild-std"]
kwargs["target_override"] = target_triple
# With asan we also want frame pointers
if "force-frame-pointers" not in env["RUSTFLAGS"]:
env["RUSTFLAGS"] += " -C force-frame-pointers=yes"
# Note: We want to use the same clang/LLVM version as rustc.
rustc_llvm_version = get_rustc_llvm_version()
if rustc_llvm_version is None:
raise RuntimeError("Unable to determine necessary clang version for ASAN support")
llvm_major: int = rustc_llvm_version[0]
target_clang = f"clang-{llvm_major}"
target_cxx = f"clang++-{llvm_major}"
if shutil.which(target_clang) is None or shutil.which(target_cxx) is None:
env.setdefault("TARGET_CC", target_clang)
env.setdefault("TARGET_CXX", target_cxx)
else:
# libasan can be compatible across multiple compiler versions and has a
# runtime check, which would fail if we used incompatible compilers, so
# we can try and fallback to the default clang.
env.setdefault("TARGET_CC", "clang")
env.setdefault("TARGET_CXX", "clang++")
# We need to use `TARGET_CFLAGS`, since we don't want to compile host dependencies with ASAN,
# since that causes issues when building build-scripts / proc macros.
env.setdefault("TARGET_CFLAGS", "")
env.setdefault("TARGET_CXXFLAGS", "")
env["TARGET_CFLAGS"] += " -fsanitize=address"
env["TARGET_CXXFLAGS"] += " -fsanitize=address"
# By default build mozjs from source to enable ASAN with mozjs.
env.setdefault("MOZJS_FROM_SOURCE", "1")
# asan replaces system allocator with asan allocator
# we need to make sure that we do not replace it with jemalloc
self.features.append("servo_allocator/use-system-allocator")
build_start = time() build_start = time()
@ -196,11 +154,11 @@ class MachCommands(CommandBase):
status = self.run_cargo_build_like_command("rustc", opts, env=env, verbose=verbose, **kwargs) status = self.run_cargo_build_like_command("rustc", opts, env=env, verbose=verbose, **kwargs)
if status == 0: if status == 0:
built_binary = self.get_binary_path(build_type, asan=with_asan) built_binary = self.get_binary_path(build_type, sanitizer=sanitizer)
if not no_package and self.target.needs_packaging(): if not no_package and self.target.needs_packaging():
rv = Registrar.dispatch( rv = Registrar.dispatch(
"package", context=self.context, build_type=build_type, flavor=flavor, with_asan=with_asan "package", context=self.context, build_type=build_type, flavor=flavor, sanitizer=sanitizer
) )
if rv: if rv:
return rv return rv
@ -258,6 +216,76 @@ class MachCommands(CommandBase):
opts += params opts += params
return check_call(["cargo", "clean"] + opts, env=self.build_env(), verbose=verbose) return check_call(["cargo", "clean"] + opts, env=self.build_env(), verbose=verbose)
def build_sanitizer_env(
self, env: Dict, opts: List[str], kwargs, target_triple, sanitizer: SanitizerKind = SanitizerKind.NONE
):
if sanitizer.is_none():
return
# do not use crown (clashes with different rust version)
env["RUSTC"] = "rustc"
# Enable usage of unstable rust flags
env["RUSTC_BOOTSTRAP"] = "1"
# std library should also be instrumented
opts += ["-Zbuild-std"]
# We need to always set the target triple, even when building for host.
kwargs["target_override"] = target_triple
# When sanitizers are used we also want framepointers to help with backtraces.
if "force-frame-pointers" not in env["RUSTFLAGS"]:
env["RUSTFLAGS"] += " -C force-frame-pointers=yes"
# Note: We want to use the same clang/LLVM version as rustc.
rustc_llvm_version = get_rustc_llvm_version()
if rustc_llvm_version is None:
raise RuntimeError("Unable to determine necessary clang version for Sanitizer support")
llvm_major: int = rustc_llvm_version[0]
target_clang = f"clang-{llvm_major}"
target_cxx = f"clang++-{llvm_major}"
if shutil.which(target_clang) is None or shutil.which(target_cxx) is None:
env.setdefault("TARGET_CC", "clang")
env.setdefault("TARGET_CXX", "clang++")
else:
# libasan can be compatible across multiple compiler versions and has a
# runtime check, which would fail if we used incompatible compilers, so
# we can try and fallback to the default clang.
env.setdefault("TARGET_CC", target_clang)
env.setdefault("TARGET_CXX", target_cxx)
# By default, build mozjs from source to enable Sanitizers in mozjs.
env.setdefault("MOZJS_FROM_SOURCE", "1")
# We need to use `TARGET_CFLAGS`, since we don't want to compile host dependencies with ASAN,
# since that causes issues when building build-scripts / proc macros.
# The actual flags will be appended below depending on the sanitizer kind.
env.setdefault("TARGET_CFLAGS", "")
env.setdefault("TARGET_CXXFLAGS", "")
env.setdefault("RUSTFLAGS", "")
if sanitizer.is_asan():
if target_triple not in SUPPORTED_ASAN_TARGETS:
print(
"AddressSanitizer is currently not supported on this platform\n",
"See https://doc.rust-lang.org/beta/unstable-book/compiler-flags/sanitizer.html",
)
sys.exit(1)
# Enable asan
env["RUSTFLAGS"] += " -Zsanitizer=address"
env["TARGET_CFLAGS"] += " -fsanitize=address"
env["TARGET_CXXFLAGS"] += " -fsanitize=address"
# asan replaces system allocator with asan allocator
# we need to make sure that we do not replace it with jemalloc
self.features.append("servo_allocator/use-system-allocator")
elif sanitizer.is_tsan():
if target_triple not in SUPPORTED_TSAN_TARGETS:
print(
"ThreadSanitizer is currently not supported on this platform\n",
"See https://doc.rust-lang.org/beta/unstable-book/compiler-flags/sanitizer.html",
)
sys.exit(1)
env["RUSTFLAGS"] += " -Zsanitizer=thread"
env["TARGET_CFLAGS"] += " -fsanitize=thread"
env["TARGET_CXXFLAGS"] += " -fsanitize=thread"
def notify(self, title: str, message: str): def notify(self, title: str, message: str):
"""Generate desktop notification when build is complete and the """Generate desktop notification when build is complete and the
elapsed build time was longer than 30 seconds. elapsed build time was longer than 30 seconds.

View file

@ -43,6 +43,8 @@ from servo.util import download_file, get_default_cache_dir
import servo.platform import servo.platform
import servo.util as util import servo.util as util
from python.servo.platform.build_target import SanitizerKind
NIGHTLY_REPOSITORY_URL = "https://servo-builds2.s3.amazonaws.com/" NIGHTLY_REPOSITORY_URL = "https://servo-builds2.s3.amazonaws.com/"
ASAN_LEAK_SUPPRESSION_FILE = "support/suppressed_leaks_for_asan.txt" ASAN_LEAK_SUPPRESSION_FILE = "support/suppressed_leaks_for_asan.txt"
@ -305,7 +307,7 @@ class CommandBase(object):
self.config["build"].setdefault("incremental", None) self.config["build"].setdefault("incremental", None)
self.config["build"].setdefault("webgl-backtrace", False) self.config["build"].setdefault("webgl-backtrace", False)
self.config["build"].setdefault("dom-backtrace", False) self.config["build"].setdefault("dom-backtrace", False)
self.config["build"].setdefault("with_asan", False) self.config["build"].setdefault("sanitizer", SanitizerKind.NONE)
self.config.setdefault("android", {}) self.config.setdefault("android", {})
self.config["android"].setdefault("sdk", "") self.config["android"].setdefault("sdk", "")
@ -328,9 +330,9 @@ class CommandBase(object):
def get_top_dir(self): def get_top_dir(self):
return self.context.topdir return self.context.topdir
def get_binary_path(self, build_type: BuildType, asan: bool = False): def get_binary_path(self, build_type: BuildType, sanitizer: SanitizerKind = SanitizerKind.NONE):
base_path = util.get_target_dir() base_path = util.get_target_dir()
if asan or self.target.is_cross_build(): if sanitizer.is_some() or self.target.is_cross_build():
base_path = path.join(base_path, self.target.triple()) base_path = path.join(base_path, self.target.triple())
binary_name = self.target.binary_name() binary_name = self.target.binary_name()
binary_path = path.join(base_path, build_type.directory_name(), binary_name) binary_path = path.join(base_path, build_type.directory_name(), binary_name)
@ -537,7 +539,24 @@ class CommandBase(object):
help="Build in release mode without debug assertions", help="Build in release mode without debug assertions",
), ),
CommandArgument("--profile", group="Build Type", help="Build with custom Cargo profile"), CommandArgument("--profile", group="Build Type", help="Build with custom Cargo profile"),
CommandArgument("--with-asan", action="store_true", help="Build with AddressSanitizer"), CommandArgumentGroup("Sanitizer"),
CommandArgument(
"--with-asan",
group="Sanitizer",
dest="sanitizer",
action="store_const",
const=SanitizerKind.ASAN,
default=SanitizerKind.NONE,
help="Build with AddressSanitizer",
),
CommandArgument(
"--with-tsan",
group="Sanitizer",
dest="sanitizer",
action="store_const",
const=SanitizerKind.TSAN,
help="Build with ThreadSanitizer",
),
] ]
if build_configuration: if build_configuration:
@ -649,13 +668,13 @@ class CommandBase(object):
kwargs["servo_binary"] = ( kwargs["servo_binary"] = (
kwargs.get("bin") kwargs.get("bin")
or self.get_nightly_binary_path(kwargs.get("nightly")) or self.get_nightly_binary_path(kwargs.get("nightly"))
or self.get_binary_path(kwargs.get("build_type"), asan=kwargs.get("with_asan")) or self.get_binary_path(kwargs.get("build_type"), sanitizer=kwargs.get("sanitizer"))
) )
kwargs.pop("bin") kwargs.pop("bin")
kwargs.pop("nightly") kwargs.pop("nightly")
if not build_type: if not build_type:
kwargs.pop("build_type") kwargs.pop("build_type")
kwargs.pop("with_asan") kwargs.pop("sanitizer")
return original_function(self, *args, **kwargs) return original_function(self, *args, **kwargs)

View file

@ -237,7 +237,7 @@ def find_non_system_dependencies_with_otool(binary_path: str) -> Set[str]:
# No need to do any processing for system libraries. They should be # No need to do any processing for system libraries. They should be
# present on all macOS systems. # present on all macOS systems.
if not is_macos_system_library(dependency): if not (is_macos_system_library(dependency) or "librustc-stable_rt" in dependency):
output.add(dependency) output.add(dependency)
return output return output

View file

@ -41,6 +41,8 @@ from servo.command_base import (
) )
from servo.util import delete, get_target_dir from servo.util import delete, get_target_dir
from python.servo.platform.build_target import SanitizerKind
PACKAGES = { PACKAGES = {
"android": [ "android": [
"android/aarch64-linux-android/release/servoapp.apk", "android/aarch64-linux-android/release/servoapp.apk",
@ -108,9 +110,9 @@ class PackageCommands(CommandBase):
@CommandArgument("--target", "-t", default=None, help="Package for given target platform") @CommandArgument("--target", "-t", default=None, help="Package for given target platform")
@CommandBase.common_command_arguments(build_configuration=False, build_type=True, package_configuration=True) @CommandBase.common_command_arguments(build_configuration=False, build_type=True, package_configuration=True)
@CommandBase.allow_target_configuration @CommandBase.allow_target_configuration
def package(self, build_type: BuildType, flavor=None, with_asan=False): def package(self, build_type: BuildType, flavor=None, sanitizer: SanitizerKind = SanitizerKind.NONE):
env = self.build_env() env = self.build_env()
binary_path = self.get_binary_path(build_type, asan=with_asan) binary_path = self.get_binary_path(build_type, sanitizer=sanitizer)
dir_to_root = self.get_top_dir() dir_to_root = self.get_top_dir()
target_dir = path.dirname(binary_path) target_dir = path.dirname(binary_path)
if self.is_android(): if self.is_android():
@ -184,8 +186,10 @@ class PackageCommands(CommandBase):
"-p", "-p",
f"buildMode={build_mode}", f"buildMode={build_mode}",
] ]
if with_asan: if sanitizer.is_asan():
hvigor_command.extend(["-p", "ohos-debug-asan=true"]) hvigor_command.extend(["-p", "ohos-debug-asan=true"])
elif sanitizer.is_tsan():
hvigor_command.extend(["-p", "ohos-enable-tsan=true"])
# Detect if PATH already has hvigor, or else fallback to npm installation # Detect if PATH already has hvigor, or else fallback to npm installation
# provided via HVIGOR_PATH # provided via HVIGOR_PATH
@ -391,17 +395,24 @@ class PackageCommands(CommandBase):
@CommandArgument("--target", "-t", default=None, help="Install the given target platform") @CommandArgument("--target", "-t", default=None, help="Install the given target platform")
@CommandBase.common_command_arguments(build_configuration=False, build_type=True, package_configuration=True) @CommandBase.common_command_arguments(build_configuration=False, build_type=True, package_configuration=True)
@CommandBase.allow_target_configuration @CommandBase.allow_target_configuration
def install(self, build_type: BuildType, emulator=False, usb=False, with_asan=False, flavor=None): def install(
self,
build_type: BuildType,
emulator=False,
usb=False,
sanitizer: SanitizerKind = SanitizerKind.NONE,
flavor=None,
):
env = self.build_env() env = self.build_env()
try: try:
binary_path = self.get_binary_path(build_type, asan=with_asan) binary_path = self.get_binary_path(build_type, sanitizer=sanitizer)
except BuildNotFound: except BuildNotFound:
print("Servo build not found. Building servo...") print("Servo build not found. Building servo...")
result = Registrar.dispatch("build", context=self.context, build_type=build_type, flavor=flavor) result = Registrar.dispatch("build", context=self.context, build_type=build_type, flavor=flavor)
if result: if result:
return result return result
try: try:
binary_path = self.get_binary_path(build_type, asan=with_asan) binary_path = self.get_binary_path(build_type, sanitizer=sanitizer)
except BuildNotFound: except BuildNotFound:
print("Rebuilding Servo did not solve the missing build problem.") print("Rebuilding Servo did not solve the missing build problem.")
return 1 return 1

View file

@ -15,6 +15,7 @@ import platform
import shutil import shutil
import subprocess import subprocess
import sys import sys
from enum import Enum
from os import path from os import path
from packaging.version import parse as parse_version from packaging.version import parse as parse_version
@ -24,6 +25,28 @@ import servo.platform
import servo.util as util import servo.util as util
class SanitizerKind(Enum):
NONE = 0
ASAN = 1
TSAN = 2
# Apparently enums don't always compare across modules, so we define
# helper methods.
def is_asan(self) -> bool:
return self is self.ASAN
def is_tsan(self) -> bool:
return self is self.TSAN
# Returns true if no sanitizer is enabled.
def is_none(self) -> bool:
return self is self.NONE
# Returns true if a sanitizer is enabled.
def is_some(self) -> bool:
return not self.is_none()
class BuildTarget(object): class BuildTarget(object):
def __init__(self, target_triple: str): def __init__(self, target_triple: str):
self.target_triple = target_triple self.target_triple = target_triple
@ -397,8 +420,9 @@ class OpenHarmonyTarget(CrossBuildTarget):
bindgen_extra_clangs_args = bindgen_extra_clangs_args + " " + ohos_cflags_str bindgen_extra_clangs_args = bindgen_extra_clangs_args + " " + ohos_cflags_str
env[bindgen_extra_clangs_args_var] = bindgen_extra_clangs_args env[bindgen_extra_clangs_args_var] = bindgen_extra_clangs_args
# On OpenHarmony we add some additional flags when asan is enabled sanitizer: SanitizerKind = config["build"]["sanitizer"]
if config["build"]["with_asan"]: san_compile_flags = []
if sanitizer.is_some():
# Lookup `<sdk>/native/llvm/lib/clang/15.0.4/lib/aarch64-linux-ohos/libclang_rt.asan.so` # Lookup `<sdk>/native/llvm/lib/clang/15.0.4/lib/aarch64-linux-ohos/libclang_rt.asan.so`
lib_clang = llvm_toolchain.joinpath("lib", "clang") lib_clang = llvm_toolchain.joinpath("lib", "clang")
children = [f.path for f in os.scandir(lib_clang) if f.is_dir()] children = [f.path for f in os.scandir(lib_clang) if f.is_dir()]
@ -406,6 +430,12 @@ class OpenHarmonyTarget(CrossBuildTarget):
raise RuntimeError(f"Expected exactly 1 libclang version: `{children}`") raise RuntimeError(f"Expected exactly 1 libclang version: `{children}`")
lib_clang_version_dir = pathlib.Path(children[0]) lib_clang_version_dir = pathlib.Path(children[0])
libclang_arch = lib_clang_version_dir.joinpath("lib", clang_target_triple).resolve() libclang_arch = lib_clang_version_dir.joinpath("lib", clang_target_triple).resolve()
# Use the clangrt from the NDK to use the same library for both C++ and Rust.
env["RUSTFLAGS"] += " -Zexternal-clangrt"
san_compile_flags.append("-fno-omit-frame-pointer")
# On OpenHarmony we add some additional flags when asan is enabled
if sanitizer.is_asan():
libasan_so_path = libclang_arch.joinpath("libclang_rt.asan.so") libasan_so_path = libclang_arch.joinpath("libclang_rt.asan.so")
libasan_preinit_path = libclang_arch.joinpath("libclang_rt.asan-preinit.a") libasan_preinit_path = libclang_arch.joinpath("libclang_rt.asan-preinit.a")
if not libasan_so_path.exists(): if not libasan_so_path.exists():
@ -422,23 +452,26 @@ class OpenHarmonyTarget(CrossBuildTarget):
] ]
) )
# Use the clangrt from the NDK to use the same library for both C++ and Rust. san_compile_flags.extend(["-fsanitize=address", "-shared-libasan", "-fsanitize-recover=address"])
env["RUSTFLAGS"] += " -Zexternal-clangrt"
asan_compile_flags = (
" -fsanitize=address -shared-libasan -fno-omit-frame-pointer -fsanitize-recover=address"
)
arch_asan_ignore_list = lib_clang_version_dir.joinpath("share", "asan_ignorelist.txt") arch_asan_ignore_list = lib_clang_version_dir.joinpath("share", "asan_ignorelist.txt")
if arch_asan_ignore_list.exists(): if arch_asan_ignore_list.exists():
asan_compile_flags += " -fsanitize-system-ignorelist=" + str(arch_asan_ignore_list) san_compile_flags.append("-fsanitize-system-ignorelist=" + str(arch_asan_ignore_list))
else: else:
print(f"Warning: Couldn't find system ASAN ignorelist at `{arch_asan_ignore_list}`") print(f"Warning: Couldn't find system ASAN ignorelist at `{arch_asan_ignore_list}`")
env["TARGET_CFLAGS"] += asan_compile_flags elif sanitizer.is_tsan():
env["TARGET_CXXFLAGS"] += asan_compile_flags libtsan_so_path = libclang_arch.joinpath("libclang_rt.tsan.so")
builtins_path = libclang_arch.joinpath("libclang_rt.builtins.a")
link_args.extend(
["-fsanitize=thread", "--rtlib=compiler-rt", "-shared-libsan", str(libtsan_so_path), str(builtins_path)]
)
san_compile_flags.append("-shared-libsan")
link_args = [f"-Clink-arg={arg}" for arg in link_args] link_args = [f"-Clink-arg={arg}" for arg in link_args]
env["RUSTFLAGS"] += " " + " ".join(link_args) env["RUSTFLAGS"] += " " + " ".join(link_args)
env["TARGET_CFLAGS"] += " " + " ".join(san_compile_flags)
env["TARGET_CXXFLAGS"] += " " + " ".join(san_compile_flags)
def binary_name(self) -> str: def binary_name(self) -> str:
return "libservoshell.so" return "libservoshell.so"