Compare commits

...

12 Commits

Author SHA1 Message Date
1f52bc7c99 restore gradle wrapper scripts 2026-10-10 20:03:49 +06:00
ec2568b988 Update dependencies and rewrite SmartSemaphore docs
Updated version numbers and modified documentation for SmartSemaphore.
2026-10-10 19:54:06 +06:00
d082e8bdba Clean up gradle-wrapper.properties
Removed network configuration settings for Gradle wrapper.
2026-10-10 19:53:31 +06:00
71e0bf214e update dependencies 2026-10-10 15:32:26 +06:00
761b14dc65 preserve concurrency constructor validation 2026-10-10 14:25:54 +06:00
63d66228ab start 0.32.0 2026-10-10 14:25:48 +06:00
c4f15c32ea Merge pull request #679 from InsanusMokrassar/0.31.1
0.31.1
2026-09-23 23:21:27 +06:00
a070f7d2f3 update changelog 2026-09-23 22:50:39 +06:00
74501079c3 fix of smart lockers 2026-09-23 22:49:30 +06:00
761cfffae1 start 0.31.1 2026-09-23 22:41:41 +06:00
f26fb352e5 Update dependencies in CHANGELOG.md for version 0.31.0 2026-08-31 10:51:21 +06:00
b71781727a Merge pull request #678 from InsanusMokrassar/0.31.0
0.31.0
2026-08-30 17:31:34 +06:00
12 changed files with 330 additions and 33 deletions

View File

@@ -1,8 +1,46 @@
# Changelog
## 0.32.0
* `Versions`:
* `crypto-js`: `4.1.1` -> `4.2.0`
* `AndroidX Fragment`: `1.9.0` -> `1.9.1`
* `AndroidX Core KTX`: `1.19.0` -> `1.19.1`
* `NMCP`: `1.6.1` -> `1.6.2`
* `Gradle Versions`: `0.61.0` -> `0.65.0`
* `KotlinPoet`: `2.3.0` -> `2.4.0`
* `Okio`: `3.18.1` -> `3.18.2`
* `Ktor`: `3.5.2` -> `3.6.0`
* `Exposed`: `1.5.0` -> `1.5.1`
* `Compose Material3`: `1.11.0-alpha07` -> `1.9.0`
* `KSLog`: `1.7.0` -> `2.1.0`
* `Kotlin`: `2.4.10` -> `2.4.21`
* `Compose`: `1.12.0` -> `1.12.1`
* `Gradle wrapper`: `9.3.1` -> `9.7.0`
* `Coroutines`:
* `SmartSemaphore`:
* Rewrite docs for `freePermits` property.
* Add constructor arguments requirements to fail on invalid `permits` and `acquiredPermits` values.
* `SmartRWLocker`:
* Add constructor arguments requirements to fail on invalid `readPermits` values.
* `SmartKeyRWLocker`:
* Add constructor arguments requirements to fail on invalid `globalLockerReadPermits` and `perKeyReadPermits` values.
## 0.31.1
* `Coroutines`:
* `SmartRWLocker`:
* Fix of `unlockWrite`, `lockWrite` and `releaseRead` calls to pass correct number of permits
* `SmartMutex`:
* Fix `unlock` call
* `SmartSemaphore`:
* Fix same issues to avoid cancellation exceptions handling errors and several other problems
## 0.31.0
* `Versions`:
* `Kotlin`: `2.3.21` -> `2.4.10`
* `SQLite`: `3.53.2.1` -> `3.53.4.0`
* `KSP`: `2.3.9` -> `2.3.11`
* `Exposed`: `1.3.0` -> `1.5.0`

View File

@@ -28,6 +28,11 @@ class SmartKeyRWLocker<T>(
globalLockerWriteIsLocked: Boolean = false,
private val perKeyReadPermits: Int = Int.MAX_VALUE
) {
init {
require(globalLockerReadPermits > 0) { "The maximal number 'globalLockerReadPermits' of global permits on reading ($globalLockerReadPermits) must be positive." }
require(perKeyReadPermits > 0) { "The maximal number 'perKeyReadPermits' of permits on reading per key ($perKeyReadPermits) must be positive." }
}
private val globalRWLocker: SmartRWLocker = SmartRWLocker(
readPermits = globalLockerReadPermits,
writeIsLocked = globalLockerWriteIsLocked

View File

@@ -1,5 +1,6 @@
package dev.inmo.micro_utils.coroutines
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -7,6 +8,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
@@ -92,8 +94,8 @@ sealed interface SmartMutex {
* If [isLocked] == true - will change it to false and return true. If current call will not unlock this
* [SmartMutex] - false
*/
suspend fun unlock(): Boolean {
return if (_lockStateFlow.value) {
suspend fun unlock(): Boolean = withContext(NonCancellable) {
if (_lockStateFlow.value) {
internalChangesMutex.withLock {
if (_lockStateFlow.value) {
_lockStateFlow.value = false

View File

@@ -1,6 +1,8 @@
package dev.inmo.micro_utils.coroutines
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
@@ -15,12 +17,17 @@ import kotlin.contracts.contract
* * [unlockWrite] will just unlock [writeMutex]
*/
class SmartRWLocker(private val readPermits: Int = Int.MAX_VALUE, writeIsLocked: Boolean = false) {
init {
require(readPermits > 0) { "The maximal number 'readPermits' of permits on reading ($readPermits) must be positive." }
}
private val _readSemaphore = SmartSemaphore.Mutable(permits = readPermits, acquiredPermits = if (writeIsLocked) readPermits else 0)
private val _writeMutex = SmartMutex.Mutable(locked = writeIsLocked)
val readSemaphore: SmartSemaphore.Immutable = _readSemaphore.immutable()
val writeMutex: SmartMutex.Immutable = _writeMutex.immutable()
/**
* Do lock in [readSemaphore] inside of [writeMutex] locking
*/
@@ -32,8 +39,8 @@ class SmartRWLocker(private val readPermits: Int = Int.MAX_VALUE, writeIsLocked:
/**
* Release one read permit in [readSemaphore]
*/
suspend fun releaseRead(): Boolean {
return _readSemaphore.release()
suspend fun releaseRead(): Boolean = withContext(NonCancellable) {
_readSemaphore.release()
}
/**
@@ -44,7 +51,9 @@ class SmartRWLocker(private val readPermits: Int = Int.MAX_VALUE, writeIsLocked:
try {
_readSemaphore.acquire(readPermits)
} catch (e: CancellationException) {
_writeMutex.unlock()
withContext(NonCancellable) {
_writeMutex.unlock()
}
throw e
}
}
@@ -52,9 +61,9 @@ class SmartRWLocker(private val readPermits: Int = Int.MAX_VALUE, writeIsLocked:
/**
* Unlock [writeMutex]
*/
suspend fun unlockWrite(): Boolean {
return _writeMutex.unlock().also {
if (it) {
suspend fun unlockWrite(): Boolean = withContext(NonCancellable) {
_writeMutex.unlock().also { unlocked ->
if (unlocked) {
_readSemaphore.release(readPermits)
}
}

View File

@@ -1,5 +1,6 @@
package dev.inmo.micro_utils.coroutines
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -8,6 +9,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
@@ -27,8 +29,10 @@ sealed interface SmartSemaphore {
val permitsStateFlow: StateFlow<Int>
/**
* * True - locked
* * False - unlocked
* The number of the available permits.
*
* If returns `0`, it is fully acquired (locked).
* If it returns the same value as `maxPermits`, it is fully released (unlocked).
*/
val freePermits: Int
get() = permitsStateFlow.value
@@ -45,6 +49,12 @@ sealed interface SmartSemaphore {
* @param locked Preset state of [freePermits] and its internal [_freePermitsStateFlow]
*/
class Mutable(permits: Int, acquiredPermits: Int = 0) : SmartSemaphore {
init {
require(permits > 0) { "The maximal number 'permits' of permits ($permits) must be positive." }
require(acquiredPermits >= 0) { "The number 'acquiredPermits' of initially acquired permits ($acquiredPermits) must be non-negative." }
require(permits >= acquiredPermits) { "The number 'acquiredPermits' of acquired initially permits ($acquiredPermits) must not exceed the maximal number 'permits' of permits ($permits)." }
}
override val maxPermits: Int = permits
private val _freePermitsStateFlow = MutableRedeliverStateFlow<Int>(permits - acquiredPermits)
override val permitsStateFlow: StateFlow<Int> = _freePermitsStateFlow.asStateFlow()
@@ -76,7 +86,9 @@ sealed interface SmartSemaphore {
}
} while (shouldContinue && currentCoroutineContext().isActive)
} catch (e: Throwable) {
release(acquiredPermits)
if (acquiredPermits > 0) {
release(acquiredPermits)
}
throw e
}
}
@@ -107,9 +119,9 @@ sealed interface SmartSemaphore {
*/
suspend fun tryAcquire(permits: Int = 1): Boolean {
val checkedPermits = checkedPermits(permits)
return if (_freePermitsStateFlow.value < checkedPermits) {
return if (_freePermitsStateFlow.value >= checkedPermits) {
internalChangesMutex.withLock {
if (_freePermitsStateFlow.value < checkedPermits) {
if (_freePermitsStateFlow.value >= checkedPermits) {
_freePermitsStateFlow.value -= checkedPermits
true
} else {
@@ -125,12 +137,12 @@ sealed interface SmartSemaphore {
* If [freePermits] == true - will change it to false and return true. If current call will not unlock this
* [SmartSemaphore] - false
*/
suspend fun release(permits: Int = 1): Boolean {
suspend fun release(permits: Int = 1): Boolean = withContext(NonCancellable) {
val checkedPermits = checkedPermits(permits)
return if (_freePermitsStateFlow.value < this.maxPermits) {
if (_freePermitsStateFlow.value < maxPermits) {
internalChangesMutex.withLock {
if (_freePermitsStateFlow.value < this.maxPermits) {
_freePermitsStateFlow.value = minOf(_freePermitsStateFlow.value + checkedPermits, this.maxPermits)
if (_freePermitsStateFlow.value < maxPermits) {
_freePermitsStateFlow.value = minOf(_freePermitsStateFlow.value + checkedPermits, maxPermits)
true
} else {
false

View File

@@ -0,0 +1,73 @@
import dev.inmo.micro_utils.coroutines.SmartMutex
import dev.inmo.micro_utils.coroutines.withLock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
class SmartMutexTests {
@Test
fun cancelledUnlockCompletesUnderContention() = runTest(timeout = 5.seconds) {
val mutex = SmartMutex.Mutable()
// Delegate this acquisition's release to another coroutine. The
// unconfined collector runs while lock() still holds its internal mutex.
val releaser = launch(Dispatchers.Unconfined) {
mutex.lockStateFlow.first { it }
currentCoroutineContext().cancel()
mutex.unlock()
}
// Keep acquisition on the normal test dispatcher so the collector
// attempts cancelled cleanup before the internal mutex is released.
mutex.lock()
releaser.join()
assertTrue(releaser.isCancelled)
assertFalse(mutex.isLocked, "Cancellation must not prevent the delegated unlock")
assertTrue(mutex.tryLock())
assertTrue(mutex.unlock())
}
@Test
fun cancellingWithLockBodyReleasesMutex() = runTest(timeout = 5.seconds) {
val mutex = SmartMutex.Mutable()
val holder = launch(Dispatchers.Unconfined) {
mutex.withLock {
awaitCancellation()
}
}
assertTrue(mutex.isLocked)
holder.cancelAndJoin()
assertFalse(mutex.isLocked)
}
@Test
fun cancelledWaiterDoesNotEnterOrReleaseHeldMutex() = runTest(timeout = 5.seconds) {
val mutex = SmartMutex.Mutable()
var entered = false
mutex.withLock {
val waiter = launch(Dispatchers.Unconfined) {
mutex.withLock {
entered = true
}
}
waiter.cancelAndJoin()
assertFalse(entered)
assertTrue(mutex.isLocked)
}
assertFalse(mutex.isLocked)
}
}

View File

@@ -9,6 +9,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.seconds
class SmartRWLockerTests {
@@ -109,6 +110,59 @@ class SmartRWLockerTests {
}
}
@Test
fun failureOnReadFreeingRead() = runTest {
val locker = SmartRWLocker()
val job = launch {
locker.withReadAcquire {
while (isActive) {
delay(1.days)
}
}
}
locker.readSemaphore.permitsStateFlow.first {
it == locker.readSemaphore.maxPermits - 1
}
job.cancelAndJoin()
locker.readSemaphore.permitsStateFlow.first {
it == locker.readSemaphore.maxPermits
}
}
@Test
fun cancelledReaderReleasesPermitUnderContention() = runTest(timeout = 5.seconds) {
val locker = SmartRWLocker(readPermits = 2)
val reader = launch(Dispatchers.Unconfined) {
locker.withReadAcquire {
awaitCancellation()
}
}
assertEquals(1, locker.readSemaphore.freePermits)
// Observe the second acquisition synchronously while it still holds the
// semaphore's internal mutex. Cancelling the unconfined reader makes its
// cleanup contend for that mutex before the acquisition can release it.
val cancellation = launch(Dispatchers.Unconfined) {
locker.readSemaphore.permitsStateFlow.first { it == 0 }
reader.cancel()
}
// Keep this acquisition on the normal test dispatcher: making it
// unconfined would change the ordering that forces cleanup contention.
locker.withReadAcquire {
cancellation.join()
reader.join()
assertTrue(reader.isCancelled)
assertEquals(
1,
locker.readSemaphore.freePermits,
"The cancelled reader must release its permit while the other reader still holds one"
)
}
assertEquals(2, locker.readSemaphore.freePermits)
}
@Test
fun simpleWithReadAcquireTest() {
val locker = SmartRWLocker()

View File

@@ -0,0 +1,104 @@
import dev.inmo.micro_utils.coroutines.SmartSemaphore
import dev.inmo.micro_utils.coroutines.withAcquire
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
class SmartSemaphoreTests {
@Test
fun cancelledHolderReleasesPermitUnderContention() = runTest(timeout = 5.seconds) {
val semaphore = SmartSemaphore.Mutable(permits = 2)
val holder = launch(Dispatchers.Unconfined) {
semaphore.withAcquire {
awaitCancellation()
}
}
assertEquals(1, semaphore.freePermits)
// The synchronous observer cancels the holder while the second
// acquisition still owns the semaphore's internal changes mutex.
val cancellation = launch(Dispatchers.Unconfined) {
semaphore.permitsStateFlow.first { it == 0 }
holder.cancel()
}
semaphore.withAcquire {
cancellation.join()
holder.join()
assertTrue(holder.isCancelled)
assertEquals(1, semaphore.freePermits, "The cancelled holder must return its permit")
}
assertEquals(2, semaphore.freePermits)
}
@Test
fun cancelledAcquireReturnsPartialPermitsUnderContention() = runTest(timeout = 5.seconds) {
// One permit belongs to another holder; the waiter can acquire two
// permits immediately, but must wait for the third.
val semaphore = SmartSemaphore.Mutable(permits = 3, acquiredPermits = 1)
lateinit var waiter: kotlinx.coroutines.Job
val cancellation = launch(Dispatchers.Unconfined) {
semaphore.permitsStateFlow.first { it == 1 }
waiter.cancel()
}
waiter = launch(Dispatchers.Unconfined) {
semaphore.acquire(3)
}
assertEquals(0, semaphore.freePermits)
assertFalse(waiter.isCompleted)
// Publishing this release resumes the observer while the internal
// mutex is held. The cancelled acquire must wait to roll back safely.
semaphore.release()
cancellation.join()
waiter.join()
assertTrue(waiter.isCancelled)
assertEquals(3, semaphore.freePermits, "Cancellation must return both partially acquired permits")
semaphore.withAcquire(3) {
assertEquals(0, semaphore.freePermits)
}
assertEquals(3, semaphore.freePermits)
}
@Test
fun cancelledAcquireWithoutPermitsDoesNotReleaseAnotherHoldersPermit() = runTest(timeout = 5.seconds) {
val semaphore = SmartSemaphore.Mutable(permits = 1, acquiredPermits = 1)
val waiter = launch(Dispatchers.Unconfined) {
semaphore.acquire()
}
assertFalse(waiter.isCompleted)
waiter.cancelAndJoin()
assertEquals(0, semaphore.freePermits, "A cancelled waiter that acquired nothing must release nothing")
assertTrue(semaphore.release())
assertEquals(1, semaphore.freePermits)
}
@Test
fun tryAcquireUsesAvailablePermits() = runTest {
val semaphore = SmartSemaphore.Mutable(permits = 3)
assertTrue(semaphore.tryAcquire(2))
assertEquals(1, semaphore.freePermits)
assertTrue(semaphore.tryAcquire())
assertEquals(0, semaphore.freePermits)
assertTrue(semaphore.release(3))
assertEquals(3, semaphore.freePermits)
}
@Test
fun tryAcquireWithInsufficientPermitsLeavesStateUnchanged() = runTest {
val semaphore = SmartSemaphore.Mutable(permits = 3, acquiredPermits = 2)
assertFalse(semaphore.tryAcquire(2))
assertEquals(1, semaphore.freePermits)
semaphore.acquire()
assertFalse(semaphore.tryAcquire())
assertEquals(0, semaphore.freePermits)
}
}

View File

@@ -11,10 +11,10 @@ org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=2g
# JS NPM
crypto_js_version=4.1.1
crypto_js_version=4.2.0
# Project data
group=dev.inmo
version=0.31.0
android_code_version=317
version=0.32.0
android_code_version=319

View File

@@ -1,17 +1,17 @@
[versions]
kt = "2.4.10"
kt = "2.4.21"
kt-serialization = "1.11.0"
kt-coroutines = "1.11.0"
kotlinx-browser = "0.5.0"
kslog = "1.7.0"
kslog = "2.1.0"
jb-compose = "1.12.0"
jb-compose-material3 = "1.11.0-alpha07"
jb-compose = "1.12.1"
jb-compose-material3 = "1.9.0"
jb-compose-icons = "1.7.8"
jb-exposed = "1.5.0"
jb-exposed = "1.5.1"
jb-dokka = "2.2.0"
sqlite = "3.53.4.0"
@@ -19,27 +19,27 @@ sqlite = "3.53.4.0"
korlibs = "5.4.0"
uuid = "0.8.4"
ktor = "3.5.2"
ktor = "3.6.0"
gh-release = "2.5.2"
koin = "4.2.2"
okio = "3.18.1"
okio = "3.18.2"
ksp = "2.3.11"
kotlin-poet = "2.3.0"
kotlin-poet = "2.4.0"
versions = "0.61.0"
nmcp = "1.6.1"
versions = "0.65.0"
nmcp = "1.6.2"
android-gradle = "9.1.1"
dexcount = "4.0.0"
android-coreKtx = "1.19.0"
android-coreKtx = "1.19.1"
android-recyclerView = "1.4.0"
android-appCompat = "1.8.0"
android-fragment = "1.9.0"
android-fragment = "1.9.1"
android-espresso = "3.7.0"
android-test = "1.3.0"

Binary file not shown.

View File

@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists