context: fix deadlock and panics when resolving parented contexts
set_current held the config RwLock for writing across make_context, which for a context with a parent re-entered the same lock through Context::new's global-manager lookup, deadlocking 'pkh context use'. Context building is now lock-free by construction: make_context resolves parent chains against a snapshot map (also rejecting parent cycles), and neither set_current nor remove_context holds a guard while building a Context. A corrupt contexts.json no longer aborts every command at manager init: load falls back to the default local-only config, backs the corrupt file up to contexts.json.bak so a later save cannot silently destroy it, and a dangling current/parent context falls back to local with an error log instead of panicking.
This commit is contained in:
+142
-40
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
@@ -48,26 +48,88 @@ impl ContextManager {
|
||||
fs::create_dir_all(config_dir)?;
|
||||
let config_path = config_dir.join("contexts.json");
|
||||
|
||||
let config = if config_path.exists() {
|
||||
// Load existing configuration file
|
||||
let content = fs::read_to_string(&config_path)?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?
|
||||
} else {
|
||||
// Create a new configuration file
|
||||
Config::default()
|
||||
let mut config = Self::load_config(&config_path);
|
||||
|
||||
// Build the initial Context against the freshly loaded map, before
|
||||
// the manager itself exists: resolution must not go through the
|
||||
// global MANAGER here, since a parented current context would
|
||||
// re-enter its own LazyLock initialization.
|
||||
let initial = match Self::make_context(&config.context, &config.contexts) {
|
||||
Ok(context) => context,
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Cannot build current context '{}' from {}: {e}; falling back to 'local'",
|
||||
config.context,
|
||||
config_path.display()
|
||||
);
|
||||
config.context = "local".to_string();
|
||||
Self::make_context("local", &config.contexts).unwrap_or_else(|e| {
|
||||
// Only possible in a hand-edited configuration without
|
||||
// any 'local' entry; a plain Local context has no parent
|
||||
// and cannot fail to build.
|
||||
log::error!(
|
||||
"'local' context missing from {}: {e}",
|
||||
config_path.display()
|
||||
);
|
||||
Context::new(ContextConfig::Local).expect("Local context cannot fail")
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
context: RwLock::new(Arc::new(Self::make_context(
|
||||
config.context.as_str(),
|
||||
&config,
|
||||
))),
|
||||
context: RwLock::new(Arc::new(initial)),
|
||||
config_path,
|
||||
config: RwLock::new(config),
|
||||
})
|
||||
}
|
||||
|
||||
/// Load the configuration stored at `path`.
|
||||
///
|
||||
/// A missing file yields [`Config::default`]. A file that cannot be read
|
||||
/// or parsed must not take the whole program down: this falls back to
|
||||
/// the default (local-only) configuration and logs an error. Because a
|
||||
/// later [`ContextManager::save`] would otherwise silently overwrite the
|
||||
/// corrupt file and destroy its content, the corrupt file is first
|
||||
/// backed up to `<path>.bak` (best effort).
|
||||
pub(crate) fn load_config(path: &Path) -> Config {
|
||||
if !path.exists() {
|
||||
return Config::default();
|
||||
}
|
||||
let loaded = fs::read_to_string(path).and_then(|content| {
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
});
|
||||
match loaded {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Context configuration {} is corrupt ({e}); using the default (local-only) configuration",
|
||||
path.display()
|
||||
);
|
||||
Self::backup_corrupt_file(path);
|
||||
Config::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Back up a corrupt configuration file (best effort) so a later save
|
||||
/// cannot silently destroy its content.
|
||||
fn backup_corrupt_file(path: &Path) {
|
||||
let mut os = path.as_os_str().to_os_string();
|
||||
os.push(".bak");
|
||||
let backup_path = PathBuf::from(os);
|
||||
match fs::copy(path, &backup_path) {
|
||||
Ok(_) => log::warn!(
|
||||
"Corrupt context configuration backed up to {}",
|
||||
backup_path.display()
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"Could not back up corrupt context configuration to {}: {e}",
|
||||
backup_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtain current ContextManager configuration
|
||||
pub fn get_config(&self) -> std::sync::RwLockReadGuard<'_, Config> {
|
||||
self.config.read().unwrap()
|
||||
@@ -77,7 +139,12 @@ impl ContextManager {
|
||||
pub fn with_path(path: PathBuf) -> Self {
|
||||
let config = Config::default();
|
||||
Self {
|
||||
context: RwLock::new(Arc::new(Self::make_context("local", &config))),
|
||||
// 'local' is always present in Config::default and has no
|
||||
// parent, so this cannot fail.
|
||||
context: RwLock::new(Arc::new(
|
||||
Self::make_context("local", &config.contexts)
|
||||
.expect("default 'local' context cannot fail"),
|
||||
)),
|
||||
config_path: path,
|
||||
config: RwLock::new(config),
|
||||
}
|
||||
@@ -92,13 +159,22 @@ impl ContextManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_context(name: &str, config: &Config) -> Context {
|
||||
let context_config = config
|
||||
.contexts
|
||||
.get(name)
|
||||
.cloned()
|
||||
.expect("Context not found in config");
|
||||
Context::new(context_config)
|
||||
/// Build a [`Context`] for `name` from `contexts`.
|
||||
///
|
||||
/// Lock-free by construction: parent references are resolved against
|
||||
/// `contexts` itself (see [`Context::with_lookup`]), never against the
|
||||
/// manager's configuration lock. This is what keeps [`ContextManager::new`]
|
||||
/// working before the global [`MANAGER`] exists, and what allows callers
|
||||
/// to build contexts without risking a re-entrant read on a lock they
|
||||
/// already hold for writing.
|
||||
fn make_context(name: &str, contexts: &HashMap<String, ContextConfig>) -> io::Result<Context> {
|
||||
let context_config = contexts.get(name).cloned().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("Context '{name}' not found in configuration"),
|
||||
)
|
||||
})?;
|
||||
Context::with_lookup(context_config, &|parent| contexts.get(parent).cloned())
|
||||
}
|
||||
|
||||
/// List contexts from configuration
|
||||
@@ -124,41 +200,67 @@ impl ContextManager {
|
||||
|
||||
/// Remove context from configuration
|
||||
pub fn remove_context(&self, name: &str) -> io::Result<()> {
|
||||
let mut config = self.config.write().unwrap();
|
||||
if name == "local" {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Cannot remove local context",
|
||||
));
|
||||
}
|
||||
if config.contexts.remove(name).is_some() {
|
||||
// If we are removing the current context, fallback to local
|
||||
if name == config.context {
|
||||
config.context = "local".to_string();
|
||||
self.set_current_ephemeral(Self::make_context("local", &config));
|
||||
// Mutate under the write lock, snapshotting the remaining map when
|
||||
// the removed context was current; the fallback Context is built
|
||||
// after the lock is released (same discipline as `set_current`).
|
||||
let fallback_contexts = {
|
||||
let mut config = self.config.write().unwrap();
|
||||
if config.contexts.remove(name).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if name == config.context {
|
||||
// If we are removing the current context, fallback to local
|
||||
config.context = "local".to_string();
|
||||
Some(config.contexts.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
drop(config); // Drop write lock before saving
|
||||
self.save()?;
|
||||
if let Some(contexts) = fallback_contexts {
|
||||
self.set_current_ephemeral(Self::make_context("local", &contexts)?);
|
||||
}
|
||||
self.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set current context from name (modifying configuration)
|
||||
pub fn set_current(&self, name: &str) -> io::Result<()> {
|
||||
let mut config = self.config.write().unwrap();
|
||||
if config.contexts.contains_key(name) {
|
||||
config.context = name.to_string();
|
||||
self.set_current_ephemeral(Self::make_context(name, &config));
|
||||
drop(config); // Drop write lock before saving
|
||||
self.save()?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
// Snapshot what `make_context` needs and release the lock before
|
||||
// building the Context. Building resolves parent contexts, and this
|
||||
// code path used to hold the config write guard while re-entering
|
||||
// the same lock for a read — a guaranteed deadlock on a
|
||||
// std::sync::RwLock (same-thread write-then-read).
|
||||
let contexts = self.config.read().unwrap().contexts.clone();
|
||||
if !contexts.contains_key(name) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("Context '{}' not found", name),
|
||||
))
|
||||
format!("Context '{name}' not found"),
|
||||
));
|
||||
}
|
||||
let context = Self::make_context(name, &contexts)?;
|
||||
|
||||
// Re-take the write lock briefly to commit. The name may have been
|
||||
// removed between snapshot and commit; report the same NotFound
|
||||
// error instead of persisting a dangling current-context reference.
|
||||
let mut config = self.config.write().unwrap();
|
||||
if !config.contexts.contains_key(name) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("Context '{name}' not found"),
|
||||
));
|
||||
}
|
||||
config.context = name.to_string();
|
||||
drop(config); // Drop write lock before saving
|
||||
self.set_current_ephemeral(context);
|
||||
self.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set current context, without modifying configuration
|
||||
|
||||
Reference in New Issue
Block a user