From 426e5bd0365734ae1b61ccc511f4ec261560a7c8 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Fri, 28 Aug 2026 15:26:01 -0500 Subject: [PATCH 1/7] feat(ai-agent-local): load the .gguf in place and flag it when unreachable ADFA-5253: read the model through a held descriptor instead of copying it, and persist the picker's read grant. The settings pane derives its model and engine status from a live readability check, so a deleted file no longer reads as ready. --- ai-agent-local/ai-agent-local.html | 35 +- ai-agent-local/src/main/AndroidManifest.xml | 6 +- .../src/main/assets/docs/index.html | 20 +- .../aiagentlocal/backend/LocalLlmBackend.kt | 481 +++++++++++------- .../backend/ModelResidencyEngine.kt | 39 ++ .../aiagentlocal/model/GgufModelInspector.kt | 18 +- .../aiagentlocal/model/ModelFileSource.kt | 43 ++ .../model/ModelLoadDiagnostics.kt | 67 ++- .../aiagentlocal/model/ModelLoadMessages.kt | 1 + .../aiagentlocal/model/ModelSourceWatcher.kt | 134 +++++ .../aiagentlocal/model/NativeModelSource.kt | 132 +++++ .../aiagentlocal/plugin/LocalLlmPlugin.kt | 8 +- .../settings/LocalLlmSettingsFragment.kt | 76 +-- .../settings/LocalLlmSettingsViewModel.kt | 205 +++++++- .../layout/fragment_local_llm_settings.xml | 2 +- .../src/main/res/values/strings.xml | 5 + .../backend/LocalLlmBackendTest.kt | 270 +++++++++- .../model/ContentModelFileSourceTest.kt | 115 +++++ .../model/ContentNativeModelSourceTest.kt | 114 +++++ .../model/GgufModelInspectorTest.kt | 106 +++- .../aiagentlocal/model/GgufTestFiles.kt | 64 +++ .../model/ModelLoadDiagnosticsTest.kt | 91 ++-- .../model/ModelLoadMessagesTest.kt | 7 + 23 files changed, 1693 insertions(+), 346 deletions(-) create mode 100644 ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/ModelResidencyEngine.kt create mode 100644 ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt create mode 100644 ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt create mode 100644 ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt create mode 100644 ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt create mode 100644 ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/GgufTestFiles.kt diff --git a/ai-agent-local/ai-agent-local.html b/ai-agent-local/ai-agent-local.html index 0ac3b8fc..299706c1 100644 --- a/ai-agent-local/ai-agent-local.html +++ b/ai-agent-local/ai-agent-local.html @@ -63,14 +63,15 @@

Core functionality

  • Model safety checks — inspects a selected .gguf header and refuses embedding-only models for chat, with a clear error instead of a native crash.
  • -
  • Actionable load failures — a failed load is classified (missing, - empty, not a GGUF, out of memory, unsupported quantization) and reported as - a message that says what to do next.
  • -
  • Storage-picker support — a model chosen as a - content:// document is copied once into private storage so the - native loader can open it, and only the current model is kept.
  • +
  • Actionable load failures — a failed load is classified (no longer + reachable, empty, not a GGUF, out of memory, unsupported quantization) and + reported as a message that says what to do next.
  • +
  • Direct storage access — a model chosen as a content:// + document is read in place, through the read grant the picker persisted. + Nothing is copied into private storage, so a multi-gigabyte model costs no + device space beyond the file you downloaded.
  • Its own settings pane — browse for a .gguf file, - re-load a previously imported model, record the model's published SHA-256, + re-load the model already selected, record the model's published SHA-256, and choose between the short system prompt small models follow reliably and the full tool-calling one. A model too large for the device's free RAM raises a warning first.
  • @@ -82,14 +83,15 @@

    Technical architecture

    LocalLlmPluginPlugin entry point. Registers the backend with AI Core on activation, re-registering if AI Core activates later; frees the native model on dispose. - LocalLlmBackendThe inference engine. Resolves - the selected model to a real file path, manages loading and unloading, and - serializes generations against the shared native context. + LocalLlmBackendThe inference engine. Opens the + selected model in place and hands the native loader that descriptor, manages + loading and unloading, and serializes generations against the shared native + context. GgufModelInspectorMinimal GGUF header reader that classifies a model as chat- or embedding-only. ModelLoadDiagnosticsClassifies a load failure - from the file, free memory and the native error text, as a pure function - that is unit-tested off-device. + from the model's size and readability, free memory and the native error + text, as a pure function that is unit-tested off-device. ModelLoadMessagesRenders a diagnosis as user-facing text, keeping string resources out of the engine. LocalLlmSettingsFragmentThe settings pane AI @@ -107,11 +109,12 @@

    Usage

    Manager, then restart the IDE.
  • Open Preferences → Configuration → Agent and select the local backend. This plugin's own pane appears below it.
  • -
  • Tap Browse and pick a .gguf model file. The file is - copied once into private storage, then loaded; a model larger than the free - RAM asks you to confirm first.
  • +
  • Tap Browse and pick a .gguf model file. It is loaded + from wherever you saved it, with no copy made; a model larger than the free + RAM asks you to confirm first. Leave the file in place — moving or deleting + it breaks the selection.
  • Optionally record the model's published SHA-256, or use Load - from saved to return to a model you already imported.
  • + from saved to return to the model you already selected.
    Model choice drives whether this works at all on a given device. A Q4_K_M diff --git a/ai-agent-local/src/main/AndroidManifest.xml b/ai-agent-local/src/main/AndroidManifest.xml index 3988f4c1..80896e03 100644 --- a/ai-agent-local/src/main/AndroidManifest.xml +++ b/ai-agent-local/src/main/AndroidManifest.xml @@ -36,8 +36,10 @@ android:name="plugin.max_ide_version" android:value="26.99" /> - + diff --git a/ai-agent-local/src/main/assets/docs/index.html b/ai-agent-local/src/main/assets/docs/index.html index c25acac5..232bf4f3 100644 --- a/ai-agent-local/src/main/assets/docs/index.html +++ b/ai-agent-local/src/main/assets/docs/index.html @@ -57,13 +57,14 @@

    The settings pane

    controls:

    • Browse — opens the system file picker to choose a - .gguf model. A model selected as a content:// - document is copied once into the plugin's private storage so the native - loader can open it, and only the current model is kept on disk. If the file - is larger than the device's free RAM, a warning asks you to confirm before - loading.
    • -
    • Load from saved — reloads the model already in private storage - without picking it again. Use this after restarting the IDE, or when a load + .gguf model. The plugin keeps read access to the document you + picked and reads it where it is — on internal storage, an SD card or a USB + volume. Nothing is copied, so a multi-gigabyte model costs no extra device + storage. Keep the file where it is: moving or deleting it breaks the + selection. If the file is larger than the device's free RAM, a warning asks + you to confirm before loading.
    • +
    • Load from saved — reloads the model you already selected without + picking it again. Use this after restarting the IDE, or when a load failed for a transient reason such as low memory.
    • SHA-256 — optional. Paste the checksum published alongside the model download to keep a record of which exact file is configured. It is @@ -93,6 +94,11 @@

      Troubleshooting

      the safest starting point).
    • The local backend never appears — AI Core isn't installed or activated; install it and restart the IDE.
    • +
    • "The selected model can no longer be reached" — the model is read + where you saved it rather than from a copy, so moving, renaming or deleting + the file, or removing the SD card it lives on, breaks the selection. + Clearing the IDE's app data also withdraws the permission to read it. Pick + the model again with Browse.
    diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt index c54dffb1..30e8ada2 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt @@ -3,14 +3,13 @@ package com.itsaky.androidide.plugins.aiagentlocal.backend import android.app.ActivityManager import android.content.Context import android.llama.cpp.LLamaAndroid -import android.net.Uri -import android.provider.OpenableColumns import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.aiagentlocal.feedback.IncompatibleModelException import com.itsaky.androidide.plugins.aiagentlocal.feedback.ModelLoadException -import com.itsaky.androidide.plugins.aiagentlocal.feedback.ModelNotConfiguredException import com.itsaky.androidide.plugins.aiagentlocal.feedback.UserActionableLlmException import com.itsaky.androidide.plugins.aiagentlocal.feedback.UserFeedback +import com.itsaky.androidide.plugins.aiagentlocal.format.ByteSize +import com.itsaky.androidide.plugins.aiagentlocal.model.ContentNativeModelSource import com.itsaky.androidide.plugins.aiagentlocal.model.GgufHeader import com.itsaky.androidide.plugins.aiagentlocal.model.GgufHeaderReader import com.itsaky.androidide.plugins.aiagentlocal.model.GgufModelInspector @@ -19,13 +18,17 @@ import com.itsaky.androidide.plugins.aiagentlocal.model.ModelContextResolver import com.itsaky.androidide.plugins.aiagentlocal.model.ModelContextSize import com.itsaky.androidide.plugins.aiagentlocal.model.ModelLoadDiagnostics import com.itsaky.androidide.plugins.aiagentlocal.model.ModelLoadMessages +import com.itsaky.androidide.plugins.aiagentlocal.model.ModelSourceWatcher +import com.itsaky.androidide.plugins.aiagentlocal.model.NativeModelSource +import com.itsaky.androidide.plugins.aiagentlocal.model.OpenModelFile +import com.itsaky.androidide.plugins.aiagentlocal.model.PlatformModelSourceWatcher import com.itsaky.androidide.plugins.aiagentlocal.preferences.LocalLlmPreferences import com.itsaky.androidide.plugins.aiagentlocal.prompt.LocalSystemPrompt import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.LlmInferenceService.* import com.itsaky.androidide.plugins.services.SharedServices +import java.io.Closeable import java.io.File -import java.io.FileOutputStream import java.util.concurrent.CompletableFuture import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException @@ -45,7 +48,10 @@ import kotlinx.coroutines.withContext * Wraps llama-impl APIs and implements LlmBackend interface. */ class LocalLlmBackend( - private val context: PluginContext + private val context: PluginContext, + private val modelSourceOverride: NativeModelSource? = null, + private val engineOverride: ModelResidencyEngine? = null, + private val watcherOverride: ModelSourceWatcher? = null, ) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend { companion object { @@ -63,6 +69,12 @@ class LocalLlmBackend( * text, in which case the native stop truncates before the match. */ private val CHAT_STOP = listOf("<|im_end|>") + + /** + * Where models were copied before ADFA-5253. Nothing writes here any more; see + * [deleteLegacyModelCache], which gives the space back. + */ + private const val LEGACY_MODEL_CACHE_DIR = "llm-models" } private val llamaLazy = lazy { LLamaAndroid.instance() } @@ -87,11 +99,85 @@ class LocalLlmBackend( private val loadMessages by lazy { ModelLoadMessages(context.androidContext) } @Volatile private var modelLoaded = false - @Volatile private var currentModelPath: String? = null + + /** + * The configured reference — path or `content://` URI — of the resident model. + * + * Keyed off the *reference*, never off the resolved native path: a document's procfs path is + * a different string on every open, so comparing resolved paths would report "not loaded" for + * a model that is already resident and reload it on every message. + */ + @Volatile private var currentModelRef: String? = null + + /** + * Holds the resident model's descriptor open. Closing it invalidates the procfs path the + * native loader was given, so it lives exactly as long as the loaded model does. + */ + @Volatile private var openModel: OpenModelFile? = null + + /** + * The reference last found unreachable, so the chat is told the backend is unavailable + * instead of being sent to a model that is gone. + * + * Held as the reference rather than a flag so picking a different model clears it by itself; + * a successful load clears it for the same one. + */ + @Volatile private var unreachableModelRef: String? = null + + /** + * Stops the delete watch on the resident model. Follows residency exactly: taken when a model + * is adopted, closed when it is released. + */ + @Volatile private var modelWatch: Closeable? = null + + /** + * Opens the configured model for the native loader. Lazy so construction touches no Android + * services, and overridable so the load path can be tested without a device. + */ + private val modelSource: NativeModelSource by lazy { + modelSourceOverride ?: ContentNativeModelSource(context.androidContext) { message, error -> + context.logger.error("LocalLlmBackend: $message", error) + } + } + + /** + * Drives model residency. Defaults to the shared native engine; overridable so the residency + * rules — evicting a model whose file went away, and releasing its descriptor — can be tested + * without loading real weights. + */ + private val engine: ModelResidencyEngine = engineOverride ?: object : ModelResidencyEngine { + override suspend fun load( + nativePath: String, + contextTokens: Int, + quantizeKv: Boolean, + fallbackContextTokens: Int, + ) = llama.load( + pathToModel = nativePath, + nCtx = contextTokens, + quantizeKv = quantizeKv, + fallbackNCtx = fallbackContextTokens, + ) + override suspend fun unload() = llama.unload() + override suspend fun contextSize() = llama.getContextSize() + } + + /** + * Reports the deletion of the resident model's file, so its gigabytes come back when the user + * deletes it rather than at their next message. Lazy for the same reason as [modelSource]. + */ + private val watcher: ModelSourceWatcher by lazy { + watcherOverride ?: PlatformModelSourceWatcher(context.androidContext) { message, error -> + context.logger.warn("LocalLlmBackend: $message", error) + } + } /** Ensures the background warm-up load is launched at most once. */ private val warmUpStarted = AtomicBoolean(false) + init { + scope.launch { deleteLegacyModelCache() } + } + override fun getId(): String = "local" override fun getName(): String = "Local LLM" @@ -148,8 +234,14 @@ class LocalLlmBackend( context.logger.debug("LocalLlmBackend.isAvailable() - configured path: $configuredPath, modelLoaded: $modelLoaded") // Chat-open hits this; start loading now so the first message isn't gated on a cold load. + // Kept ahead of the check below so a model the user restores is picked up on the next ask. maybeWarmUp(configuredPath) + // A model whose file has gone away is not available, however resident its pages still are. + // Answered from the memo rather than probed here: this runs on the caller's thread, which + // may be the main one, and a document probe is a binder round trip. + if (!configuredPath.isNullOrBlank() && configuredPath == unreachableModelRef) return false + // Available if model is loaded OR if a path is configured return modelLoaded || !configuredPath.isNullOrBlank() } @@ -169,7 +261,7 @@ class LocalLlmBackend( scope.launch { try { // Serialize with real generations so a mid-warm-up send just waits for this load. - generationMutex.withLock { ensureModelLoaded(configuredPath!!) } + generationMutex.withLock { ensureModelLoaded(configuredPath) } context.logger.info("Local model warm-up complete") } catch (e: Exception) { // Stay silent (the real send surfaces config errors); allow a later retry. @@ -180,180 +272,112 @@ class LocalLlmBackend( } /** - * Resolves the user-selected model reference to a real filesystem path the native - * loader can `fopen`. + * Loads [modelRef] unless it is already resident, diagnosing any failure into a + * [ModelLoadException]. Cancellation is rethrown first because [CancellationException] extends + * [IllegalStateException] and would otherwise be diagnosed as a corrupt model. + * + * The model is opened in place — the document the user picked, through the persisted read + * grant — and the native loader is handed the procfs path of that descriptor. Nothing is + * copied. IMPORTANT: this loads *exactly* the file the user selected. It must never fall back + * to "some other .gguf on disk" — doing so silently loads the wrong model (e.g. an embedding + * model), which aborts native inference and takes the IDE down. See ADFA-4388. * - * - A plain path is returned as-is. - * - A `content://` URI (what SAF `OpenDocument` returns, held with persistable read - * permission) is streamed into a private cache file and that path is returned. + * Visible to the module so the failure paths that never reach native code — an unreachable + * model, an embedding model, and the descriptor release that follows both — can be tested off + * a device. * - * IMPORTANT: this loads *exactly* the file the user selected. It must never fall back - * to "some other .gguf on disk" — doing so silently loads the wrong model (e.g. an - * embedding model), which aborts native inference and takes the IDE down. See ADFA-4388. + * @param modelRef the configured model path or content URI */ - private fun resolveContentUriToPath(uriString: String): String? { - if (!uriString.startsWith("content://")) { - return uriString // Already a real file path + internal suspend fun ensureModelLoaded(modelRef: String) { + if (modelLoaded && currentModelRef == modelRef) { + // Residency is not evidence the file still exists. The descriptor this backend holds + // keeps a deleted inode alive, so an unchecked early return keeps answering from a + // model the user threw away — and keeps its gigabytes mapped. Confirm, then serve. + if (modelSource.isReachable(modelRef)) return + context.logger.info("Resident model is no longer reachable; unloading: $modelRef") + evictResidentModel() + throw unopenable(modelRef) } - val uri = Uri.parse(uriString) - context.logger.info("Resolving selected model URI: $uri") + val opened = modelSource.open(modelRef) ?: throw unopenable(modelRef) - val resolver = context.androidContext.contentResolver - - // Read the selected document's display name + size (used to key the cache copy). - var displayName = "model.gguf" - var size = -1L + // Every failure below leaves this handle unadopted; without the finally it would leak a + // file descriptor per failed attempt, and warm-up retries make that a loop. + var adopted = false try { - resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null) - ?.use { c -> - if (c.moveToFirst()) { - val nameIdx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val sizeIdx = c.getColumnIndex(OpenableColumns.SIZE) - if (nameIdx >= 0 && !c.isNull(nameIdx)) displayName = c.getString(nameIdx) - if (sizeIdx >= 0 && !c.isNull(sizeIdx)) size = c.getLong(sizeIdx) - } - } - } catch (e: Exception) { - context.logger.warn("Could not query model metadata for $uri: ${e.message}") - } - - // Deterministic cache path keyed by URI + size, so the same selection reuses the - // same copy and a different selection can never collide with it. - val modelsDir = File(context.androidContext.filesDir, "llm-models").apply { mkdirs() } - val safeName = displayName.replace(Regex("[^A-Za-z0-9._-]"), "_") - val cacheFile = File(modelsDir, "${kotlin.math.abs(uriString.hashCode())}_${size}_$safeName") - - // Reuse a complete prior copy. - if (cacheFile.exists() && (size < 0 || cacheFile.length() == size)) { - context.logger.info("Using cached model copy: ${cacheFile.absolutePath}") - pruneOtherModels(modelsDir, cacheFile) - return cacheFile.absolutePath - } - - // Materialize the selected URI into the cache. Copy to a temp file then rename, so an - // interrupted copy can't be mistaken for a complete model on the next launch. - return try { - context.logger.info("Copying selected model into app storage: $displayName ($size bytes)") - val tmp = File(modelsDir, cacheFile.name + ".tmp") - val copied = resolver.openInputStream(uri)?.use { input -> - FileOutputStream(tmp).use { output -> input.copyTo(output, 1 shl 20) } - } - if (copied == null) { - context.logger.error("Could not open input stream for selected model $uri") - tmp.delete() - return null - } - if (size >= 0 && tmp.length() != size) { - context.logger.error("Model copy incomplete: expected $size bytes, got ${tmp.length()}") - tmp.delete() - return null + // One parse of the metadata block per load, feeding both the guard below and the + // context sizing after the unload: it sits at the front of a multi-GB file, and a + // model switch used to walk it twice. + val header = withContext(Dispatchers.IO) { GgufHeaderReader.read(opened::openStream) } + // The handle's own size, not File.length(): the native path is a procfs entry, on + // which length() reports 0 and would price the KV cache off a zero-byte model. + val modelSizeBytes = opened.sizeBytes.takeIf { it > 0L } + + // Guard the chat path against encoder-only embedding models. Running causal generation + // on one aborts natively (SIGABRT) and takes the IDE down. Classify BEFORE unloading any + // working chat model, so a wrong selection never tears down a good one. See ADFA-4388. + // The overload rescans for the architecture alone, and only if the parse above gave up. + val kind = withContext(Dispatchers.IO) { + GgufModelInspector.classify(header, opened::openStream) } - if (!tmp.renameTo(cacheFile)) { - tmp.copyTo(cacheFile, overwrite = true) - tmp.delete() + // UNKNOWN means the header could not be read, so the guard let this model through + // unchecked. Logged so a future embedding-model abort can be told apart from one that + // got past a header the inspector did read. + context.logger.debug("Model architecture: ${kind.architecture ?: "unreadable"} (${kind.kind})") + if (kind.isEmbeddingOnly) { + throw IncompatibleModelException( + "The selected model is an embedding model and can't be used for chat. " + + "Choose a chat model in AI Settings." + ) } - pruneOtherModels(modelsDir, cacheFile) - context.logger.info("Model ready at ${cacheFile.absolutePath}") - cacheFile.absolutePath - } catch (e: Exception) { - context.logger.error("Failed to copy selected model into app storage", e) - null - } - } - /** - * Keeps only the active model copy in the cache dir. Model files are large, and we only - * ever need the currently-selected one on disk. Deleting a file that native code has - * already mmap'd is safe on Android — the mapping stays valid until the model is freed. - */ - private fun pruneOtherModels(modelsDir: File, keep: File) { - modelsDir.listFiles()?.forEach { f -> - if (f.absolutePath != keep.absolutePath && f.delete()) { - context.logger.debug("Pruned old model copy: ${f.name}") + // Unload old model if loaded + if (modelLoaded) { + context.logger.info("Unloading previous model: $currentModelRef") + evictResidentModel() } - } - } - /** - * Loads [modelPath] unless it is already resident, diagnosing any native failure into a - * [ModelLoadException]. Cancellation is rethrown first because [CancellationException] extends - * [IllegalStateException] and would otherwise be diagnosed as a corrupt model. - * - * @param modelPath the configured model path or content URI - */ - private suspend fun ensureModelLoaded(modelPath: String) { - // Resolve content URI to actual file path - val resolvedPath = resolveContentUriToPath(modelPath) - if (resolvedPath == null) { - throw ModelNotConfiguredException("Could not read the selected model file. Re-select the .gguf model in AI Settings.") - } - - if (modelLoaded && currentModelPath == resolvedPath) { - return // Already loaded - } - - // One parse of the metadata block per load, feeding both the guard below and the context - // sizing after the unload: it sits at the front of a multi-GB file, and a model switch - // used to walk it twice. - // Every stat is inside the block too: isFile and length() both hit the filesystem, which on - // a removed SD card or a stale SAF mount blocks whoever called us. - val openModel = { File(resolvedPath).takeIf { it.isFile }?.inputStream() } - val (header, modelSizeBytes) = withContext(Dispatchers.IO) { - GgufHeaderReader.read(openModel) to File(resolvedPath).length().takeIf { it > 0L } - } - - // Guard the chat path against encoder-only embedding models. Running causal generation on - // one aborts natively (SIGABRT) and takes the IDE down. Classify BEFORE unloading any - // working chat model, so a wrong selection never tears down a good one. See ADFA-4388. - // The overload rescans for the architecture alone, and only if the parse above gave up. - val modelKind = withContext(Dispatchers.IO) { - GgufModelInspector.classify(header, openModel) - } - if (modelKind.isEmbeddingOnly) { - throw IncompatibleModelException( - "The selected model is an embedding model and can't be used for chat. " + - "Choose a chat model in AI Settings." - ) - } - - // Unload old model if loaded - if (modelLoaded) { - context.logger.info("Unloading previous model: $currentModelPath") - llama.unload() - modelLoaded = false - currentModelPath = null - } - - // Measured after the unload: availMem excludes the context and batch it just released. - val availableBytes = availableMemoryBytes() - ModelLoadDiagnostics.refuseBeforeLoad(availableBytes)?.let { shortfall -> - throw ModelLoadException(loadMessages.describe(shortfall), shortfall) - } + // Measured after the unload: availMem excludes the context and batch it just released. + val availableBytes = availableMemoryBytes() + ModelLoadDiagnostics.refuseBeforeLoad(availableBytes)?.let { shortfall -> + throw ModelLoadException(loadMessages.describe(shortfall), shortfall) + } - val contextSize = resolveContextSize(resolvedPath, availableBytes, header, modelSizeBytes) + val contextSize = resolveContextSize(modelRef, availableBytes, header, modelSizeBytes) - context.logger.info("Loading model: $resolvedPath") - try { - llama.load( - pathToModel = resolvedPath, - nCtx = contextSize.contextTokens, - quantizeKv = contextSize.kvType == KvCacheType.Q8_0, - fallbackNCtx = contextSize.fallbackContextTokens, - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - if (e is UserActionableLlmException) throw e - // Native load_model() signals failure only with a null handle, so diagnose the likely cause. - context.logger.error("Native model load failed for $resolvedPath", e) - val diagnosis = ModelLoadDiagnostics.diagnose(resolvedPath, availableMemoryBytes(), e.message) - throw ModelLoadException(loadMessages.describe(diagnosis), diagnosis) + context.logger.info("Loading model: $modelRef via ${opened.nativePath}") + try { + engine.load( + nativePath = opened.nativePath, + contextTokens = contextSize.contextTokens, + quantizeKv = contextSize.kvType == KvCacheType.Q8_0, + fallbackContextTokens = contextSize.fallbackContextTokens, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (e is UserActionableLlmException) throw e + // Native load_model() signals failure only with a null handle, so diagnose the likely cause. + context.logger.error("Native model load failed for $modelRef", e) + val diagnosis = ModelLoadDiagnostics.diagnose( + sizeBytes = opened.sizeBytes, + availableMemoryBytes = availableMemoryBytes(), + nativeError = e.message, + openStream = opened::openStream, + ) + throw ModelLoadException(loadMessages.describe(diagnosis), diagnosis) + } + modelLoaded = true + currentModelRef = modelRef + openModel = opened + unreachableModelRef = null + adopted = true + startWatching(modelRef) + context.logger.info("Model loaded successfully") + reportEffectiveContextSize(contextSize.contextTokens) + } finally { + if (!adopted) opened.close() } - modelLoaded = true - currentModelPath = resolvedPath - context.logger.info("Model loaded successfully") - reportEffectiveContextSize(contextSize.contextTokens) } /** @@ -365,7 +389,7 @@ class LocalLlmBackend( */ private suspend fun reportEffectiveContextSize(requestedTokens: Int) { val actual = try { - llama.getContextSize() + engine.contextSize() } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -385,18 +409,18 @@ class LocalLlmBackend( /** * Sizes the KV cache for this model on this device and picks the type it is stored as. Must run * after any unload, so the freed context is counted as available. Answers rather than applies: - * every part of the shape is an argument to [LLamaAndroid.load], so nothing can drift between - * being chosen here and being used natively. [ModelContextResolver] fails open, so this has no + * every part of the shape is an argument to [ModelResidencyEngine.load], so nothing can drift + * between being chosen here and being used natively. [ModelContextResolver] fails open, so this has no * failure of its own. * - * @param resolvedPath filesystem path to the model, already resolved from any content URI + * @param modelRef the configured model path or content URI, for the log line only * @param availableBytes free RAM as [availableMemoryBytes] reports it, negative if unknown * @param header the model's metadata as read once by [ensureModelLoaded], null if unreadable - * @param modelSizeBytes the model file's size, null if unreadable + * @param modelSizeBytes the model's size, null if unreadable * @return the context size, cache type and f16 fallback size to load the model with */ private fun resolveContextSize( - resolvedPath: String, + modelRef: String, availableBytes: Long, header: GgufHeader?, modelSizeBytes: Long?, @@ -408,7 +432,7 @@ class LocalLlmBackend( ) // Unconditional: a wrongly sized context otherwise just reads as the assistant forgetting. context.logger.info( - "Context size for $resolvedPath: ${resolved.contextTokens} tokens," + + "Context size for $modelRef: ${resolved.contextTokens} tokens," + " ${resolved.kvType} KV cache" + " (model advertises ${resolved.advertisedTokens ?: "unknown"}," + " ${if (availableBytes >= 0L) "$availableBytes bytes free" else "free RAM unknown"})" @@ -416,6 +440,111 @@ class LocalLlmBackend( return resolved } + /** + * Forgets the resident model and releases its descriptor. The native unload is the caller's to + * do first — the mapped pages must be freed before the descriptor behind them goes. + */ + private fun releaseCurrentModel() { + stopWatching() + modelLoaded = false + currentModelRef = null + openModel?.close() + openModel = null + // Re-arm the warm-up: a model that becomes reachable again is loaded without a restart. + warmUpStarted.set(false) + } + + /** + * Gives a resident model back in full — native pages first, then the descriptor holding the + * inode alive. That order is the whole point: closing the descriptor while the loader still + * has its procfs path mapped leaves it reading an entry whose target is gone. + * + * Callers must hold [generationMutex], so a model is never pulled out from under a generation. + */ + private suspend fun evictResidentModel() { + engine.unload() + releaseCurrentModel() + } + + /** + * Records [modelRef] as unreachable and builds the failure to report for it. + * + * @return the exception to throw; never thrown here, so the caller's control flow stays visible + */ + private fun unopenable(modelRef: String): ModelLoadException { + unreachableModelRef = modelRef + val diagnosis = ModelLoadDiagnostics.diagnoseUnopenable(modelRef) + return ModelLoadException(loadMessages.describe(diagnosis), diagnosis) + } + + /** + * Watches the newly resident model's file, so a deletion frees it right away instead of at the + * next message. Best effort — an unwatchable source just leaves the check in + * [ensureModelLoaded] to catch it. + */ + private fun startWatching(modelRef: String) { + modelWatch = try { + watcher.watch(modelRef) { onModelSourceGone(modelRef) } + } catch (e: Exception) { + context.logger.warn("Could not watch the selected model: ${e.message}") + null + } + } + + private fun stopWatching() { + try { + modelWatch?.close() + } catch (e: Exception) { + context.logger.warn("Could not stop watching the selected model: ${e.message}") + } + modelWatch = null + } + + /** + * A watch fired for [modelRef]. Notifications are hints, not verdicts — providers notify for + * edits as well as deletions, and for a whole document tree — so reachability is confirmed + * before anything is torn down. + * + * Runs under [generationMutex] on [cleanupScope]: a generation already in flight finishes on + * the model it started with, and this survives the cancellation of [scope]. + */ + private fun onModelSourceGone(modelRef: String) { + cleanupScope.launch { + generationMutex.withLock { + if (!modelLoaded || currentModelRef != modelRef) return@withLock + if (modelSource.isReachable(modelRef)) return@withLock + context.logger.info("Selected model was deleted; releasing it: $modelRef") + evictResidentModel() + unreachableModelRef = modelRef + } + } + } + + /** + * Deletes the private model copies made before ADFA-5253, which run to gigabytes. The model is + * now read in place through its own grant, so nothing recreates this directory; once it is gone + * this is a single `exists()` call, which is cheaper than storing an "already done" flag. + * + * Walks and deletes gigabytes, so it pins its own dispatcher rather than inheriting whichever + * one a caller happens to launch it on. + * + * Visible to the module so a test can run it deterministically rather than racing [init]. + */ + internal suspend fun deleteLegacyModelCache() = withContext(Dispatchers.IO) { + try { + val legacy = File(context.androidContext.filesDir, LEGACY_MODEL_CACHE_DIR) + if (!legacy.exists()) return@withContext + val freedBytes = legacy.walkBottomUp().filter { it.isFile }.sumOf { it.length() } + if (legacy.deleteRecursively()) { + context.logger.info("Reclaimed ${ByteSize.format(freedBytes)} of copied model files") + } else { + context.logger.warn("Could not fully delete the old model cache at ${legacy.absolutePath}") + } + } catch (e: Exception) { + context.logger.warn("Could not delete the old model cache: ${e.message}") + } + } + /** * @return free RAM the OS reports, or -1 if unreadable (diagnosis then skips the low-memory case) */ @@ -676,9 +805,7 @@ class LocalLlmBackend( /** Suspending model unload — safe to call from any coroutine. */ private suspend fun unloadModelInternal() { if (modelLoaded) { - llama.unload() - modelLoaded = false - currentModelPath = null + evictResidentModel() context.logger.info("Model unloaded") } } @@ -698,6 +825,8 @@ class LocalLlmBackend( fun close() { scope.cancel() val cleanup = cleanupScope.launch { + // Ahead of the native check: a watch outliving the plugin would fire into a dead scope. + stopWatching() if (!llamaLazy.isInitialized()) { return@launch } diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/ModelResidencyEngine.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/ModelResidencyEngine.kt new file mode 100644 index 00000000..2248a2c0 --- /dev/null +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/ModelResidencyEngine.kt @@ -0,0 +1,39 @@ +package com.itsaky.androidide.plugins.aiagentlocal.backend + +/** + * The slice of the native engine that owns model residency: making a model resident, and giving + * it back. Generation itself still goes straight to `LLamaAndroid`. + * + * A seam rather than a wrapper: it exists so the residency rules that matter most (a model whose + * file went away is unloaded, and its descriptor released) can be exercised off a device, where + * loading real weights is not an option. See ADFA-5253. + */ +interface ModelResidencyEngine { + + /** + * Every part of the load's shape is an argument rather than engine state, so nothing can drift + * between being sized here and being allocated natively. See ADFA-5188. + * + * @param nativePath the path handed to the loader; a procfs entry for a picked document + * @param contextTokens the KV-cache size to create the context with, as sized per model and device + * @param quantizeKv true to store the KV cache as q8_0; the engine may still refuse it, in + * which case it falls back to f16 at [fallbackContextTokens] + * @param fallbackContextTokens the context that f16 fallback gets, sized against f16's own + * per-token cost + */ + suspend fun load( + nativePath: String, + contextTokens: Int, + quantizeKv: Boolean, + fallbackContextTokens: Int, + ) + + /** Frees the model's mapped pages and the buffers around them. */ + suspend fun unload() + + /** + * @return the size of the context actually created, which the loader may clamp below the + * requested one + */ + suspend fun contextSize(): Int +} diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/GgufModelInspector.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/GgufModelInspector.kt index 8d13d817..c0f193f2 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/GgufModelInspector.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/GgufModelInspector.kt @@ -2,8 +2,6 @@ package com.itsaky.androidide.plugins.aiagentlocal.model import java.io.BufferedInputStream import java.io.DataInputStream -import java.io.File -import java.io.FileInputStream import java.io.InputStream /** @@ -18,6 +16,10 @@ import java.io.InputStream * It deliberately **fails open**: an unreadable header or a missing architecture is reported as * [ModelKind.UNKNOWN] and treated as chat-capable, so a genuine chat model is never wrongly * blocked by a header quirk. + * + * Both entry points take a stream *factory* rather than a path, because since ADFA-5253 the model + * is read in place through a `content://` grant and has no stable filesystem path. Each call opens + * its own stream and closes it, so inspection never disturbs the native loader's file offset. */ object GgufModelInspector { @@ -38,11 +40,15 @@ object GgufModelInspector { /** * Cheap magic-only check that never throws; reads just the first 4 bytes. - * @param modelPath path to the candidate file - * @return true if the file begins with the GGUF magic; false on any read error or mismatch + * + * @param openStream opens a fresh read stream over the candidate model, or returns null when + * it cannot be reached + * @return true if the model begins with the GGUF magic; false on any read error or mismatch */ - fun isGguf(modelPath: String): Boolean = try { - DataInputStream(BufferedInputStream(FileInputStream(File(modelPath)), 16)).use { readU32(it) == GGUF_MAGIC } + fun isGguf(openStream: () -> InputStream?): Boolean = try { + openStream()?.use { stream -> + readU32(DataInputStream(BufferedInputStream(stream, 16))) == GGUF_MAGIC + } ?: false } catch (_: Exception) { false } diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt index fb2ccccf..c783ca4c 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt @@ -35,9 +35,26 @@ interface ModelFileSource { /** Opens the model for reading; null when it cannot be opened. Not for the main thread. */ fun openStream(context: Context, uriString: String): InputStream? + /** + * Whether the model can still be opened right now. A configured model can go away underneath + * the settings screen — deleted, unmounted, or its read grant revoked — and the stored path + * says nothing about that, so the screen has to ask. Reports rather than logs: a model that is + * gone is an answer, not a lookup failure. Not for the main thread. + */ + fun isReadable(context: Context, uriString: String): Boolean + /** Decoded last path segment — a cheap name that at least avoids raw `%3A` escapes. */ fun fallbackDisplayName(uriOrPath: String): String + /** + * Turn the picker's one-off read grant for [uriString] into a persistable one, so the model is + * still readable after the IDE is restarted — nothing is copied into private storage, so that + * grant is the only thing keeping it reachable (ADFA-5253). A no-op for a filesystem path. + * + * @return true when the model will still be readable after a restart + */ + fun persistAccess(context: Context, uriString: String): Boolean + /** * Give back the persistable read grant the picker took for [uriString], for a model the user * ended up not keeping — the grant table has a hard per-app limit. A no-op for a filesystem @@ -76,6 +93,17 @@ class ContentModelFileSource( null } + override fun isReadable(context: Context, uriString: String): Boolean = try { + if (uriString.startsWith(CONTENT_SCHEME)) { + context.contentResolver.openInputStream(Uri.parse(uriString))?.use { true } ?: false + } else { + File(uriString).let { it.isFile && it.canRead() } + } + } catch (e: Exception) { + // Deleted, unmounted, or the grant is gone — all of which mean the same thing here. + false + } + override fun fallbackDisplayName(uriOrPath: String): String = (try { Uri.decode(uriOrPath) @@ -83,6 +111,21 @@ class ContentModelFileSource( uriOrPath }).substringAfterLast('/') + override fun persistAccess(context: Context, uriString: String): Boolean { + if (!uriString.startsWith(CONTENT_SCHEME)) return true + return try { + context.contentResolver.takePersistableUriPermission( + Uri.parse(uriString), + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + true + } catch (e: Exception) { + // A provider that hands out non-persistable grants, or a grant table that is full. + onError("could not persist the read grant for $uriString", e) + false + } + } + override fun releaseAccess(context: Context, uriString: String) { if (!uriString.startsWith(CONTENT_SCHEME)) return try { diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnostics.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnostics.kt index 2e0c663b..706a058e 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnostics.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnostics.kt @@ -1,6 +1,6 @@ package com.itsaky.androidide.plugins.aiagentlocal.model -import java.io.File +import java.io.InputStream /** * Classifies why a native model load failed, as a pure function of the file, free memory, and the @@ -16,9 +16,23 @@ object ModelLoadDiagnostics { /** Floor for [refuseBeforeLoad]; the same allowance the pre-flight estimate budgets for. */ private const val MIN_RUN_BYTES = ModelMemory.RUN_BUFFER_BYTES + /** Marks a reference the document provider owns, rather than a plain filesystem path. */ + private const val CONTENT_SCHEME = "content://" + /** Most likely cause of a load failure; the caller resolves each case to a user-facing string. */ sealed interface Diagnosis { + /** A configured filesystem path with nothing at it — the file was deleted or moved. */ data object FileMissing : Diagnosis + + /** + * A picked document that can no longer be reached: deleted, renamed, on unmounted storage, + * or its persisted read grant was revoked (clearing the IDE's app data does that). + * Distinct from [FileMissing] because the fix is to pick the model again, not to restore a + * path — and distinct from [UnsupportedOrCorrupt], which would send the user chasing a + * corruption that isn't there. See ADFA-5253. + */ + data object SourceUnavailable : Diagnosis + data object FileEmpty : Diagnosis data object NotGguf : Diagnosis /** @@ -38,22 +52,35 @@ object ModelLoadDiagnostics { } /** - * Diagnoses a load that already failed, so it tests a conservative headroom rather than the - * file size: overestimating would blame a corrupt model on memory. [ContextSizePolicy] charges - * the mmap'd weights instead, because it sizes the cache before the load pages them in. + * Why an already-open model failed to load. + * + * Takes the model's size and a stream factory rather than a path: since ADFA-5253 the loader is + * handed the procfs path of a held descriptor, on which `File.length()` reports 0 and + * `File.exists()` says nothing about the underlying document. + * + * Weights are mmap'd, so this tests a conservative headroom rather than the file size: + * overestimating would blame a corrupt model on memory. [ContextSizePolicy] charges the mmap'd + * weights instead, because it sizes the cache before the load pages them in. * - * @param modelPath resolved filesystem path the native loader was handed + * @param sizeBytes the model's size, or negative if the source could not report one * @param availableMemoryBytes free RAM reported by the OS, or negative if unknown * @param nativeError the load failure's message text, or null when unavailable + * @param openStream opens a fresh read stream over the model, or returns null when it is gone * @return the most likely cause of the load failure */ - fun diagnose(modelPath: String, availableMemoryBytes: Long, nativeError: String? = null): Diagnosis { - val file = File(modelPath) - if (!file.exists()) return Diagnosis.FileMissing - - val sizeBytes = file.length() - if (sizeBytes <= 0L) return Diagnosis.FileEmpty - if (!GgufModelInspector.isGguf(modelPath)) return Diagnosis.NotGguf + fun diagnose( + sizeBytes: Long, + availableMemoryBytes: Long, + nativeError: String? = null, + openStream: () -> InputStream?, + ): Diagnosis { + // Only a NEGATIVE size means "unknown"; 0 is a genuine empty file. + if (sizeBytes == 0L) return Diagnosis.FileEmpty + + // Checked before the header read so a source that vanished under us is not mis-reported as + // a malformed one — "pick it again" and "it's corrupt" send the user to different places. + if (!isReadable(openStream)) return Diagnosis.SourceUnavailable + if (!GgufModelInspector.isGguf(openStream)) return Diagnosis.NotGguf // "Already loaded" is a run-loop state problem, not a file or memory one, so report it // before the memory heuristic — otherwise a busy loop is mis-reported as low memory. @@ -80,6 +107,15 @@ object ModelLoadDiagnostics { * @param availableMemoryBytes free RAM reported by the OS, or negative if unknown * @return the shortfall to refuse with, or null to attempt the load */ + /** + * Why a model could not be opened at all, before any load was attempted. + * + * @param modelReference the configured model, as a `content://` URI or a filesystem path + */ + fun diagnoseUnopenable(modelReference: String): Diagnosis = + if (modelReference.startsWith(CONTENT_SCHEME)) Diagnosis.SourceUnavailable + else Diagnosis.FileMissing + fun refuseBeforeLoad(availableMemoryBytes: Long): Diagnosis.LowMemory? = // Only a NEGATIVE reading means "unknown"; 0 is a genuine out-of-memory reading. if (availableMemoryBytes in 0L until MIN_RUN_BYTES) { @@ -88,6 +124,13 @@ object ModelLoadDiagnostics { null } + /** Whether the model can still be opened for reading at all. */ + private fun isReadable(openStream: () -> InputStream?): Boolean = try { + openStream()?.use { true } ?: false + } catch (_: Exception) { + false + } + // The markers below mirror the messages thrown by LLamaAndroid.load(); keep them in sync with // that file. Matching on text is best-effort — an unrecognized message falls back to // UnsupportedOrCorrupt, which is the safe default for a valid-looking file. diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessages.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessages.kt index d0e85ade..70629409 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessages.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessages.kt @@ -22,6 +22,7 @@ internal class ModelLoadMessages(private val context: Context) { */ fun describe(diagnosis: Diagnosis): String = when (diagnosis) { Diagnosis.FileMissing -> context.getString(R.string.llm_load_error_missing) + Diagnosis.SourceUnavailable -> context.getString(R.string.llm_load_error_unavailable) Diagnosis.FileEmpty -> context.getString(R.string.llm_load_error_empty) Diagnosis.NotGguf -> context.getString(R.string.llm_load_error_not_gguf) is Diagnosis.LowMemory -> context.getString( diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt new file mode 100644 index 00000000..c3e5aa13 --- /dev/null +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt @@ -0,0 +1,134 @@ +package com.itsaky.androidide.plugins.aiagentlocal.model + +import android.content.Context +import android.database.ContentObserver +import android.net.Uri +import android.os.FileObserver +import android.os.Handler +import android.os.HandlerThread +import java.io.Closeable +import java.io.File + +/** + * Watches the file behind a resident model and reports when it goes away, so its gigabytes are + * given back at deletion time rather than at the user's next message. + * + * Best-effort by contract: a provider that does not notify simply never fires, and the + * before-generation reachability check stays the guarantee. Nothing here may be the only thing + * standing between a deleted model and a reply. + */ +interface ModelSourceWatcher { + + /** + * @param modelReference the resident model, as a `content://` URI or a filesystem path + * @param onGone invoked, off the caller's thread, when the file looks gone; may fire more than + * once and may fire spuriously, so the callback must confirm before acting + * @return a handle that stops the watch, or null when this source cannot be watched + */ + fun watch(modelReference: String, onGone: () -> Unit): Closeable? +} + +/** + * [ModelSourceWatcher] over the document provider and the filesystem. + * + * Callbacks arrive on a private [HandlerThread] — never the main thread, and never a thread the + * caller owns — started with the first watch and stopped with the last, so an idle plugin holds + * no thread. See ADFA-5253. + * + * @param onError reports a failed registration, so a silently unwatched model can be explained + */ +class PlatformModelSourceWatcher( + private val context: Context, + private val onError: (String, Throwable) -> Unit = { _, _ -> }, +) : ModelSourceWatcher { + + /** Guards [thread] and [handler]; both are touched from watch and from close. */ + private val lock = Any() + + private var thread: HandlerThread? = null + private var handler: Handler? = null + + /** Live watches, so the last one out stops the thread. */ + private var watchCount = 0 + + override fun watch(modelReference: String, onGone: () -> Unit): Closeable? = try { + if (modelReference.startsWith(CONTENT_SCHEME)) { + watchDocument(modelReference, onGone) + } else { + watchFile(modelReference, onGone) + } + } catch (e: Exception) { + onError("could not watch $modelReference", e) + null + } + + /** + * Providers notify on their own terms — often for the parent tree rather than the document, + * and often for edits rather than deletion — so this registers for descendants too and lets + * the callback decide. `onGone` is a hint, never a verdict. + */ + private fun watchDocument(uriString: String, onGone: () -> Unit): Closeable { + val uri = Uri.parse(uriString) + val observer = object : ContentObserver(acquireHandler()) { + override fun onChange(selfChange: Boolean, uri: Uri?) = onGone() + } + try { + context.contentResolver.registerContentObserver(uri, true, observer) + } catch (e: Exception) { + // The handler is already counted; give it back or the thread outlives every watch. + releaseHandler() + throw e + } + return Closeable { + try { + context.contentResolver.unregisterContentObserver(observer) + } finally { + releaseHandler() + } + } + } + + /** + * `DELETE_SELF` covers the delete; `MOVE_SELF` covers a rename or a move to another volume, + * which breaks a configured path just as thoroughly. + */ + private fun watchFile(path: String, onGone: () -> Unit): Closeable? { + val file = File(path) + if (!file.isFile) return null + val observer = object : FileObserver(file, DELETE_SELF or MOVE_SELF) { + override fun onEvent(event: Int, path: String?) = onGone() + } + // The framework holds FileObserver weakly and stops watching once it is collected, so the + // returned handle keeps the only strong reference alive for as long as the watch is wanted. + observer.startWatching() + return Closeable { observer.stopWatching() } + } + + /** Starts the delivery thread on the first watch. */ + private fun acquireHandler(): Handler = synchronized(lock) { + if (thread == null) { + thread = HandlerThread(THREAD_NAME).also { + it.start() + handler = Handler(it.looper) + } + } + watchCount++ + handler!! + } + + /** Stops the delivery thread with the last watch, so an idle plugin holds no thread. */ + private fun releaseHandler() = synchronized(lock) { + watchCount-- + if (watchCount <= 0) { + watchCount = 0 + thread?.quitSafely() + thread = null + handler = null + } + } + + private companion object { + const val CONTENT_SCHEME = "content://" + const val THREAD_NAME = "LocalLlm-ModelWatch" + } +} diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt new file mode 100644 index 00000000..0738cb25 --- /dev/null +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt @@ -0,0 +1,132 @@ +package com.itsaky.androidide.plugins.aiagentlocal.model + +import android.content.Context +import android.net.Uri +import java.io.Closeable +import java.io.File +import java.io.FileInputStream +import java.io.InputStream + +/** + * A model file held open for the native loader. + * + * [nativePath] is a path llama.cpp can `fopen` and `mmap`. For a document picked through SAF that is + * `/proc/self/fd/N` for the descriptor this handle owns: opening that procfs entry re-opens the + * underlying inode with an independent file offset, so the native loader behaves exactly as it does + * for a real path — without copying multiple gigabytes into private storage first. See ADFA-5253. + * + * IMPORTANT: the descriptor must stay open for as long as the model is resident. Closing it + * invalidates the procfs entry, and the pages the loader has mapped are the only thing keeping the + * model alive after that. [close] is therefore the unload path's job, not the load path's. + * + * @property nativePath the path to hand the native loader + * @property sizeBytes the model's size, or -1 when the source could not report one + */ +class OpenModelFile( + val nativePath: String, + val sizeBytes: Long, + private val descriptor: Closeable?, +) : Closeable { + + /** + * Opens an independent read stream over the same bytes the native loader sees — header + * inspection must never disturb the loader's own file offset. + * + * @return the stream, or null when the source became unreadable + */ + fun openStream(): InputStream? = try { + FileInputStream(nativePath) + } catch (_: Exception) { + null + } + + override fun close() { + try { + descriptor?.close() + } catch (_: Exception) { + // Already closed, or the provider died with it — there is nothing left to release. + } + } +} + +/** + * Opens the user's selected model for the native loader, in place and without copying it. + * An interface so the backend's load path can be exercised without a device. + */ +interface NativeModelSource { + + /** + * @param modelReference the configured model, as a `content://` URI or a filesystem path + * @return an open handle the caller owns and must [OpenModelFile.close], or null when the + * model cannot be reached at all (deleted, unmounted, or the read grant was revoked) + */ + fun open(modelReference: String): OpenModelFile? + + /** + * Whether [modelReference] still resolves to something readable, reading none of it. + * + * A resident model cannot answer this itself: the descriptor the loader holds keeps the + * deleted inode alive, so the mapped pages outlive the file and the model keeps replying from + * a document the user has thrown away. Only a fresh open off the reference can tell. + * + * @return true when the model is still there; false for deleted, unmounted, or revoked + */ + fun isReachable(modelReference: String): Boolean +} + +/** + * [NativeModelSource] over the document provider and the filesystem. + * + * @param context supplies the resolver holding the picker's persisted read grant + * @param onError reports a failed open, so a bare "model unavailable" can still be explained + */ +class ContentNativeModelSource( + private val context: Context, + private val onError: (String, Throwable) -> Unit = { _, _ -> }, +) : NativeModelSource { + + override fun open(modelReference: String): OpenModelFile? = + if (modelReference.startsWith(CONTENT_SCHEME)) openDocument(modelReference) + else openFile(modelReference) + + /** + * Takes the document's descriptor and hands the native loader its procfs path. `"r"` is the + * only mode asked for, which is all the persisted grant covers. + */ + private fun openDocument(uriString: String): OpenModelFile? = try { + context.contentResolver.openFileDescriptor(Uri.parse(uriString), "r") + ?.let { OpenModelFile("$FD_DIR${it.fd}", it.statSize, it) } + } catch (e: Exception) { + onError("could not open the selected model $uriString", e) + null + } + + /** + * One binder round trip for a document, one stat for a path — nothing is read, so this is + * cheap enough to ask before every generation. A failure here is the routine answer "it is + * gone", not an error worth reporting through [onError]. + */ + override fun isReachable(modelReference: String): Boolean = try { + if (modelReference.startsWith(CONTENT_SCHEME)) { + context.contentResolver + .openFileDescriptor(Uri.parse(modelReference), "r") + ?.use { true } ?: false + } else { + File(modelReference).isFile + } + } catch (_: Exception) { + false + } + + private fun openFile(path: String): OpenModelFile? = try { + File(path).takeIf { it.isFile }?.let { OpenModelFile(it.absolutePath, it.length(), null) } + } catch (e: Exception) { + onError("could not open the model file $path", e) + null + } + + private companion object { + const val CONTENT_SCHEME = "content://" + const val FD_DIR = "/proc/self/fd/" + } +} diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt index 6613c057..f7409562 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt @@ -243,8 +243,12 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { file is checked before it is stored: a file that isn't a valid .gguf is rejected, and one that looks too large for this device's free memory raises a warning first.

    -

    Load from saved re-selects the model already configured, - which is useful after clearing app data or moving the file.

    +

    The model is read where you saved it and never copied, so leave + the file in place. If it is moved or deleted, if its storage is + disconnected, or if the IDE's app data is cleared, pick it again + with Browse.

    +

    Load from saved reloads the model already configured + without opening the picker — useful after restarting the IDE.

    """.trimIndent(), ), PluginTooltipEntry( diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt index 879d85b7..49b22e6d 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt @@ -1,6 +1,5 @@ package com.itsaky.androidide.plugins.aiagentlocal.settings -import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.LayoutInflater @@ -40,9 +39,9 @@ class LocalLlmSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host { registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> uri?.let { try { - requireContext().contentResolver - .takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION) - viewModel.loadModelFromUri(it.toString(), requireContext()) + // The durable read grant is taken by the view model, with the rest of the + // selection's bookkeeping — see LocalLlmSettingsViewModel.loadModelFromUri. + viewModel.loadModelFromUri(it.toString()) Toast.makeText( requireContext(), getString(R.string.model_loading_toast), @@ -131,10 +130,7 @@ class LocalLlmSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host { wireTooltip(browseButton, LocalLlmPlugin.TOOLTIP_TAG_SETTINGS_LOCAL_MODEL) loadSavedButton.setOnClickListener { - val savedPath = viewModel.savedModelPath.value - if (savedPath != null) { - viewModel.loadModelFromUri(savedPath, requireContext()) - } + viewModel.state.value?.savedModelPath?.let(viewModel::loadModelFromUri) } // Same concept as Browse — choosing which local model to run. wireTooltip(loadSavedButton, LocalLlmPlugin.TOOLTIP_TAG_SETTINGS_LOCAL_MODEL) @@ -159,50 +155,56 @@ class LocalLlmSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host { wireTooltip(this, LocalLlmPlugin.TOOLTIP_TAG_SETTINGS_SIMPLE_PROMPT) } - viewModel.engineState.observe(viewLifecycleOwner) { state -> - when (state) { - is EngineState.Initializing, EngineState.Uninitialized -> { - engineStatusTextView.text = getString(R.string.engine_initializing) - browseButton.isEnabled = false - loadSavedButton.isEnabled = false - } - is EngineState.Initialized -> { - engineStatusTextView.text = getString(R.string.engine_ready) - browseButton.isEnabled = true - loadSavedButton.isEnabled = viewModel.savedModelPath.value != null - } - is EngineState.Error -> { - engineStatusTextView.text = state.message - browseButton.isEnabled = false - loadSavedButton.isEnabled = false - } + // All three lines describe the same model, so they are drawn from one state in one pass: + // an unreachable model must not read as ready on one line and missing on another. + viewModel.state.observe(viewLifecycleOwner) { state -> + engineStatusTextView.text = when (val engine = state.engine) { + is EngineState.NoModel -> getString(R.string.engine_no_model) + is EngineState.ModelUnavailable -> getString(R.string.engine_model_unavailable) + is EngineState.Initializing -> getString(R.string.engine_initializing) + is EngineState.Initialized -> getString(R.string.engine_ready) + is EngineState.Error -> engine.message } - } - viewModel.savedModelPath.observe(viewLifecycleOwner) { path -> - loadSavedButton.isEnabled = - path != null && viewModel.engineState.value is EngineState.Initialized + // Enabled off the model status, not off engine readiness: picking a model is exactly + // how the user recovers from an engine that isn't ready, so it must stay reachable. + val busy = state.model is ModelLoadingState.Loading + browseButton.isEnabled = !busy + loadSavedButton.isEnabled = state.savedModelPath != null && !busy - if (path != null) { + val savedName = state.savedModelName + if (savedName != null) { modelPathTextView.visibility = View.VISIBLE - val fileName = viewModel.getSavedModelName() ?: viewModel.fallbackDisplayName(path) - modelPathTextView.text = getString(R.string.model_saved_path, fileName) + modelPathTextView.text = if (state.model is ModelLoadingState.Unavailable) { + getString(R.string.model_saved_path_unavailable, savedName) + } else { + getString(R.string.model_saved_path, savedName) + } } else { modelPathTextView.visibility = View.GONE } - } - viewModel.modelLoadingState.observe(viewLifecycleOwner) { state -> modelStatusTextView.visibility = View.VISIBLE - modelStatusTextView.text = when (state) { + modelStatusTextView.text = when (val model = state.model) { is ModelLoadingState.Idle -> getString(R.string.model_none_loaded) is ModelLoadingState.Loading -> getString(R.string.model_loading_wait) - is ModelLoadingState.Loaded -> getString(R.string.model_loaded, state.modelName) - is ModelLoadingState.Error -> getString(R.string.model_load_error, state.message) + is ModelLoadingState.Loaded -> getString(R.string.model_loaded, model.modelName) + is ModelLoadingState.Unavailable -> + getString(R.string.model_unavailable, model.modelName) + is ModelLoadingState.Error -> getString(R.string.model_load_error, model.message) } } } + /** + * The model file lives outside the IDE and can be deleted or unmounted while this screen is + * away, so its availability is re-checked on every return rather than only at first load. + */ + override fun onResume() { + super.onResume() + viewModel.refreshSavedModelAvailability() + } + /** * Puts a "this model may not fit" question to the user. Collected under STARTED so the dialog is * never shown to a stopped fragment; the event waits in the ViewModel until then. diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt index 4c6b2217..e6582e4d 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt @@ -34,19 +34,50 @@ sealed class ModelLoadingState { object Idle : ModelLoadingState() object Loading : ModelLoadingState() data class Loaded(val modelName: String) : ModelLoadingState() + + /** + * A model is configured but its file can no longer be read — deleted, unmounted, or the read + * grant revoked. Distinct from [Error]: nothing failed here, the selection simply went stale, + * and the screen has to say so rather than keep reporting the model as loaded (ADFA-5253). + */ + data class Unavailable(val modelName: String) : ModelLoadingState() + data class Error(val message: String) : ModelLoadingState() } /** - * State for the inference engine initialization. + * Whether this backend can serve a request. The engine itself is loaded lazily on the first + * request, so there is no engine to interrogate here and readiness is a statement about the + * configured model: without one that can actually be loaded there is nothing to be ready for. + * Derived from [ModelLoadingState] — see [LocalLlmSettingsViewModel.engineStateFor]. */ sealed class EngineState { - object Uninitialized : EngineState() + /** No model is configured yet, so the engine has nothing to load. */ + object NoModel : EngineState() + + /** A model is configured but its file cannot be read; the engine cannot load it. */ + object ModelUnavailable : EngineState() + object Initializing : EngineState() object Initialized : EngineState() data class Error(val message: String) : EngineState() } +/** + * Everything this pane draws, as one value: the configured model, how it is doing, and the + * readiness that follows from it. One container rather than three streams, so the three lines are + * published in a single dispatch and can never describe different models mid-update. + * + * @param savedModelPath the configured model, as a `content://` URI or a path; null when unset + * @param savedModelName the display name for [savedModelPath] + */ +data class LocalLlmSettingsState( + val savedModelPath: String? = null, + val savedModelName: String? = null, + val model: ModelLoadingState = ModelLoadingState.Idle, + val engine: EngineState = EngineState.NoModel, +) + /** * A selected model that may not fit in this device's memory, with the figures to show the user. * @@ -98,14 +129,25 @@ class LocalLlmSettingsViewModel( private val KEY_SIMPLE_PROMPT = LocalLlmPreferences.KEY_SIMPLE_PROMPT } - private val _savedModelPath = MutableLiveData(null) - val savedModelPath: LiveData get() = _savedModelPath + /** + * The authoritative state, kept here rather than read back from [_state]: `postValue` publishes + * asynchronously, so a background update that read `_state.value` would compute its copy from + * a version two updates old and silently drop the ones in between. + */ + @Volatile private var current = LocalLlmSettingsState() - private val _modelLoadingState = MutableLiveData(ModelLoadingState.Idle) - val modelLoadingState: LiveData get() = _modelLoadingState + private val _state = MutableLiveData(current) + val state: LiveData get() = _state - private val _engineState = MutableLiveData(EngineState.Initialized) - val engineState: LiveData get() = _engineState + /** + * Applies [transform] to the state and publishes the result. Synchronized because the memory + * pre-flight, the availability re-check and a load can all be in flight at once. + */ + @Synchronized + private fun update(transform: (LocalLlmSettingsState) -> LocalLlmSettingsState) { + current = transform(current) + _state.postValue(current) + } /** The memory pre-flight's consent gate; see [loadModelFromUri]. */ private val memoryConfirmation = UserConfirmation() @@ -129,11 +171,71 @@ class LocalLlmSettingsViewModel( private fun checkInitialState() { val savedPath = prefs()?.getString(KEY_MODEL_PATH, null) - _savedModelPath.value = savedPath + val modelState = modelStateFor(savedPath) + // Optimistic: the file has not been read yet. refreshSavedModelAvailability() corrects it. + update { + LocalLlmSettingsState( + savedModelPath = savedPath, + savedModelName = savedPath?.let { displayNameFor(it) }, + model = modelState, + engine = engineStateFor(modelState) ?: EngineState.Initialized, + ) + } + refreshSavedModelAvailability() + } + + /** + * Re-checks that the configured model is still readable and downgrades the status to + * [ModelLoadingState.Unavailable] when it is not. Call whenever this screen becomes visible: + * the file lives outside the IDE, so it can be deleted or unmounted between two visits, and the + * stored path on its own would keep claiming the model is loaded (ADFA-5253). + */ + fun refreshSavedModelAvailability() { + val savedPath = getLocalModelPath() ?: return + val context = getContext()?.androidContext ?: return + + viewModelScope.launch(ioDispatcher) { + val readable = modelFiles.isReadable(context, savedPath) + + // A selection made while the check ran owns the status now; leave it to that load. + if (getLocalModelPath() != savedPath) return@launch + if (current.model is ModelLoadingState.Loading) return@launch + + if (readable) { + // Only ever clears a stale "unavailable": a live Error is about this same model. + if (current.model is ModelLoadingState.Unavailable) { + publishModelState(modelStateFor(savedPath)) + } + } else { + logger?.warn("$TAG: the configured model can no longer be read: $savedPath") + publishModelState( + ModelLoadingState.Unavailable(displayNameFor(savedPath)) + ) + } + } + } + + /** + * Publishes a model status together with the engine readiness that follows from it, in one + * dispatch, so the screen can never draw a model and a readiness that disagree. + */ + private fun publishModelState(model: ModelLoadingState) { + update { it.copy(model = model, engine = engineStateFor(model) ?: it.engine) } + } - // The engine is loaded lazily by the backend, so from this screen it is always "ready". - _engineState.value = EngineState.Initialized - _modelLoadingState.value = modelStateFor(savedPath) + /** + * Engine readiness implied by a model status, or null to leave the engine's status alone. + * + * @param state the model status just published + */ + private fun engineStateFor(state: ModelLoadingState): EngineState? = when (state) { + is ModelLoadingState.Idle -> EngineState.NoModel + is ModelLoadingState.Loading -> EngineState.Initializing + is ModelLoadingState.Loaded -> EngineState.Initialized + is ModelLoadingState.Unavailable -> EngineState.ModelUnavailable + // A rejected *selection* says nothing about the model that is actually configured, which + // this leaves in place — so it must not restate that model's readiness either way. + is ModelLoadingState.Error -> null } /** @@ -144,7 +246,7 @@ class LocalLlmSettingsViewModel( */ private fun modelStateFor(savedPath: String?): ModelLoadingState = if (savedPath != null) { - ModelLoadingState.Loaded(getSavedModelName() ?: fallbackDisplayName(savedPath)) + ModelLoadingState.Loaded(displayNameFor(savedPath)) } else { ModelLoadingState.Idle } @@ -164,20 +266,25 @@ class LocalLlmSettingsViewModel( get() = getContext()?.logger /** Human-readable name persisted alongside the model path at load time, if any. */ - fun getSavedModelName(): String? = + private fun getSavedModelName(): String? = prefs()?.getString(KEY_MODEL_NAME, null)?.takeIf { it.isNotBlank() } private fun saveLocalModelName(name: String?) { prefs()?.edit()?.putString(KEY_MODEL_NAME, name)?.apply() + update { it.copy(savedModelName = name) } } /** Decoded last path segment — a cheap fallback that at least avoids raw %3A escapes. */ - fun fallbackDisplayName(uriOrPath: String): String = modelFiles.fallbackDisplayName(uriOrPath) + private fun fallbackDisplayName(uriOrPath: String): String = + modelFiles.fallbackDisplayName(uriOrPath) + + /** The name to show for a configured model: the one persisted at load time, else the path's. */ + private fun displayNameFor(uriOrPath: String): String = + getSavedModelName() ?: fallbackDisplayName(uriOrPath) fun saveLocalModelPath(path: String) { prefs()?.edit()?.putString(KEY_MODEL_PATH, path)?.apply() - // Use postValue instead of value since this can be called from background threads - _savedModelPath.postValue(path) + update { it.copy(savedModelPath = path) } } fun getLocalModelPath(): String? = prefs()?.getString(KEY_MODEL_PATH, null) @@ -201,21 +308,47 @@ class LocalLlmSettingsViewModel( * makes it load, so the memory pre-flight gates it: a model the user declines is never stored, * and therefore never loaded (ADFA-1798). * + * The read grant is made persistable first: the model is read in place rather than copied, so + * without a durable grant the stored path would stop resolving at the next restart (ADFA-5253). + * * @param uriString the selected model, as a `content://` URI or a filesystem path - * @param context resolves the model's display name, size and header */ - fun loadModelFromUri(uriString: String, context: Context) { + fun loadModelFromUri(uriString: String) { + // This plugin's own context, not the caller's: a UI Context captured by a coroutine that + // outlives the fragment would hold the Activity, and only this one resolves the plugin's + // own resources for the messages below. + val context = getContext()?.androidContext ?: run { + logger?.error("$TAG: no plugin context; cannot select $uriString") + return + } + viewModelScope.launch(ioDispatcher) { - _modelLoadingState.postValue(ModelLoadingState.Loading) + publishModelState(ModelLoadingState.Loading) try { + // Taken before the first read, so every step below works off the durable grant. + if (!modelFiles.persistAccess(context, uriString)) { + // Readable now through the picker's own grant, but not after a restart. Better + // to load it and say so later than to refuse a model the user just picked. + logger?.warn("$TAG: no persistable read grant for $uriString") + } + // One lookup for both: the real file name to show, and the size to estimate from. val fileInfo = modelFiles.info(context, uriString) val fileName = fileInfo.displayName + // Checked before the GGUF sniff so a model that is simply gone — the "Load from + // saved" case after the file was deleted — is not reported as a corrupt one. + if (!modelFiles.isReadable(context, uriString)) { + releaseUnkeptGrant(context, uriString) + publishModelState(ModelLoadingState.Unavailable(fileName)) + return@launch + } + // Rejected up front, so no bad path is persisted or shown as "Loaded". if (!GgufFileInspector.looksLikeGguf(context.contentResolver, uriString)) { - _modelLoadingState.postValue( + releaseUnkeptGrant(context, uriString) + publishModelState( ModelLoadingState.Error( context.getString(R.string.error_model_not_gguf, fileName) ) @@ -225,27 +358,30 @@ class LocalLlmSettingsViewModel( if (!confirmMemoryHeadroom(uriString, fileInfo, context)) { logger?.info("$TAG: model declined at the memory warning: $fileName") - // Never the configured model: re-checking it and declining must not revoke it. - if (uriString != getLocalModelPath()) { - modelFiles.releaseAccess(context, uriString) - } + releaseUnkeptGrant(context, uriString) restoreSavedModelState() return@launch } + // The model being replaced is no longer read by anything, and grants are capped. + val replaced = getLocalModelPath() + if (replaced != null && replaced != uriString) { + modelFiles.releaseAccess(context, replaced) + } + // Persist the name before the path so the savedModelPath observer can read it. saveLocalModelName(fileName) saveLocalModelPath(uriString) // Nothing is loaded here; the engine reads this path when it needs the model. - _modelLoadingState.postValue(ModelLoadingState.Loaded(fileName)) + publishModelState(ModelLoadingState.Loaded(fileName)) logger?.debug("$TAG: model path saved: $uriString ($fileName)") } catch (e: CancellationException) { throw e } catch (e: Exception) { logger?.error("$TAG: error saving model path", e) - _modelLoadingState.postValue( + publishModelState( ModelLoadingState.Error( context.getString(R.string.error_model_save_failed, e.message.orEmpty()) ) @@ -315,11 +451,24 @@ class LocalLlmSettingsViewModel( } } + /** + * Gives back the grant taken for a selection that was not kept, so an abandoned pick does not + * hold a slot in the capped grant table. + * + * Never touches the configured model: re-checking it and abandoning that check must leave the + * model that is actually in use readable. + */ + private fun releaseUnkeptGrant(context: Context, uriString: String) { + if (uriString != getLocalModelPath()) { + modelFiles.releaseAccess(context, uriString) + } + } + /** * Republishes the model that is actually configured, so abandoning a selection leaves the * screen describing the previous model rather than the one that was never stored. */ private fun restoreSavedModelState() { - _modelLoadingState.postValue(modelStateFor(getLocalModelPath())) + publishModelState(modelStateFor(getLocalModelPath())) } } diff --git a/ai-agent-local/src/main/res/layout/fragment_local_llm_settings.xml b/ai-agent-local/src/main/res/layout/fragment_local_llm_settings.xml index c9ce6b2f..133daa4e 100644 --- a/ai-agent-local/src/main/res/layout/fragment_local_llm_settings.xml +++ b/ai-agent-local/src/main/res/layout/fragment_local_llm_settings.xml @@ -9,7 +9,7 @@ android:id="@+id/engine_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="@string/engine_initializing" + android:text="@string/engine_no_model" android:textAppearance="?android:attr/textAppearanceSmall" android:layout_marginTop="8dp" android:textColor="?android:attr/textColorSecondary"/> diff --git a/ai-agent-local/src/main/res/values/strings.xml b/ai-agent-local/src/main/res/values/strings.xml index 2dd066d3..b54fc51a 100644 --- a/ai-agent-local/src/main/res/values/strings.xml +++ b/ai-agent-local/src/main/res/values/strings.xml @@ -3,6 +3,7 @@ The model file could not be found. Re-select the .gguf model in AI Settings. + The selected model can no longer be reached. It may have been moved, deleted, or saved to storage that isn\'t connected right now, or the IDE\'s permission to read it was withdrawn. Select the .gguf model again in AI Settings. The model file is empty — the download may have been interrupted. Re-download the .gguf model and select it again. This file isn\'t a valid .gguf model (it may be corrupt or only partially downloaded). Re-download the model and select it again. Loading this model needs at least %1$s of free memory, but only %2$s is available on this device. Close other apps and try again, or pick a smaller or more heavily quantized model (for example a Q4_K_M build of a 1–3B model). @@ -13,11 +14,15 @@ Error: %s Initializing engine… Engine ready + Engine not ready — no model selected + Engine not ready — the selected model can\'t be reached Saved: %s No model is currently loaded Loading model, please wait… ✅ Model loaded: %s ❌ Error: %s + ⚠️ \"%1$s\" can no longer be reached. It may have been moved, deleted, or saved to storage that isn\'t connected right now. Select the .gguf model again. + Saved: %s (unavailable) Loading model… No model selected Browse for Model File diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt index e2846b40..5e157527 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt @@ -1,21 +1,115 @@ package com.itsaky.androidide.plugins.aiagentlocal.backend +import android.content.Context import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.aiagentlocal.feedback.IncompatibleModelException +import com.itsaky.androidide.plugins.aiagentlocal.feedback.ModelLoadException +import com.itsaky.androidide.plugins.aiagentlocal.model.GgufTestFiles +import com.itsaky.androidide.plugins.aiagentlocal.model.ModelLoadDiagnostics.Diagnosis +import com.itsaky.androidide.plugins.aiagentlocal.model.ModelSourceWatcher +import com.itsaky.androidide.plugins.aiagentlocal.model.NativeModelSource +import com.itsaky.androidide.plugins.aiagentlocal.model.OpenModelFile import com.itsaky.androidide.plugins.services.LlmInferenceService.* +import io.mockk.every import io.mockk.mockk -import org.junit.Test +import java.io.Closeable +import java.io.File +import kotlinx.coroutines.runBlocking import org.junit.Assert.* import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder class LocalLlmBackendTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private lateinit var filesDir: File + private lateinit var pluginContext: PluginContext private lateinit var backend: LocalLlmBackend + /** Records that the model's descriptor was released, which is the fd leak we can catch here. */ + private class RecordingDescriptor : Closeable { + var closed = false + override fun close() { + closed = true + } + } + + /** Serves prepared handles; anything not in [handles] is a model that cannot be reached. */ + private class FakeModelSource(private val handles: Map) : NativeModelSource { + var openCount = 0 + + /** Flipped to simulate the user deleting the file out from under a resident model. */ + var reachable = true + + override fun open(modelReference: String): OpenModelFile? { + openCount++ + return handles[modelReference].takeIf { reachable } + } + + override fun isReachable(modelReference: String): Boolean = + reachable && handles.containsKey(modelReference) + } + + /** Stands in for the native engine, recording residency without loading any weights. */ + private class FakeEngine : ModelResidencyEngine { + var loadCount = 0 + var unloadCount = 0 + + /** The context size the backend sized for the last load, so the sizing is observable. */ + var lastContextTokens = 0 + + /** Whether the last load asked for a quantized KV cache, so that choice is observable. */ + var lastQuantizeKv = false + + override suspend fun load( + nativePath: String, + contextTokens: Int, + quantizeKv: Boolean, + fallbackContextTokens: Int, + ) { + loadCount++ + lastContextTokens = contextTokens + lastQuantizeKv = quantizeKv + } + + override suspend fun unload() { + unloadCount++ + } + + override suspend fun contextSize() = lastContextTokens + } + + /** Captures the delete callback so a test can fire it the way the platform would. */ + private class FakeWatcher : ModelSourceWatcher { + var onGone: (() -> Unit)? = null + var closed = false + override fun watch(modelReference: String, onGone: () -> Unit) = Closeable { + closed = true + }.also { this.onGone = onGone } + } + @Before fun setup() { - backend = LocalLlmBackend(mockk(relaxed = true)) + filesDir = temporaryFolder.newFolder("files") + val androidContext = mockk(relaxed = true) + every { androidContext.filesDir } returns filesDir + pluginContext = mockk(relaxed = true) + every { pluginContext.androidContext } returns androidContext + backend = LocalLlmBackend(pluginContext) } + private fun backendWith(source: NativeModelSource) = LocalLlmBackend(pluginContext, source) + + private fun backendWith( + source: NativeModelSource, + engine: ModelResidencyEngine, + watcher: ModelSourceWatcher = FakeWatcher(), + ) = LocalLlmBackend(pluginContext, source, engine, watcher) + @Test fun testBackendId() { assertEquals("local", backend.getId()) @@ -52,4 +146,176 @@ class LocalLlmBackendTest { // With no model configured, generate() fails fast before any native work. assertTrue(response.error!!.contains("No model configured")) } + + @Test + fun givenAContentUriThatCannotBeOpened_whenLoading_thenFailsAsSourceUnavailable() { + // The model is read in place now, so a revoked grant or a deleted document is the most + // likely failure of all — and must not surface as "your model is corrupt". + val source = FakeModelSource(emptyMap()) + + val error = assertThrows(ModelLoadException::class.java) { + runBlocking { backendWith(source).ensureModelLoaded(CONTENT_URI) } + } + + assertEquals(Diagnosis.SourceUnavailable, error.diagnosis) + assertEquals(1, source.openCount) + } + + @Test + fun givenAConfiguredPathThatIsGone_whenLoading_thenFailsAsFileMissing() { + // A plain path survives from before the picker; "file not found" is still its right answer. + val error = assertThrows(ModelLoadException::class.java) { + runBlocking { backendWith(FakeModelSource(emptyMap())).ensureModelLoaded("/sdcard/model.gguf") } + } + + assertEquals(Diagnosis.FileMissing, error.diagnosis) + } + + @Test + fun givenAnEmbeddingModel_whenLoading_thenRejectedBeforeAnyNativeWork() { + // ADFA-4388: the classify guard must still fire when the header arrives as a stream over a + // document read in place. Reaching native code here would abort the whole IDE. + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(GgufTestFiles.withArchitecture("bert")))) + + assertThrows(IncompatibleModelException::class.java) { + runBlocking { backendWith(source).ensureModelLoaded(CONTENT_URI) } + } + } + + @Test + fun givenARejectedModel_whenLoading_thenItsDescriptorIsReleased() { + // A held descriptor that is never adopted leaks one fd per attempt, and the warm-up retries. + val descriptor = RecordingDescriptor() + val handle = handleFor(GgufTestFiles.withArchitecture("bert"), descriptor) + val source = FakeModelSource(mapOf(CONTENT_URI to handle)) + + assertThrows(IncompatibleModelException::class.java) { + runBlocking { backendWith(source).ensureModelLoaded(CONTENT_URI) } + } + + assertTrue("the rejected model's descriptor must not leak", descriptor.closed) + } + + @Test + fun givenAContentUriModel_whenLoading_thenNothingIsWrittenToInternalStorage() { + // AC 3, as far as a JVM test can honestly go: resolving a picked model must not copy it. + // The device check is `du -sh .../files/llm-models` before and after a real selection. + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(GgufTestFiles.withArchitecture("bert")))) + + assertThrows(IncompatibleModelException::class.java) { + runBlocking { backendWith(source).ensureModelLoaded(CONTENT_URI) } + } + + assertEquals(emptyList(), filesDir.walkTopDown().filter { it.isFile }.map { it.name }.toList()) + } + + @Test + fun givenModelCopiesFromAnEarlierRelease_whenCleaningUp_thenTheyAreDeleted() { + // Without this the ticket saves nothing for anyone who already used the plugin. + val legacyDir = File(filesDir, "llm-models").apply { mkdirs() } + File(legacyDir, "1234_5678_model.gguf").writeBytes(ByteArray(4096)) + + runBlocking { backendWith(FakeModelSource(emptyMap())).deleteLegacyModelCache() } + + assertFalse(legacyDir.exists()) + } + + @Test + fun givenNoLegacyModelCache_whenCleaningUp_thenItIsAQuietNoOp() { + // Runs on every activation, so the common case must neither throw nor create the directory. + runBlocking { backendWith(FakeModelSource(emptyMap())).deleteLegacyModelCache() } + + assertFalse(File(filesDir, "llm-models").exists()) + } + + @Test + fun givenAResidentModelWhoseFileWasDeleted_whenGenerating_thenItIsUnloadedAndReported() { + // The descriptor keeps the deleted inode alive, so without the reachability check the + // model answers happily from a file the user threw away. ADFA-5253. + val descriptor = RecordingDescriptor() + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(chatModel(), descriptor))) + val engine = FakeEngine() + val backend = backendWith(source, engine) + + runBlocking { backend.ensureModelLoaded(CONTENT_URI) } + source.reachable = false + + val error = assertThrows(ModelLoadException::class.java) { + runBlocking { backend.ensureModelLoaded(CONTENT_URI) } + } + + assertEquals(Diagnosis.SourceUnavailable, error.diagnosis) + assertEquals("the model's pages must be freed, not just refused", 1, engine.unloadCount) + assertTrue("the descriptor must be released or the inode stays alive", descriptor.closed) + } + + @Test + fun givenAResidentModelStillOnDisk_whenGenerating_thenItIsServedWithoutReloading() { + // The check must not cost a reload: a document's procfs path differs on every open, and + // reloading gigabytes per message would be far worse than the bug it fixes. + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(chatModel()))) + val engine = FakeEngine() + val backend = backendWith(source, engine) + + runBlocking { + backend.ensureModelLoaded(CONTENT_URI) + backend.ensureModelLoaded(CONTENT_URI) + backend.ensureModelLoaded(CONTENT_URI) + } + + assertEquals(1, engine.loadCount) + assertEquals(0, engine.unloadCount) + } + + @Test + fun givenAResidentModel_whenItsWatchFires_thenItIsUnloadedWithoutWaitingForAMessage() { + // Checkpoint 3: the gigabytes come back at deletion time, not at the user's next message. + val descriptor = RecordingDescriptor() + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(chatModel(), descriptor))) + val engine = FakeEngine() + val watcher = FakeWatcher() + val backend = backendWith(source, engine, watcher) + + runBlocking { backend.ensureModelLoaded(CONTENT_URI) } + source.reachable = false + watcher.onGone!!.invoke() + + awaitUnload(engine) + assertTrue("the descriptor must be released or the inode stays alive", descriptor.closed) + } + + @Test + fun givenAResidentModelThatIsStillThere_whenItsWatchFiresForAnEdit_thenItStaysLoaded() { + // Providers notify for edits and for the whole tree, so a notification is a hint. Acting + // on it unconfirmed would unload a working model mid-conversation. + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(chatModel()))) + val engine = FakeEngine() + val watcher = FakeWatcher() + val backend = backendWith(source, engine, watcher) + + runBlocking { backend.ensureModelLoaded(CONTENT_URI) } + watcher.onGone!!.invoke() + + Thread.sleep(200) + assertEquals("a spurious notification must not unload a reachable model", 0, engine.unloadCount) + } + + /** The eviction runs on the backend's own cleanup scope, so the test waits for it. */ + private fun awaitUnload(engine: FakeEngine) { + val deadline = System.currentTimeMillis() + 2000 + while (engine.unloadCount == 0 && System.currentTimeMillis() < deadline) { + Thread.sleep(10) + } + assertEquals("the deleted model must be unloaded", 1, engine.unloadCount) + } + + /** A minimal GGUF that passes the ADFA-4388 embedding guard, so loads reach the engine. */ + private fun chatModel(): File = GgufTestFiles.withArchitecture("qwen2") + + private fun handleFor(file: File, descriptor: Closeable? = null) = + OpenModelFile(file.absolutePath, file.length(), descriptor) + + private companion object { + const val CONTENT_URI = "content://com.android.externalstorage.documents/document/model.gguf" + } } diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt new file mode 100644 index 00000000..7f1ba276 --- /dev/null +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt @@ -0,0 +1,115 @@ +package com.itsaky.androidide.plugins.aiagentlocal.model + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.net.Uri +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.spyk +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileNotFoundException + +/** + * The read grant is the only thing keeping a picked model reachable — it is read in place, never + * copied — so persisting it is what makes a selection survive a restart (ADFA-5253). + */ +class ContentModelFileSourceTest { + + private lateinit var resolver: ContentResolver + private lateinit var context: Context + private lateinit var uri: Uri + private val errors = mutableListOf() + private val source = ContentModelFileSource { what, _ -> errors += what } + + @Before + fun setup() { + resolver = mockk(relaxed = true) + context = mockk(relaxed = true) + every { context.contentResolver } returns resolver + uri = mockk(relaxed = true) + mockkStatic(Uri::class) + every { Uri.parse(any()) } returns uri + } + + @After + fun tearDown() { + unmockkStatic(Uri::class) + } + + @Test + fun givenContentUri_whenPersistAccess_thenTakesPersistableReadPermission() { + assertTrue(source.persistAccess(context, CONTENT_URI)) + + verify { resolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } + assertTrue(errors.toString(), errors.isEmpty()) + } + + @Test + fun givenFilesystemPath_whenPersistAccess_thenNoGrantIsNeeded() { + assertTrue(source.persistAccess(context, "/sdcard/Download/model.gguf")) + + verify(exactly = 0) { resolver.takePersistableUriPermission(any(), any()) } + } + + @Test + fun givenNonPersistableGrant_whenPersistAccess_thenReportsFailureWithoutThrowing() { + every { resolver.takePersistableUriPermission(any(), any()) } throws + SecurityException("No persistable permission grants found") + + assertFalse(source.persistAccess(context, CONTENT_URI)) + assertEquals(1, errors.size) + } + + @Test + fun givenContentUri_whenReleaseAccess_thenGivesTheReadGrantBack() { + source.releaseAccess(context, CONTENT_URI) + + verify { + resolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + + @Test + fun givenDeletedDocument_whenIsReadable_thenFalseWithoutReportingAnError() { + every { resolver.openInputStream(uri) } throws + FileNotFoundException("open failed: ENOENT (No such file or directory)") + + assertFalse(source.isReadable(context, CONTENT_URI)) + // A model that is gone is an answer for the caller, not a lookup failure to log. + assertTrue(errors.toString(), errors.isEmpty()) + } + + @Test + fun givenOpenableDocument_whenIsReadable_thenTrueAndTheStreamIsClosed() { + val stream = spyk(ByteArrayInputStream(ByteArray(4))) + every { resolver.openInputStream(uri) } returns stream + + assertTrue(source.isReadable(context, CONTENT_URI)) + verify { stream.close() } + } + + @Test + fun givenMissingFilesystemPath_whenIsReadable_thenFalse() { + assertFalse(source.isReadable(context, "/sdcard/Download/gone.gguf")) + } + + @Test + fun givenExistingFile_whenIsReadable_thenTrue() { + val file = File.createTempFile("model", ".gguf").apply { deleteOnExit() } + + assertTrue(source.isReadable(context, file.absolutePath)) + } + + private companion object { + const val CONTENT_URI = "content://com.android.providers.downloads/document/42" + } +} diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt new file mode 100644 index 00000000..9f377ccd --- /dev/null +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt @@ -0,0 +1,114 @@ +package com.itsaky.androidide.plugins.aiagentlocal.model + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.ParcelFileDescriptor +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import java.io.File +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +/** + * A resident model cannot notice its own file being deleted — the descriptor the loader holds + * keeps the inode alive — so the reachability probe is the only thing that can (ADFA-5253). + */ +class ContentNativeModelSourceTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private lateinit var resolver: ContentResolver + private lateinit var context: Context + private lateinit var source: ContentNativeModelSource + + @Before + fun setup() { + resolver = mockk(relaxed = true) + context = mockk(relaxed = true) + every { context.contentResolver } returns resolver + mockkStatic(Uri::class) + every { Uri.parse(any()) } returns mockk(relaxed = true) + source = ContentNativeModelSource(context) + } + + @After + fun tearDown() { + unmockkStatic(Uri::class) + } + + @Test + fun givenAPathThatStillExists_whenProbed_thenItIsReachable() { + val model = temporaryFolder.newFile("model.gguf") + + assertTrue(source.isReachable(model.absolutePath)) + } + + @Test + fun givenADeletedPath_whenProbed_thenItIsUnreachable() { + val model = temporaryFolder.newFile("model.gguf") + assertTrue(model.delete()) + + assertFalse(source.isReachable(model.absolutePath)) + } + + @Test + fun givenADirectory_whenProbed_thenItIsUnreachable() { + // A path that resolves but holds no model must not read as a usable one. + val directory = temporaryFolder.newFolder("models") + + assertFalse(source.isReachable(directory.absolutePath)) + } + + @Test + fun givenADocumentTheProviderStillServes_whenProbed_thenItIsReachable() { + every { resolver.openFileDescriptor(any(), "r") } returns mockk(relaxed = true) + + assertTrue(source.isReachable(CONTENT_URI)) + } + + @Test + fun givenADeletedDocument_whenProbed_thenItIsUnreachable() { + // What a deleted document actually does: the provider throws rather than returning null. + every { resolver.openFileDescriptor(any(), "r") } throws java.io.FileNotFoundException() + + assertFalse(source.isReachable(CONTENT_URI)) + } + + @Test + fun givenAProviderThatAnswersWithNothing_whenProbed_thenItIsUnreachable() { + every { resolver.openFileDescriptor(any(), "r") } returns null + + assertFalse(source.isReachable(CONTENT_URI)) + } + + @Test + fun givenAProbedDocument_whenTheProbeEnds_thenItsDescriptorIsClosed() { + // The probe must not leak the fd it opens: one per generation would exhaust the table. + val descriptor = mockk(relaxed = true) + every { resolver.openFileDescriptor(any(), "r") } returns descriptor + + source.isReachable(CONTENT_URI) + + io.mockk.verify { descriptor.close() } + } + + @Test + fun givenADeletedPath_whenOpened_thenNoHandleIsReturned() { + val model = temporaryFolder.newFile("model.gguf") + assertTrue(model.delete()) + + assertNull(source.open(model.absolutePath)) + } + + private companion object { + const val CONTENT_URI = "content://com.android.externalstorage.documents/document/model.gguf" + } +} diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/GgufModelInspectorTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/GgufModelInspectorTest.kt index 2b1312aa..22dca7b2 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/GgufModelInspectorTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/GgufModelInspectorTest.kt @@ -1,15 +1,20 @@ package com.itsaky.androidide.plugins.aiagentlocal.model +import com.itsaky.androidide.plugins.aiagentlocal.model.GgufModelInspector.ModelKind import java.io.ByteArrayOutputStream +import java.io.File import java.io.InputStream import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test /** - * Pins the encoder-only guard against the files a full header parse gives up on. Classifying an - * embedding model as [GgufModelInspector.ModelKind.UNKNOWN] lets it reach a causal `llama_decode`, - * which aborts the whole IDE process — the crash ADFA-4388 added the guard to prevent. + * The ADFA-4388 guard. Running causal generation on an encoder-only model aborts natively and takes + * the IDE down, so misclassifying one is not a wrong message — it is a crash. The parser-give-up + * cases matter most: a file a full header parse rejects still has to be classified, or the guard + * waves it through. Since ADFA-5253 the header arrives as a stream over a document read in place. */ class GgufModelInspectorTest { @@ -18,7 +23,7 @@ class GgufModelInspectorTest { val bytes = gguf(architectureEntry(EMBEDDING_ARCH)) assertEquals(EMBEDDING_ARCH, GgufHeaderReader.read { bytes.inputStream() }?.architecture) - assertEquals(GgufModelInspector.ModelKind.EMBEDDING, classify(bytes).kind) + assertEquals(ModelKind.EMBEDDING, classify(bytes).kind) } @Test @@ -26,7 +31,7 @@ class GgufModelInspectorTest { val bytes = gguf(architectureEntry(EMBEDDING_ARCH), unknownTypeEntry("quirk")) assertNull(GgufHeaderReader.read { bytes.inputStream() }) - assertEquals(GgufModelInspector.ModelKind.EMBEDDING, classify(bytes).kind) + assertEquals(ModelKind.EMBEDDING, classify(bytes).kind) } @Test @@ -34,35 +39,106 @@ class GgufModelInspectorTest { val bytes = gguf(architectureEntry(EMBEDDING_ARCH), declaredEntryCount = 5000L) assertNull(GgufHeaderReader.read { bytes.inputStream() }) - assertEquals(GgufModelInspector.ModelKind.EMBEDDING, classify(bytes).kind) + assertEquals(ModelKind.EMBEDDING, classify(bytes).kind) } @Test fun givenAnUnparseableChatModel_whenClassifying_thenReportsChat() { val bytes = gguf(architectureEntry("llama"), unknownTypeEntry("quirk")) - assertEquals(GgufModelInspector.ModelKind.CHAT, classify(bytes).kind) + assertEquals(ModelKind.CHAT, classify(bytes).kind) } @Test fun givenNoArchitectureAtAll_whenClassifying_thenFailsOpenAsUnknown() { val bytes = gguf(unknownTypeEntry("quirk")) - assertEquals(GgufModelInspector.ModelKind.UNKNOWN, classify(bytes).kind) + assertEquals(ModelKind.UNKNOWN, classify(bytes).kind) } @Test - fun givenAnOpenerThatReturnsNoStream_whenClassifying_thenFailsOpenAsUnknown() { - val result = GgufModelInspector.classify(null) { null } + fun givenABertModel_whenClassified_thenEmbeddingOnly() { + val result = classify(GgufTestFiles.withArchitecture("bert")) - assertEquals(GgufModelInspector.ModelKind.UNKNOWN, result.kind) + assertEquals(ModelKind.EMBEDDING, result.kind) + assertTrue(result.isEmbeddingOnly) } - /** Classifies the way the load path does: one full parse, then the architecture-only retry. */ - private fun classify(bytes: ByteArray): GgufModelInspector.Result { - val openStream: () -> InputStream? = { bytes.inputStream() } - return GgufModelInspector.classify(GgufHeaderReader.read(openStream), openStream) + @Test + fun givenABertFamilyArchitecture_whenClassified_thenStillEmbeddingOnly() { + // The family is matched by substring, so the named variants must not need their own entry. + for (arch in listOf("nomic-bert", "jina-bert-v2", "xlm-roberta")) { + assertTrue(arch, classify(GgufTestFiles.withArchitecture(arch)).isEmbeddingOnly) + } + } + + @Test + fun givenANonBertEmbeddingArchitecture_whenClassified_thenEmbeddingOnly() { + for (arch in listOf("mpnet", "gte", "t5encoder")) { + assertTrue(arch, classify(GgufTestFiles.withArchitecture(arch)).isEmbeddingOnly) + } + } + + @Test + fun givenAChatArchitecture_whenClassified_thenChat() { + val result = classify(GgufTestFiles.withArchitecture("qwen2")) + + assertEquals(ModelKind.CHAT, result.kind) + assertEquals("qwen2", result.architecture) + assertFalse(result.isEmbeddingOnly) } + + @Test + fun givenATruncatedHeader_whenClassified_thenUnknownAndNotBlocked() { + // Fails open: a header quirk must never block a model that would have run fine. + val result = classify(GgufTestFiles.truncated()) + + assertEquals(ModelKind.UNKNOWN, result.kind) + assertFalse(result.isEmbeddingOnly) + } + + @Test + fun givenAnUnreachableSource_whenClassified_thenUnknownRatherThanThrowing() { + // A null stream is what a revoked grant or a deleted file looks like here. + val result = classify { null } + + assertEquals(ModelKind.UNKNOWN, result.kind) + assertNull(result.architecture) + } + + @Test + fun givenAStreamThatThrows_whenClassified_thenUnknownRatherThanPropagating() { + val result = classify { throw java.io.IOException("provider died") } + + assertEquals(ModelKind.UNKNOWN, result.kind) + } + + @Test + fun givenGgufMagic_whenIsGguf_thenTrue() { + assertTrue(GgufModelInspector.isGguf(streamOf(GgufTestFiles.withArchitecture("qwen2")))) + } + + @Test + fun givenNonGgufContent_whenIsGguf_thenFalse() { + assertFalse(GgufModelInspector.isGguf(streamOf(GgufTestFiles.notGguf(64)))) + } + + @Test + fun givenAnUnreachableSource_whenIsGguf_thenFalse() { + assertFalse(GgufModelInspector.isGguf { null }) + } + + private fun streamOf(file: File): () -> InputStream? = + { if (file.isFile) file.inputStream() else null } + + /** Classifies the way the load path does: one full parse, then the architecture-only retry. */ + private fun classify(openStream: () -> InputStream?): GgufModelInspector.Result = + GgufModelInspector.classify(GgufHeaderReader.read(openStream), openStream) + + private fun classify(bytes: ByteArray): GgufModelInspector.Result = + classify { bytes.inputStream() } + + private fun classify(file: File): GgufModelInspector.Result = classify(streamOf(file)) } private const val EMBEDDING_ARCH = "nomic-bert" diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/GgufTestFiles.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/GgufTestFiles.kt new file mode 100644 index 00000000..5a045ee9 --- /dev/null +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/GgufTestFiles.kt @@ -0,0 +1,64 @@ +package com.itsaky.androidide.plugins.aiagentlocal.model + +import java.io.ByteArrayOutputStream +import java.io.File + +/** + * Builds the smallest GGUF files [GgufModelInspector] can be asked to classify: magic, version, + * zero tensors and a single metadata entry. Shared by the inspector's own tests and by the + * backend's load-path tests, which need a real file behind an [OpenModelFile]. + */ +internal object GgufTestFiles { + + private const val MAGIC = "GGUF" + + /** GGUF v3: version >= 2 is what makes counts and lengths 64-bit. */ + private const val VERSION = 3 + + private const val ARCHITECTURE_KEY = "general.architecture" + + /** The GGUF metadata value type for a string. */ + private const val TYPE_STRING = 8 + + /** + * @param architecture the value stored under `general.architecture`, e.g. "bert" or "qwen2" + * @return a temp file holding a complete, minimal GGUF header + */ + fun withArchitecture(architecture: String): File { + val out = ByteArrayOutputStream() + out.write(MAGIC.toByteArray(Charsets.US_ASCII)) + out.writeU32(VERSION) + out.writeU64(0) // tensor_count + out.writeU64(1) // metadata_kv_count + out.writeString(ARCHITECTURE_KEY) + out.writeU32(TYPE_STRING) + out.writeString(architecture) + return tempFile(out.toByteArray()) + } + + /** Valid magic, then nothing — the inspector must fail open rather than throw. */ + fun truncated(): File = tempFile(MAGIC.toByteArray(Charsets.US_ASCII)) + + /** A file that is not a GGUF at all. */ + fun notGguf(sizeBytes: Int): File = tempFile(ByteArray(sizeBytes)) + + private fun tempFile(bytes: ByteArray): File = + File.createTempFile("model", ".gguf").apply { + deleteOnExit() + writeBytes(bytes) + } + + private fun ByteArrayOutputStream.writeU32(value: Int) { + for (i in 0 until 4) write((value shr (8 * i)) and 0xFF) + } + + private fun ByteArrayOutputStream.writeU64(value: Long) { + for (i in 0 until 8) write(((value shr (8 * i)) and 0xFF).toInt()) + } + + private fun ByteArrayOutputStream.writeString(value: String) { + val bytes = value.toByteArray(Charsets.UTF_8) + writeU64(bytes.size.toLong()) + write(bytes) + } +} diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnosticsTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnosticsTest.kt index d047f144..1c1a7155 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnosticsTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnosticsTest.kt @@ -2,14 +2,24 @@ package com.itsaky.androidide.plugins.aiagentlocal.model import com.itsaky.androidide.plugins.aiagentlocal.model.ModelLoadDiagnostics.Diagnosis import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import java.io.File +import java.io.InputStream class ModelLoadDiagnosticsTest { + /** + * Diagnoses [file] as the backend does: its real size, and a factory that re-opens it. Since + * ADFA-5253 the loader is handed a procfs path, so size and readability arrive separately + * rather than being read off a `File`. + */ + private fun diagnose(file: File, availableMemoryBytes: Long, nativeError: String? = null) = + ModelLoadDiagnostics.diagnose(file.length(), availableMemoryBytes, nativeError) { streamOf(file) } + + private fun streamOf(file: File): InputStream? = if (file.isFile) file.inputStream() else null + private fun tempFile(bytes: Int, magic: Boolean = true): File = File.createTempFile("model", ".gguf").apply { deleteOnExit() @@ -23,28 +33,22 @@ class ModelLoadDiagnosticsTest { } } - @Test - fun givenMissingFile_whenDiagnosed_thenFileMissing() { - val d = ModelLoadDiagnostics.diagnose("/does/not/exist.gguf", availableMemoryBytes = 8L shl 30) - assertEquals(Diagnosis.FileMissing, d) - } - @Test fun givenEmptyFile_whenDiagnosed_thenFileEmpty() { - val d = ModelLoadDiagnostics.diagnose(tempFile(0).absolutePath, availableMemoryBytes = 8L shl 30) + val d = diagnose(tempFile(0), availableMemoryBytes = 8L shl 30) assertEquals(Diagnosis.FileEmpty, d) } @Test fun givenNonGgufContent_whenDiagnosed_thenNotGguf() { - val d = ModelLoadDiagnostics.diagnose(tempFile(2048, magic = false).absolutePath, availableMemoryBytes = 8L shl 30) + val d = diagnose(tempFile(2048, magic = false), availableMemoryBytes = 8L shl 30) assertEquals(Diagnosis.NotGguf, d) } @Test fun givenValidGgufAndLowMemory_whenDiagnosed_thenLowMemory() { // 1 MB "model", only 512 KB free -> below the headroom floor. - val d = ModelLoadDiagnostics.diagnose(tempFile(1 shl 20).absolutePath, availableMemoryBytes = 512L shl 10) + val d = diagnose(tempFile(1 shl 20), availableMemoryBytes = 512L shl 10) assertTrue(d is Diagnosis.LowMemory) // neededBytes reports the headroom that tripped the check; here the 256 MB floor dominates. assertEquals(256L shl 20, (d as Diagnosis.LowMemory).neededBytes) @@ -53,23 +57,20 @@ class ModelLoadDiagnosticsTest { @Test fun givenLargeModelAndHeadroomBelowFileSize_whenDiagnosed_thenNotLowMemory() { // Guards the mmap property: free RAM under the file size is not itself a shortage. - val d = ModelLoadDiagnostics.diagnose( - tempFile(1 shl 20).absolutePath, - availableMemoryBytes = 512L shl 20, // 512 MB free, above the floor - ) + val d = diagnose(tempFile(1 shl 20), availableMemoryBytes = 512L shl 20) // above the floor assertEquals(Diagnosis.UnsupportedOrCorrupt, d) } @Test fun givenValidGgufAndAmpleMemory_whenDiagnosed_thenUnsupportedOrCorrupt() { - val d = ModelLoadDiagnostics.diagnose(tempFile(1 shl 20).absolutePath, availableMemoryBytes = 8L shl 30) + val d = diagnose(tempFile(1 shl 20), availableMemoryBytes = 8L shl 30) assertEquals(Diagnosis.UnsupportedOrCorrupt, d) } @Test fun givenUnknownMemory_whenDiagnosed_thenNotLowMemory() { // availMem < 0 (unreadable) must not be treated as "no memory". - val d = ModelLoadDiagnostics.diagnose(tempFile(1 shl 20).absolutePath, availableMemoryBytes = -1L) + val d = diagnose(tempFile(1 shl 20), availableMemoryBytes = -1L) assertEquals(Diagnosis.UnsupportedOrCorrupt, d) } @@ -77,7 +78,7 @@ class ModelLoadDiagnosticsTest { fun givenZeroFreeMemory_whenDiagnosed_thenLowMemory() { // 0 free bytes is a genuine out-of-memory reading (only a negative value means "unknown"), // so it must classify as low memory rather than falling through to unsupported/corrupt. - val d = ModelLoadDiagnostics.diagnose(tempFile(1 shl 20).absolutePath, availableMemoryBytes = 0L) + val d = diagnose(tempFile(1 shl 20), availableMemoryBytes = 0L) assertTrue(d is Diagnosis.LowMemory) assertEquals(0L, (d as Diagnosis.LowMemory).availableBytes) } @@ -85,22 +86,14 @@ class ModelLoadDiagnosticsTest { @Test fun givenAlreadyLoadedError_whenDiagnosed_thenModelBusy() { // A valid file with ample RAM but the run loop is busy must not be blamed on the file. - val d = ModelLoadDiagnostics.diagnose( - tempFile(1 shl 20).absolutePath, - availableMemoryBytes = 8L shl 30, - nativeError = "Model already loaded", - ) + val d = diagnose(tempFile(1 shl 20), availableMemoryBytes = 8L shl 30, nativeError = "Model already loaded") assertEquals(Diagnosis.ModelBusy, d) } @Test fun givenAlreadyLoadedErrorAndLowMemory_whenDiagnosed_thenModelBusyWinsOverMemory() { // "Already loaded" is a state issue, so it outranks the low-memory heuristic. - val d = ModelLoadDiagnostics.diagnose( - tempFile(1 shl 20).absolutePath, - availableMemoryBytes = 512L shl 10, - nativeError = "Model already loaded", - ) + val d = diagnose(tempFile(1 shl 20), availableMemoryBytes = 512L shl 10, nativeError = "Model already loaded") assertEquals(Diagnosis.ModelBusy, d) } @@ -108,22 +101,14 @@ class ModelLoadDiagnosticsTest { fun givenContextAllocError_whenDiagnosed_thenInitializationFailed() { // A valid file with ample RAM that still fails to allocate its context is memory pressure, // not a corrupt file. - val d = ModelLoadDiagnostics.diagnose( - tempFile(1 shl 20).absolutePath, - availableMemoryBytes = 8L shl 30, - nativeError = "new_context() failed", - ) + val d = diagnose(tempFile(1 shl 20), availableMemoryBytes = 8L shl 30, nativeError = "new_context() failed") assertEquals(Diagnosis.InitializationFailed, d) } @Test fun givenUnrecognizedError_whenDiagnosed_thenUnsupportedOrCorrupt() { // An unknown native message on a valid-looking file falls back to the safe default. - val d = ModelLoadDiagnostics.diagnose( - tempFile(1 shl 20).absolutePath, - availableMemoryBytes = 8L shl 30, - nativeError = "something unexpected", - ) + val d = diagnose(tempFile(1 shl 20), availableMemoryBytes = 8L shl 30, nativeError = "something unexpected") assertEquals(Diagnosis.UnsupportedOrCorrupt, d) } @@ -154,13 +139,35 @@ class ModelLoadDiagnosticsTest { } @Test - fun givenGgufMagic_whenIsGguf_thenTrue() { - assertTrue(GgufModelInspector.isGguf(tempFile(64).absolutePath)) + fun givenUnknownSize_whenDiagnosed_thenNotReportedAsEmpty() { + // A provider that cannot report a size hands back -1. Only 0 is genuinely empty; treating + // a negative as "<= 0" would tell every such user their model is a truncated download. + val file = tempFile(1 shl 20) + val d = ModelLoadDiagnostics.diagnose(-1L, availableMemoryBytes = 8L shl 30) { streamOf(file) } + + assertEquals(Diagnosis.UnsupportedOrCorrupt, d) + } + + @Test + fun givenAnUnreadableSourceAndAValidSize_whenDiagnosed_thenUnavailableRatherThanNotGguf() { + // "Re-download the model" is the wrong instruction for a file that is simply out of reach. + val d = ModelLoadDiagnostics.diagnose(1L shl 20, availableMemoryBytes = 8L shl 30) { null } + + assertEquals(Diagnosis.SourceUnavailable, d) } @Test - fun givenNonGgufContent_whenIsGguf_thenFalse() { - assertFalse(GgufModelInspector.isGguf(tempFile(64, magic = false).absolutePath)) - assertFalse(GgufModelInspector.isGguf("/does/not/exist.gguf")) + fun givenAContentUri_whenDiagnoseUnopenable_thenSourceUnavailable() { + val d = ModelLoadDiagnostics.diagnoseUnopenable("content://com.android.providers/document/1") + + assertEquals(Diagnosis.SourceUnavailable, d) + } + + @Test + fun givenAFilesystemPath_whenDiagnoseUnopenable_thenFileMissing() { + // A configured plain path that is gone really is a missing file, not a withdrawn grant. + val d = ModelLoadDiagnostics.diagnoseUnopenable("/sdcard/Download/model.gguf") + + assertEquals(Diagnosis.FileMissing, d) } } diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessagesTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessagesTest.kt index bf4b32c7..095bd417 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessagesTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessagesTest.kt @@ -24,6 +24,13 @@ class ModelLoadMessagesTest { verify { context.getString(R.string.llm_load_error_missing) } } + @Test + fun givenSourceUnavailable_whenDescribed_thenUnavailableString() { + // Must not share FileMissing's wording: one asks for a path, the other for a fresh pick. + messages.describe(Diagnosis.SourceUnavailable) + verify { context.getString(R.string.llm_load_error_unavailable) } + } + @Test fun givenFileEmpty_whenDescribed_thenEmptyString() { messages.describe(Diagnosis.FileEmpty) From 54c95bd09270bd377c53a8efb33fc6951d6aa184 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 3 Sep 2026 13:25:42 -0500 Subject: [PATCH 2/7] fix(ai-agent-local): address review on read-in-place model loading Drop the stale isAvailable() memo, refuse a non-seekable descriptor as SourceNotSeekable, key the pane's unavailable marker off engine status, coalesce watch notifications, and cover openDocument + the grant lifecycle. --- ai-agent-local/ai-agent-local.html | 7 +- ai-agent-local/build.gradle.kts | 3 + .../src/main/assets/docs/index.html | 11 +- .../aiagentlocal/backend/LocalLlmBackend.kt | 51 ++-- .../model/ModelLoadDiagnostics.kt | 23 +- .../aiagentlocal/model/ModelLoadMessages.kt | 1 + .../aiagentlocal/model/ModelSourceWatcher.kt | 6 +- .../aiagentlocal/model/NativeModelSource.kt | 12 +- .../settings/LocalLlmSettingsFragment.kt | 18 +- .../settings/LocalLlmSettingsViewModel.kt | 55 +++- .../src/main/res/values/strings.xml | 1 + .../backend/LocalLlmBackendTest.kt | 47 +++- .../model/ContentNativeModelSourceTest.kt | 43 ++++ .../model/ModelLoadMessagesTest.kt | 7 + .../settings/LocalLlmSettingsViewModelTest.kt | 241 ++++++++++++++++++ 15 files changed, 471 insertions(+), 55 deletions(-) create mode 100644 ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt diff --git a/ai-agent-local/ai-agent-local.html b/ai-agent-local/ai-agent-local.html index 299706c1..6b7412b5 100644 --- a/ai-agent-local/ai-agent-local.html +++ b/ai-agent-local/ai-agent-local.html @@ -64,7 +64,8 @@

    Core functionality

    header and refuses embedding-only models for chat, with a clear error instead of a native crash.
  • Actionable load failures — a failed load is classified (no longer - reachable, empty, not a GGUF, out of memory, unsupported quantization) and + reachable, streamed rather than local, empty, not a GGUF, out of memory, + unsupported quantization) and reported as a message that says what to do next.
  • Direct storage access — a model chosen as a content:// document is read in place, through the read grant the picker persisted. @@ -112,7 +113,9 @@

    Usage

  • Tap Browse and pick a .gguf model file. It is loaded from wherever you saved it, with no copy made; a model larger than the free RAM asks you to confirm first. Leave the file in place — moving or deleting - it breaks the selection.
  • + it breaks the selection. The picker offers device-local documents only: a + model still in a cloud folder can only be read as a stream, which the + in-place loader cannot use.
  • Optionally record the model's published SHA-256, or use Load from saved to return to the model you already selected.
  • diff --git a/ai-agent-local/build.gradle.kts b/ai-agent-local/build.gradle.kts index 0b99ef6b..638a36df 100644 --- a/ai-agent-local/build.gradle.kts +++ b/ai-agent-local/build.gradle.kts @@ -81,6 +81,9 @@ dependencies { testImplementation(files("../libs/plugin-api.jar")) testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk:1.13.8") + // LiveData's postValue needs the arch-core executor swapped for a synchronous one; the + // settings pane publishes its state through it, so its tests cannot run without this. + testImplementation("androidx.arch.core:core-testing:2.2.0") } // The one ABI this plugin ships. Shared by the packaging check and the unit tests. diff --git a/ai-agent-local/src/main/assets/docs/index.html b/ai-agent-local/src/main/assets/docs/index.html index 232bf4f3..5db9e206 100644 --- a/ai-agent-local/src/main/assets/docs/index.html +++ b/ai-agent-local/src/main/assets/docs/index.html @@ -61,8 +61,10 @@

    The settings pane

    picked and reads it where it is — on internal storage, an SD card or a USB volume. Nothing is copied, so a multi-gigabyte model costs no extra device storage. Keep the file where it is: moving or deleting it breaks the - selection. If the file is larger than the device's free RAM, a warning asks - you to confirm before loading. + selection. The picker offers only documents already stored on the device, + because a model still in a cloud folder has to be read as a stream and + cannot be loaded in place. If the file is larger than the device's free RAM, + a warning asks you to confirm before loading.
  • Load from saved — reloads the model you already selected without picking it again. Use this after restarting the IDE, or when a load failed for a transient reason such as low memory.
  • @@ -99,6 +101,11 @@

    Troubleshooting

    the file, or removing the SD card it lives on, breaks the selection. Clearing the IDE's app data also withdraws the permission to read it. Pick the model again with Browse. +
  • "This model is streamed from its storage location" — the file + you picked lives in a cloud folder (Google Drive, OneDrive) rather than on + the device, and can only be read as a stream. Download the + .gguf to the device — Downloads is fine — and pick + it from there.
  • diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt index 30e8ada2..e29db646 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt @@ -115,21 +115,15 @@ class LocalLlmBackend( */ @Volatile private var openModel: OpenModelFile? = null - /** - * The reference last found unreachable, so the chat is told the backend is unavailable - * instead of being sent to a model that is gone. - * - * Held as the reference rather than a flag so picking a different model clears it by itself; - * a successful load clears it for the same one. - */ - @Volatile private var unreachableModelRef: String? = null - /** * Stops the delete watch on the resident model. Follows residency exactly: taken when a model * is adopted, closed when it is released. */ @Volatile private var modelWatch: Closeable? = null + /** Whether a watch-triggered reachability check is already queued; see [onModelSourceGone]. */ + private val sourceCheckInFlight = AtomicBoolean(false) + /** * Opens the configured model for the native loader. Lazy so construction touches no Android * services, and overridable so the load path can be tested without a device. @@ -237,12 +231,8 @@ class LocalLlmBackend( // Kept ahead of the check below so a model the user restores is picked up on the next ask. maybeWarmUp(configuredPath) - // A model whose file has gone away is not available, however resident its pages still are. - // Answered from the memo rather than probed here: this runs on the caller's thread, which - // may be the main one, and a document probe is a binder round trip. - if (!configuredPath.isNullOrBlank() && configuredPath == unreachableModelRef) return false - - // Available if model is loaded OR if a path is configured + // Unreachability is left to ensureModelLoaded: a memo here goes stale the moment the user + // restores the file, refusing their first message, and that path advises them properly. return modelLoaded || !configuredPath.isNullOrBlank() } @@ -305,6 +295,14 @@ class LocalLlmBackend( // file descriptor per failed attempt, and warm-up retries make that a loop. var adopted = false try { + // Before the first read: the openStream calls below would each eat bytes off a pipe + // llama.cpp never gets to read, leaving a fine model diagnosed as corrupt (ADFA-5253). + if (!opened.isSeekable) { + context.logger.warn("The selected model is not a local file: $modelRef") + val diagnosis = ModelLoadDiagnostics.Diagnosis.SourceNotSeekable + throw ModelLoadException(loadMessages.describe(diagnosis), diagnosis) + } + // One parse of the metadata block per load, feeding both the guard below and the // context sizing after the unload: it sits at the front of a multi-GB file, and a // model switch used to walk it twice. @@ -370,7 +368,6 @@ class LocalLlmBackend( modelLoaded = true currentModelRef = modelRef openModel = opened - unreachableModelRef = null adopted = true startWatching(modelRef) context.logger.info("Model loaded successfully") @@ -467,12 +464,11 @@ class LocalLlmBackend( } /** - * Records [modelRef] as unreachable and builds the failure to report for it. + * Builds the failure to report for a model that could not be opened at all. * * @return the exception to throw; never thrown here, so the caller's control flow stays visible */ private fun unopenable(modelRef: String): ModelLoadException { - unreachableModelRef = modelRef val diagnosis = ModelLoadDiagnostics.diagnoseUnopenable(modelRef) return ModelLoadException(loadMessages.describe(diagnosis), diagnosis) } @@ -507,15 +503,22 @@ class LocalLlmBackend( * * Runs under [generationMutex] on [cleanupScope]: a generation already in flight finishes on * the model it started with, and this survives the cancellation of [scope]. + * + * Coalesced through [sourceCheckInFlight]: a chatty provider would otherwise queue one + * coroutine and one binder probe per notification behind [generationMutex]. */ private fun onModelSourceGone(modelRef: String) { + if (!sourceCheckInFlight.compareAndSet(false, true)) return cleanupScope.launch { - generationMutex.withLock { - if (!modelLoaded || currentModelRef != modelRef) return@withLock - if (modelSource.isReachable(modelRef)) return@withLock - context.logger.info("Selected model was deleted; releasing it: $modelRef") - evictResidentModel() - unreachableModelRef = modelRef + try { + generationMutex.withLock { + if (!modelLoaded || currentModelRef != modelRef) return@withLock + if (modelSource.isReachable(modelRef)) return@withLock + context.logger.info("Selected model was deleted; releasing it: $modelRef") + evictResidentModel() + } + } finally { + sourceCheckInFlight.set(false) } } } diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnostics.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnostics.kt index 706a058e..3e014021 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnostics.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnostics.kt @@ -33,6 +33,13 @@ object ModelLoadDiagnostics { */ data object SourceUnavailable : Diagnosis + /** + * The picked document is streamed rather than stored on the device, so its descriptor is a + * pipe the loader cannot `mmap` or re-open. Its own case because the alternative is + * reporting a perfectly good model as corrupt; the fix is to download it (ADFA-5253). + */ + data object SourceNotSeekable : Diagnosis + data object FileEmpty : Diagnosis data object NotGguf : Diagnosis /** @@ -99,14 +106,6 @@ object ModelLoadDiagnostics { else Diagnosis.UnsupportedOrCorrupt } - /** - * Whether to refuse a load outright, before ggml aborts the process trying it. Weighs only the - * compute buffers, so it stays far more permissive than [diagnose]'s attribution headroom: - * the memory-warning dialog lets the user proceed, and a refusal here must not overrule that. - * - * @param availableMemoryBytes free RAM reported by the OS, or negative if unknown - * @return the shortfall to refuse with, or null to attempt the load - */ /** * Why a model could not be opened at all, before any load was attempted. * @@ -116,6 +115,14 @@ object ModelLoadDiagnostics { if (modelReference.startsWith(CONTENT_SCHEME)) Diagnosis.SourceUnavailable else Diagnosis.FileMissing + /** + * Whether to refuse a load outright, before ggml aborts the process trying it. Weighs only the + * compute buffers, so it stays far more permissive than [diagnose]'s attribution headroom: + * the memory-warning dialog lets the user proceed, and a refusal here must not overrule that. + * + * @param availableMemoryBytes free RAM reported by the OS, or negative if unknown + * @return the shortfall to refuse with, or null to attempt the load + */ fun refuseBeforeLoad(availableMemoryBytes: Long): Diagnosis.LowMemory? = // Only a NEGATIVE reading means "unknown"; 0 is a genuine out-of-memory reading. if (availableMemoryBytes in 0L until MIN_RUN_BYTES) { diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessages.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessages.kt index 70629409..99634bdd 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessages.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessages.kt @@ -23,6 +23,7 @@ internal class ModelLoadMessages(private val context: Context) { fun describe(diagnosis: Diagnosis): String = when (diagnosis) { Diagnosis.FileMissing -> context.getString(R.string.llm_load_error_missing) Diagnosis.SourceUnavailable -> context.getString(R.string.llm_load_error_unavailable) + Diagnosis.SourceNotSeekable -> context.getString(R.string.llm_load_error_not_seekable) Diagnosis.FileEmpty -> context.getString(R.string.llm_load_error_empty) Diagnosis.NotGguf -> context.getString(R.string.llm_load_error_not_gguf) is Diagnosis.LowMemory -> context.getString( diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt index c3e5aa13..a76514a4 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt @@ -31,9 +31,9 @@ interface ModelSourceWatcher { /** * [ModelSourceWatcher] over the document provider and the filesystem. * - * Callbacks arrive on a private [HandlerThread] — never the main thread, and never a thread the - * caller owns — started with the first watch and stopped with the last, so an idle plugin holds - * no thread. See ADFA-5253. + * Never the main thread, and never a thread the caller owns: a document watch arrives on a private + * [HandlerThread], started with the first such watch and stopped with the last so an idle plugin + * holds no thread, and a filesystem watch on [FileObserver]'s own. See ADFA-5253. * * @param onError reports a failed registration, so a silently unwatched model can be explained */ diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt index 0738cb25..5c5219d6 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt @@ -20,7 +20,7 @@ import java.io.InputStream * model alive after that. [close] is therefore the unload path's job, not the load path's. * * @property nativePath the path to hand the native loader - * @property sizeBytes the model's size, or -1 when the source could not report one + * @property sizeBytes the model's size, or -1 when the descriptor names no regular file */ class OpenModelFile( val nativePath: String, @@ -28,6 +28,13 @@ class OpenModelFile( private val descriptor: Closeable?, ) : Closeable { + /** + * Whether [nativePath] can be `mmap`ed and re-opened, which everything above assumes. A + * streaming provider (Drive, OneDrive) hands back a pipe instead, for which `statSize` is -1 + * and each [openStream] eats bytes the loader never sees, so the caller must refuse it. + */ + val isSeekable: Boolean get() = sizeBytes >= 0 + /** * Opens an independent read stream over the same bytes the native loader sees — header * inspection must never disturb the loader's own file offset. @@ -91,7 +98,8 @@ class ContentNativeModelSource( /** * Takes the document's descriptor and hands the native loader its procfs path. `"r"` is the - * only mode asked for, which is all the persisted grant covers. + * only mode asked for, which is all the persisted grant covers. The descriptor need not be a + * file — a pipe is reported through [OpenModelFile.isSeekable] for the caller to refuse. */ private fun openDocument(uriString: String): OpenModelFile? = try { context.contentResolver.openFileDescriptor(Uri.parse(uriString), "r") diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt index 49b22e6d..4329a4ae 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt @@ -1,5 +1,7 @@ package com.itsaky.androidide.plugins.aiagentlocal.settings +import android.content.Context +import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.LayoutInflater @@ -36,7 +38,7 @@ class LocalLlmSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host { private var tooltipService: IdeTooltipService? = null private val filePickerLauncher = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + registerForActivityResult(PickLocalDocument) { uri: Uri? -> uri?.let { try { // The durable read grant is taken by the view model, with the rest of the @@ -175,7 +177,9 @@ class LocalLlmSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host { val savedName = state.savedModelName if (savedName != null) { modelPathTextView.visibility = View.VISIBLE - modelPathTextView.text = if (state.model is ModelLoadingState.Unavailable) { + // Off the engine status, which describes the configured model; the model status + // also carries the outcome of a rejected pick, which says nothing about it. + modelPathTextView.text = if (state.engine is EngineState.ModelUnavailable) { getString(R.string.model_saved_path_unavailable, savedName) } else { getString(R.string.model_saved_path, savedName) @@ -253,6 +257,16 @@ class LocalLlmSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host { } } +/** + * The document picker, asked for documents already on the device: a streaming provider hands back + * a pipe the in-place loader cannot `mmap`. Advisory only, so the load path still refuses a + * non-seekable descriptor as `Diagnosis.SourceNotSeekable` (ADFA-5253). + */ +private object PickLocalDocument : ActivityResultContracts.OpenDocument() { + override fun createIntent(context: Context, input: Array): Intent = + super.createIntent(context, input).putExtra(Intent.EXTRA_LOCAL_ONLY, true) +} + /** * Factory for creating [LocalLlmSettingsViewModel] with its PluginContext dependency. */ diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt index e6582e4d..360221c7 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt @@ -223,6 +223,27 @@ class LocalLlmSettingsViewModel( update { it.copy(model = model, engine = engineStateFor(model) ?: it.engine) } } + /** + * Publishes a selection that was not kept: the model line says what went wrong with the pick, + * the engine line keeps describing the *configured* model. A pick of another file hands the + * engine back untouched; "Load from saved" re-picks the configured one, so its failure counts. + * + * @param uriString the pick that was abandoned + * @param model what to say about it + * @param engineBefore the engine status from before the selection started + */ + private fun publishAbandonedSelection( + uriString: String, + model: ModelLoadingState, + engineBefore: EngineState, + ) { + val engine = + if (uriString == getLocalModelPath()) engineStateFor(model) ?: engineBefore + else engineBefore + + update { it.copy(model = model, engine = engine) } + } + /** * Engine readiness implied by a model status, or null to leave the engine's status alone. * @@ -322,6 +343,9 @@ class LocalLlmSettingsViewModel( return } + // Taken before the Loading below overwrites it; an abandoned pick puts it back. + val stateBefore = current + viewModelScope.launch(ioDispatcher) { publishModelState(ModelLoadingState.Loading) @@ -341,17 +365,23 @@ class LocalLlmSettingsViewModel( // saved" case after the file was deleted — is not reported as a corrupt one. if (!modelFiles.isReadable(context, uriString)) { releaseUnkeptGrant(context, uriString) - publishModelState(ModelLoadingState.Unavailable(fileName)) + publishAbandonedSelection( + uriString, + ModelLoadingState.Unavailable(fileName), + stateBefore.engine, + ) return@launch } // Rejected up front, so no bad path is persisted or shown as "Loaded". if (!GgufFileInspector.looksLikeGguf(context.contentResolver, uriString)) { releaseUnkeptGrant(context, uriString) - publishModelState( + publishAbandonedSelection( + uriString, ModelLoadingState.Error( context.getString(R.string.error_model_not_gguf, fileName) - ) + ), + stateBefore.engine, ) return@launch } @@ -359,7 +389,7 @@ class LocalLlmSettingsViewModel( if (!confirmMemoryHeadroom(uriString, fileInfo, context)) { logger?.info("$TAG: model declined at the memory warning: $fileName") releaseUnkeptGrant(context, uriString) - restoreSavedModelState() + restoreStateBefore(stateBefore) return@launch } @@ -381,10 +411,12 @@ class LocalLlmSettingsViewModel( throw e } catch (e: Exception) { logger?.error("$TAG: error saving model path", e) - publishModelState( + publishAbandonedSelection( + uriString, ModelLoadingState.Error( context.getString(R.string.error_model_save_failed, e.message.orEmpty()) - ) + ), + stateBefore.engine, ) } } @@ -465,10 +497,13 @@ class LocalLlmSettingsViewModel( } /** - * Republishes the model that is actually configured, so abandoning a selection leaves the - * screen describing the previous model rather than the one that was never stored. + * Puts the screen back as it was before a selection the user declined outright. Restores both + * lines rather than re-deriving them: a configured model that was already unreachable must + * stay reported that way. + * + * @param stateBefore the state captured before the selection started */ - private fun restoreSavedModelState() { - publishModelState(modelStateFor(getLocalModelPath())) + private fun restoreStateBefore(stateBefore: LocalLlmSettingsState) { + update { stateBefore } } } diff --git a/ai-agent-local/src/main/res/values/strings.xml b/ai-agent-local/src/main/res/values/strings.xml index b54fc51a..6098af12 100644 --- a/ai-agent-local/src/main/res/values/strings.xml +++ b/ai-agent-local/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ The model file could not be found. Re-select the .gguf model in AI Settings. The selected model can no longer be reached. It may have been moved, deleted, or saved to storage that isn\'t connected right now, or the IDE\'s permission to read it was withdrawn. Select the .gguf model again in AI Settings. + This model is streamed from its storage location rather than stored on this device, so it can\'t be read in place. Download the .gguf to the device — for example to Downloads — and select it from there. The model file is empty — the download may have been interrupted. Re-download the .gguf model and select it again. This file isn\'t a valid .gguf model (it may be corrupt or only partially downloaded). Re-download the model and select it again. Loading this model needs at least %1$s of free memory, but only %2$s is available on this device. Close other apps and try again, or pick a smaller or more heavily quantized model (for example a Q4_K_M build of a 1–3B model). diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt index 5e157527..45941bb2 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt @@ -45,13 +45,18 @@ class LocalLlmBackendTest { /** Flipped to simulate the user deleting the file out from under a resident model. */ var reachable = true + /** Reachability probes served, so a burst of watch notifications can be counted. */ + @Volatile var probeCount = 0 + override fun open(modelReference: String): OpenModelFile? { openCount++ return handles[modelReference].takeIf { reachable } } - override fun isReachable(modelReference: String): Boolean = - reachable && handles.containsKey(modelReference) + override fun isReachable(modelReference: String): Boolean { + probeCount++ + return reachable && handles.containsKey(modelReference) + } } /** Stands in for the native engine, recording residency without loading any weights. */ @@ -171,6 +176,44 @@ class LocalLlmBackendTest { assertEquals(Diagnosis.FileMissing, error.diagnosis) } + @Test + fun givenAStreamingDocument_whenLoading_thenRefusedAsNotSeekableWithoutReadingIt() { + // A cloud provider hands back a pipe, whose bytes the header reads would consume before + // llama.cpp sees any: refuse it with its own advice rather than call it corrupt. + val descriptor = RecordingDescriptor() + val pipe = OpenModelFile("/proc/self/fd/7", -1L, descriptor) + val source = FakeModelSource(mapOf(CONTENT_URI to pipe)) + val engine = FakeEngine() + + val error = assertThrows(ModelLoadException::class.java) { + runBlocking { backendWith(source, engine).ensureModelLoaded(CONTENT_URI) } + } + + assertEquals(Diagnosis.SourceNotSeekable, error.diagnosis) + assertEquals("nothing may reach the engine", 0, engine.loadCount) + assertTrue("the refused descriptor must not leak", descriptor.closed) + } + + @Test + fun givenAResidentModel_whenItsWatchFiresRepeatedly_thenOnlyOneCheckIsQueued() { + // One coroutine per notification would pile up behind generationMutex, each waking to + // issue its own binder probe; the gate collapses a burst to a single check. + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(chatModel()))) + val engine = FakeEngine() + val watcher = FakeWatcher() + val backend = backendWith(source, engine, watcher) + + runBlocking { backend.ensureModelLoaded(CONTENT_URI) } + val before = source.probeCount + repeat(50) { watcher.onGone!!.invoke() } + + Thread.sleep(300) + val probes = source.probeCount - before + // Not exactly one: a notification arriving just after a check rightly starts another. + assertTrue("a burst of 50 notifications cost $probes probes", probes in 1..5) + assertEquals("a reachable model must stay loaded", 0, engine.unloadCount) + } + @Test fun givenAnEmbeddingModel_whenLoading_thenRejectedBeforeAnyNativeWork() { // ADFA-4388: the classify guard must still fire when the header arrives as a stream over a diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt index 9f377ccd..d1538421 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt @@ -100,6 +100,49 @@ class ContentNativeModelSourceTest { io.mockk.verify { descriptor.close() } } + @Test + fun givenADocument_whenOpened_thenTheLoaderGetsItsProcfsPathAndSize() { + // The contract the read-in-place change rests on: llama.cpp gets the procfs entry for the + // descriptor this handle owns, and the size comes from statSize, not File.length(). + val descriptor = mockk(relaxed = true) + every { descriptor.fd } returns 42 + every { descriptor.statSize } returns 4_294_967_296L + every { resolver.openFileDescriptor(any(), "r") } returns descriptor + + val opened = source.open(CONTENT_URI) + + assertNotNull(opened) + assertEquals("/proc/self/fd/42", opened!!.nativePath) + assertEquals(4_294_967_296L, opened.sizeBytes) + assertTrue(opened.isSeekable) + // The descriptor belongs to the handle now: closing it here would invalidate the path. + io.mockk.verify(exactly = 0) { descriptor.close() } + } + + @Test + fun givenAStreamingProvider_whenOpened_thenTheHandleIsNotSeekable() { + // A cloud provider hands back a pipe, for which statSize is -1: the caller has to be able + // to tell, or a perfectly good model is reported as corrupt. + val descriptor = mockk(relaxed = true) + every { descriptor.fd } returns 7 + every { descriptor.statSize } returns -1L + every { resolver.openFileDescriptor(any(), "r") } returns descriptor + + assertFalse(source.open(CONTENT_URI)!!.isSeekable) + } + + @Test + fun givenAPathThatExists_whenOpened_thenItIsSeekableAtItsOwnPath() { + // A filesystem path has no descriptor to keep, and must not read as a pipe. + val model = temporaryFolder.newFile("model.gguf").apply { writeBytes(ByteArray(64)) } + + val opened = source.open(model.absolutePath) + + assertEquals(model.absolutePath, opened!!.nativePath) + assertEquals(64L, opened.sizeBytes) + assertTrue(opened.isSeekable) + } + @Test fun givenADeletedPath_whenOpened_thenNoHandleIsReturned() { val model = temporaryFolder.newFile("model.gguf") diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessagesTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessagesTest.kt index 095bd417..2cb3f6ad 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessagesTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessagesTest.kt @@ -31,6 +31,13 @@ class ModelLoadMessagesTest { verify { context.getString(R.string.llm_load_error_unavailable) } } + @Test + fun givenSourceNotSeekable_whenDescribed_thenNotSeekableString() { + // Must not share the corrupt-model wording: the model is fine, its location is the problem. + messages.describe(Diagnosis.SourceNotSeekable) + verify { context.getString(R.string.llm_load_error_not_seekable) } + } + @Test fun givenFileEmpty_whenDescribed_thenEmptyString() { messages.describe(Diagnosis.FileEmpty) diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt new file mode 100644 index 00000000..99fca1ff --- /dev/null +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt @@ -0,0 +1,241 @@ +package com.itsaky.androidide.plugins.aiagentlocal.settings + +import android.content.ContentResolver +import android.content.Context +import android.content.SharedPreferences +import android.net.Uri +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.aiagentlocal.model.DeviceMemory +import com.itsaky.androidide.plugins.aiagentlocal.model.ModelFileInfo +import com.itsaky.androidide.plugins.aiagentlocal.model.ModelFileSource +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import java.io.ByteArrayInputStream +import java.io.InputStream +import kotlinx.coroutines.Dispatchers +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +/** + * The persisted read grant is the only thing keeping a model readable now that nothing is copied, + * so releasing the wrong one strands a model the user is still running. These pin the grant + * lifecycle across the paths that abandon a selection. See ADFA-5253. + */ +class LocalLlmSettingsViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + /** Records what was granted and given back, and answers as the file source would. */ + private class FakeModelFiles : ModelFileSource { + val persisted = mutableListOf() + val released = mutableListOf() + + /** References the provider will not serve, standing in for a deleted document. */ + val unreadable = mutableSetOf() + + override fun info(context: Context, uriString: String) = + ModelFileInfo(fallbackDisplayName(uriString), 1_024L) + + override fun openStream(context: Context, uriString: String): InputStream? = null + + override fun isReadable(context: Context, uriString: String) = uriString !in unreadable + + override fun fallbackDisplayName(uriOrPath: String) = uriOrPath.substringAfterLast('/') + + override fun persistAccess(context: Context, uriString: String): Boolean { + persisted += uriString + return true + } + + override fun releaseAccess(context: Context, uriString: String) { + released += uriString + } + } + + private lateinit var stored: MutableMap + private lateinit var resolver: ContentResolver + private lateinit var pluginContext: PluginContext + private lateinit var modelFiles: FakeModelFiles + + @Before + fun setup() { + mockkStatic(Uri::class) + every { Uri.parse(any()) } returns mockk(relaxed = true) + every { Uri.decode(any()) } answers { firstArg() } + + stored = mutableMapOf() + val prefs = mockk(relaxed = true) + val editor = mockk(relaxed = true) + every { prefs.getString(any(), any()) } answers { stored[firstArg()] ?: secondArg() } + every { prefs.edit() } returns editor + every { editor.putString(any(), any()) } answers { + stored[firstArg()] = secondArg() + editor + } + + resolver = mockk(relaxed = true) + // The GGUF sniff fails OPEN, so a pick is accepted unless a test serves other bytes. + every { resolver.openInputStream(any()) } returns null + val androidContext = mockk(relaxed = true) + every { androidContext.contentResolver } returns resolver + + pluginContext = mockk(relaxed = true) + every { pluginContext.androidContext } returns androidContext + every { pluginContext.getPluginSharedPreferences(any()) } returns prefs + + modelFiles = FakeModelFiles() + } + + @After + fun tearDown() { + unmockkStatic(Uri::class) + } + + /** + * Unconfined, so every launch runs inline: nothing here suspends on a real dispatcher, and the + * memory pre-flight fails open on the fake's unreadable header. + */ + private fun viewModel() = LocalLlmSettingsViewModel( + getContext = { pluginContext }, + ioDispatcher = Dispatchers.Unconfined, + deviceMemory = DeviceMemory { null }, + modelFiles = modelFiles, + ) + + @Test + fun givenASelection_whenItIsKept_thenItsGrantIsPersistedAndStored() { + val viewModel = viewModel() + + viewModel.loadModelFromUri(MODEL_A) + + assertEquals(listOf(MODEL_A), modelFiles.persisted) + assertEquals(emptyList(), modelFiles.released) + assertEquals(MODEL_A, viewModel.getLocalModelPath()) + assertEquals(ModelLoadingState.Loaded("a.gguf"), viewModel.state.value?.model) + } + + @Test + fun givenAConfiguredModel_whenAnotherIsSelected_thenOnlyTheReplacedGrantIsReleased() { + // Grants are capped per app, so the model no longer read by anything has to give its back. + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + + viewModel.loadModelFromUri(MODEL_B) + + assertEquals(listOf(MODEL_A, MODEL_B), modelFiles.persisted) + assertEquals(listOf(MODEL_A), modelFiles.released) + assertEquals(MODEL_B, viewModel.getLocalModelPath()) + } + + @Test + fun givenAConfiguredModel_whenItIsReSelected_thenItsGrantIsNotReleased() { + // "Load from saved" re-picks the configured model; releasing here would revoke the grant + // on the model the user is still running. + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + + viewModel.loadModelFromUri(MODEL_A) + + assertEquals(emptyList(), modelFiles.released) + assertEquals(MODEL_A, viewModel.getLocalModelPath()) + } + + @Test + fun givenAConfiguredModelThatIsGone_whenItIsReSelected_thenItsGrantSurvivesTheFailure() { + // The model may be on storage that is merely unmounted; re-mounting must not need a pick. + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + modelFiles.unreadable += MODEL_A + + viewModel.loadModelFromUri(MODEL_A) + + assertEquals(emptyList(), modelFiles.released) + assertEquals(ModelLoadingState.Unavailable("a.gguf"), viewModel.state.value?.model) + assertEquals(EngineState.ModelUnavailable, viewModel.state.value?.engine) + } + + @Test + fun givenANewSelectionThatIsRejected_thenItsOwnGrantIsGivenBackAndTheConfiguredOneKept() { + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + modelFiles.unreadable += MODEL_B + + viewModel.loadModelFromUri(MODEL_B) + + assertEquals(listOf(MODEL_B), modelFiles.released) + assertEquals("the configured model must survive a failed pick", MODEL_A, viewModel.getLocalModelPath()) + } + + @Test + fun givenANonGgufSelection_thenItIsRejectedWithoutBeingStoredAndItsGrantIsReleased() { + every { resolver.openInputStream(any()) } answers { ByteArrayInputStream("NOPE".toByteArray()) } + val viewModel = viewModel() + + viewModel.loadModelFromUri(MODEL_B) + + assertEquals(listOf(MODEL_B), modelFiles.released) + assertEquals(null, viewModel.getLocalModelPath()) + assertTrue(viewModel.state.value?.model is ModelLoadingState.Error) + } + + @Test + fun givenARejectedSelection_thenTheConfiguredModelsReadinessIsLeftAlone() { + // The pane keys its "(unavailable)" marker off the engine status, so a rejected pick of + // another file must leave it alone. + every { resolver.openInputStream(any()) } answers { ByteArrayInputStream("NOPE".toByteArray()) } + val viewModel = viewModel() + stored[KEY_MODEL_PATH] = MODEL_A + viewModel.refreshSavedModelAvailability() + modelFiles.unreadable += MODEL_A + viewModel.refreshSavedModelAvailability() + assertEquals(EngineState.ModelUnavailable, viewModel.state.value?.engine) + + viewModel.loadModelFromUri(MODEL_B) + + // Not Initializing: the pick published that on its way in and never got anywhere. + assertEquals(EngineState.ModelUnavailable, viewModel.state.value?.engine) + assertTrue(viewModel.state.value?.model is ModelLoadingState.Error) + } + + @Test + fun givenAConfiguredModelThatWentAway_whenTheScreenReturns_thenItIsReportedUnavailable() { + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + modelFiles.unreadable += MODEL_A + + viewModel.refreshSavedModelAvailability() + + assertEquals(ModelLoadingState.Unavailable("a.gguf"), viewModel.state.value?.model) + assertEquals(EngineState.ModelUnavailable, viewModel.state.value?.engine) + assertEquals("a re-check must not touch the grant", emptyList(), modelFiles.released) + } + + @Test + fun givenAModelThatCameBack_whenTheScreenReturns_thenItIsReportedReadyAgain() { + // Unmounted storage comes back; the stale "unavailable" has to clear without a fresh pick. + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + modelFiles.unreadable += MODEL_A + viewModel.refreshSavedModelAvailability() + + modelFiles.unreadable -= MODEL_A + viewModel.refreshSavedModelAvailability() + + assertEquals(ModelLoadingState.Loaded("a.gguf"), viewModel.state.value?.model) + assertEquals(EngineState.Initialized, viewModel.state.value?.engine) + } + + private companion object { + const val MODEL_A = "content://com.android.externalstorage.documents/document/a.gguf" + const val MODEL_B = "content://com.android.externalstorage.documents/document/b.gguf" + const val KEY_MODEL_PATH = "local_llm_model_path" + } +} From 86da4412c54dff5a9285a3b032089f5757e2db57 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Fri, 4 Sep 2026 11:41:26 -0500 Subject: [PATCH 3/7] fix(ai-agent-local): address round-2 review on read-in-place model loading Separate "the provider said no" from "the provider did not answer" so a dead DocumentsProvider no longer evicts a resident multi-GB model, watch the parent's children URI where a delete is actually notified, and refuse a procfs path the native loader cannot re-open with its own diagnosis. --- .../src/main/assets/docs/index.html | 19 ++-- .../aiagentlocal/backend/LocalLlmBackend.kt | 39 +++++--- .../aiagentlocal/model/ModelFileSource.kt | 3 +- .../model/ModelLoadDiagnostics.kt | 7 ++ .../aiagentlocal/model/ModelLoadMessages.kt | 1 + .../aiagentlocal/model/ModelSourceWatcher.kt | 46 ++++++++- .../aiagentlocal/model/NativeModelSource.kt | 75 +++++++++++--- .../settings/LocalLlmSettingsViewModel.kt | 28 +++--- .../src/main/res/values/strings.xml | 1 + .../backend/LocalLlmBackendTest.kt | 99 ++++++++++++++++++- .../model/ContentNativeModelSourceTest.kt | 56 ++++++++--- .../model/ModelSourceWatcherTest.kt | 78 +++++++++++++++ .../settings/LocalLlmSettingsViewModelTest.kt | 87 ++++++++++++++-- 13 files changed, 465 insertions(+), 74 deletions(-) create mode 100644 ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcherTest.kt diff --git a/ai-agent-local/src/main/assets/docs/index.html b/ai-agent-local/src/main/assets/docs/index.html index 5db9e206..d0340c77 100644 --- a/ai-agent-local/src/main/assets/docs/index.html +++ b/ai-agent-local/src/main/assets/docs/index.html @@ -58,12 +58,14 @@

    The settings pane

    • Browse — opens the system file picker to choose a .gguf model. The plugin keeps read access to the document you - picked and reads it where it is — on internal storage, an SD card or a USB - volume. Nothing is copied, so a multi-gigabyte model costs no extra device - storage. Keep the file where it is: moving or deleting it breaks the - selection. The picker offers only documents already stored on the device, - because a model still in a cloud folder has to be read as a stream and - cannot be loaded in place. If the file is larger than the device's free RAM, + picked and reads it where it is, so nothing is copied and a multi-gigabyte + model costs no extra device storage. Internal storage always works; a + removable volume such as an SD card or a USB drive normally does too, and + when one won't allow a direct read the plugin says so and asks you to copy + the model to internal storage. Keep the file where it is: moving or + deleting it breaks the selection. The picker offers only documents already + stored on the device, because a model still in a cloud folder has to be + read as a stream and cannot be loaded in place. If the file is larger than the device's free RAM, a warning asks you to confirm before loading.
    • Load from saved — reloads the model you already selected without picking it again. Use this after restarting the IDE, or when a load @@ -101,6 +103,11 @@

      Troubleshooting

      the file, or removing the SD card it lives on, breaks the selection. Clearing the IDE's app data also withdraws the permission to read it. Pick the model again with Browse.
    • +
    • "This model can't be read from where it is stored" — the + volume the file sits on doesn't let the IDE open it directly, which can + happen on some removable storage. Copy the .gguf to the + device's internal storage — Downloads is fine — and pick it from + there.
    • "This model is streamed from its storage location" — the file you picked lives in a cloud folder (Google Drive, OneDrive) rather than on the device, and can only be read as a stream. Download the diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt index e29db646..1b9fc4a3 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt @@ -22,6 +22,7 @@ import com.itsaky.androidide.plugins.aiagentlocal.model.ModelSourceWatcher import com.itsaky.androidide.plugins.aiagentlocal.model.NativeModelSource import com.itsaky.androidide.plugins.aiagentlocal.model.OpenModelFile import com.itsaky.androidide.plugins.aiagentlocal.model.PlatformModelSourceWatcher +import com.itsaky.androidide.plugins.aiagentlocal.model.SourceReachability import com.itsaky.androidide.plugins.aiagentlocal.preferences.LocalLlmPreferences import com.itsaky.androidide.plugins.aiagentlocal.prompt.LocalSystemPrompt import com.itsaky.androidide.plugins.services.LlmInferenceService @@ -31,6 +32,7 @@ import java.io.Closeable import java.io.File import java.util.concurrent.CompletableFuture import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -165,8 +167,12 @@ class LocalLlmBackend( } } - /** Ensures the background warm-up load is launched at most once. */ - private val warmUpStarted = AtomicBoolean(false) + /** + * The reference the background warm-up has already been launched for. Keyed on the reference + * rather than a flag: a failed warm-up leaves the same path configured, so re-arming it would + * launch one doomed load per [isAvailable] call, which the chat screen makes on open. + */ + private val warmedUpRef = AtomicReference(null) init { scope.launch { deleteLegacyModelCache() } @@ -228,7 +234,7 @@ class LocalLlmBackend( context.logger.debug("LocalLlmBackend.isAvailable() - configured path: $configuredPath, modelLoaded: $modelLoaded") // Chat-open hits this; start loading now so the first message isn't gated on a cold load. - // Kept ahead of the check below so a model the user restores is picked up on the next ask. + // Only ever the first ask for a given selection — a restored file is loaded by the send. maybeWarmUp(configuredPath) // Unreachability is left to ensureModelLoaded: a memo here goes stale the moment the user @@ -237,16 +243,17 @@ class LocalLlmBackend( } /** - * Preloads the configured model in the background, once, so the first generation - * doesn't pay the cold-load cost. No-op unless this backend is the selected one — warming a - * multi-gigabyte model for a user who picked a cloud backend would be pure waste. + * Preloads the configured model in the background, once per selection, so the first generation + * doesn't pay the cold-load cost. No-op unless this backend is the selected one, and never + * retried for a reference that failed — the generation path loads and diagnoses that one. * * @param configuredPath the configured model path/URI, or null/blank if unset. */ private fun maybeWarmUp(configuredPath: String?) { if (configuredPath.isNullOrBlank() || modelLoaded) return if (!isSelectedBackend()) return - if (!warmUpStarted.compareAndSet(false, true)) return + // A different selection re-arms it; the same one, failed or not, does not. + if (warmedUpRef.getAndSet(configuredPath) == configuredPath) return scope.launch { try { @@ -254,9 +261,8 @@ class LocalLlmBackend( generationMutex.withLock { ensureModelLoaded(configuredPath) } context.logger.info("Local model warm-up complete") } catch (e: Exception) { - // Stay silent (the real send surfaces config errors); allow a later retry. + // Stay silent and do not re-arm: the real send surfaces config errors. context.logger.warn("Local model warm-up failed: ${e.message}") - warmUpStarted.set(false) } } } @@ -283,7 +289,8 @@ class LocalLlmBackend( // Residency is not evidence the file still exists. The descriptor this backend holds // keeps a deleted inode alive, so an unchecked early return keeps answering from a // model the user threw away — and keeps its gigabytes mapped. Confirm, then serve. - if (modelSource.isReachable(modelRef)) return + // Anything but GONE is served: a silent provider is no reason to pay a GB reload. + if (modelSource.reachabilityOf(modelRef) != SourceReachability.GONE) return context.logger.info("Resident model is no longer reachable; unloading: $modelRef") evictResidentModel() throw unopenable(modelRef) @@ -303,6 +310,13 @@ class LocalLlmBackend( throw ModelLoadException(loadMessages.describe(diagnosis), diagnosis) } + // Or it arrives as the loader's null handle: "pick it again" for a file that is there. + if (!withContext(Dispatchers.IO) { opened.isReopenable() }) { + context.logger.warn("The selected model cannot be re-opened by path: $modelRef") + val diagnosis = ModelLoadDiagnostics.Diagnosis.SourceNotReopenable + throw ModelLoadException(loadMessages.describe(diagnosis), diagnosis) + } + // One parse of the metadata block per load, feeding both the guard below and the // context sizing after the unload: it sits at the front of a multi-GB file, and a // model switch used to walk it twice. @@ -447,8 +461,6 @@ class LocalLlmBackend( currentModelRef = null openModel?.close() openModel = null - // Re-arm the warm-up: a model that becomes reachable again is loaded without a restart. - warmUpStarted.set(false) } /** @@ -513,7 +525,8 @@ class LocalLlmBackend( try { generationMutex.withLock { if (!modelLoaded || currentModelRef != modelRef) return@withLock - if (modelSource.isReachable(modelRef)) return@withLock + // Only what the provider itself called gone may cost a model its pages. + if (modelSource.reachabilityOf(modelRef) != SourceReachability.GONE) return@withLock context.logger.info("Selected model was deleted; releasing it: $modelRef") evictResidentModel() } diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt index c783ca4c..c9f517c8 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt @@ -133,8 +133,9 @@ class ContentModelFileSource( Uri.parse(uriString), Intent.FLAG_GRANT_READ_URI_PERMISSION, ) + } catch (_: SecurityException) { + // Nothing was held, or it was already released: the no-op this documents. } catch (e: Exception) { - // Never held, or already released — nothing is broken either way. onError("could not release the read grant for $uriString", e) } } diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnostics.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnostics.kt index 3e014021..c8378de7 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnostics.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadDiagnostics.kt @@ -40,6 +40,13 @@ object ModelLoadDiagnostics { */ data object SourceNotSeekable : Diagnosis + /** + * The document opened, but the path standing in for it cannot be opened by name — the only + * way the native loader uses it. Its own case because "pick the model again" is useless + * advice for a file sitting where the user left it; see [OpenModelFile.isReopenable]. + */ + data object SourceNotReopenable : Diagnosis + data object FileEmpty : Diagnosis data object NotGguf : Diagnosis /** diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessages.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessages.kt index 99634bdd..45ae9877 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessages.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelLoadMessages.kt @@ -24,6 +24,7 @@ internal class ModelLoadMessages(private val context: Context) { Diagnosis.FileMissing -> context.getString(R.string.llm_load_error_missing) Diagnosis.SourceUnavailable -> context.getString(R.string.llm_load_error_unavailable) Diagnosis.SourceNotSeekable -> context.getString(R.string.llm_load_error_not_seekable) + Diagnosis.SourceNotReopenable -> context.getString(R.string.llm_load_error_not_reopenable) Diagnosis.FileEmpty -> context.getString(R.string.llm_load_error_empty) Diagnosis.NotGguf -> context.getString(R.string.llm_load_error_not_gguf) is Diagnosis.LowMemory -> context.getString( diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt index a76514a4..a033c053 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt @@ -6,8 +6,10 @@ import android.net.Uri import android.os.FileObserver import android.os.Handler import android.os.HandlerThread +import android.provider.DocumentsContract import java.io.Closeable import java.io.File +import java.util.concurrent.atomic.AtomicBoolean /** * Watches the file behind a resident model and reports when it goes away, so its gigabytes are @@ -63,9 +65,9 @@ class PlatformModelSourceWatcher( } /** - * Providers notify on their own terms — often for the parent tree rather than the document, - * and often for edits rather than deletion — so this registers for descendants too and lets - * the callback decide. `onGone` is a hint, never a verdict. + * Registers on the document URI *and* on [parentChildrenUriOf] it, which is where a provider + * actually notifies a delete and is no descendant of the document URI. Both stay hints, never + * verdicts — the parent's URI fires for every sibling too — so the callback confirms first. */ private fun watchDocument(uriString: String, onGone: () -> Unit): Closeable { val uri = Uri.parse(uriString) @@ -74,12 +76,17 @@ class PlatformModelSourceWatcher( } try { context.contentResolver.registerContentObserver(uri, true, observer) + // Null for a document at the root of its volume; the direct watch then stands alone. + parentChildrenUriOf(uri)?.let { + context.contentResolver.registerContentObserver(it, true, observer) + } } catch (e: Exception) { // The handler is already counted; give it back or the thread outlives every watch. releaseHandler() throw e } - return Closeable { + // One unregister covers both registrations — the resolver keys them by observer. + return closeOnce { try { context.contentResolver.unregisterContentObserver(observer) } finally { @@ -101,7 +108,16 @@ class PlatformModelSourceWatcher( // The framework holds FileObserver weakly and stops watching once it is collected, so the // returned handle keeps the only strong reference alive for as long as the watch is wanted. observer.startWatching() - return Closeable { observer.stopWatching() } + return closeOnce { observer.stopWatching() } + } + + /** + * A handle whose second [Closeable.close] is a no-op. [releaseHandler] counts live watches, so + * a double close would stop the delivery thread out from under the watches still using it. + */ + private fun closeOnce(release: () -> Unit): Closeable { + val closed = AtomicBoolean(false) + return Closeable { if (closed.compareAndSet(false, true)) release() } } /** Starts the delivery thread on the first watch. */ @@ -132,3 +148,23 @@ class PlatformModelSourceWatcher( const val THREAD_NAME = "LocalLlm-ModelWatch" } } + +/** + * The children URI of [uri]'s parent document, which is where a `DocumentsProvider` notifies a + * delete. Drops the last element of the document id — `primary:Download/model.gguf` gives + * `primary:Download`. Top-level so it is testable without a `ContentObserver` and a `HandlerThread`. + * + * @return the parent's children URI, or null when the id names no parent to derive + */ +internal fun parentChildrenUriOf(uri: Uri): Uri? = try { + val documentId = DocumentsContract.getDocumentId(uri) + documentId.substringBeforeLast(DOCUMENT_ID_SEPARATOR, "") + .takeIf { it.isNotEmpty() && it != documentId } + ?.let { DocumentsContract.buildChildDocumentsUri(uri.authority, it) } +} catch (_: Exception) { + // Not a document URI, or an id this provider shapes some other way. + null +} + +/** How every provider that nests documents separates the elements of a document id. */ +private const val DOCUMENT_ID_SEPARATOR = '/' diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt index 5c5219d6..595b2f7f 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt @@ -5,6 +5,7 @@ import android.net.Uri import java.io.Closeable import java.io.File import java.io.FileInputStream +import java.io.FileNotFoundException import java.io.InputStream /** @@ -35,6 +36,17 @@ class OpenModelFile( */ val isSeekable: Boolean get() = sizeBytes >= 0 + /** + * Whether [nativePath] can be opened *by name*, which is all the native loader ever does with + * it. That open re-resolves to the real inode against this app's own credentials rather than + * the SAF grant, so removable storage can refuse it where the descriptor was not. ADFA-5253. + */ + fun isReopenable(): Boolean = try { + openStream()?.use { true } ?: false + } catch (_: Exception) { + false + } + /** * Opens an independent read stream over the same bytes the native loader sees — header * inspection must never disturb the loader's own file offset. @@ -56,6 +68,22 @@ class OpenModelFile( } } +/** + * What a reachability probe found. [GONE] and [UNKNOWN] must never be collapsed: a resident + * multi-gigabyte model is the memory pressure that gets a `DocumentsProvider` process killed, and + * reading that silence as a deletion evicts a model that is fine. Only [GONE] evicts. ADFA-5253. + */ +enum class SourceReachability { + /** The source answered, and the model is there. */ + REACHABLE, + + /** The source answered: the model is gone — deleted, unmounted, or the read grant was revoked. */ + GONE, + + /** The source did not answer, which says nothing about the model. */ + UNKNOWN, +} + /** * Opens the user's selected model for the native loader, in place and without copying it. * An interface so the backend's load path can be exercised without a device. @@ -76,9 +104,9 @@ interface NativeModelSource { * deleted inode alive, so the mapped pages outlive the file and the model keeps replying from * a document the user has thrown away. Only a fresh open off the reference can tell. * - * @return true when the model is still there; false for deleted, unmounted, or revoked + * @return what the probe found; [SourceReachability.UNKNOWN] when the source stayed silent */ - fun isReachable(modelReference: String): Boolean + fun reachabilityOf(modelReference: String): SourceReachability } /** @@ -110,20 +138,37 @@ class ContentNativeModelSource( } /** - * One binder round trip for a document, one stat for a path — nothing is read, so this is - * cheap enough to ask before every generation. A failure here is the routine answer "it is - * gone", not an error worth reporting through [onError]. + * One binder round trip for a document, one stat for a path — nothing is read, so this is cheap + * enough to ask before every generation. [SourceReachability.GONE] is only ever what the source + * itself said; a call that failed is [SourceReachability.UNKNOWN], which is not evidence. */ - override fun isReachable(modelReference: String): Boolean = try { - if (modelReference.startsWith(CONTENT_SCHEME)) { - context.contentResolver - .openFileDescriptor(Uri.parse(modelReference), "r") - ?.use { true } ?: false - } else { - File(modelReference).isFile - } - } catch (_: Exception) { - false + override fun reachabilityOf(modelReference: String): SourceReachability = + if (modelReference.startsWith(CONTENT_SCHEME)) documentReachability(modelReference) + else fileReachability(modelReference) + + private fun documentReachability(uriString: String): SourceReachability = try { + context.contentResolver + .openFileDescriptor(Uri.parse(uriString), "r") + ?.use { SourceReachability.REACHABLE } + // No descriptor and no failure is not the provider saying the document is gone. + ?: SourceReachability.UNKNOWN + } catch (_: FileNotFoundException) { + // The routine answer for a deleted or renamed document, and not worth reporting. + SourceReachability.GONE + } catch (_: SecurityException) { + // The persisted grant is gone, which is as final as a deletion from here. + SourceReachability.GONE + } catch (e: Exception) { + // DeadObjectException and friends: the provider died, which is not the routine case. + onError("could not reach the selected model $uriString", e) + SourceReachability.UNKNOWN + } + + private fun fileReachability(path: String): SourceReachability = try { + if (File(path).isFile) SourceReachability.REACHABLE else SourceReachability.GONE + } catch (e: Exception) { + onError("could not stat the model file $path", e) + SourceReachability.UNKNOWN } private fun openFile(path: String): OpenModelFile? = try { diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt index 360221c7..6b1a9f63 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt @@ -202,8 +202,10 @@ class LocalLlmSettingsViewModel( if (current.model is ModelLoadingState.Loading) return@launch if (readable) { - // Only ever clears a stale "unavailable": a live Error is about this same model. - if (current.model is ModelLoadingState.Unavailable) { + // An Error describes a refused *pick*, so a readable model clears that too. + if (current.model is ModelLoadingState.Unavailable || + current.model is ModelLoadingState.Error + ) { publishModelState(modelStateFor(savedPath)) } } else { @@ -349,6 +351,8 @@ class LocalLlmSettingsViewModel( viewModelScope.launch(ioDispatcher) { publishModelState(ModelLoadingState.Loading) + // Whether this pick became the configured model; anything else gives its grant back. + var stored = false try { // Taken before the first read, so every step below works off the durable grant. if (!modelFiles.persistAccess(context, uriString)) { @@ -364,7 +368,6 @@ class LocalLlmSettingsViewModel( // Checked before the GGUF sniff so a model that is simply gone — the "Load from // saved" case after the file was deleted — is not reported as a corrupt one. if (!modelFiles.isReadable(context, uriString)) { - releaseUnkeptGrant(context, uriString) publishAbandonedSelection( uriString, ModelLoadingState.Unavailable(fileName), @@ -375,7 +378,6 @@ class LocalLlmSettingsViewModel( // Rejected up front, so no bad path is persisted or shown as "Loaded". if (!GgufFileInspector.looksLikeGguf(context.contentResolver, uriString)) { - releaseUnkeptGrant(context, uriString) publishAbandonedSelection( uriString, ModelLoadingState.Error( @@ -388,7 +390,6 @@ class LocalLlmSettingsViewModel( if (!confirmMemoryHeadroom(uriString, fileInfo, context)) { logger?.info("$TAG: model declined at the memory warning: $fileName") - releaseUnkeptGrant(context, uriString) restoreStateBefore(stateBefore) return@launch } @@ -402,6 +403,7 @@ class LocalLlmSettingsViewModel( // Persist the name before the path so the savedModelPath observer can read it. saveLocalModelName(fileName) saveLocalModelPath(uriString) + stored = true // Nothing is loaded here; the engine reads this path when it needs the model. publishModelState(ModelLoadingState.Loaded(fileName)) @@ -418,6 +420,8 @@ class LocalLlmSettingsViewModel( ), stateBefore.engine, ) + } finally { + if (!stored) releaseUnkeptGrant(context, uriString) } } } @@ -485,10 +489,8 @@ class LocalLlmSettingsViewModel( /** * Gives back the grant taken for a selection that was not kept, so an abandoned pick does not - * hold a slot in the capped grant table. - * - * Never touches the configured model: re-checking it and abandoning that check must leave the - * model that is actually in use readable. + * hold a slot in the capped grant table. Called only from [loadModelFromUri]'s `finally`, so no + * abandon path can forget it, and never for the configured model, which stays readable. */ private fun releaseUnkeptGrant(context: Context, uriString: String) { if (uriString != getLocalModelPath()) { @@ -497,13 +499,13 @@ class LocalLlmSettingsViewModel( } /** - * Puts the screen back as it was before a selection the user declined outright. Restores both - * lines rather than re-deriving them: a configured model that was already unreachable must - * stay reported that way. + * Puts the screen back as it was before a selection the user declined outright. Restores the + * two status lines rather than re-deriving them, and only those two: a decline says nothing + * about the configured path or name, so a whole snapshot would revert a concurrent write. * * @param stateBefore the state captured before the selection started */ private fun restoreStateBefore(stateBefore: LocalLlmSettingsState) { - update { stateBefore } + update { it.copy(model = stateBefore.model, engine = stateBefore.engine) } } } diff --git a/ai-agent-local/src/main/res/values/strings.xml b/ai-agent-local/src/main/res/values/strings.xml index 6098af12..56542fd6 100644 --- a/ai-agent-local/src/main/res/values/strings.xml +++ b/ai-agent-local/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ The model file could not be found. Re-select the .gguf model in AI Settings. The selected model can no longer be reached. It may have been moved, deleted, or saved to storage that isn\'t connected right now, or the IDE\'s permission to read it was withdrawn. Select the .gguf model again in AI Settings. This model is streamed from its storage location rather than stored on this device, so it can\'t be read in place. Download the .gguf to the device — for example to Downloads — and select it from there. + This model can\'t be read from where it is stored — the storage volume doesn\'t allow the IDE to open it directly. Copy the .gguf to the device\'s internal storage — for example to Downloads — and select it from there. The model file is empty — the download may have been interrupted. Re-download the .gguf model and select it again. This file isn\'t a valid .gguf model (it may be corrupt or only partially downloaded). Re-download the model and select it again. Loading this model needs at least %1$s of free memory, but only %2$s is available on this device. Close other apps and try again, or pick a smaller or more heavily quantized model (for example a Q4_K_M build of a 1–3B model). diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt index 45941bb2..51dc6bb8 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt @@ -9,6 +9,7 @@ import com.itsaky.androidide.plugins.aiagentlocal.model.ModelLoadDiagnostics.Dia import com.itsaky.androidide.plugins.aiagentlocal.model.ModelSourceWatcher import com.itsaky.androidide.plugins.aiagentlocal.model.NativeModelSource import com.itsaky.androidide.plugins.aiagentlocal.model.OpenModelFile +import com.itsaky.androidide.plugins.aiagentlocal.model.SourceReachability import com.itsaky.androidide.plugins.services.LlmInferenceService.* import io.mockk.every import io.mockk.mockk @@ -45,6 +46,9 @@ class LocalLlmBackendTest { /** Flipped to simulate the user deleting the file out from under a resident model. */ var reachable = true + /** What the probe answers when [reachable] is false: a deletion, or a provider that died. */ + var whenUnreachable = SourceReachability.GONE + /** Reachability probes served, so a burst of watch notifications can be counted. */ @Volatile var probeCount = 0 @@ -53,9 +57,10 @@ class LocalLlmBackendTest { return handles[modelReference].takeIf { reachable } } - override fun isReachable(modelReference: String): Boolean { + override fun reachabilityOf(modelReference: String): SourceReachability { probeCount++ - return reachable && handles.containsKey(modelReference) + return if (reachable && handles.containsKey(modelReference)) SourceReachability.REACHABLE + else whenUnreachable } } @@ -292,6 +297,88 @@ class LocalLlmBackendTest { assertTrue("the descriptor must be released or the inode stays alive", descriptor.closed) } + @Test + fun givenAResidentModel_whenTheProviderStopsAnswering_thenItKeepsServing() { + // A dead provider process says nothing about the document; evicting costs a GB reload. + val descriptor = RecordingDescriptor() + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(chatModel(), descriptor))) + val engine = FakeEngine() + val backend = backendWith(source, engine) + + runBlocking { backend.ensureModelLoaded(CONTENT_URI) } + source.reachable = false + source.whenUnreachable = SourceReachability.UNKNOWN + + runBlocking { backend.ensureModelLoaded(CONTENT_URI) } + + assertEquals("a silent provider must not cost a reload", 1, engine.loadCount) + assertEquals("a silent provider must not evict the model", 0, engine.unloadCount) + assertFalse("the descriptor must stay open", descriptor.closed) + } + + @Test + fun givenAResidentModel_whenItsWatchFiresAndTheProviderIsSilent_thenItStaysLoaded() { + // The same distinction on the watch path, where a burst of notifications arrives. + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(chatModel()))) + val engine = FakeEngine() + val watcher = FakeWatcher() + val backend = backendWith(source, engine, watcher) + + runBlocking { backend.ensureModelLoaded(CONTENT_URI) } + source.reachable = false + source.whenUnreachable = SourceReachability.UNKNOWN + watcher.onGone!!.invoke() + + Thread.sleep(200) + assertEquals("an unanswered probe must not unload the model", 0, engine.unloadCount) + } + + @Test + fun givenADocumentThatCannotBeReopenedByPath_whenLoading_thenRefusedWithItsOwnAdvice() { + // Refused before native code, or it lands on "pick the model again" for a file that is there. + val descriptor = RecordingDescriptor() + val unreadable = OpenModelFile("/proc/self/fd/99", 4_096L, descriptor) + val source = FakeModelSource(mapOf(CONTENT_URI to unreadable)) + val engine = FakeEngine() + + val error = assertThrows(ModelLoadException::class.java) { + runBlocking { backendWith(source, engine).ensureModelLoaded(CONTENT_URI) } + } + + assertEquals(Diagnosis.SourceNotReopenable, error.diagnosis) + assertEquals("nothing may reach the engine", 0, engine.loadCount) + assertTrue("the refused descriptor must not leak", descriptor.closed) + } + + @Test + fun givenAConfiguredModelThatFailsToLoad_whenAvailabilityIsAskedRepeatedly_thenItIsTriedOnce() { + // A warm-up re-armed on failure launches one doomed load per isAvailable() call. + configureModelPath(CONTENT_URI) + // Not in the fake's handles, so the warm-up fails the way an unreachable model does. + val source = FakeModelSource(emptyMap()) + val backend = backendWith(source, FakeEngine()) + + repeat(5) { backend.isAvailable() } + + Thread.sleep(300) + assertEquals("five asks must cost one warm-up attempt", 1, source.openCount) + } + + @Test + fun givenAFailedWarmUp_whenAnotherModelIsSelected_thenTheWarmUpIsTriedAgain() { + // Keyed on the reference, not disabled outright: a new selection has to be warmed. + configureModelPath(CONTENT_URI) + val source = FakeModelSource(emptyMap()) + val backend = backendWith(source, FakeEngine()) + backend.isAvailable() + + configureModelPath(OTHER_CONTENT_URI) + backend.isAvailable() + + Thread.sleep(300) + assertEquals("a different selection must re-arm the warm-up", 2, source.openCount) + } + @Test fun givenAResidentModelStillOnDisk_whenGenerating_thenItIsServedWithoutReloading() { // The check must not cost a reload: a document's procfs path differs on every open, and @@ -355,10 +442,18 @@ class LocalLlmBackendTest { /** A minimal GGUF that passes the ADFA-4388 embedding guard, so loads reach the engine. */ private fun chatModel(): File = GgufTestFiles.withArchitecture("qwen2") + /** Points the backend's preferences at [modelRef], the way a saved selection does. */ + private fun configureModelPath(modelRef: String) { + val prefs = mockk(relaxed = true) + every { prefs.getString("local_llm_model_path", any()) } returns modelRef + every { pluginContext.getPluginSharedPreferences(any()) } returns prefs + } + private fun handleFor(file: File, descriptor: Closeable? = null) = OpenModelFile(file.absolutePath, file.length(), descriptor) private companion object { const val CONTENT_URI = "content://com.android.externalstorage.documents/document/model.gguf" + const val OTHER_CONTENT_URI = "content://com.android.externalstorage.documents/document/other.gguf" } } diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt index d1538421..95e71ffc 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt @@ -3,6 +3,7 @@ package com.itsaky.androidide.plugins.aiagentlocal.model import android.content.ContentResolver import android.content.Context import android.net.Uri +import android.os.DeadObjectException import android.os.ParcelFileDescriptor import io.mockk.every import io.mockk.mockk @@ -48,45 +49,63 @@ class ContentNativeModelSourceTest { fun givenAPathThatStillExists_whenProbed_thenItIsReachable() { val model = temporaryFolder.newFile("model.gguf") - assertTrue(source.isReachable(model.absolutePath)) + assertEquals(SourceReachability.REACHABLE, source.reachabilityOf(model.absolutePath)) } @Test - fun givenADeletedPath_whenProbed_thenItIsUnreachable() { + fun givenADeletedPath_whenProbed_thenItIsGone() { val model = temporaryFolder.newFile("model.gguf") assertTrue(model.delete()) - assertFalse(source.isReachable(model.absolutePath)) + assertEquals(SourceReachability.GONE, source.reachabilityOf(model.absolutePath)) } @Test - fun givenADirectory_whenProbed_thenItIsUnreachable() { + fun givenADirectory_whenProbed_thenItIsGone() { // A path that resolves but holds no model must not read as a usable one. val directory = temporaryFolder.newFolder("models") - assertFalse(source.isReachable(directory.absolutePath)) + assertEquals(SourceReachability.GONE, source.reachabilityOf(directory.absolutePath)) } @Test fun givenADocumentTheProviderStillServes_whenProbed_thenItIsReachable() { every { resolver.openFileDescriptor(any(), "r") } returns mockk(relaxed = true) - assertTrue(source.isReachable(CONTENT_URI)) + assertEquals(SourceReachability.REACHABLE, source.reachabilityOf(CONTENT_URI)) } @Test - fun givenADeletedDocument_whenProbed_thenItIsUnreachable() { + fun givenADeletedDocument_whenProbed_thenItIsGone() { // What a deleted document actually does: the provider throws rather than returning null. every { resolver.openFileDescriptor(any(), "r") } throws java.io.FileNotFoundException() - assertFalse(source.isReachable(CONTENT_URI)) + assertEquals(SourceReachability.GONE, source.reachabilityOf(CONTENT_URI)) } @Test - fun givenAProviderThatAnswersWithNothing_whenProbed_thenItIsUnreachable() { + fun givenARevokedGrant_whenProbed_thenItIsGone() { + // As final as a deletion from here: only a fresh pick can bring the document back. + every { resolver.openFileDescriptor(any(), "r") } throws SecurityException("no grant") + + assertEquals(SourceReachability.GONE, source.reachabilityOf(CONTENT_URI)) + } + + @Test + fun givenAProviderThatDied_whenProbed_thenTheAnswerIsUnknownRatherThanGone() { + // A resident multi-GB model is the pressure that kills a provider; that is not a delete. + // Instantiated through mockk: the unit-test android.jar stubs its constructor out. + every { resolver.openFileDescriptor(any(), "r") } throws mockk(relaxed = true) + + assertEquals(SourceReachability.UNKNOWN, source.reachabilityOf(CONTENT_URI)) + } + + @Test + fun givenAProviderThatAnswersWithNothing_whenProbed_thenTheAnswerIsUnknown() { + // No descriptor and no failure is not the provider saying the document is gone. every { resolver.openFileDescriptor(any(), "r") } returns null - assertFalse(source.isReachable(CONTENT_URI)) + assertEquals(SourceReachability.UNKNOWN, source.reachabilityOf(CONTENT_URI)) } @Test @@ -95,7 +114,7 @@ class ContentNativeModelSourceTest { val descriptor = mockk(relaxed = true) every { resolver.openFileDescriptor(any(), "r") } returns descriptor - source.isReachable(CONTENT_URI) + source.reachabilityOf(CONTENT_URI) io.mockk.verify { descriptor.close() } } @@ -143,6 +162,21 @@ class ContentNativeModelSourceTest { assertTrue(opened.isSeekable) } + @Test + fun givenAPathTheLoaderCanOpenByName_whenAsked_thenItIsReopenable() { + // The loader opens nativePath by name, so the handle answers for that open. + val model = temporaryFolder.newFile("model.gguf").apply { writeBytes(ByteArray(64)) } + + assertTrue(source.open(model.absolutePath)!!.isReopenable()) + } + + @Test + fun givenAPathNothingCanOpen_whenAsked_thenItIsNotReopenable() { + val opened = OpenModelFile("/proc/self/fd/99999", 64L, null) + + assertFalse(opened.isReopenable()) + } + @Test fun givenADeletedPath_whenOpened_thenNoHandleIsReturned() { val model = temporaryFolder.newFile("model.gguf") diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcherTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcherTest.kt new file mode 100644 index 00000000..5286794d --- /dev/null +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcherTest.kt @@ -0,0 +1,78 @@ +package com.itsaky.androidide.plugins.aiagentlocal.model + +import android.net.Uri +import android.provider.DocumentsContract +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** + * A `DocumentsProvider` notifies a delete against the parent's children URI, which is not a + * path-prefix descendant of the document URI — so a document-only watch never sees the deletion it + * exists to catch, and the gigabytes stay mapped until the next message. See ADFA-5253. + */ +class ModelSourceWatcherTest { + + private lateinit var parentUri: Uri + + @Before + fun setup() { + parentUri = mockk(relaxed = true) + mockkStatic(DocumentsContract::class) + every { DocumentsContract.buildChildDocumentsUri(any(), any()) } returns parentUri + } + + @After + fun tearDown() { + unmockkStatic(DocumentsContract::class) + } + + @Test + fun givenANestedDocument_whenDerivingTheWatchTarget_thenItIsTheParentsChildrenUri() { + val uri = documentUri("primary:Download/model.gguf") + + assertEquals(parentUri, parentChildrenUriOf(uri)) + io.mockk.verify { DocumentsContract.buildChildDocumentsUri(AUTHORITY, "primary:Download") } + } + + @Test + fun givenADeeplyNestedDocument_whenDerivingTheWatchTarget_thenOnlyTheLastElementIsDropped() { + val uri = documentUri("primary:Download/models/gguf/model.gguf") + + assertEquals(parentUri, parentChildrenUriOf(uri)) + io.mockk.verify { + DocumentsContract.buildChildDocumentsUri(AUTHORITY, "primary:Download/models/gguf") + } + } + + @Test + fun givenADocumentAtTheRootOfItsVolume_whenDerivingTheWatchTarget_thenThereIsNone() { + // No parent element to drop; the direct watch is all there is. + assertNull(parentChildrenUriOf(documentUri("primary:model.gguf"))) + } + + @Test + fun givenSomethingThatIsNotADocumentUri_whenDerivingTheWatchTarget_thenThereIsNone() { + val uri = mockk(relaxed = true) + every { DocumentsContract.getDocumentId(uri) } throws IllegalArgumentException("not a document") + + assertNull(parentChildrenUriOf(uri)) + } + + private fun documentUri(documentId: String): Uri { + val uri = mockk(relaxed = true) + every { uri.authority } returns AUTHORITY + every { DocumentsContract.getDocumentId(uri) } returns documentId + return uri + } + + private companion object { + const val AUTHORITY = "com.android.externalstorage.documents" + } +} diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt index 99fca1ff..2511df86 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt @@ -41,8 +41,13 @@ class LocalLlmSettingsViewModelTest { /** References the provider will not serve, standing in for a deleted document. */ val unreadable = mutableSetOf() - override fun info(context: Context, uriString: String) = - ModelFileInfo(fallbackDisplayName(uriString), 1_024L) + /** Makes the lookup blow up, standing in for a provider that fails mid-selection. */ + var failInfo = false + + override fun info(context: Context, uriString: String): ModelFileInfo { + if (failInfo) throw IllegalStateException("provider failed") + return ModelFileInfo(fallbackDisplayName(uriString), 1_024L) + } override fun openStream(context: Context, uriString: String): InputStream? = null @@ -103,12 +108,13 @@ class LocalLlmSettingsViewModelTest { * Unconfined, so every launch runs inline: nothing here suspends on a real dispatcher, and the * memory pre-flight fails open on the fake's unreadable header. */ - private fun viewModel() = LocalLlmSettingsViewModel( - getContext = { pluginContext }, - ioDispatcher = Dispatchers.Unconfined, - deviceMemory = DeviceMemory { null }, - modelFiles = modelFiles, - ) + private fun viewModel(deviceMemory: DeviceMemory = DeviceMemory { null }) = + LocalLlmSettingsViewModel( + getContext = { pluginContext }, + ioDispatcher = Dispatchers.Unconfined, + deviceMemory = deviceMemory, + modelFiles = modelFiles, + ) @Test fun givenASelection_whenItIsKept_thenItsGrantIsPersistedAndStored() { @@ -233,6 +239,71 @@ class LocalLlmSettingsViewModelTest { assertEquals(EngineState.Initialized, viewModel.state.value?.engine) } + @Test + fun givenARejectedPicksError_whenTheScreenReturnsAndTheModelReadsBack_thenItClears() { + // The error described the pick; left standing it shows on every return to the screen. + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + every { resolver.openInputStream(any()) } answers { ByteArrayInputStream("NOPE".toByteArray()) } + viewModel.loadModelFromUri(MODEL_B) + assertTrue(viewModel.state.value?.model is ModelLoadingState.Error) + + viewModel.refreshSavedModelAvailability() + + assertEquals(ModelLoadingState.Loaded("a.gguf"), viewModel.state.value?.model) + assertEquals(EngineState.Initialized, viewModel.state.value?.engine) + } + + @Test + fun givenAPickAbandonedBeforeItWasStored_thenItsGrantIsGivenBackByTheFinally() { + // Grants are capped, and only the finally covers every way out of the selection. + val viewModel = viewModel() + + every { resolver.openInputStream(any()) } answers { ByteArrayInputStream("NOPE".toByteArray()) } + viewModel.loadModelFromUri(MODEL_B) + + assertEquals(listOf(MODEL_B), modelFiles.persisted) + assertEquals(listOf(MODEL_B), modelFiles.released) + assertEquals(null, viewModel.getLocalModelPath()) + } + + @Test + fun givenASelectionThatThrows_thenItsGrantIsStillGivenBack() { + // Leaves through code no abandon path runs, as a cancellation at the dialog would. + modelFiles.failInfo = true + val viewModel = viewModel() + + viewModel.loadModelFromUri(MODEL_B) + + assertEquals(listOf(MODEL_B), modelFiles.persisted) + assertEquals(listOf(MODEL_B), modelFiles.released) + assertTrue(viewModel.state.value?.model is ModelLoadingState.Error) + } + + @Test + fun givenAModelDeclinedAtTheMemoryWarning_thenItsGrantIsGivenBackAndNothingIsStored() { + val viewModel = viewModel(deviceMemory = DeviceMemory { 1L }) + viewModel.loadModelFromUri(MODEL_B) + assertTrue("the pre-flight must be waiting on an answer", viewModel.hasPendingMemoryWarning) + + viewModel.onMemoryWarningDecision(false) + + assertEquals(listOf(MODEL_B), modelFiles.released) + assertEquals(null, viewModel.getLocalModelPath()) + } + + @Test + fun givenADeclineAtTheMemoryWarning_whenSomethingWasStoredMeanwhile_thenItIsNotReverted() { + // The decline owns the two status lines and nothing else in the state. + val viewModel = viewModel(deviceMemory = DeviceMemory { 1L }) + viewModel.loadModelFromUri(MODEL_B) + viewModel.saveLocalModelPath(MODEL_A) + + viewModel.onMemoryWarningDecision(false) + + assertEquals(MODEL_A, viewModel.state.value?.savedModelPath) + } + private companion object { const val MODEL_A = "content://com.android.externalstorage.documents/document/a.gguf" const val MODEL_B = "content://com.android.externalstorage.documents/document/b.gguf" From 2ffe8338560c06459a52089a70f09f6296f1cad0 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Mon, 7 Sep 2026 13:30:03 -0500 Subject: [PATCH 4/7] fix(ai-agent-local): address round-3 review on read-in-place model loading Confirm a GONE probe with a re-ask, give ModelFileSource the same tri-state, keep an Error about the configured model from clearing on a readability re-check, trust a fresh REACHABLE for 5s, evict under generationMutex, and stop gambling on an fd. --- .../aiagentlocal/backend/LocalLlmBackend.kt | 35 ++++++++++- .../aiagentlocal/model/ModelFileSource.kt | 44 ++++++++++---- .../aiagentlocal/model/ModelSourceWatcher.kt | 14 ++++- .../aiagentlocal/model/NativeModelSource.kt | 40 +++++++++++-- .../settings/LocalLlmSettingsViewModel.kt | 59 ++++++++++++++----- .../backend/LocalLlmBackendTest.kt | 19 +++++- .../model/ContentModelFileSourceTest.kt | 42 ++++++++++--- .../model/ContentNativeModelSourceTest.kt | 19 ++++++ .../settings/LocalLlmSettingsViewModelTest.kt | 39 +++++++++++- 9 files changed, 265 insertions(+), 46 deletions(-) diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt index 1b9fc4a3..068b624b 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt @@ -31,6 +31,7 @@ import com.itsaky.androidide.plugins.services.SharedServices import java.io.Closeable import java.io.File import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CancellationException @@ -77,6 +78,9 @@ class LocalLlmBackend( * [deleteLegacyModelCache], which gives the space back. */ private const val LEGACY_MODEL_CACHE_DIR = "llm-models" + + /** How long a REACHABLE answer stands in for the next one; see [reachabilityIsFresh]. */ + private val REACHABILITY_TRUST_NANOS = TimeUnit.SECONDS.toNanos(5) } private val llamaLazy = lazy { LLamaAndroid.instance() } @@ -126,6 +130,13 @@ class LocalLlmBackend( /** Whether a watch-triggered reachability check is already queued; see [onModelSourceGone]. */ private val sourceCheckInFlight = AtomicBoolean(false) + /** + * When the resident model's source last answered REACHABLE, as a [System.nanoTime] reading. + * The probe is a synchronous binder call in front of every generation, holding + * [generationMutex] — and a provider that hangs rather than dies cannot be cancelled out of. + */ + @Volatile private var lastReachableNanos = 0L + /** * Opens the configured model for the native loader. Lazy so construction touches no Android * services, and overridable so the load path can be tested without a device. @@ -289,8 +300,11 @@ class LocalLlmBackend( // Residency is not evidence the file still exists. The descriptor this backend holds // keeps a deleted inode alive, so an unchecked early return keeps answering from a // model the user threw away — and keeps its gigabytes mapped. Confirm, then serve. + if (reachabilityIsFresh()) return + val reachability = modelSource.reachabilityOf(modelRef) + if (reachability == SourceReachability.REACHABLE) lastReachableNanos = System.nanoTime() // Anything but GONE is served: a silent provider is no reason to pay a GB reload. - if (modelSource.reachabilityOf(modelRef) != SourceReachability.GONE) return + if (reachability != SourceReachability.GONE) return context.logger.info("Resident model is no longer reachable; unloading: $modelRef") evictResidentModel() throw unopenable(modelRef) @@ -451,6 +465,16 @@ class LocalLlmBackend( return resolved } + /** + * Whether the source answered REACHABLE recently enough to be taken at its word again, which + * bounds a wedged provider to one blocked message instead of the session. Only a probe refreshes + * it, so a model deleted between its load and its first message is still caught. + */ + private fun reachabilityIsFresh(): Boolean { + val since = System.nanoTime() - lastReachableNanos + return lastReachableNanos != 0L && since in 0..REACHABILITY_TRUST_NANOS + } + /** * Forgets the resident model and releases its descriptor. The native unload is the caller's to * do first — the mapped pages must be freed before the descriptor behind them goes. @@ -461,6 +485,7 @@ class LocalLlmBackend( currentModelRef = null openModel?.close() openModel = null + lastReachableNanos = 0L } /** @@ -818,8 +843,12 @@ class LocalLlmBackend( return runGeneration(buildPrompt(config.systemPrompt, prompt, history), config) } - /** Suspending model unload — safe to call from any coroutine. */ - private suspend fun unloadModelInternal() { + /** + * Suspending model unload — safe to call from any coroutine. Takes [generationMutex] because + * [evictResidentModel] requires it: a watch notification that arrives just before [close] runs + * its own eviction on [cleanupScope], and two of them would unload the native model twice. + */ + private suspend fun unloadModelInternal() = generationMutex.withLock { if (modelLoaded) { evictResidentModel() context.logger.info("Model unloaded") diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt index c9f517c8..2d6e5616 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.net.Uri import android.provider.OpenableColumns import java.io.File +import java.io.FileNotFoundException import java.io.InputStream /** @@ -36,12 +37,14 @@ interface ModelFileSource { fun openStream(context: Context, uriString: String): InputStream? /** - * Whether the model can still be opened right now. A configured model can go away underneath - * the settings screen — deleted, unmounted, or its read grant revoked — and the stored path - * says nothing about that, so the screen has to ask. Reports rather than logs: a model that is - * gone is an answer, not a lookup failure. Not for the main thread. + * Whether the model can still be opened right now: a configured model can be deleted or + * unmounted underneath the settings screen, and the stored path says nothing about that. + * Tri-state, because a provider that stayed silent is no reason to tell the user to re-pick a + * model that is intact. Reports rather than logs, and not for the main thread. + * + * @return what the probe found; [SourceReachability.UNKNOWN] leaves the screen's status alone */ - fun isReadable(context: Context, uriString: String): Boolean + fun readability(context: Context, uriString: String): SourceReachability /** Decoded last path segment — a cheap name that at least avoids raw `%3A` escapes. */ fun fallbackDisplayName(uriOrPath: String): String @@ -93,15 +96,36 @@ class ContentModelFileSource( null } - override fun isReadable(context: Context, uriString: String): Boolean = try { + override fun readability(context: Context, uriString: String): SourceReachability = if (uriString.startsWith(CONTENT_SCHEME)) { - context.contentResolver.openInputStream(Uri.parse(uriString))?.use { true } ?: false + // Confirmed: one FileNotFoundException covers a deletion and a dead provider alike. + confirmedGone { probeDocument(context, uriString) } } else { - File(uriString).let { it.isFile && it.canRead() } + probeFile(uriString) } + + private fun probeDocument(context: Context, uriString: String): SourceReachability = try { + context.contentResolver.openInputStream(Uri.parse(uriString)) + ?.use { SourceReachability.REACHABLE } + // No stream and no failure is not the provider saying the document is gone. + ?: SourceReachability.UNKNOWN + } catch (_: FileNotFoundException) { + // A deleted document, but also every provider-death path: only the re-ask decides. + SourceReachability.GONE + } catch (_: SecurityException) { + // The persisted grant is gone, which is as final as a deletion from here. + SourceReachability.GONE + } catch (e: Exception) { + onError("could not reach $uriString", e) + SourceReachability.UNKNOWN + } + + private fun probeFile(path: String): SourceReachability = try { + if (File(path).let { it.isFile && it.canRead() }) SourceReachability.REACHABLE + else SourceReachability.GONE } catch (e: Exception) { - // Deleted, unmounted, or the grant is gone — all of which mean the same thing here. - false + onError("could not stat $path", e) + SourceReachability.UNKNOWN } override fun fallbackDisplayName(uriOrPath: String): String = diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt index a033c053..9f5b7f58 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt @@ -81,7 +81,10 @@ class PlatformModelSourceWatcher( context.contentResolver.registerContentObserver(it, true, observer) } } catch (e: Exception) { - // The handler is already counted; give it back or the thread outlives every watch. + // Whichever registration landed goes back too: watch() is about to return nothing, and + // an observer left behind would hold onGone for the process's life — dispatching to a + // HandlerThread releaseHandler() has just quit. + unregisterQuietly(observer) releaseHandler() throw e } @@ -95,6 +98,15 @@ class PlatformModelSourceWatcher( } } + /** Unregisters on a path that is already failing, where the failure to report is the first. */ + private fun unregisterQuietly(observer: ContentObserver) { + try { + context.contentResolver.unregisterContentObserver(observer) + } catch (e: Exception) { + onError("could not unregister a half-registered model observer", e) + } + } + /** * `DELETE_SELF` covers the delete; `MOVE_SELF` covers a rename or a move to another volume, * which breaks a configured path just as thoroughly. diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt index 595b2f7f..960fee16 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt @@ -80,10 +80,34 @@ enum class SourceReachability { /** The source answered: the model is gone — deleted, unmounted, or the read grant was revoked. */ GONE, - /** The source did not answer, which says nothing about the model. */ + /** The source did not answer, or answered gone only once: no evidence about the model. */ UNKNOWN, } +/** + * Runs [probe] and reports a [SourceReachability.GONE] only when a second ask agrees: the resolver + * turns provider death into the same `FileNotFoundException` a deleted document gives, so only the + * re-ask — which restarts the provider — separates them. Sleeps, so never on the main thread. + * + * @param probe one reachability question, asked at most twice + * @return the probe's answer, an unconfirmed [SourceReachability.GONE] downgraded to UNKNOWN + */ +internal fun confirmedGone(probe: () -> SourceReachability): SourceReachability { + val first = probe() + if (first != SourceReachability.GONE) return first + try { + Thread.sleep(GONE_CONFIRM_DELAY_MS) + } catch (_: InterruptedException) { + // Interrupted mid-confirmation: nothing was established, and GONE evicts gigabytes. + Thread.currentThread().interrupt() + return SourceReachability.UNKNOWN + } + return probe() +} + +/** Long enough for a provider killed under memory pressure to be restarted for the second ask. */ +private const val GONE_CONFIRM_DELAY_MS = 250L + /** * Opens the user's selected model for the native loader, in place and without copying it. * An interface so the backend's load path can be exercised without a device. @@ -139,27 +163,31 @@ class ContentNativeModelSource( /** * One binder round trip for a document, one stat for a path — nothing is read, so this is cheap - * enough to ask before every generation. [SourceReachability.GONE] is only ever what the source - * itself said; a call that failed is [SourceReachability.UNKNOWN], which is not evidence. + * enough to ask before every generation. [SourceReachability.GONE] is only ever an answer the + * source gave twice (see [confirmedGone]); anything less is [SourceReachability.UNKNOWN]. */ override fun reachabilityOf(modelReference: String): SourceReachability = if (modelReference.startsWith(CONTENT_SCHEME)) documentReachability(modelReference) else fileReachability(modelReference) - private fun documentReachability(uriString: String): SourceReachability = try { + /** Confirmed, because one `FileNotFoundException` cannot tell a deletion from a dead provider. */ + private fun documentReachability(uriString: String): SourceReachability = + confirmedGone { probeDocument(uriString) } + + private fun probeDocument(uriString: String): SourceReachability = try { context.contentResolver .openFileDescriptor(Uri.parse(uriString), "r") ?.use { SourceReachability.REACHABLE } // No descriptor and no failure is not the provider saying the document is gone. ?: SourceReachability.UNKNOWN } catch (_: FileNotFoundException) { - // The routine answer for a deleted or renamed document, and not worth reporting. + // A deleted document, but also every provider-death path: only the re-ask decides. SourceReachability.GONE } catch (_: SecurityException) { // The persisted grant is gone, which is as final as a deletion from here. SourceReachability.GONE } catch (e: Exception) { - // DeadObjectException and friends: the provider died, which is not the routine case. + // Anything the resolver did not convert on its way out; not evidence either way. onError("could not reach the selected model $uriString", e) SourceReachability.UNKNOWN } diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt index 6b1a9f63..5ca04aaf 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt @@ -19,6 +19,7 @@ import com.itsaky.androidide.plugins.aiagentlocal.model.ModelFileInfo import com.itsaky.androidide.plugins.aiagentlocal.model.ModelFileSource import com.itsaky.androidide.plugins.aiagentlocal.model.ModelMemoryEstimator import com.itsaky.androidide.plugins.aiagentlocal.model.ModelMemoryGate +import com.itsaky.androidide.plugins.aiagentlocal.model.SourceReachability import com.itsaky.androidide.plugins.aiagentlocal.model.SystemDeviceMemory import com.itsaky.androidide.plugins.aiagentlocal.preferences.LocalLlmPreferences import kotlinx.coroutines.CancellationException @@ -42,7 +43,15 @@ sealed class ModelLoadingState { */ data class Unavailable(val modelName: String) : ModelLoadingState() - data class Error(val message: String) : ModelLoadingState() + /** + * A selection was refused. [reference] is the model the message is about, so a re-check of the + * *configured* model cannot clear an error that describes it — "Load from saved" refuses the + * configured model, and readability alone is no answer to why (ADFA-5253). + * + * @param message what to show the user + * @param reference the model the message is about; null when it is about no particular one + */ + data class Error(val message: String, val reference: String? = null) : ModelLoadingState() } /** @@ -195,28 +204,41 @@ class LocalLlmSettingsViewModel( val context = getContext()?.androidContext ?: return viewModelScope.launch(ioDispatcher) { - val readable = modelFiles.isReadable(context, savedPath) + val reachability = modelFiles.readability(context, savedPath) // A selection made while the check ran owns the status now; leave it to that load. if (getLocalModelPath() != savedPath) return@launch if (current.model is ModelLoadingState.Loading) return@launch - if (readable) { - // An Error describes a refused *pick*, so a readable model clears that too. - if (current.model is ModelLoadingState.Unavailable || - current.model is ModelLoadingState.Error - ) { - publishModelState(modelStateFor(savedPath)) + when (reachability) { + SourceReachability.REACHABLE -> clearStatusMadeStaleBy(savedPath) + SourceReachability.GONE -> { + logger?.warn("$TAG: the configured model can no longer be read: $savedPath") + publishModelState(ModelLoadingState.Unavailable(displayNameFor(savedPath))) } - } else { - logger?.warn("$TAG: the configured model can no longer be read: $savedPath") - publishModelState( - ModelLoadingState.Unavailable(displayNameFor(savedPath)) - ) + // Silence says nothing about the model, so it may not restate its status either way. + SourceReachability.UNKNOWN -> + logger?.warn("$TAG: could not tell whether $savedPath is still readable") } } } + /** + * Replaces a status that a just-confirmed readability makes stale, and leaves every other one. + * An [ModelLoadingState.Error] about the configured model is not stale: this probe only proves + * that a stream opens, which is no answer to a model whose bytes stopped being a GGUF. + * + * @param savedPath the configured model, confirmed readable a moment ago + */ + private fun clearStatusMadeStaleBy(savedPath: String) { + val stale = when (val model = current.model) { + is ModelLoadingState.Unavailable -> true + is ModelLoadingState.Error -> model.reference != savedPath + else -> false + } + if (stale) publishModelState(modelStateFor(savedPath)) + } + /** * Publishes a model status together with the engine readiness that follows from it, in one * dispatch, so the screen can never draw a model and a readiness that disagree. @@ -242,8 +264,11 @@ class LocalLlmSettingsViewModel( val engine = if (uriString == getLocalModelPath()) engineStateFor(model) ?: engineBefore else engineBefore + // Stamped here rather than at each call site, so no refusal can forget what it was about. + val stamped = + if (model is ModelLoadingState.Error) model.copy(reference = uriString) else model - update { it.copy(model = model, engine = engine) } + update { it.copy(model = stamped, engine = engine) } } /** @@ -366,8 +391,10 @@ class LocalLlmSettingsViewModel( val fileName = fileInfo.displayName // Checked before the GGUF sniff so a model that is simply gone — the "Load from - // saved" case after the file was deleted — is not reported as a corrupt one. - if (!modelFiles.isReadable(context, uriString)) { + // saved" case after the file was deleted — is not reported as a corrupt one. Only a + // confirmed GONE refuses: a provider's silence is not a model that went away, and + // every step below fails open, so the backend's own load diagnoses that case. + if (modelFiles.readability(context, uriString) == SourceReachability.GONE) { publishAbandonedSelection( uriString, ModelLoadingState.Unavailable(fileName), diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt index 51dc6bb8..f60071a7 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt @@ -336,8 +336,10 @@ class LocalLlmBackendTest { @Test fun givenADocumentThatCannotBeReopenedByPath_whenLoading_thenRefusedWithItsOwnAdvice() { // Refused before native code, or it lands on "pick the model again" for a file that is there. + // Named, not an fd number: a test worker holds hundreds, so /proc/self/fd/N often opens. val descriptor = RecordingDescriptor() - val unreadable = OpenModelFile("/proc/self/fd/99", 4_096L, descriptor) + val missing = File(temporaryFolder.root, "never-created/model.gguf").absolutePath + val unreadable = OpenModelFile(missing, 4_096L, descriptor) val source = FakeModelSource(mapOf(CONTENT_URI to unreadable)) val engine = FakeEngine() @@ -430,6 +432,21 @@ class LocalLlmBackendTest { assertEquals("a spurious notification must not unload a reachable model", 0, engine.unloadCount) } + @Test + fun givenAResidentModelJustProbed_whenGeneratingAgain_thenTheProviderIsNotAskedEveryTime() { + // The probe is a blocking binder call holding generationMutex, and a provider that hangs + // rather than dies cannot be cancelled out of: bound it to one message, not the session. + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(chatModel()))) + val backend = backendWith(source, FakeEngine()) + + runBlocking { + backend.ensureModelLoaded(CONTENT_URI) + repeat(4) { backend.ensureModelLoaded(CONTENT_URI) } + } + + assertEquals("a fresh REACHABLE answer must stand in for the next probe", 1, source.probeCount) + } + /** The eviction runs on the backend's own cleanup scope, so the test waits for it. */ private fun awaitUnload(engine: FakeEngine) { val deadline = System.currentTimeMillis() + 2000 diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt index 7f1ba276..33c30a1d 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt @@ -79,34 +79,60 @@ class ContentModelFileSourceTest { } @Test - fun givenDeletedDocument_whenIsReadable_thenFalseWithoutReportingAnError() { + fun givenDeletedDocument_whenProbed_thenGoneWithoutReportingAnError() { every { resolver.openInputStream(uri) } throws FileNotFoundException("open failed: ENOENT (No such file or directory)") - assertFalse(source.isReadable(context, CONTENT_URI)) + assertEquals(SourceReachability.GONE, source.readability(context, CONTENT_URI)) // A model that is gone is an answer for the caller, not a lookup failure to log. assertTrue(errors.toString(), errors.isEmpty()) } @Test - fun givenOpenableDocument_whenIsReadable_thenTrueAndTheStreamIsClosed() { + fun givenAProviderThatServesOnTheSecondAsk_whenProbed_thenItIsReachableRatherThanGone() { + // The resolver turns provider death into the FileNotFoundException a deletion gives, so + // only the re-ask keeps this from telling the user to re-pick a model that is intact. + every { resolver.openInputStream(uri) } throws FileNotFoundException() andThen + ByteArrayInputStream(ByteArray(4)) + + assertEquals(SourceReachability.REACHABLE, source.readability(context, CONTENT_URI)) + } + + @Test + fun givenAProviderThatStaysSilentOnTheSecondAsk_whenProbed_thenTheAnswerIsUnknown() { + // Neither ask established anything, and only GONE may say "select the model again". + every { resolver.openInputStream(uri) } throws FileNotFoundException() andThen null + + assertEquals(SourceReachability.UNKNOWN, source.readability(context, CONTENT_URI)) + } + + @Test + fun givenOpenableDocument_whenProbed_thenReachableAndTheStreamIsClosed() { val stream = spyk(ByteArrayInputStream(ByteArray(4))) every { resolver.openInputStream(uri) } returns stream - assertTrue(source.isReadable(context, CONTENT_URI)) + assertEquals(SourceReachability.REACHABLE, source.readability(context, CONTENT_URI)) verify { stream.close() } } @Test - fun givenMissingFilesystemPath_whenIsReadable_thenFalse() { - assertFalse(source.isReadable(context, "/sdcard/Download/gone.gguf")) + fun givenAProviderThatAnswersWithNothing_whenProbed_thenTheAnswerIsUnknown() { + // No stream and no failure is not the provider saying the document is gone. + every { resolver.openInputStream(uri) } returns null + + assertEquals(SourceReachability.UNKNOWN, source.readability(context, CONTENT_URI)) + } + + @Test + fun givenMissingFilesystemPath_whenProbed_thenGone() { + assertEquals(SourceReachability.GONE, source.readability(context, "/sdcard/Download/gone.gguf")) } @Test - fun givenExistingFile_whenIsReadable_thenTrue() { + fun givenExistingFile_whenProbed_thenReachable() { val file = File.createTempFile("model", ".gguf").apply { deleteOnExit() } - assertTrue(source.isReadable(context, file.absolutePath)) + assertEquals(SourceReachability.REACHABLE, source.readability(context, file.absolutePath)) } private companion object { diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt index 95e71ffc..d7489a0a 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt @@ -83,6 +83,25 @@ class ContentNativeModelSourceTest { assertEquals(SourceReachability.GONE, source.reachabilityOf(CONTENT_URI)) } + @Test + fun givenAProviderThatServesOnTheSecondAsk_whenProbed_thenItIsReachableRatherThanGone() { + // ContentResolver converts every provider-death path into FileNotFoundException before it + // returns, so the re-ask — which restarts the provider — is what separates it from a delete. + every { resolver.openFileDescriptor(any(), "r") } throws java.io.FileNotFoundException() andThen + mockk(relaxed = true) + + assertEquals(SourceReachability.REACHABLE, source.reachabilityOf(CONTENT_URI)) + } + + @Test + fun givenAProviderThatStaysSilentOnTheSecondAsk_whenProbed_thenTheAnswerIsUnknown() { + // Neither ask established anything, and only GONE may cost a resident model its pages. + every { resolver.openFileDescriptor(any(), "r") } throws java.io.FileNotFoundException() andThen + null + + assertEquals(SourceReachability.UNKNOWN, source.reachabilityOf(CONTENT_URI)) + } + @Test fun givenARevokedGrant_whenProbed_thenItIsGone() { // As final as a deletion from here: only a fresh pick can bring the document back. diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt index 2511df86..a02b3df0 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt @@ -9,6 +9,7 @@ import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.aiagentlocal.model.DeviceMemory import com.itsaky.androidide.plugins.aiagentlocal.model.ModelFileInfo import com.itsaky.androidide.plugins.aiagentlocal.model.ModelFileSource +import com.itsaky.androidide.plugins.aiagentlocal.model.SourceReachability import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -41,6 +42,9 @@ class LocalLlmSettingsViewModelTest { /** References the provider will not serve, standing in for a deleted document. */ val unreadable = mutableSetOf() + /** References the provider does not answer for, standing in for one killed under pressure. */ + val silent = mutableSetOf() + /** Makes the lookup blow up, standing in for a provider that fails mid-selection. */ var failInfo = false @@ -51,7 +55,11 @@ class LocalLlmSettingsViewModelTest { override fun openStream(context: Context, uriString: String): InputStream? = null - override fun isReadable(context: Context, uriString: String) = uriString !in unreadable + override fun readability(context: Context, uriString: String) = when (uriString) { + in silent -> SourceReachability.UNKNOWN + in unreadable -> SourceReachability.GONE + else -> SourceReachability.REACHABLE + } override fun fallbackDisplayName(uriOrPath: String) = uriOrPath.substringAfterLast('/') @@ -254,6 +262,35 @@ class LocalLlmSettingsViewModelTest { assertEquals(EngineState.Initialized, viewModel.state.value?.engine) } + @Test + fun givenAConfiguredModelWhoseProviderIsSilent_whenTheScreenReturns_thenItsStatusIsLeftAlone() { + // A resident multi-GB model is the pressure that kills a DocumentsProvider; reading that + // silence as a deletion tells the user to re-pick a model that needs nothing. + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + modelFiles.silent += MODEL_A + + viewModel.refreshSavedModelAvailability() + + assertEquals(ModelLoadingState.Loaded("a.gguf"), viewModel.state.value?.model) + assertEquals(EngineState.Initialized, viewModel.state.value?.engine) + } + + @Test + fun givenAnErrorAboutTheConfiguredModel_whenTheScreenReturns_thenItStandsInsteadOfReadingAsLoaded() { + // "Load from saved" refuses the configured model itself, and a readable stream is no answer + // to why: clearing on that probe would report a model loaded that just would not load. + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + every { resolver.openInputStream(any()) } answers { ByteArrayInputStream("NOPE".toByteArray()) } + viewModel.loadModelFromUri(MODEL_A) + val refusal = viewModel.state.value?.model as ModelLoadingState.Error + + viewModel.refreshSavedModelAvailability() + + assertEquals(refusal.message, (viewModel.state.value?.model as ModelLoadingState.Error).message) + } + @Test fun givenAPickAbandonedBeforeItWasStored_thenItsGrantIsGivenBackByTheFinally() { // Grants are capped, and only the finally covers every way out of the selection. From aa25e2e612f63643d711cc9550c2d3c843ea8d85 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 9 Sep 2026 09:53:35 -0500 Subject: [PATCH 5/7] fix(ai-agent-local): address PR #84 round-4 review findings Refusals of the configured model now reach the engine line and an unpersistable read grant is reported at selection time, plus six MINOR/NITPICK fixes. --- .../src/main/assets/docs/index.html | 32 +++-- .../aiagentlocal/backend/LocalLlmBackend.kt | 34 +++--- .../aiagentlocal/feedback/UserFeedback.kt | 7 -- .../aiagentlocal/model/NativeModelSource.kt | 16 ++- .../settings/LocalLlmSettingsFragment.kt | 33 +++--- .../settings/LocalLlmSettingsViewModel.kt | 111 ++++++++++++++---- .../src/main/res/values/strings.xml | 4 +- .../backend/LocalLlmBackendTest.kt | 19 +++ .../model/ContentNativeModelSourceTest.kt | 12 ++ .../settings/LocalLlmSettingsViewModelTest.kt | 87 +++++++++++++- 10 files changed, 272 insertions(+), 83 deletions(-) diff --git a/ai-agent-local/src/main/assets/docs/index.html b/ai-agent-local/src/main/assets/docs/index.html index d0340c77..f44bccca 100644 --- a/ai-agent-local/src/main/assets/docs/index.html +++ b/ai-agent-local/src/main/assets/docs/index.html @@ -59,14 +59,15 @@

      The settings pane

    • Browse — opens the system file picker to choose a .gguf model. The plugin keeps read access to the document you picked and reads it where it is, so nothing is copied and a multi-gigabyte - model costs no extra device storage. Internal storage always works; a - removable volume such as an SD card or a USB drive normally does too, and - when one won't allow a direct read the plugin says so and asks you to copy - the model to internal storage. Keep the file where it is: moving or - deleting it breaks the selection. The picker offers only documents already - stored on the device, because a model still in a cloud folder has to be - read as a stream and cannot be loaded in place. If the file is larger than the device's free RAM, - a warning asks you to confirm before loading.
    • + model costs no extra device storage. Not every location allows that direct + read — removable storage such as an SD card or a USB drive is the + likeliest to refuse it — and when one does, the plugin says so and + asks you to move the model somewhere it can be read. Keep the file where it + is: moving or deleting it breaks the selection. The picker offers only + documents already stored on the device, because a model still in a cloud + folder has to be read as a stream and cannot be loaded in place. If the + file is larger than the device's free RAM, a warning asks you to confirm + before loading.
    • Load from saved — reloads the model you already selected without picking it again. Use this after restarting the IDE, or when a load failed for a transient reason such as low memory.
    • @@ -98,16 +99,21 @@

      Troubleshooting

      the safest starting point).
    • The local backend never appears — AI Core isn't installed or activated; install it and restart the IDE.
    • +
    • "The IDE could not keep lasting permission to read this file" + — the model works for the rest of this session, but the permission + that survives a restart could not be taken, usually because the device + already holds its maximum number of them. If the model stops loading after + you restart the IDE, pick it again with Browse.
    • "The selected model can no longer be reached" — the model is read where you saved it rather than from a copy, so moving, renaming or deleting the file, or removing the SD card it lives on, breaks the selection. Clearing the IDE's app data also withdraws the permission to read it. Pick the model again with Browse.
    • -
    • "This model can't be read from where it is stored" — the - volume the file sits on doesn't let the IDE open it directly, which can - happen on some removable storage. Copy the .gguf to the - device's internal storage — Downloads is fine — and pick it from - there.
    • +
    • "This model can't be read from where it is stored" — the IDE + can see the file but can't open it the way the model loader needs, which is + likeliest on removable storage. Copy the .gguf to a different + folder on the device's own storage — Downloads is a good place to try + — and pick it from there.
    • "This model is streamed from its storage location" — the file you picked lives in a cloud folder (Google Drive, OneDrive) rather than on the device, and can only be read as a stream. Download the diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt index 068b624b..95216630 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt @@ -79,7 +79,7 @@ class LocalLlmBackend( */ private const val LEGACY_MODEL_CACHE_DIR = "llm-models" - /** How long a REACHABLE answer stands in for the next one; see [reachabilityIsFresh]. */ + /** How long an answered probe stands in for the next one; see [probeAnswerIsFresh]. */ private val REACHABILITY_TRUST_NANOS = TimeUnit.SECONDS.toNanos(5) } @@ -131,11 +131,11 @@ class LocalLlmBackend( private val sourceCheckInFlight = AtomicBoolean(false) /** - * When the resident model's source last answered REACHABLE, as a [System.nanoTime] reading. - * The probe is a synchronous binder call in front of every generation, holding + * When the resident model's source last answered a probe at all, as a [System.nanoTime] + * reading. The probe is a synchronous binder call in front of every generation, holding * [generationMutex] — and a provider that hangs rather than dies cannot be cancelled out of. */ - @Volatile private var lastReachableNanos = 0L + @Volatile private var lastProbeAnsweredNanos = 0L /** * Opens the configured model for the native loader. Lazy so construction touches no Android @@ -300,11 +300,15 @@ class LocalLlmBackend( // Residency is not evidence the file still exists. The descriptor this backend holds // keeps a deleted inode alive, so an unchecked early return keeps answering from a // model the user threw away — and keeps its gigabytes mapped. Confirm, then serve. - if (reachabilityIsFresh()) return + if (probeAnswerIsFresh()) return val reachability = modelSource.reachabilityOf(modelRef) - if (reachability == SourceReachability.REACHABLE) lastReachableNanos = System.nanoTime() - // Anything but GONE is served: a silent provider is no reason to pay a GB reload. - if (reachability != SourceReachability.GONE) return + // Anything but GONE is served: a silent provider is no reason to pay a GB reload — + // and it answered, so the window arms on it too. A provider wedged inside the open + // only ever yields UNKNOWN, which armed nothing and cost every message a probe. + if (reachability != SourceReachability.GONE) { + lastProbeAnsweredNanos = System.nanoTime() + return + } context.logger.info("Resident model is no longer reachable; unloading: $modelRef") evictResidentModel() throw unopenable(modelRef) @@ -466,13 +470,13 @@ class LocalLlmBackend( } /** - * Whether the source answered REACHABLE recently enough to be taken at its word again, which - * bounds a wedged provider to one blocked message instead of the session. Only a probe refreshes - * it, so a model deleted between its load and its first message is still caught. + * Whether the source answered a probe — with anything but GONE — recently enough to be taken + * at its word again, which bounds a wedged provider to one blocked message rather than the + * session. Only a probe arms it, so a model deleted before its first message is still caught. */ - private fun reachabilityIsFresh(): Boolean { - val since = System.nanoTime() - lastReachableNanos - return lastReachableNanos != 0L && since in 0..REACHABILITY_TRUST_NANOS + private fun probeAnswerIsFresh(): Boolean { + val since = System.nanoTime() - lastProbeAnsweredNanos + return lastProbeAnsweredNanos != 0L && since in 0..REACHABILITY_TRUST_NANOS } /** @@ -485,7 +489,7 @@ class LocalLlmBackend( currentModelRef = null openModel?.close() openModel = null - lastReachableNanos = 0L + lastProbeAnsweredNanos = 0L } /** diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/feedback/UserFeedback.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/feedback/UserFeedback.kt index 2f6930ac..3e51903f 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/feedback/UserFeedback.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/feedback/UserFeedback.kt @@ -54,13 +54,6 @@ object UserFeedback { */ sealed class UserActionableLlmException(message: String) : IllegalStateException(message) -/** - * Thrown when the local LLM isn't set up (no model selected, or the path can't be resolved), so the - * backend can surface it to the user instead of failing silently. - * @param message user-facing, display-ready text - */ -class ModelNotConfiguredException(message: String) : UserActionableLlmException(message) - /** * Thrown when the selected model is the wrong kind for the request (e.g. an embedding model for * chat, which would abort native inference). See ADFA-4388. diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt index 960fee16..527aea02 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt @@ -164,7 +164,8 @@ class ContentNativeModelSource( /** * One binder round trip for a document, one stat for a path — nothing is read, so this is cheap * enough to ask before every generation. [SourceReachability.GONE] is only ever an answer the - * source gave twice (see [confirmedGone]); anything less is [SourceReachability.UNKNOWN]. + * source gave twice (see [confirmedGone]), on either branch; anything less is + * [SourceReachability.UNKNOWN]. */ override fun reachabilityOf(modelReference: String): SourceReachability = if (modelReference.startsWith(CONTENT_SCHEME)) documentReachability(modelReference) @@ -192,8 +193,17 @@ class ContentNativeModelSource( SourceReachability.UNKNOWN } - private fun fileReachability(path: String): SourceReachability = try { - if (File(path).isFile) SourceReachability.REACHABLE else SourceReachability.GONE + /** + * Confirmed like the document branch, so [reachabilityOf]'s contract — a GONE is only ever an + * answer given twice — holds for every reference, not only the ones that go through a provider. + */ + private fun fileReachability(path: String): SourceReachability = + confirmedGone { probeFile(path) } + + /** Readability, not just existence: a file the loader cannot open is gone as far as it cares. */ + private fun probeFile(path: String): SourceReachability = try { + if (File(path).let { it.isFile && it.canRead() }) SourceReachability.REACHABLE + else SourceReachability.GONE } catch (e: Exception) { onError("could not stat the model file $path", e) SourceReachability.UNKNOWN diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt index 4329a4ae..9d809316 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt @@ -40,22 +40,16 @@ class LocalLlmSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host { private val filePickerLauncher = registerForActivityResult(PickLocalDocument) { uri: Uri? -> uri?.let { - try { - // The durable read grant is taken by the view model, with the rest of the - // selection's bookkeeping — see LocalLlmSettingsViewModel.loadModelFromUri. - viewModel.loadModelFromUri(it.toString()) - Toast.makeText( - requireContext(), - getString(R.string.model_loading_toast), - Toast.LENGTH_SHORT - ).show() - } catch (e: Exception) { - Toast.makeText( - requireContext(), - getString(R.string.state_error, e.message), - Toast.LENGTH_LONG - ).show() - } + // The durable read grant is taken by the view model, with the rest of the + // selection's bookkeeping — see LocalLlmSettingsViewModel.loadModelFromUri. It + // runs in its own scope and puts every outcome on the status line, so nothing + // here can throw and there is nothing to catch. + viewModel.loadModelFromUri(it.toString()) + Toast.makeText( + requireContext(), + getString(R.string.model_loading_toast), + Toast.LENGTH_SHORT + ).show() } } @@ -192,7 +186,12 @@ class LocalLlmSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host { modelStatusTextView.text = when (val model = state.model) { is ModelLoadingState.Idle -> getString(R.string.model_none_loaded) is ModelLoadingState.Loading -> getString(R.string.model_loading_wait) - is ModelLoadingState.Loaded -> getString(R.string.model_loaded, model.modelName) + is ModelLoadingState.Loaded -> + if (model.accessPersisted) { + getString(R.string.model_loaded, model.modelName) + } else { + getString(R.string.model_loaded_access_not_persisted, model.modelName) + } is ModelLoadingState.Unavailable -> getString(R.string.model_unavailable, model.modelName) is ModelLoadingState.Error -> getString(R.string.model_load_error, model.message) diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt index 5ca04aaf..26b7301f 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt @@ -34,7 +34,19 @@ import kotlinx.coroutines.launch sealed class ModelLoadingState { object Idle : ModelLoadingState() object Loading : ModelLoadingState() - data class Loaded(val modelName: String) : ModelLoadingState() + + /** + * A model is configured and readable. [accessPersisted] false means only the picker's own + * one-off grant makes it so, and the selection may not survive a restart — said now, while the + * user still holds that grant and can act on it (ADFA-5253). + * + * @param modelName the model's display name + * @param accessPersisted whether the read grant will still be held after a restart + */ + data class Loaded( + val modelName: String, + val accessPersisted: Boolean = true, + ) : ModelLoadingState() /** * A model is configured but its file can no longer be read — deleted, unmounted, or the read @@ -148,6 +160,17 @@ class LocalLlmSettingsViewModel( private val _state = MutableLiveData(current) val state: LiveData get() = _state + /** + * How many states have been published. A background check reads it before its probe and hands + * it back on the way out: anything published while the probe ran is newer than the answer it + * is carrying, and a refused pick's explanation is not the probe's to overwrite. + */ + private var statusGeneration = 0L + + /** The generation to hand back to [updateIfNothingPublishedSince] after a probe. */ + @Synchronized + private fun currentGeneration(): Long = statusGeneration + /** * Applies [transform] to the state and publishes the result. Synchronized because the memory * pre-flight, the availability re-check and a load can all be in flight at once. @@ -155,9 +178,27 @@ class LocalLlmSettingsViewModel( @Synchronized private fun update(transform: (LocalLlmSettingsState) -> LocalLlmSettingsState) { current = transform(current) + statusGeneration++ _state.postValue(current) } + /** + * [update], unless something was published since [generation] — in which case this caller's + * answer is the older one and the newer status stands. + * + * @param generation the value [currentGeneration] returned before the work that led here + * @param transform the new state, or null to publish nothing and leave the generation alone + */ + @Synchronized + private fun updateIfNothingPublishedSince( + generation: Long, + transform: (LocalLlmSettingsState) -> LocalLlmSettingsState?, + ) { + if (statusGeneration != generation) return + val next = transform(current) ?: return + update { next } + } + /** The memory pre-flight's consent gate; see [loadModelFromUri]. */ private val memoryConfirmation = UserConfirmation() @@ -202,19 +243,27 @@ class LocalLlmSettingsViewModel( fun refreshSavedModelAvailability() { val savedPath = getLocalModelPath() ?: return val context = getContext()?.androidContext ?: return + // Taken before the probe: a pick refused while it runs publishes a newer status, and this + // answer — which is only ever about savedPath — must not overwrite the explanation. + val generation = currentGeneration() viewModelScope.launch(ioDispatcher) { val reachability = modelFiles.readability(context, savedPath) // A selection made while the check ran owns the status now; leave it to that load. if (getLocalModelPath() != savedPath) return@launch + // Not covered by the generation guard below: a load already in flight when this check + // started published its Loading before the generation was taken. if (current.model is ModelLoadingState.Loading) return@launch when (reachability) { - SourceReachability.REACHABLE -> clearStatusMadeStaleBy(savedPath) + SourceReachability.REACHABLE -> clearStatusMadeStaleBy(savedPath, generation) SourceReachability.GONE -> { logger?.warn("$TAG: the configured model can no longer be read: $savedPath") - publishModelState(ModelLoadingState.Unavailable(displayNameFor(savedPath))) + val gone = ModelLoadingState.Unavailable(displayNameFor(savedPath)) + updateIfNothingPublishedSince(generation) { + it.copy(model = gone, engine = engineStateFor(gone) ?: it.engine) + } } // Silence says nothing about the model, so it may not restate its status either way. SourceReachability.UNKNOWN -> @@ -229,14 +278,20 @@ class LocalLlmSettingsViewModel( * that a stream opens, which is no answer to a model whose bytes stopped being a GGUF. * * @param savedPath the configured model, confirmed readable a moment ago + * @param generation the state's generation from before the probe; a status published since is + * newer than this answer, so it stands whatever it says */ - private fun clearStatusMadeStaleBy(savedPath: String) { - val stale = when (val model = current.model) { - is ModelLoadingState.Unavailable -> true - is ModelLoadingState.Error -> model.reference != savedPath - else -> false + private fun clearStatusMadeStaleBy(savedPath: String, generation: Long) { + updateIfNothingPublishedSince(generation) { state -> + val stale = when (val model = state.model) { + is ModelLoadingState.Unavailable -> true + is ModelLoadingState.Error -> model.reference != savedPath + else -> false + } + if (!stale) return@updateIfNothingPublishedSince null + val model = modelStateFor(savedPath) + state.copy(model = model, engine = engineStateFor(model) ?: state.engine) } - if (stale) publishModelState(modelStateFor(savedPath)) } /** @@ -250,7 +305,8 @@ class LocalLlmSettingsViewModel( /** * Publishes a selection that was not kept: the model line says what went wrong with the pick, * the engine line keeps describing the *configured* model. A pick of another file hands the - * engine back untouched; "Load from saved" re-picks the configured one, so its failure counts. + * engine back untouched; "Load from saved" re-picks the configured one, so its failure counts + * on both lines. * * @param uriString the pick that was abandoned * @param model what to say about it @@ -261,12 +317,13 @@ class LocalLlmSettingsViewModel( model: ModelLoadingState, engineBefore: EngineState, ) { - val engine = - if (uriString == getLocalModelPath()) engineStateFor(model) ?: engineBefore - else engineBefore - // Stamped here rather than at each call site, so no refusal can forget what it was about. + // Stamped here rather than at each call site, so no refusal can forget what it was about, + // and before the engine state below, which is derived from the reference it carries. val stamped = if (model is ModelLoadingState.Error) model.copy(reference = uriString) else model + val engine = + if (uriString == getLocalModelPath()) engineStateFor(stamped) ?: engineBefore + else engineBefore update { it.copy(model = stamped, engine = engine) } } @@ -281,9 +338,15 @@ class LocalLlmSettingsViewModel( is ModelLoadingState.Loading -> EngineState.Initializing is ModelLoadingState.Loaded -> EngineState.Initialized is ModelLoadingState.Unavailable -> EngineState.ModelUnavailable - // A rejected *selection* says nothing about the model that is actually configured, which - // this leaves in place — so it must not restate that model's readiness either way. - is ModelLoadingState.Error -> null + // A refusal of the *configured* model is the engine's too: it is the model the engine would + // load, so leaving the line alone draws "Engine ready" beside "isn't a valid .gguf". A + // refusal of any other pick says nothing about it, and hands the line back untouched. + is ModelLoadingState.Error -> + if (state.reference != null && state.reference == getLocalModelPath()) { + EngineState.Error(state.message) + } else { + null + } } /** @@ -357,7 +420,9 @@ class LocalLlmSettingsViewModel( * and therefore never loaded (ADFA-1798). * * The read grant is made persistable first: the model is read in place rather than copied, so - * without a durable grant the stored path would stop resolving at the next restart (ADFA-5253). + * without a durable grant the stored path would stop resolving at the next restart. When that + * grant cannot be taken the model is still kept, and the status line says it may need picking + * again after a restart (ADFA-5253). * * @param uriString the selected model, as a `content://` URI or a filesystem path */ @@ -380,9 +445,11 @@ class LocalLlmSettingsViewModel( var stored = false try { // Taken before the first read, so every step below works off the durable grant. - if (!modelFiles.persistAccess(context, uriString)) { - // Readable now through the picker's own grant, but not after a restart. Better - // to load it and say so later than to refuse a model the user just picked. + // Readable now through the picker's own grant even when this fails, so the model is + // kept rather than refused — and the status line carries the caveat, at selection + // time, while the user is still holding a grant they can act on. + val accessPersisted = modelFiles.persistAccess(context, uriString) + if (!accessPersisted) { logger?.warn("$TAG: no persistable read grant for $uriString") } @@ -433,7 +500,7 @@ class LocalLlmSettingsViewModel( stored = true // Nothing is loaded here; the engine reads this path when it needs the model. - publishModelState(ModelLoadingState.Loaded(fileName)) + publishModelState(ModelLoadingState.Loaded(fileName, accessPersisted)) logger?.debug("$TAG: model path saved: $uriString ($fileName)") } catch (e: CancellationException) { diff --git a/ai-agent-local/src/main/res/values/strings.xml b/ai-agent-local/src/main/res/values/strings.xml index 56542fd6..967f04f9 100644 --- a/ai-agent-local/src/main/res/values/strings.xml +++ b/ai-agent-local/src/main/res/values/strings.xml @@ -5,7 +5,7 @@ The model file could not be found. Re-select the .gguf model in AI Settings. The selected model can no longer be reached. It may have been moved, deleted, or saved to storage that isn\'t connected right now, or the IDE\'s permission to read it was withdrawn. Select the .gguf model again in AI Settings. This model is streamed from its storage location rather than stored on this device, so it can\'t be read in place. Download the .gguf to the device — for example to Downloads — and select it from there. - This model can\'t be read from where it is stored — the storage volume doesn\'t allow the IDE to open it directly. Copy the .gguf to the device\'s internal storage — for example to Downloads — and select it from there. + This model can\'t be read from where it is stored — the IDE can see the file but can\'t open it the way the model loader needs. Copy the .gguf to a different folder on the device\'s own storage and select it from there. The model file is empty — the download may have been interrupted. Re-download the .gguf model and select it again. This file isn\'t a valid .gguf model (it may be corrupt or only partially downloaded). Re-download the model and select it again. Loading this model needs at least %1$s of free memory, but only %2$s is available on this device. Close other apps and try again, or pick a smaller or more heavily quantized model (for example a Q4_K_M build of a 1–3B model). @@ -13,7 +13,6 @@ Another model is still loading or in use. Wait a moment and try again. The model couldn\'t be loaded. It may use a format or quantization this build doesn\'t support, or the file may be corrupt. Try a different .gguf — a Q4_K_M quantization of a smaller model is most likely to work. - Error: %s Initializing engine… Engine ready Engine not ready — no model selected @@ -22,6 +21,7 @@ No model is currently loaded Loading model, please wait… ✅ Model loaded: %s + ✅ Model loaded: %1$s\n⚠️ The IDE could not keep lasting permission to read this file, so you may have to select it again after restarting the IDE. ❌ Error: %s ⚠️ \"%1$s\" can no longer be reached. It may have been moved, deleted, or saved to storage that isn\'t connected right now. Select the .gguf model again. Saved: %s (unavailable) diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt index f60071a7..751df847 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt @@ -316,6 +316,25 @@ class LocalLlmBackendTest { assertFalse("the descriptor must stay open", descriptor.closed) } + @Test + fun givenAProviderThatOnlyEverAnswersUnknown_whenGeneratingAgain_thenItIsNotReProbed() { + // A provider wedged inside openFileDescriptor never yields REACHABLE, so arming the trust + // window on REACHABLE alone left every message paying the probe, under generationMutex. + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(chatModel()))) + val backend = backendWith(source, FakeEngine()) + + runBlocking { backend.ensureModelLoaded(CONTENT_URI) } + source.reachable = false + source.whenUnreachable = SourceReachability.UNKNOWN + runBlocking { backend.ensureModelLoaded(CONTENT_URI) } + val after = source.probeCount + + runBlocking { backend.ensureModelLoaded(CONTENT_URI) } + runBlocking { backend.ensureModelLoaded(CONTENT_URI) } + + assertEquals("an answered probe stands in for the next ones", after, source.probeCount) + } + @Test fun givenAResidentModel_whenItsWatchFiresAndTheProviderIsSilent_thenItStaysLoaded() { // The same distinction on the watch path, where a burst of notifications arrives. diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt index d7489a0a..c108ad2b 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentNativeModelSourceTest.kt @@ -68,6 +68,18 @@ class ContentNativeModelSourceTest { assertEquals(SourceReachability.GONE, source.reachabilityOf(directory.absolutePath)) } + @Test + fun givenAPathThatComesBackBeforeTheSecondAsk_whenProbed_thenItIsReachableRatherThanGone() { + // The file branch is confirmed too, so reachabilityOf's contract — GONE is only ever an + // answer given twice — holds for a legacy filesystem path as well. Created well inside the + // confirmation delay: a late first ask can only make this pass, never fail. + val model = temporaryFolder.newFile("model.gguf") + assertTrue(model.delete()) + Thread { Thread.sleep(20L); model.writeBytes(ByteArray(8)) }.start() + + assertEquals(SourceReachability.REACHABLE, source.reachabilityOf(model.absolutePath)) + } + @Test fun givenADocumentTheProviderStillServes_whenProbed_thenItIsReachable() { every { resolver.openFileDescriptor(any(), "r") } returns mockk(relaxed = true) diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt index a02b3df0..e5e6f0b9 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt @@ -48,6 +48,12 @@ class LocalLlmSettingsViewModelTest { /** Makes the lookup blow up, standing in for a provider that fails mid-selection. */ var failInfo = false + /** References the grant table has no room for, standing in for a full one. */ + val unpersistable = mutableSetOf() + + /** Runs inside a readability probe, so a test can land a status while one is in flight. */ + var duringReadability: ((String) -> Unit)? = null + override fun info(context: Context, uriString: String): ModelFileInfo { if (failInfo) throw IllegalStateException("provider failed") return ModelFileInfo(fallbackDisplayName(uriString), 1_024L) @@ -55,15 +61,19 @@ class LocalLlmSettingsViewModelTest { override fun openStream(context: Context, uriString: String): InputStream? = null - override fun readability(context: Context, uriString: String) = when (uriString) { - in silent -> SourceReachability.UNKNOWN - in unreadable -> SourceReachability.GONE - else -> SourceReachability.REACHABLE + override fun readability(context: Context, uriString: String): SourceReachability { + duringReadability?.invoke(uriString) + return when (uriString) { + in silent -> SourceReachability.UNKNOWN + in unreadable -> SourceReachability.GONE + else -> SourceReachability.REACHABLE + } } override fun fallbackDisplayName(uriOrPath: String) = uriOrPath.substringAfterLast('/') override fun persistAccess(context: Context, uriString: String): Boolean { + if (uriString in unpersistable) return false persisted += uriString return true } @@ -341,6 +351,75 @@ class LocalLlmSettingsViewModelTest { assertEquals(MODEL_A, viewModel.state.value?.savedModelPath) } + @Test + fun givenASelectionWhoseGrantCannotBePersisted_thenItIsKeptAndTheCaveatIsShown() { + // Only logging it left the model working all session and failing every message after a + // restart, with advice to re-pick a file that never moved. + modelFiles.unpersistable += MODEL_A + val viewModel = viewModel() + + viewModel.loadModelFromUri(MODEL_A) + + assertEquals(MODEL_A, viewModel.getLocalModelPath()) + assertEquals( + ModelLoadingState.Loaded("a.gguf", accessPersisted = false), + viewModel.state.value?.model, + ) + assertEquals(EngineState.Initialized, viewModel.state.value?.engine) + } + + @Test + fun givenARefusalOfTheConfiguredModel_thenTheEngineLineCarriesItToo() { + // Otherwise the pane draws "Engine ready" beside "isn't a valid .gguf", about one file. + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + every { resolver.openInputStream(any()) } answers { ByteArrayInputStream("NOPE".toByteArray()) } + + viewModel.loadModelFromUri(MODEL_A) + + val refusal = viewModel.state.value?.model as ModelLoadingState.Error + assertEquals(EngineState.Error(refusal.message), viewModel.state.value?.engine) + } + + @Test + fun givenAnEngineReadingUnavailable_whenTheConfiguredModelIsRefused_thenItStopsSayingUnavailable() { + // The mirror case: the model reads back fine, so "(unavailable)" must not outlive the probe + // that disproved it — and nothing else can clear it, now that the error rightly stands. + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + modelFiles.unreadable += MODEL_A + viewModel.refreshSavedModelAvailability() + assertEquals(EngineState.ModelUnavailable, viewModel.state.value?.engine) + + modelFiles.unreadable -= MODEL_A + every { resolver.openInputStream(any()) } answers { ByteArrayInputStream("NOPE".toByteArray()) } + viewModel.loadModelFromUri(MODEL_A) + + assertTrue(viewModel.state.value?.engine is EngineState.Error) + } + + @Test + fun givenARejectedPick_whenAReCheckStartedBeforeItLands_thenTheRejectionsErrorStands() { + // The re-check's answer is only about the configured model, and it is the older one: it + // used to overwrite the refusal with "Model loaded" and lose the only explanation. + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + modelFiles.duringReadability = { probed -> + if (probed == MODEL_A) { + modelFiles.duringReadability = null + every { resolver.openInputStream(any()) } answers { + ByteArrayInputStream("NOPE".toByteArray()) + } + viewModel.loadModelFromUri(MODEL_B) + } + } + + viewModel.refreshSavedModelAvailability() + + val error = viewModel.state.value?.model as ModelLoadingState.Error + assertEquals(MODEL_B, error.reference) + } + private companion object { const val MODEL_A = "content://com.android.externalstorage.documents/document/a.gguf" const val MODEL_B = "content://com.android.externalstorage.documents/document/b.gguf" From 733128ef897a59b25ba96ea3492a9b9236913a11 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 9 Sep 2026 10:51:56 -0500 Subject: [PATCH 6/7] fix(ai-agent-local): hold the replaced model's read grant until a load succeeds Defer the release to LocalLlmBackend's first adopted load, derive the durable-grant caveat on every visit, confirm GONE on the file probe, and shorten the engine line. --- .../aiagentlocal/backend/LocalLlmBackend.kt | 30 ++++++ .../aiagentlocal/model/ModelFileSource.kt | 26 +++++- .../aiagentlocal/model/NativeModelSource.kt | 25 +++++ .../preferences/LocalLlmPreferences.kt | 27 ++++++ .../settings/LocalLlmSettingsFragment.kt | 6 +- .../settings/LocalLlmSettingsViewModel.kt | 91 ++++++++++++++----- .../src/main/res/values/strings.xml | 1 + .../backend/LocalLlmBackendTest.kt | 66 ++++++++++++++ .../model/ContentModelFileSourceTest.kt | 54 +++++++++++ .../settings/LocalLlmSettingsViewModelTest.kt | 77 +++++++++++++++- 10 files changed, 373 insertions(+), 30 deletions(-) diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt index 95216630..2587b6d8 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt @@ -401,6 +401,7 @@ class LocalLlmBackend( currentModelRef = modelRef openModel = opened adopted = true + releaseSupersededGrants(modelRef) startWatching(modelRef) context.logger.info("Model loaded successfully") reportEffectiveContextSize(contextSize.contextTokens) @@ -409,6 +410,35 @@ class LocalLlmBackend( } } + /** + * Gives back the read grants of the models this selection replaced, now that one has actually + * loaded. Deferred to here on purpose: the checks that reject a model — `isSeekable`, + * `isReopenable`, the embedding-model guard — all run above, and nothing is copied any more, + * so releasing at selection time would have cost the user the model they were running for a + * pick this method never gets to (ADFA-5253). The settings pane writes the list; see + * `LocalLlmSettingsViewModel.supersede`. + * + * Cleared before the releases, so a provider that throws cannot leave the list to be retried + * on every later load. + * + * @param loadedRef the model just adopted; never released, however it got onto the list + */ + private fun releaseSupersededGrants(loadedRef: String) { + try { + val prefs = LocalLlmPreferences.of(context) + val superseded = LocalLlmPreferences.supersededModels(prefs) + if (superseded.isEmpty()) return + LocalLlmPreferences.setSupersededModels(prefs, emptySet()) + for (reference in superseded - loadedRef) { + context.logger.debug("Releasing the read grant of a replaced model: $reference") + modelSource.releaseAccess(reference) + } + } catch (e: Exception) { + // Grant bookkeeping, not the load: a model that is resident stays resident. + context.logger.warn("Could not release the replaced models' read grants", e) + } + } + /** * Logs the context the native side actually created. It can be smaller than what was asked for * — clamped to the trained context, or dropped to the shorter f16 fallback when a quantized diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt index 2d6e5616..4ad9a7ee 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt @@ -58,6 +58,17 @@ interface ModelFileSource { */ fun persistAccess(context: Context, uriString: String): Boolean + /** + * Whether a durable read grant for [uriString] is held right now, which is what decides + * — asked again on every visit rather than remembered from the selection — whether the pane + * still has to warn that the model may need picking again after a restart. Not for the main + * thread. + * + * @return true for a filesystem path, and whenever the answer cannot be established: a caveat + * that may be wrong is worse than none + */ + fun hasPersistedAccess(context: Context, uriString: String): Boolean + /** * Give back the persistable read grant the picker took for [uriString], for a model the user * ended up not keeping — the grant table has a hard per-app limit. A no-op for a filesystem @@ -101,7 +112,9 @@ class ContentModelFileSource( // Confirmed: one FileNotFoundException covers a deletion and a dead provider alike. confirmedGone { probeDocument(context, uriString) } } else { - probeFile(uriString) + // Confirmed on this branch too, so a GONE is an answer given twice for every reference + // — a stat that lost a race with a mount refuses a pick over a model that is fine. + confirmedGone { probeFile(uriString) } } private fun probeDocument(context: Context, uriString: String): SourceReachability = try { @@ -150,6 +163,17 @@ class ContentModelFileSource( } } + override fun hasPersistedAccess(context: Context, uriString: String): Boolean { + if (!uriString.startsWith(CONTENT_SCHEME)) return true + return try { + context.contentResolver.persistedUriPermissions + .any { it.isReadPermission && it.uri.toString() == uriString } + } catch (e: Exception) { + onError("could not read the persisted read grants for $uriString", e) + true + } + } + override fun releaseAccess(context: Context, uriString: String) { if (!uriString.startsWith(CONTENT_SCHEME)) return try { diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt index 527aea02..3a2797f2 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.plugins.aiagentlocal.model import android.content.Context +import android.content.Intent import android.net.Uri import java.io.Closeable import java.io.File @@ -131,6 +132,16 @@ interface NativeModelSource { * @return what the probe found; [SourceReachability.UNKNOWN] when the source stayed silent */ fun reachabilityOf(modelReference: String): SourceReachability + + /** + * Give back the persistable read grant for [modelReference], a model a later selection + * replaced. Held until this side rather than released when the replacement was picked: every + * check that actually refuses a model runs here, and with nothing copied any more that grant + * is the only thing keeping the replaced model readable (ADFA-5253). + * + * A no-op for a filesystem path, and for a grant that was never held. + */ + fun releaseAccess(modelReference: String) } /** @@ -209,6 +220,20 @@ class ContentNativeModelSource( SourceReachability.UNKNOWN } + override fun releaseAccess(modelReference: String) { + if (!modelReference.startsWith(CONTENT_SCHEME)) return + try { + context.contentResolver.releasePersistableUriPermission( + Uri.parse(modelReference), + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } catch (_: SecurityException) { + // Nothing was held, or it was already released: the no-op this documents. + } catch (e: Exception) { + onError("could not release the read grant for $modelReference", e) + } + } + private fun openFile(path: String): OpenModelFile? = try { File(path).takeIf { it.isFile }?.let { OpenModelFile(it.absolutePath, it.length(), null) } } catch (e: Exception) { diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/preferences/LocalLlmPreferences.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/preferences/LocalLlmPreferences.kt index 3094d8b0..40b7862a 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/preferences/LocalLlmPreferences.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/preferences/LocalLlmPreferences.kt @@ -21,6 +21,14 @@ internal object LocalLlmPreferences { const val KEY_MODEL_SHA256 = "local_llm_model_sha256" const val KEY_SIMPLE_PROMPT = "use_simple_local_prompt" + /** + * Models a later selection replaced, whose persisted read grant is still held. Written by the + * settings pane at selection time and cleared by the first load that actually succeeds, so a + * pick the loader goes on to refuse never costs the user the model they already had. See + * [supersededModels] (ADFA-5253). + */ + private const val KEY_SUPERSEDED_MODELS = "local_llm_superseded_models" + /** Set once [migrateIfNeeded] has run, so a value changed since is never overwritten. */ private const val KEY_MIGRATED = "migrated_from_agent_settings" @@ -68,6 +76,25 @@ internal object LocalLlmPreferences { fun useSimplePrompt(context: PluginContext): Boolean = of(context).getBoolean(KEY_SIMPLE_PROMPT, true) + /** + * The models whose read grants are held for a selection that has not been loaded yet. Not one + * of [OWNED_KEYS]: it is grant bookkeeping for this install, and nothing a legacy store holds. + * + * @return a copy — the stored set is the one `SharedPreferences` handed out, so it may not be + * modified in place + */ + fun supersededModels(prefs: SharedPreferences): Set = + prefs.getStringSet(KEY_SUPERSEDED_MODELS, emptySet())?.toSet().orEmpty() + + /** + * Replaces the whole list read by [supersededModels]. + * + * @param references the models whose grants are still held; empty once they are given back + */ + fun setSupersededModels(prefs: SharedPreferences, references: Set) { + prefs.edit().putStringSet(KEY_SUPERSEDED_MODELS, HashSet(references)).apply() + } + /** * Copies this backend's settings out of every store in [LEGACY_FILES], once. * diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt index 9d809316..32369f9c 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsFragment.kt @@ -154,12 +154,14 @@ class LocalLlmSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host { // All three lines describe the same model, so they are drawn from one state in one pass: // an unreachable model must not read as ready on one line and missing on another. viewModel.state.observe(viewLifecycleOwner) { state -> - engineStatusTextView.text = when (val engine = state.engine) { + // Every branch is a short phrase about the engine; the refusal's own sentence belongs + // to the model line below, which would otherwise draw the same paragraph twice. + engineStatusTextView.text = when (state.engine) { is EngineState.NoModel -> getString(R.string.engine_no_model) is EngineState.ModelUnavailable -> getString(R.string.engine_model_unavailable) is EngineState.Initializing -> getString(R.string.engine_initializing) is EngineState.Initialized -> getString(R.string.engine_ready) - is EngineState.Error -> engine.message + is EngineState.Error -> getString(R.string.engine_error) } // Enabled off the model status, not off engine readiness: picking a model is exactly diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt index 26b7301f..73090ecf 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt @@ -37,8 +37,9 @@ sealed class ModelLoadingState { /** * A model is configured and readable. [accessPersisted] false means only the picker's own - * one-off grant makes it so, and the selection may not survive a restart — said now, while the - * user still holds that grant and can act on it (ADFA-5253). + * one-off grant makes it so, and the selection may not survive a restart — said at selection + * time, while the user still holds that grant and can act on it, and re-derived on every later + * visit by [LocalLlmSettingsViewModel.refreshSavedModelAvailability] (ADFA-5253). * * @param modelName the model's display name * @param accessPersisted whether the read grant will still be held after a restart @@ -81,7 +82,13 @@ sealed class EngineState { object Initializing : EngineState() object Initialized : EngineState() - data class Error(val message: String) : EngineState() + + /** + * The configured model was refused, so there is nothing the engine could load. Carries no + * message: the refusal is the model line's to explain in full, and an engine line that + * repeated it drew the same paragraph twice. + */ + object Error : EngineState() } /** @@ -249,6 +256,9 @@ class LocalLlmSettingsViewModel( viewModelScope.launch(ioDispatcher) { val reachability = modelFiles.readability(context, savedPath) + // Asked here rather than remembered from the selection: this is the pass that runs on + // every visit, and a grant can be dropped long after the pick that took it. + val accessPersisted = modelFiles.hasPersistedAccess(context, savedPath) // A selection made while the check ran owns the status now; leave it to that load. if (getLocalModelPath() != savedPath) return@launch @@ -257,7 +267,8 @@ class LocalLlmSettingsViewModel( if (current.model is ModelLoadingState.Loading) return@launch when (reachability) { - SourceReachability.REACHABLE -> clearStatusMadeStaleBy(savedPath, generation) + SourceReachability.REACHABLE -> + publishConfirmedReadable(savedPath, generation, accessPersisted) SourceReachability.GONE -> { logger?.warn("$TAG: the configured model can no longer be read: $savedPath") val gone = ModelLoadingState.Unavailable(displayNameFor(savedPath)) @@ -273,23 +284,35 @@ class LocalLlmSettingsViewModel( } /** - * Replaces a status that a just-confirmed readability makes stale, and leaves every other one. - * An [ModelLoadingState.Error] about the configured model is not stale: this probe only proves - * that a stream opens, which is no answer to a model whose bytes stopped being a GGUF. + * Replaces a status that a just-confirmed readability makes stale, and refreshes the durable- + * grant caveat on one that stands. An [ModelLoadingState.Error] about the configured model is + * not stale: this probe only proves that a stream opens, which is no answer to a model whose + * bytes stopped being a GGUF. * * @param savedPath the configured model, confirmed readable a moment ago * @param generation the state's generation from before the probe; a status published since is * newer than this answer, so it stands whatever it says + * @param accessPersisted whether a durable read grant for [savedPath] is still held */ - private fun clearStatusMadeStaleBy(savedPath: String, generation: Long) { + private fun publishConfirmedReadable( + savedPath: String, + generation: Long, + accessPersisted: Boolean, + ) { updateIfNothingPublishedSince(generation) { state -> - val stale = when (val model = state.model) { - is ModelLoadingState.Unavailable -> true - is ModelLoadingState.Error -> model.reference != savedPath - else -> false + val model = when (val shown = state.model) { + // Stale: the model reads back, so it is not unreachable any more. + is ModelLoadingState.Unavailable -> modelStateFor(savedPath, accessPersisted) + // Stands, and only the caveat on it can have changed since it was published. + is ModelLoadingState.Loaded -> shown.copy(accessPersisted = accessPersisted) + // An error about another pick is stale; one about this model is the only answer + // anyone has to why it would not load, and a readable stream does not refute it. + is ModelLoadingState.Error -> + if (shown.reference == savedPath) return@updateIfNothingPublishedSince null + else modelStateFor(savedPath, accessPersisted) + else -> return@updateIfNothingPublishedSince null } - if (!stale) return@updateIfNothingPublishedSince null - val model = modelStateFor(savedPath) + if (model == state.model) return@updateIfNothingPublishedSince null state.copy(model = model, engine = engineStateFor(model) ?: state.engine) } } @@ -343,7 +366,7 @@ class LocalLlmSettingsViewModel( // refusal of any other pick says nothing about it, and hands the line back untouched. is ModelLoadingState.Error -> if (state.reference != null && state.reference == getLocalModelPath()) { - EngineState.Error(state.message) + EngineState.Error } else { null } @@ -354,10 +377,16 @@ class LocalLlmSettingsViewModel( * load time, so it needs no engine query. * * @param savedPath the stored model path, or null when none is configured + * @param accessPersisted whether a durable read grant is held; only the off-main pass in + * [refreshSavedModelAvailability] can answer that, so the main-thread callers assume it and + * let that pass correct them */ - private fun modelStateFor(savedPath: String?): ModelLoadingState = + private fun modelStateFor( + savedPath: String?, + accessPersisted: Boolean = true, + ): ModelLoadingState = if (savedPath != null) { - ModelLoadingState.Loaded(displayNameFor(savedPath)) + ModelLoadingState.Loaded(displayNameFor(savedPath), accessPersisted) } else { ModelLoadingState.Idle } @@ -488,11 +517,8 @@ class LocalLlmSettingsViewModel( return@launch } - // The model being replaced is no longer read by anything, and grants are capped. - val replaced = getLocalModelPath() - if (replaced != null && replaced != uriString) { - modelFiles.releaseAccess(context, replaced) - } + // Held rather than given back here; see supersede(). + supersede(replaced = getLocalModelPath(), selected = uriString) // Persist the name before the path so the savedModelPath observer can read it. saveLocalModelName(fileName) @@ -581,6 +607,27 @@ class LocalLlmSettingsViewModel( } } + /** + * Records the model [selected] replaced, whose read grant is kept until the backend has loaded + * a model at least once. None of the checks above is the one that rejects a model — + * `isSeekable`, `isReopenable` and the embedding-model guard all run in `ensureModelLoaded` — + * and nothing is copied any more, so releasing here would strand the model the user was + * running behind a pick the loader is about to refuse (ADFA-5253). + * + * [selected] itself is dropped from the list: re-picking a superseded model makes it the one + * to keep. `LocalLlmBackend.releaseSupersededGrants` gives the rest back. + * + * @param replaced the configured model this selection displaces, if any + * @param selected the model just picked + */ + private fun supersede(replaced: String?, selected: String) { + val prefs = prefs() ?: return + val pending = LocalLlmPreferences.supersededModels(prefs).toMutableSet() + replaced?.takeIf { it != selected }?.let(pending::add) + pending -= selected + LocalLlmPreferences.setSupersededModels(prefs, pending) + } + /** * Gives back the grant taken for a selection that was not kept, so an abandoned pick does not * hold a slot in the capped grant table. Called only from [loadModelFromUri]'s `finally`, so no diff --git a/ai-agent-local/src/main/res/values/strings.xml b/ai-agent-local/src/main/res/values/strings.xml index 967f04f9..8d3fa092 100644 --- a/ai-agent-local/src/main/res/values/strings.xml +++ b/ai-agent-local/src/main/res/values/strings.xml @@ -17,6 +17,7 @@ Engine ready Engine not ready — no model selected Engine not ready — the selected model can\'t be reached + Engine not ready — the selected model was refused Saved: %s No model is currently loaded Loading model, please wait… diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt index 751df847..11ccc46b 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt @@ -52,6 +52,9 @@ class LocalLlmBackendTest { /** Reachability probes served, so a burst of watch notifications can be counted. */ @Volatile var probeCount = 0 + /** Read grants given back, which only a load that succeeded may do. */ + val released = mutableListOf() + override fun open(modelReference: String): OpenModelFile? { openCount++ return handles[modelReference].takeIf { reachable } @@ -62,6 +65,10 @@ class LocalLlmBackendTest { return if (reachable && handles.containsKey(modelReference)) SourceReachability.REACHABLE else whenUnreachable } + + override fun releaseAccess(modelReference: String) { + released += modelReference + } } /** Stands in for the native engine, recording residency without loading any weights. */ @@ -467,6 +474,46 @@ class LocalLlmBackendTest { } /** The eviction runs on the backend's own cleanup scope, so the test waits for it. */ + @Test + fun givenAReplacedModel_whenTheNewOneLoads_thenItsReadGrantIsGivenBack() { + // Deferred to here from the selection: the settings pane keeps the grant so a pick this + // method refuses cannot cost the user the model they were running (ADFA-5253). + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(chatModel()))) + val pending = supersede(OTHER_CONTENT_URI) + + runBlocking { backendWith(source, FakeEngine()).ensureModelLoaded(CONTENT_URI) } + + assertEquals(listOf(OTHER_CONTENT_URI), source.released) + assertEquals("a released grant must not be released again", emptySet(), pending[KEY_SUPERSEDED]) + } + + @Test + fun givenAReplacedModel_whenTheNewOneIsRefused_thenItsReadGrantSurvives() { + // The whole point: an embedding model gets past the settings pane's checks and is refused + // here, and the working model it replaced has to still be readable afterwards. + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(GgufTestFiles.withArchitecture("bert")))) + val pending = supersede(OTHER_CONTENT_URI) + + assertThrows(IncompatibleModelException::class.java) { + runBlocking { backendWith(source).ensureModelLoaded(CONTENT_URI) } + } + + assertEquals(emptyList(), source.released) + assertEquals(setOf(OTHER_CONTENT_URI), pending[KEY_SUPERSEDED]) + } + + @Test + fun givenTheLoadedModelItselfOnTheList_whenItLoads_thenItsOwnGrantIsNotReleased() { + // Re-picking a model that had been replaced takes it off the list; releasing it here would + // revoke the grant on the model that just loaded. + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(chatModel()))) + supersede(CONTENT_URI) + + runBlocking { backendWith(source, FakeEngine()).ensureModelLoaded(CONTENT_URI) } + + assertEquals(emptyList(), source.released) + } + private fun awaitUnload(engine: FakeEngine) { val deadline = System.currentTimeMillis() + 2000 while (engine.unloadCount == 0 && System.currentTimeMillis() < deadline) { @@ -485,11 +532,30 @@ class LocalLlmBackendTest { every { pluginContext.getPluginSharedPreferences(any()) } returns prefs } + /** + * Puts [references] on the list of models a later selection replaced, the way the settings pane + * does, and hands back the store so the test can read what is left on it. + */ + private fun supersede(vararg references: String): MutableMap?> { + val sets = mutableMapOf?>(KEY_SUPERSEDED to references.toMutableSet()) + val prefs = mockk(relaxed = true) + val editor = mockk(relaxed = true) + every { prefs.getStringSet(any(), any()) } answers { sets[firstArg()] ?: mutableSetOf() } + every { prefs.edit() } returns editor + every { editor.putStringSet(any(), any()) } answers { + sets[firstArg()] = secondArg() + editor + } + every { pluginContext.getPluginSharedPreferences(any()) } returns prefs + return sets + } + private fun handleFor(file: File, descriptor: Closeable? = null) = OpenModelFile(file.absolutePath, file.length(), descriptor) private companion object { const val CONTENT_URI = "content://com.android.externalstorage.documents/document/model.gguf" const val OTHER_CONTENT_URI = "content://com.android.externalstorage.documents/document/other.gguf" + const val KEY_SUPERSEDED = "local_llm_superseded_models" } } diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt index 33c30a1d..d730ce19 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt @@ -3,6 +3,7 @@ package com.itsaky.androidide.plugins.aiagentlocal.model import android.content.ContentResolver import android.content.Context import android.content.Intent +import android.content.UriPermission import android.net.Uri import io.mockk.every import io.mockk.mockk @@ -78,6 +79,37 @@ class ContentModelFileSourceTest { } } + @Test + fun givenAHeldReadGrant_whenAskedWhetherAccessPersists_thenItDoes() { + every { resolver.persistedUriPermissions } returns listOf(readGrant(CONTENT_URI)) + + assertTrue(source.hasPersistedAccess(context, CONTENT_URI)) + } + + @Test + fun givenOnlyAGrantForAnotherDocument_whenAskedWhetherAccessPersists_thenItDoesNot() { + // Derived on every visit rather than remembered from the pick, so a grant dropped since — + // a revoked one, or a full grant table — brings the "may need re-picking" caveat back. + every { resolver.persistedUriPermissions } returns listOf(readGrant(OTHER_CONTENT_URI)) + + assertFalse(source.hasPersistedAccess(context, CONTENT_URI)) + } + + @Test + fun givenAResolverThatCannotAnswer_whenAskedWhetherAccessPersists_thenNoCaveatIsInvented() { + every { resolver.persistedUriPermissions } throws SecurityException("denied") + + assertTrue(source.hasPersistedAccess(context, CONTENT_URI)) + assertEquals(1, errors.size) + } + + @Test + fun givenFilesystemPath_whenAskedWhetherAccessPersists_thenNoGrantIsNeeded() { + assertTrue(source.hasPersistedAccess(context, "/sdcard/Download/model.gguf")) + + verify(exactly = 0) { resolver.persistedUriPermissions } + } + @Test fun givenDeletedDocument_whenProbed_thenGoneWithoutReportingAnError() { every { resolver.openInputStream(uri) } throws @@ -125,9 +157,20 @@ class ContentModelFileSourceTest { @Test fun givenMissingFilesystemPath_whenProbed_thenGone() { + // Confirmed like the document branch: it is asked twice before it answers GONE. assertEquals(SourceReachability.GONE, source.readability(context, "/sdcard/Download/gone.gguf")) } + @Test + fun givenAFileThatIsBackOnTheSecondAsk_whenProbed_thenItIsReachableRatherThanGone() { + // A stat that lost a race with a mount used to refuse the pick outright on this branch. + val file = File.createTempFile("model", ".gguf").apply { delete(); deleteOnExit() } + // Lands inside the confirmation delay, so the second ask is the one that finds it. + Thread { Thread.sleep(50); file.writeBytes(ByteArray(4)) }.start() + + assertEquals(SourceReachability.REACHABLE, source.readability(context, file.absolutePath)) + } + @Test fun givenExistingFile_whenProbed_thenReachable() { val file = File.createTempFile("model", ".gguf").apply { deleteOnExit() } @@ -135,7 +178,18 @@ class ContentModelFileSourceTest { assertEquals(SourceReachability.REACHABLE, source.readability(context, file.absolutePath)) } + /** A persisted read grant on [uriString], as the resolver reports one. */ + private fun readGrant(uriString: String): UriPermission { + val granted = mockk(relaxed = true) + every { granted.toString() } returns uriString + return mockk(relaxed = true).also { + every { it.uri } returns granted + every { it.isReadPermission } returns true + } + } + private companion object { const val CONTENT_URI = "content://com.android.providers.downloads/document/42" + const val OTHER_CONTENT_URI = "content://com.android.providers.downloads/document/43" } } diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt index e5e6f0b9..ae0000fd 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt @@ -51,6 +51,9 @@ class LocalLlmSettingsViewModelTest { /** References the grant table has no room for, standing in for a full one. */ val unpersistable = mutableSetOf() + /** References whose durable grant is no longer held, as a revoked one reads later. */ + val ungranted = mutableSetOf() + /** Runs inside a readability probe, so a test can land a status while one is in flight. */ var duringReadability: ((String) -> Unit)? = null @@ -78,12 +81,16 @@ class LocalLlmSettingsViewModelTest { return true } + override fun hasPersistedAccess(context: Context, uriString: String) = + uriString !in ungranted + override fun releaseAccess(context: Context, uriString: String) { released += uriString } } private lateinit var stored: MutableMap + private lateinit var storedSets: MutableMap?> private lateinit var resolver: ContentResolver private lateinit var pluginContext: PluginContext private lateinit var modelFiles: FakeModelFiles @@ -95,14 +102,20 @@ class LocalLlmSettingsViewModelTest { every { Uri.decode(any()) } answers { firstArg() } stored = mutableMapOf() + storedSets = mutableMapOf() val prefs = mockk(relaxed = true) val editor = mockk(relaxed = true) every { prefs.getString(any(), any()) } answers { stored[firstArg()] ?: secondArg() } + every { prefs.getStringSet(any(), any()) } answers { storedSets[firstArg()] ?: mutableSetOf() } every { prefs.edit() } returns editor every { editor.putString(any(), any()) } answers { stored[firstArg()] = secondArg() editor } + every { editor.putStringSet(any(), any()) } answers { + storedSets[firstArg()] = secondArg() + editor + } resolver = mockk(relaxed = true) // The GGUF sniff fails OPEN, so a pick is accepted unless a test serves other bytes. @@ -147,18 +160,35 @@ class LocalLlmSettingsViewModelTest { } @Test - fun givenAConfiguredModel_whenAnotherIsSelected_thenOnlyTheReplacedGrantIsReleased() { - // Grants are capped per app, so the model no longer read by anything has to give its back. + fun givenAConfiguredModel_whenAnotherIsSelected_thenTheReplacedGrantIsHeldNotReleased() { + // Nothing here rejects a model — isSeekable, isReopenable and the embedding-model guard all + // run in the backend — so releasing now would strand a working model behind a pick that is + // about to be refused. The backend gives it back once a model actually loads. val viewModel = viewModel() viewModel.loadModelFromUri(MODEL_A) viewModel.loadModelFromUri(MODEL_B) assertEquals(listOf(MODEL_A, MODEL_B), modelFiles.persisted) - assertEquals(listOf(MODEL_A), modelFiles.released) + assertEquals("the replaced model must stay readable", emptyList(), modelFiles.released) + assertEquals(setOf(MODEL_A), storedSets[KEY_SUPERSEDED_MODELS]) assertEquals(MODEL_B, viewModel.getLocalModelPath()) } + @Test + fun givenAReplacedModel_whenItIsSelectedAgain_thenItIsNoLongerQueuedForRelease() { + // How the user recovers from a pick the backend refused: the model they came back to is the + // one to keep, and the refused one takes its place on the list. + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + viewModel.loadModelFromUri(MODEL_B) + + viewModel.loadModelFromUri(MODEL_A) + + assertEquals(setOf(MODEL_B), storedSets[KEY_SUPERSEDED_MODELS]) + assertEquals(emptyList(), modelFiles.released) + } + @Test fun givenAConfiguredModel_whenItIsReSelected_thenItsGrantIsNotReleased() { // "Load from saved" re-picks the configured model; releasing here would revoke the grant @@ -377,8 +407,8 @@ class LocalLlmSettingsViewModelTest { viewModel.loadModelFromUri(MODEL_A) - val refusal = viewModel.state.value?.model as ModelLoadingState.Error - assertEquals(EngineState.Error(refusal.message), viewModel.state.value?.engine) + assertTrue(viewModel.state.value?.model is ModelLoadingState.Error) + assertEquals(EngineState.Error, viewModel.state.value?.engine) } @Test @@ -420,9 +450,46 @@ class LocalLlmSettingsViewModelTest { assertEquals(MODEL_B, error.reference) } + @Test + fun givenAConfiguredModelWhoseGrantWasDropped_whenTheScreenReturns_thenTheCaveatIsShownAgain() { + // The caveat used to be published only by the selection that took the grant, so leaving the + // pane and coming back repainted a plain "Model loaded" for a model that will not survive a + // restart. It is derived from the grants actually held, on every visit. + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + assertEquals(ModelLoadingState.Loaded("a.gguf"), viewModel.state.value?.model) + + modelFiles.ungranted += MODEL_A + viewModel.refreshSavedModelAvailability() + + assertEquals( + ModelLoadingState.Loaded("a.gguf", accessPersisted = false), + viewModel.state.value?.model, + ) + assertEquals(EngineState.Initialized, viewModel.state.value?.engine) + } + + @Test + fun givenAModelWhoseGrantWasTakenLater_whenTheScreenReturns_thenTheCaveatGoesAway() { + // The mirror case: a caveat that outlived the grant table making room reads as a warning + // about a selection that is now durable. + modelFiles.unpersistable += MODEL_A + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + assertEquals( + ModelLoadingState.Loaded("a.gguf", accessPersisted = false), + viewModel.state.value?.model, + ) + + viewModel.refreshSavedModelAvailability() + + assertEquals(ModelLoadingState.Loaded("a.gguf"), viewModel.state.value?.model) + } + private companion object { const val MODEL_A = "content://com.android.externalstorage.documents/document/a.gguf" const val MODEL_B = "content://com.android.externalstorage.documents/document/b.gguf" const val KEY_MODEL_PATH = "local_llm_model_path" + const val KEY_SUPERSEDED_MODELS = "local_llm_superseded_models" } } From 54a5b51c6ba26ce149d5c6a860ba365c59f4f66c Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 9 Sep 2026 13:20:36 -0500 Subject: [PATCH 7/7] fix(ai-agent-local): address PR #84 round-6 review findings Bound close()'s unload so a wedged probe can't strand llama.shutdown(), keep superseded grants recorded until released, and make hasPersistedAccess tri-state. --- .../aiagentlocal/backend/LocalLlmBackend.kt | 63 ++++++++++----- .../aiagentlocal/model/ModelFileSource.kt | 49 +++++------- .../aiagentlocal/model/ModelSourceWatcher.kt | 20 ++++- .../aiagentlocal/model/NativeModelSource.kt | 76 ++++++++++++------- .../settings/LocalLlmSettingsViewModel.kt | 13 ++-- .../backend/LocalLlmBackendTest.kt | 23 +++++- .../model/ContentModelFileSourceTest.kt | 12 +-- .../settings/LocalLlmSettingsViewModelTest.kt | 25 +++++- 8 files changed, 184 insertions(+), 97 deletions(-) diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt index 2587b6d8..ef762321 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt @@ -45,6 +45,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull /** * Local LLM backend using llama-impl for on-device inference. @@ -81,6 +82,9 @@ class LocalLlmBackend( /** How long an answered probe stands in for the next one; see [probeAnswerIsFresh]. */ private val REACHABILITY_TRUST_NANOS = TimeUnit.SECONDS.toNanos(5) + + /** How long [close] waits for a generation to give [generationMutex] back. */ + private val UNLOAD_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(5) } private val llamaLazy = lazy { LLamaAndroid.instance() } @@ -300,18 +304,23 @@ class LocalLlmBackend( // Residency is not evidence the file still exists. The descriptor this backend holds // keeps a deleted inode alive, so an unchecked early return keeps answering from a // model the user threw away — and keeps its gigabytes mapped. Confirm, then serve. - if (probeAnswerIsFresh()) return - val reachability = modelSource.reachabilityOf(modelRef) - // Anything but GONE is served: a silent provider is no reason to pay a GB reload — - // and it answered, so the window arms on it too. A provider wedged inside the open - // only ever yields UNKNOWN, which armed nothing and cost every message a probe. - if (reachability != SourceReachability.GONE) { + if (!probeAnswerIsFresh()) { + val reachability = modelSource.reachabilityOf(modelRef) + if (reachability == SourceReachability.GONE) { + context.logger.info("Resident model is no longer reachable; unloading: $modelRef") + evictResidentModel() + throw unopenable(modelRef) + } + // Anything but GONE is served: a silent provider is no reason to pay a GB reload — + // and it answered, so the window arms on it too. A provider wedged inside the open + // only ever yields UNKNOWN, which armed nothing and cost every message a probe. lastProbeAnsweredNanos = System.nanoTime() - return } - context.logger.info("Resident model is no longer reachable; unloading: $modelRef") - evictResidentModel() - throw unopenable(modelRef) + // Here too, not only on the load below: re-picking a model to recover from a refused + // one finds it resident, so this is the only place the refused model's grant is ever + // given back — without it the list grows one entry per refusal until the next start. + releaseSupersededGrants(modelRef) + return } val opened = modelSource.open(modelRef) ?: throw unopenable(modelRef) @@ -418,8 +427,10 @@ class LocalLlmBackend( * pick this method never gets to (ADFA-5253). The settings pane writes the list; see * `LocalLlmSettingsViewModel.supersede`. * - * Cleared before the releases, so a provider that throws cannot leave the list to be retried - * on every later load. + * Written back before the releases, so a provider that throws cannot leave the list to be + * retried on every later load — and written back rather than cleared whole: [loadedRef] can be + * on the list itself (a generation that read the old path queues behind a selection of a new + * one), and dropping it there would hold its grant with nothing left recording it. * * @param loadedRef the model just adopted; never released, however it got onto the list */ @@ -428,8 +439,9 @@ class LocalLlmBackend( val prefs = LocalLlmPreferences.of(context) val superseded = LocalLlmPreferences.supersededModels(prefs) if (superseded.isEmpty()) return - LocalLlmPreferences.setSupersededModels(prefs, emptySet()) - for (reference in superseded - loadedRef) { + val release = superseded - loadedRef + LocalLlmPreferences.setSupersededModels(prefs, superseded - release) + for (reference in release) { context.logger.debug("Releasing the read grant of a replaced model: $reference") modelSource.releaseAccess(reference) } @@ -881,6 +893,8 @@ class LocalLlmBackend( * Suspending model unload — safe to call from any coroutine. Takes [generationMutex] because * [evictResidentModel] requires it: a watch notification that arrives just before [close] runs * its own eviction on [cleanupScope], and two of them would unload the native model twice. + * + * Callers on the teardown path must bound the wait; see [close]. */ private suspend fun unloadModelInternal() = generationMutex.withLock { if (modelLoaded) { @@ -900,6 +914,11 @@ class LocalLlmBackend( * is owned by this object and cancelled as soon as the work finishes, so there is no orphan * job left behind. It cannot be joined — dispose() may be on the main thread and unload() * blocks on the native run loop — so deterministic teardown is the strongest guarantee here. + * + * The unload is bounded: it waits on [generationMutex], which a generation can be holding + * inside the uncancellable binder probe in [ensureModelLoaded], and a wedged `DocumentsProvider` + * would otherwise park this coroutine for good — taking [LLamaAndroid.shutdown] with it and + * leaking the native context and the run-loop thread for the life of the IDE process. */ fun close() { scope.cancel() @@ -910,15 +929,17 @@ class LocalLlmBackend( return@launch } try { - unloadModelInternal() + if (withTimeoutOrNull(UNLOAD_TIMEOUT_MS) { unloadModelInternal() } == null) { + context.logger.warn("Timed out unloading the model during close()") + } } catch (t: Throwable) { context.logger.error("Error unloading model during close()", t) - } finally { - try { - llama.shutdown() - } catch (t: Throwable) { - context.logger.error("Error shutting down Llm-RunLoop during close()", t) - } + } + // Reached whether or not the unload did: shutdown() is what stops the run-loop thread. + try { + llama.shutdown() + } catch (t: Throwable) { + context.logger.error("Error shutting down Llm-RunLoop during close()", t) } } cleanup.invokeOnCompletion { cleanupScope.cancel() } diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt index 4ad9a7ee..37634259 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelFileSource.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.net.Uri import android.provider.OpenableColumns import java.io.File -import java.io.FileNotFoundException import java.io.InputStream /** @@ -64,10 +63,14 @@ interface ModelFileSource { * still has to warn that the model may need picking again after a restart. Not for the main * thread. * - * @return true for a filesystem path, and whenever the answer cannot be established: a caveat - * that may be wrong is worse than none + * Tri-state, because the two directions are not symmetric: inventing a caveat is worse than + * none, but erasing one that a real `persistAccess` failure raised tells the user a selection + * is fine when the next restart will break it. + * + * @return true for a filesystem path; null when the answer cannot be established, which leaves + * whatever the pane already says about the grant standing */ - fun hasPersistedAccess(context: Context, uriString: String): Boolean + fun hasPersistedAccess(context: Context, uriString: String): Boolean? /** * Give back the persistable read grant the picker took for [uriString], for a model the user @@ -110,37 +113,17 @@ class ContentModelFileSource( override fun readability(context: Context, uriString: String): SourceReachability = if (uriString.startsWith(CONTENT_SCHEME)) { // Confirmed: one FileNotFoundException covers a deletion and a dead provider alike. - confirmedGone { probeDocument(context, uriString) } + confirmedGone { + probeOpenable({ context.contentResolver.openInputStream(Uri.parse(uriString)) }) { + onError("could not reach $uriString", it) + } + } } else { // Confirmed on this branch too, so a GONE is an answer given twice for every reference // — a stat that lost a race with a mount refuses a pick over a model that is fine. - confirmedGone { probeFile(uriString) } + confirmedGone { probeFilePath(uriString) { onError("could not stat $uriString", it) } } } - private fun probeDocument(context: Context, uriString: String): SourceReachability = try { - context.contentResolver.openInputStream(Uri.parse(uriString)) - ?.use { SourceReachability.REACHABLE } - // No stream and no failure is not the provider saying the document is gone. - ?: SourceReachability.UNKNOWN - } catch (_: FileNotFoundException) { - // A deleted document, but also every provider-death path: only the re-ask decides. - SourceReachability.GONE - } catch (_: SecurityException) { - // The persisted grant is gone, which is as final as a deletion from here. - SourceReachability.GONE - } catch (e: Exception) { - onError("could not reach $uriString", e) - SourceReachability.UNKNOWN - } - - private fun probeFile(path: String): SourceReachability = try { - if (File(path).let { it.isFile && it.canRead() }) SourceReachability.REACHABLE - else SourceReachability.GONE - } catch (e: Exception) { - onError("could not stat $path", e) - SourceReachability.UNKNOWN - } - override fun fallbackDisplayName(uriOrPath: String): String = (try { Uri.decode(uriOrPath) @@ -163,14 +146,16 @@ class ContentModelFileSource( } } - override fun hasPersistedAccess(context: Context, uriString: String): Boolean { + override fun hasPersistedAccess(context: Context, uriString: String): Boolean? { if (!uriString.startsWith(CONTENT_SCHEME)) return true return try { context.contentResolver.persistedUriPermissions .any { it.isReadPermission && it.uri.toString() == uriString } } catch (e: Exception) { + // "Could not tell", never "it is fine": a resolver that will not answer must not be + // the thing that clears a caveat a failed persistAccess put there. onError("could not read the persisted read grants for $uriString", e) - true + null } } diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt index 9f5b7f58..84a5a1ba 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ModelSourceWatcher.kt @@ -68,11 +68,21 @@ class PlatformModelSourceWatcher( * Registers on the document URI *and* on [parentChildrenUriOf] it, which is where a provider * actually notifies a delete and is no descendant of the document URI. Both stay hints, never * verdicts — the parent's URI fires for every sibling too — so the callback confirms first. + * + * Debounced for that reason: the parent's URI fires for every change in the folder, and the + * folder the help pages steer users to is Downloads, where every completed download lands. + * Each notification the callback acts on costs a binder probe under the backend's generation + * lock, so a burst is collapsed into one ask [NOTIFY_DEBOUNCE_MS] after it stops. */ private fun watchDocument(uriString: String, onGone: () -> Unit): Closeable { val uri = Uri.parse(uriString) - val observer = object : ContentObserver(acquireHandler()) { - override fun onChange(selfChange: Boolean, uri: Uri?) = onGone() + val handler = acquireHandler() + val fire = Runnable { onGone() } + val observer = object : ContentObserver(handler) { + override fun onChange(selfChange: Boolean, uri: Uri?) { + handler.removeCallbacks(fire) + handler.postDelayed(fire, NOTIFY_DEBOUNCE_MS) + } } try { context.contentResolver.registerContentObserver(uri, true, observer) @@ -91,6 +101,9 @@ class PlatformModelSourceWatcher( // One unregister covers both registrations — the resolver keys them by observer. return closeOnce { try { + // Ahead of the unregister: a debounced notification still queued would otherwise + // reach onGone after the watch was closed, on a thread that is about to quit. + handler.removeCallbacks(fire) context.contentResolver.unregisterContentObserver(observer) } finally { releaseHandler() @@ -158,6 +171,9 @@ class PlatformModelSourceWatcher( private companion object { const val CONTENT_SCHEME = "content://" const val THREAD_NAME = "LocalLlm-ModelWatch" + + /** Short enough that a real delete is still acted on promptly; see [watchDocument]. */ + const val NOTIFY_DEBOUNCE_MS = 300L } } diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt index 3a2797f2..42db2f2f 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/NativeModelSource.kt @@ -109,6 +109,47 @@ internal fun confirmedGone(probe: () -> SourceReachability): SourceReachability /** Long enough for a provider killed under memory pressure to be restarted for the second ask. */ private const val GONE_CONFIRM_DELAY_MS = 250L +/** + * One ask of a document, through whatever [open] the caller reaches it with — an input stream for + * the metadata reads, a file descriptor for the native loader. Shared by both sources because which + * exception means gone is a contract they have to agree on; as two copies they drifted once already. + * + * @param open opens the document; null without throwing is the provider saying nothing + * @param onError reports an exception that is evidence neither way + */ +internal fun probeOpenable( + open: () -> Closeable?, + onError: (Throwable) -> Unit, +): SourceReachability = try { + open()?.use { SourceReachability.REACHABLE } + // No handle and no failure is not the provider saying the document is gone. + ?: SourceReachability.UNKNOWN +} catch (_: FileNotFoundException) { + // A deleted document, but also every provider-death path: only the re-ask decides. + SourceReachability.GONE +} catch (_: SecurityException) { + // The persisted grant is gone, which is as final as a deletion from here. + SourceReachability.GONE +} catch (e: Exception) { + // Anything the resolver did not convert on its way out; not evidence either way. + onError(e) + SourceReachability.UNKNOWN +} + +/** + * One ask of a filesystem path. Readability, not just existence: a file the loader cannot open is + * gone as far as it cares. Shared for the same reason as [probeOpenable]. + * + * @param onError reports an exception that is evidence neither way + */ +internal fun probeFilePath(path: String, onError: (Throwable) -> Unit): SourceReachability = try { + if (File(path).let { it.isFile && it.canRead() }) SourceReachability.REACHABLE + else SourceReachability.GONE +} catch (e: Exception) { + onError(e) + SourceReachability.UNKNOWN +} + /** * Opens the user's selected model for the native loader, in place and without copying it. * An interface so the backend's load path can be exercised without a device. @@ -183,41 +224,18 @@ class ContentNativeModelSource( else fileReachability(modelReference) /** Confirmed, because one `FileNotFoundException` cannot tell a deletion from a dead provider. */ - private fun documentReachability(uriString: String): SourceReachability = - confirmedGone { probeDocument(uriString) } - - private fun probeDocument(uriString: String): SourceReachability = try { - context.contentResolver - .openFileDescriptor(Uri.parse(uriString), "r") - ?.use { SourceReachability.REACHABLE } - // No descriptor and no failure is not the provider saying the document is gone. - ?: SourceReachability.UNKNOWN - } catch (_: FileNotFoundException) { - // A deleted document, but also every provider-death path: only the re-ask decides. - SourceReachability.GONE - } catch (_: SecurityException) { - // The persisted grant is gone, which is as final as a deletion from here. - SourceReachability.GONE - } catch (e: Exception) { - // Anything the resolver did not convert on its way out; not evidence either way. - onError("could not reach the selected model $uriString", e) - SourceReachability.UNKNOWN + private fun documentReachability(uriString: String): SourceReachability = confirmedGone { + probeOpenable({ context.contentResolver.openFileDescriptor(Uri.parse(uriString), "r") }) { + onError("could not reach the selected model $uriString", it) + } } /** * Confirmed like the document branch, so [reachabilityOf]'s contract — a GONE is only ever an * answer given twice — holds for every reference, not only the ones that go through a provider. */ - private fun fileReachability(path: String): SourceReachability = - confirmedGone { probeFile(path) } - - /** Readability, not just existence: a file the loader cannot open is gone as far as it cares. */ - private fun probeFile(path: String): SourceReachability = try { - if (File(path).let { it.isFile && it.canRead() }) SourceReachability.REACHABLE - else SourceReachability.GONE - } catch (e: Exception) { - onError("could not stat the model file $path", e) - SourceReachability.UNKNOWN + private fun fileReachability(path: String): SourceReachability = confirmedGone { + probeFilePath(path) { onError("could not stat the model file $path", it) } } override fun releaseAccess(modelReference: String) { diff --git a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt index 73090ecf..d7698481 100644 --- a/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt +++ b/ai-agent-local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModel.kt @@ -292,24 +292,27 @@ class LocalLlmSettingsViewModel( * @param savedPath the configured model, confirmed readable a moment ago * @param generation the state's generation from before the probe; a status published since is * newer than this answer, so it stands whatever it says - * @param accessPersisted whether a durable read grant for [savedPath] is still held + * @param accessPersisted whether a durable read grant for [savedPath] is still held, or null + * when the resolver would not say — which leaves the caveat already shown alone rather than + * clearing a warning that a real `persistAccess` failure raised */ private fun publishConfirmedReadable( savedPath: String, generation: Long, - accessPersisted: Boolean, + accessPersisted: Boolean?, ) { updateIfNothingPublishedSince(generation) { state -> val model = when (val shown = state.model) { // Stale: the model reads back, so it is not unreachable any more. - is ModelLoadingState.Unavailable -> modelStateFor(savedPath, accessPersisted) + is ModelLoadingState.Unavailable -> modelStateFor(savedPath, accessPersisted ?: true) // Stands, and only the caveat on it can have changed since it was published. - is ModelLoadingState.Loaded -> shown.copy(accessPersisted = accessPersisted) + is ModelLoadingState.Loaded -> + shown.copy(accessPersisted = accessPersisted ?: shown.accessPersisted) // An error about another pick is stale; one about this model is the only answer // anyone has to why it would not load, and a readable stream does not refute it. is ModelLoadingState.Error -> if (shown.reference == savedPath) return@updateIfNothingPublishedSince null - else modelStateFor(savedPath, accessPersisted) + else modelStateFor(savedPath, accessPersisted ?: true) else -> return@updateIfNothingPublishedSince null } if (model == state.model) return@updateIfNothingPublishedSince null diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt index 11ccc46b..fdd2090c 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt @@ -507,11 +507,32 @@ class LocalLlmBackendTest { // Re-picking a model that had been replaced takes it off the list; releasing it here would // revoke the grant on the model that just loaded. val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(chatModel()))) - supersede(CONTENT_URI) + val pending = supersede(CONTENT_URI) runBlocking { backendWith(source, FakeEngine()).ensureModelLoaded(CONTENT_URI) } assertEquals(emptyList(), source.released) + // Kept on the list rather than cleared with the rest: a generation that read the old path + // can load it after the pane already configured another, and the list is the only record + // of a grant to give back when that other model loads. + assertEquals(setOf(CONTENT_URI), pending[KEY_SUPERSEDED]) + } + + @Test + fun givenAModelAlreadyResident_whenItIsAskedForAgain_thenAReplacedModelsGrantIsStillGivenBack() { + // The recovery path: A is resident, an embedding model is picked and refused, and re-picking + // A finds it resident. That never reaches the load, so this is the only place the refused + // model's grant can come back — it used to be held for the life of the process. + val source = FakeModelSource(mapOf(CONTENT_URI to handleFor(chatModel()))) + val pending = supersede() + val backend = backendWith(source, FakeEngine()) + runBlocking { backend.ensureModelLoaded(CONTENT_URI) } + + pending[KEY_SUPERSEDED] = mutableSetOf(OTHER_CONTENT_URI) + runBlocking { backend.ensureModelLoaded(CONTENT_URI) } + + assertEquals(listOf(OTHER_CONTENT_URI), source.released) + assertEquals(emptySet(), pending[KEY_SUPERSEDED]) } private fun awaitUnload(engine: FakeEngine) { diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt index d730ce19..82169230 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/model/ContentModelFileSourceTest.kt @@ -83,7 +83,7 @@ class ContentModelFileSourceTest { fun givenAHeldReadGrant_whenAskedWhetherAccessPersists_thenItDoes() { every { resolver.persistedUriPermissions } returns listOf(readGrant(CONTENT_URI)) - assertTrue(source.hasPersistedAccess(context, CONTENT_URI)) + assertEquals(true, source.hasPersistedAccess(context, CONTENT_URI)) } @Test @@ -92,20 +92,22 @@ class ContentModelFileSourceTest { // a revoked one, or a full grant table — brings the "may need re-picking" caveat back. every { resolver.persistedUriPermissions } returns listOf(readGrant(OTHER_CONTENT_URI)) - assertFalse(source.hasPersistedAccess(context, CONTENT_URI)) + assertEquals(false, source.hasPersistedAccess(context, CONTENT_URI)) } @Test - fun givenAResolverThatCannotAnswer_whenAskedWhetherAccessPersists_thenNoCaveatIsInvented() { + fun givenAResolverThatCannotAnswer_whenAskedWhetherAccessPersists_thenItSaysSoRatherThanGuessing() { + // Neither direction is safe to guess: inventing a caveat is as wrong as clearing one that a + // real persistAccess failure raised, so the caller is told nothing was established. every { resolver.persistedUriPermissions } throws SecurityException("denied") - assertTrue(source.hasPersistedAccess(context, CONTENT_URI)) + assertNull(source.hasPersistedAccess(context, CONTENT_URI)) assertEquals(1, errors.size) } @Test fun givenFilesystemPath_whenAskedWhetherAccessPersists_thenNoGrantIsNeeded() { - assertTrue(source.hasPersistedAccess(context, "/sdcard/Download/model.gguf")) + assertEquals(true, source.hasPersistedAccess(context, "/sdcard/Download/model.gguf")) verify(exactly = 0) { resolver.persistedUriPermissions } } diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt index ae0000fd..749b9ddf 100644 --- a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/settings/LocalLlmSettingsViewModelTest.kt @@ -54,6 +54,9 @@ class LocalLlmSettingsViewModelTest { /** References whose durable grant is no longer held, as a revoked one reads later. */ val ungranted = mutableSetOf() + /** References the resolver will not answer the grant question for at all. */ + val grantUnknown = mutableSetOf() + /** Runs inside a readability probe, so a test can land a status while one is in flight. */ var duringReadability: ((String) -> Unit)? = null @@ -81,8 +84,8 @@ class LocalLlmSettingsViewModelTest { return true } - override fun hasPersistedAccess(context: Context, uriString: String) = - uriString !in ungranted + override fun hasPersistedAccess(context: Context, uriString: String): Boolean? = + if (uriString in grantUnknown) null else uriString !in ungranted override fun releaseAccess(context: Context, uriString: String) { released += uriString @@ -486,6 +489,24 @@ class LocalLlmSettingsViewModelTest { assertEquals(ModelLoadingState.Loaded("a.gguf"), viewModel.state.value?.model) } + @Test + fun givenAResolverThatCannotAnswer_whenTheScreenReturns_thenTheCaveatStands() { + // "Could not tell" used to come back as "the grant is held", so a resolver that would not + // answer erased a warning a real persistAccess failure had raised — and the restart it + // warned about then broke the model with nothing on screen having said so. + modelFiles.unpersistable += MODEL_A + val viewModel = viewModel() + viewModel.loadModelFromUri(MODEL_A) + + modelFiles.grantUnknown += MODEL_A + viewModel.refreshSavedModelAvailability() + + assertEquals( + ModelLoadingState.Loaded("a.gguf", accessPersisted = false), + viewModel.state.value?.model, + ) + } + private companion object { const val MODEL_A = "content://com.android.externalstorage.documents/document/a.gguf" const val MODEL_B = "content://com.android.externalstorage.documents/document/b.gguf"