|
16 | 16 |
|
17 | 17 | package org.springframework.core.task; |
18 | 18 |
|
| 19 | +import java.util.concurrent.CountDownLatch; |
| 20 | +import java.util.concurrent.TimeUnit; |
| 21 | + |
19 | 22 | import org.junit.jupiter.api.Test; |
20 | 23 |
|
21 | 24 | import org.springframework.util.ConcurrencyThrottleSupport; |
|
24 | 27 | import static org.assertj.core.api.Assertions.assertThatExceptionOfType; |
25 | 28 | import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; |
26 | 29 | import static org.assertj.core.api.Assertions.assertThatIllegalStateException; |
| 30 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; |
| 31 | +import static org.mockito.ArgumentMatchers.any; |
| 32 | +import static org.mockito.BDDMockito.willCallRealMethod; |
| 33 | +import static org.mockito.Mockito.doThrow; |
| 34 | +import static org.mockito.Mockito.spy; |
| 35 | + |
27 | 36 |
|
28 | 37 | /** |
29 | 38 | * @author Rick Evans |
@@ -69,6 +78,59 @@ void taskRejectedWhenConcurrencyLimitReached() { |
69 | 78 | } |
70 | 79 | } |
71 | 80 |
|
| 81 | + /** |
| 82 | + * Verify that when thread creation fails in doExecute() while concurrency |
| 83 | + * limiting is active, the concurrency permit is properly released to |
| 84 | + * prevent permanent deadlock. |
| 85 | + * |
| 86 | + * <p>This test reproduces a critical bug where OutOfMemoryError from |
| 87 | + * Thread.start() causes the executor to permanently deadlock: |
| 88 | + * <ol> |
| 89 | + * <li>beforeAccess() increments concurrencyCount |
| 90 | + * <li>doExecute() throws Error before thread starts |
| 91 | + * <li>TaskTrackingRunnable.run() never executes |
| 92 | + * <li>afterAccess() in finally block never called |
| 93 | + * <li>Subsequent tasks block forever in onLimitReached() |
| 94 | + * </ol> |
| 95 | + * |
| 96 | + * <p>Test approach: The first execute() should fail with some exception |
| 97 | + * (type doesn't matter - could be Error or TaskRejectedException). |
| 98 | + * The second execute() is the real test: it should complete without |
| 99 | + * deadlock if the permit was properly released. |
| 100 | + */ |
| 101 | + @Test |
| 102 | + void executeFailsToStartThreadReleasesConcurrencyPermit() throws InterruptedException { |
| 103 | + // Arrange |
| 104 | + SimpleAsyncTaskExecutor executor = spy(new SimpleAsyncTaskExecutor()); |
| 105 | + executor.setConcurrencyLimit(1); // Enable concurrency limiting |
| 106 | + |
| 107 | + Runnable task = () -> {}; |
| 108 | + Error failure = new OutOfMemoryError("TEST: Cannot start thread"); |
| 109 | + |
| 110 | + // Simulate thread creation failure |
| 111 | + doThrow(failure).when(executor).doExecute(any(Runnable.class)); |
| 112 | + |
| 113 | + // Act - First execution fails |
| 114 | + // Both "before fix" (throws Error) and "after fix" (throws TaskRejectedException) |
| 115 | + // should throw some exception here - that's expected and correct |
| 116 | + assertThatThrownBy(() -> executor.execute(task)) |
| 117 | + .isInstanceOf(Throwable.class); |
| 118 | + |
| 119 | + // Arrange - Reset mock to allow second execution to succeed |
| 120 | + willCallRealMethod().given(executor).doExecute(any(Runnable.class)); |
| 121 | + |
| 122 | + // Assert - Second execution should NOT deadlock |
| 123 | + // This is the real test: if permit was leaked, this will timeout |
| 124 | + CountDownLatch latch = new CountDownLatch(1); |
| 125 | + executor.execute(() -> latch.countDown()); |
| 126 | + |
| 127 | + boolean completed = latch.await(1, TimeUnit.SECONDS); |
| 128 | + |
| 129 | + assertThat(completed) |
| 130 | + .withFailMessage("Executor should not deadlock if concurrency permit was properly released after first failure") |
| 131 | + .isTrue(); |
| 132 | + } |
| 133 | + |
72 | 134 | @Test |
73 | 135 | void threadNameGetsSetCorrectly() { |
74 | 136 | String customPrefix = "chankPop#"; |
|
0 commit comments