Startup hardening: safe mode, a software-renderer retry, and bad settings files set aside (#1138)

Startup no longer stops at a bad settings file or a missing GPU: a settings file that fails to load is set aside, a failed GPU start retries with the software renderer, and a safe mode starts with defaults. Adds a troubleshooting page to the guide.
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-14 13:40:04 -04:00
1 parent 8f48544021
commit 2c754c430b
10 files changed
+445 -13

No files matched your search

+19 -1
View File
@@ -184,6 +184,19 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio
None
}
/// An autosave that failed to load goes back in the recovery list, so it can be discarded (or
/// left for later) instead of coming back unasked.
fn reoffer(app: &mut SolveApp, id: &str, dir: Option<&str>) {
let dir = dir.map(std::path::PathBuf::from).or_else(|| app.autosave.as_ref().map(|a| a.dir.clone()));
let Some(dir) = dir else { return };
if app.recoverable.iter().any(|e| e.id == id) {
return;
}
if let Some(e) = solidcraft_engine::recovery::orphans(&dir).into_iter().find(|e| e.id == id) {
app.recoverable.insert(0, e);
}
}
/// A recovered design is ours now: autosave it under this app's entry before the old one goes.
fn recovered(app: &mut SolveApp, request: &Request) {
let Kind::Recover { id, dir } = &request.kind else { return };
@@ -448,7 +461,12 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
}
Err(e) => {
request.answer(Err(e.clone()));
app.set_status(e, true);
if let Kind::Recover { id, dir } = &request.kind {
reoffer(app, id, dir.as_deref());
app.set_status(format!("could not recover {}: {e}. Discard it or choose Later", request.path), true);
} else {
app.set_status(e, true);
}
}
}
}
+4 -3
View File
@@ -757,9 +757,10 @@ fn tessellation(adapter: &wgpu::Adapter, device: &wgpu::Device, queue: &wgpu::Qu
}
/// Create the pipelines. Call once with the app's render state and its depth/stencil/MSAA
/// settings. Without a stencil the section cap does not hide the inside of cut bodies.
pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) -> GpuTarget {
let tess = tessellation(&rs.adapter, &rs.device, &rs.queue, solidcraft_gpu::SHADER);
/// settings. Without a stencil the section cap does not hide the inside of cut bodies. Without
/// `gpu_meshing` (safe mode) bodies are meshed on the CPU.
pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32, gpu_meshing: bool) -> GpuTarget {
let tess = if gpu_meshing { tessellation(&rs.adapter, &rs.device, &rs.queue, solidcraft_gpu::SHADER) } else { None };
let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, stencil_bits), samples, tess: tess.is_some() };
let res = Resources::new(&rs.device, t, tess);
rs.renderer.write().callback_resources.insert(res);
+6 -2
View File
@@ -290,6 +290,9 @@ pub struct SolveApp {
pub custom_titlebar: bool,
/// macOS: the traffic lights sit over the application bar's left end.
pub integrated_titlebar: bool,
/// Build bodies' display meshes on the CPU even where the GPU could (safe mode). Read by
/// [`SolveApp::set_wgpu`].
pub cpu_meshing: bool,
pub frame_ms: f64,
pub synthetic: Vec<egui::Event>,
/// The modifiers of the synthetic press in progress (a Ctrl-drag keeps Ctrl while it moves).
@@ -357,6 +360,7 @@ impl SolveApp {
autosave_minutes: 5.0,
custom_titlebar: false,
integrated_titlebar: false,
cpu_meshing: false,
frame_ms: 0.0,
synthetic: Vec::new(),
synthetic_mods: egui::Modifiers::default(),
@@ -389,7 +393,7 @@ impl SolveApp {
/// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth and
/// stencil buffer bits and MSAA sample count). Without it the viewport renders on the CPU.
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) {
self.viewport.gpu = Some(gpu::install(rs, depth_bits, stencil_bits, samples));
self.viewport.gpu = Some(gpu::install(rs, depth_bits, stencil_bits, samples, !self.cpu_meshing));
self.msaa_supported = rs.msaa_supported.clone();
let info = rs.adapter.get_info();
self.help.gpu = Some(format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type));
@@ -468,7 +472,7 @@ impl SolveApp {
if let Some(t) = v.get("toolbar") {
self.toolbar_custom.load(t);
}
if let Some(mut p) = v.get("preferences").and_then(|p| serde_json::from_value::<prefs::Prefs>(p.clone()).ok()) {
if let Some(mut p) = v.get("preferences").filter(|p| p.is_object()).map(prefs::lenient) {
if !prefs::MSAA.contains(&p.msaa) {
p.msaa = prefs::Prefs::default().msaa;
}
+43
View File
@@ -177,6 +177,7 @@ pub const MSAA: [u32; 4] = [1, 2, 4, 8];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NavigatorStyle {
/// Also what a navigator this version doesn't know reads as (`lenient`).
#[default]
Axes,
Cube,
@@ -1101,6 +1102,28 @@ pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::
Ok(out)
}
/// Saved preferences, keeping every one that still reads. A value this version doesn't
/// understand (a setting a newer or older release saved differently, a hand edit) falls back to
/// its default on its own instead of discarding all of them.
pub fn lenient(saved: &serde_json::Value) -> Prefs {
if let Ok(p) = serde_json::from_value::<Prefs>(saved.clone()) {
return p;
}
let Ok(mut cur) = serde_json::to_value(Prefs::default()) else { return Prefs::default() };
if let (Some(o), Some(src)) = (cur.as_object_mut(), saved.as_object()) {
for (k, v) in src.iter().take(1000) {
// Keys this version doesn't have are ignored anyway.
let Some(old) = o.get(k).cloned() else { continue };
o.insert(k.clone(), v.clone());
if serde_json::from_value::<Prefs>(serde_json::Value::Object(o.clone())).is_err() {
log::warn!("preferences: `{k}` is not understood by this version; using its default");
o.insert(k.clone(), old);
}
}
}
serde_json::from_value(cur).unwrap_or_default()
}
/// Set preferences from JSON (control channel); returns them all.
pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Value, String> {
let mut cur = serde_json::to_value(&app.preferences).map_err(|e| e.to_string())?;
@@ -1151,6 +1174,26 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
#[cfg(test)]
mod tests {
#[test]
fn a_value_this_version_does_not_understand_keeps_the_other_preferences() {
use serde_json::json;
// A navigator, a field of the wrong type and a key from another release.
let saved = json!({"nav": "solidworks", "navigator_style": "hologram", "ui_scale": "big", "zoom_reverse": true, "from_the_future": 3});
let p = super::lenient(&saved);
assert_eq!(p.nav, "solidworks");
assert!(p.zoom_reverse);
assert_eq!(p.navigator_style, super::NavigatorStyle::Axes);
assert_eq!(p.ui_scale, super::Prefs::default().ui_scale);
assert_eq!(super::lenient(&json!("garbage")), super::Prefs::default());
// Whole-app load: the other saved state is kept too, and nothing panics.
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.load_prefs(&json!({"grid": false, "preferences": saved}).to_string());
assert!(!app.ui.show_grid && app.preferences.nav == "solidworks" && app.preferences.zoom_reverse);
for junk in ["", "{", "[]", "null", "{\"preferences\": 7, \"recent\": {}, \"shortcuts\": 1, \"toolbar\": \"x\"}"] {
crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()).load_prefs(junk);
}
}
#[test]
fn stored_prefs_fall_back_to_the_legacy_key() {
let only_old = |k: &str| (k == super::LEGACY_STORAGE_KEY).then(|| "old".to_string());