Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
baa9523
style: spotless reformat, no functional change
hal-eisen-adfa Oct 10, 2026
02e1f0a
ADFA-5340: Never block the main thread on terminal input
hal-eisen-adfa Oct 10, 2026
574272a
ADFA-5340: Close indexing services off the main thread
hal-eisen-adfa Oct 10, 2026
3a273d5
ADFA-5340: Move Kotlin LSP and log-sender teardown off the main thread
hal-eisen-adfa Oct 10, 2026
18397ea
ADFA-5340: Clear output by replacing the editor content
hal-eisen-adfa Oct 10, 2026
0789d12
ADFA-5340: Bound the build output editor
hal-eisen-adfa Oct 10, 2026
1a28185
ADFA-5340: Cap build output batches
hal-eisen-adfa Oct 10, 2026
a77448a
ADFA-5340: Trim build output from the editor's own tail
hal-eisen-adfa Oct 10, 2026
447ac31
ADFA-5340: Drop unsent terminal input when the session ends
hal-eisen-adfa Oct 10, 2026
d7fbc27
ADFA-5340: Keep a failed Kotlin compiler close from breaking later se…
hal-eisen-adfa Oct 10, 2026
425c1a0
ADFA-5340: Bound the wait for a previous indexing close
hal-eisen-adfa Oct 10, 2026
a533db1
ADFA-5340: Disconnect log senders on their own threads
hal-eisen-adfa Oct 10, 2026
4846c6e
ADFA-5340: Refresh output search after clearing
hal-eisen-adfa Oct 10, 2026
8fd8f53
ADFA-5340: Append build output in bounded pieces
hal-eisen-adfa Oct 10, 2026
a57f5a8
ADFA-5340: Skip setup instead of reusing indexes a hung close still h…
hal-eisen-adfa Oct 10, 2026
09ada19
ADFA-5340: Skip Kotlin setup when interrupted awaiting the previous c…
hal-eisen-adfa Oct 10, 2026
df48020
style: spotless reformat, no functional change
hal-eisen-adfa Oct 11, 2026
84c629a
ADFA-5340: Walk log senders sequentially when snapshotting
hal-eisen-adfa Oct 11, 2026
5359cef
ADFA-5340: Keep a final line longer than the output window
hal-eisen-adfa Oct 11, 2026
b7b2892
Merge branch 'stage' into bugfix/ADFA-5340
hal-eisen-adfa Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.yield

class BuildOutputFragment :
NonEditableEditorFragment(),
Expand All @@ -56,6 +57,7 @@ class BuildOutputFragment :

companion object {
private const val LAYOUT_TIMEOUT_MS = 2000L
private const val MAX_BATCH_CHARS = 64 * 1024
}

override val currentEditor: IDEEditor? get() = editor
Expand Down Expand Up @@ -361,6 +363,8 @@ class BuildOutputFragment :
noMatchTracker.reset()
buildOutputViewModel.clear()
super.clearOutput()
// super replaces the Content; drop search results that point into the old one.
onContentReplaced()
// super sets the empty state unconditionally; re-apply the invariant so an
// active filter keeps the content layout (and the filter bar) reachable.
updateEmptyState(isSourceEmpty = true, isFilterActive = isFilterActive)
Expand Down Expand Up @@ -394,12 +398,17 @@ class BuildOutputFragment :
* into a single memory operation to avoid saturating the UI queue.
*/
private fun ReceiveChannel<String>.drainTo(buffer: StringBuilder) {
// Bounded: the main thread word-wraps each batch in one appendBatch call, so draining a
// flooding build in one go stalled it into an ANR (WordwrapLayout.afterInsert). The rest
// stays queued for the next batch.
if (buffer.length >= MAX_BATCH_CHARS) return
var result = tryReceive()
while (result.isSuccess) {
val line = result.getOrNull()
if (!line.isNullOrEmpty()) {
buffer.append(line.ensureNewline())
}
if (buffer.length >= MAX_BATCH_CHARS) return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
result = tryReceive()
}
}
Expand Down Expand Up @@ -471,7 +480,7 @@ class BuildOutputFragment :
if (layoutCompleted != null) {
// clearOutput() or renderFiltered() may have run since the file append.
if (editorGen == editorContentGeneration) {
appendBatch(visibleText)
appendInPieces(visibleText, editorGen)
updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
}
} else {
Expand All @@ -481,7 +490,7 @@ class BuildOutputFragment :
awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) })
editorContentMutex.withLock {
if (editorGen == editorContentGeneration) {
appendBatch(visibleText)
appendInPieces(visibleText, editorGen)
updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
}
}
Expand All @@ -492,4 +501,38 @@ class BuildOutputFragment :
}
}
}

/**
* Appends [text] in pieces of at most [MAX_BATCH_CHARS], yielding the main thread between them:
* the editor word-wraps each piece in one pass, and a single huge output line would otherwise
* stall it. Split after filtering so line-based filters still see whole lines. Stops if
* clearOutput() or renderFiltered() replaces the content, or the view is destroyed, in between.
*/
private suspend fun IDEEditor.appendInPieces(
text: String,
editorGen: Int,
) {
var start = 0
while (start < text.length && editorGen == editorContentGeneration && !isReleased) {
var end = minOf(start + MAX_BATCH_CHARS, text.length)
if (end < text.length && Character.isHighSurrogate(text[end - 1])) end--
appendBatch(text.substring(start, end))
start = end
if (start < text.length) yield()
}
trimToWindow()
}

/**
* Replaces the editor content with its own tail once it outgrows the window. Unbounded content
* made every append, wordwrap pass and clearOutput() on the main thread slower as a build ran on,
* until they ANR'd. Trims the editor's own (already filtered) text rather than re-reading the
* session file, whose writer can lag the editor. Call on the main thread under [editorContentMutex].
*/
private fun IDEEditor.trimToWindow() {
if (isReleased) return
val tail = BuildOutputViewModel.editorTail(text) ?: return
setText(tail)
Comment thread
hal-eisen-adfa marked this conversation as resolved.
onContentReplaced()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ abstract class LogViewFragment<V : LogViewModel> :
_binding?.editor?.setText("")?.also {
// An active filter keeps the content layout (and the filter bar) reachable.
updateEmptyState(isSourceEmpty = true, isFilterActive = isFilterActive)
onContentReplaced()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ public void clearOutput() {
return;
}

// Editing CodeEditor's content is a synchronized operation
editor.getText().delete(0, editor.getText().length());
// Replace the Content rather than delete(0, length): on a large word-wrapped buffer the
// delete walks every row on the main thread (WordwrapLayout.afterDelete) and ANRs.
editor.setText("");
getEmptyStateViewModel().setEmpty(true);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.itsaky.androidide.tasks.executeAsyncProvideError
import org.slf4j.LoggerFactory
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.thread
import kotlin.concurrent.withLock

/**
Expand Down Expand Up @@ -153,12 +154,21 @@ class LogReceiverImpl(

internal fun disconnectAll() {
log.debug("Disconnecting from all senders...")
this.senders.forEach { sender ->
try {
sender.onDisconnect()
disconnectSender(sender.packageName, sender.id)
} catch (e: Exception) {
log.error("Failed to disconnect from sender", e)
// onDisconnect() is a synchronous binder call into the user's app, which may be
// hung; callers run on the main thread (Activity/Service onDestroy). Snapshot now so
// a following close() clearing the registry cannot skip any sender. One daemon thread
// per sender, not the shared common pool: a hung app must not hold up the others or
// starve every other commonPool user.
val toDisconnect = mutableListOf<CachingLogSender>()
this.senders.forEach { toDisconnect.add(it) }
Comment thread
hal-eisen-adfa marked this conversation as resolved.
toDisconnect.forEach { sender ->
thread(isDaemon = true, name = "LogSenderDisconnect") {
try {
sender.onDisconnect()
disconnectSender(sender.packageName, sender.id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (e: Exception) {
log.error("Failed to disconnect from sender", e)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,55 +23,49 @@ import java.util.concurrent.ConcurrentHashMap
* @author Akash Yadav
*/
internal class LogSendersRegistry {
private val senders = ConcurrentHashMap<String, CachingLogSender>()
private val idToPck = ConcurrentHashMap<String, String>()

private val senders = ConcurrentHashMap<String, CachingLogSender>()
private val idToPck = ConcurrentHashMap<String, String>()
val size: Int
get() = this.senders.size

val size: Int
get() = this.senders.size
fun getPendingSenders(): List<CachingLogSender> = this.senders.mapNotNull { (_, sender) -> if (sender.isStarted) null else sender }

fun getPendingSenders(): List<CachingLogSender> {
return this.senders.mapNotNull { (_, sender) -> if (sender.isStarted) null else sender }
}
fun getByPackage(packageName: String): CachingLogSender? = this.senders[packageName]

fun getByPackage(packageName: String): CachingLogSender? {
return this.senders[packageName]
}
fun containsKey(packageOrId: String): Boolean {
if (senders.containsKey(packageOrId)) {
return true
}

fun containsKey(packageOrId: String): Boolean {
if (senders.containsKey(packageOrId)) {
return true
}
return getById(packageOrId) != null
}

return getById(packageOrId) != null
}
fun getById(senderId: String): CachingLogSender? = this.idToPck[senderId]?.let(senders::get)

fun getById(senderId: String): CachingLogSender? {
return this.idToPck[senderId]?.let(senders::get)
}
fun put(sender: CachingLogSender) {
this.senders[sender.packageName] = sender
this.idToPck[sender.id] = sender.packageName
}

fun put(sender: CachingLogSender) {
this.senders[sender.packageName] = sender
this.idToPck[sender.id] = sender.packageName
}
fun remove(packageName: String) {
this.senders.remove(packageName)

fun remove(packageName: String) {
this.senders.remove(packageName)
val iterator = this.idToPck.iterator()
for (entry in iterator) {
if (entry.value == packageName) {
iterator.remove()
}
}
}

val iterator = this.idToPck.iterator()
for (entry in iterator) {
if (entry.value == packageName) {
iterator.remove()
}
}
}
fun clear() {
this.senders.clear()
this.idToPck.clear()
}

fun clear() {
this.senders.clear()
this.idToPck.clear()
}

fun forEach(action: (CachingLogSender) -> Unit) {
this.senders.forEachValue(1, action::invoke)
}
}
/** Runs [action] on the calling thread; forEachValue(1, ...) would fan out to the common pool. */
fun forEach(action: (CachingLogSender) -> Unit) {
this.senders.values.forEach(action)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -339,17 +339,18 @@ class BuildOutputViewModel(
*
* A tail sliced at a character offset begins part-way through a line, and [PREFIX_REGEX] is
* anchored to the start of one, so that fragment keeps the timestamp every other line has
* stripped. Text short enough to survive whole keeps its real first line; a tail holding no
* newline at all is returned as it is, being better than nothing.
* stripped. Text short enough to survive whole keeps its real first line; a tail with no line
* start inside it (no newline, or only the final one) is returned as it is, being better than
* nothing.
*/
internal fun tailFromLineStart(
text: String,
text: CharSequence,
maxChars: Int,
): String {
if (text.length <= maxChars) return text
val tail = text.takeLast(maxChars)
if (text.length <= maxChars) return text.toString()
val tail = text.subSequence(text.length - maxChars, text.length).toString()
val newline = tail.indexOf('\n')
return if (newline == -1) tail else tail.substring(newline + 1)
return if (newline == -1 || newline == tail.lastIndex) tail else tail.substring(newline + 1)
}

/**
Expand Down Expand Up @@ -384,6 +385,18 @@ class BuildOutputViewModel(
internal const val SESSION_FILE_NAME = "build_output_session.txt"
private const val WINDOW_SIZE_CHARS = 512 * 1024

/** Editor length past which the fragment trims back to the [WINDOW_SIZE_CHARS] tail. */
internal const val EDITOR_REWINDOW_CHARS = 2 * WINDOW_SIZE_CHARS

/**
* The last [WINDOW_SIZE_CHARS] of [text], starting at a line boundary, or null while [text]
* is within [EDITOR_REWINDOW_CHARS].
*/
internal fun editorTail(text: CharSequence): String? {
if (text.length <= EDITOR_REWINDOW_CHARS) return null
return tailFromLineStart(text, WINDOW_SIZE_CHARS)
}

/** Max length of [cachedContentSnapshot] to bound memory. */
private const val CACHE_SNAPSHOT_MAX_CHARS = WINDOW_SIZE_CHARS
private val log = org.slf4j.LoggerFactory.getLogger(BuildOutputViewModel::class.java)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.itsaky.androidide.services.log

import com.itsaky.androidide.logsender.ILogSender
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Test

class LogSendersRegistryTest {
// disconnectAll fills a plain list from forEach; a parallel walk would race on it.
@Test
fun `forEach visits every sender on the calling thread`() {
val registry = LogSendersRegistry()
repeat(8) { i ->
val sender =
mockk<ILogSender> {
every { packageName } returns "pkg$i"
every { id } returns "id$i"
}
registry.put(CachingLogSender(sender, port = 0, isStarted = true))
}

repeat(200) {
val threads = mutableSetOf<Thread>()
var visited = 0
registry.forEach {
threads.add(Thread.currentThread())
visited++
}
assertEquals(setOf(Thread.currentThread()), threads)
assertEquals(8, visited)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package com.itsaky.androidide.viewmodel

import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test

class BuildOutputFilterTest {
Expand Down Expand Up @@ -153,4 +154,32 @@ class BuildOutputFilterTest {
nowMs: Long = 1_722_000_000_000L,
stepDeltaMs: Long = 42L,
): String = BuildOutputViewModel.formatLinePrefix(nowMs, stepDeltaMs)

@Test
fun `editorTail is null within the limit`() {
assertEquals(null, BuildOutputViewModel.editorTail("x".repeat(BuildOutputViewModel.EDITOR_REWINDOW_CHARS)))
}

@Test
fun `editorTail keeps whole lines from the end`() {
val line = "0123456789".repeat(9) + "\n" // 91 chars, never divides the window evenly
val text = line.repeat(BuildOutputViewModel.EDITOR_REWINDOW_CHARS / line.length + 1) + "last\n"
val tail = BuildOutputViewModel.editorTail(text)!!
assertTrue(tail.length < BuildOutputViewModel.EDITOR_REWINDOW_CHARS / 2)
assertTrue(tail.startsWith(line))
assertTrue(tail.endsWith(line + "last\n"))
}

@Test
fun `editorTail keeps a final line longer than the window`() {
val text = "a\n" + "x".repeat(BuildOutputViewModel.EDITOR_REWINDOW_CHARS) + "\n"
val tail = BuildOutputViewModel.editorTail(text)!!
assertEquals(BuildOutputViewModel.EDITOR_REWINDOW_CHARS / 2, tail.length)
assertTrue(tail.endsWith("x\n"))
}

@Test
fun `tailFromLineStart keeps a final line longer than the window`() {
assertEquals("xxxx\n", BuildOutputViewModel.tailFromLineStart("a\n" + "x".repeat(10) + "\n", 5))
}
}
Loading
Loading