Destroy the protocol parser on disconnect when an event loop is attached - #9
Conversation
Backport of nats-io#1008 by @ arnaudhe, which fixes nats-io#1007. Both are still open upstream, where the only discussion is how to test the change, so this carries the patch onto the branch ClickHouse pins and adds the regression test upstream does not have. The parser holds half-decoded bytes that belong to exactly one socket, so it must be discarded before any byte from the next socket is parsed. The library honours that on the blocking-IO path and violates it on the external event-loop path. `natsParser_Destroy` has two call sites in `src/conn.c`: `_freeConn`, and the exit of `_readLoop`, which also NULLs `nc->ps`. `_readLoop` is the blocking-IO reader and every reconnect creates a new one, so there the parser is per socket. When `nc->opts->evLoop` is set, `_processConnInit` attaches the adapter instead, reads arrive through `natsConnection_ProcessReadEvent`, and that function creates the parser lazily and never destroys it. The disconnect notification point for that path is `_processOpError`, which stops polling and leaves `nc->ps` alone. A drop while the parser is inside `MSG_PAYLOAD` therefore survives into the reconnect, and the next bytes read on the new socket are appended to the stale pending payload. Measured on the unpatched tree with the new test: a header announcing 12 payload bytes followed by 4 of them, then a close, then `MSG foo 1 5\r\nhello\r\n` on the reconnected socket, delivers `natsConn_processMsg(buf="AAAAMSG foo ", bufLen=12)` to the subscriber. The stale parser takes the first 8 bytes of the new protocol line as the payload it was still owed, `MSG_END` then skips to the next `\n`, and the leftover `hello` is not a valid op, so the connection is torn down with `NATS_PROTOCOL_ERROR` right after handing out the wrong message. The handshake itself does not pass through the parser and cannot clear it: `_processExpectedInfo` reads the greeting through `_readProto`, which loops `natsSock_Read` one byte at a time, and `_sendConnect` reads the PONG the same way. The flag is deferred rather than a destroy at the disconnect because both direct alternatives are unavailable. `natsConnection_ProcessCloseEvent` takes `(natsSock *socket)` and has no connection pointer, and the adapters call it as `natsConnection_ProcessCloseEvent(&(nle->socket))`, so widening it is an ABI break for every adapter in tree and out. Destroying `nc->ps` inside `_processOpError` would be a use-after-free: `natsConnection_ProcessReadEvent` unlocks before calling `natsParser_Parse`, and `_processOpError` is entered from the ping timer and the write path on other threads. Setting the flag under `natsConn_Lock` and consuming it at the top of the next `ProcessReadEvent` keeps the destroy on the event-loop thread, where the previous socket's `natsParser_Parse` has already returned. Two comments are reworded from upstream's, and nothing else in the code differs: the `natsp.h` field's trailing comment and the one in `_processOpError` both say "external event loop" instead of naming libuv, because the guard is `nc->el.attached`, which is any adapter, and the test below drives the suite's generic event loop. The new `EventLoopParserResetOnDisconnect` needs no broker binary and no libuv. It drives the suite's generic external event loop, whose thread calls the patched `natsConnection_ProcessReadEvent`, against a two-accept `_startMockupServer` thread that serves the initial connection and the reconnect on one listener. Reading the resent `SUB` is what orders the post-reconnect send after the handshake, so the test does not sleep. It is a new registered case rather than an extension of `test_EventLoop` or `test_EventLoopRetryOnFailedConnect` because both of those drive a real `nats-server` through `_startServer`, and a real server never announces 12 payload bytes and then sends 4, so the defect is unreachable from either. The three checks do not return on failure, so the event loop thread is always stopped by the teardown before the library is closed. Validated with clang-22, Debug, `BUILD_TESTING=ON`, `NATS_BUILD_STREAMING=OFF`, `NATS_BUILD_LIB_STATIC=ON`, out of tree. With the two `src/` hunks reverted and the test present the case fails on 5 of 5 runs of this tree, always at the content check, with the payload above; with them applied it passes on 7 of 7. Two earlier forms of the test, before its teardown and comments were cleaned up, measured the same direction on their own binaries: 13 base failures, 0 base passes, 0 fix failures. The 114 tests of this suite that need no broker binary go 113 of 114 on the reverted tree, the single failure being this new case, and 114 of 114 with the fix, so nothing else moves. The two source files also build clean in ClickHouse's own configuration, which is where this branch is consumed. Reported on: ClickHouse/ClickHouse#76867 (comment) CI report: https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=76867&sha=dfc5ef24014997273075680988a2e6a1712b21b3&name_0=PR&name_1=Integration%20tests%20(amd_asan_ubsan,%20db%20disk,%20old%20analyzer,%204%2F8) Related: ClickHouse/ClickHouse#116417 (comment) Related: nats-io#1007 Related: nats-io#1008 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Internal second-model review: adjudication log (click to expand)Pre-publication review by an independent model (engine: codex; 5 findings) plus my own cold read of
Severity: ❌ blocker / Findings 1 to 5 are recorded here for completeness, not reopened: they concern code that is already |
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-impl-slot-5:20260915-061300 |
Backport of nats-io#1008 by @ arnaudhe, which fixes nats-io#1007. Both are open
upstream, where the only discussion is how to test it, so I am carrying the patch onto the branch
ClickHouse pins and adding the test upstream lacks.
The parser holds half-decoded bytes belonging to one socket, so it must be discarded before any byte
from the next socket is parsed. In
src/conn.cthat happens in_freeConnand at the exit of_readLoop, the blocking-IO reader that every reconnect recreates, so there the parser is persocket. With an event loop attached, reads arrive instead through
natsConnection_ProcessReadEvent,which creates the parser lazily and never destroys it, and the disconnect funnel
_processOpErrorleaves
nc->psalone. A drop while the parser is insideMSG_PAYLOADtherefore survives thereconnect, and the next bytes read on the new socket are appended to the stale pending payload. The
handshake cannot clear it:
_processExpectedInfoand_sendConnectread through_readProto, oneraw byte at a time.
Measured on the unpatched tree: after a header announcing 12 payload bytes followed by 4 of them and
a close, the reconnected socket's
MSG foo 1 5\r\nhello\r\nreaches the subscriber asnatsConn_processMsg(buf="AAAAMSG foo ", bufLen=12); the leftoverhellois not a valid op, so theconnection is then torn down, after the wrong message was handed out. ClickHouse is always on this
path and sees it as a whole-message shortfall in
test_storage_nats.The flag is deferred rather than a destroy at the disconnect because both direct alternatives are
unavailable:
natsConnection_ProcessCloseEventtakes(natsSock *socket), so handing it theconnection breaks the adapter ABI; and destroying
nc->psin_processOpErrorwould be ause-after-free, since
ProcessReadEventunlocks beforenatsParser_Parsewhile_processOpErroralso runs on the ping timer and write paths. Consuming the flag at the top of the next read event
keeps the destroy on the event-loop thread.
Three dispositions, yours to overturn. The code is upstream's unchanged; both of its comments are
reworded to say "external event loop" instead of "libuv", because the guard is
nc->el.attachedandthe test below drives the suite's generic loop. The test is a new registered case, not an extension
of
test_EventLooportest_EventLoopRetryOnFailedConnect, because both drive a realnats-serverthrough
_startServer, which never announces 12 payload bytes and then sends 4; the new case bringsits own two-accept
_startMockupServerthread and needs no broker and no libuv. And I am not sendingthe same cherry-pick onto
ClickHouse/v3.13.0, which is diverged and has its own open bump PR; onecherry-pick onto
137843892fbdon request. The ClickHouse-side pin then advances throughClickHouse/ClickHouse#119867.
Requested on ClickHouse/ClickHouse#76867. Root cause:
ClickHouse/ClickHouse#116417 (comment)
Validation (click to expand)
clang-22,
CMAKE_BUILD_TYPE=Debug,BUILD_TESTING=ON,NATS_BUILD_STREAMING=OFF,NATS_BUILD_EXAMPLES=OFF,NATS_BUILD_LIB_STATIC=ON, configured out of tree. Both arms are thecommitted form of the test.
src/conn.c+src/natsp.htestsuitesha256EventLoopParserResetOnDisconnect6daa0163cb384612fff6...c0ef8098240390188a08...Base arm output:
Two earlier forms of the same test, before its teardown and comments were cleaned up, measured the
same direction on their own binaries: 13 base failures, 0 base passes, 0 fix failures.
The 114 tests in this suite that need no
nats-serverbinary go 113 of 114 on the base tree, thesingle failure being this new case, and 114 of 114 with the fix, so nothing else moves. The
nats-server-dependent tests (EventLoop,EventLoopRetryOnFailedConnect,EventLoopTLS) wereexcluded rather than measured, since this machine has no broker binary. The two source files also
build clean in ClickHouse's own configuration, where this branch is consumed.