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:
+141
-4
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user