#820: count only quick deaths, two in a row; notice with reset and --graphics
Review on #827: a start marker left pending by any early exit (force-quit, hang, unrelated crash, two launches racing) moved the user down a rung for good, with no way back but deleting a file. - Each start writes its own attempt file (graphics-start/<pid>-<n>.json) that is removed after two painted frames, on a normal exit, or 15 s after launch whatever happened. A later launch only counts attempts that are older than that window, so a hang, a later crash or force-quit, and an instance that is still starting are never failures. - graphics-start.json now holds {mode, failures}: two failed starts in a row in the same mode move on to the next one; a start that paints resets the count. A wgpu start-up error still moves on at once. - All files are written to a per-process temp name and renamed into place, so concurrent launches never see or leave half a file. - A fallback mode shows a notice (graphics.notice) with Use Default Graphics (graphics.notice.reset) and Close; es, fr, ja and zh-cn strings. - --graphics default|dx12|software picks a mode by hand and keeps it; `--graphics default` undoes a fallback. Modes this platform lacks are refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
00ad651814
commit
6dd829dd61
|
|
@ -15,6 +15,9 @@ Options:
|
||||||
--no-recover start without asking about unsaved changes
|
--no-recover start without asking about unsaved changes
|
||||||
--data-dir <dir> folder for auto-save, crash recovery and logs
|
--data-dir <dir> folder for auto-save, crash recovery and logs
|
||||||
(also: FILMCRAFT_DATA_DIR)
|
(also: FILMCRAFT_DATA_DIR)
|
||||||
|
--graphics <mode> start with this graphics mode and keep it: default,
|
||||||
|
dx12 (Windows), software (Windows, Linux);
|
||||||
|
`--graphics default` undoes an automatic fallback
|
||||||
--version print the version and exit
|
--version print the version and exit
|
||||||
-h, --help print this help and exit
|
-h, --help print this help and exit
|
||||||
";
|
";
|
||||||
|
|
@ -39,6 +42,8 @@ pub struct Launch {
|
||||||
pub startup_flag: bool,
|
pub startup_flag: bool,
|
||||||
pub recover: Option<bool>,
|
pub recover: Option<bool>,
|
||||||
pub data_dir: Option<std::path::PathBuf>,
|
pub data_dir: Option<std::path::PathBuf>,
|
||||||
|
/// `--graphics <mode>`: start with this graphics mode and keep it (see `graphics.rs`).
|
||||||
|
pub graphics: Option<crate::graphics::Mode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the arguments after the program name. `env_control_port` is the value of
|
/// Parse the arguments after the program name. `env_control_port` is the value of
|
||||||
|
|
@ -49,7 +54,16 @@ pub struct Launch {
|
||||||
/// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error
|
/// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error
|
||||||
/// too, and for a port from the environment exactly as for one from `--control`.
|
/// too, and for a port from the environment exactly as for one from `--control`.
|
||||||
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = String>) -> Result<Cli, String> {
|
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = String>) -> Result<Cli, String> {
|
||||||
let mut l = Launch { control_port: None, control_input_only: false, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None };
|
let mut l = Launch {
|
||||||
|
control_port: None,
|
||||||
|
control_input_only: false,
|
||||||
|
files: Vec::new(),
|
||||||
|
demo: true,
|
||||||
|
startup_flag: false,
|
||||||
|
recover: None,
|
||||||
|
data_dir: None,
|
||||||
|
graphics: None,
|
||||||
|
};
|
||||||
let (mut help, mut version) = (false, false);
|
let (mut help, mut version) = (false, false);
|
||||||
let mut args = args.into_iter();
|
let mut args = args.into_iter();
|
||||||
while let Some(a) = args.next() {
|
while let Some(a) = args.next() {
|
||||||
|
|
@ -66,6 +80,10 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = St
|
||||||
"--recover" => l.recover = Some(true),
|
"--recover" => l.recover = Some(true),
|
||||||
"--no-recover" => l.recover = Some(false),
|
"--no-recover" => l.recover = Some(false),
|
||||||
"--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?),
|
"--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?),
|
||||||
|
"--graphics" => {
|
||||||
|
let name = args.next().ok_or("--graphics needs a mode: default, dx12 or software")?;
|
||||||
|
l.graphics = Some(graphics_mode(&name)?);
|
||||||
|
}
|
||||||
// everything after `--` is a file, whatever it looks like
|
// everything after `--` is a file, whatever it looks like
|
||||||
"--" => l.files.extend(args.by_ref()),
|
"--" => l.files.extend(args.by_ref()),
|
||||||
// macOS adds a process serial number when it launches an app bundle
|
// macOS adds a process serial number when it launches an app bundle
|
||||||
|
|
@ -91,6 +109,17 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = St
|
||||||
Ok(Cli::Run(l))
|
Ok(Cli::Run(l))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `--graphics` value: one of this platform's modes.
|
||||||
|
fn graphics_mode(name: &str) -> Result<crate::graphics::Mode, String> {
|
||||||
|
use crate::graphics::{LADDER, Mode};
|
||||||
|
let here = || LADDER.iter().map(|m| m.name()).collect::<Vec<_>>().join(", ");
|
||||||
|
let mode = Mode::parse(name).ok_or_else(|| format!("--graphics needs one of {}, got `{name}`", here()))?;
|
||||||
|
if !LADDER.contains(&mode) {
|
||||||
|
return Err(format!("--graphics {name} is not available on this platform (use one of {})", here()));
|
||||||
|
}
|
||||||
|
Ok(mode)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -166,7 +195,16 @@ mod tests {
|
||||||
let none = launch(&[]);
|
let none = launch(&[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
none,
|
none,
|
||||||
Launch { control_port: None, control_input_only: false, files: vec![], demo: true, startup_flag: false, recover: None, data_dir: None }
|
Launch {
|
||||||
|
control_port: None,
|
||||||
|
control_input_only: false,
|
||||||
|
files: vec![],
|
||||||
|
demo: true,
|
||||||
|
startup_flag: false,
|
||||||
|
recover: None,
|
||||||
|
data_dir: None,
|
||||||
|
graphics: None
|
||||||
|
}
|
||||||
);
|
);
|
||||||
let l = launch(&["--control", "9876", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]);
|
let l = launch(&["--control", "9876", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]);
|
||||||
assert_eq!(l.control_port, Some(9876));
|
assert_eq!(l.control_port, Some(9876));
|
||||||
|
|
@ -188,4 +226,23 @@ mod tests {
|
||||||
assert_eq!(parse_args(&["--control-input-only", "--help"]), Ok(Cli::Help));
|
assert_eq!(parse_args(&["--control-input-only", "--help"]), Ok(Cli::Help));
|
||||||
assert!(USAGE.contains("--control-input-only"));
|
assert!(USAGE.contains("--control-input-only"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn graphics_picks_one_of_this_platforms_modes() {
|
||||||
|
use crate::graphics::{LADDER, Mode};
|
||||||
|
assert_eq!(launch(&[]).graphics, None);
|
||||||
|
assert_eq!(launch(&["--graphics", "default"]).graphics, Some(Mode::Default));
|
||||||
|
for mode in LADDER {
|
||||||
|
assert_eq!(launch(&["--graphics", mode.name()]).graphics, Some(*mode));
|
||||||
|
}
|
||||||
|
// a mode this platform has not is refused, not ignored
|
||||||
|
for mode in [Mode::Dx12, Mode::Software].into_iter().filter(|m| !LADDER.contains(m)) {
|
||||||
|
assert!(parse_args(&["--graphics", mode.name()]).unwrap_err().contains("not available"));
|
||||||
|
}
|
||||||
|
assert!(parse_args(&["--graphics"]).is_err(), "missing mode");
|
||||||
|
assert!(parse_args(&["--graphics", "vulkan"]).unwrap_err().contains("vulkan"));
|
||||||
|
assert!(parse_args(&["--graphics", "DEFAULT"]).is_err());
|
||||||
|
assert!(parse_args(&["--graphics", &"x".repeat(1 << 16)]).is_err());
|
||||||
|
assert!(USAGE.contains("--graphics"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,36 @@
|
||||||
//! A graphics driver can crash inside wgpu instance or device creation (Intel's Vulkan driver
|
//! A graphics driver can crash inside wgpu instance or device creation (Intel's Vulkan driver
|
||||||
//! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error
|
//! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error
|
||||||
//! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand.
|
//! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand.
|
||||||
//! Each start now leaves a marker in the data folder ([`STATE_FILE`]) that is cleared once the
|
//!
|
||||||
//! window has painted. A marker still pending at the next launch means that start died: the launch
|
//! # When a start counts as failed
|
||||||
//! moves on to the next [`Mode`] of the platform's [`LADDER`] and keeps it. A start-up error that
|
//! The data folder keeps the mode to start with and the failed starts in a row in it
|
||||||
//! wgpu does report (no adapter, device creation failed) moves on within the same process. An
|
//! ([`STATE_FILE`]). Each start also leaves its own attempt file (`graphics-start/<pid>-<n>.json`,
|
||||||
//! explicit `WGPU_BACKEND` leaves the marker alone: the user is in charge.
|
//! so concurrent launches never write the same file) that is removed once the window has painted
|
||||||
|
//! two frames, when the process ends normally, or [`DEATH_WINDOW`] after launch, whatever happened
|
||||||
|
//! by then. An attempt file found by a later launch therefore means a process that died within
|
||||||
|
//! [`DEATH_WINDOW`] of its start without painting: a driver crash, typically. A start that hangs,
|
||||||
|
//! or is force-quit or crashes later, is not counted; neither is another instance that is still
|
||||||
|
//! starting (its attempt is younger than [`DEATH_WINDOW`] and is left alone). After
|
||||||
|
//! [`FAILURES_TO_MOVE_ON`] such failures in a row in the same mode, the launch moves on to the next
|
||||||
|
//! [`Mode`] of the platform's [`LADDER`] and keeps it; a start that paints resets the count. A
|
||||||
|
//! start-up error that wgpu does report (no adapter, device creation failed) moves on at once,
|
||||||
|
//! within the same process.
|
||||||
|
//!
|
||||||
|
//! # Getting back
|
||||||
|
//! A fallback mode shows a notice with a reset button in the window (`graphics.notice.reset`,
|
||||||
|
//! [`GraphicsStart::reset_to_default`]); `--graphics default` does the same from the command
|
||||||
|
//! line, and `--graphics dx12|software` picks a mode by hand. An explicit `WGPU_BACKEND` leaves
|
||||||
|
//! the saved state alone: the user is in charge.
|
||||||
|
//!
|
||||||
|
//! All files are written beside and renamed into place, so a reader never sees half a file; two
|
||||||
|
//! instances updating [`STATE_FILE`] at the same moment can lose one update (a failure counted
|
||||||
|
//! once less, a confirmation repeated by the next start), never corrupt it.
|
||||||
|
|
||||||
use std::io::Read as _;
|
use std::io::Read as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use eframe::{
|
use eframe::{
|
||||||
NativeOptions,
|
NativeOptions,
|
||||||
|
|
@ -20,9 +41,19 @@ use eframe::{
|
||||||
wgpu::{self, Backends},
|
wgpu::{self, Backends},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The start marker in the data folder: `{"mode": "<mode>", "pending": <bool>}`. Delete it to
|
/// The saved graphics state in the data folder: `{"mode": "<mode>", "failures": <n>}`.
|
||||||
/// start with the default graphics mode again.
|
|
||||||
pub const STATE_FILE: &str = "graphics-start.json";
|
pub const STATE_FILE: &str = "graphics-start.json";
|
||||||
|
/// The folder in the data folder with one attempt file per start that has not painted yet.
|
||||||
|
pub const ATTEMPTS_DIR: &str = "graphics-start";
|
||||||
|
/// How long after launch a start that has not painted may die and still count as failed. A
|
||||||
|
/// driver crash in instance or device creation happens within a second or two.
|
||||||
|
pub const DEATH_WINDOW: Duration = Duration::from_secs(15);
|
||||||
|
/// Failed starts in a row, in the same mode, that move on to the next mode.
|
||||||
|
pub const FAILURES_TO_MOVE_ON: u32 = 2;
|
||||||
|
/// Attempt files looked at per launch; anything beyond is left for a later launch.
|
||||||
|
const MAX_ATTEMPTS: usize = 64;
|
||||||
|
/// The state and attempt files are a few bytes; anything longer is not ours.
|
||||||
|
const MAX_FILE: u64 = 4096;
|
||||||
|
|
||||||
/// How the window's graphics device is created.
|
/// How the window's graphics device is created.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
|
@ -38,6 +69,7 @@ pub enum Mode {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Mode {
|
impl Mode {
|
||||||
|
/// The name in [`STATE_FILE`] and on the command line (`--graphics <name>`).
|
||||||
pub fn name(self) -> &'static str {
|
pub fn name(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Mode::Default => "default",
|
Mode::Default => "default",
|
||||||
|
|
@ -46,7 +78,16 @@ impl Mode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse(name: &str) -> Option<Mode> {
|
/// The English name shown in the interface (translated there).
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Mode::Default => "default graphics",
|
||||||
|
Mode::Dx12 => "DirectX 12 only",
|
||||||
|
Mode::Software => "software rendering",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(name: &str) -> Option<Mode> {
|
||||||
[Mode::Default, Mode::Dx12, Mode::Software].into_iter().find(|m| m.name() == name)
|
[Mode::Default, Mode::Dx12, Mode::Software].into_iter().find(|m| m.name() == name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -66,107 +107,316 @@ fn next_in(ladder: &[Mode], mode: Mode) -> Option<Mode> {
|
||||||
ladder.get(i.checked_add(1)?).copied()
|
ladder.get(i.checked_add(1)?).copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This launch's mode from the saved marker (`(mode, pending)`; `None` when there is none or it
|
/// [`STATE_FILE`]: the mode to start with and the failed starts in a row in it.
|
||||||
/// is unreadable), and whether it moved on because the last start died.
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
fn launch_mode(saved: Option<(Mode, bool)>, ladder: &[Mode]) -> (Mode, bool) {
|
struct Saved {
|
||||||
|
mode: Mode,
|
||||||
|
failures: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An attempt file left by a start: its process id (from the file name), mode and launch time.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
struct Attempt {
|
||||||
|
pid: u32,
|
||||||
|
mode: Mode,
|
||||||
|
/// Seconds since the Unix epoch.
|
||||||
|
started: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a launch makes of the saved state and the attempt files it found.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
struct Decision {
|
||||||
|
/// The state to start with and save.
|
||||||
|
saved: Saved,
|
||||||
|
/// Moved on to the next mode because the last starts failed.
|
||||||
|
moved_on: bool,
|
||||||
|
/// Per attempt: done with (counted, or not ours to count), delete it.
|
||||||
|
delete: Vec<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This launch's mode from the saved state and the attempts other starts left. `now` is in Unix
|
||||||
|
/// seconds, `pid` is this process (an attempt with our process id is a dead process whose id was
|
||||||
|
/// reused), `window` is [`DEATH_WINDOW`] in seconds.
|
||||||
|
fn decide(saved: Option<Saved>, attempts: &[Attempt], now: u64, pid: u32, window: u64, ladder: &[Mode]) -> Decision {
|
||||||
let first = ladder.first().copied().unwrap_or(Mode::Default);
|
let first = ladder.first().copied().unwrap_or(Mode::Default);
|
||||||
match saved {
|
// Nothing saved, or a mode this platform does not use (a data folder copied from another OS).
|
||||||
// A mode this platform does not use (a data folder copied from another OS): start over.
|
let saved = saved.filter(|s| ladder.contains(&s.mode)).unwrap_or(Saved { mode: first, failures: 0 });
|
||||||
Some((mode, _)) if !ladder.contains(&mode) => (first, false),
|
let mut failures = saved.failures;
|
||||||
Some((mode, true)) => match next_in(ladder, mode) {
|
let mut delete = Vec::with_capacity(attempts.len());
|
||||||
Some(next) => (next, true),
|
for a in attempts {
|
||||||
// Nothing left to try: the last mode is still the best chance.
|
if a.started > now.saturating_add(window) {
|
||||||
None => (mode, false),
|
// From the future (a clock that jumped back): unknown, and not worth keeping.
|
||||||
},
|
delete.push(true);
|
||||||
Some((mode, false)) => (mode, false),
|
continue;
|
||||||
None => (first, false),
|
}
|
||||||
|
let dead = a.pid == pid || now.saturating_sub(a.started) >= window;
|
||||||
|
// A younger attempt is another instance starting right now: leave it to that instance.
|
||||||
|
delete.push(dead);
|
||||||
|
// Only failures of the mode we would start in count; older ones are history.
|
||||||
|
if dead && a.mode == saved.mode {
|
||||||
|
failures = failures.saturating_add(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if failures >= FAILURES_TO_MOVE_ON {
|
||||||
|
return match next_in(ladder, saved.mode) {
|
||||||
|
Some(next) => Decision { saved: Saved { mode: next, failures: 0 }, moved_on: true, delete },
|
||||||
|
// Nothing left to try: the last mode is still the best chance.
|
||||||
|
None => Decision { saved: Saved { mode: saved.mode, failures: 0 }, moved_on: false, delete },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Decision { saved: Saved { mode: saved.mode, failures }, moved_on: false, delete }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `(mode, pending)` from the marker's text; `None` for anything else.
|
fn read_small(path: &Path) -> Option<String> {
|
||||||
fn parse_state(text: &str) -> Option<(Mode, bool)> {
|
|
||||||
let v: serde_json::Value = serde_json::from_str(text).ok()?;
|
|
||||||
Some((Mode::parse(v.get("mode")?.as_str()?)?, v.get("pending")?.as_bool()?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_state(path: &Path) -> Option<(Mode, bool)> {
|
|
||||||
// The marker is a few bytes; anything longer is not ours.
|
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
std::fs::File::open(path).ok()?.take(4096).read_to_string(&mut text).ok()?;
|
std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_string(&mut text).ok()?;
|
||||||
parse_state(&text)
|
Some(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_state(path: &Path, mode: Mode, pending: bool) -> std::io::Result<()> {
|
/// [`Saved`] from its text; `None` for anything else. A missing failure count is none.
|
||||||
|
fn parse_state(text: &str) -> Option<Saved> {
|
||||||
|
let v: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||||
|
let mode = Mode::parse(v.get("mode")?.as_str()?)?;
|
||||||
|
let failures = match v.get("failures") {
|
||||||
|
None => 0,
|
||||||
|
Some(n) => u32::try_from(n.as_u64()?).ok()?,
|
||||||
|
};
|
||||||
|
Some(Saved { mode, failures })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_json(s: Saved) -> String {
|
||||||
|
serde_json::json!({ "mode": s.mode.name(), "failures": s.failures }).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An attempt from its file name (`<pid>-<n>.json`) and text; `None` for anything else.
|
||||||
|
fn parse_attempt(file_name: &str, text: &str) -> Option<Attempt> {
|
||||||
|
let pid = file_name.strip_suffix(".json")?.split_once('-')?.0.parse().ok()?;
|
||||||
|
let v: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||||
|
Some(Attempt { pid, mode: Mode::parse(v.get("mode")?.as_str()?)?, started: v.get("started")?.as_u64()? })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attempt_json(mode: Mode, started: u64) -> String {
|
||||||
|
serde_json::json!({ "mode": mode.name(), "started": started }).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `text` beside `path` (a name only this process uses) and rename it into place, so a
|
||||||
|
/// start that dies mid-write, or another instance reading, never sees half a file.
|
||||||
|
fn write_atomic(path: &Path, text: &str) -> std::io::Result<()> {
|
||||||
if let Some(dir) = path.parent() {
|
if let Some(dir) = path.parent() {
|
||||||
std::fs::create_dir_all(dir)?;
|
std::fs::create_dir_all(dir)?;
|
||||||
}
|
}
|
||||||
let text = serde_json::json!({ "mode": mode.name(), "pending": pending }).to_string();
|
let mut tmp = path.as_os_str().to_owned();
|
||||||
// Written beside and renamed, so a start that dies mid-write leaves the old marker whole.
|
tmp.push(format!(".{}.tmp", std::process::id()));
|
||||||
let tmp = path.with_extension("json.tmp");
|
let tmp = PathBuf::from(tmp);
|
||||||
std::fs::write(&tmp, text)?;
|
std::fs::write(&tmp, text)?;
|
||||||
std::fs::rename(&tmp, path)
|
std::fs::rename(&tmp, path).inspect_err(|_| {
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This launch's graphics mode and its start marker.
|
fn remove_quietly(path: &Path) {
|
||||||
|
match std::fs::remove_file(path) {
|
||||||
|
Err(e) if e.kind() != std::io::ErrorKind::NotFound => log::warn!("can't remove {}: {e}", path.display()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The attempt files in `dir` (at most [`MAX_ATTEMPTS`]): path and contents, `None` when unreadable.
|
||||||
|
fn list_attempts(dir: &Path) -> Vec<(PathBuf, Option<Attempt>)> {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||||
|
entries
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter_map(|e| {
|
||||||
|
let name = e.file_name().into_string().ok()?;
|
||||||
|
name.ends_with(".json").then(|| {
|
||||||
|
let path = e.path();
|
||||||
|
let attempt = read_small(&path).and_then(|text| parse_attempt(&name, &text));
|
||||||
|
(path, attempt)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.take(MAX_ATTEMPTS)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> u64 {
|
||||||
|
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt numbers within this process, so each attempt file has its own name.
|
||||||
|
static NEXT_ATTEMPT: AtomicU32 = AtomicU32::new(0);
|
||||||
|
|
||||||
|
/// This launch's graphics mode and its files in the data folder.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct GraphicsStart {
|
pub struct GraphicsStart {
|
||||||
/// The marker; `None` without a data folder or with `WGPU_BACKEND` set.
|
/// [`STATE_FILE`]; `None` without a data folder or with `WGPU_BACKEND` set.
|
||||||
path: Option<PathBuf>,
|
state: Option<PathBuf>,
|
||||||
|
/// This start's attempt file (with `state`).
|
||||||
|
attempt: Option<PathBuf>,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
|
/// [`Self::reset_to_default`] was used: a later [`Self::confirm`] must not save this mode over it.
|
||||||
|
reset: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GraphicsStart {
|
impl GraphicsStart {
|
||||||
/// Pick this launch's mode from the marker in `data_dir` and mark the start pending.
|
/// Pick this launch's mode from the data folder and record the attempt. `forced`
|
||||||
/// `explicit_backend` (`WGPU_BACKEND` set) starts in [`Mode::Default`] and leaves the marker
|
/// (`--graphics`) starts in that mode and saves it. `explicit_backend` (`WGPU_BACKEND` set)
|
||||||
/// alone.
|
/// leaves the data folder alone and starts in `forced`, else [`Mode::Default`].
|
||||||
pub fn begin(data_dir: Option<&Path>, explicit_backend: bool) -> Self {
|
pub fn begin(data_dir: Option<&Path>, explicit_backend: bool, forced: Option<Mode>) -> Self {
|
||||||
let path = if explicit_backend { None } else { data_dir.map(|d| d.join(STATE_FILE)) };
|
let start = Self::begin_at(data_dir, explicit_backend, forced, unix_now(), std::process::id());
|
||||||
let saved = path.as_deref().and_then(read_state);
|
if let Some(path) = start.attempt.clone() {
|
||||||
let (mode, moved_on) = launch_mode(saved, LADDER);
|
expire_later(path);
|
||||||
let file = path.as_deref().map(|p| p.display().to_string()).unwrap_or_default();
|
|
||||||
if moved_on {
|
|
||||||
let failed = saved.map_or("default", |(m, _)| m.name());
|
|
||||||
log::warn!(
|
|
||||||
"the last start did not get its window up with graphics mode {failed}; starting with {} (delete {file} to try the default again)",
|
|
||||||
mode.name()
|
|
||||||
);
|
|
||||||
} else if mode != Mode::Default {
|
|
||||||
log::info!("graphics mode {} after an earlier failed start (delete {file} to try the default again)", mode.name());
|
|
||||||
}
|
}
|
||||||
let start = Self { path, mode };
|
|
||||||
start.save(true);
|
|
||||||
start
|
start
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`Self::begin`] at `now` (Unix seconds) as process `pid`, without the expiry timer.
|
||||||
|
fn begin_at(data_dir: Option<&Path>, explicit_backend: bool, forced: Option<Mode>, now: u64, pid: u32) -> Self {
|
||||||
|
let first = LADDER.first().copied().unwrap_or(Mode::Default);
|
||||||
|
let forced = forced.map(|m| if LADDER.contains(&m) { m } else { first });
|
||||||
|
let reset = Arc::new(AtomicBool::new(false));
|
||||||
|
let Some(dir) = data_dir.filter(|_| !explicit_backend) else {
|
||||||
|
return Self { state: None, attempt: None, mode: forced.unwrap_or(Mode::Default), reset };
|
||||||
|
};
|
||||||
|
let state = dir.join(STATE_FILE);
|
||||||
|
let attempts_dir = dir.join(ATTEMPTS_DIR);
|
||||||
|
let saved = read_small(&state).and_then(|t| parse_state(&t));
|
||||||
|
let found = list_attempts(&attempts_dir);
|
||||||
|
let known: Vec<Attempt> = found.iter().filter_map(|(_, a)| *a).collect();
|
||||||
|
let decision = decide(saved, &known, now, pid, DEATH_WINDOW.as_secs(), LADDER);
|
||||||
|
let mut to_delete = decision.delete.iter();
|
||||||
|
for (path, attempt) in &found {
|
||||||
|
// unreadable files in our folder are debris (a different version, a damaged disk)
|
||||||
|
if attempt.is_none() || to_delete.next().copied().unwrap_or(false) {
|
||||||
|
remove_quietly(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let file = state.display();
|
||||||
|
let next = match forced {
|
||||||
|
Some(mode) => {
|
||||||
|
log::info!("graphics mode {} chosen with --graphics", mode.name());
|
||||||
|
Saved { mode, failures: 0 }
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if decision.moved_on {
|
||||||
|
let failed = saved.map_or(first, |s| s.mode).name();
|
||||||
|
log::warn!(
|
||||||
|
"the last {FAILURES_TO_MOVE_ON} starts with graphics mode {failed} died before their window painted; starting with {} (`--graphics default` or the notice in the window goes back)",
|
||||||
|
decision.saved.mode.name()
|
||||||
|
);
|
||||||
|
} else if decision.saved.failures > 0 {
|
||||||
|
log::warn!("the last start with graphics mode {} died before its window painted", decision.saved.mode.name());
|
||||||
|
} else if decision.saved.mode != Mode::Default {
|
||||||
|
log::info!("graphics mode {} after earlier failed starts ({file}; `--graphics default` goes back)", decision.saved.mode.name());
|
||||||
|
}
|
||||||
|
decision.saved
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if saved != Some(next)
|
||||||
|
&& let Err(e) = write_atomic(&state, &state_json(next))
|
||||||
|
{
|
||||||
|
log::warn!("can't write {file}: {e}");
|
||||||
|
}
|
||||||
|
let start = Self { state: Some(state), attempt: None, mode: next.mode, reset };
|
||||||
|
start.with_new_attempt(&attempts_dir, now, pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a new attempt file for the current mode.
|
||||||
|
fn with_new_attempt(mut self, dir: &Path, now: u64, pid: u32) -> Self {
|
||||||
|
let n = NEXT_ATTEMPT.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let path = dir.join(format!("{pid}-{n}.json"));
|
||||||
|
match write_atomic(&path, &attempt_json(self.mode, now)) {
|
||||||
|
Ok(()) => self.attempt = Some(path),
|
||||||
|
Err(e) => log::warn!("can't write {}: {e}", path.display()),
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mode(&self) -> Mode {
|
pub fn mode(&self) -> Mode {
|
||||||
self.mode
|
self.mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Running in a fallback mode, i.e. not the platform default.
|
||||||
|
pub fn degraded(&self) -> bool {
|
||||||
|
self.mode != Mode::Default
|
||||||
|
}
|
||||||
|
|
||||||
/// Move on to the next mode after a start-up error wgpu reported; `false` when none is left.
|
/// Move on to the next mode after a start-up error wgpu reported; `false` when none is left.
|
||||||
|
/// The error is certain, so the next mode is saved at once.
|
||||||
pub fn step_down(&mut self) -> bool {
|
pub fn step_down(&mut self) -> bool {
|
||||||
let Some(next) = next_in(LADDER, self.mode) else {
|
let Some(next) = next_in(LADDER, self.mode) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
log::warn!("graphics mode {} failed to start; trying {}", self.mode.name(), next.name());
|
log::warn!("graphics mode {} failed to start; trying {}", self.mode.name(), next.name());
|
||||||
self.mode = next;
|
self.mode = next;
|
||||||
self.save(true);
|
if let Some(state) = self.state.clone() {
|
||||||
|
self.save(&state, Saved { mode: next, failures: 0 });
|
||||||
|
if let Some(old) = self.attempt.take() {
|
||||||
|
remove_quietly(&old);
|
||||||
|
if let Some(dir) = old.parent() {
|
||||||
|
let path = self.clone().with_new_attempt(dir, unix_now(), std::process::id()).attempt;
|
||||||
|
if let Some(path) = &path {
|
||||||
|
expire_later(path.clone());
|
||||||
|
}
|
||||||
|
self.attempt = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The window is up: keep this mode for the next launches.
|
/// The window is up: keep this mode, and the failed starts in a row are over.
|
||||||
pub fn confirm(&self) {
|
pub fn confirm(&self) {
|
||||||
self.save(false);
|
self.finish();
|
||||||
|
let Some(state) = &self.state else { return };
|
||||||
|
if self.reset.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ok = Saved { mode: self.mode, failures: 0 };
|
||||||
|
if read_small(state).and_then(|t| parse_state(&t)) != Some(ok) {
|
||||||
|
self.save(state, ok);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save(&self, pending: bool) {
|
/// This start is over, whether it painted or not (the window closed, or every mode failed
|
||||||
if let Some(path) = &self.path
|
/// with an error that was reported): its attempt no longer means a crash.
|
||||||
&& let Err(e) = write_state(path, self.mode, pending)
|
pub fn finish(&self) {
|
||||||
{
|
if let Some(path) = &self.attempt {
|
||||||
|
remove_quietly(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start with the default mode next time (the notice's reset button). This process keeps
|
||||||
|
/// its mode: the graphics device can't be swapped under a running window.
|
||||||
|
pub fn reset_to_default(&self) -> Result<(), String> {
|
||||||
|
self.reset.store(true, Ordering::Relaxed);
|
||||||
|
let Some(state) = &self.state else { return Ok(()) };
|
||||||
|
let first = LADDER.first().copied().unwrap_or(Mode::Default);
|
||||||
|
write_atomic(state, &state_json(Saved { mode: first, failures: 0 })).map_err(|e| format!("can't write {}: {e}", state.display()))?;
|
||||||
|
log::info!("graphics mode reset to {} for the next start", first.name());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&self, path: &Path, s: Saved) {
|
||||||
|
if let Err(e) = write_atomic(path, &state_json(s)) {
|
||||||
log::warn!("can't write {}: {e}", path.display());
|
log::warn!("can't write {}: {e}", path.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove the attempt file at `path` [`DEATH_WINDOW`] from now: a start still alive then is not
|
||||||
|
/// counted as a failed one, painted or not.
|
||||||
|
fn expire_later(path: PathBuf) {
|
||||||
|
let spawned = std::thread::Builder::new().name("graphics-start".into()).spawn(move || {
|
||||||
|
let _ = std::panic::catch_unwind(move || {
|
||||||
|
std::thread::sleep(DEATH_WINDOW);
|
||||||
|
remove_quietly(&path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if let Err(e) = spawned {
|
||||||
|
log::warn!("can't start the graphics start timer: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Configure `options` for `mode`, before eframe creates the wgpu instance. `backend_override`
|
/// Configure `options` for `mode`, before eframe creates the wgpu instance. `backend_override`
|
||||||
/// is normally `Backends::from_env()`, i.e. `WGPU_BACKEND`.
|
/// is normally `Backends::from_env()`, i.e. `WGPU_BACKEND`.
|
||||||
pub fn apply(options: &mut NativeOptions, mode: Mode, backend_override: Option<Backends>) {
|
pub fn apply(options: &mut NativeOptions, mode: Mode, backend_override: Option<Backends>) {
|
||||||
|
|
@ -269,46 +519,103 @@ mod tests {
|
||||||
assert_eq!(configured(Mode::Default, None, false), (eframe_default.instance_descriptor.backends, false));
|
assert_eq!(configured(Mode::Default, None, false), (eframe_default.instance_descriptor.backends, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
const W: u64 = 15;
|
||||||
fn a_start_that_died_moves_on_to_the_next_mode_and_keeps_it() {
|
const NOW: u64 = 1_000_000;
|
||||||
// first launch
|
const ME: u32 = 7;
|
||||||
assert_eq!(launch_mode(None, WINDOWS), (Mode::Default, false));
|
|
||||||
// the default start died in the driver: DX12 next, then software
|
fn saved(mode: Mode, failures: u32) -> Option<Saved> {
|
||||||
assert_eq!(launch_mode(Some((Mode::Default, true)), WINDOWS), (Mode::Dx12, true));
|
Some(Saved { mode, failures })
|
||||||
assert_eq!(launch_mode(Some((Mode::Dx12, true)), WINDOWS), (Mode::Software, true));
|
}
|
||||||
assert_eq!(launch_mode(Some((Mode::Default, true)), LINUX), (Mode::Software, true));
|
|
||||||
// a confirmed mode is kept
|
fn dead(mode: Mode) -> Attempt {
|
||||||
assert_eq!(launch_mode(Some((Mode::Dx12, false)), WINDOWS), (Mode::Dx12, false));
|
Attempt { pid: 1, mode, started: NOW - 100 }
|
||||||
assert_eq!(launch_mode(Some((Mode::Default, false)), WINDOWS), (Mode::Default, false));
|
}
|
||||||
// nothing left to try: stay
|
|
||||||
assert_eq!(launch_mode(Some((Mode::Software, true)), WINDOWS), (Mode::Software, false));
|
fn mode_after(s: Option<Saved>, attempts: &[Attempt], ladder: &[Mode]) -> (Mode, u32, bool) {
|
||||||
assert_eq!(launch_mode(Some((Mode::Default, true)), MACOS), (Mode::Default, false));
|
let d = decide(s, attempts, NOW, ME, W, ladder);
|
||||||
// a mode this platform has not: start over
|
(d.saved.mode, d.saved.failures, d.moved_on)
|
||||||
assert_eq!(launch_mode(Some((Mode::Dx12, false)), LINUX), (Mode::Default, false));
|
|
||||||
assert_eq!(launch_mode(Some((Mode::Software, true)), MACOS), (Mode::Default, false));
|
|
||||||
assert_eq!(launch_mode(None, &[]), (Mode::Default, false));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_marker_round_trips_and_rejects_anything_else() {
|
fn two_failed_starts_in_a_row_move_on_to_the_next_mode_and_keep_it() {
|
||||||
|
// first launch
|
||||||
|
assert_eq!(mode_after(None, &[], WINDOWS), (Mode::Default, 0, false));
|
||||||
|
// one start died: counted, but the same mode is tried again
|
||||||
|
assert_eq!(mode_after(None, &[dead(Mode::Default)], WINDOWS), (Mode::Default, 1, false));
|
||||||
|
// the second in a row moves on: DX12, then software
|
||||||
|
assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
|
||||||
|
assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], WINDOWS), (Mode::Software, 0, true));
|
||||||
|
assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], LINUX), (Mode::Software, 0, true));
|
||||||
|
// two dead instances found at once count as two
|
||||||
|
assert_eq!(mode_after(None, &[dead(Mode::Default), dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
|
||||||
|
// a saved mode is kept
|
||||||
|
assert_eq!(mode_after(saved(Mode::Dx12, 0), &[], WINDOWS), (Mode::Dx12, 0, false));
|
||||||
|
assert_eq!(mode_after(saved(Mode::Default, 1), &[], WINDOWS), (Mode::Default, 1, false));
|
||||||
|
// nothing left to try: stay
|
||||||
|
assert_eq!(mode_after(saved(Mode::Software, 1), &[dead(Mode::Software)], WINDOWS), (Mode::Software, 0, false));
|
||||||
|
assert_eq!(mode_after(saved(Mode::Default, 5), &[dead(Mode::Default)], MACOS), (Mode::Default, 0, false));
|
||||||
|
// a mode this platform has not: start over
|
||||||
|
assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], LINUX), (Mode::Default, 0, false));
|
||||||
|
assert_eq!(mode_after(saved(Mode::Software, 0), &[], MACOS), (Mode::Default, 0, false));
|
||||||
|
assert_eq!(mode_after(None, &[], &[]), (Mode::Default, 0, false));
|
||||||
|
// a hostile count doesn't overflow
|
||||||
|
assert_eq!(mode_after(saved(Mode::Default, u32::MAX), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_starts_that_died_soon_after_launch_in_the_current_mode_count() {
|
||||||
|
let starting = Attempt { pid: 2, mode: Mode::Default, started: NOW - 3 };
|
||||||
|
let just_now = Attempt { pid: 3, mode: Mode::Default, started: NOW };
|
||||||
|
let future = Attempt { pid: 4, mode: Mode::Default, started: NOW + 3600 };
|
||||||
|
let older_mode = dead(Mode::Dx12);
|
||||||
|
let mine = Attempt { pid: ME, mode: Mode::Default, started: NOW - 1 };
|
||||||
|
let d = decide(saved(Mode::Default, 0), &[starting, just_now, future, older_mode, mine], NOW, ME, W, WINDOWS);
|
||||||
|
// another instance still starting is left alone; one from the future is dropped; a
|
||||||
|
// failure in another mode is history; our own process id can only be a dead process
|
||||||
|
assert_eq!(d.delete, [false, false, true, true, true]);
|
||||||
|
assert_eq!((d.saved, d.moved_on), (Saved { mode: Mode::Default, failures: 1 }, false));
|
||||||
|
// the clock a little behind another instance's: still starting
|
||||||
|
let ahead = Attempt { pid: 5, mode: Mode::Default, started: NOW + 2 };
|
||||||
|
assert_eq!(decide(None, &[ahead], NOW, ME, W, WINDOWS).delete, [false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_files_round_trip_and_reject_anything_else() {
|
||||||
for mode in WINDOWS {
|
for mode in WINDOWS {
|
||||||
for pending in [false, true] {
|
for failures in [0, 1, 2, u32::MAX] {
|
||||||
let text = serde_json::json!({ "mode": mode.name(), "pending": pending }).to_string();
|
assert_eq!(parse_state(&state_json(Saved { mode: *mode, failures })), saved(*mode, failures));
|
||||||
assert_eq!(parse_state(&text), Some((*mode, pending)));
|
|
||||||
}
|
}
|
||||||
|
assert_eq!(parse_attempt("12-0.json", &attempt_json(*mode, NOW)), Some(Attempt { pid: 12, mode: *mode, started: NOW }));
|
||||||
}
|
}
|
||||||
|
// without a count (an earlier build of this change wrote `pending`): none
|
||||||
|
assert_eq!(parse_state(r#"{"mode":"dx12","pending":true}"#), saved(Mode::Dx12, 0));
|
||||||
for bad in [
|
for bad in [
|
||||||
"",
|
"",
|
||||||
"{",
|
"{",
|
||||||
"null",
|
"null",
|
||||||
"[]",
|
"[]",
|
||||||
r#"{"mode":"vulkan","pending":true}"#,
|
r#"{"mode":"vulkan","failures":1}"#,
|
||||||
r#"{"mode":"dx12"}"#,
|
r#"{"failures":1}"#,
|
||||||
r#"{"mode":1,"pending":true}"#,
|
r#"{"mode":1,"failures":1}"#,
|
||||||
r#"{"mode":"dx12","pending":"yes"}"#,
|
r#"{"mode":"dx12","failures":"one"}"#,
|
||||||
|
r#"{"mode":"dx12","failures":-1}"#,
|
||||||
|
r#"{"mode":"dx12","failures":4294967296}"#,
|
||||||
] {
|
] {
|
||||||
assert_eq!(parse_state(bad), None, "{bad}");
|
assert_eq!(parse_state(bad), None, "{bad}");
|
||||||
}
|
}
|
||||||
|
let good = attempt_json(Mode::Default, NOW);
|
||||||
|
for (name, text) in [
|
||||||
|
("12-0.json", "{"),
|
||||||
|
("12-0.json", r#"{"mode":"default"}"#),
|
||||||
|
("12-0.json", r#"{"mode":"default","started":-5}"#),
|
||||||
|
("x-0.json", good.as_str()),
|
||||||
|
("12.json", good.as_str()),
|
||||||
|
("-0.json", good.as_str()),
|
||||||
|
("99999999999-0.json", good.as_str()),
|
||||||
|
("12-0.txt", good.as_str()),
|
||||||
|
] {
|
||||||
|
assert_eq!(parse_attempt(name, text), None, "{name} {text}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn temp_dir(name: &str) -> PathBuf {
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
|
|
@ -317,65 +624,198 @@ mod tests {
|
||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn state_of(dir: &Path) -> Option<Saved> {
|
||||||
|
read_small(&dir.join(STATE_FILE)).and_then(|t| parse_state(&t))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attempt_files(dir: &Path) -> usize {
|
||||||
|
std::fs::read_dir(dir.join(ATTEMPTS_DIR)).map_or(0, |d| d.filter_map(Result::ok).count())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A launch as another process (`pid`) at `now`.
|
||||||
|
fn launch(dir: &Path, pid: u32, now: u64) -> GraphicsStart {
|
||||||
|
GraphicsStart::begin_at(Some(dir), false, None, now, pid)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn begin_marks_the_start_pending_and_confirm_clears_it() {
|
fn a_start_that_paints_keeps_its_mode_and_clears_its_attempt() {
|
||||||
let dir = temp_dir("marker");
|
let dir = temp_dir("paint");
|
||||||
let file = dir.join(STATE_FILE);
|
let start = launch(&dir, 10, NOW);
|
||||||
let start = GraphicsStart::begin(Some(&dir), false);
|
|
||||||
assert_eq!(start.mode(), Mode::Default);
|
assert_eq!(start.mode(), Mode::Default);
|
||||||
assert_eq!(read_state(&file), Some((Mode::Default, true)));
|
assert!(!start.degraded());
|
||||||
|
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||||
|
assert_eq!(attempt_files(&dir), 1);
|
||||||
start.confirm();
|
start.confirm();
|
||||||
assert_eq!(read_state(&file), Some((Mode::Default, false)));
|
assert_eq!(attempt_files(&dir), 0);
|
||||||
// a confirmed start is kept on the next launch
|
// a window closed before it painted is no failure either
|
||||||
assert_eq!(GraphicsStart::begin(Some(&dir), false).mode(), Mode::Default);
|
let start = launch(&dir, 11, NOW + 60);
|
||||||
// ... and one that never confirmed moves on (Windows and Linux; macOS has nowhere to go)
|
start.finish();
|
||||||
let next = GraphicsStart::begin(Some(&dir), false);
|
assert_eq!(attempt_files(&dir), 0);
|
||||||
assert_eq!(next.mode(), LADDER.get(1).copied().unwrap_or(Mode::Default));
|
assert_eq!(launch(&dir, 12, NOW + 120).mode(), Mode::Default);
|
||||||
|
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_corrupt_or_huge_marker_starts_with_the_default() {
|
fn two_dead_starts_move_on_and_a_painted_one_resets_the_count() {
|
||||||
|
let dir = temp_dir("dead");
|
||||||
|
let second = LADDER.get(1).copied().unwrap_or(Mode::Default);
|
||||||
|
// dies without painting
|
||||||
|
let _ = launch(&dir, 20, NOW);
|
||||||
|
// the next launch counts it, tries the same mode again and dies too
|
||||||
|
let again = launch(&dir, 21, NOW + 60);
|
||||||
|
assert_eq!(again.mode(), Mode::Default);
|
||||||
|
assert_eq!(state_of(&dir), saved(Mode::Default, 1));
|
||||||
|
// two in a row: the next mode, kept
|
||||||
|
let moved = launch(&dir, 22, NOW + 120);
|
||||||
|
assert_eq!(moved.mode(), second);
|
||||||
|
assert_eq!(moved.degraded(), second != Mode::Default);
|
||||||
|
moved.confirm();
|
||||||
|
assert_eq!(launch(&dir, 23, NOW + 180).mode(), second);
|
||||||
|
// one failure, then a start that paints: the count starts over
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let _ = launch(&dir, 30, NOW);
|
||||||
|
let painted = launch(&dir, 31, NOW + 60);
|
||||||
|
painted.confirm();
|
||||||
|
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||||
|
let _ = launch(&dir, 32, NOW + 120);
|
||||||
|
assert_eq!(launch(&dir, 33, NOW + 180).mode(), Mode::Default);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_launches_neither_count_nor_disturb_each_other() {
|
||||||
|
let dir = temp_dir("race");
|
||||||
|
// two instances start within the same seconds; neither has painted yet
|
||||||
|
let a = launch(&dir, 40, NOW);
|
||||||
|
let b = launch(&dir, 41, NOW + 1);
|
||||||
|
let c = launch(&dir, 42, NOW + 2);
|
||||||
|
assert_eq!((a.mode(), b.mode(), c.mode()), (Mode::Default, Mode::Default, Mode::Default));
|
||||||
|
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||||
|
assert_eq!(attempt_files(&dir), 3, "each instance has its own attempt file");
|
||||||
|
b.confirm();
|
||||||
|
a.confirm();
|
||||||
|
c.confirm();
|
||||||
|
assert_eq!(attempt_files(&dir), 0);
|
||||||
|
assert_eq!(launch(&dir, 43, NOW + 60).mode(), Mode::Default);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_huge_or_foreign_files_start_with_the_default() {
|
||||||
let dir = temp_dir("corrupt");
|
let dir = temp_dir("corrupt");
|
||||||
let file = dir.join(STATE_FILE);
|
let _ = std::fs::create_dir_all(dir.join(ATTEMPTS_DIR));
|
||||||
let _ = std::fs::create_dir_all(&dir);
|
let huge = format!(r#"{{"mode":"software","failures":1,"pad":"{}"}}"#, "x".repeat(10_000));
|
||||||
for text in ["garbage".to_string(), format!(r#"{{"mode":"dx12","pending":true,"pad":"{}"}}"#, "x".repeat(10_000))] {
|
for text in ["garbage", huge.as_str()] {
|
||||||
let _ = std::fs::write(&file, text);
|
let _ = std::fs::write(dir.join(STATE_FILE), text);
|
||||||
let start = GraphicsStart::begin(Some(&dir), false);
|
let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("50-0.json"), text);
|
||||||
|
let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("notes.txt"), text);
|
||||||
|
let start = launch(&dir, 51, NOW);
|
||||||
assert_eq!(start.mode(), Mode::Default);
|
assert_eq!(start.mode(), Mode::Default);
|
||||||
// and it is replaced with a well-formed pending marker
|
// replaced with a well-formed state; the unreadable attempt is gone, other files stay
|
||||||
assert_eq!(read_state(&file), Some((Mode::Default, true)));
|
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||||
|
assert!(!dir.join(ATTEMPTS_DIR).join("50-0.json").exists());
|
||||||
|
assert!(dir.join(ATTEMPTS_DIR).join("notes.txt").exists());
|
||||||
|
start.finish();
|
||||||
}
|
}
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
// a data folder that is a file: nothing can be written, the default still starts
|
||||||
}
|
let file = dir.join("not-a-folder");
|
||||||
|
let _ = std::fs::write(&file, "x");
|
||||||
#[test]
|
let mut start = GraphicsStart::begin_at(Some(&file), false, None, NOW, 52);
|
||||||
fn an_explicit_backend_leaves_the_marker_alone() {
|
|
||||||
let dir = temp_dir("explicit");
|
|
||||||
let file = dir.join(STATE_FILE);
|
|
||||||
let _ = std::fs::create_dir_all(&dir);
|
|
||||||
let _ = write_state(&file, Mode::Software, true);
|
|
||||||
let mut start = GraphicsStart::begin(Some(&dir), true);
|
|
||||||
assert_eq!(start.mode(), Mode::Default);
|
assert_eq!(start.mode(), Mode::Default);
|
||||||
start.confirm();
|
start.confirm();
|
||||||
let _ = start.step_down();
|
let _ = start.step_down();
|
||||||
assert_eq!(read_state(&file), Some((Mode::Software, true)));
|
assert!(start.reset_to_default().is_err());
|
||||||
// no data folder: nothing is written, the default still starts
|
|
||||||
assert_eq!(GraphicsStart::begin(None, false).mode(), Mode::Default);
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn step_down_walks_the_ladder_and_records_each_attempt() {
|
fn many_attempt_files_are_bounded_per_launch() {
|
||||||
|
let dir = temp_dir("many");
|
||||||
|
let attempts = dir.join(ATTEMPTS_DIR);
|
||||||
|
let _ = std::fs::create_dir_all(&attempts);
|
||||||
|
let n = MAX_ATTEMPTS + 10;
|
||||||
|
for pid in 0..n {
|
||||||
|
let _ = std::fs::write(attempts.join(format!("{}-0.json", pid + 1000)), attempt_json(Mode::Software, NOW + 1));
|
||||||
|
}
|
||||||
|
// all of them belong to instances still starting: none is counted or removed
|
||||||
|
let start = launch(&dir, 60, NOW);
|
||||||
|
assert_eq!(start.mode(), Mode::Default);
|
||||||
|
assert_eq!(attempt_files(&dir), n + 1);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn graphics_flag_picks_the_mode_and_default_undoes_a_fallback() {
|
||||||
|
let dir = temp_dir("forced");
|
||||||
|
let last = LADDER.last().copied().unwrap_or(Mode::Default);
|
||||||
|
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 1 }));
|
||||||
|
launch(&dir, 70, NOW).confirm();
|
||||||
|
// `--graphics default`
|
||||||
|
let start = GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Default), NOW + 60, 71);
|
||||||
|
assert_eq!(start.mode(), Mode::Default);
|
||||||
|
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||||
|
start.confirm();
|
||||||
|
assert_eq!(launch(&dir, 72, NOW + 120).mode(), Mode::Default);
|
||||||
|
// `--graphics <the last mode>` is kept for the next launches
|
||||||
|
GraphicsStart::begin_at(Some(&dir), false, Some(last), NOW + 180, 73).confirm();
|
||||||
|
assert_eq!(launch(&dir, 74, NOW + 240).mode(), last);
|
||||||
|
// a mode this platform has not starts with the first one
|
||||||
|
if !LADDER.contains(&Mode::Dx12) {
|
||||||
|
assert_eq!(GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Dx12), NOW + 300, 75).mode(), Mode::Default);
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_to_default_wins_over_a_later_confirm() {
|
||||||
|
let dir = temp_dir("reset");
|
||||||
|
let last = LADDER.last().copied().unwrap_or(Mode::Default);
|
||||||
|
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 0 }));
|
||||||
|
let start = launch(&dir, 80, NOW);
|
||||||
|
assert_eq!(start.mode(), last);
|
||||||
|
// the notice's button, from a clone of the start (the app holds one)
|
||||||
|
assert_eq!(start.clone().reset_to_default(), Ok(()));
|
||||||
|
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||||
|
start.confirm();
|
||||||
|
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||||
|
assert_eq!(launch(&dir, 81, NOW + 60).mode(), Mode::Default);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_explicit_backend_leaves_the_data_folder_alone() {
|
||||||
|
let dir = temp_dir("explicit");
|
||||||
|
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: Mode::Software, failures: 1 }));
|
||||||
|
let mut start = GraphicsStart::begin_at(Some(&dir), true, None, NOW, 90);
|
||||||
|
assert_eq!(start.mode(), Mode::Default);
|
||||||
|
start.confirm();
|
||||||
|
let _ = start.step_down();
|
||||||
|
assert_eq!(start.reset_to_default(), Ok(()));
|
||||||
|
assert_eq!(state_of(&dir), saved(Mode::Software, 1));
|
||||||
|
assert_eq!(attempt_files(&dir), 0);
|
||||||
|
// with --graphics too, that mode starts, still without touching the folder
|
||||||
|
let last = LADDER.last().copied().unwrap_or(Mode::Default);
|
||||||
|
assert_eq!(GraphicsStart::begin_at(Some(&dir), true, Some(last), NOW, 91).mode(), last);
|
||||||
|
assert_eq!(state_of(&dir), saved(Mode::Software, 1));
|
||||||
|
// no data folder: nothing is written, the default still starts
|
||||||
|
assert_eq!(GraphicsStart::begin_at(None, false, None, NOW, 92).mode(), Mode::Default);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_down_walks_the_ladder_and_saves_each_mode_at_once() {
|
||||||
let dir = temp_dir("step");
|
let dir = temp_dir("step");
|
||||||
let file = dir.join(STATE_FILE);
|
let mut start = launch(&dir, ME, NOW);
|
||||||
let mut start = GraphicsStart::begin(Some(&dir), false);
|
|
||||||
for expected in LADDER.iter().skip(1) {
|
for expected in LADDER.iter().skip(1) {
|
||||||
assert!(start.step_down());
|
assert!(start.step_down());
|
||||||
assert_eq!(start.mode(), *expected);
|
assert_eq!(start.mode(), *expected);
|
||||||
assert_eq!(read_state(&file), Some((*expected, true)));
|
assert_eq!(state_of(&dir), saved(*expected, 0));
|
||||||
|
assert_eq!(attempt_files(&dir), 1, "one attempt file per process");
|
||||||
}
|
}
|
||||||
assert!(!start.step_down(), "nothing after the last mode");
|
assert!(!start.step_down(), "nothing after the last mode");
|
||||||
|
start.finish();
|
||||||
|
assert_eq!(attempt_files(&dir), 0);
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,10 +120,11 @@ fn main() -> eframe::Result {
|
||||||
// OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸
|
// OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸
|
||||||
// Hardware decoding switches them off. Unsupported streams and failures use our decoders.
|
// Hardware decoding switches them off. Unsupported streams and failures use our decoders.
|
||||||
register_hardware_decoders();
|
register_hardware_decoders();
|
||||||
// Which graphics backends and adapter the window starts with: the mode after the last one
|
// Which graphics backends and adapter the window starts with: `--graphics`, else the saved
|
||||||
// if the previous start died before its window painted (a driver crash), see graphics.rs.
|
// mode, which moves on after starts that died before their window painted (a driver crash),
|
||||||
|
// see graphics.rs.
|
||||||
let backend_override = eframe::wgpu::Backends::from_env();
|
let backend_override = eframe::wgpu::Backends::from_env();
|
||||||
let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some());
|
let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some(), launch.graphics);
|
||||||
// The GPU export renderer creates its own wgpu instance with every backend; after a start
|
// The GPU export renderer creates its own wgpu instance with every backend; after a start
|
||||||
// fell back, that would load the driver that failed. Exports then composite on the CPU.
|
// fell back, that would load the driver that failed. Exports then composite on the CPU.
|
||||||
if graphics.mode() == graphics::Mode::Default {
|
if graphics.mode() == graphics::Mode::Default {
|
||||||
|
|
@ -149,6 +150,8 @@ fn main() -> eframe::Result {
|
||||||
}
|
}
|
||||||
break result;
|
break result;
|
||||||
};
|
};
|
||||||
|
// However it ended, this start no longer counts as one that died.
|
||||||
|
graphics.finish();
|
||||||
// The window could not be created (no usable GPU / graphics driver, no display): say so
|
// The window could not be created (no usable GPU / graphics driver, no display): say so
|
||||||
// instead of exiting silently, and keep the reason in the crash log.
|
// instead of exiting silently, and keep the reason in the crash log.
|
||||||
if let Err(e) = &started {
|
if let Err(e) = &started {
|
||||||
|
|
@ -184,9 +187,14 @@ fn native_options(agent: bool) -> eframe::NativeOptions {
|
||||||
/// Creates the app once eframe has its window and graphics device; sets `created` when it runs,
|
/// Creates the app once eframe has its window and graphics device; sets `created` when it runs,
|
||||||
/// and confirms the graphics mode once two frames have painted.
|
/// and confirms the graphics mode once two frames have painted.
|
||||||
fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<AtomicBool>) -> eframe::AppCreator<'static> {
|
fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<AtomicBool>) -> eframe::AppCreator<'static> {
|
||||||
let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir } = launch;
|
let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir, graphics: _ } = launch;
|
||||||
Box::new(move |cc| {
|
Box::new(move |cc| {
|
||||||
created.store(true, Ordering::Relaxed);
|
created.store(true, Ordering::Relaxed);
|
||||||
|
// A fallback graphics mode says so in the window, with a button back to the default.
|
||||||
|
let notice = graphics.degraded().then(|| {
|
||||||
|
let start = graphics.clone();
|
||||||
|
filmcraft_ui_egui::graphics_notice::GraphicsNotice::new(graphics.mode().label(), Box::new(move || start.reset_to_default()))
|
||||||
|
});
|
||||||
let confirmed = AtomicBool::new(false);
|
let confirmed = AtomicBool::new(false);
|
||||||
cc.egui_ctx.on_end_pass(
|
cc.egui_ctx.on_end_pass(
|
||||||
"graphics-start",
|
"graphics-start",
|
||||||
|
|
@ -240,6 +248,7 @@ fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<A
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut app = FilmcraftApp::new(session);
|
let mut app = FilmcraftApp::new(session);
|
||||||
|
app.graphics_notice = notice;
|
||||||
if recover == Some(false) {
|
if recover == Some(false) {
|
||||||
app.dialog = None;
|
app.dialog = None;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
//! The notice shown while the window runs in a fallback graphics mode (#820): the desktop app
|
||||||
|
//! moved on to DirectX 12 only or a software adapter after earlier starts died in the graphics
|
||||||
|
//! driver. It says so and offers a one-click way back to the default mode for the next start.
|
||||||
|
//!
|
||||||
|
//! Automation ids: `graphics.notice` (the notice), `graphics.notice.reset` (Use Default Graphics),
|
||||||
|
//! `graphics.notice.close`.
|
||||||
|
|
||||||
|
use crate::{FilmcraftApp, i18n};
|
||||||
|
|
||||||
|
/// Puts the default graphics mode back for the next start; the error says what could not be saved.
|
||||||
|
pub type ResetFn = Box<dyn FnMut() -> Result<(), String>>;
|
||||||
|
|
||||||
|
/// A fallback graphics mode, as the host reports it.
|
||||||
|
pub struct GraphicsNotice {
|
||||||
|
/// The mode's English name ("DirectX 12 only", "software rendering"), translated when shown.
|
||||||
|
pub mode: String,
|
||||||
|
reset: ResetFn,
|
||||||
|
/// The default mode is saved for the next start.
|
||||||
|
pub reset_done: bool,
|
||||||
|
/// Closed for this session.
|
||||||
|
pub closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GraphicsNotice {
|
||||||
|
pub fn new(mode: &str, reset: ResetFn) -> Self {
|
||||||
|
Self { mode: mode.to_string(), reset, reset_done: false, closed: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use Default Graphics: save the default mode for the next start.
|
||||||
|
pub fn reset(&mut self) -> Result<(), String> {
|
||||||
|
(self.reset)()?;
|
||||||
|
self.reset_done = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the notice (bottom right, above the status bar) until it is closed.
|
||||||
|
pub(crate) fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||||
|
let Some(mut notice) = app.graphics_notice.take() else { return };
|
||||||
|
if notice.closed {
|
||||||
|
app.graphics_notice = Some(notice);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let title = tlf!("Safe graphics mode: {mode}", mode = i18n::t(¬ice.mode));
|
||||||
|
let (mut reset, mut close) = (false, false);
|
||||||
|
let shown = egui::Window::new("graphics-notice")
|
||||||
|
.id(egui::Id::new("graphics-notice"))
|
||||||
|
.title_bar(false)
|
||||||
|
.collapsible(false)
|
||||||
|
.resizable(false)
|
||||||
|
.anchor(egui::Align2::RIGHT_BOTTOM, [-12.0, -32.0])
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.set_max_width(360.0);
|
||||||
|
ui.label(egui::RichText::new(&title).strong());
|
||||||
|
ui.add_space(4.0);
|
||||||
|
if notice.reset_done {
|
||||||
|
ui.label(tl!("The default graphics are used from the next start."));
|
||||||
|
} else {
|
||||||
|
ui.label(tl!("An earlier start failed with the default graphics. Updating the graphics driver usually fixes this."));
|
||||||
|
}
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if !notice.reset_done {
|
||||||
|
let label = tl!("Use Default Graphics");
|
||||||
|
let r = ui.button(label);
|
||||||
|
app.auto.add_response("graphics.notice.reset", &r, label);
|
||||||
|
reset = r.clicked();
|
||||||
|
}
|
||||||
|
let label = tl!("Close");
|
||||||
|
let c = ui.button(label);
|
||||||
|
app.auto.add_response("graphics.notice.close", &c, label);
|
||||||
|
close = c.clicked();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if let Some(shown) = shown {
|
||||||
|
app.auto.add("graphics.notice", shown.response.rect, &title);
|
||||||
|
}
|
||||||
|
if reset && let Err(e) = notice.reset() {
|
||||||
|
app.ui.status = e;
|
||||||
|
}
|
||||||
|
notice.closed |= close;
|
||||||
|
app.graphics_notice = Some(notice);
|
||||||
|
}
|
||||||
|
|
@ -3562,3 +3562,9 @@
|
||||||
Edit Project Notes Editar notas del proyecto
|
Edit Project Notes Editar notas del proyecto
|
||||||
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas de este proyecto: tareas pendientes, instrucciones, números de fotograma… Se guardan con el proyecto.
|
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas de este proyecto: tareas pendientes, instrucciones, números de fotograma… Se guardan con el proyecto.
|
||||||
Use Effect Shutter Angle Usar el ángulo de obturación del efecto
|
Use Effect Shutter Angle Usar el ángulo de obturación del efecto
|
||||||
|
Safe graphics mode: {mode} Modo gráfico seguro: {mode}
|
||||||
|
An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. Un inicio anterior falló con los gráficos predeterminados. Actualizar el controlador gráfico suele solucionarlo.
|
||||||
|
Use Default Graphics Usar gráficos predeterminados
|
||||||
|
The default graphics are used from the next start. Los gráficos predeterminados se usarán a partir del próximo inicio.
|
||||||
|
DirectX 12 only Solo DirectX 12
|
||||||
|
software rendering renderizado por software
|
||||||
|
|
|
||||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
|
|
@ -3545,3 +3545,9 @@
|
||||||
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notes pour ce projet : tâches, instructions, numéros d'image… Enregistrées avec le projet.
|
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notes pour ce projet : tâches, instructions, numéros d'image… Enregistrées avec le projet.
|
||||||
Use Effect Shutter Angle Utiliser l'angle d'obturation de l'effet
|
Use Effect Shutter Angle Utiliser l'angle d'obturation de l'effet
|
||||||
Settings Préférences
|
Settings Préférences
|
||||||
|
Safe graphics mode: {mode} Mode graphique sans échec : {mode}
|
||||||
|
An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. Un démarrage précédent a échoué avec les graphismes par défaut. Mettre à jour le pilote graphique résout généralement le problème.
|
||||||
|
Use Default Graphics Utiliser les graphismes par défaut
|
||||||
|
The default graphics are used from the next start. Les graphismes par défaut seront utilisés au prochain démarrage.
|
||||||
|
DirectX 12 only DirectX 12 uniquement
|
||||||
|
software rendering rendu logiciel
|
||||||
|
|
|
||||||
|
Can't render this file because it contains an unexpected character in line 5 and column 83.
|
|
|
@ -3544,3 +3544,9 @@
|
||||||
Edit Project Notes プロジェクトメモを編集
|
Edit Project Notes プロジェクトメモを編集
|
||||||
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. このプロジェクトのメモ: やること、指示、フレーム番号など… プロジェクトと一緒に保存されます。
|
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. このプロジェクトのメモ: やること、指示、フレーム番号など… プロジェクトと一緒に保存されます。
|
||||||
GPU acceleration is unavailable; preview renders on the CPU GPU アクセラレーションを利用できません。プレビューは CPU でレンダリングされます
|
GPU acceleration is unavailable; preview renders on the CPU GPU アクセラレーションを利用できません。プレビューは CPU でレンダリングされます
|
||||||
|
Safe graphics mode: {mode} セーフグラフィックモード: {mode}
|
||||||
|
An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. 以前の起動がデフォルトのグラフィックで失敗しました。グラフィックドライバーを更新すると、通常は解決します。
|
||||||
|
Use Default Graphics デフォルトのグラフィックを使用
|
||||||
|
The default graphics are used from the next start. 次回の起動からデフォルトのグラフィックが使用されます。
|
||||||
|
DirectX 12 only DirectX 12 のみ
|
||||||
|
software rendering ソフトウェアレンダリング
|
||||||
|
|
|
||||||
|
Can't render this file because it contains an unexpected character in line 11 and column 59.
|
|
|
@ -3540,3 +3540,9 @@
|
||||||
Delete Caption Track 删除字幕轨道
|
Delete Caption Track 删除字幕轨道
|
||||||
Delete Caption Tracks 删除多个字幕轨道
|
Delete Caption Tracks 删除多个字幕轨道
|
||||||
Delete Empty Caption Tracks 删除空字幕轨道
|
Delete Empty Caption Tracks 删除空字幕轨道
|
||||||
|
Safe graphics mode: {mode} 安全图形模式:{mode}
|
||||||
|
An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. 上次启动时默认图形模式失败。更新显卡驱动程序通常可以解决此问题。
|
||||||
|
Use Default Graphics 使用默认图形
|
||||||
|
The default graphics are used from the next start. 下次启动时将使用默认图形。
|
||||||
|
DirectX 12 only 仅 DirectX 12
|
||||||
|
software rendering 软件渲染
|
||||||
|
|
|
||||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
|
|
@ -39,6 +39,7 @@ pub mod dock;
|
||||||
mod dock_shared;
|
mod dock_shared;
|
||||||
mod dock_ui;
|
mod dock_ui;
|
||||||
pub mod frames;
|
pub mod frames;
|
||||||
|
pub mod graphics_notice;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
pub mod icons;
|
pub mod icons;
|
||||||
|
|
@ -302,6 +303,9 @@ pub struct FilmcraftApp {
|
||||||
styled: bool,
|
styled: bool,
|
||||||
/// A panic in the UI pass, shown in an error window until dismissed (see [`crash`]).
|
/// A panic in the UI pass, shown in an error window until dismissed (see [`crash`]).
|
||||||
pub ui_error: Option<String>,
|
pub ui_error: Option<String>,
|
||||||
|
/// The window runs in a fallback graphics mode (set by the desktop host): a notice with a
|
||||||
|
/// button back to the default mode (see [`graphics_notice`]).
|
||||||
|
pub graphics_notice: Option<graphics_notice::GraphicsNotice>,
|
||||||
/// Fault injection for robustness tests: the next UI pass panics.
|
/// Fault injection for robustness tests: the next UI pass panics.
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub panic_next_frame: bool,
|
pub panic_next_frame: bool,
|
||||||
|
|
@ -546,6 +550,7 @@ impl FilmcraftApp {
|
||||||
bindings_rev: 0,
|
bindings_rev: 0,
|
||||||
shortcut_editor: Default::default(),
|
shortcut_editor: Default::default(),
|
||||||
toast: None,
|
toast: None,
|
||||||
|
graphics_notice: None,
|
||||||
tl: Default::default(),
|
tl: Default::default(),
|
||||||
command_inbox: None,
|
command_inbox: None,
|
||||||
v_down: false,
|
v_down: false,
|
||||||
|
|
@ -1935,6 +1940,10 @@ impl eframe::App for FilmcraftApp {
|
||||||
self.playback.playing = false;
|
self.playback.playing = false;
|
||||||
}
|
}
|
||||||
self.error_window(ui.ctx());
|
self.error_window(ui.ctx());
|
||||||
|
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| graphics_notice::show(self, ui.ctx()))).is_err() {
|
||||||
|
// a broken notice must not take the session down: drop it
|
||||||
|
self.graphics_notice = None;
|
||||||
|
}
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
self.last_ui_time = ctx.input(|i| i.time);
|
self.last_ui_time = ctx.input(|i| i.time);
|
||||||
if !self.synthetic.is_empty() {
|
if !self.synthetic.is_empty() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
//! The fallback graphics notice (#820): shown while the desktop app runs in a fallback graphics
|
||||||
|
//! mode, its button saves the default mode for the next start, and it can be closed.
|
||||||
|
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::mpsc::{Sender, channel};
|
||||||
|
|
||||||
|
use egui_kittest::Harness;
|
||||||
|
use filmcraft_engine::Session;
|
||||||
|
use filmcraft_ui_egui::FilmcraftApp;
|
||||||
|
use filmcraft_ui_egui::control::ControlRequest;
|
||||||
|
use filmcraft_ui_egui::graphics_notice::GraphicsNotice;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
struct Driver {
|
||||||
|
harness: Harness<'static, FilmcraftApp>,
|
||||||
|
tx: Sender<ControlRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Driver {
|
||||||
|
fn new(notice: Option<GraphicsNotice>) -> Self {
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
let mut app = FilmcraftApp::new(Session::default()).with_control(rx);
|
||||||
|
app.graphics_notice = notice;
|
||||||
|
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
|
||||||
|
let mut d = Driver { harness, tx };
|
||||||
|
d.frames(4);
|
||||||
|
d
|
||||||
|
}
|
||||||
|
fn frames(&mut self, n: usize) {
|
||||||
|
for _ in 0..n {
|
||||||
|
let ctx = self.harness.ctx.clone();
|
||||||
|
let mut raw = std::mem::take(self.harness.input_mut());
|
||||||
|
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
|
||||||
|
*self.harness.input_mut() = raw;
|
||||||
|
self.harness.step();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn call(&mut self, method: &str, params: Value) -> Value {
|
||||||
|
let (req, reply) = ControlRequest::new(method, params.clone());
|
||||||
|
self.tx.send(req).unwrap();
|
||||||
|
for _ in 0..600 {
|
||||||
|
self.frames(1);
|
||||||
|
if let Ok(v) = reply.try_recv() {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("no reply to {method} {params}");
|
||||||
|
}
|
||||||
|
fn ids(&mut self) -> Vec<String> {
|
||||||
|
let v = self.call("ui.elements", json!({"prefix": "graphics.notice"}));
|
||||||
|
assert_eq!(v["ok"], json!(true), "{v}");
|
||||||
|
v["result"].as_array().unwrap().iter().map(|e| e["id"].as_str().unwrap().to_string()).collect()
|
||||||
|
}
|
||||||
|
fn label(&mut self, id: &str) -> String {
|
||||||
|
let v = self.call("ui.elements", json!({"prefix": id}));
|
||||||
|
v["result"].as_array().unwrap().iter().find(|e| e["id"] == id).map(|e| e["label"].as_str().unwrap().to_string()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
fn click(&mut self, id: &str) {
|
||||||
|
let v = self.call("ui.click", json!({"id": id}));
|
||||||
|
assert_eq!(v["ok"], json!(true), "{id}: {v}");
|
||||||
|
self.frames(3);
|
||||||
|
}
|
||||||
|
fn app(&mut self) -> &mut FilmcraftApp {
|
||||||
|
self.harness.state_mut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_notice_in_the_default_graphics_mode() {
|
||||||
|
let mut d = Driver::new(None);
|
||||||
|
assert!(d.ids().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_reset_button_saves_the_default_mode_once_and_close_hides_the_notice() {
|
||||||
|
let resets = Rc::new(Cell::new(0));
|
||||||
|
let counter = Rc::clone(&resets);
|
||||||
|
let notice = GraphicsNotice::new(
|
||||||
|
"software rendering",
|
||||||
|
Box::new(move || {
|
||||||
|
counter.set(counter.get() + 1);
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let mut d = Driver::new(Some(notice));
|
||||||
|
let ids = d.ids();
|
||||||
|
for id in ["graphics.notice", "graphics.notice.reset", "graphics.notice.close"] {
|
||||||
|
assert!(ids.iter().any(|i| i == id), "{id} in {ids:?}");
|
||||||
|
}
|
||||||
|
assert!(d.label("graphics.notice").contains("software rendering"));
|
||||||
|
d.click("graphics.notice.reset");
|
||||||
|
assert_eq!(resets.get(), 1);
|
||||||
|
assert!(d.app().graphics_notice.as_ref().is_some_and(|n| n.reset_done));
|
||||||
|
// done: the button is gone, the notice says when it takes effect
|
||||||
|
let ids = d.ids();
|
||||||
|
assert!(!ids.iter().any(|i| i == "graphics.notice.reset"), "{ids:?}");
|
||||||
|
assert!(ids.iter().any(|i| i == "graphics.notice"));
|
||||||
|
d.click("graphics.notice.close");
|
||||||
|
assert!(d.ids().is_empty());
|
||||||
|
assert_eq!(resets.get(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reset_that_cannot_be_saved_says_why_and_keeps_the_button() {
|
||||||
|
let notice = GraphicsNotice::new("DirectX 12 only", Box::new(|| Err("can't write graphics-start.json: read-only".into())));
|
||||||
|
let mut d = Driver::new(Some(notice));
|
||||||
|
d.click("graphics.notice.reset");
|
||||||
|
assert!(d.app().ui.status.contains("read-only"), "{}", d.app().ui.status);
|
||||||
|
assert!(d.app().graphics_notice.as_ref().is_some_and(|n| !n.reset_done));
|
||||||
|
assert!(d.ids().iter().any(|i| i == "graphics.notice.reset"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_notice_follows_the_interface_language() {
|
||||||
|
let notice = GraphicsNotice::new("software rendering", Box::new(|| Ok(())));
|
||||||
|
let mut d = Driver::new(Some(notice));
|
||||||
|
d.app().ui.language = filmcraft_ui_egui::i18n::Language::Es;
|
||||||
|
d.frames(3);
|
||||||
|
assert_eq!(d.label("graphics.notice"), "Modo gráfico seguro: renderizado por software");
|
||||||
|
assert_eq!(d.label("graphics.notice.reset"), "Usar gráficos predeterminados");
|
||||||
|
filmcraft_ui_egui::i18n::set_current(filmcraft_ui_egui::i18n::Language::En);
|
||||||
|
}
|
||||||
15
docs/gaps.md
15
docs/gaps.md
|
|
@ -79,11 +79,13 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
|
||||||
|
|
||||||
## G4. Crashes at start-up, and no test CI on pull requests
|
## G4. Crashes at start-up, and no test CI on pull requests
|
||||||
|
|
||||||
- **Have:** a graphics start-up fallback (#820): a start that dies before its window paints (a
|
- **Have:** a graphics start-up fallback (#820): after two starts in a row that died within 15 s
|
||||||
driver crash in wgpu instance / device creation) leaves a marker in the data folder, and the next
|
of launch without painting (a driver crash in wgpu instance / device creation; hangs, later
|
||||||
launch moves on to DirectX 12 only (Windows), then a software adapter (WARP, lavapipe, llvmpipe)
|
crashes and concurrent instances don't count), the next launch moves on to DirectX 12 only
|
||||||
with CPU compositing, and keeps the mode that works; a start-up error wgpu reports moves on in
|
(Windows), then a software adapter (WARP, lavapipe, llvmpipe) with CPU compositing, and keeps
|
||||||
the same process. An explicit `WGPU_BACKEND` stays in charge.
|
the mode that works; a start-up error wgpu reports moves on in the same process. A fallback mode
|
||||||
|
shows a notice with Use Default Graphics; `--graphics default|dx12|software` picks a mode by
|
||||||
|
hand. An explicit `WGPU_BACKEND` stays in charge.
|
||||||
- **Missing:** a launch that never fails, and CI that runs the gates.
|
- **Missing:** a launch that never fails, and CI that runs the gates.
|
||||||
- **Evidence:** open issues: macOS 12 Monterey aborts on a missing VideoToolbox symbol (#468,
|
- **Evidence:** open issues: macOS 12 Monterey aborts on a missing VideoToolbox symbol (#468,
|
||||||
#661, #655), Intel UHD graphics (`igvk64.dll`, #512), Windows 11 does not run or fails after setup
|
#661, #655), Intel UHD graphics (`igvk64.dll`, #512), Windows 11 does not run or fails after setup
|
||||||
|
|
@ -93,8 +95,7 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
|
||||||
contributors' local runs (main needed "green again after the batch merges" on 10-10).
|
contributors' local runs (main needed "green again after the batch merges" on 10-10).
|
||||||
- **Impact:** a user who cannot start the app gets zero value; regressions land unnoticed.
|
- **Impact:** a user who cannot start the app gets zero value; regressions land unnoticed.
|
||||||
- **Done when:** weak-linked or runtime-loaded OS symbols on old macOS, ~~a software / safe-mode
|
- **Done when:** weak-linked or runtime-loaded OS symbols on old macOS, ~~a software / safe-mode
|
||||||
start when the GPU adapter fails~~ (done, #820; a way back to the default mode from Settings is
|
start when the GPU adapter fails~~ (done, #820), a PR workflow running `cargo xtask ci`
|
||||||
still missing: today the marker file is deleted by hand), a PR workflow running `cargo xtask ci`
|
|
||||||
on macOS, Windows and Linux, and a crash-report triage pass. **40–80 h.**
|
on macOS, Windows and Linux, and a crash-report triage pass. **40–80 h.**
|
||||||
|
|
||||||
## G5. Hardware acceleration is partial
|
## G5. Hardware acceleration is partial
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue