Handle canceled keys in TCP client#411
Conversation
A failed write in ChannelState#processWrite canceled the selection key and then fell through to key.interestOps(OP_READ), throwing CancelledKeyException. The exception escaped runSelector's IOException handling and killed the shared selector thread: all pending and future queries hung forever (their timeouts run on that thread), and on Android, where an uncaught exception on any thread is fatal, the whole app crashed. Triggered in the field by servers that reset a TCP connection while multiplexed queries are still being written. Treat a write failure like the other error paths in this class: fail all transactions multiplexed on the channel, close it and stop processing the key. Also re-check key validity before the isReadable check in processReadyKey, which would otherwise throw for the canceled key right after processWrite returns. This is the TCP counterpart of dnsjava#129.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #411 +/- ##
============================================
+ Coverage 66.69% 66.79% +0.09%
Complexity 3074 3074
============================================
Files 198 198
Lines 13645 13645
Branches 2126 2126
============================================
+ Hits 9101 9114 +13
+ Misses 3966 3954 -12
+ Partials 578 577 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
|
This is the text from claude code for more context: What the PR contains (commit 0ce1ec8, "Handle canceled keys in TCP client") The fix turned out to need two changes, not one — and the test proved it:
Plus a regression test, testWriteFailureDoesNotKillSelectorThread, built from Verification, both directions: without the fix the test fails ★ Insight ─────────────────────────────────────
|
|
Forgot to mention - feel free to take over the patch and commit it yourself. I'm not its author anyway so no credits needed. |



Disclaimer: the whole patch, including the description below, was generated using Claude Fable using the xhigh effort - I only experienced the crash on Android and asked it to fix the problem, but I didn't dig into the problem myself. Please let me know if the patch needs any adjustments.
Problem
A failed write in
NioTcpClient.ChannelState#processWrite(e.g. the server resetting theconnection while multiplexed queries are still being written) kills the shared selector
thread with an unhandled
CancelledKeyException:There are two escape paths:
processWritecatches theIOExceptionfromTransaction#send, callskey.cancel(),and then falls through to
key.interestOps(SelectionKey.OP_READ)on the canceled key.processReadyKeycallskey.isReadable()right afterprocessWritereturns; on the now-canceled key this also throws (
isReadable→readyOps→ensureValid).Since
runSelectoronly handlesIOException/ClosedSelectorException, the exceptionkills the selector thread. All pending and future queries then never complete — query
timeouts run on that same thread — and on Android, where an uncaught exception on any
thread is fatal, the whole application crashes. We see this regularly in the field from an
app whose DNS tool issues parallel queries with DNSSEC (truncation → TCP fallback,
multiplexed on one channel) against home routers that reset the TCP connection mid-write.
This is the TCP counterpart of #129 ("Handle canceled keys in UDP client", fixed in 3.6.5).
Fix
processWrite: treat a write failure like the other error paths in this class(
processConnect,processRead) — a failed write leaves the TCP stream in an undefinedstate for every transaction multiplexed on the channel, so fail them all via
handleChannelException, cancel the key, and return without touchinginterestOps.processReadyKey: re-checkkey.isValid()before theisReadable()branch, sinceprocessWritemay have canceled the key.Test
testWriteFailureDoesNotKillSelectorThreadreproduces the crash deterministically: largequeries against a server with a 16-byte receive buffer (the
testResponseStreamtrick)leave transactions queued mid-write with
OP_WRITEarmed; the server then resets theconnection, so the next write fails inside
processWrite. The test asserts that alltransactions complete exceptionally and that the selector thread survives (watched via an
uncaught-exception handler, the same pattern as
testProcessReadyKeysShouldNotThrowConcurrentModificationException).Without the fix the test fails deterministically (selector thread dies, transactions never
complete); with the fix, the whole
NioTcpClientTest/NioUdpClientTestsuites pass.