diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/diagnostics/CrashLog.kt b/app/src/main/java/com/yugahashimoto/andcode/core/diagnostics/CrashLog.kt index 1f333b5f7..f4c536f15 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/diagnostics/CrashLog.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/diagnostics/CrashLog.kt @@ -2,6 +2,7 @@ package com.yugahashimoto.andcode.core.diagnostics import android.content.Context import android.os.Build +import com.yugahashimoto.andcode.BuildConfig import java.io.File import java.io.PrintWriter import java.io.StringWriter @@ -51,9 +52,11 @@ object CrashLog { buildString { appendLine("AndCode crash") appendLine("Time: ${TIMESTAMP.format(Date())}") + appendLine("Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") appendLine("Thread: ${thread.name}") appendLine("Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})") appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}") + appendLine("Heap: ${heapUsage()}") appendLine() append(stackTrace.take(MAX_TRACE_CHARS)) } @@ -62,8 +65,17 @@ object CrashLog { target.writeText(report) } + private fun heapUsage(): String { + val runtime = Runtime.getRuntime() + val used = (runtime.totalMemory() - runtime.freeMemory()) / MEGABYTE + val max = runtime.maxMemory() / MEGABYTE + return "$used MB used of $max MB" + } + private fun file(context: Context): File = File(File(context.filesDir, "diagnostics"), "last-crash.txt") + private const val MEGABYTE = 1024L * 1024L + private val TIMESTAMP = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) private const val MAX_TRACE_CHARS = 12_000 } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/DiagnosticsSheet.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/DiagnosticsSheet.kt index 2db2bb140..a840c0049 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/DiagnosticsSheet.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/DiagnosticsSheet.kt @@ -169,7 +169,9 @@ private fun DiagnosticsSectionHeader(title: String) { ) } -private const val CRASH_PREVIEW_LINES = 12 +// The report's header is eight lines (including the blank one before the stack), so this leaves +// roughly the same amount of the stack visible as before the version and heap rows were added. +private const val CRASH_PREVIEW_LINES = 14 private fun formatBytes(bytes: Long): String { val gb = bytes / (1024.0 * 1024.0 * 1024.0) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityMessageStore.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityMessageStore.kt new file mode 100644 index 000000000..63760593c --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityMessageStore.kt @@ -0,0 +1,95 @@ +package com.yugahashimoto.andcode.runtime.local + +import android.util.Log +import com.yugahashimoto.andcode.core.api.OpenCodeMessage +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import java.io.File + +/** + * Chat history for the local Antigravity agent, kept in memory and flushed to disk. + * + * Like [ClaudeMessageStore], the history is read and written as a stream. The runtime used to load + * it with `readText()` and serialize the whole map with `encodeToString()` on every message, which + * held the entire file as a String on top of the decoded messages and could exhaust the heap on a + * long history (issue #392). The write also goes to a temporary file first, so an allocation + * failure or a killed process mid-write cannot truncate the history already on disk. + */ +@OptIn(ExperimentalSerializationApi::class) +internal class AntigravityMessageStore( + private val file: File, + private val json: Json, +) { + private val messages = linkedMapOf>() + private var loaded = false + private var dirty = false + + init { + // No history yet is normal, not a read failure worth warning about. + loaded = + !file.exists() || + runCatching { + file.inputStream().buffered().use { json.decodeFromStream>>(it) } + }.mapCatching { decoded -> + decoded.forEach { (sessionId, sessionMessages) -> + messages[sessionId] = sessionMessages.toMutableList() + } + }.onFailure { Log.w(TAG, "Could not read the Antigravity history", it) } + .isSuccess + } + + @Synchronized + fun list(sessionId: String): List = messages[sessionId].orEmpty().toList() + + @Synchronized + fun append( + sessionId: String, + message: OpenCodeMessage, + ) { + messages.getOrPut(sessionId) { mutableListOf() } += message + dirty = true + } + + @Synchronized + fun remove(sessionId: String): Boolean { + val removed = messages.remove(sessionId) != null + if (removed) dirty = true + return removed + } + + @Synchronized + fun flush() { + if (!dirty) return + runCatching { + file.parentFile?.mkdirs() + // A failed load (corrupt JSON, or an OOM while decoding a long history) used to leave an + // empty map behind, so the next flush silently replaced the real history with only the + // messages written since. Keep the unreadable file instead of destroying it. + if (!loaded) { + // The backup name is fixed: a second unreadable history replaces the first, which + // keeps one copy for manual recovery without piling up files on every failure. + file.takeIf(File::exists)?.let { unreadable -> + val backup = File(unreadable.parentFile, "${unreadable.name}.unreadable") + if (backup.exists()) backup.delete() + check(unreadable.renameTo(backup)) { "Cannot preserve unreadable ${unreadable.name}" } + } + loaded = true + } + val temporary = File(file.parentFile, ".${file.name}.tmp") + temporary.outputStream().buffered().use { output -> + json.encodeToStream(messages.mapValues { it.value.toList() }, output) + } + if (!temporary.renameTo(file)) { + temporary.delete() + error("Could not replace ${file.name}") + } + dirty = false + }.onFailure { Log.w(TAG, "Could not write the Antigravity history", it) } + } + + private companion object { + const val TAG = "AntigravityMessageStore" + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityRuntime.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityRuntime.kt index b14a05c2d..4b65e7f00 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityRuntime.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityRuntime.kt @@ -55,7 +55,7 @@ class AntigravityRuntime( private val recordsFile = File(runtimeDirectory, "antigravity-sessions.json") private val messagesFile = File(runtimeDirectory, "antigravity-messages.json") private val records = linkedMapOf() - private val messages = linkedMapOf>() + private val messageStore = AntigravityMessageStore(messagesFile, json) private val processes = linkedMapOf() /** See [AntigravityAbortTracker] for why an intentional kill must not be reported as a crash. */ @@ -244,7 +244,8 @@ class AntigravityRuntime( // bubble is replaced by that snapshot - so a user turn that only lands at the // end of the send would vanish from the screen (and never reappear on a failed // result, which persisted nothing for it). - messages.getOrPut(sessionId) { mutableListOf() }.add( + messageStore.append( + sessionId, OpenCodeMessage( OpenCodeMessageInfo(userId, sessionId, "user", OpenCodeTime(turnNow, turnNow, turnNow)), // A part without an id is dropped when the chat maps the transcript for @@ -267,7 +268,7 @@ class AntigravityRuntime( }, ), ) - persist() + persistMessages() var discoveredConversationId: String? = null var turnFinished = false var turnError: String? = null @@ -283,7 +284,8 @@ class AntigravityRuntime( // one-turn lag seen on device. Claude Code avoids this by upserting before // it emits, so the assistant reply (and its tool parts) land first here too. parsed.messages.forEach { assistant -> - messages.getOrPut(sessionId) { mutableListOf() }.add( + messageStore.append( + sessionId, assistant.copy( info = assistant.info.copy( @@ -291,7 +293,7 @@ class AntigravityRuntime( ), ), ) - persist() + persistMessages() } parsed.events.forEach(events::tryEmit) if (parsed.turnFinished) { @@ -317,7 +319,7 @@ class AntigravityRuntime( updatedAt = System.currentTimeMillis(), lastStep = record0.lastStep + 1, ) - persist() + persistRecords() if (turnFinished) { // On a failed result the parser already emitted SessionError then SessionIdle; // the user turn is persisted above, and there is no assistant reply to add. @@ -375,19 +377,20 @@ class AntigravityRuntime( if (record.archived) return record val archived = record.copy(archived = true) records[sessionId] = archived - persist() + persistRecords() return archived } - fun listMessages(sessionId: String): List = messages[sessionId].orEmpty().toList() + fun listMessages(sessionId: String): List = messageStore.list(sessionId) fun remove(sessionId: String): Boolean { abort(sessionId) val removed = records.remove(sessionId) != null - messages.remove(sessionId) + messageStore.remove(sessionId) val safeSession = sessionId.replace(Regex("[^A-Za-z0-9._-]"), "_") File(runtimeDirectory, "workspace/$ATTACHMENT_DIRECTORY/$safeSession").deleteRecursively() - persist() + persistRecords() + persistMessages() return removed } @@ -399,7 +402,7 @@ class AntigravityRuntime( ) { records[sessionId] = AntigravitySessionRecord(sessionId, null, workspace, title = title, permissionMode = permissionMode.cliValue) - persist() + persistRecords() } /** Remembers the model/variant a session's next message should use. */ @@ -410,7 +413,7 @@ class AntigravityRuntime( ) { val record = records[sessionId] ?: return records[sessionId] = record.copy(model = model, variant = variant) - persist() + persistRecords() } /** Renames a session; see [AntigravitySessionRecord.title]. */ @@ -420,7 +423,7 @@ class AntigravityRuntime( ) { val record = records[sessionId] ?: return records[sessionId] = record.copy(title = title, updatedAt = System.currentTimeMillis()) - persist() + persistRecords() } /** @@ -497,7 +500,7 @@ class AntigravityRuntime( ) { val record = records[sessionId] ?: return records[sessionId] = record.copy(permissionMode = mode.cliValue) - persist() + persistRecords() } fun abortAll() { @@ -535,20 +538,18 @@ class AntigravityRuntime( recordsFile.readText(), ).forEach { records[it.appSessionId] = it } } - runCatching { - json.decodeFromString>>( - messagesFile.readText(), - ).forEach { messages[it.key] = it.value.toMutableList() } - } } - private fun persist() { + private fun persistRecords() { runCatching { recordsFile.parentFile?.mkdirs() writeAtomically(recordsFile, json.encodeToString(records.values.toList())) - writeAtomically(messagesFile, json.encodeToString(messages.mapValues { it.value.toList() })) } } + + private fun persistMessages() { + messageStore.flush() + } } private fun writeAtomically( diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityTarget.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityTarget.kt index 65432bff0..de0415950 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityTarget.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityTarget.kt @@ -101,9 +101,9 @@ class AntigravityTarget(internal val runtime: AntigravityRuntime) : RuntimeTarge val id = UUID.randomUUID().toString() // Same as ClaudeCodeTarget: the mode settings shows is what a new chat runs in. Leaving the // record's own default here is what made a full-access setting start chats in accept-edits. - // persist() serializes the whole message store to disk, so this must stay off the caller's - // (usually main) thread - left inline it blocked the UI long enough for Crashlytics to see - // it as an ANR once the store grew. + // Creating the record writes the session list to disk, so this stays off the caller's + // (usually main) thread - left inline the write blocked the UI long enough for Crashlytics + // to see it as an ANR once the list grew. withContext(kotlinx.coroutines.Dispatchers.IO) { runtime.create(id, directory ?: "/workspace", title, mutableDefaultPermissionMode.value) } @@ -131,14 +131,19 @@ class AntigravityTarget(internal val runtime: AntigravityRuntime) : RuntimeTarge /** Same as [ClaudeCodeTarget.listMessages]: names the model for chats held before it was stored. */ override suspend fun listMessages(sessionId: String): List { - val messages = runtime.listMessages(sessionId) - val model = runtime.findSession(sessionId)?.model ?: return messages - val reference = OpenCodeModelReference(AntigravityModels.PROVIDER_ID, model) - return messages.map { message -> - if (message.info.role == "assistant" && message.info.model == null) { - message.copy(info = message.info.copy(model = reference)) - } else { - message + // The store's lock is held while the whole history is flushed, so reading (and remapping a + // long transcript) on the chat's dispatcher (main) could block the UI for the length of a + // write. + return withContext(Dispatchers.IO) { + val messages = runtime.listMessages(sessionId) + val model = runtime.findSession(sessionId)?.model ?: return@withContext messages + val reference = OpenCodeModelReference(AntigravityModels.PROVIDER_ID, model) + messages.map { message -> + if (message.info.role == "assistant" && message.info.model == null) { + message.copy(info = message.info.copy(model = reference)) + } else { + message + } } } } diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/AntigravityMessageStoreTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/AntigravityMessageStoreTest.kt new file mode 100644 index 000000000..3faf9a7e3 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/AntigravityMessageStoreTest.kt @@ -0,0 +1,68 @@ +package com.yugahashimoto.andcode.runtime.local + +import com.yugahashimoto.andcode.core.api.OpenCodeMessage +import com.yugahashimoto.andcode.core.api.OpenCodeMessageInfo +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class AntigravityMessageStoreTest { + private val json = Json { encodeDefaults = true } + + @Test + fun `messages are written through a temporary file and survive a reload`() { + val directory = java.nio.file.Files.createTempDirectory("antigravity-message-store").toFile() + val file = File(directory, "antigravity-messages.json") + val store = AntigravityMessageStore(file, json) + store.append("s1", message("m1", "s1")) + store.flush() + store.append("s1", message("m2", "s1")) + store.flush() + + assertEquals(listOf("antigravity-messages.json"), directory.list()?.toList()) + assertEquals(listOf("m1", "m2"), AntigravityMessageStore(file, json).list("s1").map { it.info.id }) + directory.deleteRecursively() + } + + @Test + fun `removing a session deletes its messages from disk`() { + val directory = java.nio.file.Files.createTempDirectory("antigravity-message-store").toFile() + val file = File(directory, "antigravity-messages.json") + val store = AntigravityMessageStore(file, json) + store.append("kept", message("m1", "kept")) + store.append("removed", message("m2", "removed")) + store.flush() + + assertTrue(store.remove("removed")) + assertFalse(store.remove("missing")) + store.flush() + + val reloaded = AntigravityMessageStore(file, json) + assertEquals(listOf("m1"), reloaded.list("kept").map { it.info.id }) + assertTrue(reloaded.list("removed").isEmpty()) + directory.deleteRecursively() + } + + @Test + fun `a corrupt history is preserved instead of being overwritten`() { + val directory = java.nio.file.Files.createTempDirectory("antigravity-message-store").toFile() + val file = File(directory, "antigravity-messages.json").also { it.writeText("{not json") } + val store = AntigravityMessageStore(file, json) + + assertTrue(store.list("s1").isEmpty()) + store.append("s1", message("m1", "s1")) + store.flush() + + assertEquals("{not json", File(directory, "antigravity-messages.json.unreadable").readText()) + assertEquals(listOf("m1"), AntigravityMessageStore(file, json).list("s1").map { it.info.id }) + directory.deleteRecursively() + } + + private fun message( + id: String, + sessionId: String, + ) = OpenCodeMessage(info = OpenCodeMessageInfo(id, sessionId, "user")) +}