Skip to content

DeepDebug: McMini, DMTCP and TSAN#11

Draft
gc00 wants to merge 36 commits into
mcminickpt:mainfrom
gc00:mcmini-dmtcp-tsan
Draft

DeepDebug: McMini, DMTCP and TSAN#11
gc00 wants to merge 36 commits into
mcminickpt:mainfrom
gc00:mcmini-dmtcp-tsan

Conversation

@gc00

@gc00 gc00 commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

DMTCP now supports TSAN. Working on porting this support to McMini/DeepDebug.
Part of the issue is that McMini, TSAN and DMTCP all have wrapper functions, and they can interfere with each other if we are not careful.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0153317c-57ee-4d5a-857f-da7c8b02ecc2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gc00 gc00 added the enhancement New feature or request label Jul 25, 2026
@gc00
gc00 force-pushed the mcmini-dmtcp-tsan branch from 26e334e to 0712e36 Compare July 25, 2026 15:09
gc00 added 2 commits July 26, 2026 11:40
TSAN's pthread_join() interceptor delegates to a genuine OS-level
join and blocks via the kernel until the target thread actually
dies -- it does not rely on its own creation-time bookkeeping. But
mc_pthread_join()'s TARGET_BRANCH case only simulated success at the
model level, while the joined thread was kept parked in
thread_block_indefinitely() forever, so a real join on it (e.g. from
TSan) could never complete.

Give each thread its own exit_permission_sem (alongside its existing
pthread_map entry). A finishing thread waits on it before returning;
mc_pthread_join() posts it and performs a real libpthread_pthread_join()
before returning, and as a bonus, pthread_join returns a return value.
@gc00
gc00 force-pushed the mcmini-dmtcp-tsan branch from 0712e36 to ad268e7 Compare July 26, 2026 16:18
gc00 and others added 20 commits July 27, 2026 00:36
classic_dpor::verify_using()'s forward-exploration path catches
real_world::process::termination_error and reports it via the
abnormal_termination callback, letting the run end cleanly. The
backtrack-replay path (coordinator::return_to_depth(), which replays
prior transitions against a freshly restarted process) had no such
handling, so the same exception there escaped all the way to the
top-level catch-all instead.

Wrap return_to_depth() the same way. found_abnormal_termination()
also needed a null check: return_to_depth()'s target thread may have
no pending transition in the model's current view (unlike the forward
path, where the culprit is always the runner DPOR just selected as
enabled). The report then falls back to a plain "no longer pending"
line instead of dereferencing a null transition.
The TSAN-supporting DMTCP branch (tsan-phased-init) bumped the plugin API
from v3 to v4, an ABI change (DmtcpPluginDescriptor_t / DmtcpUniqueProcessId,
new DmtcpCkptHeader etc.). DMTCP refused to load libmcmini.so:

  ASSERT pluginmanager.cpp:228: incompatible DMTCP plugin API version:
  plugin_api=3 expected=4

Sync the vendored include/dmtcp.h to DMTCP's v4 header (correct version
string and descriptor ABI), and carry forward the only McMini-specific
additions -- the mcmini_virtual_pid / mcmini_real_pid macros -- updated to
the v4 function names (dmtcp_{real_to_virtual,virtual_to_real}_pid became
dmtcp_pid_{real_to_virtual,virtual_to_real}). Also update the two direct
callers in multithreaded_fork.c. The unused dmtcp_restore_buf_* decls are
dropped (not referenced by libmcmini, and gone from v4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
template_thread() computed the number of threads to wait for by
scanning /proc/self/task live, at restart time, then subtracting a
blanket 2 (for itself and the checkpoint thread). DMTCP recreates
checkpointed threads asynchronously via clone(), so a scan that runs
before it has finished recreating all of them undercounts -- this
barrier then declares "consistent state" and lets the template
thread proceed before every thread has actually restarted. Confirmed
via added diagnostic logging: one thread's own restart-completion
signal could arrive after the barrier already released.

Fix: count ALIVE THREAD entries in head_record_mode instead. That
list only ever gets entries for genuine target threads (the template
thread and checkpoint thread never go through libmcmini's wrapped
pthread_create(), so neither is ever recorded there), and since a
DMTCP checkpoint is a full memory snapshot, it's preserved exactly
as-is across every restart -- immune to any restart-time scheduling
race.
…e=thread

Off by default. Experiment to see whether instrumenting libmcmini.so
itself (as opposed to only the target) changes TSan-registration-order
crashes; empirically it did not move the crash site, so the normal path
stays uninstrumented per PLAN.txt's suppressions-file design.
getcontext()/setcontext() already restore a thread's blocked-signal
set via uc_sigmask, even across the raw clone() used to recreate a
checkpointed thread. The thread_sigmask field and abort were
unnecessary; both are removed. This check ran on every restarted
thread regardless of mode, so it also blocked plain (non-multithreaded-fork)
restart of any target with a blocked-signal thread.
When a -fsanitize=thread target runs under DMTCP, dmtcp_launch prepends
libtsan ahead of libmcmini, so a new thread's entry order is
  dmtcp thread_start -> libmcmini's mc_thread_routine_wrapper -> libtsan
  trampoline -> user routine.
libmcmini's wrapper thus runs BEFORE libtsan has registered the thread.
Its prologue then called libc functions that libtsan intercepts (malloc,
and the raw pthread_rwlock_* in insert_pthread_map), and those interceptors
dereference the unregistered thread's null ThreadState -> SEGV / TSan
"sanitizer_thread_registry.cpp:348" CHECK. (Full analysis in
TSAN-McMini-DMTCP.txt.)

Make the prologue free of TSan-intercepted libc calls:
  - Add mc_ts_alloc (mem.c/mem.h): a bump allocator over a static BSS arena.
    No libc call and no syscall on the fast path (only an atomic bump), so
    it never enters a TSan interceptor. Fail path uses raw syscalls only.
    Never frees (the pthread_map / rec_list nodes it backs are never freed).
  - insert_pthread_map / search_pthread_map: use libmcmini's libpthread_*
    handle wrappers (which bypass libtsan) instead of the raw pthread_rwlock_*
    symbols, and mc_ts_alloc instead of malloc. Also fixes a pre-existing
    missing-unlock bug in search_pthread_map's found path.
  - add_rec_entry_record_mode_ts (record.c): mc_ts_alloc-backed variant of
    add_rec_entry_record_mode; the prologue's THREAD-record insert uses it.

Verified: mcmini -i 3 on ~/dmtcp.git/test/tsan_target (DMTCP tsan-phased-init,
plugin API v4) no longer crashes in the thread-creation prologue; both worker
threads now run under RECORD mode.

Scope: this fixes the prologue only. A separate TSan-interception site remains
downstream -- mc_pthread_join calls pthread_timedjoin_np directly (no bypass
handle), tripping libtsan's ConsumeThreadUserId CHECK -- to be addressed next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mc_pthread_join's RECORD loop called pthread_timedjoin_np directly. Under a
TSAN target, that symbol resolves to libtsan's interceptor, whose
ConsumeThreadUserId trips a thread-registry CHECK
(sanitizer_thread_registry.cpp:348) and aborts.

Add a libpthread_timedjoin_np handle (dlsym'd from libpthread, like the
mutex/cond/sem wrappers) that bypasses libtsan, and call it from
mc_pthread_join's RECORD loop instead of the raw symbol.

With this, the three-fix stack achieves END-TO-END checkpointing of a
-fsanitize=thread target under deep-debug (mcmini record mode):
  1. 5be8500  DMTCP plugin API v3 -> v4      (DMTCP loads libmcmini.so)
  2. 4bf2720  TSan-safe RECORD prologue      (worker creation doesn't crash
             before libtsan registers the thread)
  3. this     timed-join via bypass handle   (join loop doesn't trip the
             registry CHECK)

Verified: `mcmini -i 3 ~/dmtcp.git/test/tsan_target` (DMTCP tsan-phased-init)
now runs with no SEGV / no ThreadSanitizer errors and DMTCP produces a valid
checkpoint (ckpt_tsan_target_*.dmtcp, ~5 MB, matching the no-mcmini baseline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
libmcmini's template thread was created via libdmtcp_pthread_create,
bypassing libtsan's pthread_create interceptor. When the target is built
with -fsanitize=thread, that left the template thread with no libtsan
ThreadState. On DMTCP restart, libtsan's setjmp/longjmp restore
(reached from threadlist.cpp:stopthisthread) dereferences the thread's
absent ThreadState and crashes with SIGSEGV during thread restore.

Create the template thread through the public pthread_create instead, so
libtsan's interceptor registers it and wraps its start routine. A new
thread-local flag mc_creating_internal_thread tells mc_pthread_create to
skip the user-thread/model-checking machinery and still route the actual
creation through DMTCP (libdmtcp_pthread_create), keeping the thread
DMTCP-known. When the target is not instrumented, the public
pthread_create resolves straight to mc_pthread_create and behavior is
unchanged.

With this, record -> checkpoint -> restart of a TSAN target survives
thread restore (verified: raw dmtcp_restart restores all threads with no
SIGSEGV, where it previously crashed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the throwaway SIGSEGV-backtrace handler and its install call
sites added during TSan-restart investigation, and restore the
DMTCP_EVENT_RESTART SIG_DFL reset for SIGSEGV. Keep the R2 (TSan fork
syscall hooks around _Fork()) and R4 (fresh TSan fiber for recreated
threads) prototypes as the starting point for the multithreaded_fork
TSan port described in PLAN.txt.

This is an unfinished prototype on this branch: R3 (__clone) and the
R4 remainder were never applied here, and none of this code has been
exercised by a --multithreaded-fork run under TSan in this branch's
history.

The completed version lives on branch recreated-thread-sigmask (also
reachable as an ancestor from tsan-checkpoint-restart), continuing
with:
  - Add design spec for TSan port Phase 2 (R2+R3+R4)
  - Add implementation plan for TSan port Phase 2 (R2+R3+R4)
  - Add standalone harness proving R3 (__clone) + R4-remainder
    (forker fiber) under TSan
  - Apply R3 (__clone) and R4 remainder (forker fiber) to
    fast_multithreaded_fork
  - Correct overstated R4-remainder claim in fastpath fork/clone/fiber
    harness comment
  - Fix cosmetic inaccuracy in harness comment re: forking-thread
    fiber switch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements a helper function to detect if a thread has a signal blocked
via its /proc/self/task/<tid>/status SigBlk mask. This will be consumed
by Task 2 to identify and skip ThreadSanitizer's internal background
thread during DMTCP checkpoint restart.

- Add include/mcmini/spy/checkpointing/tsan_support.h with function declaration
- Add src/lib/tsan_support.c with implementation
- Add test/tsan_support/test_thread_blocks_signal.c standalone unit test
- Wire new source file into CMakeLists.txt LIBMCMINI_C_SRC list

Test verified: compiles with -Wall -Werror, test passes (exit code 0).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Under a TSan-instrumented target, TSan's own background thread is
spawned as a nested pthread_create() during the same window in which
DMTCP creates its checkpoint thread. Both requests reach libmcmini's
dmtcp_create_checkpoint_thread_wrapper(), which previously had no way
to tell them apart at creation time. The real checkpoint thread could
end up misclassified as an ordinary user thread and get recorded into
the target's rec_list, corrupting the recording and causing "Expected
a callback for 0" on restart.

dmtcp_is_tsan_background_thread() resolves this, but classification
requires DMTCP's endCkptThreadCreationWindow(), which can't run until
the new thread has been registered with TSan -- which only happens
once dmtcp_create_checkpoint_thread_wrapper() calls its real routine.
Blocking on classification before that call therefore deadlocks.

Fix: mark the thread as an unresolved candidate and run its real
routine immediately, then resolve classification lazily on this same
thread's first subsequent wrapped call (get_current_mode()), by which
point TSan registration is guaranteed to have already happened.
The mode previously meant only "the DMTCP checkpoint thread is
calling in". ThreadSanitizer's own internal background thread calls
into libmcmini.so's overridden functions too, on a thread that is
likewise not part of the target program, so get_current_mode() now
reports the same value for it. Renamed to reflect the broader meaning.

Detection reuses thread_blocks_signal() (already used by the R1
restart-barrier fix) via two new tsan_support.c helpers:
mc_real_tid(), which reads /proc/thread-self since DMTCP virtualizes
gettid() (including via the raw syscall path); and
mc_is_current_thread_tsan_internal(), a per-thread-cached wrapper
around it.

Isolating which of thread_blocks_signal()'s calls were themselves
TSan-safe required bisecting its RECORD-mode-original fopen/fgets/
sscanf/fclose implementation one call at a time. Findings: raw
syscalls for openat/read/readlink are safe from a thread TSan has
not yet registered, but even a raw syscall(SYS_close, fd) is not, so
the one fd this code opens is deliberately never closed (bounded to
one leak per thread, since the result is cached for that thread's
lifetime).
mc_is_current_thread_tsan_internal() is only needed in TARGET_BRANCH/
restart modes, but was also running during RECORD, where its first
call per thread leaks an fd pointing at that thread's own
/proc/<pid>/task/<tid>/status. That fd is open at checkpoint time,
so DMTCP must checkpoint/restore a path whose pid/tid cannot exist
after restart.

Skipping the check during RECORD/PRE_CHECKPOINT empirically confirmed
this is why template_thread() was failing to wake up after restart --
it now does. Not yet understood: WHY this causes that hang, i.e.
whether DMTCP is failing to restore the fd correctly or something
else is at fault. Keep this gate until that's diagnosed and a
permanent fix (most likely: never opening the fd at all) replaces it.
The prior gate excluded RECORD/PRE_CHECKPOINT but missed
PRE_DMTCP_INIT and PRE_CHECKPOINT_THREAD, so one fd (the calling
thread's own /proc/<pid>/task/<tid>/status, opened by its very first
call to mc_is_current_thread_tsan_internal()) was still leaking
during those earlier modes and getting checkpointed. Switched from a
negative exclusion list to a positive list of the four modes where
the check actually matters, matching the exact case-label grouping
every wrapper function already uses for this purpose.

Confirmed via /proc/<pid>/fd census: zero leaked fds now, for the
whole RECORD phase. But restarting from a checkpoint recorded this
way does not by itself get further than before: template_thread()
still hangs, now in a different, later place (the
`dmtcp_restart_sem` count loop). So this fd was not, on its own,
responsible for that specific hang -- still needs its own analysis.
A DMTCP_RESTART_INTO_BRANCH process explores exactly one trace,
then gets discarded -- it never legitimately needs to checkpoint
again. Left alone, the checkpoint thread resumes its normal
sleep-checkpoint-resume loop and blocks forever waiting for a
checkpoint request from this restart's one-shot, otherwise-idle
coordinator (dmtcp_process_source spins up a fresh coordinator per
branch). Call the new dmtcp_skip_post_restart_checkpoint_loop() to
tell DMTCP not to resume that loop.

Verified: the checkpoint thread now parks in pause() right after
restart instead of hanging in read() waiting on the coordinator.
Under DMTCP, libtsan.so resolves ahead of libmcmini.so, so a target's
own pthread_join() calls reach TSan's interceptor first. For a thread
DMTCP resurrected via clone() (bypassing TSan's pthread_create()
interceptor), TSan can never resolve its Tid and hangs forever -- see
TSAN-pthread-join.md.

Fix: link the target with -Wl,--wrap=pthread_join plus the new
pthread_join_wrap.c, rewriting the target's own pthread_join() calls
at link time so they reach mc_pthread_join_maybe_defer() before the
dynamic linker (and TSan) ever sees them. A DMTCP-resurrected thread's
join is handled entirely via a really_exited_sem handshake; any thread
with a valid TSan Tid (classic mode, or created via pthread_create()
after restart) is still really joined via __real_pthread_join(), so
TSan keeps seeing and tracking that join normally.
mcmini_log() calls localtime() from any thread, unsynchronized.
glibc's tzset_internal() (invoked on every localtime_r() call, not
just the first) is not safe to run concurrently even when TZ never
changes, so under TSan this showed up as a data race and, at least
once, a subsequent SEGV.

A real pthread mutex around the whole function isn't enough by
itself: it's taken via libpthread_mutex_lock(), which resolves
straight to libpthread's own symbol (see
mc_load_intercepted_pthread_functions() in interception.c),
bypassing TSan's interceptor entirely. So the lock genuinely
serializes the calls, but TSan never sees it and keeps reporting
a race. Fix that with TSan's own public annotation API
(__tsan_acquire/__tsan_release, declared weak so this is a no-op
without a TSan runtime in the process) around the same lock.

Also switch localtime() to localtime_r() with a stack-local struct
tm, and add tzset() as a constructor so the timezone database is
loaded once, single-threaded, before any thread can race on it.

Verified against a freshly-recorded DMTCP checkpoint (rebuilding
libmcmini.so does not affect restarts of an already-recorded
checkpoint, which pins whatever plugin build was loaded at record
time): three consecutive restarts now show zero tzset_internal
races, versus 3/3 racing beforehand.
A CMake-managed, TSan-instrumented build of producer-consumer, wired
with -Wl,--wrap=pthread_join and src/lib/pthread_join_wrap.c so it
exercises the DMTCP-restart + TSan pthread_join fix without a manual
ad hoc compile step.
Without --wrap (see src/lib/pthread_join_wrap.c), a DMTCP-resurrected
thread's pthread_join() can hang forever under TSan. Detect both
conditions via weak symbols (same idiom as the other TSan/DMTCP
optional-symbol checks in this codebase): __tsan_acquire is non-NULL
only when libtsan.so is loaded (i.e. a TSan target), and
__wrap_pthread_join is non-NULL only if the target itself was linked
with --wrap. Warn as early as possible (a constructor) when the
former is true and the latter isn't.

Verified via `mcmini`: a TSan target built without --wrap prints the
warning, producer-consumer-tsan (built with --wrap) and a plain
non-TSan target both stay silent.
Its destructor unconditionally ran `dmtcp_command -q --port
<coordinator_target.get_port()>`, but coordinator_target (a
dmtcp_coordinator member) never had launch_and_wait() called
anywhere in this class, so its port field stayed at its
default-constructed 0 forever -- this call always failed, since no
coordinator ever listens on port 0.

It's also unnecessary: each branch's own `dmtcp_restart
--new-coordinator --port 0` call spawns its own private coordinator
with --exit-on-last baked in by DMTCP itself, so it already
self-terminates once its sole client disconnects. Nothing in this
class needs explicit shutdown.

Confirmed via a restart run: the bogus `dmtcp_command -q --port 0`
[...] exited with status 2` error no longer appears, with no other
change in behavior.
@gc00
gc00 force-pushed the mcmini-dmtcp-tsan branch from ad268e7 to 40bc35f Compare July 27, 2026 07:17
gc00 and others added 3 commits July 27, 2026 08:19
local_linux_process::execute_runner() previously treated any pending
SIGCHLD as proof that *this* branch had just died, and reported it
with a hardcoded SIGTERM regardless of the true cause. But
signal_tracker's SIGCHLD count is a global counter, not tied to a
pid, and ~local_linux_process() (invoked by
coordinator::assign_new_process_handle() to tear down the previous
branch immediately before spawning a new one, e.g. from
return_to_depth() on every DPOR backtrack) kills that old process and
reaps it without ever consuming the SIGCHLD it generates.

That leftover count then lingers until the *next* branch process's
very first execute_runner() call, which sees try_consume_signal()
return true and wrongly concludes the brand-new process just died --
even though it's alive and simply hasn't responded yet. Confirmed via
a waitpid() on the supposedly-dead pid: it blocked forever, proving
the process was never actually dead.

Fix: ~local_linux_process() now consumes the SIGCHLD from its own
deliberate kill, then drains (non-blocking) any other
already-reapable zombies -- e.g. the old branch's own private DMTCP
coordinator -- consuming one signal per reap, since none of them are
tied to a specific pid either.

execute_runner() itself also loops on a pending SIGCHLD instead of
treating it as automatic proof of death: since the counter isn't
pid-scoped, a signal here can still belong to some other descendant.
It confirms via a non-blocking waitpid() on this->pid specifically,
resuming the wait if that comes back empty, and only reports the real
signal/exit code once waitpid() actually confirms this->pid died.

Verified: 9/10 fresh-checkpoint restart runs now fully explore all 9
traces and complete cleanly, with zero false "Abnormally Termination"
reports across every run (previously: every run failed after 1-2
branches). One rarer, separate hang remains -- a genuine
restart-synchronization stall, not a signal-tracking issue -- tracked
separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ber) under TSan

Adds test/tsan_support/test_fastpath_fork_clone_fiber.c, a self-contained
host-side test (compiled/run directly with gcc, not via CMake) that mirrors
dmtcp-callback.c's fast-path fork+clone+fiber resumption mechanism to prove
under real ThreadSanitizer that:

- R3 (using libc's __clone instead of the public, libtsan-intercepted
  clone()) and R4's forking-thread fiber switch are both required: with
  -DMTF_BUGGY (public clone(), no forker fiber switch) the recreated
  threads crash with "ThreadSanitizer: CHECK failed: tsan_rtl.cpp:253
  ((!thr->slot)) != (0)" inside ForkChildAfter, and the child process exits
  with code 66.
- The default (fixed) configuration passes cleanly: both parent and child
  report shared_counter=300000 (expected 300000), the child exits 0, and no
  CHECK failures occur across repeated runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ded_fork

restart_child_threads_fast() now calls libc's raw __clone() instead of the
public clone(), since libtsan's clone() interceptor treats every call as a
fork and corrupts its thread-slot state for the CLONE_THREAD clone used to
recreate pre-checkpoint threads. Also switch the forking thread itself onto
a fresh TSan fiber in fast_multithreaded_fork()'s child branch, since it
otherwise keeps its inherited (fork-copied) ThreadState whose shadow call
stack can overflow as the thread keeps running. Both fixes were already
proven against a standalone TSan repro harness in a prior commit; this
applies them to the production DMTCP restart path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
gc00 and others added 11 commits July 27, 2026 08:19
…rness comment

Final review found the harness's forking thread (main()) never does enough
post-fork instrumented work to trigger the shadow-call-stack overflow that
the forking-thread fiber switch (R4 remainder) guards against, so the
RED/GREEN evidence only actually falsifies/confirms R3 (__clone), not R4.
Rewrite the header comment to state this accurately; no code logic changes.
…witch

The fiber switch is guarded by #ifndef MTF_BUGGY, so it does not execute
in the buggy build -- only the default (fixed) one. Doesn't change any
of the comment's substantive conclusions (still not independently
validated by this harness), per final re-review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Real DMTCP restart testing (dmtcp-tsan.git) surfaced a second
LD_PRELOAD interceptor on the raw __clone symbol used to recreate
threads after checkpoint restart: DMTCP's own libdmtcp.so strongly
exports __clone (threadwrappers.cpp) and unconditionally aborts
unless the caller is mid-DMTCP's-own pthread_create --

  ASSERT at threadwrappers.cpp:177: false: Thread creation with
  clone syscall is not supported: flags=4001536

matching restart_child_threads_fast()'s exact clone_flags. Calling
the bare `__clone` symbol hits this interceptor instead of libc's
real implementation, exactly like the public clone() hits libtsan's
(the reason this code already avoided clone() in favor of __clone).

Resolve the real, uninterposed __clone the same way this codebase
already resolves other real libc primitives around interceptors:
add a libc_clone() forwarding function to interception.c's existing
mc_load_intercepted_pthread_functions() table, dlsym'd from libc
once at process startup, well before any fork or checkpoint restart
ever happens. An initial version of this fix instead did a fresh
dlopen/dlsym lazily inside restart_child_threads_fast() itself
(post-_Fork(), in the child); code review flagged that as a
duplicate resolution mechanism that also risked deadlocking on a
dynamic-linker lock left held by a thread that no longer exists in
that child, so it was folded into the existing, already-proven-safe
libc_* table instead.

Verified end-to-end against a real DMTCP checkpoint/restart of the
producer-consumer example (record via `mcmini -i 1`, restart via
`mcmini --from-checkpoint ... --multithreaded-fork`): the full
pipeline (checkpoint -> restart -> fast_multithreaded_fork() ->
libc_clone()-based thread recreation -> DPOR exploration) completes
cleanly -- 9 traces explored, correct DEADLOCK detection, exit 0,
"Deep debugging completed!", zero ASSERT/CHECK-failed/segfault
occurrences.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…technique

This test demonstrates that pthread_exit can be safely resolved via dlopen+dlsym
and called from a worker thread, with the return value correctly delivered to
pthread_join(). This technique is used in the next task for production code.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Intercepts pthread_exit() and routes it through the existing
THREAD_EXIT_TYPE model-checking machinery (mc_exit_thread_in_child() /
mc_exit_main_thread_in_child()) uniformly across all restart/branch
modes, instead of falling through to the real libpthread pthread_exit
and, on a __clone()-recreated thread, tripping libtsan's real
interceptor (which is not registered for that "fiber" thread and can
crash). Pre-restart modes forward to the real pthread_exit via a new
libtsan-bypassing libpthread_pthread_exit() handle, resolved with the
same dlopen+dlsym technique already used for pthread_timedjoin_np and
validated standalone in the prior commit's harness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ion.h

Matches the header's existing convention (libc_exit/libc_abort use the
macro two lines below); interception.c's own pointer declarations
already use the raw attribute for a different, pre-existing reason
(exit_ptr/abort_ptr), so that file is untouched. Per Phase 3 Task 2
review's one Minor finding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…as live

DMTCP_RESTART_INTO_BRANCH/TEMPLATE were grouped with TARGET_BRANCH* in a
single case, skipping the mandatory thread_handle_after_dmtcp_restart()
call that every other wrapper performs for those two restart modes.
Split them into their own case that reports THREAD_EXIT_TYPE, calls
thread_handle_after_dmtcp_restart(), then falls through into the
unchanged TARGET_BRANCH*/TARGET_BRANCH_AFTER_RESTART body, matching the
mc_transparent_exit/mc_transparent_abort fallthrough precedent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A DMTCP-restarted target thread can be genuinely blocked at the kernel
level on this exact futex word while glibc's own userspace "is anyone
really waiting" bookkeeping (packed into the same memory sem_post()
checks before deciding whether to skip the underlying FUTEX_WAKE
syscall) is desynced from that -- because mc_runner_mailbox_init()/
_destroy() reinitialize this memory before every new DMTCP-restarted
branch, independent of whatever kernel-level futex wait state a
resurrected thread still has queued. When that happens, sem_post()
silently skips the wake and the branch hangs forever.

A plain futex word has no such bookkeeping: mc_raw_sem_post() always
calls FUTEX_WAKE unconditionally, and mc_raw_sem_wait() only ever
blocks after re-checking the atomic counter, so a wake can never be
lost regardless of what order post/wait actually race in. Diagnosed
via Gemini-assisted research into glibc's NPTL sem_t internals (packed
nwaiters/value in one 64-bit word on 64-bit architectures) plus a
direct FUTEX_WAKE(INT_MAX) probe confirming exactly one real waiter
was queued when a hang occurred -- ruling out stale/multiple waiters
as the cause and pointing squarely at the userspace-bookkeeping
desync. model_side_sem is untouched: mcmini itself is never
checkpointed, so glibc's bookkeeping for it can never desync.

Condition variables (pthread_cond_wait/pthread_cond_signal) have the
same class of vulnerability via glibc's G1/G2 waiter-group bookkeeping
and will need an analogous fix separately.

See doc/glibc-sem-desync.txt for a fuller explanation of the desync
mechanism and why it is specific to checkpoint/restart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
do_recording() (src/mcmini/mcmini.cpp) resolves the DMTCP plugin path
as getcwd()/libmcmini.so, so mcmini must be invoked from the directory
holding libmcmini.so -- CMAKE_BINARY_DIR, not this target's own output
directory (build/src/examples/). A manually-placed copy in
CMAKE_BINARY_DIR from days ago silently went stale relative to
rebuilds of libmcmini.so and this target itself, costing significant
debugging time chasing what looked like a live TSan/DMTCP hang but was
actually just a pre-fix binary missing -Wl,--wrap=pthread_join's
effect. Add a POST_BUILD copy so this can't happen again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
mc_pthread_cond_wait() never calls the real libpthread_cond_wait()/
libpthread_cond_timedwait() in any post-restart mode (DMTCP_RESTART_
INTO_BRANCH/TEMPLATE, TARGET_BRANCH, TARGET_BRANCH_AFTER_RESTART): the
wait is entirely simulated via the mailbox handshake, true even in
classic (non-DMTCP) mode. But mc_pthread_cond_signal()/_broadcast()/
_init()/_destroy() still called the real libpthread_cond_signal()/
broadcast()/init()/destroy() in those same modes -- an asymmetry.

That asymmetry is dangerous specifically under DMTCP restart: a
clone()-recreated thread can still be genuinely, kernel-level blocked
inside a pre-restart real pthread_cond_timedwait() call (from RECORD
mode, if the checkpoint landed mid-call). A real signal/broadcast
reaching that thread wakes it for real, letting it resume running
application code without ever going through the model checker's own
scheduling -- breaking DPOR's single-stepping invariant. Unlike the
child_side_sem fix, this isn't a lost-wakeup story: it's a real,
uncontrolled wakeup escaping the model checker entirely, which is
arguably worse than a hang.

Fix: remove the real libpthread_cond_signal()/broadcast()/init()/
destroy() calls from all four post-restart cases, mirroring what
mc_pthread_cond_wait() already did -- once wait never consults the
real object's state, touching it from signal/broadcast/init/destroy
serves no purpose, only risk. RECORD/PRE_CHECKPOINT mode is untouched:
those real calls remain necessary and safe there, since it's one
continuous execution before any checkpoint exists.

Verified classic-mode cv-test produces byte-for-byte-equivalent output
(modulo debug-log pid/interleaving) before and after, confirming no
behavior change for the already-real-call-free wait path this mirrors.

See doc/glibc-cond-var-desync.txt for the full analysis, including why
a normally single-process (pshared=0) condition variable is affected by
the same class of bug as the pshared=1 child_side_sem mailbox semaphore
despite the different pshared requirement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
producer-consumer(-tsan) has no condition variables at all, so it
can't exercise doc/glibc-cond-var-desync.txt's fix (f54bb73) under a
real DMTCP+TSan checkpoint/restart cycle. Add a structurally identical
producer-consumer variant that uses a mutex + condition variable
(count-based bounded buffer, single shared cond, classic while-loop
predicate wait) instead of the two semaphores, plus the matching TSan
target -- same build recipe as producer-consumer-tsan (-fsanitize=thread,
-Wl,--wrap=pthread_join + pthread_join_wrap.c, POST_BUILD copy into
CMAKE_BINARY_DIR).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@gc00
gc00 force-pushed the mcmini-dmtcp-tsan branch from 40bc35f to 8d6d6d9 Compare July 27, 2026 14:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant