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:
2026-09-16 02:27:11 +02:00
parent 592e98c1e9
commit 6a5c5a7106
7 changed files with 360 additions and 68 deletions
+142 -40
View File
@@ -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