Skip to content

Commit 9b046b6

Browse files
committed
fix: use daemon threads in OkHttp dispatchers to allow graceful JVM shutdown
This fixes an issue where the JVM hangs waiting for background networking threads to timeout after completing an execution. Replaced default OkHttpClient builders with a new HttpUtils.createSharedHttpClient factory that injects a custom thread factory setting daemon=true.
1 parent bead3da commit 9b046b6

5 files changed

Lines changed: 122 additions & 3 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.internal.http;
18+
19+
import java.util.concurrent.Executors;
20+
import java.util.concurrent.ThreadFactory;
21+
import okhttp3.Dispatcher;
22+
import okhttp3.OkHttpClient;
23+
24+
/** Utility class for common HTTP client configuration across the ADK. */
25+
public final class HttpClientFactory {
26+
27+
private HttpClientFactory() {}
28+
29+
/**
30+
* Configures a custom OkHttp dispatcher that uses daemon threads. By default, OkHttp uses
31+
* non-daemon threads for its async call thread pool, which prevents the JVM from shutting down
32+
* for 60 seconds (the default keep-alive) after the last streaming request completes.
33+
*
34+
* @param name The prefix name to use for the dispatcher threads.
35+
* @return A pre-configured Dispatcher using daemon threads.
36+
*/
37+
private static Dispatcher createDaemonDispatcher(String name) {
38+
ThreadFactory daemonThreadFactory =
39+
r -> {
40+
Thread t = new Thread(r, name + "-Dispatcher");
41+
t.setDaemon(true);
42+
return t;
43+
};
44+
return new Dispatcher(Executors.newCachedThreadPool(daemonThreadFactory));
45+
}
46+
47+
/**
48+
* Creates a shared OkHttpClient instance equipped with a daemon thread dispatcher.
49+
*
50+
* @param threadName The prefix name to use for the dispatcher threads.
51+
* @return A pre-configured OkHttpClient.
52+
*/
53+
public static OkHttpClient createSharedHttpClient(String threadName) {
54+
return new OkHttpClient.Builder().dispatcher(createDaemonDispatcher(threadName)).build();
55+
}
56+
}

‎core/src/main/java/com/google/adk/models/Gemini.java‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@
1919
import static com.google.common.base.StandardSystemProperty.JAVA_VERSION;
2020

2121
import com.google.adk.Version;
22+
import com.google.adk.internal.http.HttpClientFactory;
2223
import com.google.common.collect.ImmutableList;
2324
import com.google.common.collect.ImmutableMap;
2425
import com.google.errorprone.annotations.CanIgnoreReturnValue;
2526
import com.google.genai.Client;
2627
import com.google.genai.ResponseStream;
2728
import com.google.genai.types.Candidate;
29+
import com.google.genai.types.ClientOptions;
2830
import com.google.genai.types.Content;
2931
import com.google.genai.types.FinishReason;
3032
import com.google.genai.types.FunctionCall;
@@ -42,6 +44,7 @@
4244
import java.util.Objects;
4345
import java.util.UUID;
4446
import java.util.concurrent.CompletableFuture;
47+
import okhttp3.OkHttpClient;
4548
import org.jspecify.annotations.Nullable;
4649
import org.slf4j.Logger;
4750
import org.slf4j.LoggerFactory;
@@ -56,6 +59,8 @@ public class Gemini extends BaseLlm {
5659

5760
private static final Logger logger = LoggerFactory.getLogger(Gemini.class);
5861
private static final ImmutableMap<String, String> TRACKING_HEADERS;
62+
private static final OkHttpClient SHARED_HTTP_CLIENT =
63+
HttpClientFactory.createSharedHttpClient("GeminiApiClient");
5964

6065
static {
6166
String frameworkLabel = "google-adk/" + Version.JAVA_ADK_VERSION;
@@ -94,6 +99,7 @@ public Gemini(String modelName, String apiKey) {
9499
Client.builder()
95100
.apiKey(apiKey)
96101
.httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build())
102+
.clientOptions(ClientOptions.builder().customHttpClient(SHARED_HTTP_CLIENT).build())
97103
.build();
98104
}
99105

@@ -107,7 +113,9 @@ public Gemini(String modelName, VertexCredentials vertexCredentials) {
107113
super(modelName);
108114
Objects.requireNonNull(vertexCredentials, "vertexCredentials cannot be null");
109115
Client.Builder apiClientBuilder =
110-
Client.builder().httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build());
116+
Client.builder()
117+
.httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build())
118+
.clientOptions(ClientOptions.builder().customHttpClient(SHARED_HTTP_CLIENT).build());
111119
vertexCredentials.project().ifPresent(apiClientBuilder::project);
112120
vertexCredentials.location().ifPresent(apiClientBuilder::location);
113121
vertexCredentials.credentials().ifPresent(apiClientBuilder::credentials);
@@ -206,6 +214,7 @@ public Gemini build() {
206214
modelName,
207215
Client.builder()
208216
.httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build())
217+
.clientOptions(ClientOptions.builder().customHttpClient(SHARED_HTTP_CLIENT).build())
209218
.build());
210219
}
211220
}

‎core/src/main/java/com/google/adk/models/chat/ChatCompletionsHttpClient.java‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import com.fasterxml.jackson.core.JsonProcessingException;
2020
import com.fasterxml.jackson.databind.ObjectMapper;
2121
import com.google.adk.JsonBaseModel;
22+
import com.google.adk.internal.http.HttpClientFactory;
2223
import com.google.adk.models.LlmRequest;
2324
import com.google.adk.models.LlmResponse;
2425
import com.google.common.annotations.VisibleForTesting;
@@ -72,7 +73,8 @@ public final class ChatCompletionsHttpClient implements ChatCompletionsClient {
7273
* {@link ChatCompletionsHttpClient} instances. Each instance forks this client via {@link
7374
* OkHttpClient#newBuilder()} to apply per-instance timeouts without leaking pools.
7475
*/
75-
private static final OkHttpClient SHARED_POOL_CLIENT = new OkHttpClient();
76+
private static final OkHttpClient SHARED_POOL_CLIENT =
77+
HttpClientFactory.createSharedHttpClient("ChatCompletionsHttpClient");
7678

7779
private final OkHttpClient client;
7880
private final HttpUrl completionsUrl;

‎core/src/main/java/com/google/adk/sessions/ApiClient.java‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import static com.google.common.base.StandardSystemProperty.JAVA_VERSION;
2020

21+
import com.google.adk.internal.http.HttpClientFactory;
2122
import com.google.auth.oauth2.GoogleCredentials;
2223
import com.google.common.base.Ascii;
2324
import com.google.common.base.Strings;
@@ -102,8 +103,22 @@ abstract class ApiClient {
102103
this.httpClient = createHttpClient(httpOptions.timeout().orElse(null));
103104
}
104105

106+
/**
107+
* Shared OkHttpClient instance whose connection pool and thread dispatcher are reused across all
108+
* {@link ApiClient} subclass instances.
109+
*
110+
* <p>Even though {@link ApiClient} is abstract, every instantiation of a concrete subclass (e.g.,
111+
* GoogleAiClient) triggers this class's constructor. Making this static prevents a severe
112+
* resource leak where every new LLM Agent would otherwise spin up a brand new thread pool and TCP
113+
* connection pool. Instead, all clients share this pool and fork it via {@link
114+
* OkHttpClient#newBuilder()}.
115+
*/
116+
private static final OkHttpClient SHARED_POOL_CLIENT =
117+
HttpClientFactory.createSharedHttpClient("ApiClient");
118+
105119
private OkHttpClient createHttpClient(@Nullable Integer timeout) {
106-
OkHttpClient.Builder builder = new OkHttpClient().newBuilder();
120+
OkHttpClient.Builder builder = SHARED_POOL_CLIENT.newBuilder();
121+
107122
if (timeout != null) {
108123
builder.connectTimeout(Duration.ofMillis(timeout));
109124
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.internal.http;
18+
19+
import static org.junit.jupiter.api.Assertions.assertTrue;
20+
21+
import java.util.concurrent.ThreadFactory;
22+
import java.util.concurrent.ThreadPoolExecutor;
23+
import org.junit.jupiter.api.Test;
24+
25+
public class HttpClientFactoryTest {
26+
27+
@Test
28+
public void testSharedHttpClientDaemonThreads() {
29+
ThreadFactory tf =
30+
((ThreadPoolExecutor)
31+
HttpClientFactory.createSharedHttpClient("Test").dispatcher().executorService())
32+
.getThreadFactory();
33+
// Usually OkHttp uses a specific thread factory. We passed our own DaemonThreadFactory
34+
Thread t = tf.newThread(() -> {});
35+
assertTrue(t.isDaemon(), "HttpClientFactory thread factory should produce daemon threads");
36+
}
37+
}

0 commit comments

Comments
 (0)