git.iko.soy/opencode-android
on master help sign in
Fix cache-deletion race, isLiveRunActivity over-counting, refresh spinner premature clear, and lost undo on rapid deletes
commit9614d117948483bc143388c459a7cd456f2fea69 authoriko <mail@iko.soy> dateFri 3 Jul 2026, 05:55 (3 months ago) parent8aabad6
9 files changed+65−21
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+1−9 app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+21 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+11−5 app/src/main/java/soy/iko/opencode/ui/chat/Attachments.kt+2−1 app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.kt+2−1 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+8−4 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+2−1 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+9 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+9
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+1−9
@@ -30,18 +30,10 @@ open class DraftStore private constructor(
3030 protected constructor() : this(null, null, true)
3131
3232 private val prefs by lazy {
33− val sp = appContext?.getSharedPreferences("drafts", Context.MODE_PRIVATE)
33+ appContext?.getSharedPreferences("drafts", Context.MODE_PRIVATE)
3434 ?: error("No context — override methods in test subclass")
35− prefsInitialized.set(true)
36− sp
3735 }
3836
39− /** Tracks whether the [prefs] lazy has been resolved (i.e. the file has been
40− * opened from disk). Accessed from [flushDraft] to decide whether a synchronous
41− * write on the main thread is safe or whether the write must be deferred to a
42− * background thread to avoid blocking on first-time disk I/O. */
43− private val prefsInitialized = java.util.concurrent.atomic.AtomicBoolean(false)
44−
4537 /** Single-thread executor for flushDraft when prefs aren't initialized yet,
4638 * so the main thread is never blocked on disk I/O. */
4739 private val flushExecutor by lazy {
app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+21
@@ -55,8 +55,22 @@ open class MessageCacheStore private constructor(
5555 private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
5656 private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
5757
58+ /** Sessions whose cache file has been removed by [remove]. A teardown flush of an
59+ * observer (CacheWriter.flushOnTeardown) runs under NonCancellable and can race with
60+ * remove(): both serialize on [lockFor], but their relative order is nondeterministic.
61+ * If the flush's save lands after remove, it re-creates the deleted session's cache
62+ * file, defeating the deletion (a privacy/disk-leak with no background cleanup). The
63+ * tombstone lets a post-remove save detect the deletion and skip. Cleared by [load] so
64+ * re-opening a session (which won't happen for a truly deleted UUID, but is safe)
65+ * re-enables saving. Bounded by distinct deleted sessions, each a few bytes. */
66+ private val tombstones: MutableSet<String> =
67+ java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
68+
5869 /** Load the cached messages for [sessionId] under [profileId], or empty if none / unreadable. */
5970 open suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
71+ // Re-opening a session re-enables saving: clear any tombstone so a fresh conversation
72+ // write isn't suppressed by a prior deletion of the same (reused) id.
73+ tombstones.remove(sessionId)
6074 withContext(Dispatchers.IO) {
6175 val file = fileFor(profileId, sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
6276 runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
@@ -66,6 +80,10 @@ open class MessageCacheStore private constructor(
6680
6781 /** Persist [messages] for [sessionId] under [profileId] (an empty list deletes the file). */
6882 open suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
83+ // Skip if this session was deleted since the observer started: a teardown flush racing
84+ // after remove() must not re-create the file. (Held under the same per-session lock as
85+ // remove, so the tombstone is visible to a save that lost the race.)
86+ if (sessionId in tombstones) return@withLock
6987 withContext(Dispatchers.IO) {
7088 val file = fileFor(profileId, sessionId) ?: return@withContext
7189 runCatchingCancellable {
@@ -94,6 +112,9 @@ open class MessageCacheStore private constructor(
94112 * and the map is bounded by the number of distinct sessions touched. */
95113 open suspend fun remove(sessionId: String) {
96114 lockFor(sessionId).withLock {
115+ // Set the tombstone before deleting so a concurrent/loser save sees it and skips,
116+ // instead of re-creating the file we're about to delete.
117+ tombstones.add(sessionId)
97118 withContext(Dispatchers.IO) {
98119 runCatchingCancellable {
99120 val name = idRegex.replace(sessionId, "_") + ".json"
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+11−5
@@ -570,16 +570,22 @@ open class AppContainer private constructor(
570570 * follow it, re-adding the session would pin anyRunActive true indefinitely. */
571571 private fun isLiveRunActivity(event: BusEvent): Boolean {
572572 if (event !is MessagePartUpdated && event !is MessageUpdated) return false
573− val info = (event as? MessageUpdated)?.properties?.info
574− if (info is AssistantMessage && info.isComplete) return false
573+ // For MessageUpdated, only an in-flight *assistant* message counts as live activity.
574+ // A UserMessage or UnknownMessage update (e.g. a metadata refresh of an existing user
575+ // message) would otherwise add the session to activeRuns with no following SessionIdle
576+ // to clear it — pinning anyRunActive true and the foreground notification indefinitely.
577+ // Mirrors SessionRepository.isRunActivity's guard exactly.
578+ if (event is MessageUpdated) {
579+ val info = event.properties.info
580+ return info is AssistantMessage && !info.isComplete && info.error == null
581+ }
575582 // A step-finish part is the trailing completion marker for a run (it carries the
576583 // final cost/token totals) and arrives after the last stream delta. Like a completed
577584 // message.updated, treating it as live would re-add the session to activeRuns with no
578585 // following SessionIdle to clear it — pinning anyRunActive true indefinitely when such
579586 // a part is replayed/reordered after the run already went idle.
580− val part = (event as? MessagePartUpdated)?.properties?.part
581− if (part is StepFinishPart) return false
582− return true
587+ val part = (event as MessagePartUpdated).properties.part
588+ return part !is StepFinishPart
583589 }
584590
585591 /** Session ids currently streaming an assistant run (best-effort, in-process). Backed by
app/src/main/java/soy/iko/opencode/ui/chat/Attachments.kt+2−1
@@ -7,6 +7,7 @@ import android.util.Base64
77import androidx.core.content.FileProvider
88import soy.iko.opencode.data.model.FilePromptPart
99import soy.iko.opencode.data.network.NetworkConfig
10+import soy.iko.opencode.util.runCatchingCancellable
1011import kotlinx.coroutines.Dispatchers
1112import kotlinx.coroutines.withContext
1213import java.io.File
@@ -28,7 +29,7 @@ suspend fun Uri.toAttachmentResult(context: Context): AttachmentResult = withCon
2829 if (declaredSize != null && declaredSize > max) return@withContext AttachmentResult.TooLarge
2930 // Read with a hard cap so a provider that under-reports or omits its size still can't OOM
3031 // us: stop as soon as we've read one byte past the cap, then reject.
31− val bytes = runCatching {
32+ val bytes = runCatchingCancellable {
3233 resolver.openInputStream(this@toAttachmentResult)?.use { it.readAtMost(max + 1) }
3334 }.getOrNull() ?: return@withContext AttachmentResult.Failed
3435 if (bytes.size > max) return@withContext AttachmentResult.TooLarge
app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.kt+2−1
@@ -28,6 +28,7 @@ import androidx.compose.ui.res.stringResource
2828import androidx.compose.ui.unit.dp
2929import androidx.compose.ui.window.Dialog
3030import soy.iko.opencode.R
31+import soy.iko.opencode.util.runCatchingCancellable
3132
3233/**
3334 * A keyboard-first command palette: a search field over a list of [actions], filtered as the
@@ -74,7 +75,7 @@ fun CommandPalette(
7475 }
7576 },
7677 )
77− LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
78+ LaunchedEffect(Unit) { runCatchingCancellable { focus.requestFocus() } }
7879 if (filtered.isEmpty()) {
7980 Text(
8081 stringResource(R.string.palette_no_results),
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+8−4
@@ -122,11 +122,15 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
122122 } else {
123123 open(_state.value.path)
124124 }
125+ // Capture the job this refresh kicked off. open()/setQuery() reassign the field to a
126+ // new job on a second refresh() (cancelling the first), so a naive join() on the first
127+ // would complete immediately (cancelled) and clear the spinner while the second load is
128+ // still in flight. Only clear when the active job is still the one we joined — a newer
129+ // refresh owns the spinner now. Mirrors SessionListViewModel.refresh().
130+ val job = if (searching) searchJob else openJob
125131 viewModelScope.launch {
126− // Clear the spinner once the in-flight load settles by joining whichever job
127− // the refresh kicked off (search or directory open).
128− (if (searching) searchJob else openJob)?.join()
129− _refreshing.value = false
132+ job?.join()
133+ if ((if (searching) searchJob else openJob) === job) _refreshing.value = false
130134 }
131135 }
132136
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+2−1
@@ -45,6 +45,7 @@ import soy.iko.opencode.R
4545import soy.iko.opencode.di.AppContainer
4646import soy.iko.opencode.ui.components.RelativeTimeText
4747import soy.iko.opencode.ui.vmFactory
48+import soy.iko.opencode.util.runCatchingCancellable
4849
4950/** Cross-session message search: type a query, tap a result to open that conversation. */
5051@OptIn(ExperimentalMaterial3Api::class)
@@ -60,7 +61,7 @@ fun GlobalSearchScreen(
6061 val keyboard = LocalSoftwareKeyboardController.current
6162
6263 LaunchedEffect(Unit) {
63− runCatching { focusRequester.requestFocus() }
64+ runCatchingCancellable { focusRequester.requestFocus() }
6465 }
6566
6667 Scaffold(
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+9
@@ -121,7 +121,15 @@ fun ServerListScreen(
121121 // delete timer (started at emit time) fires before its snackbar is ever shown —
122122 // a dead Undo button. collectLatest instead cancels the current snackbar and
123123 // shows the newest immediately, keeping its Undo window aligned with its timer.
124+ // Tracking `pending` mirrors DiagnosticsScreen: when a newer delete supersedes the
125+ // prior snackbar mid-window, the prior profile's deferred delete (still scheduled
126+ // on the container's scope) would fire with no reachable Undo. Withdrawing it via
127+ // undoDelete cancels the timer and re-shows the row (no-op if it already fired), so
128+ // no profile is silently deleted without a chance to undo.
129+ var pending: String? = null
124130 vm.undoEvents.collectLatest { profileId ->
131+ pending?.let { vm.undoDelete(it) }
132+ pending = profileId
125133 coroutineScope {
126134 val dismisser = launch {
127135 delay(NetworkConfig.undoServerDeleteDelayMs)
@@ -135,6 +143,7 @@ fun ServerListScreen(
135143 dismisser.cancel()
136144 if (result == SnackbarResult.ActionPerformed) {
137145 vm.undoDelete(profileId)
146+ pending = null
138147 }
139148 }
140149 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+9
@@ -172,7 +172,15 @@ fun SessionListScreen(
172172 // delete timer (started at emit time) fires before its snackbar is ever shown —
173173 // a dead Undo button. collectLatest instead cancels the current snackbar and
174174 // shows the newest immediately, keeping its Undo window aligned with its timer.
175+ // Tracking `pending` mirrors DiagnosticsScreen: when a newer delete supersedes the
176+ // prior snackbar mid-window, the prior session's deferred REST delete (still
177+ // scheduled on the container's scope) would fire with no reachable Undo. Withdrawing
178+ // it via undoDelete cancels the timer and restores the row (no-op if it already
179+ // fired), so no session is silently deleted without a chance to undo.
180+ var pending: String? = null
175181 vm.undoEvents.collectLatest { sessionId ->
182+ pending?.let { vm.undoDelete(it) }
183+ pending = sessionId
176184 coroutineScope {
177185 val dismisser = launch {
178186 delay(NetworkConfig.undoDeleteDelayMs)
@@ -186,6 +194,7 @@ fun SessionListScreen(
186194 dismisser.cancel()
187195 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
188196 vm.undoDelete(sessionId)
197+ pending = null
189198 }
190199 }
191200 }