fix(runtime): bound resources and quarantine stalled extensions

This commit is contained in:
zarzet
2026-08-26 21:09:05 +07:00
parent 0e3fca9967
commit 747dea886c
18 changed files with 402 additions and 35 deletions
@@ -41,15 +41,13 @@ object NativeDownloadFinalizer {
// Keep this schema contract in sync with Dart HistoryDatabase before bumping either side.
const val HISTORY_SCHEMA_VERSION = 12
internal val activeFFmpegSessionIds = mutableSetOf<Long>()
internal val nativeFFmpegSessionIds = mutableSetOf<Long>()
internal val nativeFFmpegSessionIds = BoundedRegistry<Long>(maxEntries = 256)
internal val activeFFmpegSessionLock = Any()
internal val ffmpegCompleteCallbackLock = Any()
internal val qualityVariantNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
internal val qualityVariantNameLocks = KeyedLockPool<String>()
internal var forwardedFFmpegCompleteCallback: FFmpegSessionCompleteCallback? = null
internal val nativeFilteringFFmpegCompleteCallback = FFmpegSessionCompleteCallback { session ->
val isNativeSession = synchronized(activeFFmpegSessionLock) {
nativeFFmpegSessionIds.contains(session.sessionId)
}
val isNativeSession = nativeFFmpegSessionIds.consume(session.sessionId)
if (!isNativeSession) {
val delegate = synchronized(ffmpegCompleteCallbackLock) {
forwardedFFmpegCompleteCallback
@@ -104,8 +104,8 @@ internal fun NativeDownloadFinalizer.runFFmpeg(command: String, shouldCancel: ()
val sessionId = session.sessionId
synchronized(activeFFmpegSessionLock) {
activeFFmpegSessionIds.add(sessionId)
nativeFFmpegSessionIds.add(sessionId)
}
nativeFFmpegSessionIds.add(sessionId)
FFmpegKitConfig.asyncFFmpegExecute(session)
try {
var cancelRequested = false
@@ -124,8 +124,7 @@ internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename(
val cleanTarget = File(source.parentFile, cleanName)
val lockTarget = if (collisionOnly) cleanTarget else File(source.parentFile, preferredName)
val lockKey = lockTarget.absolutePath.lowercase(Locale.ROOT)
val lock = qualityVariantNameLocks.computeIfAbsent(lockKey) { Any() }
synchronized(lock) {
qualityVariantNameLocks.withLock(lockKey) {
val selectedName = if (collisionOnly) {
resolveQualityVariantFilename(
fileName = logicalFileName,
@@ -138,11 +137,11 @@ internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename(
preferredName
}
input.result.put("quality_variant_file_name", selectedName)
if (selectedName == state.fileName) return@synchronized
if (selectedName == state.fileName) return@withLock
val target = uniqueLocalFile(source.parentFile, selectedName)
if (!source.renameTo(target)) {
Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}")
return@synchronized
return@withLock
}
state.filePath = target.absolutePath
state.fileName = target.name
@@ -0,0 +1,63 @@
package com.zarz.spotiflac
import java.util.LinkedHashSet
/**
* Serializes work per key while allowing idle key entries to be reclaimed.
* References are counted before a caller waits on the monitor, so a waiter can
* never race with removal and end up using a different monitor for the same key.
*/
internal class KeyedLockPool<K> {
private class Entry {
val monitor = Any()
var references = 0
}
private val registryLock = Any()
private val entries = HashMap<K, Entry>()
fun <T> withLock(key: K, block: () -> T): T {
val entry = synchronized(registryLock) {
entries.getOrPut(key) { Entry() }.also { it.references++ }
}
try {
return synchronized(entry.monitor) { block() }
} finally {
synchronized(registryLock) {
entry.references--
if (entry.references == 0 && entries[key] === entry) {
entries.remove(key)
}
}
}
}
internal fun activeKeyCount(): Int = synchronized(registryLock) {
entries.size
}
}
/** A small insertion-ordered registry that consumes completed IDs. */
internal class BoundedRegistry<T>(private val maxEntries: Int) {
private val lock = Any()
private val values = LinkedHashSet<T>()
init {
require(maxEntries > 0) { "maxEntries must be greater than zero" }
}
fun add(value: T) = synchronized(lock) {
values.remove(value)
values.add(value)
while (values.size > maxEntries) {
val oldest = values.iterator().next()
values.remove(oldest)
}
}
fun consume(value: T): Boolean = synchronized(lock) {
values.remove(value)
}
internal fun size(): Int = synchronized(lock) { values.size }
}
@@ -24,7 +24,7 @@ object SafDownloadHandler {
// cannot interleave writes into one document. Different names keep
// downloading in parallel; the second same-name caller blocks, then hits
// the exists check and reports already_exists.
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
private val safNameLocks = KeyedLockPool<String>()
data class UniqueWriteResult(val uri: String, val fileName: String)
data class ExistingAwareWriteResult(
@@ -40,8 +40,7 @@ object SafDownloadHandler {
block: () -> T
): T {
val key = "$treeUriStr|$relativeDir|${fileName.lowercase(Locale.ROOT)}"
val lock = safNameLocks.computeIfAbsent(key) { Any() }
return synchronized(lock) { block() }
return safNameLocks.withLock(key, block)
}
/**
@@ -0,0 +1,62 @@
package com.zarz.spotiflac
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeResourcePoolsTest {
@Test
fun keyedLockPoolReclaimsAnIdleKey() {
val pool = KeyedLockPool<String>()
assertEquals("done", pool.withLock("track.flac") { "done" })
assertEquals(0, pool.activeKeyCount())
}
@Test
fun keyedLockPoolKeepsOneMonitorWhileAnotherCallerWaits() {
val pool = KeyedLockPool<String>()
val firstEntered = CountDownLatch(1)
val releaseFirst = CountDownLatch(1)
val secondEntered = CountDownLatch(1)
val first = thread {
pool.withLock("same-name.flac") {
firstEntered.countDown()
releaseFirst.await(2, TimeUnit.SECONDS)
}
}
assertTrue(firstEntered.await(2, TimeUnit.SECONDS))
val second = thread {
pool.withLock("same-name.flac") {
secondEntered.countDown()
}
}
assertFalse(secondEntered.await(100, TimeUnit.MILLISECONDS))
assertEquals(1, pool.activeKeyCount())
releaseFirst.countDown()
first.join(2_000)
second.join(2_000)
assertTrue(secondEntered.await(100, TimeUnit.MILLISECONDS))
assertEquals(0, pool.activeKeyCount())
}
@Test
fun boundedRegistryConsumesIdsAndEvictsOldestEntries() {
val registry = BoundedRegistry<Long>(maxEntries = 2)
registry.add(1)
registry.add(2)
registry.add(3)
assertFalse(registry.consume(1))
assertTrue(registry.consume(2))
assertTrue(registry.consume(3))
assertEquals(0, registry.size())
}
}