Bug 25
IPC unit tests abort on 32-bit CI when GCC and clang code are mixed at -O0
- Status
- Fixed
- Symptom
- Crash; worst effect seen: crash
- Where seen
- Bitcoin Core unit tests
- Trigger
- Normal use; no special event is needed
- First reported
- 2025-01-31 (first report)
- Fixed
- 2025-03-13
Summary
With depends and the main build both at -O0, ipc_tests aborted deterministically on the i686 (32-bit x86) multiprocess CI job with a kj "Out-of-bounds ArrayPtr::slice()" exception thrown from the EventLoop constructor. kj is the support library that ships with Cap'n Proto. The job built Cap'n Proto with GCC and Bitcoin Core with clang, and on i686 the two compilers pass kj's empty PlacementNew argument differently, so a GCC-built kj::ctor called a clang-built operator new and got back a wrong pointer. This was a CI toolchain problem in third-party headers, not a defect in libmultiprocess or Bitcoin Core code, and it did not affect users. Fixed by bitcoin#31998 ("depends: patch around PlacementNew issue in capnp"), which patches Cap'n Proto in depends.
What happened
@fanquake's bitcoin#29796, which built debug builds at -O0, made the i686 multiprocess job fail in ipc_tests with kj/common.h:1797: failed: expected start <= end && end <= size_; Out-of-bounds ArrayPtr::slice(). and SIGABRT. He reproduced it on master with depends and the main build forced to -O0 and filed bitcoin#31772 (2025-01-31). ryanofsky got a gdb trace into kj::CidrRange::CidrRange called from kj::setupAsyncIo() in mp::EventLoop::EventLoop, then reduced it to a small two-compiler test case (2025-02-04 to 02-06). bitcoin#29796 was closed without being merged, so the failure never reached master CI.
Root cause
kj declares inline void* operator new(size_t, kj::_::PlacementNew, void* p) with an empty struct as the second parameter, and kj::ctor<T>() calls it. GCC and clang use different calling conventions for passing an empty class by value on i686 (a known Itanium C++ ABI gap). At -O0 nothing is inlined, and because libbitcoin_ipc_test.a precedes the kj static libraries on the link line, the linker paired the GCC-generated kj::ctor<unsigned int> from libkj with the clang-generated operator new from the Bitcoin object. The mismatched call returned the wrong pointer, so ctor never wrote its value: ArrayPtr<const char>::findFirst returned a Maybe<size_t> holding garbage, and CidrRange's constructor sliced out of bounds.
The job had mixed GCC-built depends with a clang main build since bitcoin#21812 (2021-05), which enabled _GLIBCXX_DEBUG on the job and switched it to clang to avoid an out-of-memory failure in commit fad0f21c3cab. It had been an i686 job since bitcoin#22923 ("test: Switch multiprocess to clang i686 build", 2021-09). The combination was harmless while optimization inlined operator new, which the fix's patch also notes; bitcoin#29796 removed that.
Fix
ryanofsky wrote a Cap'n Proto patch that moves the empty struct to the last parameter of the placement operator new, so both compilers agree on how the pointer argument is passed, and sent it upstream as capnproto#2235. Commit 1ef22ce33517 (fanquake) applies it in depends as depends/patches/capnp/abi_placement_new.patch (+76). bitcoin#31998 was split out of bitcoin#29796 so it could merge separately.
Timeline
- Introduced: unknown as a single change; the failure needs a clang main build over GCC depends (2021-05), the i686 target (2021-09) and
-O0depends (the unmerged bitcoin#29796), and the defect itself is in upstream kj. - 2025-01-31: bitcoin#31772 filed by fanquake.
- 2025-02-06: parameter-order patch written and sent upstream as capnproto#2235.
- 2025-03-13: fix merged in bitcoin#31998.
- CI only; no release or user affected.
Lessons
What would have prevented this bug, found it sooner, or made it easier to debug. The patterns page groups these lessons across all bugs.
Pattern CI, toolchain and platform: Objects from two compilers in one program
- Prevented by other
- Earliest detector a unit test (existed then)
- Diagnosis hard
- Fix design easy
- Debugging aids symbolized stack traces, a reproduction script
- Rust n/a
Would Rust have prevented it? Not applicable. A kj header that GCC and clang lay out differently on i686 is a C++ toolchain problem in which no libmultiprocess or Bitcoin Core code is wrong; a capnproto-rust build has one compiler and no kj, but the question does not apply to the C++ code. More on Rust
Could it have been prevented?
Only on the CI side. The defect is in a kj header (an empty struct passed by value to
placement operator new, where GCC and clang disagree on i686), so nothing in
libmultiprocess or Bitcoin Core could have avoided writing it. What made it reachable was
a job that linked GCC-built depends with a clang-built main program, chosen in 2021 to
dodge an OOM (fad0f21c3cab),
then moved to i686, then run at -O0. A rule of one compiler per link, or at least
"a mixed-compiler job is an ABI test and should be labelled as one", would have kept
the combination from being the only 32-bit coverage of IPC. The upstream fix
(capnproto#2235) reorders the
parameters so the empty struct is last; that is a library-author lesson, not one for
a user of the library.
Could it have been found sooner?
ipc_tests found it on the first -O0 run; the test existed and the failure was
deterministic. It could not have fired earlier because with any optimization the
placement new was inlined and the two compilers never met at a call boundary. So the
detector was in place and the exposure was created by
bitcoin#29796, which is the right
order of events; the only earlier signal would have been a deliberate ABI test of the
mixed link, which nobody had reason to write.
What made it hard to debug?
The symptom was an out-of-bounds slice inside kj::CidrRange during EventLoop
construction, three libraries away from the cause, and ryanofsky's first hypothesis was
a GCC miscompile. It took several days (2025-02-04 to 02-06): a
gdb trace
into findFirst returning garbage, then noticing that link order paired GCC's
kj::ctor with clang's operator new, then a
20-line two-compiler reduction
that proved it. The reduction was the turning point; a symbolized trace alone pointed
at kj, not at the toolchain. A later, undiagnosed i386 crash in another out-of-line kj
function taking an empty struct (bitcoin#34732)
suggests the same mechanism may still be live where distributions mix compilers.
Lesson
When a program is linked from objects built by two compilers, every non-inlined call
across that seam is an ABI test you did not mean to write; keep one toolchain per link,
and when a crash lands deep inside a dependency at -O0 only, suspect the seam before
the compiler.
Notes
- A 32-bit crash inside
kj::operator*(kj::_::Stringifier, const kj::Exception&), another out-of-line kj function taking an empty struct by value, was seen later in libmultiprocess's gnu32 CI job and with a GCC-built Debianlibkjused from a clang-builtbitcoin-node(bitcoin#34732, an issue reporting that i386 crash). It may be related but was never diagnosed, and the gnu32 sighting is an all-GCC build, which this mechanism does not explain; it is tracked separately.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | crash |
| Worst effect observed | crash |
| Seen in | unit-test |
| Scope | ci-infra |
| Trigger | Normal use (ordinary-path) |
| Phase | startup |
| Side | n/a |
| Component | toolchain |
| Root cause kind | other |
| Fix kind | workaround |
| Detection | ci-deterministic |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Confidence | medium |