Skip to content

Commit fbb0808

Browse files
authored
Lock .skiko directory using lockfile when unpacking the native binary (#1128)
In the compose hot reload project, we have seen races from multiple tests trying to unpack skiko into the .skiko directory. Currently, the library loading is only synchronized within the project. This MR also adds a .lock file, which has to be locked when modifying the dataDir. Note: This MR also changed the Library.load method from always entering a monitor, to resolving an atomic reference, best case. ## Release Notes ### Fixes - Desktop - Fixed a race condition that occurred when multiple processes attempted to unpack Skiko binary files at startup
1 parent 8a4144c commit fbb0808

File tree

4 files changed

+219
-28
lines changed

4 files changed

+219
-28
lines changed

skiko/buildSrc/src/main/kotlin/tasks/configuration/JvmTasksConfiguration.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,7 @@ fun SkikoProjectContext.setupJvmTestTask(skikoAwtJarForTests: TaskProvider<Jar>,
515515
}
516516
}
517517

518+
classpath += files(skikoAwtRuntimeJarForTests)
518519
jvmArgs = listOf("--add-opens", "java.desktop/sun.font=ALL-UNNAMED")
519520
}
520521
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
@file:OptIn(ExperimentalCoroutinesApi::class)
2+
3+
package org.jetbrains.skiko
4+
5+
6+
import kotlinx.coroutines.*
7+
import kotlinx.coroutines.future.asDeferred
8+
import kotlinx.coroutines.selects.onTimeout
9+
import kotlinx.coroutines.selects.select
10+
import kotlinx.coroutines.test.runTest
11+
import org.junit.Test
12+
import java.nio.file.Files
13+
import java.nio.file.Path
14+
import kotlin.io.path.*
15+
import kotlin.test.fail
16+
import kotlin.time.Duration.Companion.minutes
17+
import kotlin.time.Duration.Companion.seconds
18+
19+
/**
20+
* Special test which tests if loading the native library works 'under stress',
21+
* where multiple processes try to launch at once, trying to load (and potentially unpack) skiko.
22+
* The test therefore spawns multiple processes in parallel:
23+
* Process A is called 'loader' which just tries to load (potentially unpack) the library
24+
* Process B is called 'deleter' which deletes the .skiko folder
25+
*
26+
* Since multiple processes A are launched in parallel, at any given time,
27+
* the 'deleter' provokes the 'loader' processes to race with each other.
28+
* If not properly synchronized, those loaders will fail because of corrupted .skiko directories
29+
*
30+
*/
31+
@ExperimentalPathApi
32+
class LibraryLoadStressTest {
33+
34+
/**
35+
* Testing if library loading works if the 'skiko data dir' is not yet present on disk
36+
*/
37+
@Test
38+
fun `load library in empty directory`() = runTest {
39+
val tempDataDir = Files.createTempDirectory("skiko-tests")
40+
val nonExistingDir = tempDataDir.resolve("non-existing-dir")
41+
coroutineContext.job.invokeOnCompletion { tempDataDir.deleteRecursively() }
42+
launchProcess("load", nonExistingDir)
43+
}
44+
45+
@Test
46+
fun `load library - stress test`() = runTest(timeout = 10.minutes) {
47+
val tempDataDir = Files.createTempDirectory("skiko-stress-test")
48+
coroutineContext.job.invokeOnCompletion { tempDataDir.deleteRecursively() }
49+
50+
val parallelism = 4
51+
val repetitions = 32
52+
repeat(repetitions) { repetitionIdx ->
53+
Logger.info { "running test repetition #$repetitionIdx" }
54+
55+
coroutineScope {
56+
repeat(parallelism) { loaderIdx ->
57+
launch(Dispatchers.IO + CoroutineName("loader: $loaderIdx, repetition: $repetitionIdx")) {
58+
launchProcess("load", tempDataDir)
59+
}
60+
}
61+
}
62+
63+
withContext(Dispatchers.IO) {
64+
Logger.info { "Cleanup" }
65+
launchProcess("delete", tempDataDir)
66+
}
67+
}
68+
}
69+
70+
suspend fun launchProcess(
71+
command: String, skikoDataDir: Path
72+
) = withContext(Dispatchers.IO) {
73+
val process = ProcessBuilder(
74+
ProcessHandle.current().info().command().get(),
75+
"-cp", System.getProperty("java.class.path"),
76+
"-Dskiko.data.path=${skikoDataDir.toAbsolutePath()}",
77+
"-Xmx256m", "-Xms64m",
78+
StressTestMain::class.java.name, command
79+
).start()
80+
81+
coroutineContext.job.invokeOnCompletion {
82+
process.destroyForcibly()
83+
}
84+
85+
launch(Dispatchers.IO) {
86+
process.inputStream.bufferedReader().forEachLine { line ->
87+
println("$command: $line")
88+
}
89+
}
90+
91+
val errorOut = async(Dispatchers.IO) {
92+
process.errorStream.bufferedReader().readText()
93+
}
94+
95+
select {
96+
process.onExit().asDeferred().onAwait { }
97+
onTimeout(15.seconds) {
98+
process.destroyForcibly()
99+
fail("Timeout waiting on '$command' to finish")
100+
}
101+
}
102+
103+
if (process.exitValue() != 0) {
104+
fail("Process ($command) exited with code ${process.exitValue()}: ${errorOut.await()}")
105+
}
106+
}
107+
108+
/**
109+
* This class is the target for spawning new processes:
110+
* It can act as 'loader' or 'deleter' depending on the argument ("load" or "delete")
111+
*/
112+
object StressTestMain {
113+
@JvmStatic
114+
fun main(args: Array<String>) {
115+
when (val operation = args.first()) {
116+
"load" -> load()
117+
"delete" -> delete()
118+
else -> error("Operation not supported: '$operation'")
119+
}
120+
}
121+
122+
private fun load() {
123+
Library.load()
124+
/*
125+
Check if we can access the 'currentSystemTheme' as this will perform an actual native call
126+
*/
127+
currentSystemTheme
128+
}
129+
130+
@OptIn(ExperimentalPathApi::class)
131+
private fun delete() {
132+
val dataDir = Path(SkikoProperties.dataPath)
133+
if (dataDir.exists()) {
134+
dataDir.listDirectoryEntries().forEach { entry ->
135+
entry.deleteRecursively()
136+
}
137+
}
138+
}
139+
}
140+
}
Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
package org.jetbrains.skia.impl
22

33
import org.jetbrains.skiko.Library
4-
import java.util.concurrent.atomic.AtomicBoolean
54

65
actual class Library {
76
actual companion object {
8-
var loaded = AtomicBoolean(false)
97
@JvmStatic
108
actual fun staticLoad() {
11-
if (loaded.compareAndSet(false, true)) {
12-
Library.load()
13-
}
9+
Library.load()
1410
}
1511

16-
@JvmStatic external fun _nAfterLoad()
12+
@JvmStatic
13+
external fun _nAfterLoad()
1714
}
1815
}

skiko/src/jvmMain/kotlin/org/jetbrains/skiko/Library.kt

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,18 @@ package org.jetbrains.skiko
22

33
import org.jetbrains.skia.Bitmap
44
import java.io.File
5+
import java.nio.channels.FileChannel
56
import java.nio.file.Files
7+
import java.nio.file.Path
68
import java.nio.file.StandardCopyOption
7-
import java.util.concurrent.atomic.AtomicBoolean
9+
import java.nio.file.StandardOpenOption.*
10+
import java.util.concurrent.atomic.AtomicReference
11+
import java.util.concurrent.locks.ReentrantLock
12+
import kotlin.concurrent.withLock
13+
import kotlin.io.bufferedReader
14+
import kotlin.io.path.createParentDirectories
15+
import kotlin.io.resolve
16+
import kotlin.use
817

918
object Library {
1019
private var copyDir: File? = null
@@ -31,38 +40,66 @@ object Library {
3140
private fun unpackIfNeeded(dest: File, resourceName: String, deleteOnExit: Boolean): File {
3241
val file = File(dest, resourceName)
3342
if (!file.exists()) {
34-
val tempFile = File.createTempFile("skiko", "", dest)
35-
if (deleteOnExit)
36-
file.deleteOnExit()
37-
Library::class.java.getResourceAsStream("/$resourceName").use { input ->
38-
Files.copy(input, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
43+
withFileLock(dest.resolve(".lock").toPath()) {
44+
if (file.exists()) return file
45+
val tempFile = File.createTempFile("skiko", "", dest)
46+
if (deleteOnExit)
47+
file.deleteOnExit()
48+
Library::class.java.getResourceAsStream("/$resourceName").use { input ->
49+
Files.copy(input, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
50+
}
51+
Files.move(tempFile.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE)
3952
}
40-
Files.move(tempFile.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE)
4153
}
4254
return file
4355
}
4456

45-
private var loaded = AtomicBoolean(false)
57+
/**
58+
* Holds a reference to the lock which has to be acquired when loading the native library,
59+
* or to wait for the loading to finish.
60+
* The reference will resolve to `null` if loading is done and callers do not need to wait anymore.
61+
*/
62+
private val loadingLock = AtomicReference(ReentrantLock())
4663

4764
// This function does the following: on request to load given resource,
4865
// it checks if resource with given name is found in content-derived directory
4966
// in Skiko's home, and if not - unpacks it. It could also load additional
5067
// localization resources, on platforms where it is needed.
51-
@Synchronized
5268
fun load() {
53-
if (!loaded.compareAndSet(false, true)) return
54-
55-
// Find/unpack a usable copy of the native library.
56-
findAndLoad()
57-
58-
// TODO move properties to SkikoProperties
59-
Setup.init()
60-
61-
try {
62-
// Init code executed after library was loaded.
63-
org.jetbrains.skia.impl.Library._nAfterLoad()
64-
} catch (t: Throwable) {
65-
t.printStackTrace()
69+
/**
70+
* If there is no more loading lock available, then the loading has finished
71+
* and we can just return as normal, assuming that the library was successfully loaded.
72+
*/
73+
val lock = loadingLock.get() ?: return
74+
75+
/**
76+
* If the lock is held by the current thread, then this indicates a recursive call to load.
77+
* Methods like `_nAfterLoad()` might trigger additional Class loading, where .clinit (static init methods)
78+
* trigger further calls to Library.staticLoad() -> Library.load() while holding the current lock.
79+
*
80+
* It is fine, in such cases, to return eagerly and assume that the Library is successfully loaded and
81+
* the recursion is a result of callbacks indicating the successful load.
82+
*/
83+
if (lock.isHeldByCurrentThread) return
84+
85+
lock.withLock {
86+
// We entered the critical section, but another thread might have already entered and finished
87+
if (loadingLock.get() !== lock) return
88+
89+
// Find/unpack a usable copy of the native library.
90+
findAndLoad()
91+
92+
// TODO move properties to SkikoProperties
93+
Setup.init()
94+
95+
try {
96+
// Init code executed after library was loaded.
97+
org.jetbrains.skia.impl.Library._nAfterLoad()
98+
} catch (t: Throwable) {
99+
t.printStackTrace()
100+
} finally {
101+
loadingLock.compareAndSet(lock, null)
102+
}
66103
}
67104
}
68105

@@ -128,3 +165,19 @@ internal class LibraryTestImpl() {
128165
return bitmap._ptr
129166
}
130167
}
168+
169+
170+
/**
171+
* Simple lockfile utility which ensures that the lockfile at the given [path] exists and is locked properly.
172+
* Note: This method cannot be re-entered recusrively
173+
* Note: The same process can only take a given lock once
174+
*/
175+
internal inline fun <T> withFileLock(path: Path, action: () -> T): T {
176+
path.createParentDirectories()
177+
return FileChannel.open(path, READ, WRITE, CREATE).use { channel ->
178+
val lock = channel.lock()
179+
lock.use {
180+
action()
181+
}
182+
}
183+
}

0 commit comments

Comments
 (0)