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

+92 -7
View File
@@ -1,6 +1,9 @@
//! SolidCraft desktop app.
//!
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [design.solidcraft | part.step]`
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]`
//!
//! `--safe-mode` (or `SOLIDCRAFT_SAFE_MODE=1`) starts with default settings, no recovery offer and
//! the software renderer; see `startup` and docs/book/src/guide/troubleshooting.md.
//!
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
@@ -20,18 +23,24 @@ mod control_server;
mod open_documents;
#[cfg(any(target_os = "windows", target_os = "linux"))]
mod space_mouse;
mod startup;
use solidcraft_engine::{Session, io};
use solidcraft_ui_egui::{Services, SolveApp};
struct App {
solve: SolveApp,
/// The settings folder whose start marker the first frame clears (`startup::started`).
starting: Option<std::path::PathBuf>,
#[cfg(any(target_os = "windows", target_os = "linux"))]
space_mouse: space_mouse::SpaceMouse,
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if let Some(dir) = self.starting.take() {
startup::started(&dir);
}
// The design on screen is the one whose commands a crash report replays.
self.solve.session.crash_journal = solidcraft_engine::crash::enabled();
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
@@ -306,7 +315,7 @@ const APP_ID: &str = solidcraft_engine::io::APP_ID;
fn main() -> eframe::Result {
adopt_legacy_settings();
let samples = msaa();
let mut safe_mode = startup::env_on(std::env::var(startup::SAFE_MODE_ENV).ok().as_deref());
let mut control_port: Option<u16> = solidcraft_engine::geom::env::var("CONTROL_PORT").and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
@@ -315,6 +324,7 @@ fn main() -> eframe::Result {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--safe-mode" => safe_mode = true,
"--version" | "-V" => {
println!("solidcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
@@ -333,12 +343,30 @@ fn main() -> eframe::Result {
return Ok(());
}
"--help" | "-h" => {
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [design.solidcraft | part.step]");
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]");
return Ok(());
}
_ => files.push(a),
}
}
// Settings that don't read are moved aside; launches that never got a window are counted,
// and after a few in a row this one starts in safe mode (docs/book/src/guide/troubleshooting.md).
let settings = settings_dir();
let mut notes: Vec<String> = Vec::new();
let mut failed_starts = 0;
if let Some(dir) = &settings {
notes.extend(startup::check_settings(dir, &[solidcraft_ui_egui::prefs::STORAGE_KEY, solidcraft_ui_egui::prefs::LEGACY_STORAGE_KEY]));
failed_starts = startup::begin(dir);
}
if failed_starts >= startup::FAILED_STARTS_FOR_SAFE_MODE && !safe_mode {
safe_mode = true;
notes.push(format!("The last {failed_starts} launches stopped before the window opened, so this one is in safe mode."));
}
let cpu_renderer = safe_mode || startup::cpu_renderer_env(std::env::var(startup::RENDERER_ENV).ok().as_deref());
if safe_mode {
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
}
let samples = if safe_mode { MSAA } else { msaa() };
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
let crashed = solidcraft_engine::crash::default_dir()
.map(|dir| {
@@ -355,8 +383,17 @@ fn main() -> eframe::Result {
depth_buffer: DEPTH_BITS,
stencil_buffer: STENCIL_BITS,
multisampling: samples,
// Kept with the other settings, not in a folder named after the app id.
persistence_path: settings_dir().map(|d| d.join("app.ron")),
// Kept with the other settings, not in a folder named after the app id. Safe mode reads
// and writes a scratch copy, leaving the user's settings as they were.
persistence_path: if safe_mode {
let scratch = std::env::temp_dir().join(format!("solidcraft-safe-mode-{}.ron", std::process::id()));
let _ = std::fs::remove_file(&scratch);
Some(scratch)
} else {
settings.as_ref().map(|d| d.join("app.ron"))
},
persist_window: !safe_mode,
wgpu_options: wgpu_options(cpu_renderer),
..Default::default()
};
#[cfg(all(unix, not(target_os = "macos")))]
@@ -376,15 +413,20 @@ fn main() -> eframe::Result {
// Files opened from Finder and the Dock arrive as events, not arguments.
#[cfg(target_os = "macos")]
open_documents::install();
let created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let created_in_app = created.clone();
let starting = settings.clone();
let result = eframe::run_native(
"SolidCraft",
options,
Box::new(move |cc| {
created_in_app.store(true, std::sync::atomic::Ordering::SeqCst);
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
let mut app = SolveApp::new(Session::default(), services());
app.custom_titlebar = CUSTOM_TITLEBAR;
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
app.cpu_meshing = safe_mode;
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
@@ -429,20 +471,63 @@ fn main() -> eframe::Result {
if let Some(dir) = solidcraft_engine::recovery::default_dir() {
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
solidcraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
// Safe mode keeps autosaving but offers nothing: the entries wait for a normal start.
if safe_mode {
app.recoverable.clear();
}
}
// Tell the user about crashes since the last start (after the recovery offer is set up).
solidcraft_ui_egui::crash_ui::announce(&mut app, &crashed);
if !notes.is_empty() {
app.set_status(notes.join(" "), true);
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
Ok(Box::new(App {
solve: app,
starting,
#[cfg(any(target_os = "windows", target_os = "linux"))]
space_mouse,
}))
}),
);
control_server::remove_token_file();
result
match result {
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, settings.as_deref()),
r => r,
}
}
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe).
fn wgpu_options(cpu: bool) -> eframe::egui_wgpu::WgpuConfiguration {
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
if cpu && let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
setup.native_adapter_selector = Some(std::sync::Arc::new(startup::pick_cpu_adapter));
}
options
}
/// The window never opened (no graphics adapter, a device that wouldn't start): once more on
/// the software renderer, else a message box saying what failed instead of a silent exit.
fn no_window(error: eframe::Error, tried_cpu: bool, settings: Option<&std::path::Path>) -> eframe::Result {
log::error!("the window could not open: {error}");
eprintln!("SolidCraft: the window could not open: {error}");
if !tried_cpu && let Ok(exe) = std::env::current_exe() {
eprintln!("SolidCraft: trying again with the software renderer ({}=cpu)", startup::RENDERER_ENV);
match std::process::Command::new(exe).args(std::env::args_os().skip(1)).env(startup::RENDERER_ENV, "cpu").status() {
Ok(status) if status.success() => return Ok(()),
// The second try showed its own message.
Ok(_) => return Err(error),
Err(e) => log::error!("could not start again with the software renderer: {e}"),
}
}
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("SolidCraft could not start")
.set_description(startup::no_window_message(&error.to_string(), settings))
.set_buttons(rfd::MessageButtons::Ok)
.show();
Err(error)
}
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
+204
View File
@@ -0,0 +1,204 @@
//! Getting the window open, whatever state the settings folder or the graphics device is in
//! (docs/book/src/guide/troubleshooting.md).
//!
//! - **Settings that don't read** (`app.ron`, `graphics.json`): moved aside as
//! `<name>.bad-<time>` and the app starts with defaults, instead of a newer or damaged file
//! being dropped silently or stopping the start.
//! - **Launches that die before the window is up**: a marker file counts them; after two in a
//! row the next launch starts in safe mode by itself.
//! - **Safe mode** (`--safe-mode` or `SOLIDCRAFT_SAFE_MODE=1`): default settings (the saved ones
//! are left untouched), no recovery offer, the software renderer and CPU meshing.
//! - **No usable graphics device**: one retry on the software renderer
//! (`SOLIDCRAFT_RENDERER=cpu`), then a message box saying what failed and what to try.
use std::path::{Path, PathBuf};
/// The environment variable that starts safe mode (any value but `0` or empty).
pub const SAFE_MODE_ENV: &str = "SOLIDCRAFT_SAFE_MODE";
/// `SOLIDCRAFT_RENDERER=cpu` picks the software renderer (WARP, lavapipe, llvmpipe).
pub const RENDERER_ENV: &str = "SOLIDCRAFT_RENDERER";
/// Consecutive launches that died before the window was up, after which safe mode starts.
pub const FAILED_STARTS_FOR_SAFE_MODE: u32 = 2;
const MARKER: &str = "starting";
/// Whether an environment value switches something on.
pub fn env_on(v: Option<&str>) -> bool {
v.map(str::trim).is_some_and(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
}
/// Whether `SOLIDCRAFT_RENDERER` asks for the software renderer.
pub fn cpu_renderer_env(v: Option<&str>) -> bool {
v.map(str::trim).is_some_and(|v| v.eq_ignore_ascii_case("cpu") || v.eq_ignore_ascii_case("software"))
}
fn unix_now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// Move `path` aside as `<name>.bad-<time>` (kept for a bug report); returns where it went.
pub fn back_up(path: &Path) -> std::io::Result<PathBuf> {
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "settings".into());
let mut to = path.with_file_name(format!("{name}.bad-{}", unix_now()));
let mut n = 1;
while to.exists() && n < 100 {
to = path.with_file_name(format!("{name}.bad-{}-{n}", unix_now()));
n += 1;
}
std::fs::rename(path, &to)?;
Ok(to)
}
/// Whether eframe's `app.ron` reads, with the preferences inside it.
fn app_ron_reads(text: &str, prefs_keys: &[&str]) -> bool {
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
prefs_keys.iter().filter_map(|k| kv.get(*k)).all(|p| serde_json::from_str::<serde_json::Value>(p).is_ok_and(|v| v.is_object()))
}
/// Check the settings files in `dir` before anything reads them: one that doesn't read is moved
/// aside (the app then starts with defaults for it). Returns a line per file moved.
pub fn check_settings(dir: &Path, prefs_keys: &[&str]) -> Vec<String> {
let mut notes = Vec::new();
let mut check = |name: &str, reads: &dyn Fn(&str) -> bool| {
let path = dir.join(name);
let Ok(bytes) = std::fs::read(&path) else { return };
let ok = std::str::from_utf8(&bytes).is_ok_and(|t| t.trim().is_empty() || reads(t));
if ok {
return;
}
match back_up(&path) {
Ok(to) => {
log::warn!("settings: {} did not read; moved to {}", path.display(), to.display());
notes.push(format!("{name} could not be read and was moved to {}; defaults are used.", to.display()));
}
Err(e) => log::warn!("settings: {} did not read and could not be moved: {e}", path.display()),
}
};
check("app.ron", &|t| app_ron_reads(t, prefs_keys));
check("graphics.json", &|t| serde_json::from_str::<serde_json::Value>(t).is_ok_and(|v| v.is_object()));
notes
}
/// A launch begins: count it in the marker in `dir`. Returns how many launches before this one
/// died before their window was up.
pub fn begin(dir: &Path) -> u32 {
let marker = dir.join(MARKER);
let before = std::fs::read_to_string(&marker).ok().and_then(|s| s.trim().parse::<u32>().ok()).unwrap_or(0).min(1000);
if std::fs::create_dir_all(dir).is_ok() {
let _ = std::fs::write(&marker, (before + 1).to_string());
}
before
}
/// The window is up: this launch did not fail to start.
pub fn started(dir: &Path) {
let _ = std::fs::remove_file(dir.join(MARKER));
}
/// The software adapter among `adapters` (WARP on Windows, lavapipe or llvmpipe elsewhere), else
/// the first one that can draw to `surface`.
pub fn pick_cpu_adapter(adapters: &[eframe::wgpu::Adapter], surface: Option<&eframe::wgpu::Surface<'_>>) -> Result<eframe::wgpu::Adapter, String> {
let fits = |a: &&eframe::wgpu::Adapter| surface.is_none_or(|s| a.is_surface_supported(s));
adapters
.iter()
.filter(fits)
.find(|a| a.get_info().device_type == eframe::wgpu::DeviceType::Cpu)
.or_else(|| adapters.iter().find(fits))
.cloned()
.ok_or_else(|| {
let names: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().backend)).collect();
format!("no graphics adapter can draw the window (found: {})", if names.is_empty() { "none".into() } else { names.join(", ") })
})
}
/// The message shown when no window could be opened at all.
pub fn no_window_message(error: &str, settings: Option<&Path>) -> String {
let folder = settings.map(|d| d.display().to_string()).unwrap_or_else(|| "the SolidCraft settings folder".into());
format!(
"SolidCraft could not open its window.\n\n{error}\n\n\
The software renderer was tried too. Things to try:\n\
- Install or update the graphics driver (on Windows, from the GPU maker's site rather than Windows Update).\n\
- Over Remote Desktop or in a virtual machine, enable 3D acceleration or run it on the machine itself.\n\
- Start it once with `solidcraft --safe-mode`.\n\
- Rename the settings folder: {folder}\n\n\
More: https://bherbruck.github.io/solidcraft/guide/troubleshooting.html"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("solidcraft-startup-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn switches_read_from_the_environment() {
assert!(env_on(Some("1")) && env_on(Some("yes")) && !env_on(Some("0")) && !env_on(Some(" ")) && !env_on(None) && !env_on(Some("false")));
assert!(cpu_renderer_env(Some("cpu")) && cpu_renderer_env(Some("Software")) && !cpu_renderer_env(Some("gpu")) && !cpu_renderer_env(None));
}
#[test]
fn settings_that_do_not_read_are_moved_aside_and_good_ones_stay() {
let keys = ["solidcraft.prefs", "solvecraft.prefs"];
let d = scratch("check");
// Good files (and an empty one) stay where they are.
std::fs::write(d.join("app.ron"), r#"{"solidcraft.prefs": "{\"grid\":true}", "egui": "(anything)"}"#).unwrap();
std::fs::write(d.join("graphics.json"), r#"{"msaa":4}"#).unwrap();
assert!(check_settings(&d, &keys).is_empty());
assert!(d.join("app.ron").is_file() && d.join("graphics.json").is_file());
// Damaged RON, preferences that aren't JSON, a truncated graphics.json, non-UTF-8 bytes.
for (name, text) in [
("app.ron", b"{\"solidcraft.prefs\": \"{\\\"gr".as_slice()),
("app.ron", br#"{"solvecraft.prefs": "not json"}"#.as_slice()),
("graphics.json", br#"{"msaa":"#.as_slice()),
("graphics.json", b"\xff\xfe\x00".as_slice()),
] {
std::fs::write(d.join(name), text).unwrap();
let notes = check_settings(&d, &keys);
assert_eq!(notes.len(), 1, "{name}: {notes:?}");
assert!(!d.join(name).exists(), "{name} moved");
let kept: Vec<_> =
std::fs::read_dir(&d).unwrap().flatten().filter(|e| e.file_name().to_string_lossy().starts_with(&format!("{name}.bad-"))).collect();
assert!(kept.iter().any(|e| std::fs::read(e.path()).unwrap() == text), "{name}: the bad file is kept");
}
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn launches_that_never_got_a_window_are_counted_until_one_does() {
let d = scratch("marker");
assert_eq!(begin(&d), 0);
assert_eq!(begin(&d), 1);
assert_eq!(begin(&d), FAILED_STARTS_FOR_SAFE_MODE);
started(&d);
assert_eq!(begin(&d), 0);
// A damaged marker counts as none.
std::fs::write(d.join(MARKER), "zz").unwrap();
assert_eq!(begin(&d), 0);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_message_names_the_settings_folder_and_safe_mode() {
let m = no_window_message("no adapter", Some(Path::new("/x/SolidCraft")));
assert!(m.contains("no adapter") && m.contains("/x/SolidCraft") && m.contains("--safe-mode"));
}
#[test]
fn the_software_adapter_is_preferred_and_its_absence_is_an_error_not_a_panic() {
assert!(pick_cpu_adapter(&[], None).unwrap_err().contains("none"));
let instance = eframe::wgpu::Instance::new(eframe::wgpu::InstanceDescriptor::new_without_display_handle());
let adapters = pollster::block_on(instance.enumerate_adapters(eframe::wgpu::Backends::all()));
if adapters.is_empty() {
return;
}
let picked = pick_cpu_adapter(&adapters, None).unwrap();
if adapters.iter().any(|a| a.get_info().device_type == eframe::wgpu::DeviceType::Cpu) {
assert_eq!(picked.get_info().device_type, eframe::wgpu::DeviceType::Cpu);
}
}
}