Keep JDK Unix socket timeouts from stopping trace delivery - #12322
Draft
bric3 wants to merge 3 commits into
Draft
Conversation
Create the `SocketChannel` with `TunnelingJdkSocket` instead of waiting until `connect` so they share the same lifetime. This gives each socket one stable channel and ensures a failed connection closes all associated resources. Okio may close a socket from its _timeout watchdog_ while another thread is _initializing the read selector_ or _performing a half-close_. Coordinate these compound lifecycle operations on the socket monitor: - `getInputStream` publishes the selector before `close` can snapshot it, also it runs once per physical OkHttp connection - `shutdownInput` and `shutdownOutput` complete their channel operation and state update without interleaving with `close`. Those are lifecycle operations. - `close` publishes the terminal state and snapshots the selector atomically, then releases the monitor before closing resources and waking blocked reads. This is a lifecycle operation. - The normal bulk reads and writes only perform volatile state reads. - Previous synchronization on the selection key is unchanged. So, this introduces the socket monitor only for these lifecycle transitions. This keeps normal reads and writes outside the socket monitor. Only selector publication and socket lifecycle transitions require that coordination.
This comment has been minimized.
This comment has been minimized.
Contributor
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What Does This Do
Keeps trace delivery alive when an OkHttp request over a JDK Unix-domain socket times out.
This pull request makes two related but distinct changes:
It also adds focused selector tests and a writer-level timeout-then-reconnect test over a real Unix-domain socket.
Motivation
1. Keep selector closure on the I/O failure path
OkHttp creates the Agent connection with
UnixDomainSocketFactory. Okio then wraps that socket and usesAsyncTimeout.Watchdogto enforce the read timeout. When a response stalls, the watchdog closes the socket from a different thread to interrupt the blocked read.Closing
TunnelingJdkSocketcloses its selector. Depending on where the reader is, JDK NIO can then throwClosedSelectorExceptionorCancelledKeyException. These are unchecked exceptions, so Okio does not handle them as transport failures. They can escape the send path and stop the singledd-trace-processorthread, after which later traces are no longer sent.flowchart LR subgraph DD["dd-trace-java"] worker["TraceProcessingWorker"] api["DDAgentApi"] udsRead["TunnelingJdkSocket.read()"] udsClose["TunnelingJdkSocket.close()"] nextPayload["next payload<br/>fresh connection"] end subgraph OkHttp["OkHttp 3.x"] codec["Http1Codec"] connection["RealConnection"] end subgraph Okio["Okio 1.x"] source["Okio.source(socket)"] watchdog["AsyncTimeout.Watchdog"] end subgraph JDK["JDK NIO"] select["Selector.select()"] lifecycleException["ClosedSelectorException<br/>or CancelledKeyException"] end worker --> api --> codec --> source --> udsRead --> select watchdog -->|"read timeout closes socket"| udsClose udsClose -->|"closes selector"| select select --> lifecycleException lifecycleException -->|"translated to SocketException"| udsRead udsRead -->|"IOException"| source source -->|"failed exchange"| codec codec -->|"failed send; worker survives"| worker worker --> nextPayload --> connection --> udsReadThe socket now translates only these selector lifecycle exceptions to
SocketException. The timed-out POST is not retried because the Agent may already have received it. Instead, that payload fails normally, the processor keeps running, and the next payload uses a fresh connection.2. Serialize socket lifecycle transitions
The exception translation makes asynchronous closure survivable, but the socket also needs a coherent lifetime while OkHttp and Okio use it from different threads.
The synchronization is deliberately narrow. It protects compound lifecycle operations rather than normal I/O:
getInputStream()validates the state and publishes the lazily created selector beforeclose()can snapshot it.shutdownInput()andshutdownOutput()keep validation, the channel operation, and the state update atomic withclose().close()publishes the terminal state and snapshots the selector under the socket monitor, then closes the selector and channel after releasing the monitor so a blocked read can wake without extending the critical section.flowchart LR subgraph OkHttp["OkHttp 3.x"] realConnection["RealConnection<br/>one socket per physical connection"] halfClose["connection shutdown"] end subgraph Okio["Okio 1.x"] sourceSetup["Okio.source(socket)"] timeoutClose["AsyncTimeout.Watchdog<br/>socket.close()"] end subgraph DD["dd-trace-java"] factory["UnixDomainSocketFactory"] socket["TunnelingJdkSocket"] inputLifecycle["getInputStream()<br/>validate + publish"] halfLifecycle["shutdownInput/Output()<br/>validate + update"] closeLifecycle["close()<br/>terminal state + snapshot"] monitor["synchronized (this)<br/>short lifecycle transition"] resourceClose["close resources<br/>outside monitor"] end subgraph JDK["JDK NIO"] channel["final SocketChannel"] selector["lazy Selector"] end factory --> socket -->|"created together"| channel realConnection -->|"connect"| socket sourceSetup --> inputLifecycle --> monitor -->|"publish"| selector halfClose --> halfLifecycle --> monitor -->|"native half-close"| channel timeoutClose --> closeLifecycle --> monitor -->|"snapshot"| resourceClose resourceClose -->|"wake blocked read"| selector resourceClose --> channelNormal bulk reads and writes do not acquire the socket lifecycle monitor. They continue to observe volatile state and operate on the stable channel.
Additional Notes
The writer-level test uses MockWebServer's
NO_RESPONSEpolicy over a JNR Unix-domain server adapter. It verifies that the first request times out, the second request arrives on a new connection, and both callbacks run on the same surviving writer thread.The focused socket and writer tests pass on JDK 17, 21, and 25.
Contributor Checklist
type:and (comp:orinst:) labels in addition to any other useful labelsclose,fix, or any linking keywords when referencing an issueUse
solvesinstead, and assign the PR milestone to the issueJira ticket: APMS-20292