diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..4b48751 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +# Never strip on macOS. Apple's `strip` can leave the LINKEDIT string pool +# 4-byte aligned, and macOS 27's dyld refuses to load such an image when it was +# linked against the macOS 27 SDK (Apple's, from Xcode or the Command Line +# Tools) or later. Stripping saves nothing there: debug info stays out of the +# dylib. This overrides the release profile's `strip`. +[target.'cfg(target_os = "macos")'] +rustflags = ["-C", "strip=none"] diff --git a/.github/scripts/check_macho_strtab.py b/.github/scripts/check_macho_strtab.py new file mode 100644 index 0000000..266b54a --- /dev/null +++ b/.github/scripts/check_macho_strtab.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Fail if a macOS wheel's extension has a misaligned LINKEDIT string pool. + +Usage: check_macho_strtab.py [ ...] + +macOS 27's dyld refuses to load a 64-bit Mach-O image linked against the +macOS 27 SDK or later when its string pool offset (LC_SYMTAB stroff) is not +8-byte aligned. Older dyld versions load the same file without complaint, so +an import smoke test on an older runner cannot catch it. Wheels without Mach-O members +are skipped. Runs on the stock Python of every CI platform (no third-party +imports). +""" + +import struct +import sys +import zipfile + +MH_MAGIC_64 = 0xFEEDFACF +FAT_MAGIC = 0xCAFEBABE +FAT_MAGIC_64 = 0xCAFEBABF +LC_SYMTAB = 0x2 + + +def thin_images(data: bytes): + """Yield (offset, bytes) for each 64-bit Mach-O image in a thin or fat file.""" + if len(data) < 8: + return + magic = struct.unpack_from(">I", data)[0] + if magic in (FAT_MAGIC, FAT_MAGIC_64): + nfat = struct.unpack_from(">I", data, 4)[0] + entry, fmt = (20, ">iiIII") if magic == FAT_MAGIC else (32, ">iiQQII") + for i in range(nfat): + _cpu, _sub, offset, size = struct.unpack_from(fmt, data, 8 + i * entry)[:4] + yield from thin_images(data[offset:offset + size]) + elif struct.unpack_from(" int: + failures = 0 + with zipfile.ZipFile(path) as wheel: + for member in wheel.namelist(): + if not member.endswith((".so", ".dylib")): + continue + for image in thin_images(wheel.read(member)): + for stroff in string_pool_offsets(image): + if stroff % 8: + print(f"::error::{path}: {member} string pool offset " + f"0x{stroff:08X} is not 8-byte aligned") + failures += 1 + else: + print(f"ok {path}: {member} string pool offset 0x{stroff:08X}") + return failures + + +def main() -> None: + failures = sum(check_wheel(path) for path in sys.argv[1:]) + if failures: + raise SystemExit(f"{failures} misaligned string pool(s)") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml index 1b0a09d..4ffc088 100644 --- a/.github/workflows/build-release.yaml +++ b/.github/workflows/build-release.yaml @@ -202,6 +202,12 @@ jobs: sparse-checkout-cone-mode: false - name: Collect build artifacts uses: ./.github/actions/collect-build-artifacts + - name: Checkout wheel checks + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + path: native + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false - name: Verify collected Python artifacts run: | @@ -224,6 +230,9 @@ jobs: esac done shopt -s nullglob + # macOS 27 refuses to load a misaligned string pool in an image + # linked against the macOS 27 SDK. + python3 native/.github/scripts/check_macho_strtab.py build-artifacts/*.whl for sdist in build-artifacts/*.tar.gz; do tar -tzf "$sdist" >/dev/null done diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 72ab88e..f19afc3 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -541,6 +541,11 @@ jobs: with: target: x86_64 args: --release --out aerospike_native --interpreter ${{ matrix.python }} + # macOS 27's dyld rejects a misaligned string pool, but only in images + # linked against the macOS 27 SDK. The import smoke below catches that + # only when the runner has both, so check the layout directly. + - name: Check string pool alignment + run: python3 .github/scripts/check_macho_strtab.py aerospike_native/*.whl # Zero-container smoke: verify the freshly built wheel installs and imports # on macOS (catches link/ABI/symbol breakage the build step alone can miss). - name: Import smoke test @@ -599,6 +604,9 @@ jobs: with: target: aarch64 args: --release --out aerospike_native --interpreter ${{ matrix.python }} + # See macos-x86: the import smoke can't reliably catch this. + - name: Check string pool alignment + run: python3 .github/scripts/check_macho_strtab.py aerospike_native/*.whl # Zero-container smoke: verify the freshly built wheel installs and imports # on macOS (catches link/ABI/symbol breakage the build step alone can miss). - name: Import smoke test diff --git a/Cargo.toml b/Cargo.toml index d561dac..cb0ad13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,9 @@ tokio-rustls = { version = "0.26", default-features = false, features = ["ring", rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"], optional = true } rustls-pemfile = { version = "2", optional = true } webpki-roots = { version = "1.0", optional = true } -mimalloc = "0.1" +# v3 (through 3.3.2) segfaults once the thread that first imported the module +# exits, and on macOS shares a fixed TLS slot with any other bundled v3. +mimalloc = { version = "0.1", features = ["v2"] } rand = { version = "0.8", features = ["small_rng"] } [build-dependencies] diff --git a/pyproject.toml b/pyproject.toml index 0dab589..0e9e093 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,8 @@ Changelog = "https://github.com/aerospike/aerospike-client-python-native/release features = ["pyo3/extension-module"] module-name = "aerospike_native._native" python-source = "python" +# Source builds need the macOS no-strip setting too. +include = [{ path = ".cargo/config.toml", format = "sdist" }] [tool.ruff] # Matches PSDK so one lint dialect covers both repos. diff --git a/python/tests/unit/thread_exit_test.py b/python/tests/unit/thread_exit_test.py new file mode 100644 index 0000000..90b9e75 --- /dev/null +++ b/python/tests/unit/thread_exit_test.py @@ -0,0 +1,69 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Using the module after the thread that first imported it has exited. + +mimalloc 3.3.x treats the first thread that initializes it as the main +thread, and the next allocation on a new thread segfaults once that thread +has exited. The test suite imports on the main thread, which never exits, +so only a fresh interpreter can exercise this path. +""" + +import subprocess +import sys +import textwrap + + +_REPRO = textwrap.dedent("""\ + import threading + + modules = {} + + def first_import(): + import aerospike_native + modules["native"] = aerospike_native + + loader = threading.Thread(target=first_import) + loader.start() + loader.join() + native = modules["native"] + + def build_keys(): + for _ in range(300): + [native.Key("test", "users", f"user-{i}") for i in range(50)] + native.ClientPolicy() + + workers = [threading.Thread(target=build_keys) for _ in range(8)] + for worker in workers: + worker.start() + for worker in workers: + worker.join() + print("OK") +""") + + +def test_use_after_importing_thread_exits(): + result = subprocess.run( + [sys.executable, "-c", _REPRO], + capture_output=True, + text=True, + timeout=60, + ) + # A crash shows up only as a negative returncode (killed by signal). + assert result.returncode == 0, ( + f"returncode={result.returncode} (negative => killed by signal)\n" + f"--- stderr ---\n{result.stderr}" + ) + assert result.stdout.strip() == "OK"