Skip to content

libuv adapter: do not let an attach overtake a queued poll removal - #5

Merged
alexey-milovidov merged 1 commit into
ClickHouse:ClickHouse/v3.9.2from
groeneai:fix-libuv-attach-ordering
Sep 13, 2026
Merged

alexey-milovidov merged 1 commit into
ClickHouse:ClickHouse/v3.9.2from
groeneai:fix-libuv-attach-ordering

Conversation

@groeneai

Copy link
Copy Markdown

natsLibuv_Attach can install a new socket before an already queued READ removal is drained, and that removal then closes the new socket.

The adapter defers poll mutation to the event loop thread through the nle->head/tail FIFO, but attach did not follow that discipline: it wrote nle->socket/nle->events inline (on a reconnect that runs on the nats-io reconnect thread, _doReconnect -> _processConnInit -> evCbs.attach, so those writes race uvPollUpdate's close branch on the loop thread), and it scheduled only if (sched) where _Read/_Write/_Detach use sched || (nle->head != NULL), so a loop-thread attach could jump ahead of queued removals with no second thread involved.

Both reach the same outcome. _evStopPolling only requests removal of write then read, and _processOpError then starts the reconnect thread. When the loop thread finally drains that older READ removal, nle->events reaches 0, uvPollUpdate takes the close branch, and natsConnection_ProcessCloseEvent closes the freshly connected fd. The reconnect still reports success (nc->el.attached = true) while the connection never polls again, so a subscriber goes permanently silent after a broker restart.

Fix: carry the socket in the NATS_LIBUV_ATTACH event and assign nle->socket/nle->events inside uvAsyncAttach, so both are written only by the loop thread; and give attach the same "or events are queued" guard as the other three callbacks. Ordering is then enforced by the queue rather than by timing, and the race disappears because the fields are no longer shared. No new lock. First attach is unaffected: a freshly calloc'd nle has head == NULL.

Found by ThreadSanitizer in ClickHouse CI, which vendors this adapter (1 hit in 1455 tests, alongside the NATS server becoming unreachable during a broker-restart test):

SUMMARY: ThreadSanitizer: data race adapters/libuv.h:392:21 in natsLibuv_Attach

CI report

That window is narrow, so I verified with a standalone harness linking this header against libuv under ThreadSanitizer, injecting the interleaving rather than waiting for it. Before, 25/25 deterministic runs close the new fd and 23/25 concurrent runs report the race; after, 0 in both, with the close path still exercised.

Validation detail, test-suite gap, and upstream context

The harness oracle is the outcome rather than the sanitizer warning: it counts closes of an fd installed by an attach after the drained removal was queued, so it cannot pass by simply losing coverage (a run that never reaches the close branch is reported as a broken probe). A separate probe demonstrates the loop-thread queue-jump with no second thread involved: it fails before and passes after.

I did not add a test to test/test.c: libuv is not part of this repo's build or CI (NATS_BUILD_LIBUV_EXAMPLE defaults OFF and needs a caller-supplied LIBUV_DIR), and the existing event-loop test registers mock callbacks rather than natsLibuv_*. That is also why this went unnoticed. Happy to add one if you want libuv wired into the test build.

Both defects are verbatim in current upstream nats-io/nats.c; I am filing an issue there too. Upstream nats-io#814 (closed) produced the split-close design this bug lives in; nats-io#1005/nats-io#1006 touch the same function but are a different defect, deliberately not folded in.

adapters/libevent.h has a structurally similar attach but keeps no socket field (it stores struct event * and reads the fd back via event_get_fd), so it is unaffected.

The adapter defers poll mutation to the event loop thread through a FIFO,
but natsLibuv_Attach did not follow that discipline in two ways.

First, it wrote nle->socket and nle->events directly. On a reconnect that
runs on the nats-io reconnect thread (_doReconnect -> _processConnInit ->
evCbs.attach), those writes race the event loop thread's write in
uvPollUpdate's close branch. ThreadSanitizer reports it as a data race on
a 4-byte field of natsLibuvEvents.

Second, it scheduled only when it was called off the loop thread, while
Read/Write/Detach also schedule when events are already queued. A
loop-thread attach could therefore run ahead of removals already in the
list.

Both let an attach install a new socket before an older, already queued
READ removal is drained. _evStopPolling only requests the removals and
then _processOpError starts the reconnect thread, so when the loop thread
finally drains that removal, nle->events reaches 0 and uvPollUpdate takes
the close branch on the socket the attach just installed:
natsConnection_ProcessCloseEvent closes a live, freshly connected fd and
sets nle->socket to invalid. The reconnect still reports success
(nc->el.attached = true) but the connection never polls again.

Carry the socket in the ATTACH event and assign nle->socket and
nle->events inside uvAsyncAttach, so both fields are written only by the
loop thread, and give the attach the same "or events are queued" guard as
the other three callbacks. Ordering is then enforced by the queue instead
of by timing, and the race disappears because the fields are no longer
shared. No new lock is introduced; nle->lock still guards only head/tail.

Reproduced with a standalone harness that links this header against libuv
under ThreadSanitizer and drives the interleaving deterministically:
before, 25/25 runs close the freshly attached fd and 23/25 concurrent runs
report the data race in natsLibuv_Attach; after, 0 in both.
@groeneai

Copy link
Copy Markdown
Author
Internal second-model review (2 findings, both resolved without code change)

Before opening this PR I ran an independent cold review plus a second-model review of the diff, then adjudicated every finding against the recorded evidence. Both findings were about verification and documentation rather than the change itself; neither produced a code change.

⚠️ missing-libuv-regression (major) - disagreed, with a gap disclosed in the description.
The finding asked for a deterministic in-repo libuv test that would fail on the parent revision. Its citations are accurate and I verified each: test/test.c:20209 defines _evLoopAttach as a mock, test/test.c:20335-20339 registers those mocks via natsOptions_SetEventLoop, and grep -rn natsLibuv test/ returns 0, so the suite never exercises this adapter. I disagreed because the ask is not satisfiable here: CMakeLists.txt:41 gates libuv behind NATS_BUILD_LIBUV_EXAMPLE (default OFF), CMakeLists.txt:70 needs a caller-supplied LIBUV_DIR, and no file under .github/workflows/ installs or links libuv, so such a test could not compile in CI. Wiring libuv into the test build is a build-system change well outside this fix. The substance is covered by the standalone harness, which links the real header against real libuv under TSan; I re-ran both arms myself rather than trusting the recorded numbers, with verified-distinct build IDs: order mode base OUTCOME=1, socket_field=-1 versus fix OUTCOME=0, REACHED=1, socket_field=<new fd>; race mode base 4 of 5 runs naming natsLibuv_Attach versus fix 0 warnings and 0 summaries; edge probe base rc=1 "loop-thread attach jumped the queue" versus fix rc=0. The gap is stated in the description rather than left implicit.

💡 missing-pr-contract (nit) - disagreed, review-harness artifact.
Reported the PR body as absent. It exists; the gate observed a different scratch directory because of the route used to give it reviewable content (see below). No action.

One note on process, since it affects how much weight to give the gate here. The deliverable is a commit inside a vendored submodule, so my first gate run reviewed the superproject and correctly reported an empty diff, which no re-run would have changed. I re-ran it against a worktree of the submodule at this commit and confirmed the scope was full rather than narrowed: the reviewed change map was "1 file changed, 60 insertions(+), 19 deletions(-)", byte-identical to git diff --stat for this commit, against base cf441828d30fd. So the findings above came from a review of exactly the content in this PR.

@groeneai

Copy link
Copy Markdown
Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. A standalone harness links this header against libuv under ThreadSanitizer and injects the interleaving instead of waiting for it (the CI window is 1 hit in 1455 tests). 25/25 runs reproduce.
b Root cause explained? Yes. _evStopPolling only queues the WRITE then READ removals, and _processOpError then starts the reconnect thread. The reconnect thread's natsLibuv_Attach installs the new fd before the loop thread drains that older READ removal, so nle->events reaches 0, uvPollUpdate takes the close branch, and natsConnection_ProcessCloseEvent closes the fd just installed. The reported data race is the same off-thread write seen by the sanitizer.
c Fix matches root cause? Yes. Poll state becomes loop-thread-owned and the attach is placed behind the queue, so ordering is structural. Deliberately not a mutex: a lock silences the sanitizer while the socket-close bug survives, because the removal is already queued and a lock does not reorder it.
d Test intent preserved / new tests added? No existing test is weakened or removed. The defect is in a vendored header that no SQL surface reaches, so coverage is the existing test_storage_nats/test_nats_jet_stream.py integration test plus the standalone reproducer carried in this change.
e Both directions demonstrated? Yes, from identical harness source with verified different Build IDs. Deterministic mode: 25/25 close the new fd before, 25/25 pass after with the close path still exercised. Concurrent mode (25 runs x 20 rounds): 36 data races and 23/25 runs naming natsLibuv_Attach before, 0 in every metric after.
f Fix is general across code paths? Yes. All four callbacks now share one queue-jump guard, and the state is fixed at its owner (uvAsyncAttach) rather than guarded at the close site. adapters/libevent.h has a structurally similar attach but keeps no socket field (it stores struct event * and reads the fd back via event_get_fd), so it is unaffected and left alone.
g Fix generalizes across inputs (params/datatypes/wrappers)? No type or settings matrix applies to this surface. Value-domain edges covered: first attach vs reconnect, empty vs non-empty queue, loop vs non-loop caller, attach failure (existing single cleanup path), and event types carrying no socket. Both UV_VERSION_MAJOR arms of uv_poll_init* updated.
h Backward compatible? (maintainer-approved exception only) Yes. No setting, no serialization format, no experimental gate. natsLibuvEvent is private to this header, so growing it breaks no ABI.
i Invariants and contracts preserved? Yes. nle->lock still guards only head/tail; the new field is written into a locally allocated event before it is linked, matching the existing type/add pattern, so the critical section and the uv_async_send-under-lock reasoning are unchanged. No new lock, so no new lock-ordering risk. uvFinalCloseCb frees queued events without reading socket, so teardown needs no change. Error paths still route through the one pre-existing cleanup.

Session id: cron:clickhouse-impl-slot-6:20260818-044502

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