From 97ad1ec0cd426cf491c364aa33e6eb11c42d4207 Mon Sep 17 00:00:00 2001 From: Min Jin Date: Thu, 22 Oct 2026 10:27:27 -0700 Subject: [PATCH] util: shut down scheduled executor in Wait.poll to fix thread leak Wait.poll created a single-thread ScheduledExecutorService but never shut it down. The default thread factory produces non-daemon threads, so every poll() call left a lingering worker thread alive after returning, leaking a thread (and preventing clean JVM shutdown) on each invocation. Shut the executor down in a finally block so it is released on every exit path, preserving all existing return values. Adds a regression test that fails before and passes after the fix. --- .../io/kubernetes/client/util/wait/Wait.java | 4 +- .../kubernetes/client/util/wait/WaitTest.java | 58 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 util/src/test/java/io/kubernetes/client/util/wait/WaitTest.java diff --git a/util/src/main/java/io/kubernetes/client/util/wait/Wait.java b/util/src/main/java/io/kubernetes/client/util/wait/Wait.java index a3b0ab07d8..e077ad435c 100644 --- a/util/src/main/java/io/kubernetes/client/util/wait/Wait.java +++ b/util/src/main/java/io/kubernetes/client/util/wait/Wait.java @@ -68,8 +68,10 @@ public static boolean poll( } } catch (Exception e) { return result.get(); + } finally { + future.cancel(true); + executorService.shutdownNow(); } - future.cancel(true); return result.get(); } } diff --git a/util/src/test/java/io/kubernetes/client/util/wait/WaitTest.java b/util/src/test/java/io/kubernetes/client/util/wait/WaitTest.java new file mode 100644 index 0000000000..a1d1eafbdc --- /dev/null +++ b/util/src/test/java/io/kubernetes/client/util/wait/WaitTest.java @@ -0,0 +1,58 @@ +/* +Copyright 2026 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package io.kubernetes.client.util.wait; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class WaitTest { + + /** + * Regression test: {@link Wait#poll} must shut down the internal scheduled executor before + * returning. Prior to the fix the executor was never shut down, so its (non-daemon) worker thread + * stayed alive after the call, leaking a thread on every invocation. + */ + @Test + void pollShutsDownExecutorThreadAfterReturning() throws InterruptedException { + AtomicReference workerThread = new AtomicReference<>(); + + boolean finished = + Wait.poll( + Duration.ofMillis(1), + Duration.ofMillis(1), + Duration.ofSeconds(5), + () -> { + workerThread.compareAndSet(null, Thread.currentThread()); + return true; + }); + + assertThat(finished).isTrue(); + + Thread worker = workerThread.get(); + assertThat(worker).as("condition should have executed on an executor thread").isNotNull(); + + // After poll returns the executor must have been shut down, so its worker thread terminates. + // Allow a short grace period for the interrupt/shutdown to take effect. + long deadline = System.currentTimeMillis() + 2000; + while (worker.isAlive() && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + + assertThat(worker.isAlive()) + .as("Wait.poll must shut down its executor so the worker thread is not leaked") + .isFalse(); + } +}