Skip to content

Commit d87a89d

Browse files
committed
Fix race condition in OutputCapture
This race could occur because of the existing cache in OutputCapture: When data was written, while it was read, any following reading of data might miss the last output. The cache was not invalidated correctly in this case. We are now versioning the cache entries to make sure this can't happen anymore. Signed-off-by: Daniel Schmidt <[email protected]>
1 parent d0dd8c8 commit d87a89d

File tree

2 files changed

+87
-12
lines changed

2 files changed

+87
-12
lines changed

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

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.util.ArrayList;
2424
import java.util.Deque;
2525
import java.util.List;
26+
import java.util.concurrent.atomic.AtomicLong;
2627
import java.util.concurrent.atomic.AtomicReference;
2728
import java.util.function.Consumer;
2829
import java.util.function.Predicate;
@@ -42,6 +43,7 @@
4243
* @author Phillip Webb
4344
* @author Andy Wilkinson
4445
* @author Sam Brannen
46+
* @author Daniel Schmidt
4547
* @see OutputCaptureExtension
4648
* @see OutputCaptureRule
4749
*/
@@ -51,11 +53,14 @@ class OutputCapture implements CapturedOutput {
5153

5254
private @Nullable AnsiOutputState ansiOutputState;
5355

54-
private final AtomicReference<String> out = new AtomicReference<>(null);
56+
private final AtomicLong outVersion = new AtomicLong();
57+
private final AtomicReference<VersionedCacheResult> out = new AtomicReference<>(null);
5558

56-
private final AtomicReference<String> err = new AtomicReference<>(null);
59+
private final AtomicLong errVersion = new AtomicLong();
60+
private final AtomicReference<VersionedCacheResult> err = new AtomicReference<>(null);
5761

58-
private final AtomicReference<String> all = new AtomicReference<>(null);
62+
private final AtomicLong allVersion = new AtomicLong();
63+
private final AtomicReference<VersionedCacheResult> all = new AtomicReference<>(null);
5964

6065
/**
6166
* Push a new system capture session onto the stack.
@@ -108,7 +113,7 @@ public String toString() {
108113
*/
109114
@Override
110115
public String getAll() {
111-
return get(this.all, (type) -> true);
116+
return get(this.all, this.allVersion, (type) -> true);
112117
}
113118

114119
/**
@@ -117,7 +122,7 @@ public String getAll() {
117122
*/
118123
@Override
119124
public String getOut() {
120-
return get(this.out, Type.OUT::equals);
125+
return get(this.out, this.outVersion, Type.OUT::equals);
121126
}
122127

123128
/**
@@ -126,7 +131,7 @@ public String getOut() {
126131
*/
127132
@Override
128133
public String getErr() {
129-
return get(this.err, Type.ERR::equals);
134+
return get(this.err, this.errVersion, Type.ERR::equals);
130135
}
131136

132137
/**
@@ -138,19 +143,24 @@ void reset() {
138143
}
139144

140145
void clearExisting() {
146+
this.outVersion.incrementAndGet();
141147
this.out.set(null);
148+
this.errVersion.incrementAndGet();
142149
this.err.set(null);
150+
this.allVersion.incrementAndGet();
143151
this.all.set(null);
144152
}
145153

146-
private String get(AtomicReference<String> existing, Predicate<Type> filter) {
154+
private String get(AtomicReference<VersionedCacheResult> resultCache, AtomicLong version, Predicate<Type> filter) {
147155
Assert.state(!this.systemCaptures.isEmpty(),
148156
"No system captures found. Please check your output capture registration.");
149-
String result = existing.get();
150-
if (result == null) {
151-
result = build(filter);
152-
existing.compareAndSet(null, result);
157+
long currentVersion = version.get();
158+
VersionedCacheResult cached = resultCache.get();
159+
if (cached != null && cached.version == currentVersion) {
160+
return cached.result;
153161
}
162+
String result = build(filter);
163+
resultCache.compareAndSet(null, new VersionedCacheResult(result, currentVersion));
154164
return result;
155165
}
156166

@@ -162,6 +172,10 @@ String build(Predicate<Type> filter) {
162172
return builder.toString();
163173
}
164174

175+
private record VersionedCacheResult(String result, long version) {
176+
177+
}
178+
165179
/**
166180
* A capture session that captures {@link System#out System.out} and {@link System#out
167181
* System.err}.

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

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,13 @@
1919
import java.io.ByteArrayOutputStream;
2020
import java.io.PrintStream;
2121
import java.util.NoSuchElementException;
22+
import java.util.concurrent.CountDownLatch;
23+
import java.util.concurrent.ExecutorService;
24+
import java.util.concurrent.Executors;
25+
import java.util.concurrent.TimeUnit;
2226
import java.util.function.Predicate;
2327

28+
import org.jspecify.annotations.Nullable;
2429
import org.junit.jupiter.api.AfterEach;
2530
import org.junit.jupiter.api.BeforeEach;
2631
import org.junit.jupiter.api.Test;
@@ -32,6 +37,7 @@
3237
* Tests for {@link OutputCapture}.
3338
*
3439
* @author Phillip Webb
40+
* @author Daniel Schmidt
3541
*/
3642
class OutputCaptureTests {
3743

@@ -188,6 +194,45 @@ void getErrUsesCache() {
188194
assertThat(this.output.buildCount).isEqualTo(2);
189195
}
190196

197+
@Test
198+
void getOutCacheShouldNotReturnStaleDataWhenDataIsLoggedWhileReading() throws Exception {
199+
this.output.push();
200+
System.out.print("A");
201+
this.output.waitAfterBuildLatch = new CountDownLatch(1);
202+
203+
ExecutorService executorService = null;
204+
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();
224+
}
225+
finally {
226+
if (executorService != null) {
227+
executorService.shutdown();
228+
executorService.awaitTermination(10, TimeUnit.SECONDS);
229+
}
230+
}
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");
234+
}
235+
191236
private void pushAndPrint() {
192237
this.output.push();
193238
System.out.print("A");
@@ -212,10 +257,26 @@ static class TestOutputCapture extends OutputCapture {
212257

213258
int buildCount;
214259

260+
@Nullable
261+
CountDownLatch waitAfterBuildLatch = null;
262+
263+
CountDownLatch releaseAfterBuildLatch = new CountDownLatch(1);
264+
215265
@Override
216266
String build(Predicate<Type> filter) {
217267
this.buildCount++;
218-
return super.build(filter);
268+
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+
}
279+
return result;
219280
}
220281

221282
}

0 commit comments

Comments
 (0)