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
7 changes: 7 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -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"]
74 changes: 74 additions & 0 deletions .github/scripts/check_macho_strtab.py
Original file line number Diff line number Diff line change
@@ -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 <wheel> [<wheel> ...]

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("<I", data)[0] == MH_MAGIC_64:
yield data


def string_pool_offsets(image: bytes):
"""Yield the stroff of every LC_SYMTAB in a 64-bit Mach-O image."""
ncmds = struct.unpack_from("<I", image, 16)[0]
pos = 32
for _ in range(ncmds):
cmd, cmdsize = struct.unpack_from("<II", image, pos)
if cmd == LC_SYMTAB:
yield struct.unpack_from("<I", image, pos + 16)[0]
pos += cmdsize


def check_wheel(path: str) -> 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()
9 changes: 9 additions & 0 deletions .github/workflows/build-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
69 changes: 69 additions & 0 deletions python/tests/unit/thread_exit_test.py
Original file line number Diff line number Diff line change
@@ -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"
Loading