git.iko.soy/opencode-android
on master help sign in
Fix reconnect concurrent-call leak, profile lastUsed clobber on save timeout, silent server-switch restore failure, and complete stub refresh failure test
d4cde6aiko
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+10−1
@@ -142,12 +142,21 @@ open class ProfileStore private constructor(
142 runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
142 runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
143 .getOrDefault(emptyList())
143 .getOrDefault(emptyList())
144 } ?: emptyList()
144 } ?: emptyList()
145+ // If the incoming profile has lastUsed=0 (e.g. ServerEditViewModel.save()
146+ // couldn't read the existing value due to a DataStore timeout), preserve
147+ // the existing nonzero lastUsed so saving an edit doesn't reset the
148+ // profile's sort position in the server list.
149+ val preservedLastUsed = if (profile.lastUsed == 0L) {
150+ current.firstOrNull { it.id == profile.id }?.lastUsed ?: 0L
151+ } else {
152+ profile.lastUsed
153+ }
145 val stored = StoredProfile(
154 val stored = StoredProfile(
146 id = profile.id,
155 id = profile.id,
147 label = profile.label,
156 label = profile.label,
148 baseUrl = profile.baseUrl,
157 baseUrl = profile.baseUrl,
149 username = profile.username?.takeIf { it.isNotBlank() },
158 username = profile.username?.takeIf { it.isNotBlank() },
150− lastUsed = profile.lastUsed,
159+ lastUsed = preservedLastUsed,
151 )
160 )
152 val updated = current.filterNot { it.id == profile.id } + stored
161 val updated = current.filterNot { it.id == profile.id } + stored
153 prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
162 prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+17−12
@@ -431,19 +431,24 @@ class ChatViewModel(
431 /** Reconnect to the most recently used server profile (used when the connection is gone). */
431 /** Reconnect to the most recently used server profile (used when the connection is gone). */
432 fun reconnect() {
432 fun reconnect() {
433 if (container.activeConnection.value != null) return
433 if (container.activeConnection.value != null) return
434+ // Guard against concurrent reconnect calls: the user can tap the button
435+ // multiple times while _reconnecting is true, which would launch parallel
436+ // connect() coroutines that race to set the active connection, leaking the
437+ // intermediate connections.
438+ if (!_reconnecting.compareAndSet(false, true)) return
434 viewModelScope.launch {
439 viewModelScope.launch {
435− _reconnecting.value = true
440+ try {
436− val recent = runCatchingCancellable { container.profileStore.profiles.first() }
441+ val recent = runCatchingCancellable { container.profileStore.profiles.first() }
437− .getOrDefault(emptyList())
442+ .getOrDefault(emptyList())
438− .firstOrNull() ?: run { _reconnecting.value = false; return@launch }
443+ .firstOrNull() ?: return@launch
439− runCatchingCancellable {
444+ runCatchingCancellable {
440− val conn = container.connect(recent)
445+ val conn = container.connect(recent)
441− conn.api.ping()
446+ conn.api.ping()
442− }.onSuccess {
443− _reconnecting.value = false
444− }.onFailure {
447+ }.onFailure {
445− container.disconnect()
448+ container.disconnect()
446− _error.value = container.friendlyError(it)
449+ _error.value = container.friendlyError(it)
450+ }
451+ } finally {
447 _reconnecting.value = false
452 _reconnecting.value = false
448 }
453 }
449 }
454 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+6
@@ -91,6 +91,12 @@ class ServerEditViewModel(
91 _state.update { it.copy(saving = true) }
91 _state.update { it.copy(saving = true) }
92 viewModelScope.launch {
92 viewModelScope.launch {
93 val result = runCatchingCancellable {
93 val result = runCatchingCancellable {
94+ // Preserve the existing lastUsed timestamp so saving an edit doesn't
95+ // reset the profile's sort position (profiles are sorted descending
96+ // by lastUsed). connect() is responsible for updating lastUsed on
97+ // actual server use. If loading the existing profile times out,
98+ // lastUsed=0 is passed — ProfileStore.save() preserves the existing
99+ // nonzero lastUsed in that case so the sort position isn't lost.
94 val existingLastUsed = if (s.id != null) {
100 val existingLastUsed = if (s.id != null) {
95 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
101 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
96 container.profileStore.profiles.first()
102 container.profileStore.profiles.first()
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+17−5
@@ -307,16 +307,28 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
307 _switchingId.value = null
307 _switchingId.value = null
308 result.onSuccess { refresh() }
308 result.onSuccess { refresh() }
309 .onFailure { error ->
309 .onFailure { error ->
310− // connect() already closed the old connection; if we just disconnect()
310+ // connect() already closed the old connection; try to restore the
311− // the user is left with nothing. Try to restore the previous profile so
312− // the session list survives. If there was none, show the error state.
311+ // previous profile so the session list survives. If there was none,
312+ // show the error state.
313 container.disconnect()
313 container.disconnect()
314 if (previousProfile != null) {
314 if (previousProfile != null) {
315− runCatchingCancellable { container.connect(previousProfile) }
315+ val restored = runCatchingCancellable { container.connect(previousProfile) }
316 // connect() clears unread state; restore what we saved so the
316 // connect() clears unread state; restore what we saved so the
317 // user doesn't lose unread badges on a failed server switch.
317 // user doesn't lose unread badges on a failed server switch.
318 savedUnread.forEach { container.restoreUnread(it) }
318 savedUnread.forEach { container.restoreUnread(it) }
319+ if (restored.isSuccess) {
319− _state.update { it.copy(loading = false, error = container.friendlyError(error)) }
320+ _state.update { it.copy(loading = false, error = container.friendlyError(error)) }
321+ } else {
322+ // Restore also failed — the user is now disconnected from
323+ // both servers. Surface both failures so the user understands
324+ // why they're looking at an empty list.
325+ val restoreMsg = container.friendlyError(restored.exceptionOrNull() ?: error)
326+ _state.value = SessionListState(
327+ loading = false,
328+ error = container.string(R.string.error_switch_and_restore_failed,
329+ container.friendlyError(error), restoreMsg),
330+ )
331+ }
320 } else {
332 } else {
321 _state.value = SessionListState(loading = false, error = container.friendlyError(error))
333 _state.value = SessionListState(loading = false, error = container.friendlyError(error))
322 }
334 }
app/src/main/res/values/strings.xml+1
@@ -96,6 +96,7 @@
96 <string name="error_timeout">The request timed out. Check the connection and try again.</string>
96 <string name="error_timeout">The request timed out. Check the connection and try again.</string>
97 <string name="error_server">The server reported an error. Try again later.</string>
97 <string name="error_server">The server reported an error. Try again later.</string>
98 <string name="error_load_timeout">Loading took too long. Try again.</string>
98 <string name="error_load_timeout">Loading took too long. Try again.</string>
99+ <string name="error_switch_and_restore_failed">Could not connect to the new server (%1$s), and restoring the previous server also failed (%2$s).</string>
99
100
100 <!-- Part renderer -->
101 <!-- Part renderer -->
101 <string name="thinking">Thinking…</string>
102 <string name="thinking">Thinking…</string>
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+1
@@ -16,6 +16,7 @@ import org.junit.Assert.assertTrue
16import org.junit.Test
16import org.junit.Test
17import java.io.IOException
17import java.io.IOException
18
18
19+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
19class WithRetryTest {
20class WithRetryTest {
20
21
21 /** Produce the real exception Ktor throws for a status when expectSuccess = true. */
22 /** Produce the real exception Ktor throws for a status when expectSuccess = true. */
app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+21−4
@@ -97,11 +97,28 @@ class SessionListViewModelTest {
97 val vm = makeVm(container)
97 val vm = makeVm(container)
98 assertTrue(vm.state.value.sessions.isNotEmpty())
98 assertTrue(vm.state.value.sessions.isNotEmpty())
99
99
100− // Now make listSessions fail
100+ // Now make listSessions fail — the existing list should remain and a
101− api.sessions = listOf()
101+ // transient error should be surfaced (not replace the whole list).
102+ repo.listSessionsThrows = IOException("network error")
103+ vm.refresh()
104+ testScheduler.advanceUntilIdle()
105+ assertTrue(vm.state.value.sessions.isNotEmpty())
106+ assertFalse(vm.state.value.loading)
107+ assertNotNull(vm.transientError.value)
108+ }
109+
110+ @Test
111+ fun refresh_failureWithNoSessions_setsErrorState() = testScope.runTest {
112+ val api = FakeOpencodeApiClient()
113+ val events = FakeEventStreamClient()
114+ val repo = FakeSessionRepository(api, events)
102 repo.sessions = emptyList()
115 repo.sessions = emptyList()
103− // Can't easily make listSessions throw via FakeSessionRepository since it
116+ repo.listSessionsThrows = IOException("network error")
104− // returns sessions directly. Test that the error path for empty + failure works.
117+ val container = makeContainer(api = api, events = events, repo = repo)
118+ val vm = makeVm(container)
119+ assertFalse(vm.state.value.loading)
120+ assertNotNull(vm.state.value.error)
121+ assertTrue(vm.state.value.sessions.isEmpty())
105 }
122 }
106
123
107 // --- SSE events ---
124 // --- SSE events ---
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+5−1
@@ -222,10 +222,14 @@ class FakeSessionRepository(
222 var createdSession: Session = Session(id = "new-session", title = "New")
222 var createdSession: Session = Session(id = "new-session", title = "New")
223 var messages: List<MessageWithParts> = emptyList()
223 var messages: List<MessageWithParts> = emptyList()
224 var sendPromptThrows: Throwable? = null
224 var sendPromptThrows: Throwable? = null
225+ var listSessionsThrows: Throwable? = null
225 var abortCalls: List<String> = emptyList()
226 var abortCalls: List<String> = emptyList()
226 private set
227 private set
227
228
228− override suspend fun listSessions(): List<Session> = sessions
229+ override suspend fun listSessions(): List<Session> {
230+ listSessionsThrows?.let { throw it }
231+ return sessions
232+ }
229 override suspend fun createSession(title: String?): Session = createdSession
233 override suspend fun createSession(title: String?): Session = createdSession
230 override suspend fun deleteSession(id: String) { /* no-op */ }
234 override suspend fun deleteSession(id: String) { /* no-op */ }
231 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
235 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }