ReportGenerationService.generateReport() is submitted to a single-threaded
ExecutorService. Inside that task the code submits a data-collection subtask
to the same executor and immediately calls Future.get() on the result.
Because the only thread is already occupied running generateReport(), the
subtask sits in the queue forever. get() never returns and the executor hangs
permanently.
Thread-0 (busy running generateReport)
└─ submits collectRawData() → queued behind Thread-0 (no free thread)
└─ calls dataFuture.get() → blocks
↑ deadlock
ExecutorService is a bounded resource. When every available thread is occupied
and one of those threads tries to obtain a result from a task that has not yet
started, you have a self-deadlock:
- The running task holds the only thread and is waiting for a sibling.
- The sibling cannot start because there is no free thread.
- Neither side can make progress.
The defect is invisible in single-threaded tests because the test thread calls
generateReport() directly, bypassing the executor, so the subtask can use
the idle executor thread without conflict.
- Open
ReportGenerationServiceTest. - Remove
@DisabledfromtestGenerateReport_concurrent_detectsExecutorDeadlock. - Run the test.
ExecutorDeadlockDetector will report, and failOn = FailOn.LOW will turn that report into a
failed run:
EXECUTOR SELF-DEADLOCK DETECTED:
- report-executor: all 1 worker(s) are waiting on sibling tasks while 16 task(s) remain queued
Fix: do not wait on sibling tasks from the same bounded executor
ExecutorDeadlockDetector is recording-fed. It needs four facts, and only one of them is
interesting: that a running task has started waiting on a sibling. Nothing outside the code can
supply that, which is why ReportGenerationService.observeExecutor installs the hooks inside the
service; they default to no-ops, so the production path never touches the test library.
The detector also has to be the one the run owns, from
AsyncTestContext.executorDeadlockDetector(). Before issue #346 this demonstration recorded a
lifecycle by hand into a locally constructed detector and asserted on it, without ever calling the
service. It proved the detector's arithmetic, which was never in doubt, and told the library
nothing, so failOn had no finding to gate on and enabling the demonstration left it green.
generateReport() waits with a timeout (SUBTASK_TIMEOUT_MS, 100ms) rather than an unbounded
Future.get(). The shape this is drawn from has no timeout and the thread is gone for the life of
the process. With a timeout, the request fails instead - every time, on every report - which is the
same bug wearing a different symptom, and it is the difference between a demonstration and a hung
build.
Submit the subtask to a different executor so the parent thread can block without preventing the subtask from running:
// Before (deadlock)
Future<String> data = executor.submit(() -> collectRawData(id));
String raw = data.get(); // blocks the only thread — subtask can never start
// After (safe)
Future<String> data = subtaskExecutor.submit(() -> collectRawData(id));
String raw = data.get(); // subtaskExecutor has free threads — works correctlyAlternatively, use non-blocking composition so no thread ever blocks at all:
CompletableFuture.supplyAsync(() -> collectRawData(id), executor)
.thenApply(raw -> assembleReport(id, raw));- A thread calling
Future.get()orCompletableFuture.join()is blocked — it cannot execute anything else until the future completes. - When that blocked thread belongs to a bounded executor and the future's task is also submitted to that executor, you have a self-deadlock.
- Always submit blocking-wait parent tasks and their subtasks to separate executors, or redesign with non-blocking async pipelines.
- Virtual threads avoid this class of deadlock entirely because blocking a virtual thread unmounts it from the carrier thread, freeing the carrier for other work.