Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/check-ubuntu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,66 @@ jobs:
- name: Test large library archive
run: soldr cargo test -p fbuild-library large_archive_handles_windows_command_limit_and_quoted_paths -- --ignored --nocapture

glibc-floor:
# FastLED/fbuild#1629. The manylinux_2_17 tag is assigned from a filename
# map in ci/publish.py, not measured, and `cargo check` never links -- so a
# too-new glibc symbol (libc::memfd_create, glibc 2.27+, was the real case)
# survives every PR gate and only fails in the release cross-link lane.
# Cross-link the two shipped binaries for both Linux arches under soldr's
# glibc-2.17 sysroot, then read the linked symbol tables. No new workflow
# file: this is another job in the existing Ubuntu lane.
name: glibc 2.17 floor (linux x86_64 + aarch64)
runs-on: ubuntu-latest
# Cold cache: two release cross-links of fbuild-cli + fbuild-daemon.
# Measured ~16 min for the daemon alone on a loaded 16-core host with an
# empty cache; 60 leaves room for the first (cache-miss) run.
timeout-minutes: 60
steps:
- name: Checkout source
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref }}
- name: Verify candidate SHA
if: inputs.ref != ''
env:
CANDIDATE_SHA: ${{ inputs.ref }}
run: |
[[ "$CANDIDATE_SHA" =~ ^[0-9a-f]{40}$ ]]
test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA"
- name: Set up uv
uses: astral-sh/setup-uv@v3
with:
python-version: "3.12"
# Own build-cache store: this job cross-links two release targets and
# must not compete with (or poison) the test job's cache generation.
- name: Setup soldr
uses: zackees/setup-soldr@6bf04d4196ee26fe9d51550187013a7f32955e20 # setup-soldr#582 exact-main promotion canary
with:
cache-key-suffix: glibc-floor
job-status: ${{ job.status }}
cache: true
build-cache: true
target-cache: true
prebuild-deps: none
linker: platform-default
- name: Self-test the floor guard
run: uv run --no-project python -m unittest ci.test_check_glibc_floor
- name: Cross-link release binaries
timeout-minutes: 45
run: |
for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
soldr prepare --target "$target"
soldr build --release --target "$target" -p fbuild-cli -p fbuild-daemon
done
- name: Enforce the manylinux_2_17 floor
run: |
uv run --no-project python ci/check_glibc_floor.py --floor 2.17 \
target/x86_64-unknown-linux-gnu/release/fbuild \
target/x86_64-unknown-linux-gnu/release/fbuild-daemon \
target/aarch64-unknown-linux-gnu/release/fbuild \
target/aarch64-unknown-linux-gnu/release/fbuild-daemon

python-facade-tests:
# FastLED/fbuild#1485 §5.2/§6: the embedded-CPython AT-P integration
# tests need libpython at link and run time, so they're `#[ignore]`d
Expand Down
14 changes: 14 additions & 0 deletions agents/docs/cross-compilation.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ objdump -T <lib>.so | grep -o 'GLIBC_[0-9.]*' | sort -Vu | tail -4
python3 -c "d=open('<bin>','rb').read(8); print(d[:4].hex(), hex(int.from_bytes(d[4:8],'little')))"
```

The same check is a PR gate now (`ci/check_glibc_floor.py`, job `glibc-floor`
in `check-ubuntu.yml`): it cross-links `fbuild-cli` + `fbuild-daemon` for
`x86_64-unknown-linux-gnu` and `aarch64-unknown-linux-gnu` under soldr's
glibc-2.17 sysroot and fails on any `GLIBC_` symbol above the floor, naming the
symbols that need it. Run it locally against any linked binary:

```bash
uv run --no-project python ci/check_glibc_floor.py --floor 2.17 <binary>
```

A *host-native* debug binary is not a valid input: it links against the host
glibc (2.39+ on recent distros) and will always fail. The guard is meaningful
only on artifacts built through soldr's catalogue sysroot.

The published wheel is downloadable, so the shipped floor is always
checkable after the fact:

Expand Down
187 changes: 187 additions & 0 deletions ci/check_glibc_floor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"""Enforce the manylinux glibc floor on linked Linux binaries (#1629).

`ci/publish.py` assigns the `manylinux_2_17` wheel tag from a filename map, not
from the artifact, and `cargo check` never links -- so a wheel can promise
glibc >= 2.17 while needing a symbol from 2.27. That is exactly how
`libc::memfd_create` (glibc 2.27+) slipped past every PR gate and only failed in
the release cross-link lane.

The floor is a property of the *dynamic symbol table*, so this guard reads an
already-linked binary with `objdump -T` and fails when any `GLIBC_x.y` version
requirement is newer than the floor. It never builds anything; CI links the
release binaries first, then runs this over each artifact.

Usage::

uv run --no-project python ci/check_glibc_floor.py BINARY [BINARY ...]
uv run --no-project python ci/check_glibc_floor.py --floor 2.17 BINARY

Exit codes: 0 = at or below the floor, 1 = above the floor, 2 = the artifact
could not be inspected (missing file, no toolchain, not a glibc binary).
"""

from __future__ import annotations

import argparse
import re
import subprocess
import sys
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path

DEFAULT_FLOOR = "2.17"
DEFAULT_OBJDUMP = "objdump"

_VERSION_RE = re.compile(r"^(\d+)\.(\d+)$")
# `objdump -T` prints the requirement in the version column:
# 0000000000000000 DF *UND* 0000000000000000 GLIBC_2.27 memfd_create
_SYMBOL_RE = re.compile(r"\bGLIBC_(\d+)\.(\d+)(?:\.(\d+))?\b")


@dataclass(frozen=True, order=True)
class Version:
"""A glibc symbol-version number, comparable field by field."""

major: int
minor: int
patch: int

@classmethod
def parse(cls, text: str) -> Version:
match = _VERSION_RE.match(text)
if match is None:
raise ValueError(f"expected MAJOR.MINOR, got {text!r}")
return cls(int(match.group(1)), int(match.group(2)), 0)

@classmethod
def from_match(cls, match: re.Match[str]) -> Version:
return cls(int(match.group(1)), int(match.group(2)), int(match.group(3) or 0))

def __str__(self) -> str:
base = f"{self.major}.{self.minor}"
return base if self.patch == 0 else f"{base}.{self.patch}"


@dataclass(frozen=True)
class Finding:
"""One binary that needs a glibc symbol newer than the floor."""

binary: Path
version: Version
symbols: tuple[str, ...]


def parse_requirements(symbol_table: str) -> dict[Version, set[str]]:
"""Map each required `GLIBC_x.y` version to the symbols that need it."""
requirements: dict[Version, set[str]] = defaultdict(set)
for line in symbol_table.splitlines():
match = _SYMBOL_RE.search(line)
if match is None:
continue
fields = line.split()
symbol = fields[-1] if fields else "<unknown>"
requirements[Version.from_match(match)].add(symbol)
return dict(requirements)


def max_version(requirements: dict[Version, set[str]]) -> Version | None:
return max(requirements) if requirements else None


def violations(
binary: Path, requirements: dict[Version, set[str]], floor: Version
) -> list[Finding]:
"""Return one finding per too-new version, oldest first."""
return [
Finding(binary, version, tuple(sorted(requirements[version])))
for version in sorted(requirements)
if version > floor
]


def read_symbol_table(binary: Path, objdump: str) -> str:
result = subprocess.run(
[objdump, "-T", str(binary)],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(
f"{objdump} -T {binary} failed ({result.returncode}): "
f"{result.stderr.strip()}"
)
return result.stdout


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--floor",
default=DEFAULT_FLOOR,
help=f"highest permitted glibc symbol version (default {DEFAULT_FLOOR})",
)
parser.add_argument(
"--objdump",
default=DEFAULT_OBJDUMP,
help=f"objdump executable to use (default {DEFAULT_OBJDUMP})",
)
parser.add_argument("binaries", nargs="+", help="linked ELF binaries to inspect")
args = parser.parse_args(argv)

try:
floor = Version.parse(str(args.floor))
except ValueError as error:
parser.error(str(error))
return 2 # unreachable; parser.error exits

findings: list[Finding] = []
failures: list[str] = []
for name in args.binaries:
binary = Path(str(name))
if not binary.is_file():
failures.append(f"{binary}: no such file")
continue
try:
table = read_symbol_table(binary, str(args.objdump))
except (OSError, RuntimeError) as error:
failures.append(f"{error}")
continue
requirements = parse_requirements(table)
newest = max_version(requirements)
if newest is None:
failures.append(f"{binary}: no GLIBC_ symbol versions found")
continue
status = "OK" if newest <= floor else "TOO NEW"
print(
f"glibc floor {floor}: {binary} (max GLIBC_{newest}) {status}", flush=True
)
findings.extend(violations(binary, requirements, floor))

for finding in findings:
print(
f"error: {finding.binary} requires GLIBC_{finding.version} (floor {floor})",
file=sys.stderr,
)
print(
f" GLIBC_{finding.version}: {', '.join(finding.symbols)}", file=sys.stderr
)
for failure in failures:
print(f"error: {failure}", file=sys.stderr)

if findings:
print(
"The manylinux_2_17 promise is broken by the symbols above; call the "
"raw syscall (libc::syscall(libc::SYS_...)) or keep a fallback for "
"glibc older than the floor.",
file=sys.stderr,
)
return 1
if failures:
return 2
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading