|
22 | 22 | on the shipped runtime with a relocatable RUNPATH. |
23 | 23 | """ |
24 | 24 |
|
| 25 | +import importlib.util |
25 | 26 | import os |
26 | 27 | import re |
27 | 28 | import shutil |
28 | 29 | import subprocess |
| 30 | +import tempfile |
29 | 31 | import sys |
30 | 32 | from pathlib import Path |
31 | 33 |
|
@@ -284,8 +286,333 @@ def test_python_extensions_import() -> None: |
284 | 286 | ) |
285 | 287 |
|
286 | 288 |
|
| 289 | +_CUSTOM_OP_SOURCE = """\ |
| 290 | +// A custom operator, built the way an out-of-tree project builds one: against the |
| 291 | +// shipped Python extension rather than an ExecuTorch source tree. |
| 292 | +#include <executorch/extension/kernel_util/make_boxed_from_unboxed_functor.h> |
| 293 | +#include <executorch/runtime/kernel/kernel_includes.h> |
| 294 | +
|
| 295 | +namespace { |
| 296 | +
|
| 297 | +executorch::aten::Tensor& custom_double_out( |
| 298 | + executorch::runtime::KernelRuntimeContext& context, |
| 299 | + const executorch::aten::Tensor& input, |
| 300 | + executorch::aten::Tensor& out) { |
| 301 | + (void)context; |
| 302 | + const float* in = input.const_data_ptr<float>(); |
| 303 | + float* dst = out.mutable_data_ptr<float>(); |
| 304 | + for (ssize_t i = 0; i < input.numel(); ++i) { |
| 305 | + dst[i] = in[i] * 2.0f; |
| 306 | + } |
| 307 | + return out; |
| 308 | +} |
| 309 | +
|
| 310 | +} // namespace |
| 311 | +
|
| 312 | +// The registration macro is the point of the check: it has to compile and resolve |
| 313 | +// against the registry the shipped extension provides. |
| 314 | +EXECUTORCH_LIBRARY(wheel_check, "custom_double.out", custom_double_out); |
| 315 | +""" |
| 316 | + |
| 317 | + |
| 318 | +_CUSTOM_OP_CMAKE = """\ |
| 319 | +cmake_minimum_required(VERSION 3.28) |
| 320 | +project(custom_op_check CXX) |
| 321 | +
|
| 322 | +find_package(executorch REQUIRED) |
| 323 | +
|
| 324 | +add_library(custom_op_check SHARED custom_op.cpp) |
| 325 | +# The legacy contract: a custom-op library links the shipped Python extension, |
| 326 | +# which owns the operator registry it registers into. |
| 327 | +target_link_libraries(custom_op_check PRIVATE _portable_lib) |
| 328 | +""" |
| 329 | + |
| 330 | + |
| 331 | +def test_shipped_libraries_load() -> None: |
| 332 | + """Every shipped library must depend only on things that exist. |
| 333 | +
|
| 334 | + The symbol checks prove each component is defined exactly once, but a library |
| 335 | + can still be unloadable if it needs something nothing provides, which is a |
| 336 | + packaging bug rather than a duplication bug. |
| 337 | +
|
| 338 | + A dependency the wheel ships elsewhere is fine even when `ldd` cannot resolve |
| 339 | + it: some extensions are loaded after `import torch` has already brought their |
| 340 | + dependencies into the process, so they intentionally carry no path to them. |
| 341 | + Only a name nothing in the wheel provides is a real problem. |
| 342 | + """ |
| 343 | + if shutil.which("ldd") is None: |
| 344 | + print("- ldd not available, skipping the load check") |
| 345 | + return |
| 346 | + |
| 347 | + package_dir = _installed_package_dir() |
| 348 | + libraries = _shipped_shared_objects(package_dir) |
| 349 | + shipped = {library.name for library in libraries} |
| 350 | + |
| 351 | + # A dependency is only excusable when the wheel ships it AND the loader can |
| 352 | + # actually reach it from the library that needs it. Loaded-later extensions |
| 353 | + # such as the Torch libraries are the real exception: they resolve once the |
| 354 | + # Python package that owns them is imported. Anything the wheel itself ships |
| 355 | + # must resolve here, because a RUNPATH applies to the library carrying it and |
| 356 | + # is not inherited on behalf of a dependency's own dependencies. |
| 357 | + broken = {} |
| 358 | + unreachable = {} |
| 359 | + unresolved = {} |
| 360 | + for library in libraries: |
| 361 | + resolved = subprocess.run( |
| 362 | + # -r resolves data and function symbols too, not just the NEEDED |
| 363 | + # entries. A SHARED link does not error on undefined symbols, so |
| 364 | + # without this an under-linked library passes here and fails at first |
| 365 | + # use instead. |
| 366 | + ["ldd", "-r", str(library)], |
| 367 | + capture_output=True, |
| 368 | + text=True, |
| 369 | + check=False, |
| 370 | + # Any LD_LIBRARY_PATH in the build environment would paper over a |
| 371 | + # RUNPATH the shipped library is actually missing. |
| 372 | + env={ |
| 373 | + key: value |
| 374 | + for key, value in os.environ.items() |
| 375 | + if key != "LD_LIBRARY_PATH" |
| 376 | + }, |
| 377 | + ) |
| 378 | + # ldd reports missing libraries on stdout but undefined symbols on stderr, |
| 379 | + # so both streams matter. |
| 380 | + combined = resolved.stdout + resolved.stderr |
| 381 | + missing = [ |
| 382 | + line.split("=>")[0].strip() |
| 383 | + for line in combined.splitlines() |
| 384 | + if "not found" in line |
| 385 | + ] |
| 386 | + # Interpreter symbols are excluded rather than whole files. A library that |
| 387 | + # is loaded by Python, whether a extension module or an ahead-of-time |
| 388 | + # plugin, resolves those only once an interpreter is running, so ldd can |
| 389 | + # never resolve them and their absence says nothing about packaging. |
| 390 | + # Filtering the symbols rather than guessing from the file name keeps the |
| 391 | + # check active for everything else those libraries need. |
| 392 | + undefined = [ |
| 393 | + line.strip() |
| 394 | + for line in combined.splitlines() |
| 395 | + if "undefined symbol" in line |
| 396 | + and not re.search(r"undefined symbol:\s+_?Py", line) |
| 397 | + ] |
| 398 | + if undefined: |
| 399 | + unresolved[str(library.relative_to(package_dir))] = undefined[:5] |
| 400 | + absent = [name for name in missing if name not in shipped] |
| 401 | + present_but_unreachable = [name for name in missing if name in shipped] |
| 402 | + if absent: |
| 403 | + broken[str(library.relative_to(package_dir))] = absent |
| 404 | + if present_but_unreachable: |
| 405 | + unreachable[str(library.relative_to(package_dir))] = present_but_unreachable |
| 406 | + |
| 407 | + assert not broken, ( |
| 408 | + "shipped libraries need dependencies that nothing provides, so they will " |
| 409 | + f"fail to load: {broken}" |
| 410 | + ) |
| 411 | + assert not unreachable, ( |
| 412 | + "shipped libraries need dependencies the wheel ships but the loader " |
| 413 | + "cannot reach from them, which usually means a missing RUNPATH entry: " |
| 414 | + f"{unreachable}" |
| 415 | + ) |
| 416 | + assert not unresolved, ( |
| 417 | + "shipped libraries reference symbols nothing provides, so they will fail " |
| 418 | + f"at first use rather than at load: {unresolved}" |
| 419 | + ) |
| 420 | + print("✓ every shipped library resolves in an environment with torch present") |
| 421 | + |
| 422 | + |
| 423 | +def test_shipped_libraries_resolve_without_build_tree() -> None: |
| 424 | + """A shipped library must resolve using only its relative runtime paths. |
| 425 | +
|
| 426 | + Packaging copies binaries out of the build directory, so they still carry the |
| 427 | + absolute paths they were linked with. On the machine that produced the wheel |
| 428 | + those paths exist, which means a library whose relative path is wrong can still |
| 429 | + resolve and look correct. Anywhere else it would fail. |
| 430 | +
|
| 431 | + Copy each library and its wheel-provided dependencies into a fresh tree that |
| 432 | + mirrors the wheel layout, drop every absolute runtime path, and check what is |
| 433 | + left is enough. |
| 434 | + """ |
| 435 | + if shutil.which("ldd") is None or shutil.which("patchelf") is None: |
| 436 | + print("- ldd or patchelf unavailable, skipping the relocated load check") |
| 437 | + return |
| 438 | + |
| 439 | + package_dir = _installed_package_dir() |
| 440 | + libraries = _shipped_shared_objects(package_dir) |
| 441 | + environment = { |
| 442 | + key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" |
| 443 | + } |
| 444 | + |
| 445 | + with tempfile.TemporaryDirectory() as work_dir: |
| 446 | + root = Path(work_dir) / package_dir.name |
| 447 | + # Mirror the layout so a relative path such as $ORIGIN/../../lib still |
| 448 | + # points where it would in a real install. |
| 449 | + for library in libraries: |
| 450 | + target = root / library.relative_to(package_dir) |
| 451 | + target.parent.mkdir(parents=True, exist_ok=True) |
| 452 | + shutil.copy2(library, target) |
| 453 | + |
| 454 | + broken = {} |
| 455 | + for library in libraries: |
| 456 | + target = root / library.relative_to(package_dir) |
| 457 | + current = subprocess.run( |
| 458 | + ["patchelf", "--print-rpath", str(target)], |
| 459 | + capture_output=True, |
| 460 | + text=True, |
| 461 | + check=False, |
| 462 | + ).stdout.strip() |
| 463 | + relative = [ |
| 464 | + entry for entry in current.split(":") if entry.startswith("$ORIGIN") |
| 465 | + ] |
| 466 | + subprocess.run( |
| 467 | + ["patchelf", "--set-rpath", ":".join(relative), str(target)], |
| 468 | + # A failure here would leave the original absolute build paths in |
| 469 | + # place, and the check below would then pass by resolving through |
| 470 | + # them, which is exactly what this test exists to rule out. |
| 471 | + check=True, |
| 472 | + ) |
| 473 | + resolved = subprocess.run( |
| 474 | + ["ldd", str(target)], |
| 475 | + capture_output=True, |
| 476 | + text=True, |
| 477 | + check=False, |
| 478 | + env=environment, |
| 479 | + ).stdout |
| 480 | + shipped = {item.name for item in libraries} |
| 481 | + all_missing = [ |
| 482 | + line.split("=>")[0].strip() |
| 483 | + for line in resolved.splitlines() |
| 484 | + if "not found" in line |
| 485 | + ] |
| 486 | + # Only wheel-provided dependencies are asserted on, because an external |
| 487 | + # one is expected to come from the environment. They are still reported, |
| 488 | + # since silently dropping them would hide a library that resolves only |
| 489 | + # through an absolute build path. |
| 490 | + missing = [name for name in all_missing if name in shipped] |
| 491 | + external = [name for name in all_missing if name not in shipped] |
| 492 | + if external: |
| 493 | + print( |
| 494 | + f"- {library.relative_to(package_dir)} also needs " |
| 495 | + f"{external} from the environment" |
| 496 | + ) |
| 497 | + if missing: |
| 498 | + broken[str(library.relative_to(package_dir))] = missing |
| 499 | + |
| 500 | + assert not broken, ( |
| 501 | + "shipped libraries only resolve their wheel-provided dependencies " |
| 502 | + "through absolute build paths, so they would fail on any other " |
| 503 | + f"machine: {broken}" |
| 504 | + ) |
| 505 | + print("✓ every shipped library resolves without the build tree") |
| 506 | + |
| 507 | + |
| 508 | +def test_custom_op_compiles(work_dir: Path) -> None: |
| 509 | + """A custom operator compiles and links against the shipped extension. |
| 510 | +
|
| 511 | + This is how an out-of-tree project adds its own kernels, and it points at the |
| 512 | + Python extension rather than the runtime, so it is not covered by the consumer |
| 513 | + check above. |
| 514 | + """ |
| 515 | + assert shutil.which("cmake") is not None, "cmake is required to build a consumer" |
| 516 | + |
| 517 | + package_dir = _installed_package_dir() |
| 518 | + if not list(package_dir.glob("extension/pybindings/_portable_lib*")): |
| 519 | + print("- the wheel ships no Python extension, skipping the custom op check") |
| 520 | + return |
| 521 | + |
| 522 | + source_dir = work_dir / "custom-op" |
| 523 | + build_dir = work_dir / "custom-op-build" |
| 524 | + source_dir.mkdir(parents=True, exist_ok=True) |
| 525 | + (source_dir / "custom_op.cpp").write_text(_CUSTOM_OP_SOURCE) |
| 526 | + (source_dir / "CMakeLists.txt").write_text(_CUSTOM_OP_CMAKE) |
| 527 | + |
| 528 | + configure = subprocess.run( |
| 529 | + [ |
| 530 | + "cmake", |
| 531 | + "-S", |
| 532 | + str(source_dir), |
| 533 | + "-B", |
| 534 | + str(build_dir), |
| 535 | + f"-DCMAKE_PREFIX_PATH={package_dir}", |
| 536 | + ], |
| 537 | + capture_output=True, |
| 538 | + text=True, |
| 539 | + check=False, |
| 540 | + ) |
| 541 | + assert configure.returncode == 0, ( |
| 542 | + "a custom operator project cannot configure against the wheel: " |
| 543 | + f"{(configure.stderr or configure.stdout).strip()[-600:]}" |
| 544 | + ) |
| 545 | + |
| 546 | + compiled = subprocess.run( |
| 547 | + ["cmake", "--build", str(build_dir)], |
| 548 | + capture_output=True, |
| 549 | + text=True, |
| 550 | + check=False, |
| 551 | + ) |
| 552 | + assert compiled.returncode == 0, ( |
| 553 | + "a custom operator does not compile or link against the shipped extension: " |
| 554 | + f"{(compiled.stderr or compiled.stdout).strip()[-800:]}" |
| 555 | + ) |
| 556 | + assert list(build_dir.rglob("libcustom_op_check.so")) or list( |
| 557 | + build_dir.rglob("custom_op_check.dll") |
| 558 | + ), "the custom operator library was not produced" |
| 559 | + print("✓ a custom operator compiles against the shipped Python extension") |
| 560 | + |
| 561 | + |
| 562 | +def test_wheel_platform_tag() -> None: |
| 563 | + """The wheel's declared platform tag must match what its libraries need. |
| 564 | +
|
| 565 | + A library that quietly picks up a newer dependency, or a newer minimum glibc, |
| 566 | + makes the wheel unusable on machines the tag says it supports. auditwheel is |
| 567 | + the tool that decides this, so ask it rather than guessing. |
| 568 | +
|
| 569 | + Only a contradiction between the tag and the contents fails here. Reports about |
| 570 | + instruction set extensions are left to the caller, because a prebuilt tool that |
| 571 | + ships in the wheel can legitimately require a newer baseline than the tag |
| 572 | + implies. |
| 573 | + """ |
| 574 | + if importlib.util.find_spec("auditwheel") is None: |
| 575 | + print("- auditwheel unavailable, skipping the platform tag check") |
| 576 | + return |
| 577 | + |
| 578 | + wheels = sorted(Path(os.environ.get("WHEEL_DIR", ".")).glob("executorch-*.whl")) |
| 579 | + if not wheels: |
| 580 | + print("- no wheel file to inspect, skipping the platform tag check") |
| 581 | + return |
| 582 | + |
| 583 | + result = subprocess.run( |
| 584 | + [sys.executable, "-m", "auditwheel", "show", str(wheels[-1])], |
| 585 | + capture_output=True, |
| 586 | + text=True, |
| 587 | + check=False, |
| 588 | + ) |
| 589 | + # auditwheel wraps its verdict across lines, so compare on collapsed |
| 590 | + # whitespace rather than the literal output. |
| 591 | + combined = " ".join((result.stdout + result.stderr).split()) |
| 592 | + match = re.search( |
| 593 | + r'consistent with the following platform tag: "([^"]+)"', combined |
| 594 | + ) |
| 595 | + assert match, ( |
| 596 | + "auditwheel reported no platform tag for the wheel, so its contents could " |
| 597 | + f"not be checked against what it claims: {combined[-400:]}" |
| 598 | + ) |
| 599 | + # The tag auditwheel derives from the contents has to be the one the file name |
| 600 | + # claims. A wheel that names a stricter tag than its libraries support installs |
| 601 | + # on machines it cannot actually run on. |
| 602 | + claimed = wheels[-1].name.split("-")[-1].removesuffix(".whl") |
| 603 | + assert match.group(1) in claimed, ( |
| 604 | + f"the wheel claims platform tag {claimed} but its contents only support " |
| 605 | + f"{match.group(1)}" |
| 606 | + ) |
| 607 | + print(f"✓ the wheel contents match its declared platform tag {match.group(1)}") |
| 608 | + |
| 609 | + |
287 | 610 | def run_tests(work_dir: Path) -> None: |
288 | 611 | test_single_backend_registry() |
289 | 612 | test_python_extensions_import() |
| 613 | + test_shipped_libraries_load() |
| 614 | + test_shipped_libraries_resolve_without_build_tree() |
| 615 | + test_wheel_platform_tag() |
| 616 | + test_custom_op_compiles(work_dir) |
290 | 617 | test_single_threadpool() |
291 | 618 | test_cpp_consumer(work_dir) |
0 commit comments