Skip to content

Don't fault on an unaligned lock cmpxchg8b - #2813

Closed
emkey1 wants to merge 1 commit into
ish-app:masterfrom
emkey1:fix_unaligned_cmpxchg8b
Closed

emkey1 wants to merge 1 commit into
ish-app:masterfrom
emkey1:fix_unaligned_cmpxchg8b

Conversation

@emkey1

@emkey1 emkey1 commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

On aarch64, lock cmpxchg8b on an address that isn't 8-byte aligned goes straight to segfault_write. x86 allows any alignment, and the i386 ABI aligns uint64_t struct fields to 4 bytes, so 64-bit atomics on struct fields land here. Python 3.14, which Alpine 3.24 ships, does one during startup, so python3 can't run at all.

It shows up as a SIGSEGV or a hang. The branch never sets tlb->segfault_addr, so the fault reports whatever address is left there. If that address is mapped, the page-fault handler returns and the instruction retries forever.

This does the unaligned case in C (helper_cmpxchg8b_unaligned), reading and writing through the TLB under a lock. A page-straddling operand works, and a real fault reports its own address. The aligned ldaxr/stlxr path is unchanged, and so is the x86_64 host, whose own lock cmpxchg8b handles alignment.

Tested with the new tests/e2e/cmpxchg8b, in which four threads increment a 4-byte-aligned uint64_t mid-page and across a page boundary. It segfaults before this change and counts correctly after. On the Alpine 3.24 x86 rootfs, python3 now starts and runs stdlib imports, threads, HTTPS and venv.

🤖 Generated with Claude Code

x86 allows any alignment, and the i386 ABI aligns uint64_t struct fields
to 4 bytes, but the aarch64 gadget sent every address that isn't 8-byte
aligned to segfault_write without setting tlb->segfault_addr. The guest
got a SIGSEGV or, if the stale address was mapped, retried forever.
Python 3.14 (Alpine 3.24) hits this at startup.

Do the unaligned case in C, through the TLB under a lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@saagarjha

Copy link
Copy Markdown
Member

This is not correct; you can't just put a lock around cmpxchg to fix its semantics. Doing this properly is quite difficult and the segfault was intentionally added in lieu of that. #1429 has some discussion from a past attempt at trying to implement this.

@saagarjha saagarjha closed this Sep 26, 2026
@emkey1

emkey1 commented Sep 26, 2026

Copy link
Copy Markdown
Contributor Author

Fair enough, and thanks for the pointer to #1429. This was sent as the smallest change that gets Python 3.14 running; a fully correct unaligned atomic is a much bigger piece of work, and it would help to agree on a direction before anyone spends that time.

One thing worth knowing either way: the segfault path doesn't fire reliably. It branches to segfault_write without setting tlb->segfault_addr, so the fault reports whatever address is left there. When that address happens to be mapped, the handler returns and the instruction retries forever, which is the 100% CPU hang Python 3.14 gets when started from a shell. Would you take a small PR that makes it a clean SIGBUS? And for Python itself, would ldaxp/stlxp on the enclosing 16-byte block be acceptable when the operand fits inside it, with SIGBUS only when it straddles?

@saagarjha

Copy link
Copy Markdown
Member

Would you take a small PR that makes it a clean SIGBUS?

Sure

And for Python itself, would ldaxp/stlxp on the enclosing 16-byte block be acceptable when the operand fits inside it, with SIGBUS only when it straddles?

I don't think this has the correct semantics? It's not not an atomic CAS

emkey1 pushed a commit to emkey1/ish-AOK that referenced this pull request Sep 26, 2026
A misaligned locked read-modify-write (i386 lock cmpxchg8b, and every
unaligned locked op on amd64) took the global atomic_l_lock around a read
and a write. That serialised locked ops against each other and nothing
else: a plain store from another thread could land in between and be
lost. That is the objection raised upstream (ish-app#2813, ish-app#1429).
The i386 ABI makes the 64-bit case ordinary, since it aligns uint64_t
struct fields to 4 bytes, and Python 3.14 does one at startup.

Now exact, in emu/tlb.c:
- Inside one 16-byte-aligned block: a 16-byte host compare-exchange on
  the block, changing only the operand's bytes. It is a real atomic
  against every access, other processes included.
- Straddling two blocks (and maybe two pages): the address space's lock,
  taken as a writer. No guest code or guest-memory syscall runs in the
  process while it is held. The upgrade never drops the read side
  (read_to_write_lock_timed): this runs inside the JIT holding
  jetsam_lock, and dropping it let mprotect in to wait for jetsam while
  this waited for mprotect -- a deadlock, reproduced and sampled. One
  thread upgrades at a time, siblings are poked out of the JIT, and on
  contention or timeout the instruction restarts via a resolvable fault.
  Processes sharing the page are not held off in this case.

The i386 cmpxchg8b helper now uses x86_atomic_cas.

Test: tests/manual/x86_unaligned_lock runs one thread doing locked
increments against one doing plain stores, on operands at block offsets
4, 12 and 1, across a page, and (amd64) 32- and 16-bit ones. Before this
change it lost up to 60% of the stores on amd64 and 17% on i386, while
the locked counts were exact. After it, zero lost; it passes on
alpine-i386, alpine-amd64 and devuan-amd64 and skips on arm64. The 52
futex, lock, mmap, fork, swap and JIT tests pass on i386, amd64 and
arm64. Python 3.14's unaligned atomics sit at block offset 4 (fast path)
in steady state; a few at offset 12 occur at startup. The xcodebuild
iphoneos build succeeds.

Not covered: the i386 JIT's 16- and 32-bit locked gadgets still run an
exclusive on a misaligned address and fault (queued in docs/TODO.md);
the test leaves those cases out on i386.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@emkey1

emkey1 commented Sep 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, I'll send the SIGBUS fix.

On the block approach, I think it is an atomic CAS. ldaxp/stlxp on the aligned 16-byte block is a 128-bit atomic read-modify-write; it's how a 16-byte CAS is done on ARMv8.0 without LSE. The store-exclusive only succeeds if nothing has written to the block since the load, so a store from any thread, to the operand or to the other 8 bytes, makes it fail and retry, and only the operand's bytes ever change. When the compare fails it stores the block back unchanged, so the value returned in edx:eax is an atomic snapshot, the same as x86, where a failed lock cmpxchg8b still does its locked write.

For what it's worth, my fork does exactly this now. A test running locked increments against plain stores on the same 4-byte-aligned uint64_t loses nothing; the global-lock version it replaced lost up to 60%.

@emkey1

emkey1 commented Sep 27, 2026

Copy link
Copy Markdown
Contributor Author

The SIGBUS fix is up as #2816.

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.

3 participants