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
56 changes: 11 additions & 45 deletions ebuild/build/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,48 +27,6 @@
SUPPORTED_BACKENDS = TIER_1 | TIER_2 | TIER_3


class BackendError(RuntimeError):
"""Raised when the external dispatcher cannot handle a backend."""


def _validate_backend(backend: str, supported: Set[str]) -> None:
"""Reject values that the requested dispatcher operation cannot handle."""
if backend in supported:
return

if backend in ALL_BACKENDS:
message = f"BackendDispatcher cannot handle backend '{backend}'."
else:
message = f"Unknown build backend '{backend}'."

supported_names = ", ".join(sorted(supported))
raise BackendError(f"{message} Supported backends: {supported_names}.")

#: Backends this dispatcher actually drives. "ninja" is ebuild's own backend --
#: the CLI invokes NinjaBackend directly and never routes it through here.
DISPATCHED_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild"}


class UnknownBackendError(ValueError, RuntimeError):
"""Raised when a backend reaches the dispatcher that it cannot drive.

Subclasses both ValueError and RuntimeError: callers treat an unrecognized
backend name as a bad argument, while the CLI treats a backend it failed to
route (notably "ninja") as a routing failure. Silently doing nothing here is
what made `ebuild build` report "Build completed successfully" without ever
running a compiler.
"""


def _unknown_backend(backend: str, step: str) -> UnknownBackendError:
return UnknownBackendError(
f"Unknown build backend '{backend}'. "
f"Supported backends: {', '.join(sorted(DISPATCHED_BACKENDS))}. "
"ebuild's own 'ninja' backend is invoked directly by the CLI and is "
f"not dispatched here, so it cannot be {step} through BackendDispatcher."
)


def ninja_command():
"""Return the argv prefix that runs ninja on this machine.

Expand Down Expand Up @@ -107,6 +65,10 @@ class UnknownBackendError(ValueError, RuntimeError):
"""


#: The name this error carried before the two mechanisms were consolidated.
#: Kept so callers and tests that import it keep working.
BackendError = UnknownBackendError

def _unknown_backend(backend: str, action: str, supported: set) -> UnknownBackendError:
"""Build the error raised for a backend a step cannot handle.

Expand Down Expand Up @@ -204,7 +166,13 @@ def configure(
Raises:
UnknownBackendError: If this step does not handle the backend.
"""
_validate_backend(backend, SUPPORTED_BACKENDS)
# Reject before any side effect. The removed duplicate validation
# ran ahead of the mkdir below; raising only from the else branch
# left a stray build directory behind for a backend this step
# never handles.
if backend not in CONFIGURE_BACKENDS:
raise _unknown_backend(backend, "configure", CONFIGURE_BACKENDS)

config = config or {}
self.build_dir.mkdir(parents=True, exist_ok=True)

Expand Down Expand Up @@ -244,7 +212,6 @@ def build(
Raises:
UnknownBackendError: If this step does not handle the backend.
"""
_validate_backend(backend, SUPPORTED_BACKENDS)
config = config or {}

if backend == "cmake":
Expand Down Expand Up @@ -291,7 +258,6 @@ def clean(
Raises:
UnknownBackendError: If this step does not handle the backend.
"""
_validate_backend(backend, ALL_BACKENDS)
if backend == "cmake":
_run_or_log(
["cmake", "--build", str(self.build_dir), "--target", "clean"],
Expand Down
76 changes: 65 additions & 11 deletions ebuild/build/footprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@
r"^\s*(?P<text>\d+)\s+(?P<data>\d+)\s+(?P<bss>\d+)\s+\d+\s+[0-9a-fA-F]+\s"
)

#: Apple's size(1) prints "__TEXT __DATA __OBJC others" instead of the
#: Berkeley "text data bss" columns. _SIZE_LINE matches that row too and
#: read __OBJC -- always 0 -- as bss, so every footprint measured on macOS
#: silently reported RAM without any zero-initialised data in it.
_MACHO_COLUMNS = re.compile(r"^\s*__TEXT\s+__DATA\s+__OBJC\s+others\b")
_MACHO_SEGMENT = re.compile(r"^Segment\s+(?P<name>\S+?):\s+(?P<size>\d+)")
_MACHO_SECTION = re.compile(
r"^\s+Section\s+(?P<name>\S+?):\s+(?P<size>\d+)(?P<zero>\s*\(zerofill\))?"
)


class FootprintError(RuntimeError):
"""Raised when a footprint cannot be measured."""
Expand Down Expand Up @@ -105,6 +115,55 @@ def find_size_tool(toolchain_prefix: Optional[str] = None) -> Optional[str]:
return shutil.which("size")


def _run_size(tool: str, args: list, artifact: Path):
"""Run the size tool, turning every failure mode into FootprintError."""
try:
proc = subprocess.run([tool] + args, capture_output=True,
text=True, timeout=60)
except (OSError, subprocess.TimeoutExpired) as exc:
raise FootprintError(f"{tool} failed on {artifact}: {exc}") from exc
if proc.returncode != 0:
raise FootprintError(
f"{tool} exited {proc.returncode} on {artifact}: "
f"{(proc.stderr or proc.stdout).strip()[:200]}"
)
return proc


def _measure_macho(tool: str, artifact: Path) -> "Footprint":
"""Section sizes from Apple size(1), which needs -m to report them.

Sections rather than segments: a segment is page-aligned, so __TEXT
reads as 16 KB for 112 bytes of code. The Berkeley numbers this
mirrors are section sizes, and the two tools must not disagree about
what a number means.
"""
proc = _run_size(tool, ["-m", str(artifact)], artifact)
text = data = bss = 0
segment = ""
for line in proc.stdout.splitlines():
seg = _MACHO_SEGMENT.match(line)
if seg:
segment = seg.group("name")
continue
sec = _MACHO_SECTION.match(line)
if not sec:
continue
size = int(sec.group("size"))
if sec.group("zero"):
bss += size # zerofill: RAM only, never stored
elif segment == "__TEXT":
text += size
elif segment not in ("__PAGEZERO", "__LINKEDIT"):
data += size
if not (text or data or bss):
raise FootprintError(
f"could not read any section size from {tool} -m output "
f"for {artifact}"
)
return Footprint(text=text, data=data, bss=bss)


def measure(artifact: Path, size_tool: Optional[str] = None) -> Footprint:
"""Section sizes of ``artifact``.

Expand All @@ -122,19 +181,14 @@ def measure(artifact: Path, size_tool: Optional[str] = None) -> Footprint:
"(install binutils, or the toolchain's binutils for a cross build)"
)

try:
proc = subprocess.run([tool, str(artifact)], capture_output=True,
text=True, timeout=60)
except (OSError, subprocess.TimeoutExpired) as exc:
raise FootprintError(f"{tool} failed on {artifact}: {exc}") from exc

if proc.returncode != 0:
raise FootprintError(
f"{tool} exited {proc.returncode} on {artifact}: "
f"{(proc.stderr or proc.stdout).strip()[:200]}"
)
proc = _run_size(tool, [str(artifact)], artifact)

for line in proc.stdout.splitlines():
# Apple size(1) leads with a column header naming Mach-O segments.
# Its data row matches _SIZE_LINE, so without this the __OBJC
# column is silently taken for bss.
if _MACHO_COLUMNS.match(line):
return _measure_macho(tool, artifact)
m = _SIZE_LINE.match(line)
if m:
return Footprint(text=int(m.group("text")),
Expand Down
52 changes: 32 additions & 20 deletions ebuild/build/ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,26 @@ class PackagePaths:
"-fno-pie", "-fno-PIE"}


def _exe_suffix() -> str:
"""The extension the compiler driver gives an executable.

gcc on Windows appends .exe when -o names no extension, so an edge
declaring "app" produced "app.exe" on disk: ninja never saw its own
output, treated the target as dirty and relinked on every build.
"""
return ".exe" if sys.platform == "win32" else ""


def _shared_flag() -> str:
"""The flag that makes the compiler driver emit a shared object.

macOS links dynamic libraries with -dynamiclib; ELF platforms use
-shared. It lives on the rule rather than on each edge so a
shared_library target cannot be emitted without it.
"""
return "-dynamiclib" if sys.platform == "darwin" else "-shared"


def escape_ninja_path(path) -> str:
"""Escape a path for use inside a Ninja build statement.

Expand Down Expand Up @@ -78,22 +98,6 @@ def generate(self) -> None:
self._write_ninja()
self._write_compile_commands()

def _object_path(self, target, src: str) -> Path:
"""Object file path for *src* as compiled by *target*.

Object paths are namespaced by target name. Two targets may legitimately
list the same source: a library and a test binary sharing a helper, or
one source built twice with different defines. Each needs its own
object, because each compiles with its own cflags. Keying only on the
source made both targets claim one output, which ninja rejects with
"multiple rules generate ...".

Example:
>>> backend._object_path(target, "src/main.c") # target.name == "app"
PosixPath('_build/obj/app/src/main.o')
"""
return (self.build_dir / "obj" / target.name / src).with_suffix(".o")

def _get_toolchain_cflags(self) -> List[str]:
"""Return toolchain-level cflags including sysroot.

Expand Down Expand Up @@ -179,6 +183,13 @@ def _write_ninja(self) -> None:
" command = $ar rcs $out $in",
" description = AR $out",
"",
# A shared_library edge names this rule. Without the rule the
# generated build.ninja referenced an undefined rule and ninja
# refused the whole file.
"rule link_shared",
f" command = $cc {_shared_flag()} $ldflags $in -o $out $libs",
" description = LINK_SHARED $out",
"",
]

toolchain_ldflags = self._get_toolchain_ldflags()
Expand Down Expand Up @@ -218,9 +229,10 @@ def _write_ninja(self) -> None:
)

link_inputs = obj_files + dep_archives
out = escape_ninja_path(self.build_dir / target.name)
out = escape_ninja_path(
self.build_dir / (target.name + _exe_suffix()))
lines.append(
f"build {_ninja_path(out)}: link " f"{' '.join(_ninja_path(x) for x in link_inputs)}"
f"build {out}: link " f"{' '.join(link_inputs)}"
)
if ldflags:
lines.append(f" ldflags = {' '.join(ldflags)}")
Expand All @@ -238,7 +250,7 @@ def _write_ninja(self) -> None:
out = escape_ninja_path(self.build_dir / f"lib{target.name}{ext}")

if target.target_type == "static_library":
lines.append(f"build {_ninja_path(out)}: ar_rule " f"{' '.join(_ninja_path(x) for x in obj_files)}")
lines.append(f"build {out}: ar_rule " f"{' '.join(obj_files)}")
else:
# Shared libraries need the same -L/-l wiring executables
# get, which the rule preamble alone does not supply. The
Expand All @@ -254,7 +266,7 @@ def _write_ninja(self) -> None:
for lib in pkg.libraries:
libs.append(f"-l{lib}")

lines.append(f"build {_ninja_path(out)}: link_shared " f"{' '.join(_ninja_path(x) for x in obj_files)}")
lines.append(f"build {out}: link_shared " f"{' '.join(obj_files)}")
if ldflags:
lines.append(f" ldflags = {' '.join(ldflags)}")
if libs:
Expand Down
Loading
Loading