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
+141 -4
View File
@@ -76,7 +76,7 @@ mod tests {
let src_file = temp_dir.path().join("src.txt");
fs::write(&src_file, "local").unwrap();
let ctx = Context::new(ContextConfig::Local);
let ctx = Context::new(ContextConfig::Local).unwrap();
let dest = ctx.ensure_available(&src_file, "/tmp").unwrap();
// Should return a path that exists and has the same content
@@ -155,10 +155,147 @@ mod tests {
assert!(mgr.list_contexts().contains(&"local".to_string()));
}
/// `set_current` on a context whose configuration carries a `parent`
/// must complete without deadlocking: building the Context resolves the
/// parent chain, which used to re-enter the config lock while
/// `set_current` still held it for writing (a guaranteed deadlock on a
/// std::sync::RwLock, same-thread write-then-read).
#[test]
fn test_set_current_parented_context_no_deadlock() {
let temp_file = NamedTempFile::new().unwrap();
let mgr = Arc::new(ContextManager::with_path(temp_file.path().to_path_buf()));
mgr.add_context("base", ContextConfig::Local).unwrap();
mgr.add_context(
"child",
ContextConfig::Schroot {
name: "testchroot".to_string(),
parent: Some("base".to_string()),
},
)
.unwrap();
// Run with a timeout so a regression fails fast instead of hanging
// the test binary forever.
let (tx, rx) = std::sync::mpsc::channel();
let worker = {
let mgr = mgr.clone();
std::thread::spawn(move || {
let result = mgr.set_current("child");
tx.send(()).expect("receiver still waiting");
result
})
};
match rx.recv_timeout(std::time::Duration::from_secs(30)) {
Ok(()) => {}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
panic!("set_current() deadlocked building a parented context");
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
panic!("set_current() thread panicked before completing");
}
}
worker.join().unwrap().unwrap();
assert_eq!(mgr.current_name(), "child");
}
/// A context referencing a missing parent must produce an error, not a
/// panic (the parent lookup used to `.expect()`).
#[test]
fn test_set_current_dangling_parent_errors() {
let temp_file = NamedTempFile::new().unwrap();
let mgr = ContextManager::with_path(temp_file.path().to_path_buf());
mgr.add_context(
"orphan",
ContextConfig::Unshare {
path: "/some/chroot".to_string(),
parent: Some("missing".to_string()),
},
)
.unwrap();
let err = mgr.set_current("orphan").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
// Nothing was committed: the current context is unchanged.
assert_eq!(mgr.current_name(), "local");
}
/// A parent cycle in a hand-edited configuration must be rejected with
/// an error instead of recursing until the stack overflows.
#[test]
fn test_set_current_parent_cycle_errors() {
let temp_file = NamedTempFile::new().unwrap();
let mgr = ContextManager::with_path(temp_file.path().to_path_buf());
mgr.add_context(
"a",
ContextConfig::Schroot {
name: "schroot-a".to_string(),
parent: Some("b".to_string()),
},
)
.unwrap();
mgr.add_context(
"b",
ContextConfig::Unshare {
path: "/chroot-b".to_string(),
parent: Some("a".to_string()),
},
)
.unwrap();
let err = mgr.set_current("a").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
/// A corrupt contexts.json must not take the manager down:
/// `load_config` falls back to the default (local-only) configuration,
/// keeps the corrupt file in place and backs it up to contexts.json.bak
/// so a later save cannot silently destroy its content.
#[test]
fn test_load_config_corrupt_file_falls_back_and_backs_up() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("contexts.json");
let garbage = "{ this is definitely not valid json";
fs::write(&config_path, garbage).unwrap();
let config = ContextManager::load_config(&config_path);
// Falls back to the default (local-only) configuration...
assert_eq!(config.context, "local");
assert!(config.contexts.contains_key("local"));
// ...preserving the corrupt file via the backup, original untouched.
let backup_path = temp_dir.path().join("contexts.json.bak");
assert_eq!(fs::read_to_string(&backup_path).unwrap(), garbage);
assert_eq!(fs::read_to_string(&config_path).unwrap(), garbage);
// A subsequent save replaces only the original, never the backup.
let mgr = ContextManager::with_path(config_path.clone());
mgr.add_context("newctx", ContextConfig::Local).unwrap();
let rewritten = fs::read_to_string(&config_path).unwrap();
serde_json::from_str::<super::manager::Config>(&rewritten).unwrap();
assert_eq!(fs::read_to_string(&backup_path).unwrap(), garbage);
}
/// A missing contexts.json yields the default configuration and writes
/// nothing (no file, no backup) until an explicit save.
#[test]
fn test_load_config_missing_file_defaults() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("contexts.json");
let config = ContextManager::load_config(&config_path);
assert_eq!(config.context, "local");
assert!(config.contexts.contains_key("local"));
assert!(!config_path.exists());
assert!(!temp_dir.path().join("contexts.json.bak").exists());
}
#[test]
fn test_context_file_ops() {
let temp_dir = tempfile::tempdir().unwrap();
let ctx = Context::new(ContextConfig::Local);
let ctx = Context::new(ContextConfig::Local).unwrap();
let file_path = temp_dir.path().join("test.txt");
let content = "hello world";
@@ -200,7 +337,7 @@ mod tests {
fn test_context_copy_preserves_dangling_symlink() {
use std::os::unix::fs::symlink;
let temp_dir = tempfile::tempdir().unwrap();
let ctx = Context::new(ContextConfig::Local);
let ctx = Context::new(ContextConfig::Local).unwrap();
let src_dir = temp_dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
@@ -236,7 +373,7 @@ mod tests {
fs::create_dir_all(src_root.join("src/.svn")).unwrap();
fs::write(src_root.join("src/hello.c"), "int main() {}").unwrap();
let ctx = Context::new(ContextConfig::Local);
let ctx = Context::new(ContextConfig::Local).unwrap();
let dest = ctx.ensure_available(&src_root, "/tmp").unwrap();
assert!(dest.join("src/hello.c").exists());