Skip to content

Fix data races on session status and the Call Home thread flag - #636

Merged
michalvasko merged 7 commits into
develfrom
issue635-tsan-session-races
Sep 3, 2026
Merged

Fix data races on session status and the Call Home thread flag#636
michalvasko merged 7 commits into
develfrom
issue635-tsan-session-races

Conversation

@Roytak

@Roytak Roytak commented Sep 3, 2026

Copy link
Copy Markdown
Contributor
  • nc_session.status / term_reason are lock-free by design and there is no lock that could cover them without introducing an ordering hazard with rpc_lock/io_lock. They are now ATOMIC_T, accessed via NC_SESSION_STATUS_GET/SET() and NC_SESSION_TERM_REASON_GET/SET(). All accesses had to convert.
  • NC_SESSION_CH_THREAD was a bit in nc_session.flags, so clearing it was a read-modify-write on a byte shared with flags nobody locks. It is now a dedicated atomic, opts.server.ch_thread_active. nc_session_free() polls the atomic instead of waiting on ch_cond, which drops the ch_lock dependency of the wait.

Also fixed:

  • The Call Home thread entered pthread_cond_clockwait() without checking the status first, so the "session is closing" signal was lost if it fired before the thread reached the wait.
  • nc_ps_poll() tested term_reason with a bitwise AND against a sequential enum, so DROPPED, BADHELLO and OTHER were reported as clean closes without NC_PSPOLL_SESSION_ERROR.

Fixes #635

ATOMIC_STORE_RELEASE() and ATOMIC_LOAD_ACQUIRE() are needed to hand
over ownership of a shared object between two threads without a mutex.

The __sync fallback has no load/store builtins, so a release store is
an explicit barrier followed by a plain store and an acquire load is a
no-op fetch-and-add, which is already a full barrier.
ThreadSanitizer reports a data race on nc_session.status between the
Call Home thread reading it in
nc_server_ch_client_thread_session_cond_wait() under ch_lock and a poll
thread writing it in nc_server_send_reply_io() under rpc_lock. There is
no lock held by every thread that uses a session, and there cannot be
one without introducing a lock ordering problem with rpc_lock/io_lock,
so status is simply accessed lock-free by design.

Make that explicit and free of undefined behaviour by turning status
into an ATOMIC_T accessed through NC_SESSION_STATUS_GET/SET(). Silencing
the race requires converting every access, not just the two the
sanitizer happened to pair up.

term_reason is written next to status on almost every path and read by
the same poll thread, so it has exactly the same problem and is
converted along with it.

Refs #635
ThreadSanitizer reports a data race between the Call Home thread
clearing NC_SESSION_CH_THREAD in nc_session.flags and a poll thread
reading NC_SESSION_CALLHOME from the same byte in
nc_ps_poll_session_io(). The flag was documented as protected by
ch_lock, but a read-modify-write of a byte shared with flags that
nobody locks cannot be made safe by any lock.

The flag also could not keep that contract in the first place: two
error paths had to write it without the lock, and two more returned
without clearing it at all, leaving the session marked as owned by a
Call Home thread that had already exited. nc_session_free() then burned
its whole timeout waiting for nobody.

Replace it with a dedicated atomic, nc_session.opts.server
.ch_thread_active, so it can be cleared on every path the thread
leaves by, and let nc_session_free() poll it instead of waiting on
ch_cond. That drops the ch_lock dependency of the wait, so freeing a
Call Home session whose lock could not be acquired now waits for the
thread properly instead of logging an error and racing on.

ch_cond keeps its other direction, nc_session_free() telling the Call
Home thread the session is closing, so the signal stays.

Fixes #635
The Call Home thread entered pthread_cond_clockwait() without ever
checking the session status first, so the signal nc_session_free()
sends to say the session is closing was lost whenever it fired before
the thread reached the wait. The thread then slept for the full
NC_CH_THREAD_IDLE_TIMEOUT_SLEEP, and since ETIMEDOUT skips the status
check it took a config lock and a ch_lock re-acquisition on top of that
before releasing the session.

nc_session_free() waits only NC_SESSION_FREE_LOCK_TIMEOUT, which is the
same 1000 ms, so it could give up first and go on to destroy ch_cond
and ch_lock and free the session while the thread was still blocked on
them.

Turn the loop into a while so that the status is tested under ch_lock
before the first wait, which is the lock nc_session_free() sets it
under.
nc_ps_poll() tested the termination reason with a bitwise AND against
NC_SESSION_TERM_CLOSED | NC_SESSION_TERM_KILLED, but
NC_SESSION_TERM_REASON is a sequential enum, not a bitmask. That mask
is 3, so DROPPED (3), BADHELLO (5) and OTHER (6) all tested non-zero
and the session was reported as a clean close without
NC_PSPOLL_SESSION_ERROR.

Compare against both reasons explicitly, the way nc_ps_poll_sess()
already does.
"Send a reply acquiring IO lock as needed. Session RPC lock must be
held!" reads as if the caller had to hold the IO lock. It describes what
the function does internally, the caller only has to hold the RPC lock;
say so. Same for nc_ps_poll_session_io().
Several conditions read nc_session.status twice, which are two separate
atomic loads. Another thread can change the status between them, so the
two comparisons can be evaluated against different values and reach a
conclusion that was never true at any single point in time.

nc_session_ssh_msg() showed this best: it tested the status twice to
decide the session is not usable and then read it a third time to name
it in the log, so the message could say "invalid" about a session the
condition had matched as "closing".

Take one snapshot into a local and compare that. Checks that are meant
to observe a change, such as the second one in nc_write_msg_io() after
the message has been written, keep re-reading.
@michalvasko
michalvasko merged commit 0a07921 into devel Sep 3, 2026
11 checks passed
@michalvasko
michalvasko deleted the issue635-tsan-session-races branch September 3, 2026 13:20
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.

2 participants