forked from 1b-agent/opencadstudio-upstream
A day of pan/zoom that had suddenly become unusable on a 1.2M-entity drawing turned out to be no regression at all: an unattended NVIDIA userspace update had been applied without a reboot, the Vulkan ICD could no longer talk to the still-loaded kernel module, and wgpu silently fell through to Mesa's llvmpipe. Nothing in the app said so. iced picks the adapter and names it only under RUST_LOG, so the only evidence was 32 `llvmpipe-N` threads in /proc. The same silence covers WARP on Windows, SwiftShader in browsers, and the case where wgpu finds no adapter at all and iced quietly draws the chrome with tiny-skia while the shader widget is dropped, leaving a blank viewport. This commit makes all of that visible, on every platform, without new dependencies. Detection (src/scene/pipeline/mod.rs) ------------------------------------- `GpuStatus` is derived from two signals: * `record_gpu_adapter`, run from `MultiPipeline::new` (once per device, next to the existing uncaptured-error handler), reads `Device::adapter_info()`, prints one `[gpu] adapter: <name> (<backend>, <device type>, driver …)` line — stderr natively, `console.warn` on the web, which has no stderr — and keeps a copy. * `note_scene_draw`, run from `ViewportPane::draw`, counts how often the viewport asked for a primitive. Every renderer makes that call; only wgpu goes on to `prepare` and create a device. Three draws with no device ever recorded therefore mean the primitive is being dropped. The verdict is one of `Unknown`, `Hardware`, `Software(adapter)` or `NoRenderer`. `Software` is `DeviceType::Cpu` — what Vulkan reports for llvmpipe, DX12 for WARP and GL for a renderer string that gives it away — plus a name match on the fallbacks browsers expose (SwiftShader, llvmpipe, lavapipe, "Microsoft Basic Render"), because the WebGPU backend reports every adapter as `Other` (gfx-rs/wgpu#8819). Metal has no software device, so on macOS only `NoRenderer` can occur (a VM without a GPU); it is caught by the draw count like everywhere else. `gpu_status_if_changed` costs one atomic load per app message: a generation counter moves only when a device is recorded or the draw count crosses its threshold, so the app re-derives the verdict a handful of times per session. Notification (src/app/startup.rs, view/modal.rs, ui/statusbar) ------------------------------------------------------------- `refresh_gpu_status` runs after every message. On a degraded verdict it speaks through three channels, because many users never read the command line: * A popup, `ModalKind::GpuWarning`, queued once per verdict through the startup-modal queue so it waits its turn behind the file open. It names the adapter, states the consequence (slow navigation, unstable or missing rendering) and gives the remedy that usually applies on the running platform: a driver update not yet followed by a reboot on Linux, a missing driver or a Remote Desktop / VM session on Windows, a VM without a Metal device on macOS, hardware acceleration switched off in the browser. "Don't show again for this device" stores `GpuStatus::identity()` in `settings.gpu_warning_silenced`; keying it by verdict means silencing "software rendering on llvmpipe" does not silence a later, different failure. * A status-bar pill in the theme's warning colours, `⚠ Software rendering` or `⚠ No GPU renderer`. It is not a `StatusPill`, so it cannot be hidden, and it stays for the session; clicking it reopens the popup, silenced or not. * A command-line entry through the new `CommandLine::push_warning`, which is styled like an error but deliberately does not touch `last_error` / `error_revision`: automation reads those to decide whether the command it just ran failed, and a graphics warning is not a failed command. `subscription()` requests frames only while the verdict is `Unknown` on a document tab (the Start page draws no viewport), so the probe costs one frame on a working GPU, three on none, then stops. Verification ------------ * Live on the machine that had the incident, with the Vulkan loader restricted to Mesa (`VK_DRIVER_FILES=…/lvp_icd.json WGPU_BACKEND=vulkan`): popup → OK → pill → click reopens; "Don't show again" → restart → pill only, no popup. * `gpu_tests::the_adapter_is_reported_once_per_device` (ignored, needs an adapter) asserts the classification on a real device; it was run on both llvmpipe (`Cpu`) and an NVIDIA GT 1030 (`DiscreteGpu`). * Unit tests cover the name heuristic, the verdict function, the identity rule, the warning text with an adapter name that itself contains parentheses and commas, the settings round trip and the command-line semantics. New strings are in all 21 locales as the `gpu` Fluent group. Co-Authored-By: Claude Opus 5 <[email protected]>
222 lines
9.8 KiB
Rust
222 lines
9.8 KiB
Rust
use super::{ModalKind, OpenCADStudio};
|
|
use crate::scene::pipeline::{GpuAdapter, GpuStatus};
|
|
|
|
impl OpenCADStudio {
|
|
pub(super) fn queue_startup_prompts(&mut self) {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if !self.default_assoc_prompted {
|
|
self.pending_startup_modals
|
|
.push_back(ModalKind::AssocPrompt);
|
|
}
|
|
if self.donation_prompt_version != env!("OCS_APP_VERSION") {
|
|
self.pending_startup_modals
|
|
.push_back(ModalKind::DonationPrompt);
|
|
}
|
|
self.show_next_startup_modal();
|
|
}
|
|
|
|
pub(super) fn show_next_startup_modal(&mut self) {
|
|
if self.active_modal.is_none() && self.opening.is_none() && self.pending_opens.is_empty() {
|
|
if let Some(&kind) = self.pending_startup_modals.front() {
|
|
self.active_modal = Some(kind);
|
|
self.reset_modal_geometry();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Notice when the scene is drawn by a software rasterizer, or not at
|
|
/// all, and say so everywhere the user might look: the command line,
|
|
/// the log, a one-time popup, and a status-bar pill that stays.
|
|
///
|
|
/// Runs after every message; the pipeline records the adapter when iced
|
|
/// first builds a device and counts the viewport's draws in the meantime,
|
|
/// so the verdict lands on the first message after the first frame
|
|
/// (`subscription` keeps frames ticking until it does). A working GPU
|
|
/// gets no word here — the `[gpu] adapter:` log line already names it.
|
|
pub(super) fn refresh_gpu_status(&mut self) {
|
|
let Some(status) =
|
|
crate::scene::pipeline::gpu_status_if_changed(&mut self.gpu_status_generation)
|
|
else {
|
|
return;
|
|
};
|
|
if status == self.gpu_status {
|
|
return;
|
|
}
|
|
self.gpu_status = status;
|
|
let Some(identity) = self.gpu_status.identity() else {
|
|
return;
|
|
};
|
|
match &self.gpu_status {
|
|
GpuStatus::Software(adapter) => {
|
|
self.command_line.push_warning(&software_rasterizer_warning(adapter));
|
|
}
|
|
GpuStatus::NoRenderer => {
|
|
crate::scene::pipeline::report_gpu_line(
|
|
"[gpu] no adapter: wgpu found nothing to draw with; the interface is on \
|
|
iced's software renderer and the scene is not drawn at all",
|
|
);
|
|
self.command_line
|
|
.push_warning(&crate::tr!("gpu", "command-line-no-renderer"));
|
|
}
|
|
GpuStatus::Unknown | GpuStatus::Hardware(_) => {}
|
|
}
|
|
// The popup: once per verdict, unless the user silenced this one.
|
|
let already_queued = self.active_modal == Some(ModalKind::GpuWarning)
|
|
|| self.pending_startup_modals.contains(&ModalKind::GpuWarning);
|
|
if self.gpu_warning_silenced != identity && !already_queued {
|
|
self.pending_startup_modals.push_back(ModalKind::GpuWarning);
|
|
self.show_next_startup_modal();
|
|
}
|
|
}
|
|
|
|
pub(super) fn mark_startup_modal_shown(&mut self) {
|
|
if self.active_modal.is_some()
|
|
&& self.active_modal.as_ref() == self.pending_startup_modals.front()
|
|
{
|
|
self.pending_startup_modals.pop_front();
|
|
}
|
|
if self.active_modal == Some(ModalKind::DonationPrompt)
|
|
&& self.donation_prompt_version != env!("OCS_APP_VERSION")
|
|
{
|
|
self.donation_prompt_version = env!("OCS_APP_VERSION").to_string();
|
|
self.save_config();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The command-line warning for a software rasterizer, naming the adapter so
|
|
/// the user can tell llvmpipe from SwiftShader from WARP.
|
|
pub(super) fn software_rasterizer_warning(adapter: &GpuAdapter) -> String {
|
|
let adapter = &adapter.name;
|
|
crate::tf!("GPU unavailable — rendering in software on the CPU ({adapter}). Large drawings will navigate slowly; check the graphics driver (a driver update without a reboot is the usual cause).")
|
|
.into_owned()
|
|
}
|
|
|
|
/// What usually puts a machine in this state, per platform. The cause is
|
|
/// almost always outside the app, and it differs: on Linux it is a driver
|
|
/// updated under a running kernel module, on Windows a driver that is
|
|
/// missing or a session (Remote Desktop, a VM) that has no GPU to offer, on
|
|
/// macOS a VM without a Metal device, in a browser hardware acceleration
|
|
/// switched off.
|
|
pub(crate) fn gpu_platform_hint() -> String {
|
|
if cfg!(target_arch = "wasm32") {
|
|
crate::tr!("gpu", "hint-web")
|
|
} else if cfg!(target_os = "windows") {
|
|
crate::tr!("gpu", "hint-windows")
|
|
} else if cfg!(target_os = "macos") {
|
|
crate::tr!("gpu", "hint-macos")
|
|
} else {
|
|
crate::tr!("gpu", "hint-linux")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::app::{config::AppConfig, Message};
|
|
use iced::wgpu;
|
|
|
|
#[test]
|
|
fn the_software_warning_names_the_adapter_and_every_platform_has_a_hint() {
|
|
let cpu = GpuAdapter {
|
|
name: "llvmpipe (LLVM 20.1.8, 256 bits)".to_string(),
|
|
backend: wgpu::Backend::Vulkan,
|
|
device_type: wgpu::DeviceType::Cpu,
|
|
};
|
|
let warning = software_rasterizer_warning(&cpu);
|
|
// The name carries parentheses and commas of its own; the localized
|
|
// template must still place it whole. The loader follows the system
|
|
// locale here, so the assertions stay language-neutral.
|
|
assert!(warning.contains("(llvmpipe (LLVM 20.1.8, 256 bits))"), "{warning}");
|
|
assert!(warning.contains("GPU"), "{warning}");
|
|
assert!(!warning.contains("{adapter}") && !warning.contains("__ocs_"), "{warning}");
|
|
// Every platform has a hint, and it is a sentence, not a missing key.
|
|
let hint = gpu_platform_hint();
|
|
assert!(hint.len() > 20 && !hint.contains("hint-"), "{hint}");
|
|
}
|
|
|
|
#[test]
|
|
fn silencing_the_popup_is_per_verdict_and_survives_a_settings_round_trip() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.gpu_status = GpuStatus::NoRenderer;
|
|
let _ = app.update(Message::GpuWarningSilence);
|
|
assert_eq!(app.gpu_warning_silenced, "no-renderer");
|
|
assert!(app.active_modal.is_none());
|
|
|
|
let saved = serde_json::to_string(&app.current_config()).unwrap();
|
|
let mut restarted = OpenCADStudio::new_for_test();
|
|
restarted.apply_config(serde_json::from_str(&saved).unwrap());
|
|
assert_eq!(restarted.gpu_warning_silenced, "no-renderer");
|
|
// A different verdict is not covered by that choice.
|
|
let software = GpuStatus::Software(GpuAdapter {
|
|
name: "llvmpipe".to_string(),
|
|
backend: wgpu::Backend::Vulkan,
|
|
device_type: wgpu::DeviceType::Cpu,
|
|
});
|
|
assert_ne!(software.identity().unwrap(), restarted.gpu_warning_silenced);
|
|
}
|
|
|
|
#[test]
|
|
fn donation_prompt_persists_once_per_version() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
// Older settings have no donation version; headless construction never prompts.
|
|
app.apply_config(serde_json::from_str::<AppConfig>("{}").unwrap());
|
|
assert!(app.active_modal.is_none());
|
|
assert!(app.pending_startup_modals.is_empty());
|
|
app.default_assoc_prompted = true;
|
|
app.queue_startup_prompts();
|
|
assert_eq!(app.active_modal, Some(ModalKind::DonationPrompt));
|
|
assert!(app.donation_prompt_version.is_empty());
|
|
|
|
let _ = app.update(Message::ModalContentResized(iced::Size::new(540.0, 280.0)));
|
|
let saved = serde_json::to_string(&app.current_config()).unwrap();
|
|
let _ = app.update(Message::CloseModal);
|
|
assert!(app.active_modal.is_none());
|
|
|
|
let mut restarted = OpenCADStudio::new_for_test();
|
|
restarted.apply_config(serde_json::from_str(&saved).unwrap());
|
|
restarted.queue_startup_prompts();
|
|
assert_eq!(restarted.donation_prompt_version, env!("OCS_APP_VERSION"));
|
|
assert!(restarted.active_modal.is_none());
|
|
|
|
restarted.donation_prompt_version = "previous-release".to_string();
|
|
restarted.queue_startup_prompts();
|
|
assert_eq!(restarted.active_modal, Some(ModalKind::DonationPrompt));
|
|
let _ = restarted.update(Message::CommandEscape);
|
|
assert!(restarted.active_modal.is_none());
|
|
assert_eq!(restarted.donation_prompt_version, env!("OCS_APP_VERSION"));
|
|
let _ = restarted.update(Message::Noop);
|
|
assert!(restarted.active_modal.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn startup_dialogs_wait_their_turn_and_preserve_unseen_prompts() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.apply_config(AppConfig::default());
|
|
app.queue_startup_prompts();
|
|
assert_eq!(app.active_modal, Some(ModalKind::AssocPrompt));
|
|
let _ = app.update(Message::UpdateCheckResult(Some(
|
|
crate::io::update_check::UpdateInfo {
|
|
version: "next-release".to_string(),
|
|
body: String::new(),
|
|
},
|
|
)));
|
|
assert_eq!(app.active_modal, Some(ModalKind::AssocPrompt));
|
|
let _ = app.update(Message::AssocPromptNo);
|
|
assert_eq!(app.active_modal, Some(ModalKind::DonationPrompt));
|
|
|
|
// A file-recovery dialog may interrupt startup before the first frame.
|
|
app.active_modal = Some(ModalKind::Recovery);
|
|
let _ = app.update(Message::CloseModal);
|
|
assert_eq!(app.active_modal, Some(ModalKind::DonationPrompt));
|
|
assert!(app.donation_prompt_version.is_empty());
|
|
let _ = app.update(Message::ModalContentResized(iced::Size::new(540.0, 280.0)));
|
|
assert_eq!(app.donation_prompt_version, env!("OCS_APP_VERSION"));
|
|
let _ = app.update(Message::CloseModal);
|
|
assert_eq!(app.active_modal, Some(ModalKind::UpdateNotice));
|
|
let _ = app.update(Message::UpdateNoticeClose);
|
|
assert!(app.active_modal.is_none());
|
|
assert!(app.pending_startup_modals.is_empty());
|
|
}
|
|
}
|