diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputBuffer.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputBuffer.kt new file mode 100644 index 0000000000..934e92bd6e --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputBuffer.kt @@ -0,0 +1,165 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.fragments.output + +import kotlinx.coroutines.channels.Channel + +/** + * Thread-safe pending build output with fixed memory and batch budgets. + * + * Inputs are indivisible unless one exceeds [maxPendingChars], in which case only its newest tail + * is retained. Older pending output is evicted first, while batches preserve the retained order. + */ +internal class BuildOutputBuffer( + private val maxPendingChars: Int = DEFAULT_MAX_PENDING_CHARS, + private val maxBatchChars: Int = DEFAULT_MAX_BATCH_CHARS, +) { + data class Batch( + val text: String, + val sessionToken: Int, + val sourceChars: Int, + ) + + private sealed interface Entry { + val sessionToken: Int + + data class Text( + val value: String, + override val sessionToken: Int, + ) : Entry + + data class Omission( + var lineCount: Long, + var sourceChars: Int, + override val sessionToken: Int, + ) : Entry + } + + private val entries = ArrayDeque() + private val available = Channel(Channel.CONFLATED) + private val lock = Any() + private var retainedChars = 0 + + internal val pendingChars: Int + get() = synchronized(lock) { retainedChars } + + init { + require(maxPendingChars > 0) { "maxPendingChars must be positive" } + require(maxBatchChars > 0) { "maxBatchChars must be positive" } + } + + fun offer( + text: String, + sessionToken: Int, + ) { + if (text.isEmpty()) return + val normalized = if (text.endsWith('\n')) text else "$text\n" + synchronized(lock) { + val existingOmission = entries.firstOrNull() as? Entry.Omission + if (existingOmission != null) entries.removeFirst() + + var retained = normalized + var omittedLines = existingOmission?.lineCount ?: 0 + var omittedChars = existingOmission?.sourceChars ?: 0 + if (retained.length > maxPendingChars) { + val droppedPrefix = retained.dropLast(maxPendingChars) + omittedLines += lineCount(droppedPrefix) + omittedChars = saturatedAdd(omittedChars, droppedPrefix.length) + retained = retained.takeLast(maxPendingChars) + } + while (retainedChars > maxPendingChars - retained.length) { + val evicted = entries.removeFirst() as Entry.Text + retainedChars -= evicted.value.length + omittedLines += lineCount(evicted.value) + omittedChars = saturatedAdd(omittedChars, evicted.value.length) + } + + if (omittedLines > 0) { + entries.addFirst(Entry.Omission(omittedLines, omittedChars, sessionToken)) + } + entries.addLast(Entry.Text(retained, sessionToken)) + retainedChars += retained.length + available.trySend(Unit) + } + } + + suspend fun takeBatch(): Batch { + while (true) { + available.receive() + val batch = synchronized(lock) { takeAvailableBatch() } + if (batch != null) return batch + } + } + + fun clear() { + synchronized(lock) { + entries.clear() + retainedChars = 0 + while (available.tryReceive().isSuccess) { + // Discard stale wakeups from the cleared build session. + } + } + } + + private fun takeAvailableBatch(): Batch? { + if (entries.isEmpty()) return null + val sessionToken = entries.first().sessionToken + val batch = StringBuilder(minOf(retainedChars, maxBatchChars)) + var sourceChars = 0 + while (entries.isNotEmpty()) { + val entry = entries.first() + if (entry.sessionToken != sessionToken) break + val value = + when (entry) { + is Entry.Text -> entry.value + is Entry.Omission -> omissionMarker(entry.lineCount) + } + if (batch.isNotEmpty() && batch.length + value.length > maxBatchChars) break + + entries.removeFirst() + batch.append(value) + when (entry) { + is Entry.Text -> { + retainedChars -= entry.value.length + sourceChars = saturatedAdd(sourceChars, entry.value.length) + } + is Entry.Omission -> sourceChars = saturatedAdd(sourceChars, entry.sourceChars) + } + } + if (entries.isNotEmpty()) available.trySend(Unit) + return Batch(batch.toString(), sessionToken, sourceChars) + } + + private fun lineCount(text: String): Long = + text.count { it == '\n' }.toLong() + if (text.endsWith('\n')) 0 else 1 + + private fun saturatedAdd( + left: Int, + right: Int, + ): Int = (left.toLong() + right).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + + private fun omissionMarker(lineCount: Long): String { + val noun = if (lineCount == 1L) "line" else "lines" + return "[$lineCount build output $noun omitted]\n" + } + + companion object { + private const val DEFAULT_MAX_PENDING_CHARS = 256 * 1024 + private const val DEFAULT_MAX_BATCH_CHARS = 32 * 1024 + } +} diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt index 6c5e4c42dc..c0eb0a6506 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt @@ -36,9 +36,8 @@ import com.itsaky.androidide.utils.BasicBuildInfo import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashInfo import com.itsaky.androidide.viewmodel.BuildOutputViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.drop @@ -54,13 +53,9 @@ class BuildOutputFragment : ViewOptionsOutputFragment { private val buildOutputViewModel: BuildOutputViewModel by activityViewModels() - companion object { - private const val LAYOUT_TIMEOUT_MS = 2000L - } - override val currentEditor: IDEEditor? get() = editor - private val logChannel = Channel(Channel.UNLIMITED) + private val outputBuffer = BuildOutputBuffer() private var searchLayout: EditorSearchLayout? = null private var filterBar: LogFilterBarController? = null @@ -69,15 +64,17 @@ class BuildOutputFragment : // so a re-render never misses or duplicates a concurrently flushed batch. private val editorContentMutex = Mutex() - // Bumped only when a build session is cleared (new build) so live streaming logs - // are never dropped from the disk session file during filter re-renders. - @Volatile - private var sessionGeneration = 0 + // Keeps producer-side disk appends ordered and provides an atomic restore snapshot boundary. + private val appendMutex = Mutex() // Bumped on every wholesale content replacement (filtered re-render or clear) so an // in-flight batch flush drained before the replacement can detect it and drop itself. @Volatile private var editorContentGeneration = 0 + + // Written on Main and read by the background batch processor. + @Volatile + private var editorSourceChars = 0 private val noMatchTracker = FilterNoMatchTracker() // Reads view state (bar visibility), so evaluate it on the main thread. @@ -95,11 +92,9 @@ class BuildOutputFragment : setupSearchLayout() viewLifecycleOwner.lifecycleScope.launch { - launch { restoreWindowFromViewModel() } - launch(Dispatchers.Default) { processLogs() } launch { - val content = buildOutputViewModel.getFullContent() - buildOutputViewModel.setCachedSnapshot(content) + restoreWindowFromViewModel() + withContext(Dispatchers.Default) { processLogs() } } launch { combine( @@ -121,15 +116,23 @@ class BuildOutputFragment : showTimestamps: Boolean = buildOutputViewModel.showTimestamps.value, showDeltas: Boolean = buildOutputViewModel.showDeltas.value, ) { - editorContentMutex.withLock { - editorContentGeneration++ - val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } - val filtered = - withContext(Dispatchers.Default) { - BuildOutputViewModel.filterLines(window, query, showTimestamps, showDeltas) - } + val renderGeneration = withContext(Dispatchers.Main) { - editor?.setText(filtered) + editorContentGeneration++ + editorContentGeneration + } + val window = + snapshotEditorWindow() + val filtered = + withContext(Dispatchers.Default) { + BuildOutputViewModel.filterLines(window, query, showTimestamps, showDeltas) + } + withContext(Dispatchers.Main) { + editorContentMutex.withLock { + if (renderGeneration != editorContentGeneration) return@withLock + val editor = editor ?: return@withLock + editor.setText(filtered) + editorSourceChars = BuildOutputViewModel.editorSourceCharsAfterRefresh(window.length) val isSourceEmpty = window.isBlank() updateEmptyState(isSourceEmpty = isSourceEmpty, isFilterActive = isFilterActive) if (noMatchTracker.onRender(isSourceEmpty = isSourceEmpty, isFilteredEmpty = filtered.isBlank())) { @@ -287,61 +290,72 @@ class BuildOutputFragment : }.also { filterBar = it } } - private suspend fun restoreWindowFromViewModel() { - val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } - val content = - BuildOutputViewModel.filterLines( - window, - buildOutputViewModel.filterText.value, - buildOutputViewModel.showTimestamps.value, - buildOutputViewModel.showDeltas.value, - ) - val query = buildOutputViewModel.filterText.value - val isSourceEmpty = window.isBlank() - val isFilteredEmpty = content.isBlank() + private suspend fun restoreWindowFromViewModel() = + withContext(Dispatchers.Default) { + val generationAtRestore = editorContentGeneration + val window = snapshotEditorWindow() + val content = + BuildOutputViewModel.filterLines( + window, + buildOutputViewModel.filterText.value, + buildOutputViewModel.showTimestamps.value, + buildOutputViewModel.showDeltas.value, + ) + fun isRestoreCurrent() = editorContentGeneration == generationAtRestore + val isSourceEmpty = window.isBlank() + val isFilteredEmpty = content.isBlank() - withContext(Dispatchers.Main) { - updateEmptyState(isSourceEmpty = isSourceEmpty, isFilterActive = isFilterActive) - noMatchTracker.prime(isFilteredEmpty) - if (!isSourceEmpty && isFilteredEmpty) { - editor?.setText("") - onContentReplaced() + withContext(Dispatchers.Main) { + updateEmptyState(isSourceEmpty = isSourceEmpty, isFilterActive = isFilterActive) + noMatchTracker.prime(isFilteredEmpty) + if (!isSourceEmpty && isFilteredEmpty) { + editorContentMutex.withLock { + if (isRestoreCurrent()) { + editor?.run { + setText("") + editorSourceChars = + BuildOutputViewModel.editorSourceCharsAfterRefresh(window.length) + onContentReplaced() + } + } + } + } } - } - if (content.isEmpty()) return - withContext(Dispatchers.Main) { - val editor = this@BuildOutputFragment.editor ?: return@withContext - val layoutCompleted = - withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { + if (content.isEmpty()) return@withContext + withContext(Dispatchers.Main) { + val editor = this@BuildOutputFragment.editor ?: return@withContext + val layoutCompleted = + withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { + editor.awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) }) + } + if (layoutCompleted != null) { + editorContentMutex.withLock { + if (isRestoreCurrent() && editor.appendBatchIfReady(content)) { + editorSourceChars = + BuildOutputViewModel.editorSourceCharsAfterRefresh(window.length) + updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) + } + } + } else { + // Layout timed out; keep waiting so the restored content is not lost. editor.awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) }) - } - if (layoutCompleted != null) { - editor.appendBatch(content) - updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) - } else { - // Timeout: defer append until layout is ready so content is not lost - val generationAtRestore = editorContentGeneration - val job = - viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { - editor.run { - awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) }) - editorContentMutex.withLock { - if (editorContentGeneration == generationAtRestore) { - appendBatch(content) - updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) - } - } + editorContentMutex.withLock { + if (isRestoreCurrent() && editor.appendBatchIfReady(content)) { + editorSourceChars = + BuildOutputViewModel.editorSourceCharsAfterRefresh(window.length) + updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) } } - job.join() + } } } - } override fun onDestroyView() { searchLayout = null filterBar = null + editorContentGeneration++ + editorSourceChars = 0 editor?.release() super.onDestroyView() } @@ -351,13 +365,9 @@ class BuildOutputFragment : // Avoid forcing the activityViewModels lazy init (which calls requireActivity()) // when the fragment is detached, otherwise an IllegalStateException is thrown. if (!isAdded || activity == null) return - while (logChannel.tryReceive().isSuccess) { - // Discard: these lines belong to the session being cleared. - } - // Invalidate in-flight flushes before deleting content, so a batch drained from the - // channel earlier cannot re-seed the cleared session. - sessionGeneration++ + outputBuffer.clear() editorContentGeneration++ + editorSourceChars = 0 noMatchTracker.reset() buildOutputViewModel.clear() super.clearOutput() @@ -376,119 +386,159 @@ class BuildOutputFragment : } fun appendOutput(output: String?) { - if (!output.isNullOrEmpty()) { - logChannel.trySend(output) + val text = output ?: return + if (text.isEmpty()) return + val sessionToken = buildOutputViewModel.currentSessionToken + lifecycleScope.launch { + appendMutex.withLock { + val normalized = + withContext(Dispatchers.Default) { + if (text.endsWith('\n')) text else "$text\n" + } + if ( + buildOutputViewModel.append(normalized, sessionToken) && + buildOutputViewModel.isCurrentSession(sessionToken) + ) { + outputBuffer.offer(normalized, sessionToken) + } + } } } - /** - * Ensures the string ends with a newline character (`\n`). - * Useful for maintaining correct formatting when concatenating log lines. - */ - private fun String.ensureNewline(): String = if (endsWith('\n')) this else "$this\n" - - /** - * Immediately drains (consumes) all available messages from the channel into the [buffer]. - * - * This is a **non-blocking** operation that enables batching, grouping hundreds of pending lines - * into a single memory operation to avoid saturating the UI queue. - */ - private fun ReceiveChannel.drainTo(buffer: StringBuilder) { - var result = tryReceive() - while (result.isSuccess) { - val line = result.getOrNull() - if (!line.isNullOrEmpty()) { - buffer.append(line.ensureNewline()) - } - result = tryReceive() + private suspend fun snapshotEditorWindow(): String = + appendMutex.withLock { + val snapshot = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } + // The snapshot already contains everything persisted before this boundary. + outputBuffer.clear() + buildOutputViewModel.setCachedSnapshot(snapshot) + snapshot } - } /** * Main log orchestrator: Consumes, Batches, and Dispatches. * - * 1. Suspends (zero CPU usage) until the first log arrives. - * 2. Wakes up and drains the entire queue (Batching). - * 3. Sends the complete block to the UI in a single pass. + * Suspends until bounded output is available, then sends one bounded batch to the UI. */ - private suspend fun processLogs() = - with(StringBuilder()) { - for (firstLine in logChannel) { - val sessionGenAtDrain = sessionGeneration - val editorGenAtDrain = editorContentGeneration - append(firstLine.ensureNewline()) - logChannel.drainTo(this) - - if (isNotEmpty()) { - val batchText = toString() - clear() - flushToEditor(batchText, sessionGenAtDrain, editorGenAtDrain) - } + private suspend fun processLogs() { + while (true) { + val batch = outputBuffer.takeBatch() + val editorGenAtDrain = editorContentGeneration + try { + flushToEditor( + batch.text, + batch.sourceChars, + batch.sessionToken, + editorGenAtDrain, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.error("Failed to flush a build output batch to the editor", e) } } + } /** * Performs the safe UI update on the Main Thread. * - * Appends to the session file on a background dispatcher before switching to Main. + * Applies output already persisted by the producer before switching to Main. * Uses [IDEEditor.awaitLayout] to guarantee the editor has physical dimensions (width > 0) * before attempting to insert text, preventing the Sora library's `ArrayIndexOutOfBoundsException`. */ private suspend fun flushToEditor( text: String, - sessionGen: Int, + sourceChars: Int, + sessionToken: Int, editorGen: Int, ) { - editorContentMutex.withLock { - // A clear (new build) after this batch was drained invalidates session append. - if (sessionGen != sessionGeneration) return - - buildOutputViewModel.append(text) - - // The session file always gets the full text; the editor only shows matching lines - val visibleText = - BuildOutputViewModel.filterLines( - text, - buildOutputViewModel.filterText.value, - buildOutputViewModel.showTimestamps.value, - buildOutputViewModel.showDeltas.value, - ) - if (visibleText.isEmpty()) { - return + if (!buildOutputViewModel.isCurrentSession(sessionToken)) return + val refreshEditorWindow = + BuildOutputViewModel.wouldExceedEditorWindow(editorSourceChars, sourceChars) + val visibleText = + BuildOutputViewModel.filterLines( + text, + buildOutputViewModel.filterText.value, + buildOutputViewModel.showTimestamps.value, + buildOutputViewModel.showDeltas.value, + ) + val refreshedWindow = + if (refreshEditorWindow) { + val window = + appendMutex.withLock { + val snapshot = buildOutputViewModel.getCachedContentSnapshot() + outputBuffer.clear() + snapshot + } + withContext(Dispatchers.Default) { + Pair( + BuildOutputViewModel.filterLines( + window, + buildOutputViewModel.filterText.value, + buildOutputViewModel.showTimestamps.value, + buildOutputViewModel.showDeltas.value, + ), + window.length, + ) + } + } else { + null } - withContext(Dispatchers.Main) { + withContext(Dispatchers.Main) { + editorContentMutex.withLock { + if ( + editorGen != editorContentGeneration || + !buildOutputViewModel.isCurrentSession(sessionToken) + ) { + return@withLock + } + val editor = editor ?: return@withLock updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) + if (refreshedWindow != null) { + editorContentGeneration++ + editor.setText(refreshedWindow.first) + editorSourceChars = + BuildOutputViewModel.editorSourceCharsAfterRefresh(refreshedWindow.second) + onContentReplaced() + return@withLock + } if (visibleText.isEmpty()) { - return@withContext + editorSourceChars += sourceChars + return@withLock } - editor?.run { - val layoutCompleted = - withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { - awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) }) - } - if (layoutCompleted != null) { - // clearOutput() or renderFiltered() may have run since the file append. - if (editorGen == editorContentGeneration) { - appendBatch(visibleText) - updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) - } - } else { - // Timeout: defer append until layout is ready (same as restoreWindowFromViewModel) - viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { - editor?.run { - awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) }) - editorContentMutex.withLock { - if (editorGen == editorContentGeneration) { - appendBatch(visibleText) - updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) - } - } - } - } + + val layoutCompleted = + withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { + editor.awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) }) + } + if (layoutCompleted != null) { + if (editor.appendBatchIfReady(visibleText)) { + editorSourceChars += sourceChars + } + } else { + editor.awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) }) + if ( + editorGen == editorContentGeneration && + buildOutputViewModel.isCurrentSession(sessionToken) && + editor.appendBatchIfReady(visibleText) + ) { + editorSourceChars += sourceChars + updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) } } } } } + + private fun IDEEditor.appendBatchIfReady(text: String): Boolean { + if (!isReadyToAppend) return false + val previousLength = this.text.length + appendBatch(text) + return this.text.length == previousLength + text.length + } + + companion object { + private const val LAYOUT_TIMEOUT_MS = 2000L + private val log = org.slf4j.LoggerFactory.getLogger(BuildOutputFragment::class.java) + } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt index dc94377062..f3b89f21d4 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt @@ -47,6 +47,16 @@ class BuildOutputViewModel( ) : AndroidViewModel(application) { private val lock = ReentrantLock() + @Volatile + private var sessionGeneration = 0 + + /** Token for output produced by the current build session. */ + val currentSessionToken: Int + get() = sessionGeneration + + /** Returns whether [token] still belongs to the current build session. */ + fun isCurrentSession(token: Int): Boolean = token == sessionGeneration + /** * Case-insensitive line filter applied to the *editor view* of the build output. * The session file always receives the unfiltered text. @@ -88,32 +98,38 @@ class BuildOutputViewModel( /** * Appends text to the session file. File I/O is performed on a background dispatcher; call from - * any thread. Prefer calling before switching to Main so disk write does not block the UI. + * any thread. [sessionToken] values invalidated by [clear] are rejected inside the file lock. */ - suspend fun append(text: String) { - if (text.isEmpty()) return - withContext(Dispatchers.IO) { + suspend fun append( + text: String, + sessionToken: Int, + ): Boolean { + if (text.isEmpty()) return false + return withContext(Dispatchers.IO) { lock.withLock { + if (!isCurrentSession(sessionToken)) return@withLock false try { FileOutputStream(sessionFile, true).use { it.write(text.toByteArray(StandardCharsets.UTF_8)) } cachedContentSnapshot = (cachedContentSnapshot + text).takeLast(CACHE_SNAPSHOT_MAX_CHARS) + true } catch (e: Exception) { log.error("Failed to append build output to session file", e) + false } } } } /** - * Returns the last [WINDOW_SIZE_CHARS] characters from the session file for the editor to + * Returns the last [EDITOR_WINDOW_MAX_CHARS] characters from the session file for the editor to * display (e.g. initial view or after rotation). Returns empty string if no content. */ fun getWindowForEditor(): String = lock.withLock { - readTailFromFile(sessionFile, WINDOW_SIZE_CHARS) + readTailFromFile(sessionFile, EDITOR_WINDOW_MAX_CHARS) } /** @@ -159,6 +175,7 @@ class BuildOutputViewModel( */ fun clear() { lock.withLock { + sessionGeneration++ cachedContentSnapshot = "" try { if (sessionFile.exists()) { @@ -194,6 +211,19 @@ class BuildOutputViewModel( } companion object { + internal const val EDITOR_WINDOW_MAX_CHARS = 512 * 1024 + private const val EDITOR_WINDOW_REFRESH_CHARS = 128 * 1024 + private const val EDITOR_WINDOW_REFRESH_BASE_CHARS = + EDITOR_WINDOW_MAX_CHARS - EDITOR_WINDOW_REFRESH_CHARS + + internal fun wouldExceedEditorWindow( + currentChars: Int, + incomingChars: Int, + ): Boolean = currentChars > EDITOR_WINDOW_MAX_CHARS - incomingChars + + internal fun editorSourceCharsAfterRefresh(windowChars: Int): Int = + windowChars.coerceAtMost(EDITOR_WINDOW_REFRESH_BASE_CHARS) + // Must mirror formatLinePrefix exactly; the round-trip is covered by BuildOutputFilterTest. // Anchored to line start so timestamp-shaped text inside a message is never stripped. private val PREFIX_REGEX = @@ -261,10 +291,8 @@ class BuildOutputViewModel( } private const val SESSION_FILE_NAME = "build_output_session.txt" - private const val WINDOW_SIZE_CHARS = 512 * 1024 - /** Max length of [cachedContentSnapshot] to bound memory. */ - private const val CACHE_SNAPSHOT_MAX_CHARS = WINDOW_SIZE_CHARS + private const val CACHE_SNAPSHOT_MAX_CHARS = EDITOR_WINDOW_MAX_CHARS private val log = org.slf4j.LoggerFactory.getLogger(BuildOutputViewModel::class.java) } } diff --git a/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputBufferTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputBufferTest.kt new file mode 100644 index 0000000000..30ef509ab2 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputBufferTest.kt @@ -0,0 +1,205 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.fragments.output + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.viewmodel.BuildOutputViewModel +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class BuildOutputBufferTest { + private fun BuildOutputBuffer.offer(text: String) { + offer(text, sessionToken = 0) + } + + @Test + fun `output below limits is emitted in order with one trailing newline`() = + runTest { + val buffer = BuildOutputBuffer(maxPendingChars = 64, maxBatchChars = 64) + + buffer.offer("first") + buffer.offer("second\n") + + assertThat(buffer.takeBatch().text).isEqualTo("first\nsecond\n") + } + + @Test + fun `output is split into bounded batches without reordering`() = + runTest { + val buffer = BuildOutputBuffer(maxPendingChars = 64, maxBatchChars = 6) + + buffer.offer("aa") + buffer.offer("bb") + buffer.offer("cc") + + val first = buffer.takeBatch().text + val second = buffer.takeBatch().text + assertThat(first).isEqualTo("aa\nbb\n") + assertThat(second).isEqualTo("cc\n") + assertThat(first.length).isAtMost(6) + assertThat(second.length).isAtMost(6) + } + + @Test + fun `one indivisible input may exceed the batch limit`() = + runTest { + val buffer = BuildOutputBuffer(maxPendingChars = 64, maxBatchChars = 4) + + buffer.offer("oversized") + + assertThat(buffer.takeBatch().text).isEqualTo("oversized\n") + } + + @Test + fun `overflow evicts oldest output and keeps newest output`() = + runTest { + val buffer = BuildOutputBuffer(maxPendingChars = 11, maxBatchChars = 128) + + buffer.offer("one") + buffer.offer("two") + buffer.offer("three") + buffer.offer("four") + + val batch = buffer.takeBatch() + assertThat(batch.text).isEqualTo("[2 build output lines omitted]\nthree\nfour\n") + assertThat(batch.sourceChars).isEqualTo(19) + assertThat(buffer.pendingChars).isAtMost(11) + } + + @Test + fun `oversized input retains its newest bounded tail`() = + runTest { + val buffer = BuildOutputBuffer(maxPendingChars = 8, maxBatchChars = 128) + + buffer.offer("0123456789") + + val batch = buffer.takeBatch() + assertThat(batch.text).isEqualTo("[1 build output line omitted]\n3456789\n") + assertThat(batch.sourceChars).isEqualTo(11) + assertThat(buffer.pendingChars).isEqualTo(0) + } + + @Test + fun `clear resets pending output and overflow accounting`() = + runTest { + val buffer = BuildOutputBuffer(maxPendingChars = 4, maxBatchChars = 64) + + buffer.offer("kept") + buffer.offer("dropped") + buffer.clear() + buffer.offer("new") + + assertThat(buffer.takeBatch().text).isEqualTo("new\n") + } + + @Test + fun `in-flight batch keeps the session token from its producer`() = + runTest { + val buffer = BuildOutputBuffer(maxPendingChars = 64, maxBatchChars = 64) + + buffer.offer("old", sessionToken = 3) + val inFlight = buffer.takeBatch() + buffer.clear() + buffer.offer("new", sessionToken = 4) + + assertThat(inFlight.sessionToken).isEqualTo(3) + assertThat(buffer.takeBatch().sessionToken).isEqualTo(4) + } + + @Test + fun `clear invalidates stale view model session tokens`() = + runTest { + val viewModel = + BuildOutputViewModel(ApplicationProvider.getApplicationContext()) + viewModel.clear() + val staleToken = viewModel.currentSessionToken + + assertThat(viewModel.append("old\n", staleToken)).isTrue() + viewModel.clear() + + assertThat(viewModel.append("stale\n", staleToken)).isFalse() + assertThat(viewModel.getFullContent()).isEmpty() + } + + @Test + fun `window refresh uses hysteresis after reaching the editor limit`() { + val batchChars = 32 * 1024 + var sourceChars = BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS + var refreshCount = 0 + + repeat(6) { + if (BuildOutputViewModel.wouldExceedEditorWindow(sourceChars, batchChars)) { + refreshCount++ + sourceChars = + BuildOutputViewModel.editorSourceCharsAfterRefresh( + BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS, + ) + } else { + sourceChars += batchChars + } + } + + assertThat(refreshCount).isEqualTo(2) + } + + @Test + fun `filtered editor refreshes when hidden source output advances the window`() { + val oldMatch = "old match\n" + val hidden = "x".repeat(BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS) + var session = oldMatch + var sourceChars = session.length + var visible = oldMatch + + session += hidden + if (BuildOutputViewModel.wouldExceedEditorWindow(sourceChars, hidden.length)) { + val window = session.takeLast(BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS) + visible = + BuildOutputViewModel.filterLines( + window, + query = "match", + showTimestamps = true, + showDeltas = true, + ) + sourceChars = BuildOutputViewModel.editorSourceCharsAfterRefresh(window.length) + } + + assertThat(visible).isEmpty() + assertThat(sourceChars).isLessThan(BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS) + } + + @Test + fun `refreshed tail applies filtering and timing visibility`() { + val prefix = BuildOutputViewModel.formatLinePrefix(1_722_000_000_000L, 42L) + val tail = prefix + "ignored\n" + prefix + "newest output\n" + + val visible = + BuildOutputViewModel.filterLines( + tail, + query = "newest", + showTimestamps = false, + showDeltas = false, + ) + + assertThat(visible).isEqualTo("newest output\n") + } +}