git.iko.soy/opencode-android
on master help sign in
Fix markdown streaming throttle starvation and server edit state races

- MarkdownText: keyed LaunchedEffect cancelled the throttle delay on every token; under fast streaming the delay never completed, starving the render. Replaced with conflated snapshotFlow + collect so the delay always runs. - CodeWithCopy: remember(model) re-executed extractCode on every recomposition because MarkdownComponentModel is unstable; key on content + node offsets. - ServerEditViewModel: non-atomic StateFlow read-modify-write lost concurrent edits; onFailure lambda shadowing passed the state object instead of the throwable to friendlyError. Switched to atomic update {} with explicit param. - Added tests for code extraction edge cases and server save error path.

commit598c3067538a31dd8305f671f734a2320b5aad76 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 12:49 (3 months ago) parentf2ff2eb
5 files changed+145−21
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+50−17 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+5−4 app/src/test/java/soy/iko/opencode/ui/components/ExtractCodeTextTest.kt+56 app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+30 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+4
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+50−17
@@ -26,7 +26,9 @@ import androidx.compose.runtime.LaunchedEffect
2626import androidx.compose.runtime.getValue
2727import androidx.compose.runtime.mutableStateOf
2828import androidx.compose.runtime.remember
29+import androidx.compose.runtime.rememberUpdatedState
2930import androidx.compose.runtime.setValue
31+import androidx.compose.runtime.snapshotFlow
3032import androidx.compose.ui.Alignment
3133import androidx.compose.ui.Modifier
3234import androidx.compose.ui.draw.clip
@@ -35,6 +37,7 @@ import androidx.compose.ui.semantics.Role
3537import androidx.compose.ui.text.TextStyle
3638import androidx.compose.ui.text.font.FontFamily
3739import androidx.compose.ui.unit.dp
40+import kotlinx.coroutines.flow.conflate
3841import kotlinx.coroutines.delay
3942import com.mikepenz.markdown.compose.components.MarkdownComponentModel
4043import com.mikepenz.markdown.compose.components.markdownComponents
@@ -55,6 +58,14 @@ import soy.iko.opencode.data.network.NetworkConfig
5558 * whenever the content string changes). To avoid O(n²) work during long responses, the
5659 * rendered content is throttled — the latest [markdown] is committed to the renderer at
5760 * most once per frame (~16ms), so a burst of tokens coalesces into a single re-parse.
61+ *
62+ * The throttle uses a single long-lived `LaunchedEffect(Unit)` that observes the markdown
63+ * parameter via `rememberUpdatedState` + `snapshotFlow` + `conflate` + `collect`. A keyed
64+ * effect (`LaunchedEffect(markdown)`) cancels and restarts on every token; if tokens arrive
65+ * faster than the throttle delay, the in-flight `delay` is cancelled before it completes,
66+ * starving the render and showing stale content until the stream pauses. The conflated
67+ * `collect` lets the delay run to completion, then picks up the most recent buffered value,
68+ * so rendering always progresses even under continuous fast streaming.
5869 */
5970@OptIn(ExperimentalFoundationApi::class)
6071@Composable
@@ -64,21 +75,29 @@ fun MarkdownText(
6475 style: TextStyle = MaterialTheme.typography.bodyLarge,
6576) {
6677 val context = LocalContext.current
67− // Throttle the rendered content so a rapid burst of streaming tokens coalesces
68− // into a single markdown re-parse per frame instead of one per token. The delay
69− // is only applied when the content is growing incrementally (streaming) — a
70− // content switch or initial render proceeds immediately.
78+ // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
79+ val markdownState = rememberUpdatedState(markdown)
7180 // Keyed to a content-prefix so switching to a different message resets
7281 // immediately instead of showing the old message for one frame.
7382 var renderedContent by remember(markdown.take(32)) { mutableStateOf(markdown) }
74− LaunchedEffect(markdown) {
75− if (renderedContent.isNotEmpty() &&
76− markdown.startsWith(renderedContent) &&
77− markdown != renderedContent
78− ) {
79− delay(NetworkConfig.streamingThrottleMs)
80− }
81− renderedContent = markdown
83+ // conflate() coalesces a burst of token updates into a single emission so the
84+ // collector sees only the latest value after the previous delay completes. Plain
85+ // collect (not collectLatest) is critical: collectLatest would cancel the delay
86+ // on every new value, reintroducing the same starvation the keyed effect had.
87+ LaunchedEffect(markdown.take(32)) {
88+ snapshotFlow { markdownState.value }
89+ .conflate()
90+ .collect { md ->
91+ // Only throttle when the content is growing incrementally (streaming):
92+ // a content switch or initial render proceeds immediately.
93+ if (renderedContent.isNotEmpty() &&
94+ md.startsWith(renderedContent) &&
95+ md != renderedContent
96+ ) {
97+ delay(NetworkConfig.streamingThrottleMs)
98+ }
99+ renderedContent = md
100+ }
82101 }
83102 Markdown(
84103 content = renderedContent,
@@ -101,7 +120,13 @@ fun MarkdownText(
101120@Composable
102121private fun CodeWithCopy(model: MarkdownComponentModel) {
103122 val context = LocalContext.current
104− val code = androidx.compose.runtime.remember(model) { extractCode(model.content, model.node) }
123+ // Key on the content string + the AST node's offset range (stable ints) instead of
124+ // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
125+ // ASTNode), so remembering on it re-executes extractCode on every recomposition.
126+ val node = model.node
127+ val code = remember(model.content, node.startOffset, node.endOffset) {
128+ extractCode(model.content, node)
129+ }
105130 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
106131 Box(
107132 modifier = Modifier
@@ -143,10 +168,18 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
143168 * Extract the raw code text from a markdown code node, stripping fence markers
144169 * (```/~~~ and the language tag) so only the code content is copied.
145170 */
146−private fun extractCode(content: String, node: ASTNode): String {
147− val raw = node.getTextInNode(content).toString()
148− val isOpenFence = node.type == MarkdownElementTypes.CODE_FENCE
149− if (!isOpenFence) return raw.trimIndent()
171+private fun extractCode(content: String, node: ASTNode): String =
172+ extractCodeText(node.getTextInNode(content).toString(), node.type == MarkdownElementTypes.CODE_FENCE)
173+
174+/**
175+ * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
176+ * only the code content is returned. Extracted as a pure function for testability.
177+ *
178+ * @param raw the raw text of the code node (including fence markers for fenced blocks)
179+ * @param isFenced true for CODE_FENCE nodes, false for indented CODE_BLOCK nodes
180+ */
181+internal fun extractCodeText(raw: String, isFenced: Boolean): String {
182+ if (!isFenced) return raw.trimIndent()
150183 val lines = raw.lines()
151184 val body = lines.drop(1).toMutableList()
152185 if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+5−4
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
1010import kotlinx.coroutines.flow.StateFlow
1111import kotlinx.coroutines.flow.asStateFlow
1212import kotlinx.coroutines.flow.first
13+import kotlinx.coroutines.flow.update
1314import kotlinx.coroutines.launch
1415import kotlinx.coroutines.withTimeoutOrNull
1516import soy.iko.opencode.R
@@ -76,18 +77,18 @@ class ServerEditViewModel(
7677 )
7778 return@launch
7879 }
79− _state.value = _state.value.copy(loaded = true)
80+ _state.update { it.copy(loaded = true) }
8081 }
8182 }
8283
8384 fun update(transform: (ServerEditState) -> ServerEditState) {
84− _state.value = transform(_state.value)
85+ _state.update(transform)
8586 }
8687
8788 fun save(onDone: () -> Unit) {
8889 val s = _state.value
8990 if (!s.canSave || s.saving) return
90− _state.value = s.copy(saving = true)
91+ _state.update { it.copy(saving = true) }
9192 viewModelScope.launch {
9293 val result = runCatchingCancellable {
9394 val existingLastUsed = if (s.id != null) {
@@ -108,7 +109,7 @@ class ServerEditViewModel(
108109 )
109110 }
110111 result.onSuccess { onDone() }
111− .onFailure { _state.value = _state.value.copy(error = container.friendlyError(it), saving = false) }
112+ .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
112113 }
113114 }
114115}
app/src/test/java/soy/iko/opencode/ui/components/ExtractCodeTextTest.ktnew file+56
@@ -0,0 +1,56 @@
1+package soy.iko.opencode.ui.components
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Test
5+
6+class ExtractCodeTextTest {
7+
8+ @Test
9+ fun fencedCodeStripsOpeningAndClosingFence() {
10+ val raw = "```kotlin\nfun main() {}\n```"
11+ assertEquals("fun main() {}", extractCodeText(raw, isFenced = true))
12+ }
13+
14+ @Test
15+ fun fencedCodeWithTildeFenceStripped() {
16+ val raw = "~~~python\nprint('hi')\n~~~"
17+ assertEquals("print('hi')", extractCodeText(raw, isFenced = true))
18+ }
19+
20+ @Test
21+ fun fencedCodePreservesBlankLines() {
22+ val raw = "```text\nline1\n\nline3\n```"
23+ assertEquals("line1\n\nline3", extractCodeText(raw, isFenced = true))
24+ }
25+
26+ @Test
27+ fun fencedCodeWithoutClosingFence() {
28+ // A streaming code block may not have a closing fence yet.
29+ val raw = "```kotlin\nfun main() {}"
30+ assertEquals("fun main() {}", extractCodeText(raw, isFenced = true))
31+ }
32+
33+ @Test
34+ fun fencedEmptyBody() {
35+ val raw = "```kotlin\n```"
36+ assertEquals("", extractCodeText(raw, isFenced = true))
37+ }
38+
39+ @Test
40+ fun fencedMultilineCode() {
41+ val raw = "```js\nconst a = 1;\nconst b = 2;\n```"
42+ assertEquals("const a = 1;\nconst b = 2;", extractCodeText(raw, isFenced = true))
43+ }
44+
45+ @Test
46+ fun indentedCodeTrimIndent() {
47+ val raw = " fun main() {\n println()\n }"
48+ assertEquals("fun main() {\n println()\n}", extractCodeText(raw, isFenced = false))
49+ }
50+
51+ @Test
52+ fun indentedCodeNoIndent() {
53+ val raw = "code without indent"
54+ assertEquals("code without indent", extractCodeText(raw, isFenced = false))
55+ }
56+}
app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+30
@@ -227,4 +227,34 @@ class ServerEditViewModelTest {
227227 assertNull(saved.username)
228228 assertNull(saved.password)
229229 }
230+
231+ @Test
232+ fun save_failureSetsErrorAndClearsSaving() = testScope.runTest {
233+ val container = FakeAppContainer()
234+ container.fakeProfileStore.saveException = RuntimeException("disk full")
235+ val vm = makeVm(container)
236+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
237+ var doneCalled = false
238+ vm.save { doneCalled = true }
239+ testScheduler.advanceUntilIdle()
240+ assertFalse(doneCalled)
241+ assertFalse(vm.state.value.saving)
242+ // The error must come from the exception, not from a clobbered ServerEditState.
243+ assertNotNull(vm.state.value.error)
244+ assertTrue(vm.state.value.error!!.contains("disk full"))
245+ }
246+
247+ @Test
248+ fun save_failureDoesNotClobberUserEdits() = testScope.runTest {
249+ val container = FakeAppContainer()
250+ container.fakeProfileStore.saveException = RuntimeException("disk full")
251+ val vm = makeVm(container)
252+ vm.update { it.copy(baseUrl = "http://localhost:3000", label = "Original") }
253+ vm.save { }
254+ // Simulate the user editing the label while the save coroutine is in-flight.
255+ vm.update { it.copy(label = "Edited") }
256+ testScheduler.advanceUntilIdle()
257+ // The error path must not overwrite the user's edit.
258+ assertEquals("Edited", vm.state.value.label)
259+ }
230260}
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+4
@@ -80,9 +80,13 @@ class FakeProfileStore : ProfileStore() {
8080 var savedProfile: ServerProfile? = null
8181 private set
8282
83+ /** When non-null, [save] throws this exception instead of persisting. */
84+ var saveException: Exception? = null
85+
8386 override suspend fun resolve(profile: ServerProfile): ServerProfile = profile
8487
8588 override suspend fun save(profile: ServerProfile) {
89+ saveException?.let { throw it }
8690 savedProfile = profile
8791 val current = _profiles.value.toMutableList()
8892 current.removeAll { it.id == profile.id }