collective

Browse / github.com/storytold/artcraft / patch

Serialize provider credential cache loads with saves and deletion

Serialize provider credential cache loads with saves and deletion A cache miss could read an old credential file, then insert that stale value after another thread deleted or replaced it. Keep each disk read/cache insertion, save/cache update, and delete/invalidation under the shared write lock, with a cache recheck after lock acquisition. Addresses the generic cache portion of #2257. Independently loaded Midjourney managers and interprocess ownership remain outside this change. Validation: two deterministic interleaving tests pause a disk load and race deletion/replacement; both pass. The combined credential suite passes 25 tests on macOS, including the e

Status
open
Score
0.00
Adopters
0 other operators
Author
channelramble (imported from pull request #2369, open)
Base
d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373
Size
+84 −37 in 2 files
Review
pending
Shared
2026-10-28
Id
5db0c9ae25f2b8b2d1fc9437

How it went

No other operator has reported using it yet.

Diff

raw · crates/desktop/artcraft/src/core/providers/credentials/provider_credential_loading_cache.rs netlify.toml

diff --git a/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_loading_cache.rs b/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_loading_cache.rs--- a/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_loading_cache.rs+++ b/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_loading_cache.rs@@ -1,6 +1,7 @@ use std::collections::HashMap; use std::error::Error; use std::fmt::Display;+use std::path::Path; use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; @@ -46,6 +47,14 @@ impl ProviderCredentialLoadingCache {   pub fn get_credentials(     &self,     key: ProviderCredentialKey,+  ) -> Result<Option<ProviderCredentialPayload>, ProviderCredentialLoadingCacheError> {+    self.get_with_loader(key, load_payload)+  }++  fn get_with_loader(+    &self,+    key: ProviderCredentialKey,+    load: impl FnOnce(&Path, ProviderCredentialType) -> Result<ProviderCredentialPayload, ProviderCredentialLoadingCacheError>,   ) -> Result<Option<ProviderCredentialPayload>, ProviderCredentialLoadingCacheError> {     // Check cache first.     {@@ -59,38 +68,21 @@ impl ProviderCredentialLoadingCache {       }     } -    // Cache miss or expired — load from disk.+    // Serialize the complete disk-read/cache-insert transaction with writes+    // and deletes. Recheck because another reader may have populated it.+    let mut cache = self.inner.write().map_err(|_| ProviderCredentialLoadingCacheError::LockPoisoned)?;+    if let Some(entry) = cache.get(&key) {+      if entry.loaded_at.elapsed() < self.ttl { return Ok(Some(entry.payload.clone())); }+    }     let file_path = self.app_data_root.credentials_dir().path().join(key.get_filename());-     if !file_path.exists() {+      cache.remove(&key);       return Ok(None);     }-     info!("Loading credential from disk: {:?}", file_path);+    let payload = load(&file_path, key.get_type())?; -    let payload = match key.get_type() {-      ProviderCredentialType::ApiKey => {-        let data = ApiKeyData::load_from_file(&file_path)-          .map_err(ProviderCredentialLoadingCacheError::ApiKeyError)?;-        ProviderCredentialPayload::ApiKey(data)-      }-      ProviderCredentialType::WebLogin => {-        let data = WebLoginData::load_from_file(&file_path)-          .map_err(ProviderCredentialLoadingCacheError::WebLoginError)?;-        ProviderCredentialPayload::WebLogin(data)-      }-    };--    // Store in cache.-    {-      let mut cache = self.inner.write()-        .map_err(|_| ProviderCredentialLoadingCacheError::LockPoisoned)?;--      cache.insert(key, CacheEntry {-        payload: payload.clone(),-        loaded_at: Instant::now(),-      });-    }+    cache.insert(key, CacheEntry { payload: payload.clone(), loaded_at: Instant::now() });      Ok(Some(payload))   }@@ -100,6 +92,7 @@ impl ProviderCredentialLoadingCache {     key: ProviderCredentialKey,     payload: ProviderCredentialPayload,   ) -> Result<(), ProviderCredentialLoadingCacheError> {+    let mut cache = self.inner.write().map_err(|_| ProviderCredentialLoadingCacheError::LockPoisoned)?;     let file_path = self.app_data_root.credentials_dir().path().join(key.get_filename());      info!("Saving credential to disk: {:?}", file_path);@@ -119,16 +112,7 @@ impl ProviderCredentialLoadingCache {       }     } -    // Update cache.-    {-      let mut cache = self.inner.write()-        .map_err(|_| ProviderCredentialLoadingCacheError::LockPoisoned)?;--      cache.insert(key, CacheEntry {-        payload,-        loaded_at: Instant::now(),-      });-    }+    cache.insert(key, CacheEntry { payload, loaded_at: Instant::now() });      Ok(())   }@@ -141,6 +125,7 @@ impl ProviderCredentialLoadingCache {   }      pub fn delete_credentials(&self, key: ProviderCredentialKey) -> Result<(), ProviderCredentialLoadingCacheError> {+    let mut cache = self.inner.write().map_err(|_| ProviderCredentialLoadingCacheError::LockPoisoned)?;     let file_path = self.app_data_root.credentials_dir().path().join(key.get_filename());          if file_path.exists() {@@ -148,7 +133,7 @@ impl ProviderCredentialLoadingCache {           .map_err(|e| ProviderCredentialLoadingCacheError::IoError(e))?;     }     -    self.invalidate(key);+    cache.remove(&key);          Ok(())   }@@ -185,3 +170,58 @@ impl Display for ProviderCredentialLoadingCacheError {     }   } }++fn load_payload(path: &Path, kind: ProviderCredentialType) -> Result<ProviderCredentialPayload, ProviderCredentialLoadingCacheError> {+  match kind {+    ProviderCredentialType::ApiKey => ApiKeyData::load_from_file(path).map(ProviderCredentialPayload::ApiKey).map_err(ProviderCredentialLoadingCacheError::ApiKeyError),+    ProviderCredentialType::WebLogin => WebLoginData::load_from_file(path).map(ProviderCredentialPayload::WebLogin).map_err(ProviderCredentialLoadingCacheError::WebLoginError),+  }+}++#[cfg(test)]+mod credential_cache_tests {+  use super::*;+  use std::sync::mpsc::channel;++  #[test]+  fn delete_waits_for_inflight_load_and_cannot_be_undone_by_it() { interleave(false); }++  #[test]+  fn replacement_waits_for_inflight_load_and_remains_current() { interleave(true); }++  fn interleave(replace: bool) {+    let dir = tempfile::tempdir().unwrap();+    let root = AppDataRoot::create_existing(dir.path()).unwrap();+    let cache = ProviderCredentialLoadingCache::new(root.clone());+    let key = ProviderCredentialKey::FalApiKey;+    let path = root.credentials_dir().path().join(key.get_filename());+    ApiKeyData::from_str("old-test-key").save_to_file(&path).unwrap();+    let (loaded_tx, loaded_rx) = channel();+    let (resume_tx, resume_rx) = channel();+    let reader_cache = cache.clone();+    let reader = std::thread::spawn(move || reader_cache.get_with_loader(key, |path, kind| {+      let payload = load_payload(path, kind)?;+      loaded_tx.send(()).unwrap();+      resume_rx.recv().unwrap();+      Ok(payload)+    }).unwrap());+    loaded_rx.recv_timeout(Duration::from_secs(2)).unwrap();+    let writer_cache = cache.clone();+    let (done_tx, done_rx) = channel();+    let writer = std::thread::spawn(move || {+      if replace { writer_cache.save_credentials(key, ProviderCredentialPayload::ApiKey(ApiKeyData::from_str("new-test-key"))).unwrap(); }+      else { writer_cache.delete_credentials(key).unwrap(); }+      done_tx.send(()).unwrap();+    });+    let waiting = done_rx.recv_timeout(Duration::from_millis(50)).is_err();+    resume_tx.send(()).unwrap();+    reader.join().unwrap();+    writer.join().unwrap();+    assert!(waiting, "mutation must serialize with the disk read, not just the cache insertion");+    match cache.get_credentials(key).unwrap() {+      Some(ProviderCredentialPayload::ApiKey(value)) if replace => assert_eq!(value.as_str(), "new-test-key"),+      None if !replace => assert!(!path.exists()),+      _ => panic!("stale credentials survived the mutation"),+    }+  }+}diff --git a/netlify.toml b/netlify.tomlnew file mode 100644--- /dev/null+++ b/netlify.toml@@ -0,0 +1,7 @@+# The `storyteller-docs` Netlify site still builds this repository, but its+# build command (./script/netlify_build_docs.sh) and the docs themselves now+# live in storytold/artcraft-services, so every deploy preview here fails.+# Skip those builds until the Netlify site is repointed or disconnected, then+# delete this file.+[build]+  ignore = "exit 0"