From 72c3f660639d3a242ef87c78cd4938ffe4d94b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 15 Jun 2026 08:02:05 +0200 Subject: [PATCH 1/2] fix: semaphore release submitted to a dead executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConsumerSupport uses 3 semaphores to gate consumeFinalBestSolution(). Each is acquired before a task runs, and released after the task completes via whenCompleteAsync(release, consumerExecutor). whenCompleteAsync(action, executor) does not run action inline. It submits action as a new task to executor when the prior future completes. Two-step: 1. Prior task finishes → future completes 2. Java calls executor.execute(releaseTask) Between steps 1 and 2, executor can be shut down. shutdownNow() rejects new submissions and cancels queued ones. RejectedExecutionException → release task never runs → semaphore stays at 0. Who shuts the executor down? ConsumerSupport.close() — called from DefaultSolverJob.close() when a solver job is cleaned up. Its finally block calls shutdownConsumerExecutor() unconditionally, even if acquireAll() threw InterruptedException. If the close-thread is interrupted mid-acquire(), the executor dies with semaphores still at 0 and their release tasks still queued. Race that produces the hang: 1. Consumer thread finishes start-job task → future F completes 2. Concurrently: close-thread interrupted in acquireAll() → finally → shutdownNow() 3. whenCompleteAsync tries executor.execute(startSolverJobConsumption.release()) → rejected 4. Solver thread later calls consumeFinalBestSolution() → acquireAll() → startSolverJobConsumption.acquire() → blocks forever Why whenComplete fixes it: whenComplete(action) (no executor) runs action synchronously on the thread that completed the future — here, the consumer executor's own thread, immediately after the task finishes, before returning to the executor's work loop. No second submission, no queue, nothing to reject. The release is atomic with the task's completion from the executor's perspective. Executor shutdown can only happen after acquireAll() succeeds in consumeFinalBestSolution, which is after all releases have run. The release is now unreachable by any shutdown path. --- .../solver/core/impl/solver/ConsumerSupport.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java index 5df65930e84..c030d383cc1 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java @@ -84,10 +84,10 @@ private void tryConsumeWaitingIntermediateBestSolution() { } if (activeConsumption.tryAcquire()) { scheduleIntermediateBestSolutionConsumption() - .whenCompleteAsync((solution, throwable) -> { + .whenComplete((solution, throwable) -> { activeConsumption.release(); tryConsumeWaitingIntermediateBestSolution(); - }, consumerExecutor); + }); } } @@ -120,7 +120,7 @@ void consumeFirstInitializedSolution(Solution_ solution, EventProducerId produce this.firstInitializedSolution.getAndSet(solution); // Reachable more than once; problem change triggers restart. scheduleFirstInitializedSolutionConsumption(s -> firstInitializedSolutionConsumer .accept(new FirstInitializedSolutionEventImpl<>(s, producerId, isTerminatedEarly))) - .whenCompleteAsync((unused, throwable) -> firstSolutionConsumption.release(), consumerExecutor); + .whenComplete((unused, throwable) -> firstSolutionConsumption.release()); } private CompletableFuture scheduleFirstInitializedSolutionConsumption(Consumer solutionConsumer) { @@ -153,8 +153,7 @@ void consumeStartSolverJob(Solution_ solution) { throw new IllegalStateException("Interrupted when waiting for the start solver job consumption."); } this.initialSolution.getAndSet(solution); // Reachable more than once; problem change triggers restart. - scheduleStartJobConsumption().whenCompleteAsync((unused, throwable) -> startSolverJobConsumption.release(), - consumerExecutor); + scheduleStartJobConsumption().whenComplete((unused, throwable) -> startSolverJobConsumption.release()); } private CompletableFuture scheduleStartJobConsumption() { From 6e6bcf96f456679240d3d9eb56ecc60c11daec91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 15 Jun 2026 10:42:18 +0200 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../ai/timefold/solver/core/impl/solver/ConsumerSupport.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java index c030d383cc1..f432fb58a00 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java @@ -84,7 +84,7 @@ private void tryConsumeWaitingIntermediateBestSolution() { } if (activeConsumption.tryAcquire()) { scheduleIntermediateBestSolutionConsumption() - .whenComplete((solution, throwable) -> { + .whenComplete((unused, throwable) -> { activeConsumption.release(); tryConsumeWaitingIntermediateBestSolution(); });