From 401e65c1327d1ac9dfeb0bc1436aaa109cd0c759 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 28 Oct 2026 11:45:04 +0100 Subject: [PATCH] Read Android log snapshots without losing queued updates --- .../java/com/utun/chat/data/LogManager.kt | 69 +++++++++--- .../com/utun/chat/ui/screens/LogScreen.kt | 38 ++++--- .../java/com/utun/chat/data/LogHistoryTest.kt | 103 ++++++++++++++++++ 3 files changed, 176 insertions(+), 34 deletions(-) create mode 100644 tools/chatgui-android/app/src/test/java/com/utun/chat/data/LogHistoryTest.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/LogManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/LogManager.kt index 4ffc0b3f..77ddda8b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/LogManager.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/LogManager.kt @@ -1,10 +1,10 @@ package com.utun.chat.data import android.util.Log -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +/** Одна строка диагностики; timestamp — локальное время приёма в миллисекундах, level — имя TRACE..ERROR. */ data class LogEntry( val timestamp: Long = System.currentTimeMillis(), val level: String, @@ -12,31 +12,65 @@ data class LogEntry( val message: String ) +/** Ограниченная история в порядке приёма, общая для всех потоков. + * revision уведомляет об изменении, а не переносит строки: медленный/новый подписчик читает актуальный getRecent(). + * Ревизии могут объединяться StateFlow; строки сохраняются до вытеснения старейших по capacity. + */ +internal class LogHistory(private val capacity: Int) { + init { require(capacity > 0) { "Log history capacity must be positive: $capacity" } } + private val buffer = ArrayDeque(capacity) + private val _revision = MutableStateFlow(0L) + val revision = _revision.asStateFlow() + + @Synchronized + fun add(entry: LogEntry) { + if (buffer.size >= capacity) buffer.removeFirst() + buffer.add(entry) + _revision.value += 1 + } + + /** Независимый снимок последних count строк от старых к новым; отрицательный count вызывает IllegalArgumentException. */ + @Synchronized + fun getRecent(count: Int): List = buffer.takeLast(count) +} + +/** Общий буфер последних 100000 строк Kotlin/JNI; состояние переживает Activity, при завершении процесса теряется. + * addLog потокобезопасен и пишет в logcat с указанной важностью, без native-фильтра категорий/доставки в файл или UDP. + * revision — объединяемое уведомление для чтения снимка getRecent(), поэтому задержка UI не теряет строки в очереди событий. + */ object LogManager { /** Категория video проходит общий native-фильтр и доставку в файл/UDP, как C-логи. */ fun videoDebug(message: String) { NativeLib.videoLog(false, message) } fun videoTrace(message: String) { NativeLib.videoLog(true, message) } - private const val MAX_BUFFER = 100000 - private val _buffer = ArrayDeque(MAX_BUFFER) - private val _events = MutableSharedFlow(replay = 0, extraBufferCapacity = 512) - val events: SharedFlow = _events.asSharedFlow() + private val history = LogHistory(100000) + val revision = history.revision @Synchronized fun addLog(level: String, category: String, message: String) { - Log.d("utun-gui", "[$level] [$category] $message") - val entry = LogEntry(level = level, category = category, message = message) - if (_buffer.size >= MAX_BUFFER) _buffer.removeFirst() - _buffer.add(entry) - _events.tryEmit(entry) + val priority = when (level) { + "TRACE" -> Log.VERBOSE + "DEBUG" -> Log.DEBUG + "INFO" -> Log.INFO + "WARN" -> Log.WARN + "ERROR" -> Log.ERROR + else -> Log.DEBUG + } + Log.println(priority, "utun-gui", "[$level] [$category] $message") + history.add(LogEntry(level = level, category = category, message = message)) } - @Synchronized + /** Снимок последних count строк в порядке приёма; count >= 0, превышение размера истории возвращает всю историю. */ fun getRecent(count: Int = 100): List { - return _buffer.takeLast(count) + if (count < 0) { + addLog("ERROR", "Log", "Invalid log history count=$count") + throw IllegalArgumentException("Log history count must be non-negative: $count") + } + return history.getRecent(count) } - /** Called from JNI (via JNI config callback interface) */ + /** JNI передаёт bridge-уровни 0=TRACE..4=ERROR, уже преобразованные из DEBUG_LEVEL_* и прошедшие native-фильтр. + * Неизвестный уровень записывается предупреждением с исходным значением; null category/message заменяются пустыми строками. */ fun onNativeLog(level: Int, category: String?, message: String?) { val lvl = when (level) { 0 -> "TRACE" @@ -44,7 +78,10 @@ object LogManager { 2 -> "INFO" 3 -> "WARN" 4 -> "ERROR" - else -> "NONE" + else -> { + addLog("WARN", "Log", "Unknown JNI log level=$level category=${category.orEmpty()} message=${message.orEmpty()}") + return + } } addLog(lvl, category ?: "", message ?: "") } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt index 492ce1b9..256b5ae0 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack @@ -17,33 +16,39 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.utun.chat.ChatApplication -import com.utun.chat.data.LogEntry import com.utun.chat.data.LogManager +/** Последние 10000 строк общей истории, включая накопленные до открытия экрана; снимки обновляются не чаще кадра. + * Фильтр выбирает точный уровень. Пауза отключает только автопрокрутку, сбор логов продолжается. + * Подписка получает актуальную ревизию при открытии; прокрутка следует отфильтрованному списку и не блокирует чтение истории. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun LogScreen(onBack: () -> Unit) { - val entries = remember { mutableStateListOf() } + var entries by remember { mutableStateOf(LogManager.getRecent(10000)) } val listState = rememberLazyListState() var filterLevel by remember { mutableStateOf("") } var autoScroll by remember { mutableStateOf(true) } LaunchedEffect(Unit) { - /* Load history from in-memory buffer */ - val recent = LogManager.getRecent(500) - entries.addAll(recent) - - /* Live tail via SharedFlow */ - LogManager.events.collect { entry -> - entries.add(entry) - if (entries.size > 10000) entries.removeAt(0) - if (autoScroll) { - listState.animateScrollToItem(entries.size - 1) + LogManager.addLog("DEBUG", "Log", "Log viewer subscribed: history_limit=10000") + try { + LogManager.revision.collect { + withFrameNanos { } + entries = LogManager.getRecent(10000) } + } finally { + LogManager.addLog("DEBUG", "Log", "Log viewer unsubscribed") } } + val filtered = remember(entries, filterLevel) { + if (filterLevel.isEmpty()) entries else entries.filter { it.level == filterLevel } + } + LaunchedEffect(filtered, autoScroll) { + if (autoScroll && filtered.isNotEmpty()) listState.scrollToItem(filtered.lastIndex) + } + Scaffold( topBar = { TopAppBar( @@ -72,11 +77,8 @@ fun LogScreen(onBack: () -> Unit) { ) } ) { padding -> - val filtered = if (filterLevel.isEmpty()) entries.toList() - else entries.filter { it.level == filterLevel } - LazyColumn(modifier = Modifier.padding(padding), state = listState) { - items(filtered.size) { idx -> // index-based to avoid recomposition on each new entry + items(filtered.size) { idx -> val entry = filtered[idx] val color = when (entry.level) { "ERROR" -> Color(0xFFFFB4AB) diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/LogHistoryTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/LogHistoryTest.kt new file mode 100644 index 00000000..04cd9b26 --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/LogHistoryTest.kt @@ -0,0 +1,103 @@ +package com.utun.chat.data + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.* +import org.junit.Test + +/** Настоящая история экрана логов: переполнение, открытие/задержка подписчика и конкурентные производители. */ +class LogHistoryTest { + private fun entry(message: String) = LogEntry(timestamp = 1, level = "INFO", category = "test", message = message) + + @Test fun boundedHistoryKeepsLatestEntriesInReceptionOrder() { + val history = LogHistory(3) + repeat(5) { history.add(entry("$it")) } + assertEquals(listOf("2", "3", "4"), history.getRecent(10).map { it.message }) + assertEquals(listOf("3", "4"), history.getRecent(2).map { it.message }) + assertTrue(history.getRecent(0).isEmpty()) + assertEquals(5L, history.revision.value) + } + + @Test fun snapshotRemainsUnchangedWhileOldRowsAreEvicted() { + val history = LogHistory(2) + history.add(entry("before")) + val snapshot = history.getRecent(2) + repeat(20) { history.add(entry("after-$it")) } + assertEquals(listOf("before"), snapshot.map { it.message }) + assertEquals(listOf("after-18", "after-19"), history.getRecent(2).map { it.message }) + } + + @Test fun identicalMessagesWithTheSameTimestampStillAdvanceRevision() { + val history = LogHistory(4) + val repeated = entry("same") + repeat(4) { history.add(repeated) } + assertEquals(4L, history.revision.value) + assertEquals(List(4) { repeated }, history.getRecent(4)) + } + + @Test fun subscriberOpeningAfterLastLogSeesHistoryWithoutAnotherMessage() = runBlocking { + val history = LogHistory(4) + history.add(entry("already buffered")) + assertEquals(1L, withTimeout(2000) { history.revision.first() }) + assertEquals("already buffered", history.getRecent(1).single().message) + } + + @Test fun stalledSubscriberRecoversBurstBeyondTheOld512EventQueue() = runBlocking { + val history = LogHistory(4096) + val resume = CompletableDeferred() + val observed = CompletableDeferred>>() + val reader = launch(start = CoroutineStart.UNDISPATCHED) { + history.revision.collect { revision -> + if (revision == 0L) resume.await() + else observed.complete(revision to history.getRecent(4096)) + } + } + try { + repeat(2048) { history.add(entry("$it")) } + resume.complete(Unit) + val (revision, snapshot) = withTimeout(2000) { observed.await() } + assertEquals(2048L, revision) + assertEquals((0 until 2048).map(Int::toString), snapshot.map { it.message }) + } finally { reader.cancelAndJoin() } + } + + @Test fun concurrentWritersPreserveEveryEntryAndTheirOwnOrder() { + val writers = 6 + val count = 1024 + val history = LogHistory(writers * count) + val start = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(writers) + try { + val tasks = (0 until writers).map { writer -> + executor.submit { + check(start.await(5, TimeUnit.SECONDS)) + repeat(count) { history.add(entry("$writer:$it")) } + } + } + start.countDown() + tasks.forEach { it.get(5, TimeUnit.SECONDS) } + val messages = history.getRecent(writers * count).map { it.message } + assertEquals((writers * count).toLong(), history.revision.value) + assertEquals(writers * count, messages.toSet().size) + repeat(writers) { writer -> + assertEquals((0 until count).map { "$writer:$it" }, messages.filter { it.startsWith("$writer:") }) + } + } finally { executor.shutdownNow() } + } + + @Test fun invalidBoundsAreRejectedAndEmptyHistoryIsReadable() { + for (capacity in listOf(0, -1)) assertThrows(IllegalArgumentException::class.java) { LogHistory(capacity) } + val history = LogHistory(1) + assertTrue(history.getRecent(10).isEmpty()) + assertEquals(0L, history.revision.value) + assertThrows(IllegalArgumentException::class.java) { history.getRecent(-1) } + } +}