[BUG] Native installer produces a binary with the correct size but the wrong contents; hangs pre-main on a pthread_mutex_lock
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Native installer produces a binary with the correct size but the wrong contents; hangs pre-main on a pthread_mutex_lock
Summary
On Linux x86-64, every binary placed in ~/.local/share/claude/versions/ by the native
installer hangs indefinitely at startup. The npm-delivered binary for the same version
works fine on the same machine.
The installed file has exactly the size published in the release manifest
(250162696 bytes for linux-x64, v2.1.247) but a different SHA-256. Roughly 9 MB of
its contents differ from the npm-delivered copy, which hashes to the manifest's published
checksum.
Part of the divergence lands inside the region backing .bss. The runtime then reads a
non-zero value where it expects zeroed memory, treats it as a held mutex, and blocks
forever on a lock no thread owns.
Environment
| | |
|---|---|
| OS | Ubuntu x86-64 |
| Kernel | 7.0.0-30-generic |
| libc | glibc (/lib/x86_64-linux-gnu/libc.so.6, 2125328 bytes) |
| Filesystem | ext4 on /dev/sda2, fully allocated (no sparse extents) |
| RAM | 32 GB |
| Install method | native installer (curl -fsSL https://claude.ai/install.sh \| bash) |
| Affected versions |2.1.238, 2.1.241, 2.1.246, 2.1.247 — all of them |
| Working version | 2.1.247 via npm install -g @anthropic-ai/claude-code |
This is a plain VM. No endpoint security software, no antivirus, no HTTP(S)_PROXY set.
The checksum mismatch
Published manifest for v2.1.247 (https://downloads.claude.ai/claude-code-releases/2.1.247/manifest.json):
"linux-x64": {
"binary": "claude",
"checksum": "5fb321bf417ffc5cd4e3f36e7c9c7e029bf47aaa36d5621db979fcc5e6eabe15",
"size": 250162696
}
What the two install paths actually produced:
$ sha256sum ~/.local/share/claude/versions/2.1.247
f6c7c295a3f94a3466e2a3ff12b1636ae7927989a6e60b471465ad9f229d6d0b <-- native installer, HANGS
$ sha256sum /usr/local/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe
5fb321bf417ffc5cd4e3f36e7c9c7e029bf47aaa36d5621db979fcc5e6eabe15 <-- npm, WORKS (matches manifest)
$ stat -c %s /usr/local/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe \
~/.local/share/claude/versions/2.1.247
250162696
250162696
Both report 2.1.247 (Claude Code). Both have byte-identical section tables:
[29] .bun PROGBITS 00000000054d9000 52d9000 9bab683 00 WA 0 0 16384
[35] .bss NOBITS 00000000052f14c0 50f1488 1e7028 00 WA 0 0 64
cmp -l between them reports roughly 9 MB of differing bytes.
The hash of the bad file is stable across repeated reads, so this is not a transient
read error. The file is fully allocated on ext4 with no holes (filefrag shows 7
contiguous extents covering all 61075 blocks), so this is not sparse-file zero-fill
weirdness either.
Symptom
claude produces no output and never returns. It must be killed. This affectsclaude --version as well as an interactive session, so it is not TUI- or
config-related. --safe-mode, a fresh ~/.claude.json, and removing all MCP servers
make no difference — the process never reaches any of that code.
$ timeout 20 strace -f -tt -o cc.log ~/.local/share/claude/versions/2.1.247
Tail of the trace — note there is no clone3, so the process is still single-threaded,
and it has not opened a single file of its own:
3138 14:28:18.834750 prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, ...}) = 0
3138 14:28:18.835006 munmap(0x7b9f9834b000, 95247) = 0
3138 14:28:18.835308 futex(0x5326380, FUTEX_WAIT_PRIVATE, 2, NULL) = ? ERESTARTSYS
3138 14:28:39.596495 --- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=3134} ---
3138 14:28:39.597168 +++ killed by SIGTERM +++
The block is one syscall after the dynamic linker finishes — i.e. in a static
initializer, before main.
Backtrace
$ gdb ~/.local/share/claude/versions/2.1.247
(gdb) run
^C
Program received signal SIGINT, Interrupt.
futex_wait (private=0, expected=2, futex_word=0x5326380) at ../sysdeps/nptl/futex-internal.h:146
(gdb) info threads
Id Target Id Frame
* 1 Thread 0x7ffff7ea6fc0 (LWP 3232) "2.1.247" futex_wait (...)
(gdb) bt
#0 futex_wait (private=0, expected=2, futex_word=0x5326380) at ../sysdeps/nptl/futex-internal.h:146
#1 __GI___lll_lock_wait (futex=futex@entry=0x5326380, private=0) at ./nptl/lowlevellock.c:49
#2 0x00007ffff7ca01e1 in lll_mutex_lock_optimized (mutex=0x5326380) at ./nptl/pthread_mutex_lock.c:48
#3 ___pthread_mutex_lock (mutex=0x5326380) at ./nptl/pthread_mutex_lock.c:93
#4 0x0000000002359df9 in ?? ()
Frames past #4 are unreliable (gdb fell back to stack scanning; #5 is a stack address,
not code). Frame #4 is inside the executable's r-xp segment.
One thread, waiting on a mutex, with no other thread in the address space that could ever
release it or issue a wake.
Why this points at the file contents
0x5326380 is inside .bss (0x52f14c0–0x54d84e8). Because .bun sits immediately
after .bss at 0x54d9000 and is file-backed, the whole RW PT_LOAD is mapped from the
file as one contiguous VMA — /proc/<pid>/maps shows 0x52be000–0xf085000 with no
anonymous region anywhere. So correctness here depends on those file bytes being zeros.
In the bad file they are not:
(gdb) x/6dw 0x5326380
0x5326380: 2 -5 200720 24
0x5326390: 0 -1
$ xxd -s 0x5126380 -l 24 ~/.local/share/claude/versions/2.1.247
05126380: 0500 0000 fbff ffff 1010 0300 1800 0000 ................
05126390: 0000 0000 ffff ffff ........
(The RW segment's load delta is 0x200000, so vaddr 0x5326380 = file offset 0x5126380.)
Five of the six words are byte-identical between disk and memory. Only the first changed,
5 → 2, which is glibc's contended-lock path: it found the word non-zero, exchanged in 2 to
mark "locked with waiters," and called FUTEX_WAIT. Nothing ever unlocks it, because
nothing ever locked it. Interpreted as a pthread_mutex_t, the neighbouring fields are
nonsense (__count = -5, __nusers = 24 in a single-threaded process), confirming this is
not real lock state — just data that happens to sit where a lock is expected. glibc only
reads __kind (0 here, non-recursive) and __lock, so it waits regardless.
This also explains why different versions fail at different points: each carries different
unexpected content at different offsets, so each wedges wherever it first touches a
poisoned word. 2.1.238 got as far as spawning a thread and reserving its JS heap before
parking on FUTEX_WAIT_BITSET; 2.1.247 dies before main.
Ruled out
- Kernel / futex layer — an unrelated threaded round-trip works:
python3 -c "import threading,queue; q=queue.Queue(); threading.Thread(target=q.put,args=(1,)).start(); print(q.get(timeout=5))" prints 1. And the npm binary runs on this same kernel.
- ELF layout / loader — the working binary has an identical section table and identical
.bss/.bunaddresses. - Filesystem — ext4, fully allocated, no sparse extents, hash stable across reads. Copying to
/tmp(a different mount) still hangs. - Config / MCP / hooks / TUI / auth / network — the process deadlocks before
main, before opening any of its own files, before any socket exists. - Endpoint security, antivirus, TLS-inspecting proxy — none present; bare VM, no proxy env vars.
- Disk or RAM flakiness — repeated
sha256sumof the same file is stable.
Expected vs actual
Expected: the file the native installer leaves in ~/.local/share/claude/versions/<version> matches the checksum published in that version's manifest.json.
Actual: it has the manifest's exact size but a different SHA-256, and ~9 MB of contents differ from the npm-delivered binary of the same version.
The part I can't investigate
A same-length rewrite is an odd failure mode — it doesn't look like truncation or a stream
being mangled in transit, both of which would change the size. Whether the installer is
expected to modify the binary in place after download (which would make the checksum
mismatch benign and mean the real bug is elsewhere), and whether manifest.json's checksum
is meant to describe the installed file or only the download artifact, are things only you
can confirm.
If it helps, I still have the bad files and can run anything against them.
Workaround
npm install -g @anthropic-ai/claude-code
rm -f ~/.local/bin/claude # remove the native installer's symlink
rm -rf ~/.local/share/claude/versions
Make sure $(npm prefix -g)/bin precedes ~/.local/bin in PATH.
What Should Happen?
Claude code as installed by the installer should start.
Error Messages/Logs
No output at all - just hangs. No segfault, nothing, just a deadlock
Steps to Reproduce
run "claude"
Claude Model
None
Is this a regression?
Yes, this worked in a previous version
Last Working Version
2.1.239
Claude Code Version
2.1.247
Platform
Anthropic API
Operating System
Ubuntu/Debian Linux
Terminal/Shell
Xterm
Additional Information
Also fails with 2.1.2* versions