Reject new capture while an IO teardown holds its lifecycle lock

This commit is contained in:
evgeny committed 2026-10-29 13:35:19 +01:00
1 parent c7356d2a5b
commit 48a20cda8c
2 files changed
+19 -9

No files matched your search

@@ -12,6 +12,8 @@ import com.utun.chat.ChatApplication
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/**
* Запись голосового сообщения/тест микрофона и отдельный плеер native Opus-файла.
@@ -54,6 +56,7 @@ class AudioRecorderManager {
@Volatile private var captureRunning = false
@Volatile private var captureFailed = false
private val closing = AtomicReference<CompletableFuture<Unit>?>(null)
private val captureLock = ReentrantLock()
private val playbackWorkers = ConcurrentHashMap.newKeySet<Thread>()
val isTesting: Boolean get() = captureMode == CaptureMode.TEST && captureRunning
@@ -67,7 +70,8 @@ class AudioRecorderManager {
* true — Android/native захват запущен; false — отказ с диагностикой и откатом принадлежащих ресурсов. */
fun startRecording(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean {
if (closing.get()?.isDone == false) { LogManager.addLog("WARN", "AudioRecorder", "record start rejected: cleanup pending"); return false }
return synchronized(this) { startCapture(CaptureMode.RECORD, channelId, replyTo, requestId, generation) }
if (!captureLock.tryLock()) { LogManager.addLog("WARN", "AudioRecorder", "record start rejected: capture lifecycle busy"); return false }
try { return startCapture(CaptureMode.RECORD, channelId, replyTo, requestId, generation) } finally { captureLock.unlock() }
}
private fun startCapture(mode: CaptureMode, target: String, reply: String = "", request: Long = 0, generation: Long = 0): Boolean {
@@ -192,14 +196,16 @@ class AudioRecorderManager {
/** Длительность native в мс, -1 — отказ/запись отброшена после ошибки, 0 — нет записи.
* Успех означает передачу подготовки в native, публикация сообщения подтверждается отдельно событиями. */
@Synchronized fun stopRecording(): Int {
if (captureMode == CaptureMode.RECORD) return finishCapture(true)
fun stopRecording(): Int = captureLock.withLock {
if (captureMode == CaptureMode.RECORD) return@withLock finishCapture(true)
if (captureMode == null) releaseCaptureDevice()
return 0
0
}
fun cancelRecording() {
if (closing.get()?.isDone == false) return
synchronized(this) { if (captureMode == CaptureMode.RECORD) finishCapture(false) else if (captureMode == null) releaseCaptureDevice() }
if (!captureLock.tryLock()) { if (captureMode == CaptureMode.RECORD) requestClose(); return }
try { if (captureMode == CaptureMode.RECORD) finishCapture(false) else if (captureMode == null) releaseCaptureDevice() }
finally { captureLock.unlock() }
}
fun isActive(): Boolean = captureMode == CaptureMode.RECORD && captureRunning
@@ -210,11 +216,14 @@ class AudioRecorderManager {
LogManager.addLog("WARN", "AudioRecorder", "microphone test rejected: stream owns capture")
return false
}
return synchronized(this) { if (isTesting) true else startCapture(CaptureMode.TEST, "__test__") }
if (!captureLock.tryLock()) { LogManager.addLog("WARN", "AudioRecorder", "microphone test rejected: capture lifecycle busy"); return false }
try { return if (isTesting) true else startCapture(CaptureMode.TEST, "__test__") } finally { captureLock.unlock() }
}
fun stopMicTest() {
if (closing.get()?.isDone == false) return
synchronized(this) { if (captureMode == CaptureMode.TEST) finishCapture(false) else if (captureMode == null) releaseCaptureDevice() }
if (!captureLock.tryLock()) { if (captureMode == CaptureMode.TEST) requestClose(); return }
try { if (captureMode == CaptureMode.TEST) finishCapture(false) else if (captureMode == null) releaseCaptureDevice() }
finally { captureLock.unlock() }
}
@Volatile private var playing = false
@@ -222,7 +231,7 @@ class AudioRecorderManager {
private var playThread: Thread? = null
@Volatile private var playGeneration = 0
@Synchronized private fun forgetIfIdle() {
private fun forgetIfIdle() = captureLock.withLock {
if (captureMode == null && audioRecord == null && playbackWorkers.isEmpty()) owners.remove(this)
}
@@ -241,7 +250,7 @@ class AudioRecorderManager {
var retryMs = 250L
try {
while (true) {
val released = synchronized(this) {
val released = captureLock.withLock {
if (captureMode != null) finishCapture(false) else releaseCaptureDevice()
stopPlayback()
captureMode == null && audioRecord == null && playbackWorkers.isEmpty()
@@ -126,6 +126,7 @@ class AudioRecorderManagerTest {
val worker = Thread(stopped, "voice-stop-race").apply { start() }
try {
assertTrue(device.stopEntered.await(1, TimeUnit.SECONDS))
assertFalse(manager.startMicTest())
leave.countDown()
assertEquals(-1, stopped.get(1, TimeUnit.SECONDS))
assertEquals(0, NativeLib.stops.get()); assertEquals(1, NativeLib.cancels.get())