Address forceReconnectImpl reader/writer stop race - #1601
Conversation
|
@claude review |
|
Claude finished @scottf's task in 2m 54s —— View job Claude's Review
Overall AssessmentThe fix is correct and well-reasoned. The root cause diagnosis in the PR description is accurate, and the three-part remedy (ordering, timeout, future capture) directly addresses each piece of the race. What the Fix Gets Right1. Critical ordering: This is the crux of the fix. The reader's } catch (IOException io) {
if (running.get()) {
this.connection.handleCommunicationIssue(io);
}
}Previously, The 2. Timeout extended from 100ms → The async port close is what actually unblocks the blocked 3. Future captured before async close Future<Boolean> readerStopped = reader.isRunning() ? reader.stop(false) : null;This captures the reader's current 4. Guard against double-stop is correct
Minor ObservationsIndependent timeouts for reader and writer Both joins use the same The if (reader.isRunning()) {
this.reader.stop().get(timeoutNanos, TimeUnit.NANOSECONDS);
}On the Test coverage No new tests are added, which is understandable — this race requires precise thread scheduling (busy executor + TLS) and is difficult to reproduce deterministically. The existing Comments The two large comment blocks are unusually long, but for a concurrency fix with this many interacting invariants, explaining why in place is valuable for future maintainers. The comments are accurate and precise. VerdictNo blocking issues. The fix correctly addresses all three points in the race trace: ordering of stop vs. close, sufficient join timeout, and the |
forceReconnectImpl tears the socket down with reader.stop(false) + an async port close + a 100 ms join, then proceeds to reconnectImpl() regardless of whether that join succeeded. Three facts combine into a race:
reader.stop(false) sets running=false but does NOT shutdownInput() — the reader thread stays blocked in dataPort.read(...); only the port close wakes it, and that runs on another thread.
The stopped future completes only when run() actually returns, so the .get(100ms) is really "wait up to 100 ms for the reader thread to die" — and on a busy executor / TLS socket it can miss.
tryToConnect re-joins the reader only if (reader.isRunning()) (V3: core/.../NatsConnection.java:516), which is now false — so it does NOT wait for a still-alive reader before calling reader.start(...) on the same instance.
A reader that outlives the 100 ms window can then, once the async close finally unblocks it: throw IOException, see running flipped back true by the new start(), call handleCommunicationIssue on the now-healthy connection (spurious full reconnect), and — via its finally { running.set(false) } — stomp the shared flag the freshly started reader loops on. Timing-dependent, worse under executor pressure and TLS. See the audit for the full trace.