Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 57 additions & 5 deletions src/plugin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ impl PluginHooks {
}
}

#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, Clone)]
struct PluginsRegistry {
#[serde(default)]
pub(super) plugins: Vec<PluginEntry>,
Expand Down Expand Up @@ -387,23 +387,75 @@ fn registry_path() -> PathBuf {
}

fn load_registry() -> Result<PluginsRegistry> {
let path = registry_path();
load_registry_from(&registry_path())
}

fn load_registry_from(path: &Path) -> Result<PluginsRegistry> {
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<PluginsRegistry> {
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(&registry_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 ──────────────────────────────────────────────────────────
Expand Down
55 changes: 55 additions & 0 deletions src/plugin/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &registry).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"
);
}
Loading