Conversation
A connection attached to an external event loop could be freed while its poll
handle was still armed, so a later poll event dereferenced it:
ERROR: AddressSanitizer: heap-use-after-free
READ of size 8 ... natsConnection_ProcessReadEvent conn.c:4105
natsLibuvPoll adapters/libuv.h:197
freed by ... natsConnection_ProcessReadEvent conn.c:4145
The adapter keeps the connection as a raw, never-cleared nle->nc, and the
library held no reference for the attachment. When the last user reference is
dropped from a non-loop thread, _close only QUEUES the poll removals and the
detach onto the loop thread, so that release can bring the count to zero while
an event is still in flight. The guard at the top of
natsConnection_ProcessReadEvent is meant for exactly such a late event, but it
needs a live connection to be readable at all.
This is a backport of upstream b89b44d ("EventLoop: Libuv may crash if
connection destroyed while consuming", resolves nats-io#888) and
655c8ea ("EventLoop: Handling of possible failure on initial attach"). The
library now retains once at the very first attach, and an adapter calls the new
natsConnection_ProcessDetachedEvent when it can no longer dereference nle->nc,
so the connection provably outlives the poll handle.
The library half (natsp.h, conn.c, nats.h) is upstream's. The adapter half
diverges twice, because this branch's libuv adapter is not upstream's:
* Upstream's third b89b44d hunk, uv_poll_stop(nle->handle) at the top of
natsLibuv_Read, is not taken. natsLibuv_Read(userData, false) runs on
whatever thread closes the connection, routinely not the loop thread, which
is why the lines below it compute sched and queue the work; uv_poll_stop
mutates loop->watchers, and nle->handle is legitimately NULL here. It is also
not load-bearing for memory safety: with the retain in place, a late poll
event lands on a live connection and is refused by the existing guard.
* Upstream's "else if (created) natsLibuvEvents_free(nle, false)" teardown
raw-frees a uv_async_t that uv_async_init has already linked into
loop->handle_queue and loop->async_handles. Here, a failed attach closes what
libuv knows about and frees only storage it never saw (the choice is made on
the handle's own type: uv_poll_init sets it in the same step that links the
handle into the loop, and can fail on either side of that step), uvAsyncAttach
undoes its own poll handle, and whether the attachment reference is owned
travels in a new releaseConnOnFree flag. A uv_mutex_init failure now also
frees and nulls nle->lock, so a non-NULL lock always means an initialized
mutex, which that teardown requires (uv_mutex_destroy aborts on an
uninitialized one).
The detach also closes a poll handle the library's stop-polling request failed to
retire. _evStopPolling issues the write-event removal first and asks for the read
removal only if that one succeeded, _close discards its result either way, and off
the loop thread the removal is an allocation that can fail, so the detach can be
reached with the handle still armed. The adapter's free is now the release point,
so that handle is the adapter's to close, and uvHandleClosedCb only frees the
handle, which makes it safe whichever order libuv runs the two close callbacks in.
Gating the teardown on "created" also repairs a pre-existing defect: a failure
on a SUCCESSIVE attach used to free nle unconditionally, leaving a dangling
nc->el.data for the next reconnect to dereference. Measured by failing the
reconnect's scheduling call: before, the connection never came back up and the
poll handle leaked; now it reconnects on a later attempt.
test: carry b89b44d's test hunks, and add test_EventLoopDestroyWhileAttached,
which destroys the connection while the event loop is still attached and then
delivers a late event. Under ASan it reproduces the report above on the
unpatched branch and passes here.
Internal second-model review: adjudication log (click to expand)Pre-publication review by an independent model over four rounds (engine: codex; 3, then 2, then 1,
Severity: ❌ blocker / Also noticed and deliberately not changed, none of them reachable from any input a user can run: Session id: cron:clickhouse-review-slot-9:20260923-072700 |
Pre-PR validation (a-i)
Two mutation arms are the non-vacuity proof: removing only the library's retain brings the |
Backport of upstream nats-io/nats.c@b89b44d07 (resolves nats-io#888) and nats-io/nats.c@655c8ea54, neither on this branch. Red in ClickHouse CI now, on
Integration tests (amd_asan_ubsan, db disk, 4/8): https://github.com/ClickHouse/ClickHouse/actions/runs/35728383336/job/106765149366 . The abort kills the server container, so 18 of that shard's 33test_nats_core.pycases go red at once. Five such jobs on five PRs since 2026-09-20; 0 true-master rows.The adapter keeps the connection as a raw, never-cleared
nle->nc, and the library held no reference for it. When the last user reference is dropped off the loop thread,_closeonly queues the poll removals and the detach, so that release can bring the count to zero while a read event is in flight. The guard atopnatsConnection_ProcessReadEventexists for exactly such a late event, but it needs a live connection to be readable at all. The library now retains once at the very first attach, and an adapter calls the newnatsConnection_ProcessDetachedEventwhen it can no longer dereferencenle->nc.Abridged ASan report from that job (the freed-by stack is the whole root cause)
Validation, same sanitizer setup, driving the destroy off the loop thread:
natsConn_createin the LSan stack)uv_poll_startfailed on the very first attachnatsLibuvPollsrc/natsp.h,src/conn.candsrc/nats.hare upstream's hunks unchanged. Three adapter-side departures, because this branch's adapters are not upstream's:uv_poll_stop(nle->handle)atopnatsLibuv_Read, is not taken: it would mutateloop->watchersfrom whatever thread closes the connection and deref annle->handlethat is legitimately NULL there. Nor is it load-bearing: with the retain a late poll event lands on a live connection, which the existing guard refuses.else if (created) natsLibuvEvents_free(nle, false)raw-frees auv_async_talready linked intoloop->handle_queueandloop->async_handles. Here a failed attach closes what libuv knows about and frees only storage it never saw,uvAsyncAttachundoes its own poll handle, reference ownership travels in a newreleaseConnOnFreeflag, and a failinguv_mutex_initfrees and nullsnle->lockso a non-NULL lock always means an initialized mutex. Thatcreatedgate also repairs a pre-existing defect the refcount contract makes mandatory: a successive attach failing used to freenleunconditionally, leavingnc->el.datadangling for the next reconnect._evStopPollingasks for the read removal only if the write removal succeeded and_closediscards the result, so off the loop thread an allocation failure can leave it armed past the free of itsnle.test_EventLoopDestroyWhileAttachedis a new registered case, deterministic both ways, and the four existingEventLoop*cases still pass againstnats-serverv2.14.6. It covers the library half; the adapter half is covered by the driver above, since nothing here compiles an adapter header.Related, not a substitute: ClickHouse#119507 would bump this submodule to
v3.13.0, which has both commits; it is open and master pinsv3.9.2.