diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 8ba27e6..4a89047 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -169,7 +169,7 @@ impl PluginHooks { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] struct PluginsRegistry { #[serde(default)] pub(super) plugins: Vec, @@ -387,23 +387,75 @@ fn registry_path() -> PathBuf { } fn load_registry() -> Result { - let path = registry_path(); + load_registry_from(®istry_path()) +} + +fn load_registry_from(path: &Path) -> Result { if !path.exists() { return Ok(PluginsRegistry { plugins: Vec::new(), }); } - let content = fs::read_to_string(&path).context("reading plugins.toml")?; + match read_and_parse_registry(path) { + Ok(registry) => Ok(registry), + Err(first_error) => { + // A registration by an older fledge (pre-atomic-write) may be + // mid-rewrite; one short retry rides out the window instead of + // resolving every plugin subcommand to "unrecognized". + std::thread::sleep(std::time::Duration::from_millis(50)); + read_and_parse_registry(path).map_err(|_| { + eprintln!( + "warning: plugins.toml at {} is unreadable or corrupt — plugin \ + subcommands will not resolve this invocation: {first_error:#}", + path.display() + ); + first_error + }) + } + } +} + +fn read_and_parse_registry(path: &Path) -> Result { + let content = fs::read_to_string(path).context("reading plugins.toml")?; toml::from_str(&content).context("parsing plugins.toml") } fn save_registry(registry: &PluginsRegistry) -> Result<()> { - let path = registry_path(); + save_registry_to(®istry_path(), registry) +} + +fn save_registry_to(path: &Path, registry: &PluginsRegistry) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let content = toml::to_string_pretty(registry).context("serializing plugins.toml")?; - fs::write(&path, content).context("writing plugins.toml") + // Write-then-rename so concurrent readers never observe a truncated + // file. `fs::write` truncates in place, and any fledge invocation + // that reads the registry during the rewrite parses partial TOML — + // dispatch then reports a perfectly registered plugin command as + // "unrecognized subcommand". Same-directory rename is atomic on + // POSIX, so readers see either the old registry or the new one. + let tmp = path.with_extension(format!("toml.tmp.{}", std::process::id())); + if let Err(error) = fs::write(&tmp, content) { + let _ = fs::remove_file(&tmp); + return Err(error).context("writing plugins.toml temp file"); + } + // Windows refuses the rename with a sharing violation while another + // process has the destination open for reading; retry briefly. On + // POSIX the first attempt always wins. + let mut last_error = None; + for _ in 0..20 { + match fs::rename(&tmp, path) { + Ok(()) => return Ok(()), + Err(error) => { + last_error = Some(error); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + } + let _ = fs::remove_file(&tmp); + Err(last_error.expect("rename attempted at least once")) + .context("atomically replacing plugins.toml") } // ─── Source helpers ────────────────────────────────────────────────────────── diff --git a/src/plugin/tests.rs b/src/plugin/tests.rs index 91c2271..ca58a91 100644 --- a/src/plugin/tests.rs +++ b/src/plugin/tests.rs @@ -1445,3 +1445,58 @@ fn team_tier_allows_exec() { }; assert!(check_tier_capabilities(TrustTier::Team, &caps).is_ok()); } + +#[test] +fn registry_save_is_atomic_under_concurrent_readers() { + // Regression for the "unrecognized subcommand" race: `fs::write` + // truncates plugins.toml in place, so a reader racing a re-registration + // parsed partial TOML and dispatch dropped every plugin command. With + // write-then-rename, readers must see a complete registry every time. + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("plugins.toml"); + let entry = super::PluginEntry { + name: "fledge-plugin-augur".to_string(), + source: "CorvidLabs/fledge-plugin-augur".to_string(), + version: "0.2.0".to_string(), + installed: "2026-06-12".to_string(), + commands: vec!["augur".to_string(); 64], + pinned_ref: None, + capabilities: None, + runtime: None, + }; + let registry = super::PluginsRegistry { + plugins: vec![entry; 24], + }; + super::save_registry_to(&path, ®istry).unwrap(); + + let writer_path = path.clone(); + let writer_registry = registry.clone(); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let writer_stop = stop.clone(); + let writer = std::thread::spawn(move || { + while !writer_stop.load(std::sync::atomic::Ordering::Relaxed) { + super::save_registry_to(&writer_path, &writer_registry).unwrap(); + } + }); + + for _ in 0..400 { + let loaded = super::load_registry_from(&path) + .expect("reader must never observe a truncated registry"); + assert_eq!(loaded.plugins.len(), 24); + assert_eq!(loaded.plugins[0].commands.len(), 64); + } + stop.store(true, std::sync::atomic::Ordering::Relaxed); + writer.join().unwrap(); +} + +#[test] +fn registry_load_reports_corruption_instead_of_empty() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("plugins.toml"); + std::fs::write(&path, "[[plugins]]\nname = \"trunc").unwrap(); + let result = super::load_registry_from(&path); + assert!( + result.is_err(), + "corrupt registry must surface an error, not parse as empty" + ); +}