Conversation
`natsSock_WaitReady` reported every `poll()` failure as `NATS_IO_ERROR`,
including `EINTR`. `poll` is never restarted after a signal handler runs,
whatever `SA_RESTART` says (`signal(7)`), so `EINTR` there says nothing about
the socket: the wait was simply cut short. Reporting it as an I/O error makes
`natsSock_ConnectTcp` close the fd and move to the next `addrinfo`, so a single
asynchronous signal delivered during a connect or a handshake read fails the
whole connection attempt.
Any embedder that delivers periodic per-thread signals reaches this. ClickHouse
does so by default: it arms a 10 s profiler timer (SIGUSR1 with
`SIGEV_THREAD_ID`) on every thread it takes from its global thread pool, with
the first fire placed at a uniformly random point inside that first period so
that short-lived work is still sampled, and it runs the NATS event loop on such
a thread while posting the connect onto it milliseconds later. Its `NATS` table
engine then fails to be created with
Cannot connect to Nats last error: (unix/sock.c:56): poll error: 4
where `errno 4` is `EINTR`. ClickHouse CI hit that leaf three times inside
3 h 35 min on 2026-09-14, on three unrelated carriers and three different build
flavours: on master in `Integration tests (amd_asan_ubsan, db disk, old
analyzer, 4/8)` at 19:30:23Z, and on two unrelated pull requests in `Integration
tests (arm_binary, distributed plan, 1/4)` at 20:53:58Z and in `Integration
tests (amd_llvm_coverage, 8/8)` at 23:05:25Z. The third check is green only
because the integration runner's retry passed; its recorded context still
carries the leaf. Both test functions of a new integration module are
represented. `arm_binary` is a plain aarch64 build with no sanitizer, so the
defect is neither sanitizer- nor x86-specific. That module sets the engine's
connect attempts to 1, which is what turns one interrupted `poll` into a failed
DDL rather than a retried connect.
The `poll` now sits in a loop that recomputes `natsDeadline_GetTimeout(deadline)`
on each iteration and continues when `poll` fails with `EINTR`. Recomputing is
what bounds the loop: `natsDeadline_GetTimeout` returns -1 for an inactive
deadline, which is the requested wait-forever semantics, and otherwise the
remaining milliseconds clamped at 0, so an expired deadline gives `poll` a
timeout of 0, `poll` returns 0, and the pre-existing `NATS_TIMEOUT` arm fires
exactly as before. Re-arming the full timeout instead would be the one way to
get this wrong, which is why the new test arms bound the elapsed time as well as
the status. The three result arms, the `pfd` setup and the `waitMode` switch are
unchanged, so nothing changes on any path that does not see `EINTR`, and there
is no new symbol, no signature change and no lock.
This is the only `poll` or `select` in the non-Windows sources, and it is
reached both for connect completion and for the handshake read, so the two call
shapes are covered by the one change. `natsSock_Read` and `natsSock_Write`
report an `EINTR` from `recv`/`send` the same fatal way and are left alone as
unreproduced: their SSL arms cannot see one, because the TLS blocking window
from `natsSock_SetBlocking(fd, true)` in `_makeTLSConn` to the matching restore
around `SSL_do_handshake` contains no such call, and their plain arms see a
blocking fd only without an external event loop (`_processConnInit`,
`conn.c:1983`, when `opts->writeDeadline <= 0`, after which
`_spinUpSocketWatchers` runs `_readLoop` on it), which is not a mode ClickHouse
uses, since it always sets an event loop and `conn.c:1992` then restores
non-blocking. `SSL_do_handshake` itself can report a signal-interrupted blocking
handshake, but it is a different call site with a different retry contract, so
it too is deliberately left alone.
Test: two arms in the existing `test_natsWaitReady`, both run under a storm
thread that `pthread_kill`s SIGALRM at the waiting thread about every
millisecond, and both asserting a receipt counter the handler increments, so
that a failed `sigaction`, a failed mutex or thread create, or undelivered
signals fail the case instead of quietly degrading it to an unsignalled wait.
The first arm wraps the existing no-deadline case, where the wait survives some
460 delivered signals and still returns `NATS_OK` when the fake server's byte
arrives, inside the same 450-600 ms bound; that is what pins progress. The
second keeps a 50 ms deadline and asserts `NATS_TIMEOUT` inside 40-100 ms under
some 46 signals; that is what pins the deadline being recomputed rather than
re-armed. The stop flag is guarded by a `natsMutex` instead of being a plain
`volatile`, because the suite's own test properties set
`TSAN_OPTIONS=...:halt_on_error=1` whenever `NATS_SANITIZE` is on, and an
unsynchronised flag aborts this very case under `-fsanitize=thread`. The storm
also stops by itself after 1500 ms, far past both arms' bounds, so that an
implementation re-arming the full 50 ms on each retry fails the second arm's
duration bound at about 1.55 s rather than stalling in the wait until the fake
server tears the connection down. Measured on this branch: both arms fail before
this change with the `poll error: 4` message above and pass 10 of 10 after; with
the `pthread_kill` call removed both fail on the receipt counter alone; with the
deadline recompute hoisted back out of the loop the second arm fails on duration
at 1549 ms; and `natsWaitReady` is clean under `-fsanitize=thread`, where the
unsynchronised flag is reported as a data race. Delivery is targeted at one
thread, so no helper thread's `nats_Sleep` is cut short; the arms are guarded
for non-Windows, and the no-op handler is left installed rather than restored,
so a signal still in flight cannot terminate the process.
The defect is verbatim in this fork's `ClickHouse/v3.13.0` branch and in
upstream `nats-io/nats.c` `main`.
CI reports:
https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?REF=master&sha=3fba61b4895078ed184a939a9012caba51022398&name_0=MasterCI&name_1=Integration%20tests%20%28amd_asan_ubsan%2C%20db%20disk%2C%20old%20analyzer%2C%204%2F8%29
https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=116234&sha=a9aa67f7a30ee303d3f001a812db3f712411414e&name_0=PR&name_1=Integration%20tests%20%28arm_binary%2C%20distributed%20plan%2C%201%2F4%29
https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=96844&sha=4631dd43b10e7028e31e10a26564556c2ec95aa0&name_0=PR&name_1=Integration%20tests%20%28amd_llvm_coverage%2C%208%2F8%29
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Internal second-model reviewAn independent reviewer (different model, fresh context) and a separate cold code review ran over ❌ No positive-path oracle for the retry (agreed, fixed). Every assertion in the first version of ❌ The signal storm was process-directed (agreed, fixed). ❌ The storm's stop flag was unsynchronised (agreed, fixed). It was a
💡 Redundant ✅ "The submodule range ships the libuv and JetStream fixes too, so wait for the pin bump" The reviewer's remaining findings are against |
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-impl-slot-4:20260915-012300 |
|
Superseded: the retry landed on this branch as |
natsSock_WaitReadyreports everypoll()failure asNATS_IO_ERROR, includingEINTR, so one asynchronous signal delivered during a connect or handshake wait fails the whole connection attempt.pollis never restarted after a signal handler runs, whateverSA_RESTARTsays (signal(7)), soEINTRhere says nothing about the socket. Any embedder that delivers periodic per-thread signals reaches this. ClickHouse does by default: it arms a 10 s per-thread profiler timer (SIGUSR1,SIGEV_THREAD_ID) on every thread from its global pool, its first fire placed at random inside that first period, and it runs the NATS event loop on such a thread. When the signal lands in the connect window,natsSock_ConnectTcpdiscards the fd andCREATE TABLE ... ENGINE = NATSfails witherrno 4isEINTR. ClickHouse CI hit it three times in 3h35m on 2026-09-14, in both tests of a new integration module and on three different builds: on master in anamd_asan_ubsanrun, and on two pull requests underarm_binaryandamd_llvm_coverage, the third green only because a retry passed, so it is neither sanitizer- nor x86-specific. That module setsnats_startup_connect_tries=1, which turns one interruptedpollinto a failed DDL rather than a retried connect.Fix: the
pollnow sits in a loop that recomputesnatsDeadline_GetTimeout(deadline)on each iteration and continues onEINTR. The remaining time bounds it: an inactive deadline yields-1(wait forever, the requested semantics) and an expired one0, sopollreturns0and the pre-existingNATS_TIMEOUTarm fires as before. Nothing changes on a path that does not seeEINTR. This is the onlypollorselectin the non-Windows sources, and it is reached both for connect completion and for the handshake read.Test: two arms in the existing
test_natsWaitReady, both waiting under a storm thread thatpthread_killsSIGALRMat the waiting thread about every millisecond, and both asserting a delivery counter, so a run that received no signal cannot pass. One requiresNATS_OKinside the existing 450-600 ms no-deadline bound, which pins progress across ~460 interruptions; the other requiresNATS_TIMEOUTinside 40-100 ms, which pins a recomputed rather than re-armed deadline. Both fail before this change on the samepoll error: 4leaf and pass 10/10 after. On a real ClickHouse server against a silent listener, the message appears in exactly one of four cells: unpatched, profiler on.The same function is unpatched upstream in
nats-io/nats.cmain. ClickHouse's pin needs a bump to pick this up;ClickHouse/ClickHouse#119867already moves it to this commit's parent.What I deliberately did not change, and levers for you
SSL_do_handshake(conn.c:749) is the one sibling structurally reachable with a blocking fd, since_makeTLSConnsets blocking at:677and restores it at:760. Different call site, different retry contract, not reproduced, so left alone.natsSock_Read/natsSock_Writealso report anEINTRfromrecv/sendas a fatalNATS_IO_ERROR. Their SSL arms cannot see one, because the TLS blocking window above contains nonatsSock_Read/Writecall. Their plain arms can, but only in the mode ClickHouse does not use:_processConnInit(conn.c:1983) switches the fd to blocking whenwriteDeadline <= 0, and_spinUpSocketWatchersthen runs_readLoopon it. With an external event loop, which ClickHouse always sets,:1992restores non-blocking and no_readLoopis started, so a transfer returnsEWOULDBLOCKinto the function fixed here instead. Not reproduced either, so also left alone. Say the word if you want either covered.ClickHouse/v3.13.0carries the identical unpatched function; I can open the same PR against that branch if you want both lines fixed.On the ClickHouse side, dropping
nats_startup_connect_tries=1from the two rotation fixtures would restore the shipped default of 5 attempts, and since the profiler interval is 10 s while five attempts complete in milliseconds, one interrupt cannot reach more than one of them. I have not done it, because it papers over this bug and drops coverage of a real setting value.natsSock_Flush'sfsynchas no callers.