Skip to content

Fix 32-bit Linux and Raspberry Pi loading, load the library lazily, document binary provenance - #158

Merged
FlorianREGAZ merged 6 commits into
mainfrom
fix/platform-loading
Oct 10, 2026
Merged

FlorianREGAZ merged 6 commits into
mainfrom
fix/platform-loading

Conversation

@FlorianREGAZ

@FlorianREGAZ FlorianREGAZ commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Fixes the native-library problems in four open issues: 32-bit Linux, Raspberry Pi, fork() hangs, and the question of where the binaries come from.

This builds on #154, which already stopped committing binaries, added per-platform wheels, and made unknown platforms raise UnsupportedPlatformError instead of loading the wrong file. So no binaries are committed here either: the new libraries are new entries for scripts/fetch_library.py, SHA256SUMS, hatch_build.py and the Wheels workflow, and each gets its own wheel.

#145 and #111: 32-bit x86 and armv7 Linux

  • cffi.LINUX_LIBRARIES maps each platform.machine() value to its 64-bit and 32-bit library, and the pointer size picks one. That also covers a 32-bit userland on a 64-bit kernel, such as 32-bit Raspberry Pi OS on a Pi 4, which reports aarch64.
  • Two upstream v1.16.0 assets were added; upstream has no non-xgo 32-bit x86 Linux build, so that one comes from its xgo build. Both need glibc 2.4 at most, so they're tagged manylinux_2_17_i686 and manylinux_2_17_armv7l, the same 2_17 floor as the other Linux wheels.
  • GitHub has no 32-bit runners, so a new smoke-test-container job installs each wheel in a linux/386 or linux/arm/v7 (QEMU) python:3.12-slim container and runs scripts/smoke_test.py. publish waits for it.
  • ARMv6 (armv6l: Raspberry Pi 1 and Zero) stays unsupported, with an error message that says why. Upstream's armv7 build is GOARM=7, and the Go runtime exits the process on a CPU without ARMv7/VFPv3. Upstream's xgo linux-arm-6 and linux-arm-5 builds are soft-float (ELF flags 0x5000200), so a hard-float Python such as Raspberry Pi OS's can't load them.
  • The unsupported-platform error now also links the upstream releases and this repo's issues.
platform.machine() 64-bit Python 32-bit Python
x86_64, amd64 tls-client-linux-ubuntu-amd64.so tls-client-linux-386.so (new)
i686, i386 n/a tls-client-linux-386.so (new)
aarch64, arm64 tls-client-linux-arm64.so tls-client-linux-armv7.so (new)
armv8l, armv7l n/a tls-client-linux-armv7.so (new)
armv6l, i586, others, musl error error

macOS, Windows and Cygwin detection is unchanged.

Library files: old and new

Before #154 (committed) Now (fetched, one per wheel) Upstream v1.16.0 asset
tls-client-x86.dylib tls-client-darwin-amd64.dylib tls-client-darwin-amd64-1.16.0.dylib
tls-client-arm64.dylib tls-client-darwin-arm64.dylib tls-client-darwin-arm64-1.16.0.dylib
tls-client-x86.so tls-client-linux-ubuntu-amd64.so tls-client-linux-ubuntu-amd64-1.16.0.so
tls-client-amd64.so (was the Alpine build, also loaded on i686/armv7) n/a (no musl wheel) tls-client-linux-alpine-amd64-1.16.0.so
tls-client-arm64.so tls-client-linux-arm64.so tls-client-linux-arm64-1.16.0.so
(none) tls-client-linux-386.so (new) tls-client-xgo-1.16.0-linux-386.so
(none) tls-client-linux-armv7.so (new) tls-client-linux-armv7-1.16.0.so
tls-client-32.dll tls-client-windows-32.dll tls-client-windows-32-1.16.0.dll
tls-client-64.dll tls-client-windows-64.dll tls-client-windows-64-1.16.0.dll

#101: hangs under multiprocessing

cffi.py loaded the library, and so started the Go runtime, at import time. A Go runtime doesn't survive fork(), so a forked child hung on its first request even if the parent had only imported tls_client. Now cffi.load_library() (functools.cache) loads the library on first use. request, freeMemory and destroySession are thin wrappers around it, so sessions.py and the unit-test fakes that monkeypatch those names don't change.

A new README section, "Multiprocessing and gevent", says to use the spawn or forkserver start method if the parent has already made requests, and to run requests through gevent.get_hub().threadpool under gevent, because a native call blocks the hub.

A separate commit also sets freeMemory.restype = None. The Go function returns void, and with a c_char_p restype ctypes read the leftover return register as a string pointer.

#34: binary provenance

  • The pinned hashes moved out of fetch_library.py into tls_client/dependencies/SHA256SUMS, in sha256sum format. It ships in the sdist and every wheel, so an installed library can be checked with sha256sum --check --ignore-missing SHA256SUMS.
  • python scripts/fetch_library.py --update-checksums regenerates it. It downloads every library of VERSION, compares each against the SHA-256 digest GitHub lists for the release asset, fails on a mismatch, and rewrites the file. Upstream publishes no checksum file of its own, so GitHub's asset digests are the only published checksums. All 8 matched.
  • A new README section, "Native library", says the libraries are unmodified bogdanfinn/tls-client v1.16.0 release assets, built upstream with cffi_dist/build.sh. It lists each asset and explains how to verify and update them.

Refs #34: the per-platform half of the issue is already covered by the per-platform wheels from #154. One gap remains, proposed below.

Proposal: closing the rest of #34

The wheels already ship only their own platform's library. What's left is the sdist and unsupported platforms. Installing from the sdist currently fails in hatch_build.py because no library is present. I'd make the build hook fetch the right library, with the checksum verified against SHA256SUMS, when building a wheel from the sdist for the current machine. It would reuse cffi.library_filename()'s mapping and fetch_library.py, behind an opt-out environment variable for offline or distro builds such as the AUR package from the issue. Distro packagers could then build from the sdist and get exactly one verified library, with no workaround of deleting files.

Fixes

Fixes #145
Fixes #111
Fixes #101
Refs #34

Testing

  • uv run ruff check, uv run ruff format --check and uv run mypy (strict): pass.
  • uv run pytest on macOS arm64: 123 passed, 9 xfailed (all pre-existing), coverage 91.96%. New tests:
    • tests/unit/test_cffi.py: platform mapping cases for i686, i386, armv7l, armv8l, and 32-bit Python on x86_64/aarch64 kernels; armv6l, i586 and musl armv7l as unsupported; the armv6 explanation; the releases link; every expected filename listed in SHA256SUMS; a fresh interpreter where import tls_client plus Session() never calls ctypes.cdll.LoadLibrary; load_library() loading once.
    • tests/integration/test_multiprocessing.py: a fresh parent imports tls_client without using it, then a fork-started child requests the local httpbin. The child has a 30 s join timeout and the subprocess a 60 s timeout, and the test is skipped where fork isn't available.
  • Linux, by hand in Docker on Apple Silicon (python:3.12-slim-bookworm), installing wheels built from this branch:
    • linux/386 (QEMU): pip accepts the manylinux_2_17_i686 wheel, scripts/smoke_test.py tls-client-linux-386.so passes, and a forked child's request succeeds.
    • linux/arm/v7 (QEMU): same for the manylinux_2_17_armv7l wheel, with platform.machine() reporting armv7l.
    • linux/arm64: the smoke test passes. A fork after the library is loaded, whether eagerly as before or after a request in the parent, hangs, which reproduces tls_client will be stuck in multiprocessing #101. With lazy loading and no request in the parent, the forked child succeeds. On macOS the eager case crashes the child with fatal error: runtime: netpollBreak write failed.
  • I checked the binaries with file and llvm-readelf:
    • 386: ELF 32-bit Intel 80386, GO386=sse2.
    • armv7: ELF 32-bit ARM EABI5 with hard-float flags 0x5000400, GOARM=7.
    • Both need glibc 2.4 at most, and their SHA-256 matches GitHub's asset digests.
  • CI on this PR passed everything: Lint, Test on all 15 OS × Python combinations, all 8 wheel builds, and all 8 smoke tests, including the new linux-386 and linux-armv7 container jobs.

🤖 Generated with Claude Code

FlorianREGAZ and others added 6 commits October 10, 2026 13:37
Loading the library starts the Go runtime, and a Go runtime doesn't
survive fork(). Because cffi.py loaded it at import time, a child forked
from a parent that had only imported tls_client hung (Linux) or crashed
(macOS) on its first request.

cffi.load_library() now loads it once, on the first call, and request,
freeMemory and destroySession are thin wrappers around it, so sessions.py
keeps its imports. The README explains when to use the spawn or
forkserver start method, and how to keep gevent's hub responsive.

Fixes #101

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Go function returns void, but its restype was c_char_p, so ctypes
read whatever was left in the return register as a string pointer and
copied the bytes it pointed to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…come from

The pinned hashes move from fetch_library.py into
tls_client/dependencies/SHA256SUMS, in sha256sum format, so it ships in
every wheel and the sdist and anyone can check an installed library with
sha256sum --check. fetch_library.py --update-checksums regenerates it:
it downloads every library of the release, checks it against the digest
GitHub lists for the asset (upstream publishes no checksum file), and
fails on a mismatch.

The README gains a section saying the libraries are unmodified assets of
the bogdanfinn/tls-client v1.16.0 release, which asset each one is, and
how to verify and update them.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two more upstream assets, with their checksums in SHA256SUMS:

  tls-client-linux-386.so    <- tls-client-xgo-1.16.0-linux-386.so
  tls-client-linux-armv7.so  <- tls-client-linux-armv7-1.16.0.so

Upstream's own builds have no 32-bit x86 Linux library, so that one is
its xgo build. Both are glibc libraries needing at most GLIBC_2.4; the
386 build is GO386=sse2, the armv7 build GOARM=7 with the hard-float
ABI. They become manylinux_2_17_i686 and manylinux_2_17_armv7l wheels.
GitHub has no 32-bit runners, so the Wheels workflow smoke-tests them
in linux/386 and linux/arm/v7 (QEMU) python:3.12-slim containers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Linux detection only knew 64-bit x86_64 and aarch64, so 32-bit Pythons
(i686, armv7l, and 32-bit userlands on 64-bit kernels such as 32-bit
Raspberry Pi OS on a Pi 4) got UnsupportedPlatformError. Before that they
silently loaded a 64-bit library.

LINUX_LIBRARIES now maps each platform.machine() value to its 64-bit and
32-bit library, and the pointer size picks one: x86_64, i686 and i386 to
the amd64 or 386 build; aarch64, armv8l and armv7l to the arm64 or armv7
build. ARMv6 stays unsupported with an explanation, because the armv7
build needs ARMv7 and upstream's ARMv6 build is soft-float. Unsupported
platforms now also point to the upstream releases.

Fixes #145
Fixes #111

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@FlorianREGAZ
FlorianREGAZ merged commit 99ca69e into main Oct 10, 2026
34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Doesn't work in 32 bit Installing on Raspberry Pi OS does not automatically install dependencies tls_client will be stuck in multiprocessing

1 participant