Skip to content

Commit 742bcc5

Browse files
committed
Polish 'Fix race condition in OutputCapture'
See gh-46685
1 parent 10b8a43 commit 742bcc5

File tree

2 files changed

+69
-73
lines changed

2 files changed

+69
-73
lines changed

spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/system/OutputCapture.java

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
import java.util.ArrayList;
2424
import java.util.Deque;
2525
import java.util.List;
26-
import java.util.concurrent.atomic.AtomicLong;
2726
import java.util.concurrent.atomic.AtomicReference;
2827
import java.util.function.Consumer;
2928
import java.util.function.Predicate;
@@ -51,14 +50,15 @@ class OutputCapture implements CapturedOutput {
5150

5251
private AnsiOutputState ansiOutputState;
5352

54-
private final AtomicLong outVersion = new AtomicLong();
55-
private final AtomicReference<VersionedCacheResult> out = new AtomicReference<>(null);
53+
private final AtomicReference<Object> out = new AtomicReference<>();
5654

57-
private final AtomicLong errVersion = new AtomicLong();
58-
private final AtomicReference<VersionedCacheResult> err = new AtomicReference<>(null);
55+
private final AtomicReference<Object> err = new AtomicReference<>();
5956

60-
private final AtomicLong allVersion = new AtomicLong();
61-
private final AtomicReference<VersionedCacheResult> all = new AtomicReference<>(null);
57+
private final AtomicReference<Object> all = new AtomicReference<>();
58+
59+
OutputCapture() {
60+
clearExisting();
61+
}
6262

6363
/**
6464
* Push a new system capture session onto the stack.
@@ -111,7 +111,7 @@ public String toString() {
111111
*/
112112
@Override
113113
public String getAll() {
114-
return get(this.all, this.allVersion, (type) -> true);
114+
return get(this.all, (type) -> true);
115115
}
116116

117117
/**
@@ -120,7 +120,7 @@ public String getAll() {
120120
*/
121121
@Override
122122
public String getOut() {
123-
return get(this.out, this.outVersion, Type.OUT::equals);
123+
return get(this.out, Type.OUT::equals);
124124
}
125125

126126
/**
@@ -129,7 +129,7 @@ public String getOut() {
129129
*/
130130
@Override
131131
public String getErr() {
132-
return get(this.err, this.errVersion, Type.ERR::equals);
132+
return get(this.err, Type.ERR::equals);
133133
}
134134

135135
/**
@@ -141,25 +141,21 @@ void reset() {
141141
}
142142

143143
void clearExisting() {
144-
this.outVersion.incrementAndGet();
145-
this.out.set(null);
146-
this.errVersion.incrementAndGet();
147-
this.err.set(null);
148-
this.allVersion.incrementAndGet();
149-
this.all.set(null);
144+
this.out.set(new NoOutput());
145+
this.err.set(new NoOutput());
146+
this.all.set(new NoOutput());
150147
}
151148

152-
private String get(AtomicReference<VersionedCacheResult> resultCache, AtomicLong version, Predicate<Type> filter) {
149+
private String get(AtomicReference<Object> existing, Predicate<Type> filter) {
153150
Assert.state(!this.systemCaptures.isEmpty(),
154151
"No system captures found. Please check your output capture registration.");
155-
long currentVersion = version.get();
156-
VersionedCacheResult cached = resultCache.get();
157-
if (cached != null && cached.version == currentVersion) {
158-
return cached.result;
152+
Object existingOutput = existing.get();
153+
if (existingOutput instanceof String) {
154+
return (String) existingOutput;
159155
}
160-
String result = build(filter);
161-
resultCache.compareAndSet(null, new VersionedCacheResult(result, currentVersion));
162-
return result;
156+
String builtOutput = build(filter);
157+
existing.compareAndSet(existingOutput, builtOutput);
158+
return builtOutput;
163159
}
164160

165161
String build(Predicate<Type> filter) {
@@ -170,10 +166,6 @@ String build(Predicate<Type> filter) {
170166
return builder.toString();
171167
}
172168

173-
private record VersionedCacheResult(String result, long version) {
174-
175-
}
176-
177169
/**
178170
* A capture session that captures {@link System#out System.out} and {@link System#out
179171
* System.err}.
@@ -353,4 +345,8 @@ static AnsiOutputState saveAndDisable() {
353345

354346
}
355347

348+
static class NoOutput {
349+
350+
}
351+
356352
}

spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/system/OutputCaptureTests.java

Lines changed: 45 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@
2222
import java.util.concurrent.CountDownLatch;
2323
import java.util.concurrent.ExecutorService;
2424
import java.util.concurrent.Executors;
25+
import java.util.concurrent.Future;
2526
import java.util.concurrent.TimeUnit;
2627
import java.util.function.Predicate;
2728

28-
import org.jspecify.annotations.Nullable;
2929
import org.junit.jupiter.api.AfterEach;
3030
import org.junit.jupiter.api.BeforeEach;
3131
import org.junit.jupiter.api.Test;
@@ -196,41 +196,21 @@ void getErrUsesCache() {
196196

197197
@Test
198198
void getOutCacheShouldNotReturnStaleDataWhenDataIsLoggedWhileReading() throws Exception {
199-
this.output.push();
199+
TestLatchedOutputCapture output = new TestLatchedOutputCapture();
200+
output.push();
200201
System.out.print("A");
201-
this.output.waitAfterBuildLatch = new CountDownLatch(1);
202-
203-
ExecutorService executorService = null;
202+
ExecutorService executor = Executors.newFixedThreadPool(2);
204203
try {
205-
executorService = Executors.newFixedThreadPool(2);
206-
var readingThreadFuture = executorService.submit(() -> {
207-
// this will release the releaseAfterBuildLatch and block on the waitAfterBuildLatch
208-
assertThat(this.output.getOut()).isEqualTo("A");
209-
});
210-
var writingThreadFuture = executorService.submit(() -> {
211-
// wait until we finished building the first result (but did not yet update the cache)
212-
try {
213-
this.output.releaseAfterBuildLatch.await();
214-
}
215-
catch (InterruptedException e) {
216-
throw new RuntimeException(e);
217-
}
218-
// print something else and then release the latch, for the other thread to continue
219-
System.out.print("B");
220-
this.output.waitAfterBuildLatch.countDown();
221-
});
222-
readingThreadFuture.get();
223-
writingThreadFuture.get();
204+
Future<?> reader = executor.submit(output::releaseAfterBuildAndAssertResultIsA);
205+
Future<?> writer = executor.submit(output::awaitReleaseAfterBuildThenWriteBAndRelease);
206+
reader.get();
207+
writer.get();
224208
}
225209
finally {
226-
if (executorService != null) {
227-
executorService.shutdown();
228-
executorService.awaitTermination(10, TimeUnit.SECONDS);
229-
}
210+
executor.shutdown();
211+
executor.awaitTermination(10, TimeUnit.SECONDS);
230212
}
231-
232-
// If not synchronized correctly this will fail, because the second print did not clear the cache and the cache will return stale data.
233-
assertThat(this.output.getOut()).isEqualTo("AB");
213+
assertThat(output.getOut()).isEqualTo("AB");
234214
}
235215

236216
private void pushAndPrint() {
@@ -257,28 +237,48 @@ static class TestOutputCapture extends OutputCapture {
257237

258238
int buildCount;
259239

260-
@Nullable
261-
CountDownLatch waitAfterBuildLatch = null;
240+
@Override
241+
String build(Predicate<Type> filter) {
242+
this.buildCount++;
243+
return super.build(filter);
244+
}
245+
246+
}
247+
248+
static class TestLatchedOutputCapture extends OutputCapture {
262249

263-
CountDownLatch releaseAfterBuildLatch = new CountDownLatch(1);
250+
private final CountDownLatch waitAfterBuild = new CountDownLatch(1);
251+
252+
private final CountDownLatch releaseAfterBuild = new CountDownLatch(1);
264253

265254
@Override
266255
String build(Predicate<Type> filter) {
267-
this.buildCount++;
268256
var result = super.build(filter);
269-
this.releaseAfterBuildLatch.countDown();
270-
if (this.waitAfterBuildLatch != null) {
271-
try {
272-
this.waitAfterBuildLatch.await();
273-
}
274-
catch (InterruptedException e) {
275-
Thread.currentThread().interrupt();
276-
throw new RuntimeException(e);
277-
}
278-
}
257+
this.releaseAfterBuild.countDown();
258+
await(this.waitAfterBuild);
279259
return result;
280260
}
281261

262+
void releaseAfterBuildAndAssertResultIsA() {
263+
assertThat(getOut()).isEqualTo("A");
264+
}
265+
266+
void awaitReleaseAfterBuildThenWriteBAndRelease() {
267+
await(this.releaseAfterBuild);
268+
System.out.print("B");
269+
this.waitAfterBuild.countDown();
270+
}
271+
272+
private void await(CountDownLatch latch) {
273+
try {
274+
latch.await();
275+
}
276+
catch (InterruptedException ex) {
277+
Thread.currentThread().interrupt();
278+
throw new RuntimeException(ex);
279+
}
280+
}
281+
282282
}
283283

284284
}

0 commit comments

Comments
 (0)