Skip to content

Commit 10b8a43

Browse files
schmidti159philwebb
authored andcommitted
Fix race condition in OutputCapture
Update `OutputCapture` to fix a race condition that could occur due to the cache. Prior to this commit, when data was written whilst simultaneously being read, any subsequent reading of data might miss the last output. See gh-46685 Signed-off-by: Daniel Schmidt <[email protected]>
1 parent 815bdc5 commit 10b8a43

File tree

2 files changed

+87
-12
lines changed

2 files changed

+87
-12
lines changed

spring-boot-project/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;
@@ -40,6 +41,7 @@
4041
* @author Phillip Webb
4142
* @author Andy Wilkinson
4243
* @author Sam Brannen
44+
* @author Daniel Schmidt
4345
* @see OutputCaptureExtension
4446
* @see OutputCaptureRule
4547
*/
@@ -49,11 +51,14 @@ class OutputCapture implements CapturedOutput {
4951

5052
private AnsiOutputState ansiOutputState;
5153

52-
private final AtomicReference<String> out = new AtomicReference<>(null);
54+
private final AtomicLong outVersion = new AtomicLong();
55+
private final AtomicReference<VersionedCacheResult> out = new AtomicReference<>(null);
5356

54-
private final AtomicReference<String> err = new AtomicReference<>(null);
57+
private final AtomicLong errVersion = new AtomicLong();
58+
private final AtomicReference<VersionedCacheResult> err = new AtomicReference<>(null);
5559

56-
private final AtomicReference<String> all = new AtomicReference<>(null);
60+
private final AtomicLong allVersion = new AtomicLong();
61+
private final AtomicReference<VersionedCacheResult> all = new AtomicReference<>(null);
5762

5863
/**
5964
* Push a new system capture session onto the stack.
@@ -106,7 +111,7 @@ public String toString() {
106111
*/
107112
@Override
108113
public String getAll() {
109-
return get(this.all, (type) -> true);
114+
return get(this.all, this.allVersion, (type) -> true);
110115
}
111116

112117
/**
@@ -115,7 +120,7 @@ public String getAll() {
115120
*/
116121
@Override
117122
public String getOut() {
118-
return get(this.out, Type.OUT::equals);
123+
return get(this.out, this.outVersion, Type.OUT::equals);
119124
}
120125

121126
/**
@@ -124,7 +129,7 @@ public String getOut() {
124129
*/
125130
@Override
126131
public String getErr() {
127-
return get(this.err, Type.ERR::equals);
132+
return get(this.err, this.errVersion, Type.ERR::equals);
128133
}
129134

130135
/**
@@ -136,19 +141,24 @@ void reset() {
136141
}
137142

138143
void clearExisting() {
144+
this.outVersion.incrementAndGet();
139145
this.out.set(null);
146+
this.errVersion.incrementAndGet();
140147
this.err.set(null);
148+
this.allVersion.incrementAndGet();
141149
this.all.set(null);
142150
}
143151

144-
private String get(AtomicReference<String> existing, Predicate<Type> filter) {
152+
private String get(AtomicReference<VersionedCacheResult> resultCache, AtomicLong version, Predicate<Type> filter) {
145153
Assert.state(!this.systemCaptures.isEmpty(),
146154
"No system captures found. Please check your output capture registration.");
147-
String result = existing.get();
148-
if (result == null) {
149-
result = build(filter);
150-
existing.compareAndSet(null, result);
155+
long currentVersion = version.get();
156+
VersionedCacheResult cached = resultCache.get();
157+
if (cached != null && cached.version == currentVersion) {
158+
return cached.result;
151159
}
160+
String result = build(filter);
161+
resultCache.compareAndSet(null, new VersionedCacheResult(result, currentVersion));
152162
return result;
153163
}
154164

@@ -160,6 +170,10 @@ String build(Predicate<Type> filter) {
160170
return builder.toString();
161171
}
162172

173+
private record VersionedCacheResult(String result, long version) {
174+
175+
}
176+
163177
/**
164178
* A capture session that captures {@link System#out System.out} and {@link System#out
165179
* System.err}.

spring-boot-project/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)