Repository navigation
pyunwind: read the thread pointer on arm64, and measure what the walk needs - #208
Merged
Merged
Conversation
… needs First half of #206. Python frames are the only amd64-only part of the agent, and the gap is narrower than it looks: pywalk_arm64_bpfel.o is already committed, tsdOffsets already carries PthreadSize ("arm64 only; 0 on amd64"), and both hostTSSGet and bpf/interp/python/python_walk.h already subtract it. What was missing is Go-side. Thread pointer. The ptrace machinery -- seize, interrupt, the bounded spin, and the detach that must not be skipped -- is architecture-independent and moves to tlsbase_ptrace.go, leaving each architecture one function: amd64 PTRACE_GETREGS, user_regs_struct carries the FS base directly arm64 PTRACE_GETREGSET with NT_ARM_TLS, whose payload is TPIDR_EL0 Both return the RAW register. The PthreadSize correction is deliberately not applied here: hostTSSGet and the BPF walker each do it already, so doing it here too would subtract twice on arm64 and not at all on amd64. Measurement. The amd64 row in glibcTSDOffsets was measured with a probe that was described in a comment and never committed, so nothing guarded it and nothing could reproduce it for a second architecture. That probe now exists as pyunwind/internal/tsdprobe: create three real pthread keys, store markers through the real API, and find them relative to `struct pthread`. Three keys rather than one because the stride between them is what distinguishes sizeof(pthread_key_data) from a coincidence. PthreadSize falls out of the same probe without a second method, which is what makes measuring arm64 possible at all: on glibc, pthread_self() IS the struct pthread *, so the distance to __builtin_thread_pointer() is exactly the TLS_PRE_TCB_SIZE the walk has to undo. Zero on x86-64 (TLS variant II, thread pointer at the struct), non-zero on aarch64 (variant I, struct before the TCB). It is a package rather than a _test.go file because Go does not permit cgo in test files, and this has to be real C calling the real pthread API -- the point is to observe where glibc puts things, not to restate a header. Verified on this machine: the probe independently derives Specific1stBlock=0x310, KeyDataSize=16, KeyDataOff=8, PthreadSize=0 for amd64, matching the committed row it had no access to, and the test fails if the row is altered. glibcTSDOffsets still has NO arm64 row. Filling it needs the probe run on aarch64, which this branch exists to do: tests run only on pull requests, on native arm64 runners, so the arm64 arm of this PR is expected to fail and its failure message carries the four measured numbers.
CI measured them on a native aarch64 runner (Ubuntu 24.04 arm64, image 20261002.596): Specific1stBlock=0x110 KeyDataSize=16 KeyDataOff=8 PthreadSize=1856 KeyDataSize and KeyDataOff come out identical to amd64, which is a useful check on the measurement rather than an assumption: pthread_key_data is the same struct on both LP64 targets. PthreadSize is 1856 there and 0 on amd64 because the two use different TLS variants -- x86-64 is variant II, where the thread pointer IS the struct pthread address, and aarch64 is variant I, where the struct sits before it. A bounds bug in the probe, found by running the whole suite rather than one test. The scan window was 4096 bytes from the start of struct pthread. On x86-64 the struct is about 2.3 KB and the static TLS block is at NEGATIVE offsets from the thread pointer, so scanning forward past the struct walked into unmapped memory and took the test binary down with SIGSEGV -- a crash with one of the probe's own markers still in rcx. It survived a single-test run and only died under the full suite, which is the worst way for a bounds bug to behave. The window is now 1536, inside the struct on both targets, and the scan stops once all three markers are found. Two existing tests asserted arm64 is REFUSED and now fail, correctly: the behaviour changed on purpose. They move to architectures that are still unmeasured (riscv64, 386, ppc64le, s390x), and gain a counterpart -- TestGlibcTSDOffsetsServesMeasuredArches -- asserting the other half, that every architecture the agent ships on HAS a row. Without it, dropping a row would make Python frames quietly unavailable on that architecture with nothing failing to say so. This does NOT finish #206. prepareInfoForArch gets past glibcTSDOffsets on arm64 now and reaches ParseAutoTSSKeyRef, which decodes the interpreter's prologue as x86-64 machine code and recognises three shapes, all x86-64. Its own comment anticipated this: "an arm64 shape becomes worth measuring on the day glibc's TSD layout is measured for arm64, and not before." That day is now. Until a shape is added, an arm64 target is refused by name with ErrTSSPatternUnrecognised rather than walked wrongly, which is the designed failure.
Second half of #206, and the reason it needed the first: the file's own comment said an arm64 shape "becomes worth measuring on the day glibc's TSD layout is measured for arm64, and not before". That day was the previous commit. This is not a data addition. Every existing shape reads its reference as one signed 32-bit displacement sitting whole in four little-endian bytes, measured from the end of its instruction. aarch64 has no such thing: a page-relative reference is ADRP plus ADD, and the part this parser wants is a 12-bit immediate at bits 21:10 of the ADD. Read as an int32 those four bytes are the encoded instruction, not an offset. So tssShape gains a fieldEnc, and fold() learns the second encoding -- including bit 22, the shift flag, which scales the immediate by 4096 when set. The shape is measured from Ubuntu 24.04's libpython3.12.so.1.0 (3.12.3-1ubuntu0.17, arm64), disassembled rather than guessed: 76 bytes, nineteen instructions, with the reference appearing twice as `add x0, x19, #0x608`. Four instructions are deliberately not pinned, because their immediates are link-time values that differ per build: the ADRP page, the GOT slot in the LDR, and the two 26-bit branch targets. Pinning them would make the shape match exactly one build of one distro. The CBZ is pinned, because its target is inside this body and so is a property of the shape. Two bytes of each ADD are pinned and two are not. The immediate must stay free -- it is what differs between CPython versions (0x608 on 3.12, 0x870 on 3.13) -- but Rd, the low bits of Rn, and the opcode byte do not vary, and pinning them is what stops some other instruction being read as an ADD and its bits 21:10 reported as an offset. That gap was visible only from writing the corruption case, which is why the case is in this commit rather than left for later. It decodes to 0x608, the same value the x86-64 3.12 fixture gives. That agreement is the strongest check here: autoTSSkey's position inside _PyRuntime is a struct layout fact, so the same CPython version must yield the same offset on both LP64 targets. A new decoder returning a plausible but different number would fail on that, not on byte matching alone. TestParseAutoTSSKeyRefRefusesEverySingleByteCorruption now covers the arm64 fixture too. Its unchecked list is exactly the four link-varying instructions: the ADD immediates are NOT excluded, because a single-byte flip can only corrupt one of the two and the cross-check then sees them disagree.
Lint caught encRIPDisp32 as unused: the x86-64 shapes reach it by leaving enc at its zero value, so the name was never written down. Silencing that would have kept a two-valued switch written as one if and an implicit else. fold() now switches on both, with a default that panics rather than falling through to the x86 reading. A third encoding should fail loudly at the one place that has to understand it, not quietly decode an ADD as a signed displacement.
Three live tests gated on `runtime.GOARCH != "amd64"`, giving the reason "arm64 is a named refusal". That reason stopped being true when #206 measured arm64's glibc TSD offsets, but the gates did not, so the arm64 runner reported those tests as skipped while the support they were skipping for was present. Measured-but-never-exercised is the worst of both: the table says the walk works there and nothing has ever made it walk. They now ask glibcTSDOffsets whether this architecture has a row, which is the same question the production path asks. An architecture with no row still skips, with the refusal quoted. One that has a row runs, so the act of measuring a future architecture is also the act of putting its end-to-end path under test. Verified on amd64 that all three still run rather than skipping -- a gate rewritten into something that never fires would look identical in CI to one that was correctly relaxed.
…umber
CI refused the interpreter it actually reaches. The arm64 runner's
/usr/bin/python3.12 has a 72-byte body and the shape added last commit is
76 -- measured from libpython3.12.so.1.0, the shared library, which that
executable does not use. This is the loop the file comment already
describes for x86-64, where the 37-byte shape was found the same way.
The two differ by one instruction, and that instruction changes the
meaning:
lib: adrp x19, page ; ldr x19, [x19, #slot] ; add x0, x19, #0x608
exe: adrp x19, page ; add x0, x19, #0xab8
The library loads &_PyRuntime through the GOT, so its immediate is an
offset inside that struct. The executable has no GOT hop: x19 is a page and
the immediate is the low 12 bits of the field's own address. Decoding the
second as a runtime offset yields 0xab8 -- a plausible number pointing at
nothing.
So this is a fourth AutoTSSKeyRefKind rather than another shape.
KeyRefA64PageRelative cannot collapse into KeyRefBodyRelative ("BodyVaddr
plus a constant") because ADRP is relative to the 4 KB page its own
instruction sits in: the answer depends on BodyVaddr's low bits as well as
its value, and no constant folded at parse time can carry that. Resolve
does the page arithmetic, and refuses without a body address rather than
resolving against zero and landing in the first page.
Verified against the binary rather than against itself: decoded and
resolved, autoTSSkey lands 0x608 into _PyRuntime -- the same offset the
x86-64 and aarch64 shared-library fixtures give directly. Three builds,
two architectures, two linkage models, one layout fact. That agreement is
what catches a page-alignment or sign-extension error, which byte matching
cannot see.
A gap the fixtures could not close: every real body measured so far
addresses a page ABOVE its own, so the sign-extension branch never ran, and
deleting it left the end-to-end test passing. TestA64AdrpPageDeltaSignExtends
covers it with built words, and its own encoder is validated against the
fixture's real instruction so the synthetic cases cannot be vacuous. Without
that, a target page below the instruction would resolve 4 GB away.
#206 measured the glibc TSD offsets for aarch64 and added the two shapes its interpreters use, so the "amd64 + glibc only" limitation is no longer true. CI now runs the live-interpreter tests on the arm64 runner and they pass against /usr/bin/python3.12: the TSD lookup returns the same PyThreadState the interpreter itself reports, on two threads. The entry says what is covered there and what is not. CI validates the ATTACH path on arm64 -- thread pointer, offsets, shape decode, TSD lookup -- not the BPF walker emitting Python frames into a profile. The walker object is built for aarch64 and shares the same offsets, but no test on an arm64 runner observes frames coming out of it. Calling that "supported" without the distinction would be claiming a measurement nobody took.
dpsoft
marked this pull request as ready for review
October 14, 2026 16:09
Two places. The supported-range line is now wrong -- #206 added arm64 -- and the Py_tss_t layout paragraph said "on amd64" about a struct that is 8 bytes on both supported architectures, since int and pthread_key_t are 4 bytes on each. The second was never arch-specific, only written while one architecture was all there was.
It said tlsBase refuses on every architecture but linux/amd64 and that the arm exists "to keep the package building for arm64" -- both written when amd64 was the only measured layout. arm64 now has a measured layout and a real implementation in its own file, so this file is what is left over, not the arm64 path.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First half of #206. Draft on purpose: the arm64 test arm is expected to
fail, and its failure is the measurement.
Why this is narrower than "add arm64 support"
Most of it is already there:
pyunwind/pywalk_arm64_bpfel.ois committed beside the x86 object — theBPF walker already builds for arm64.
tsdOffsetsalready carriesPthreadSize uint32 // arm64 only; 0 on amd64,and both consumers already subtract it:
hostTSSGet(attach.go) andbpf/interp/python/python_walk.h.What was missing is Go-side, and it is two things.
1. The thread pointer
The ptrace machinery — seize, interrupt, the bounded spin, and the detach
that must not be skipped — is architecture-independent and moves to
tlsbase_ptrace.go, leaving each architecture exactly one function:PTRACE_GETREGS;user_regs_structcarries the FS basePTRACE_GETREGSETwithNT_ARM_TLS, payload isTPIDR_EL0Both return the raw register. The
PthreadSizecorrection isdeliberately not applied there — the two consumers above already do it, so
doing it here as well would subtract twice on arm64 and not at all on amd64.
2. A probe that can measure a second architecture
The amd64 row was measured with a probe that was described in a comment and
never committed. So nothing guarded it, and nothing could reproduce it for
another architecture.
pyunwind/internal/tsdprobeis that probe, committed: create three realpthread keys, store markers through the real API, find them relative to
struct pthread. Three keys rather than one because the stride between themis what separates
sizeof(pthread_key_data)from a coincidence.The part that makes arm64 measurable at all: on glibc,
pthread_self()isthe
struct pthread *, so the distance to__builtin_thread_pointer()isexactly the
TLS_PRE_TCB_SIZEthe walk has to undo. No second method needed.Zero on x86-64 (TLS variant II), non-zero on aarch64 (variant I).
It is a package rather than a
_test.gobecause Go does not permit cgo intest files, and this has to be real C calling the real pthread API.
Validated where the answer is already known
On amd64 the probe independently derives
Specific1stBlock=0x310,KeyDataSize=16,KeyDataOff=8,PthreadSize=0— matching the committedrow it has no access to. Mutation-checked: changing the table to
0x318fails the test with "measured 0x310 … table says 0x318".
What this PR does not have yet
glibcTSDOffsetshas no arm64 row. Filling it needs the probe run onaarch64, and tests run only on pull requests, on native arm64 runners — so
this PR is the measurement vehicle. The arm64 job will fail with the four
numbers in the message; I will add the row and mark this ready.
Precedent: #152 did the same thing for the shim's USDT register binding,
proving it on a real arm64 runner rather than asserting it.