Skip to content

Commit 89b13e6

Browse files
authored
Fix race conditions writing snapshots when junit.jupiter.execution.parallel.enabled is true (#534)
2 parents 49bf096 + 99852ac commit 89b13e6

File tree

7 files changed

+110
-26
lines changed

7 files changed

+110
-26
lines changed

jvm/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
## [Unreleased]
1414
### Fixed
1515
- Restore support for JRE 11. (fixes [#528](https://github.com/diffplug/selfie/issues/528))
16+
- snapshots created by `junit.jupiter.api.TestFactory` are no longer garbage-collected (#534)
17+
- support parallel testing under `junit.jupiter.execution.parallel.enabled=true` (#534)
1618

1719
## [2.5.2] - 2025-04-28
1820
### Fixed

jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/CommentTracker.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2024 DiffPlug
2+
* Copyright (C) 2024-2025 DiffPlug
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -39,7 +39,8 @@ class CommentTracker {
3939
comment.writable
4040
} else {
4141
val newComment = commentAndLine(path, layout.fs).first
42-
cache.updateAndGet { it.plus(path, newComment) }
42+
// may race get(), ignore if already present
43+
cache.updateAndGet { it.plusOrNoOp(path, newComment) }
4344
newComment.writable
4445
}
4546
}

jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieGC.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2023-2024 DiffPlug
2+
* Copyright (C) 2023-2025 DiffPlug
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -22,6 +22,7 @@ import com.diffplug.selfie.guts.WithinTestGC
2222
private val testAnnotations =
2323
listOf(
2424
"org.junit.jupiter.api.Test", // junit5,
25+
"org.junit.jupiter.api.TestFactory", // junit5,
2526
"org.junit.jupiter.params.ParameterizedTest",
2627
"org.junit.Test" // junit4
2728
)

jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SnapshotSystemJUnit5.kt

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2023-2024 DiffPlug
2+
* Copyright (C) 2023-2025 DiffPlug
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -177,9 +177,9 @@ internal class SnapshotFileProgress(val system: SnapshotSystemJUnit5, val classN
177177
require(tests.get() !== TERMINATED) { "Cannot call methods on a terminated ClassProgress" }
178178
}
179179

180-
private var file: SnapshotFile? = null
180+
private var file = atomic<SnapshotFile?>(null)
181181
private val tests = atomic(ArrayMap.empty<String, WithinTestGC>())
182-
private var diskWriteTracker: DiskWriteTracker? = DiskWriteTracker()
182+
private var diskWriteTracker = atomic<DiskWriteTracker?>(DiskWriteTracker())
183183
private val timesStarted = AtomicInteger(0)
184184
private val hasFailed = AtomicBoolean(false)
185185

@@ -209,23 +209,24 @@ internal class SnapshotFileProgress(val system: SnapshotSystemJUnit5, val classN
209209
}
210210
private fun finishedClassWithSuccess(success: Boolean) {
211211
assertNotTerminated()
212-
diskWriteTracker = null // don't need this anymore
212+
diskWriteTracker.updateAndGet { null } // don't need this anymore
213213
val tests = tests.getAndUpdate { TERMINATED }
214214
require(tests !== TERMINATED) { "Snapshot $className already terminated!" }
215+
val file = file.getAndUpdate { null }
215216
if (file != null) {
216217
val staleSnapshotIndices =
217218
WithinTestGC.findStaleSnapshotsWithin(
218-
file!!.snapshots, tests, findTestMethodsThatDidntRun(className, tests))
219-
if (staleSnapshotIndices.isNotEmpty() || file!!.wasSetAtTestTime) {
220-
file!!.removeAllIndices(staleSnapshotIndices)
219+
file.snapshots, tests, findTestMethodsThatDidntRun(className, tests))
220+
if (staleSnapshotIndices.isNotEmpty() || file.wasSetAtTestTime) {
221+
file.removeAllIndices(staleSnapshotIndices)
221222
val snapshotPath = system.layout.snapshotPathForClass(className)
222-
if (file!!.snapshots.isEmpty()) {
223+
if (file.snapshots.isEmpty()) {
223224
deleteFileAndParentDirIfEmpty(snapshotPath)
224225
} else {
225226
system.markPathAsWritten(system.layout.snapshotPathForClass(className))
226227
Files.createDirectories(snapshotPath.toPath().parent)
227228
Files.newBufferedWriter(snapshotPath.toPath(), StandardCharsets.UTF_8).use { writer ->
228-
file!!.serialize(writer)
229+
file.serialize(writer)
229230
}
230231
}
231232
}
@@ -239,8 +240,6 @@ internal class SnapshotFileProgress(val system: SnapshotSystemJUnit5, val classN
239240
deleteFileAndParentDirIfEmpty(snapshotFile)
240241
}
241242
}
242-
// now that we are done, allow our contents to be GC'ed
243-
file = null
244243
}
245244
// the methods below are called from the test thread for I/O on snapshots
246245
fun keep(test: String, suffixOrAll: String?) {
@@ -260,7 +259,7 @@ internal class SnapshotFileProgress(val system: SnapshotSystemJUnit5, val classN
260259
) {
261260
assertNotTerminated()
262261
val key = "$test$suffix"
263-
diskWriteTracker!!.record(key, snapshot, callStack, layout)
262+
diskWriteTracker.get()!!.record(key, snapshot, callStack, layout)
264263
tests.get()[test]!!.keepSuffix(suffix)
265264
read().setAtTestTime(key, snapshot)
266265
}
@@ -273,17 +272,22 @@ internal class SnapshotFileProgress(val system: SnapshotSystemJUnit5, val classN
273272
return snapshot
274273
}
275274
private fun read(): SnapshotFile {
276-
if (file == null) {
277-
val snapshotPath = system.layout.snapshotPathForClass(className).toPath()
278-
file =
279-
if (Files.exists(snapshotPath) && Files.isRegularFile(snapshotPath)) {
280-
val content = Files.readAllBytes(snapshotPath)
281-
SnapshotFile.parse(SnapshotValueReader.of(content))
282-
} else {
283-
SnapshotFile.createEmptyWithUnixNewlines(system.layout.unixNewlines)
284-
}
285-
}
286-
return file!!
275+
val file = this.file.get()
276+
if (file != null) return file
277+
278+
return this.file.updateAndGet { file ->
279+
if (file != null) {
280+
file
281+
} else {
282+
val snapshotPath = system.layout.snapshotPathForClass(className).toPath()
283+
if (Files.exists(snapshotPath) && Files.isRegularFile(snapshotPath)) {
284+
val content = Files.readAllBytes(snapshotPath)
285+
SnapshotFile.parse(SnapshotValueReader.of(content))
286+
} else {
287+
SnapshotFile.createEmptyWithUnixNewlines(system.layout.unixNewlines)
288+
}
289+
}
290+
}!!
287291
}
288292
fun incrementContainers() {
289293
assertNotTerminated()
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright (C) 2025 DiffPlug
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.diffplug.selfie.junit5
17+
18+
import com.diffplug.selfie.ArrayMap
19+
import com.diffplug.selfie.Snapshot
20+
import com.diffplug.selfie.SnapshotFile
21+
import kotlin.text.format
22+
import org.junit.jupiter.api.Test
23+
24+
class ConcurrencyStressTest : HarnessJUnit() {
25+
26+
@Test
27+
fun smoke() {
28+
gradleWriteSS()
29+
ut_snapshot().assertContent(expectedSnapshot())
30+
gradleReadSS()
31+
ut_snapshot().deleteIfExists()
32+
}
33+
private fun expectedSnapshot(): String {
34+
var expectedSnapshots = ArrayMap.empty<String, Snapshot>()
35+
for (d in 1..1000) {
36+
expectedSnapshots =
37+
expectedSnapshots.plus(String.format("testFactory/%04d", d), Snapshot.of(d.toString()))
38+
}
39+
val snapshotFile = SnapshotFile()
40+
snapshotFile.snapshots = expectedSnapshots
41+
val buffer = kotlin.text.StringBuilder()
42+
snapshotFile.serialize(buffer)
43+
return buffer.toString()
44+
}
45+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package undertest.junit5
2+
3+
import com.diffplug.selfie.Selfie.expectSelfie
4+
import java.util.concurrent.CountDownLatch
5+
import java.util.concurrent.TimeUnit
6+
import kotlin.streams.asStream
7+
import org.junit.jupiter.api.DynamicTest.dynamicTest
8+
import org.junit.jupiter.api.TestFactory
9+
import org.junit.jupiter.api.parallel.Execution
10+
import org.junit.jupiter.api.parallel.ExecutionMode
11+
12+
@Execution(ExecutionMode.CONCURRENT)
13+
class UT_ConcurrencyStressTest {
14+
// sanity check: make sure our junit-platform.properties file is getting picked up
15+
private val latch = CountDownLatch(8)
16+
17+
@TestFactory
18+
fun testFactory() =
19+
(1..1000).asSequence().asStream().map { digit ->
20+
dynamicTest(String.format("%04d", digit)) {
21+
latch.countDown()
22+
latch.await(5, TimeUnit.SECONDS)
23+
println(Thread.currentThread())
24+
expectSelfie(digit.toString()).toMatchDisk(String.format("%04d", digit))
25+
}
26+
}
27+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
junit.jupiter.execution.parallel.enabled=true
2+
# force parallelism, even if there's only 1 CPU core
3+
junit.jupiter.execution.parallel.config.strategy=fixed
4+
junit.jupiter.execution.parallel.config.fixed.parallelism=8

0 commit comments

Comments
 (0)