Skip to content

Commit f92a4ab

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 987ef4e commit f92a4ab

5 files changed

Lines changed: 120 additions & 3 deletions

File tree

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,11 +19,13 @@
1919
import static com.google.common.base.StandardSystemProperty.JAVA_VERSION;
2020

2121
import com.google.adk.Version;
22+
import com.google.adk.utils.HttpUtils;
2223
import com.google.common.collect.ImmutableMap;
2324
import com.google.errorprone.annotations.CanIgnoreReturnValue;
2425
import com.google.genai.Client;
2526
import com.google.genai.ResponseStream;
2627
import com.google.genai.types.Candidate;
28+
import com.google.genai.types.ClientOptions;
2729
import com.google.genai.types.Content;
2830
import com.google.genai.types.FinishReason;
2931
import com.google.genai.types.GenerateContentConfig;
@@ -37,6 +39,7 @@
3739
import java.util.Objects;
3840
import java.util.Optional;
3941
import java.util.concurrent.CompletableFuture;
42+
import okhttp3.OkHttpClient;
4043
import org.slf4j.Logger;
4144
import org.slf4j.LoggerFactory;
4245

@@ -50,6 +53,8 @@ public class Gemini extends BaseLlm {
5053

5154
private static final Logger logger = LoggerFactory.getLogger(Gemini.class);
5255
private static final ImmutableMap<String, String> TRACKING_HEADERS;
56+
private static final OkHttpClient SHARED_HTTP_CLIENT =
57+
HttpUtils.createSharedHttpClient("GeminiApiClient");
5358

5459
static {
5560
String frameworkLabel = "google-adk/" + Version.JAVA_ADK_VERSION;
@@ -88,6 +93,7 @@ public Gemini(String modelName, String apiKey) {
8893
Client.builder()
8994
.apiKey(apiKey)
9095
.httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build())
96+
.clientOptions(ClientOptions.builder().customHttpClient(SHARED_HTTP_CLIENT).build())
9197
.build();
9298
}
9399

@@ -101,7 +107,9 @@ public Gemini(String modelName, VertexCredentials vertexCredentials) {
101107
super(modelName);
102108
Objects.requireNonNull(vertexCredentials, "vertexCredentials cannot be null");
103109
Client.Builder apiClientBuilder =
104-
Client.builder().httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build());
110+
Client.builder()
111+
.httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build())
112+
.clientOptions(ClientOptions.builder().customHttpClient(SHARED_HTTP_CLIENT).build());
105113
vertexCredentials.project().ifPresent(apiClientBuilder::project);
106114
vertexCredentials.location().ifPresent(apiClientBuilder::location);
107115
vertexCredentials.credentials().ifPresent(apiClientBuilder::credentials);
@@ -200,6 +208,7 @@ public Gemini build() {
200208
modelName,
201209
Client.builder()
202210
.httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build())
211+
.clientOptions(ClientOptions.builder().customHttpClient(SHARED_HTTP_CLIENT).build())
203212
.build());
204213
}
205214
}

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
@@ -21,6 +21,7 @@
2121
import com.google.adk.JsonBaseModel;
2222
import com.google.adk.models.LlmRequest;
2323
import com.google.adk.models.LlmResponse;
24+
import com.google.adk.utils.HttpUtils;
2425
import com.google.common.annotations.VisibleForTesting;
2526
import com.google.common.collect.ImmutableList;
2627
import com.google.common.collect.ImmutableMap;
@@ -74,7 +75,8 @@ public final class ChatCompletionsHttpClient {
7475
* {@link ChatCompletionsHttpClient} instances. Each instance forks this client via {@link
7576
* OkHttpClient#newBuilder()} to apply per-instance timeouts without leaking pools.
7677
*/
77-
private static final OkHttpClient SHARED_POOL_CLIENT = new OkHttpClient();
78+
private static final OkHttpClient SHARED_POOL_CLIENT =
79+
HttpUtils.createSharedHttpClient("ChatCompletionsHttpClient");
7880

7981
private final OkHttpClient client;
8082
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.utils.HttpUtils;
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+
HttpUtils.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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2025 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.utils;
18+
19+
import java.time.Duration;
20+
import java.util.concurrent.Executors;
21+
import java.util.concurrent.ThreadFactory;
22+
import okhttp3.Dispatcher;
23+
import okhttp3.OkHttpClient;
24+
25+
/** Utility class for common HTTP client configuration across the ADK. */
26+
public final class HttpUtils {
27+
28+
private HttpUtils() {}
29+
30+
/**
31+
* Configures a custom OkHttp dispatcher that uses daemon threads. By default, OkHttp uses
32+
* non-daemon threads for its async call thread pool, which prevents the JVM from shutting down
33+
* for 60 seconds (the default keep-alive) after the last streaming request completes.
34+
*
35+
* @param name The prefix name to use for the dispatcher threads.
36+
* @return A pre-configured Dispatcher using daemon threads.
37+
*/
38+
private static Dispatcher createDaemonDispatcher(String name) {
39+
ThreadFactory daemonThreadFactory =
40+
r -> {
41+
Thread t = new Thread(r, name + "-Dispatcher");
42+
t.setDaemon(true);
43+
return t;
44+
};
45+
return new Dispatcher(Executors.newCachedThreadPool(daemonThreadFactory));
46+
}
47+
48+
/**
49+
* Creates a shared OkHttpClient instance equipped with a daemon thread dispatcher.
50+
*
51+
* @param threadName The prefix name to use for the dispatcher threads.
52+
* @return A pre-configured OkHttpClient.
53+
*/
54+
public static OkHttpClient createSharedHttpClient(String threadName) {
55+
return new OkHttpClient.Builder()
56+
.dispatcher(createDaemonDispatcher(threadName))
57+
.connectTimeout(Duration.ofMinutes(5))
58+
.readTimeout(Duration.ofMinutes(5))
59+
.writeTimeout(Duration.ofMinutes(5))
60+
.build();
61+
}
62+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.google.adk.utils;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertTrue;
5+
6+
import java.util.concurrent.ThreadFactory;
7+
import org.junit.jupiter.api.Test;
8+
9+
public class HttpUtilsTest {
10+
11+
@Test
12+
public void testSharedHttpClientDaemonThreads() {
13+
ThreadFactory tf =
14+
((java.util.concurrent.ThreadPoolExecutor)
15+
HttpUtils.createSharedHttpClient("Test").dispatcher().executorService())
16+
.getThreadFactory();
17+
// Usually OkHttp uses a specific thread factory. We passed our own DaemonThreadFactory
18+
Thread t = tf.newThread(() -> {});
19+
assertTrue(t.isDaemon(), "HttpUtils thread factory should produce daemon threads");
20+
}
21+
22+
@Test
23+
public void testSharedHttpClientTimeouts() {
24+
okhttp3.OkHttpClient client = HttpUtils.createSharedHttpClient("Test");
25+
assertEquals(300000, client.connectTimeoutMillis());
26+
assertEquals(300000, client.readTimeoutMillis());
27+
assertEquals(300000, client.writeTimeoutMillis());
28+
}
29+
}

0 commit comments

Comments
 (0)