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..b4cadb2cfe --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputBuffer.kt @@ -0,0 +1,160 @@ +/* + * 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, + val omittedLines: Long, + ) + + 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 + var omittedLines = 0L + while (entries.isNotEmpty()) { + val entry = entries.first() + if (entry.sessionToken != sessionToken) break + if (entry is Entry.Text && batch.isNotEmpty() && batch.length + entry.value.length > maxBatchChars) break + + entries.removeFirst() + when (entry) { + is Entry.Text -> { + batch.append(entry.value) + retainedChars -= entry.value.length + sourceChars = saturatedAdd(sourceChars, entry.value.length) + } + is Entry.Omission -> { + omittedLines += entry.lineCount + sourceChars = saturatedAdd(sourceChars, entry.sourceChars) + } + } + } + if (entries.isNotEmpty()) available.trySend(Unit) + return Batch(batch.toString(), sessionToken, sourceChars, omittedLines) + } + + 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() + + 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..146e6f4d93 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,15 @@ class BuildOutputFragment : setupSearchLayout() viewLifecycleOwner.lifecycleScope.launch { - launch { restoreWindowFromViewModel() } - launch(Dispatchers.Default) { processLogs() } launch { - val content = buildOutputViewModel.getFullContent() - buildOutputViewModel.setCachedSnapshot(content) + try { + restoreWindowFromViewModel() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.error("Failed to restore build output to the editor", e) + } + withContext(Dispatchers.Default) { processLogs() } } launch { combine( @@ -121,15 +122,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 +296,81 @@ 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) { + editorContentMutex.withLock { + if (!isRestoreCurrent()) return@withLock + updateEmptyState(isSourceEmpty = isSourceEmpty, isFilterActive = isFilterActive) + noMatchTracker.prime(isFilteredEmpty) + if (!isSourceEmpty && isFilteredEmpty) { + 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) { - 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) - } + if (content.isEmpty()) return@withContext + withContext(Dispatchers.Main) { + val editor = this@BuildOutputFragment.editor ?: return@withContext + val layoutCompleted = + withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { + editor.awaitLayout { + if (isRestoreCurrent()) { + 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 { + // Replace content without waiting indefinitely for an offscreen editor's layout. + editorContentMutex.withLock { + if (isRestoreCurrent()) { + val contentReplaced = + runCatching { editor.setText(content) } + .onFailure { log.error("Failed to restore build output before editor layout", it) } + .isSuccess + if (contentReplaced) { + editorSourceChars = + BuildOutputViewModel.editorSourceCharsAfterRefresh(window.length) + updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) + onContentReplaced() } } } - job.join() + } } } - } override fun onDestroyView() { searchLayout = null filterBar = null + editorContentGeneration++ + editorSourceChars = 0 editor?.release() super.onDestroyView() } @@ -351,13 +380,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 +401,170 @@ class BuildOutputFragment : } fun appendOutput(output: String?) { - if (!output.isNullOrEmpty()) { - logChannel.trySend(output) + if (!isAdded || activity == null) return + 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.omittedLines, + 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( + internal suspend fun flushToEditor( text: String, - sessionGen: Int, + sourceChars: Int, + omittedLines: Long, + 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 + // A filter render or clear can make this snapshot stale; the generation check before applying + // the batch makes that harmless without adding a Main-thread dispatch to every batch. + 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) { - updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) - if (visibleText.isEmpty()) { - return@withContext - } - 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) - } - } + withContext(Dispatchers.Main) { + val editor = editor ?: return@withContext + val needsAppend = refreshedWindow == null && (visibleText.isNotEmpty() || omittedLines > 0) + if (needsAppend) { + val layoutCompleted = + withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { + editor.awaitLayout { + if (editorGen == editorContentGeneration) { + updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) } } } + // The batch is already persisted and will be recovered by the next window snapshot. + if (layoutCompleted == null) return@withContext + } + editorContentMutex.withLock { + if ( + editorGen != editorContentGeneration || + !buildOutputViewModel.isCurrentSession(sessionToken) + ) { + return@withLock + } + updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) + if (refreshedWindow != null) { + editorContentGeneration++ + editor.setText(refreshedWindow.first) + editorSourceChars = + BuildOutputViewModel.editorSourceCharsAfterRefresh(refreshedWindow.second) + onContentReplaced() + return@withLock + } + val omissionMarker = + if (omittedLines > 0) formatOmissionMarker(omittedLines) else "" + val textToAppend = omissionMarker + visibleText + if (textToAppend.isEmpty()) { + editorSourceChars += sourceChars + return@withLock + } + if (editor.appendBatchIfReady(textToAppend)) { + editorSourceChars += sourceChars } } } } + + private fun formatOmissionMarker(lineCount: Long): String { + val quantity = lineCount.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + return resources + .getQuantityString(R.plurals.msg_build_output_lines_omitted, quantity, lineCount) + .plus('\n') + } + + 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..a3d7ce47c2 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt @@ -46,6 +46,19 @@ class BuildOutputViewModel( application: Application, ) : AndroidViewModel(application) { private val lock = ReentrantLock() + private val cachedContentSnapshot = StringBuilder() + // Reused until clear/onCleared so noisy builds do not open and close the file per line. + private var sessionOutputStream: FileOutputStream? = null + + @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. @@ -63,24 +76,15 @@ class BuildOutputViewModel( val showLineNumbers = MutableStateFlow(EditorPreferences.outputLineNumbers) /** - * Thread-safe snapshot of content for synchronous [getShareableContent] without blocking. - * Updated on [append] and [clear]; primed on restore via [setCachedSnapshot]. - * Capped at [CACHE_SNAPSHOT_MAX_CHARS] to bound memory. + * Returns the thread-safe cached snapshot for synchronous share/copy. Updated on [append] and + * [clear], primed on restore via [setCachedSnapshot], and capped at + * [CACHE_SNAPSHOT_MAX_CHARS]. */ - @Volatile - private var cachedContentSnapshot: String = "" - - /** Returns the current cached snapshot for share/copy (non-blocking). */ - fun getCachedContentSnapshot(): String = cachedContentSnapshot + fun getCachedContentSnapshot(): String = lock.withLock { cachedContentSnapshot.toString() } /** Updates the cached snapshot (e.g. after loading full content on restore). Capped to [CACHE_SNAPSHOT_MAX_CHARS]. */ fun setCachedSnapshot(content: String) { - cachedContentSnapshot = - if (content.length <= CACHE_SNAPSHOT_MAX_CHARS) { - content - } else { - content.takeLast(CACHE_SNAPSHOT_MAX_CHARS) - } + lock.withLock { replaceCachedSnapshot(content) } } private val sessionFile: File @@ -88,32 +92,41 @@ 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) + val output = + sessionOutputStream + ?: FileOutputStream(sessionFile, true).also { + sessionOutputStream = it + } + output.write(text.toByteArray(StandardCharsets.UTF_8)) + appendCachedSnapshot(text) + true } catch (e: Exception) { + closeSessionOutputStream() 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,7 +172,9 @@ class BuildOutputViewModel( */ fun clear() { lock.withLock { - cachedContentSnapshot = "" + sessionGeneration++ + closeSessionOutputStream() + cachedContentSnapshot.setLength(0) try { if (sessionFile.exists()) { sessionFile.delete() @@ -170,6 +185,37 @@ class BuildOutputViewModel( } } + override fun onCleared() { + lock.withLock { closeSessionOutputStream() } + super.onCleared() + } + + private fun appendCachedSnapshot(text: String) { + if (text.length >= CACHE_SNAPSHOT_MAX_CHARS) { + replaceCachedSnapshot(text) + return + } + val overflow = cachedContentSnapshot.length + text.length - CACHE_SNAPSHOT_MAX_CHARS + if (overflow > 0) cachedContentSnapshot.delete(0, overflow) + cachedContentSnapshot.append(text) + } + + private fun replaceCachedSnapshot(content: String) { + cachedContentSnapshot.setLength(0) + val start = (content.length - CACHE_SNAPSHOT_MAX_CHARS).coerceAtLeast(0) + cachedContentSnapshot.append(content, start, content.length) + } + + private fun closeSessionOutputStream() { + try { + sessionOutputStream?.close() + } catch (e: Exception) { + log.error("Failed to close build output session file", e) + } finally { + sessionOutputStream = null + } + } + private fun readTailFromFile( file: File, maxChars: Int, @@ -194,6 +240,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 +320,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..ca90f1070e --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputBufferTest.kt @@ -0,0 +1,225 @@ +/* + * 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("three\nfour\n") + assertThat(batch.omittedLines).isEqualTo(2) + 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("3456789\n") + assertThat(batch.omittedLines).isEqualTo(1) + 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 `repeated appends retain the complete editor window in the bounded cache`() = + runTest { + val viewModel = BuildOutputViewModel(ApplicationProvider.getApplicationContext()) + viewModel.clear() + val first = "a".repeat(384 * 1024) + val second = "b".repeat(256 * 1024) + val fullOutput = first + second + assertThat(viewModel.append(first, viewModel.currentSessionToken)).isTrue() + assertThat(viewModel.append(second, viewModel.currentSessionToken)).isTrue() + assertThat(viewModel.getCachedContentSnapshot()) + .isEqualTo(fullOutput.takeLast(BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS)) + assertThat(viewModel.getWindowForEditor()) + .isEqualTo(fullOutput.takeLast(BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS)) + assertThat(viewModel.getFullContent()).isEqualTo(fullOutput) + viewModel.clear() + } + + @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") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentDetachedTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentDetachedTest.kt index 9f0d3e0a70..43a1e501b8 100644 --- a/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentDetachedTest.kt +++ b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentDetachedTest.kt @@ -25,13 +25,14 @@ import org.robolectric.RobolectricTestRunner /** * Regression test for ADFA-3472. * - * [BuildOutputFragment.clearOutput] and [BuildOutputFragment.getShareableContent] touch + * [BuildOutputFragment.clearOutput], [BuildOutputFragment.getShareableContent], and + * [BuildOutputFragment.appendOutput] touch * `buildOutputViewModel`, which is created via `by activityViewModels()`. Forcing that lazy * delegate while the fragment is detached calls `requireActivity()`, which throws an * [IllegalStateException] ("not attached to an activity"). The run-tasks dialog / config-change * path can invoke these methods on a detached fragment, crashing the app (Sentry ADFA-3472). * - * The fix guards both methods with `if (!isAdded || activity == null) return`. These tests + * The fix guards all three methods with `if (!isAdded || activity == null) return`. These tests * assert that a detached fragment does NOT crash and returns the safe no-op values. * * Mutation-mindset: on the pre-fix code (no guard), both calls force the activityViewModels @@ -39,34 +40,43 @@ import org.robolectric.RobolectricTestRunner */ @RunWith(RobolectricTestRunner::class) class BuildOutputFragmentDetachedTest { + /** Verifies clearOutput() is a safe no-op on a detached fragment instead of crashing. */ + @Test + fun `clearOutput on a detached fragment does not crash`() { + // A freshly-constructed fragment that was never added to an activity is "detached": + // isAdded == false and activity == null, exactly the run-tasks / config-change state + // in which the Sentry crash was observed. + val fragment = BuildOutputFragment() - /** Verifies clearOutput() is a safe no-op on a detached fragment instead of crashing. */ - @Test - fun `clearOutput on a detached fragment does not crash`() { - // A freshly-constructed fragment that was never added to an activity is "detached": - // isAdded == false and activity == null, exactly the run-tasks / config-change state - // in which the Sentry crash was observed. - val fragment = BuildOutputFragment() + assertThat(fragment.isAdded).isFalse() - assertThat(fragment.isAdded).isFalse() + // Pre-fix: this forces the `by activityViewModels()` delegate, which calls + // requireActivity() on a detached fragment and throws IllegalStateException. + // Post-fix: the guard returns early, no exception. + fragment.clearOutput() + } - // Pre-fix: this forces the `by activityViewModels()` delegate, which calls - // requireActivity() on a detached fragment and throws IllegalStateException. - // Post-fix: the guard returns early, no exception. - fragment.clearOutput() - } + /** Verifies getShareableContent() returns an empty string on a detached fragment instead of crashing. */ + @Test + fun `getShareableContent on a detached fragment returns empty without crashing`() { + val fragment = BuildOutputFragment() - /** Verifies getShareableContent() returns an empty string on a detached fragment instead of crashing. */ - @Test - fun `getShareableContent on a detached fragment returns empty without crashing`() { - val fragment = BuildOutputFragment() + assertThat(fragment.isAdded).isFalse() - assertThat(fragment.isAdded).isFalse() + // Pre-fix: forces the activityViewModels delegate -> requireActivity() -> ISE. + // Post-fix: guard returns "" without touching the view model. + val content = fragment.getShareableContent() - // Pre-fix: forces the activityViewModels delegate -> requireActivity() -> ISE. - // Post-fix: guard returns "" without touching the view model. - val content = fragment.getShareableContent() + assertThat(content).isEmpty() + } - assertThat(content).isEmpty() - } + /** Verifies appendOutput() is a safe no-op on a detached fragment instead of crashing. */ + @Test + fun `appendOutput on a detached fragment does not crash`() { + val fragment = BuildOutputFragment() + + assertThat(fragment.isAdded).isFalse() + + fragment.appendOutput("build output") + } } diff --git a/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentTest.kt new file mode 100644 index 0000000000..c7cf77898e --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentTest.kt @@ -0,0 +1,87 @@ +/* + * 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.view.View +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModelProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.R +import com.itsaky.androidide.app.BaseApplication +import com.itsaky.androidide.viewmodel.BuildOutputViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(application = BuildOutputFragmentTest.TestApp::class) +class BuildOutputFragmentTest { + open class TestApp : BaseApplication() + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `offscreen batch times out without blocking later editor work`() = + runTest(testDispatcher) { + val controller = Robolectric.buildActivity(FragmentActivity::class.java) + controller.get().setTheme(R.style.Theme_AndroidIDE) + val activity = controller.setup().get() + try { + val fragment = BuildOutputFragment() + activity.supportFragmentManager + .beginTransaction() + .add(android.R.id.content, fragment) + .commitNow() + testDispatcher.scheduler.advanceUntilIdle() + + val editor = checkNotNull(fragment.editor) + editor.visibility = View.GONE + editor.layout(0, 0, 0, 0) + val viewModel = ViewModelProvider(activity)[BuildOutputViewModel::class.java] + val sessionToken = viewModel.currentSessionToken + + fragment.flushToEditor("first\n", 6, 0, sessionToken, 0) + fragment.flushToEditor("second\n", 7, 0, sessionToken, 0) + + assertThat(editor.text.toString()).isEmpty() + } finally { + controller.destroy() + } + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 7ff3bc7f18..f215d7e439 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -687,6 +687,10 @@ Open a file to show its diagnostic results Filter lines No matching log entries found. + + [%1$d build output line omitted] + [%1$d build output lines omitted] + No search matches found. Filter Search