Summary
Debug builds intermittently SIGSEGV in the debug-only lock-contention tracking:
a thread that fails a try-lock reads the current holder's SourceLocation out
of the ProxyMutex without holding anything, and SourceLocation::str() then
faults dereferencing a stale file pointer.
The cache unit tests are the only thing in the tree that enables the locks
debug tag, so this surfaces as a rare test_cache_* segfault on the Fedora CI
job. It is not a cache bug; the cache tests are just the trigger.
How we saw it in CI
The Fedora job failed on an unrelated PR (#13523, an HttpSM change) with:
26/186 Test #29: test_cache_Update_L_to_S ...............***Exception: SegFault 1.01 sec
...
99% tests passed, 1 tests failed out of 186
The following tests FAILED:
29 - test_cache_Update_L_to_S (SEGFAULT)
https://ci.trafficserver.apache.org/job/Github_Builds/job/fedora/9141/
The test's own output ends like this — note the two lock-tracking warnings
immediately before the crash:
[...] DIAG: <CacheRead.cc:224 (openReadFromWriter)> (cache_read_agg) 0x2236840: key: B7088DC0 In openReadFromWriter
[...] DIAG: <CacheVC.cc:510 (handleRead)> (cache_ram) all memory cache miss
WARNING: holding lock CacheRead.cc:789 (openReadStartEarliest) too long for UNKNOWN
[...] DIAG: <StripeSM.cc:742 (aggWriteDone)> (cache_agg) ...
WARNING: holding lock UnixEThread.cc:156 (process_event) too long for UNKNOWN
DummyLocation:18446744073709551615: FAILED:
{Unknown expression after the reported line}
due to a fatal error condition:
SIGSEGV - Segmentation violation signal
The PR under test cannot be responsible: add_cache_test builds each cache test
from unit_tests/main.cc, unit_tests/stub.cc, unit_tests/CacheTestHandler.cc
and the test source, linked against ts::inkcache and Catch2 only. Nothing from
libhttp is in the binary.
Reproduction
On master at b9b9109 with no modifications, using the CI configuration
(clang, CMAKE_BUILD_TYPE=Debug):
cmake -B build --preset ci-fedora-cxx20 -DBUILD_TESTING=ON
cmake --build build
cd build
# Loop this; it fails roughly 1 in 12-20 iterations.
for i in $(seq 1 30); do ctest -R "^test_cache_" -j 8 --output-on-failure; done
Observed:
| What |
Result |
test_cache_Update_L_to_S alone, 25 runs |
25 pass |
ctest -R "^test_cache_" -j 8, 12 iterations |
1 crash (Update_S_to_L) |
ctest -R "^test_cache_" -j 8, 30 iterations |
1 crash (Alternate_S_to_L) |
It never fails standalone, and it lands on a different test in the
Update/Alternate family each time, which is what pointed at shared code rather
than any one test.
An ASan build did not reproduce it in 15 iterations of the same loop, and runs
supervised by gdb did not reproduce it in 120 attempts — both perturb the timing
window. A concurrently running unsupervised Alternate_L_to_S did crash and
dump a core, which is where the backtrace below comes from.
Backtrace
#0 0x0000ffffb0137e50 in strrchr () from /lib64/libc.so.6
#1 0x00000000008e0708 in SourceLocation::str (this=0x2cc09858, buf=..., buflen=128)
at src/tsutil/SourceLocation.cc:48
#2 0x00000000008669e4 in lock_waiting (srcloc=..., handler=0x0)
at src/iocore/eventsystem/Lock.cc:47
#3 0x0000000000472dd0 in Mutex_trylock (location=..., ahandler=0x0, m=0x2cc09800, t=...)
at include/iocore/eventsystem/Lock.h:264
#4 0x0000000000472d04 in Mutex_trylock (...) at include/iocore/eventsystem/Lock.h:303
#5 0x00000000004707f0 in MutexTryLock::MutexTryLock (...) at include/iocore/eventsystem/Lock.h:590
#6 0x000000000049fbb8 in CacheVC::openReadStartHead (this=..., event=2, e=...)
at src/iocore/cache/CacheRead.cc:1014
#7 0x0000000000468064 in Continuation::handleEvent (...)
#8 0x0000000000868464 in EThread::process_event (...) at src/iocore/eventsystem/UnixEThread.cc:171
#9 0x0000000000868b48 in EThread::execute_regular (...)
m = 0x2cc09800 is the ProxyMutex; this = 0x2cc09858 in frame #1 is
m->srcloc inside it.
Analysis
Mutex_trylock, include/iocore/eventsystem/Lock.h:261:
if (m->thread_holding != t) {
if (!ink_mutex_try_acquire(&m->the_mutex)) {
#ifdef DEBUG
lock_waiting(m->srcloc, m->handler); // line 264
#endif
return false;
}
The thread reaching line 264 just failed to acquire the mutex, so it holds
nothing, yet it reads m->srcloc and m->handler. Those fields are owned by
whoever holds the mutex and are written non-atomically:
Lock.h:276-277 (Mutex_trylock, after acquiring)
Lock.h:323-324 (Mutex_lock, after acquiring)
Lock.h:370-371 (Mutex_unlock, cleared before ink_mutex_release)
SourceLocation::operator= copies file, func and line as three separate
stores, so a racing reader can observe a mixture of old and new fields.
SourceLocation::valid() only checks file && line
(include/tsutil/SourceLocation.h:49), so a stale file with a non-zero line
passes validation and strrchr(file, '/')
(src/tsutil/SourceLocation.cc:48) faults.
Three conditions have to coincide, which is why this is so rare and why it only
ever shows up in these tests:
- A Debug build. The whole block is
#ifdef DEBUG, defined by
CMakeLists.txt:63 when CMAKE_BUILD_TYPE is Debug — that is the ci
preset family, including ci-fedora-cxx20.
- The
locks debug tag enabled. lock_waiting() only calls
SourceLocation::str() inside if (dbg_ctl_locks.on())
(src/iocore/eventsystem/Lock.cc:45-49). The cache unit tests are the only
place that turns it on:
src/iocore/cache/unit_tests/main.cc:132 does
diags()->activate_taglist("cache.*|agg.*|locks", DiagsTagType_Debug).
- Lock contention. Line 264 is reached only when
ink_mutex_try_acquire
fails, which is why parallelism matters and a solo run never trips it.
The two WARNING: holding lock ... too long for UNKNOWN lines in the CI output
come from lock_holding() in Mutex_unlock, which is called while holding the
lock and is safe. They are the tell that the locks tag was active and that
contention was occurring — the precondition for the crashing lock_waiting path.
Possible fix
lock_waiting() is the only one of these that reads holder state without
holding the lock. Reporting the waiter's location — already passed in, always
a __FILE__ literal, never raced — instead of m->srcloc removes the race
outright. That does drop "who is holding it" from the message, so if that
information is worth keeping the alternative is to make the debug fields
atomically publishable rather than three plain stores. Happy to put up a PR
either way once there is a preference.
Environment
master at b9b9109, unmodified.
cmake --preset ci-fedora-cxx20 -DBUILD_TESTING=ON, clang, CMAKE_BUILD_TYPE=Debug.
- Reproduced in a Fedora 44 container. Note this was aarch64, whereas the CI
job that failed is x86_64; same source and same preset, different architecture.
The CI log's evidence is consistent with this mechanism but we have not proven
the CI crash is the identical one.
- The code involved is long-standing, so 10.2.x and earlier are very likely
affected too.
Summary
Debug builds intermittently SIGSEGV in the debug-only lock-contention tracking:
a thread that fails a try-lock reads the current holder's
SourceLocationoutof the
ProxyMutexwithout holding anything, andSourceLocation::str()thenfaults dereferencing a stale
filepointer.The cache unit tests are the only thing in the tree that enables the
locksdebug tag, so this surfaces as a rare
test_cache_*segfault on the Fedora CIjob. It is not a cache bug; the cache tests are just the trigger.
How we saw it in CI
The Fedora job failed on an unrelated PR (#13523, an
HttpSMchange) with:https://ci.trafficserver.apache.org/job/Github_Builds/job/fedora/9141/
The test's own output ends like this — note the two lock-tracking warnings
immediately before the crash:
The PR under test cannot be responsible:
add_cache_testbuilds each cache testfrom
unit_tests/main.cc,unit_tests/stub.cc,unit_tests/CacheTestHandler.ccand the test source, linked against
ts::inkcacheand Catch2 only. Nothing fromlibhttpis in the binary.Reproduction
On
masterat b9b9109 with no modifications, using the CI configuration(clang,
CMAKE_BUILD_TYPE=Debug):Observed:
test_cache_Update_L_to_Salone, 25 runsctest -R "^test_cache_" -j 8, 12 iterationsUpdate_S_to_L)ctest -R "^test_cache_" -j 8, 30 iterationsAlternate_S_to_L)It never fails standalone, and it lands on a different test in the
Update/Alternate family each time, which is what pointed at shared code rather
than any one test.
An ASan build did not reproduce it in 15 iterations of the same loop, and runs
supervised by gdb did not reproduce it in 120 attempts — both perturb the timing
window. A concurrently running unsupervised
Alternate_L_to_Sdid crash anddump a core, which is where the backtrace below comes from.
Backtrace
m = 0x2cc09800is theProxyMutex;this = 0x2cc09858in frame #1 ism->srclocinside it.Analysis
Mutex_trylock,include/iocore/eventsystem/Lock.h:261:The thread reaching line 264 just failed to acquire the mutex, so it holds
nothing, yet it reads
m->srclocandm->handler. Those fields are owned bywhoever holds the mutex and are written non-atomically:
Lock.h:276-277(Mutex_trylock, after acquiring)Lock.h:323-324(Mutex_lock, after acquiring)Lock.h:370-371(Mutex_unlock, cleared beforeink_mutex_release)SourceLocation::operator=copiesfile,funcandlineas three separatestores, so a racing reader can observe a mixture of old and new fields.
SourceLocation::valid()only checksfile && line(
include/tsutil/SourceLocation.h:49), so a stalefilewith a non-zerolinepasses validation and
strrchr(file, '/')(
src/tsutil/SourceLocation.cc:48) faults.Three conditions have to coincide, which is why this is so rare and why it only
ever shows up in these tests:
#ifdef DEBUG, defined byCMakeLists.txt:63whenCMAKE_BUILD_TYPEisDebug— that is thecipreset family, including
ci-fedora-cxx20.locksdebug tag enabled.lock_waiting()only callsSourceLocation::str()insideif (dbg_ctl_locks.on())(
src/iocore/eventsystem/Lock.cc:45-49). The cache unit tests are the onlyplace that turns it on:
src/iocore/cache/unit_tests/main.cc:132doesdiags()->activate_taglist("cache.*|agg.*|locks", DiagsTagType_Debug).ink_mutex_try_acquirefails, which is why parallelism matters and a solo run never trips it.
The two
WARNING: holding lock ... too long for UNKNOWNlines in the CI outputcome from
lock_holding()inMutex_unlock, which is called while holding thelock and is safe. They are the tell that the
lockstag was active and thatcontention was occurring — the precondition for the crashing
lock_waitingpath.Possible fix
lock_waiting()is the only one of these that reads holder state withoutholding the lock. Reporting the waiter's
location— already passed in, alwaysa
__FILE__literal, never raced — instead ofm->srclocremoves the raceoutright. That does drop "who is holding it" from the message, so if that
information is worth keeping the alternative is to make the debug fields
atomically publishable rather than three plain stores. Happy to put up a PR
either way once there is a preference.
Environment
masterat b9b9109, unmodified.cmake --preset ci-fedora-cxx20 -DBUILD_TESTING=ON, clang,CMAKE_BUILD_TYPE=Debug.job that failed is x86_64; same source and same preset, different architecture.
The CI log's evidence is consistent with this mechanism but we have not proven
the CI crash is the identical one.
affected too.