Skip to content

Commit 8e5d3f9

Browse files
committed
fix: harden configure failure handling (PR #416)
1 parent fe7c683 commit 8e5d3f9

File tree

1 file changed

+202
-20
lines changed

1 file changed

+202
-20
lines changed

crates/pet/src/jsonrpc.rs

Lines changed: 202 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -579,20 +579,28 @@ pub fn handle_configure(context: Arc<Context>, id: u32, params: Value) {
579579
);
580580
}
581581

582-
apply_configure_options(
582+
if let Err(message) = apply_configure_options(
583583
context.configuration.as_ref(),
584584
&context.locators,
585585
configure_options,
586586
workspace_directories,
587587
environment_directories,
588-
);
588+
) {
589+
error!("Configure failed: {message}");
590+
send_error(Some(id), -4, message);
591+
return;
592+
}
589593
info!("Configure completed in {:?}", now.elapsed());
590594
send_reply(id, None::<()>);
591595
});
592596
}
593597
Err(e) => {
594-
send_reply(id, None::<u128>);
595598
error!("Failed to parse configure options {:?}: {}", params, e);
599+
send_error(
600+
Some(id),
601+
-4,
602+
format!("Failed to parse configure options {params:?}: {e}"),
603+
);
596604
}
597605
}
598606
}
@@ -655,31 +663,95 @@ fn apply_configure_options(
655663
configure_options: ConfigureOptions,
656664
workspace_directories: Option<Vec<PathBuf>>,
657665
environment_directories: Option<Vec<PathBuf>>,
658-
) {
666+
) -> Result<(), String> {
659667
let mut state = configuration.write().unwrap();
660-
state.config.workspace_directories = workspace_directories;
661-
state.config.conda_executable = configure_options.conda_executable;
662-
state.config.environment_directories = environment_directories;
663-
state.config.pipenv_executable = configure_options.pipenv_executable;
664-
state.config.poetry_executable = configure_options.poetry_executable;
668+
let previous_config = state.config.clone();
669+
let mut next_config = state.config.clone();
670+
let next_generation = state.generation + 1;
671+
672+
next_config.workspace_directories = workspace_directories;
673+
next_config.conda_executable = configure_options.conda_executable;
674+
next_config.environment_directories = environment_directories;
675+
next_config.pipenv_executable = configure_options.pipenv_executable;
676+
next_config.poetry_executable = configure_options.poetry_executable;
665677
// We will not support changing the cache directories once set.
666678
// No point, supporting such a use case.
667-
if let Some(cache_directory) = configure_options.cache_directory {
668-
set_cache_directory(cache_directory.clone());
669-
state.config.cache_directory = Some(cache_directory);
679+
let cache_directory = configure_options.cache_directory;
680+
if let Some(cache_directory) = cache_directory.clone() {
681+
next_config.cache_directory = Some(cache_directory);
670682
}
671-
state.generation += 1;
683+
684+
trace!(
685+
"Configuring locators with generation {}: {:?}",
686+
next_generation,
687+
next_config
688+
);
689+
690+
if let Err(panic_payload) = panic::catch_unwind(AssertUnwindSafe(|| {
691+
configure_locators(locators, &next_config);
692+
})) {
693+
let panic_message = panic_payload_message(panic_payload.as_ref());
694+
error!(
695+
"Locator configuration panicked for generation {}: {}",
696+
next_generation, panic_message
697+
);
698+
rollback_locator_config(locators, &previous_config, next_generation);
699+
return Err(format!(
700+
"Locator configuration failed for generation {next_generation}: {panic_message}"
701+
));
702+
}
703+
704+
if let Some(cache_directory) = cache_directory {
705+
if let Err(panic_payload) = panic::catch_unwind(AssertUnwindSafe(|| {
706+
set_cache_directory(cache_directory);
707+
})) {
708+
let panic_message = panic_payload_message(panic_payload.as_ref());
709+
error!(
710+
"Cache directory configuration panicked for generation {}: {}",
711+
next_generation, panic_message
712+
);
713+
rollback_locator_config(locators, &previous_config, next_generation);
714+
return Err(format!(
715+
"Cache directory configuration failed for generation {next_generation}: {panic_message}"
716+
));
717+
}
718+
}
719+
state.config = next_config;
720+
state.generation = next_generation;
672721
// Reset missing-env reporting so that the next refresh after
673722
// reconfiguration can trigger it again (Fixes #395). Done inside the write
674723
// lock to avoid a TOCTOU window with concurrent refresh threads reading the
675724
// generation.
676725
MISSING_ENVS_REPORTING_STATE.store(MISSING_ENVS_AVAILABLE, Ordering::Release);
677-
trace!(
678-
"Configuring locators with generation {}: {:?}",
679-
state.generation,
680-
state.config
681-
);
682-
configure_locators(locators, &state.config);
726+
727+
Ok(())
728+
}
729+
730+
fn rollback_locator_config(
731+
locators: &Arc<Vec<Arc<dyn Locator>>>,
732+
previous_config: &Configuration,
733+
failed_generation: u64,
734+
) {
735+
if let Err(panic_payload) = panic::catch_unwind(AssertUnwindSafe(|| {
736+
configure_locators(locators, previous_config);
737+
})) {
738+
error!(
739+
"Rollback after failed locator configuration for generation {} also panicked: {}",
740+
failed_generation,
741+
panic_payload_message(panic_payload.as_ref())
742+
);
743+
}
744+
}
745+
746+
fn panic_payload_message(panic_payload: &(dyn std::any::Any + Send)) -> String {
747+
if let Some(message) = panic_payload.downcast_ref::<&str>() {
748+
return (*message).to_string();
749+
}
750+
if let Some(message) = panic_payload.downcast_ref::<String>() {
751+
return message.clone();
752+
}
753+
754+
"unknown panic payload".to_string()
683755
}
684756

685757
fn configure_locators(locators: &Arc<Vec<Arc<dyn Locator>>>, config: &Configuration) {
@@ -1267,6 +1339,12 @@ mod tests {
12671339
configured_workspace_directories: Mutex<Option<Vec<PathBuf>>>,
12681340
}
12691341

1342+
struct PanicConfigureLocator;
1343+
1344+
struct RecordingConfigureLocator {
1345+
configured_workspace_directories: Mutex<Option<Vec<PathBuf>>>,
1346+
}
1347+
12701348
impl Reporter for RecordingReporter {
12711349
fn report_manager(&self, manager: &EnvManager) {
12721350
self.managers.lock().unwrap().push(manager.clone());
@@ -1321,6 +1399,47 @@ mod tests {
13211399
fn find(&self, _reporter: &dyn Reporter) {}
13221400
}
13231401

1402+
impl Locator for PanicConfigureLocator {
1403+
fn get_kind(&self) -> LocatorKind {
1404+
LocatorKind::Venv
1405+
}
1406+
1407+
fn configure(&self, _config: &Configuration) {
1408+
panic!("configure boom");
1409+
}
1410+
1411+
fn supported_categories(&self) -> Vec<PythonEnvironmentKind> {
1412+
vec![PythonEnvironmentKind::Venv]
1413+
}
1414+
1415+
fn try_from(&self, _env: &pet_core::env::PythonEnv) -> Option<PythonEnvironment> {
1416+
None
1417+
}
1418+
1419+
fn find(&self, _reporter: &dyn Reporter) {}
1420+
}
1421+
1422+
impl Locator for RecordingConfigureLocator {
1423+
fn get_kind(&self) -> LocatorKind {
1424+
LocatorKind::Venv
1425+
}
1426+
1427+
fn configure(&self, config: &Configuration) {
1428+
*self.configured_workspace_directories.lock().unwrap() =
1429+
config.workspace_directories.clone();
1430+
}
1431+
1432+
fn supported_categories(&self) -> Vec<PythonEnvironmentKind> {
1433+
vec![PythonEnvironmentKind::Venv]
1434+
}
1435+
1436+
fn try_from(&self, _env: &pet_core::env::PythonEnv) -> Option<PythonEnvironment> {
1437+
None
1438+
}
1439+
1440+
fn find(&self, _reporter: &dyn Reporter) {}
1441+
}
1442+
13241443
fn make_refresh_key(generation: u64, options: RefreshOptions) -> RefreshKey {
13251444
RefreshKey::new(&options, generation)
13261445
}
@@ -1480,7 +1599,8 @@ mod tests {
14801599
},
14811600
Some(workspace_directories),
14821601
None,
1483-
);
1602+
)
1603+
.unwrap();
14841604
done_tx.send(()).unwrap();
14851605
})
14861606
};
@@ -1504,6 +1624,68 @@ mod tests {
15041624
);
15051625
}
15061626

1627+
#[test]
1628+
fn test_configure_panic_does_not_publish_state_or_poison_lock() {
1629+
let configuration = RwLock::new(ConfigurationState::default());
1630+
let locators = Arc::new(vec![Arc::new(PanicConfigureLocator) as Arc<dyn Locator>]);
1631+
let workspace_directories = vec![PathBuf::from("/workspace")];
1632+
1633+
let result = apply_configure_options(
1634+
&configuration,
1635+
&locators,
1636+
ConfigureOptions {
1637+
workspace_directories: None,
1638+
conda_executable: Some(PathBuf::from("/configured/conda")),
1639+
pipenv_executable: None,
1640+
poetry_executable: None,
1641+
environment_directories: None,
1642+
cache_directory: None,
1643+
},
1644+
Some(workspace_directories),
1645+
None,
1646+
);
1647+
1648+
assert!(result.unwrap_err().contains("configure boom"));
1649+
let state = configuration.read().unwrap();
1650+
assert_eq!(state.generation, 0);
1651+
assert!(state.config.workspace_directories.is_none());
1652+
assert!(state.config.conda_executable.is_none());
1653+
}
1654+
1655+
#[test]
1656+
fn test_configure_panic_rolls_back_previously_configured_locators() {
1657+
let configuration = RwLock::new(ConfigurationState::default());
1658+
let recording_locator = Arc::new(RecordingConfigureLocator {
1659+
configured_workspace_directories: Mutex::new(None),
1660+
});
1661+
let locators = Arc::new(vec![
1662+
recording_locator.clone() as Arc<dyn Locator>,
1663+
Arc::new(PanicConfigureLocator) as Arc<dyn Locator>,
1664+
]);
1665+
1666+
let result = apply_configure_options(
1667+
&configuration,
1668+
&locators,
1669+
ConfigureOptions {
1670+
workspace_directories: None,
1671+
conda_executable: None,
1672+
pipenv_executable: None,
1673+
poetry_executable: None,
1674+
environment_directories: None,
1675+
cache_directory: None,
1676+
},
1677+
Some(vec![PathBuf::from("/workspace")]),
1678+
None,
1679+
);
1680+
1681+
assert!(result.is_err());
1682+
assert!(recording_locator
1683+
.configured_workspace_directories
1684+
.lock()
1685+
.unwrap()
1686+
.is_none());
1687+
}
1688+
15071689
#[test]
15081690
fn test_stale_generation_does_not_begin_missing_env_reporting() {
15091691
let _guard = MISSING_ENVS_TEST_LOCK.lock().unwrap();

0 commit comments

Comments
 (0)