From 7f641b9c10d3bfc7de9a5d866b541b4f256d8256 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:00:31 -0800 Subject: [PATCH 1/5] Help: a stalled interface writes a hang report, and Copy Troubleshooting Info copies it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the interface thread stays in one part of a frame for 3 s, a watching thread writes a hang report to the log and to logs/hang-report-.txt. The report gives the part of the frame (input hook, logic, interface, or the renderer after a frame in which any window had input), a system clipboard call in progress, the last 8 different command ids, the last 16 frame times, the platform, window system, desktop and session type, Flatpak, graphics adapter, the present mode and frame latency the app asks for, the running time and the number of open documents. It holds no document contents, file paths or command parameters. The interface thread marks its stages with a few atomic stores a frame, and the watching thread reads those marks without waiting on it. The process keeps the report file locked until the interface answers again, then removes it. A file nobody holds at a later launch was left by a session that was closed while it did not answer: the launch writes the report to the log, moves the file to logs/hang-report.1.txt and, unless another start-up message is showing, says so in the status bar. Help › Copy Troubleshooting Info puts the platform facts, the recent commands and frame times and both sessions' reports on the clipboard for a bug report. An in-line file dialog, an open menu of the macOS menu bar, and the time after a frame that minimizes or hides the window or starts moving or resizing it are not timed. The menu label and the two status messages have rows in every catalog. --- apps/vectorcraft/src/app.rs | 34 +- apps/vectorcraft/src/hang_watch.rs | 445 ++++++++++++++++++ apps/vectorcraft/src/hang_watch/tests.rs | 270 +++++++++++ apps/vectorcraft/src/host_services.rs | 5 +- apps/vectorcraft/src/main.rs | 42 ++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/commands.rs | 1 + crates/ui-egui/src/hang_beacon.rs | 557 +++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/tree.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 6 + crates/ui-egui/src/services.rs | 4 + crates/ui-egui/src/troubleshooting.rs | 72 +++ docs/development.md | 2 + docs/target-app-parity.md | 1 + 43 files changed, 1580 insertions(+), 2 deletions(-) create mode 100644 apps/vectorcraft/src/hang_watch.rs create mode 100644 apps/vectorcraft/src/hang_watch/tests.rs create mode 100644 crates/ui-egui/src/hang_beacon.rs create mode 100644 crates/ui-egui/src/troubleshooting.rs diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index fa0f0dca2..bfd8ce1c1 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -9,6 +9,7 @@ use super::{App, VectorcraftApp, file_access, window}; #[cfg(target_os = "macos")] use super::{mac_menu, mac_window, open_documents}; use vectorcraft_ui_egui::dialogs::safe_mode; +use vectorcraft_ui_egui::hang_beacon::{self, BEACON, FrameTime, Stage}; /// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating /// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel @@ -34,12 +35,16 @@ impl eframe::App for App { // control channel asks for, and the frames carrying input it injected, to the automation roots // (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + BEACON.enter(Stage::Logic); file_access::unconfined(|| self.logic_frame(ctx, frame)); self.app.finish_ime_logic(ctx); + BEACON.enter(Stage::Idle); } /// Restore the borderless caption, apply platform menu and pen input, and hand the frame to the /// app's raw-input hook while the keyboard user is unconfined. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + BEACON.enter(Stage::Input); + self.frame_start = Some(std::time::Instant::now()); // winit gives the window its caption back when it changes the window's flags (maximize, // restore, …): take it off again before the frame is laid out (#1758). if let Some(window_frame) = self.window_frame.as_mut() { @@ -60,9 +65,10 @@ impl eframe::App for App { // that no mouse button is down. #[cfg(target_os = "macos")] self.app.release_lost_buttons(ctx, raw, objc2_app_kit::NSEvent::pressedMouseButtons() != 0); + BEACON.enter(Stage::Idle); } - #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + BEACON.enter(Stage::Interface); #[cfg(target_os = "macos")] { let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT; @@ -89,8 +95,13 @@ impl eframe::App for App { if !self.app.file_dialog_open() && self.app.take_ime_discard() { discard_marked_text(); } + self.end_frame(ui.ctx(), frame); } fn on_exit(&mut self) { + // The window is closing: saving and teardown are not timed for the hang report, and a report + // of a stall the interface came back from is removed. + let log_dir = super::prefs::log_dir().filter(|_| super::prefs::prefs_enabled()); + super::hang_watch::closing(log_dir.as_deref(), &super::hang_watch::CLOSING, &BEACON); file_access::unconfined(|| save_prefs(&self.app)); // A ready update installs once the process has exited. The macOS exit below skips // dropping the app, so the helper is started here. @@ -102,6 +113,27 @@ impl eframe::App for App { } impl App { + /// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a + /// focused, shown window whose windows had input waits for the renderer and the next frame, + /// which the input asks for at once; any other frame leaves the interface thread idle + /// (`hang_watch`). + fn end_frame(&mut self, ctx: &egui::Context, frame: &eframe::Frame) { + // A pass egui discards and runs again is not the frame's end: the pass after it is. + if ctx.will_discard() { + return; + } + if let Some(start) = self.frame_start.take() { + let work_ms = start.elapsed().as_secs_f32() * 1000.0; + let previous_total_ms = frame.info().cpu_usage.map(|s| s * 1000.0); + BEACON.frame(FrameTime { work_ms, previous_total_ms }, self.app.session.documents().len()); + } + // Hide on macOS hides the window at once, before the window reports losing focus. + let shown = ctx.input(|i| i.focused && i.viewport().minimized != Some(true)) + && self.winit_window.as_ref().is_none_or(|w| w.is_visible() != Some(false)); + let input = BEACON.take_input(); + BEACON.enter(ctx.viewport(|vp| hang_beacon::after_frame(input, shown, &vp.commands))); + } + fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] if self.winit_window.as_ref().is_some_and(|w| !w.has_focus() || w.is_visible() == Some(false) || w.is_minimized() == Some(true)) { diff --git a/apps/vectorcraft/src/hang_watch.rs b/apps/vectorcraft/src/hang_watch.rs new file mode 100644 index 000000000..e5adb77df --- /dev/null +++ b/apps/vectorcraft/src/hang_watch.rs @@ -0,0 +1,445 @@ +//! The hang report. A thread reads the interface thread's marks +//! ([`vectorcraft_ui_egui::hang_beacon`]) every [`POLL`]. When the interface thread has made no +//! mark for [`STALL`] while inside a stage of its frame, the thread writes what the app knows to the +//! log (`logs/vectorcraft.log`) and to `logs/hang-report-.txt`: the stage, a clipboard +//! call in progress, the last commands, the last frames' times, the platform, the window system, the +//! graphics processor and the present mode the app asked for, and how long the session has run with +//! how many documents open. The process holds a lock on the file until the interface thread answers +//! again; then the log says after how long and the file is removed. A file nobody holds at a later +//! launch was left by a session that was closed while it did not answer, and a locked one belongs to +//! another instance that is stalled now and is left alone. That launch writes the report to the log, +//! moves the newest abandoned file to `hang-report.1.txt` and removes the others, says so in the +//! status bar, and Help › Copy Troubleshooting Info copies the reports. Where the file system has no +//! file locks, the file is written and taken without one. +//! +//! No native stack is recorded: the marks are what the report has. The report holds command ids and +//! counts, never document contents or file paths. [`STALL`] is VectorCraft's own value. +//! +//! A stall in the renderer is timed only after a frame that had input in any window: input asks for +//! the next frame at once, so a frame that does not start then is late. After a frame without +//! input, the window waiting for input looks the same as one stuck drawing, so that time is not +//! timed. Nor is the time after a frame that minimizes or hides the main window or hands it to the +//! system to move or resize. A window the system stops redrawing while it is hidden can still look +//! like a stall after a frame with input; the report's stage says so. A system file dialog shown in +//! line (on Windows and Linux) and an open menu of the macOS menu bar wait for the user and are not +//! timed. + +use std::fs::File; +use std::io::{Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant, SystemTime}; + +#[cfg(target_os = "macos")] +use vectorcraft_ui_egui::hang_beacon::Activity; +use vectorcraft_ui_egui::hang_beacon::{BEACON, Beacon, FrameTime, Sample, Stage}; + +use super::logging::timestamp; + +/// How long the interface thread may stay in one stage before the report is written. +pub const STALL: Duration = Duration::from_secs(3); +/// How often the watching thread reads the marks. +pub const POLL: Duration = Duration::from_millis(500); +/// The report of a stall that has not ended, in the log folder: this, the process id, `.txt`. +pub const REPORT_PREFIX: &str = "hang-report-"; +/// At most this many report files are taken when a launch looks for abandoned ones; older abandoned +/// ones are removed. +const MAX_REPORT_FILES: usize = 16; +/// At most this many entries of the log folder are looked at for report files. +const MAX_SCANNED_ENTRIES: usize = 1024; +/// The report the last session left, moved here at launch. +pub const PREVIOUS_FILE: &str = "hang-report.1.txt"; +/// A report file larger than this is not read. +pub const MAX_REPORT_BYTES: u64 = 64 * 1024; +/// Environment values in the report are cut to this many characters. +const MAX_VALUE_CHARS: usize = 64; + +/// What [`Watch::check`] found. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Event { + /// The interface thread has been in the sample's stage, without a mark, for this long. + Stalled(Duration), + /// The interface thread made a mark again after a [`Event::Stalled`], about this long after + /// the stall began. + Answered(Duration), +} + +/// Decides from successive samples when the interface thread has stalled and when it answers +/// again. Time comes from the caller, so tests drive it without waiting. +#[derive(Debug)] +pub struct Watch { + stall: Duration, + /// The step seen in a busy stage and when it was first seen. + seen: Option<(u64, Duration)>, + reported: bool, +} + +impl Watch { + pub fn new(stall: Duration) -> Watch { + Watch { stall, seen: None, reported: false } + } + + /// The marks read at `now` (any monotonic clock that started before the first call). + pub fn check(&mut self, now: Duration, sample: Sample) -> Option { + let same = self.seen.is_some_and(|(step, _)| step == sample.step); + if same && sample.busy() { + let since = self.seen.map_or(now, |(_, since)| since); + let stalled = now.saturating_sub(since); + if !self.reported && stalled >= self.stall { + self.reported = true; + return Some(Event::Stalled(stalled)); + } + return None; + } + let answered = match (self.reported, self.seen) { + (true, Some((_, since))) => Some(Event::Answered(now.saturating_sub(since))), + _ => None, + }; + self.reported = false; + self.seen = sample.busy().then_some((sample.step, now)); + answered + } +} + +/// What the report says about the platform. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Facts { + /// The window system the window opened on: Wayland, X11, AppKit, Win32, other or unknown. + pub window_system: String, + /// `XDG_CURRENT_DESKTOP`. + pub desktop: Option, + /// `XDG_SESSION_TYPE`. + pub session_type: Option, + /// Running in a Flatpak sandbox. + pub flatpak: bool, + /// The graphics processor the window draws with. + pub graphics: Option, + /// The present mode and frame queue the app asked the window surface for. + pub surface: String, +} + +impl Facts { + /// The facts from the environment (`env`, `flatpak_info`: whether `/.flatpak-info` exists) and + /// what the app knows. + pub fn new(env: impl Fn(&str) -> Option, flatpak_info: bool, window_system: &str, graphics: Option, surface: String) -> Facts { + let value = |name: &str| env(name).map(|v| clean(&v)).filter(|v| !v.is_empty()); + Facts { + window_system: window_system.to_string(), + desktop: value("XDG_CURRENT_DESKTOP"), + session_type: value("XDG_SESSION_TYPE"), + flatpak: flatpak_info || env("FLATPAK_ID").is_some_and(|v| !v.is_empty()), + graphics, + surface, + } + } + + /// The facts as report lines. + pub fn lines(&self) -> String { + let or_unknown = |v: &Option| v.clone().unwrap_or_else(|| "unknown".to_string()); + format!( + "VectorCraft {} on {} {}\nWindow system: {}; desktop: {}; session type: {}; Flatpak: {}\nGraphics: {}; {}\n", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + self.window_system, + or_unknown(&self.desktop), + or_unknown(&self.session_type), + if self.flatpak { "yes" } else { "no" }, + or_unknown(&self.graphics), + self.surface, + ) + } +} + +/// `v` with control characters removed, cut to [`MAX_VALUE_CHARS`] characters. +fn clean(v: &str) -> String { + v.chars().filter(|c| !c.is_control()).take(MAX_VALUE_CHARS).collect::().trim().to_string() +} + +/// The marks other than the stage: the last commands, the last frames and the session. +fn activity_lines(beacon: &Beacon, uptime: Duration) -> String { + let commands = match beacon.recent_commands() { + Some(c) if c.is_empty() => "none".to_string(), + Some(c) => c.join(", "), + None => "unavailable (being written)".to_string(), + }; + let (work, total) = match beacon.recent_frames() { + Some(frames) => { + let list = |f: &dyn Fn(&FrameTime) -> Option| { + let times: Vec = frames.iter().filter_map(f).map(|ms| format!("{ms:.1}")).collect(); + if times.is_empty() { "none".to_string() } else { times.join(", ") } + }; + (list(&|t| Some(t.work_ms)), list(&|t| t.previous_total_ms)) + } + None => ("unavailable (being written)".to_string(), "unavailable (being written)".to_string()), + }; + format!( + "Recent commands, oldest first: {commands}\nApp work per frame (ms), oldest first: {work}\nWork and drawing per frame, without waiting for the display (ms), oldest first: {total}\nSession: running for {} s, {} documents open\n", + uptime.as_secs(), + beacon.documents(), + ) +} + +/// The report of a stall that has lasted `stalled` in `sample`'s stage, written at `at`. +pub fn report(at: SystemTime, stalled: Duration, sample: Sample, beacon: &Beacon, uptime: Duration, facts: &Facts) -> String { + let waiting = sample.activity.describe().map(|a| format!("Waiting on: {a}\n")).unwrap_or_default(); + format!( + "VectorCraft hang report, {}\nThe interface thread has not moved on for {:.1} s.\nStage: {}\n{waiting}{}{}No native stack is recorded: the stage and the activity are what the app marks.\n", + timestamp(at), + stalled.as_secs_f64(), + sample.stage.describe(), + activity_lines(beacon, uptime), + facts.lines(), + ) +} + +/// What the watching thread and Help › Copy Troubleshooting Info share. +pub struct Shared { + pub facts: Facts, + pub started: Instant, + /// This session's last report. + pub last: Mutex>, + /// The report the last session left ([`take_previous`]). + pub previous: Option, +} + +/// The text Help › Copy Troubleshooting Info copies. +pub fn troubleshooting(shared: &Shared, beacon: &Beacon, at: SystemTime) -> String { + let last = shared.last.lock().unwrap_or_else(PoisonError::into_inner).clone(); + format!( + "VectorCraft troubleshooting info, {}\n{}{}\nThis session's last hang report:\n{}\nThe hang report the last session left (it was closed while it did not answer):\n{}\n", + timestamp(at), + shared.facts.lines(), + activity_lines(beacon, shared.started.elapsed()), + last.as_deref().unwrap_or("none\n"), + shared.previous.as_deref().unwrap_or("none\n"), + ) +} + +/// The report file of this process in `dir`. +fn report_path(dir: &Path) -> PathBuf { + dir.join(format!("{REPORT_PREFIX}{}.txt", std::process::id())) +} + +/// A report file this process wrote and holds locked until the interface answers again. +#[derive(Debug)] +pub struct Held { + path: PathBuf, + _file: File, +} + +/// Write `text` to this process's report file in `dir` and lock it. Where the file system has no +/// file locks, the file is written unlocked and the log says so. +fn write_report(dir: &Path, text: &str) -> std::io::Result { + std::fs::create_dir_all(dir)?; + let path = report_path(dir); + let mut file = File::create(&path)?; + if let Err(e) = file.lock() { + log::warn!("the hang report file is written without a lock: {e}"); + } + file.write_all(text.as_bytes())?; + Ok(Held { path, _file: file }) +} + +/// Whether a report file whose `try_lock` gave `locked` was abandoned: nobody holds it. A file +/// system without file locks answers [`std::io::ErrorKind::Unsupported`], and its files count as +/// abandoned; any other error leaves the file alone. +fn abandoned(locked: Result<(), std::fs::TryLockError>) -> bool { + match locked { + Ok(()) => true, + Err(std::fs::TryLockError::Error(e)) => e.kind() == std::io::ErrorKind::Unsupported, + Err(std::fs::TryLockError::WouldBlock) => false, + } +} + +/// The report file at `path`, opened and locked, with its length, when it was abandoned +/// ([`abandoned`]) and is not empty (an empty one may be being written). +fn open_abandoned(path: &Path) -> Option<(File, u64)> { + let file = File::options().read(true).write(true).open(path).ok()?; + if !abandoned(file.try_lock()) { + return None; + } + let len = file.metadata().map_or(0, |m| m.len()); + (len > 0).then_some((file, len)) +} + +/// The reports that sessions closed while they did not answer left in `dir`, oldest first; `None` +/// when there are none. The newest file is moved to [`PREVIOUS_FILE`] and the others are removed +/// once read. Of more than [`MAX_REPORT_FILES`], the newest are taken and the older ones removed +/// unread; the first [`MAX_SCANNED_ENTRIES`] entries of `dir` are looked at. A report file another +/// process holds locked, or one still empty, is left alone. A file over [`MAX_REPORT_BYTES`] is +/// taken without being read. A file that can't be moved is removed, so no later launch offers it +/// again: the caller writes the reports to the log. Each file is opened once. +pub fn take_previous(dir: &Path) -> Option { + let mut found: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(dir) + .ok()? + .take(MAX_SCANNED_ENTRIES) + .filter_map(Result::ok) + .filter(|e| { + e.file_name() + .to_str() + .and_then(|n| n.strip_prefix(REPORT_PREFIX)?.strip_suffix(".txt")) + .is_some_and(|pid| !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit())) + }) + .map(|e| (e.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH), e.path())) + .collect(); + found.sort(); + // Newest first: only abandoned reports count toward the limit. + let mut texts = Vec::new(); + for (_, path) in found.into_iter().rev() { + let Some((file, len)) = open_abandoned(&path) else { continue }; + if texts.len() == MAX_REPORT_FILES { + drop(file); + remove_quietly(&path); + continue; + } + let mut bytes = Vec::new(); + let text = if len <= MAX_REPORT_BYTES && (&file).take(MAX_REPORT_BYTES).read_to_end(&mut bytes).is_ok() { + String::from_utf8_lossy(&bytes).into_owned() + } else { + format!("The report was larger than {MAX_REPORT_BYTES} bytes and was not read.\n") + }; + // Closed (and unlocked) before it is moved: Windows moves no open file. + drop(file); + if !texts.is_empty() { + remove_quietly(&path); + } else if let Err(e) = std::fs::rename(&path, dir.join(PREVIOUS_FILE)) { + log::warn!("couldn't move the hang report file to {PREVIOUS_FILE}: {e}"); + remove_quietly(&path); + } + texts.push(text); + } + texts.reverse(); + (!texts.is_empty()).then(|| texts.join("\n")) +} + +/// Remove the report file at `path`; a failure goes to the log. +fn remove_quietly(path: &Path) { + if let Err(e) = std::fs::remove_file(path) { + log::warn!("couldn't remove the hang report file: {e}"); + } +} + +/// Set when the app closes normally ([`closing`]): the watching thread writes no report file after it. +pub static CLOSING: AtomicBool = AtomicBool::new(false); + +/// Remove `held`'s file, closing it first. → the error message; a file already gone is none. +fn remove(held: Held) -> Option { + let Held { path, _file: file } = held; + drop(file); + match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Some(e.to_string()), + _ => None, + } +} + +/// Act on what [`Watch::check`] found: write (and hold) or remove this process's report file in +/// `dir`, then log it. The file comes first: logging can wait on what the interface thread waits on. +#[allow(clippy::too_many_arguments)] +pub fn record( + event: Event, + sample: Sample, + beacon: &Beacon, + shared: &Shared, + dir: Option<&Path>, + held: &mut Option, + closing: &AtomicBool, + at: SystemTime, +) { + match event { + Event::Stalled(stalled) => { + let text = report(at, stalled, sample, beacon, shared.started.elapsed(), &shared.facts); + let mut failed = None; + if let Some(dir) = dir.filter(|_| !closing.load(Ordering::Acquire)) { + match write_report(dir, &text) { + Ok(file) => *held = Some(file), + Err(e) => failed = Some(e.to_string()), + } + // The app began to close while the file was written: it is not a report to leave. + if closing.load(Ordering::Acquire) { + failed = held.take().and_then(remove).or(failed); + } + } + *shared.last.lock().unwrap_or_else(PoisonError::into_inner) = Some(text.clone()); + log::warn!("the interface stopped answering:\n{}", text.trim_end()); + if let Some(e) = failed { + log::warn!("couldn't write or remove the hang report file: {e}"); + } + } + Event::Answered(after) => { + let failed = held.take().and_then(remove); + log::warn!("the interface answered again about {:.1} s after it stopped", after.as_secs_f64()); + if let Some(e) = failed { + log::warn!("couldn't remove the hang report file: {e}"); + } + } + } +} + +/// The app is closing normally: mark the interface thread idle on `beacon` (saving and teardown are +/// not timed), remove this process's report file in `dir` and have the watching thread write no +/// more. It does not wait for the watching thread. The file can be removed while that thread holds +/// it open and locked: the standard library opens files with `FILE_SHARE_DELETE` on Windows, and a +/// byte-range lock does not prevent deletion. Where Windows deletes a file only once its last +/// handle closes, the name goes when the process ends. +pub fn closing(dir: Option<&Path>, closing: &AtomicBool, beacon: &Beacon) { + beacon.enter(Stage::Idle); + closing.store(true, Ordering::Release); + if let Some(dir) = dir { + match std::fs::remove_file(report_path(dir)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => log::warn!("couldn't remove the hang report file: {e}"), + _ => {} + } + } +} + +/// The window system `window` opened on, for [`Facts::window_system`]. +pub fn window_system(window: Option<&winit::window::Window>) -> &'static str { + use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + match window.and_then(|w| w.window_handle().ok()).map(|h| h.as_raw()) { + Some(RawWindowHandle::Wayland(_)) => "Wayland", + Some(RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)) => "X11", + Some(RawWindowHandle::AppKit(_)) => "AppKit", + Some(RawWindowHandle::Win32(_)) => "Win32", + Some(_) => "other", + None => "unknown", + } +} + +/// The present mode and frame queue the app asked the window surface for, for [`Facts::surface`]; +/// `None` when the window has no wgpu renderer. +pub fn surface(config: Option<(String, Option)>) -> String { + match config { + Some((mode, Some(frames))) => format!("requested present mode {mode}, frame latency {frames}"), + Some((mode, None)) => format!("requested present mode {mode}"), + None => "no wgpu renderer".to_string(), + } +} + +/// Start the watching thread; the report file goes in `dir` (none: the log only). +pub fn start(dir: Option, shared: Arc) { + let spawned = std::thread::Builder::new().name("hang-watch".into()).spawn(move || { + let mut watch = Watch::new(STALL); + let mut held = None; + loop { + std::thread::sleep(POLL); + #[cfg_attr(not(target_os = "macos"), allow(unused_mut))] + let mut sample = BEACON.sample(); + // An open menu of the menu bar waits for the user. + #[cfg(target_os = "macos")] + if super::mac_menu::is_tracking() { + sample.activity = Activity::Menu; + } + if let Some(event) = watch.check(shared.started.elapsed(), sample) { + record(event, sample, &BEACON, &shared, dir.as_deref(), &mut held, &CLOSING, SystemTime::now()); + } + } + }); + if let Err(e) = spawned { + log::warn!("no hang report this session: the watching thread didn't start: {e}"); + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/vectorcraft/src/hang_watch/tests.rs b/apps/vectorcraft/src/hang_watch/tests.rs new file mode 100644 index 000000000..09cf74e40 --- /dev/null +++ b/apps/vectorcraft/src/hang_watch/tests.rs @@ -0,0 +1,270 @@ +use super::*; +use std::time::UNIX_EPOCH; +use vectorcraft_ui_egui::hang_beacon::{Activity, Stage}; + +fn ms(n: u64) -> Duration { + Duration::from_millis(n) +} + +fn sample(step: u64, stage: Stage) -> Sample { + Sample { step, stage, activity: Activity::None } +} + +fn facts() -> Facts { + let env = |name: &str| match name { + "XDG_CURRENT_DESKTOP" => Some("KDE".to_string()), + "XDG_SESSION_TYPE" => Some("wayland\u{7}".to_string()), + _ => None, + }; + Facts::new(env, true, "Wayland", Some("Test GPU (Vulkan, DiscreteGpu)".to_string()), surface(Some(("AutoVsync".to_string(), Some(1))))) +} + +fn shared(previous: Option) -> Shared { + Shared { facts: facts(), started: Instant::now(), last: Mutex::new(None), previous } +} + +#[test] +fn a_stage_held_for_the_stall_time_is_reported_once_and_its_end_after_it() { + let mut w = Watch::new(STALL); + assert_eq!(w.check(ms(0), sample(5, Stage::Interface)), None); + assert_eq!(w.check(ms(2_999), sample(5, Stage::Interface)), None); + assert_eq!(w.check(ms(3_000), sample(5, Stage::Interface)), Some(Event::Stalled(ms(3_000)))); + assert_eq!(w.check(ms(9_000), sample(5, Stage::Interface)), None); + assert_eq!(w.check(ms(9_500), sample(6, Stage::Idle)), Some(Event::Answered(ms(9_500)))); + assert_eq!(w.check(ms(20_000), sample(6, Stage::Idle)), None); +} + +#[test] +fn frames_that_keep_moving_and_an_idle_window_are_never_reported() { + let mut w = Watch::new(STALL); + // A long animation: the step changes at every read. + for n in 0..100 { + assert_eq!(w.check(ms(n * 500), sample(n, Stage::Drawing)), None); + } + // A window waiting for input for a minute. + for n in 0..120 { + assert_eq!(w.check(ms(50_000 + n * 500), sample(1_000, Stage::Idle)), None); + } +} + +#[test] +fn a_file_dialog_shown_in_line_is_waiting_for_the_user_not_stalled() { + let mut w = Watch::new(STALL); + let dialog = Sample { step: 7, stage: Stage::Interface, activity: Activity::Dialog }; + for n in 0..60 { + assert_eq!(w.check(ms(n * 500), dialog), None); + } + // Back from the dialog, the command goes on and is timed again. + assert_eq!(w.check(ms(30_000), sample(8, Stage::Interface)), None); + assert_eq!(w.check(ms(33_000), sample(8, Stage::Interface)), Some(Event::Stalled(ms(3_000)))); +} + +#[test] +fn an_open_menu_bar_menu_is_waiting_for_the_user_not_stalled() { + let mut w = Watch::new(STALL); + let menu = Sample { step: 3, stage: Stage::Drawing, activity: Activity::Menu }; + for n in 0..60 { + assert_eq!(w.check(ms(n * 500), menu), None); + } +} + +#[test] +fn a_new_busy_step_starts_the_time_again() { + let mut w = Watch::new(STALL); + assert_eq!(w.check(ms(0), sample(1, Stage::Logic)), None); + assert_eq!(w.check(ms(2_500), sample(2, Stage::Interface)), None); + assert_eq!(w.check(ms(5_000), sample(2, Stage::Interface)), None); + assert_eq!(w.check(ms(5_500), sample(2, Stage::Interface)), Some(Event::Stalled(ms(3_000)))); + // A mark inside the stall (a clipboard read ending) is an answer. + assert_eq!(w.check(ms(6_000), sample(3, Stage::Interface)), Some(Event::Answered(ms(3_500)))); +} + +#[test] +fn the_report_names_the_stage_the_activity_the_commands_and_the_platform() { + let beacon = Beacon::new(); + beacon.command("edit.copy"); + beacon.command("edit.paste"); + beacon.frame(FrameTime { work_ms: 1.24, previous_total_ms: Some(3.5) }, 2); + let s = Sample { step: 9, stage: Stage::Interface, activity: Activity::ClipboardRead }; + let text = report(UNIX_EPOCH + Duration::from_secs(1_794_385_145), ms(3_240), s, &beacon, Duration::from_secs(754), &facts()); + for want in [ + "VectorCraft hang report, 2026-11-11T08:19:05.000Z", + "has not moved on for 3.2 s", + "Stage: running the interface", + "Waiting on: reading the system clipboard", + "Recent commands, oldest first: edit.copy, edit.paste", + "App work per frame (ms), oldest first: 1.2", + "without waiting for the display (ms), oldest first: 3.5", + "Session: running for 754 s, 2 documents open", + "Window system: Wayland; desktop: KDE; session type: wayland; Flatpak: yes", + "Graphics: Test GPU (Vulkan, DiscreteGpu); requested present mode AutoVsync, frame latency 1", + ] { + assert!(text.contains(want), "{want:?} missing from:\n{text}"); + } +} + +#[test] +fn environment_values_lose_control_characters_and_are_cut_short() { + let long = "x".repeat(500); + let env = |name: &str| (name == "XDG_CURRENT_DESKTOP").then(|| format!("A\nB{long}")); + let f = Facts::new(env, false, "X11", None, String::new()); + assert_eq!(f.desktop.as_deref().map(str::len), Some(MAX_VALUE_CHARS)); + assert!(f.desktop.as_deref().is_some_and(|d| d.starts_with("ABx"))); + assert!(!f.flatpak); + assert!(f.lines().contains("Graphics: unknown")); +} + +#[test] +fn a_stall_writes_the_report_file_and_its_end_removes_it() { + let dir = tempfile::tempdir().unwrap(); + let shared = shared(None); + let beacon = Beacon::new(); + let s = sample(4, Stage::Drawing); + let (mut held, open) = (None, AtomicBool::new(false)); + record(Event::Stalled(ms(3_000)), s, &beacon, &shared, Some(dir.path()), &mut held, &open, UNIX_EPOCH); + let path = report_path(dir.path()); + // Windows refuses reads through another handle while the file is locked: the length is read. + let last = shared.last.lock().unwrap().clone().unwrap_or_default(); + assert!(last.contains("Stage: after a frame with input went to the renderer")); + assert_eq!(std::fs::metadata(&path).unwrap().len(), last.len() as u64); + // While the stall lasts, a launch of another instance leaves the file alone. + assert_eq!(take_previous(dir.path()), None); + assert!(path.exists()); + record(Event::Answered(ms(4_000)), s, &beacon, &shared, Some(dir.path()), &mut held, &open, UNIX_EPOCH); + assert!(!path.exists()); + // This session's report stays for Copy Troubleshooting Info. + assert!(shared.last.lock().unwrap().is_some()); +} + +#[test] +fn closing_normally_removes_the_report_and_writes_no_more() { + let dir = tempfile::tempdir().unwrap(); + let shared = shared(None); + let beacon = Beacon::new(); + let (mut held, flag) = (None, AtomicBool::new(false)); + record(Event::Stalled(ms(3_000)), sample(4, Stage::Interface), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH); + let path = report_path(dir.path()); + assert!(path.exists()); + // The interface answered after a frame with input, and the app quit before the watching thread + // looked again: saving and teardown are not timed. + beacon.enter(Stage::Drawing); + closing(Some(dir.path()), &flag, &beacon); + assert_eq!(beacon.sample().stage, Stage::Idle); + assert!(!path.exists()); + assert_eq!(take_previous(dir.path()), None); + // The watching thread then sees the answer. + record(Event::Answered(ms(3_500)), sample(5, Stage::Idle), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH); + assert!(held.is_none()); + // A stall the watching thread finds while the app closes goes to the log only. + record(Event::Stalled(ms(3_000)), sample(9, Stage::Interface), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH); + assert!(!path.exists()); + assert!(held.is_none()); +} + +#[test] +fn the_next_launch_takes_a_left_report_once_and_keeps_a_copy() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(take_previous(dir.path()), None); + let left = dir.path().join(format!("{REPORT_PREFIX}4242.txt")); + std::fs::write(&left, "VectorCraft hang report, then\n").unwrap(); + // Other files in the log folder are not reports. + std::fs::write(dir.path().join(format!("{REPORT_PREFIX}notes.txt")), "x").unwrap(); + std::fs::write(dir.path().join(format!("{REPORT_PREFIX}17.txt")), "").unwrap(); + assert_eq!(take_previous(dir.path()).as_deref(), Some("VectorCraft hang report, then\n")); + assert!(!left.exists()); + assert_eq!(std::fs::read_to_string(dir.path().join(PREVIOUS_FILE)).unwrap(), "VectorCraft hang report, then\n"); + assert_eq!(take_previous(dir.path()), None); + // An empty file may be one being written: it stays. + assert!(dir.path().join(format!("{REPORT_PREFIX}17.txt")).exists()); +} + +#[test] +fn of_several_left_reports_the_newest_is_kept_and_all_are_offered_oldest_first() { + let dir = tempfile::tempdir().unwrap(); + for (pid, text, secs) in [(9, "newer\n", 2_000), (10, "older\n", 1_000)] { + let path = dir.path().join(format!("{REPORT_PREFIX}{pid}.txt")); + std::fs::write(&path, text).unwrap(); + let file = std::fs::File::options().write(true).open(&path).unwrap(); + file.set_modified(UNIX_EPOCH + Duration::from_secs(secs)).unwrap(); + } + assert_eq!(take_previous(dir.path()).as_deref(), Some("older\n\nnewer\n")); + assert_eq!(std::fs::read_to_string(dir.path().join(PREVIOUS_FILE)).unwrap(), "newer\n"); +} + +#[test] +fn a_report_that_cannot_be_moved_is_offered_once() { + let dir = tempfile::tempdir().unwrap(); + // Something that is not a file is in the way. + std::fs::create_dir_all(dir.path().join(PREVIOUS_FILE).join("x")).unwrap(); + let left = dir.path().join(format!("{REPORT_PREFIX}5.txt")); + std::fs::write(&left, "kept\n").unwrap(); + assert_eq!(take_previous(dir.path()).as_deref(), Some("kept\n")); + assert!(!left.exists()); + assert_eq!(take_previous(dir.path()), None); +} + +#[test] +fn reports_beyond_the_newest_are_removed_not_left_for_a_later_launch() { + let dir = tempfile::tempdir().unwrap(); + let count = MAX_REPORT_FILES + 3; + for n in 0..count { + let path = dir.path().join(format!("{REPORT_PREFIX}{}.txt", 100 + n)); + std::fs::write(&path, format!("report {n}\n")).unwrap(); + let file = std::fs::File::options().write(true).open(&path).unwrap(); + file.set_modified(UNIX_EPOCH + Duration::from_secs(1_000 + n as u64)).unwrap(); + } + let text = take_previous(dir.path()).unwrap(); + assert!(text.starts_with("report 3\n"), "{text}"); + assert!(text.ends_with(&format!("report {}\n", count - 1)), "{text}"); + assert!(!text.contains("report 2\n")); + assert_eq!(take_previous(dir.path()), None); + let left: Vec<_> = std::fs::read_dir(dir.path()).unwrap().filter_map(Result::ok).map(|e| e.file_name()).collect(); + assert_eq!(left, vec![std::ffi::OsString::from(PREVIOUS_FILE)]); +} + +#[test] +fn newer_empty_files_do_not_push_out_an_abandoned_report() { + let dir = tempfile::tempdir().unwrap(); + let old = dir.path().join(format!("{REPORT_PREFIX}1.txt")); + std::fs::write(&old, "old\n").unwrap(); + std::fs::File::options().write(true).open(&old).unwrap().set_modified(UNIX_EPOCH + Duration::from_secs(1_000)).unwrap(); + for n in 0..MAX_REPORT_FILES + 1 { + let path = dir.path().join(format!("{REPORT_PREFIX}{}.txt", 100 + n)); + std::fs::write(&path, "").unwrap(); + std::fs::File::options().write(true).open(&path).unwrap().set_modified(UNIX_EPOCH + Duration::from_secs(2_000)).unwrap(); + } + assert_eq!(take_previous(dir.path()).as_deref(), Some("old\n")); +} + +#[test] +fn a_file_system_without_locks_does_not_hide_a_report() { + use std::io::{Error, ErrorKind}; + assert!(abandoned(Ok(()))); + assert!(!abandoned(Err(std::fs::TryLockError::WouldBlock))); + assert!(abandoned(Err(std::fs::TryLockError::Error(Error::from(ErrorKind::Unsupported))))); + // Any other error may hide a live session's lock: the file is left alone. + assert!(!abandoned(Err(std::fs::TryLockError::Error(Error::from(ErrorKind::PermissionDenied))))); +} + +#[test] +fn an_oversized_report_file_is_not_read() { + let dir = tempfile::tempdir().unwrap(); + let big = vec![b'x'; usize::try_from(MAX_REPORT_BYTES).unwrap() + 1]; + let left = dir.path().join(format!("{REPORT_PREFIX}7.txt")); + std::fs::write(&left, big).unwrap(); + let text = take_previous(dir.path()).unwrap(); + assert!(text.starts_with("The report was larger than")); + assert!(!left.exists()); +} + +#[test] +fn troubleshooting_info_holds_both_sessions_reports() { + let shared = shared(Some("VectorCraft hang report, last session\n".to_string())); + *shared.last.lock().unwrap() = Some("VectorCraft hang report, this session\n".to_string()); + let beacon = Beacon::new(); + let text = troubleshooting(&shared, &beacon, UNIX_EPOCH); + assert!(text.starts_with("VectorCraft troubleshooting info, 1970-01-01T00:00:00.000Z\n")); + assert!(text.contains("This session's last hang report:\nVectorCraft hang report, this session\n")); + assert!(text.contains("(it was closed while it did not answer):\nVectorCraft hang report, last session\n")); + assert!(text.contains("Recent commands, oldest first: none")); +} diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 187c42cde..88dd4e6c0 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -139,6 +139,8 @@ fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) /// Show the dialog for `request` over `parent` now → the paths picked. #[cfg(not(target_os = "macos"))] fn pick_now(request: PickRequest, parent: &Parent) -> Vec { + // The dialog waits for the user: not a stall for the hang report (`hang_watch`). + let _mark = vectorcraft_ui_egui::hang_beacon::BEACON.activity(vectorcraft_ui_egui::hang_beacon::Activity::Dialog); show_dialog(file_dialog(&request, parent), &request, parent) } @@ -363,7 +365,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { fileio::write_file_with(p, fill).map_err(|e| e.to_string()) })), // Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event). - system_clipboard: Some(clipboard::system_clipboard(clip)), + // Its calls are marked for the hang report (`hang_watch`). + system_clipboard: Some(Box::new(vectorcraft_ui_egui::hang_beacon::Marked(clipboard::system_clipboard(clip)))), // Linux checks whether Paste has something to take on a background thread: an X11 clipboard // owner that never answers holds a read for up to 4 s. Windows only asks which formats the // clipboard holds and macOS asks the pasteboard server, so they check in line. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index bb94bad1c..33e03b1f7 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -30,6 +30,7 @@ mod control_server; mod cursor_theme; #[cfg(feature = "wgpu")] mod gpu; +mod hang_watch; #[cfg(target_os = "linux")] mod linux_stylus; mod logging; @@ -123,6 +124,8 @@ struct App { held_files: Vec, /// The time and UI zoom of the last monitor list ([`window::monitors_due`]). monitors_listed: Option<(f64, f32)>, + /// When this frame's input hook started, for the hang report's frame times (`hang_watch`). + frame_start: Option, } /// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s. @@ -293,6 +296,11 @@ fn main() -> std::process::ExitCode { None => logger.no_file(), } } + // The report a session left when it was closed while it did not answer (`hang_watch`). + let previous_hang = log_dir().filter(|_| prefs_enabled()).and_then(|dir| hang_watch::take_previous(&dir)); + if let Some(report) = &previous_hang { + log::warn!("the last session was closed while it did not answer; its report:\n{}", report.trim_end()); + } // The roots confine the control channel: a bad one keeps the app from starting rather than // leaving agents unconfined. Without a control port there is nothing to confine. let roots = if control_port.is_none() { @@ -437,6 +445,9 @@ fn main() -> std::process::ExitCode { open_documents::install(); #[cfg(feature = "wgpu")] let created = startup.clone(); + // For the hang report (`hang_watch`). + #[cfg(feature = "wgpu")] + let requested_surface = options.wgpu_options.surface; // A panic that ends the window (egui-wgpu's own, #502) is caught here: never a crash. let run = |options: eframe::NativeOptions, app_creator: eframe::AppCreator<'_>| { #[cfg(target_os = "linux")] @@ -537,6 +548,36 @@ fn main() -> std::process::ExitCode { let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone()); rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}"))); } + // The hang report and Help › Copy Troubleshooting Info (`hang_watch`). + // The surface settings the options asked for; the driver resolves the present mode. + #[cfg(feature = "wgpu")] + let surface = cc + .wgpu_render_state + .as_ref() + .map(|_| (format!("{:?}", requested_surface.present_mode), requested_surface.desired_maximum_frame_latency)); + #[cfg(not(feature = "wgpu"))] + let surface = None; + let hang = std::sync::Arc::new(hang_watch::Shared { + facts: hang_watch::Facts::new( + |name| std::env::var(name).ok(), + std::path::Path::new("/.flatpak-info").exists(), + hang_watch::window_system(winit_window.as_deref()), + app.graphics_adapter.clone(), + hang_watch::surface(surface), + ), + started: std::time::Instant::now(), + last: std::sync::Mutex::new(None), + previous: previous_hang.clone(), + }); + vectorcraft_ui_egui::hang_beacon::watch_input(&vectorcraft_ui_egui::hang_beacon::BEACON, &cc.egui_ctx); + hang_watch::start(log_dir().filter(|_| prefs_enabled()), hang.clone()); + app.services.troubleshooting = Some(Box::new(move || { + hang_watch::troubleshooting(&hang, &vectorcraft_ui_egui::hang_beacon::BEACON, std::time::SystemTime::now()) + })); + // A message about the graphics processor, or a stopped plug-in, comes first. + if previous_hang.is_some() && app.ui.status.is_empty() { + vectorcraft_ui_egui::troubleshooting::last_session_stopped(&mut app); + } app.titlebar_from_env = titlebar_from_env(); app.custom_titlebar = custom_titlebar; app.desktop_frame = !custom_titlebar; @@ -583,6 +624,7 @@ fn main() -> std::process::ExitCode { maximize_tries: 8, held_files, monitors_listed: None, + frame_start: None, })) }), ) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 7b69fbfb8..3ce714322 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -135,6 +135,7 @@ impl VectorcraftApp { last_window_title: String::new(), fullscreen_for_mode: false, graphics_adapter: None, + beacon: &crate::hang_beacon::BEACON, place: Default::default(), system_paste: false, clipboard_image_request: None, diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index d97d11181..3a661a7d8 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -73,6 +73,7 @@ fn substitute_warnings_follow_current_font_inventory() { impl VectorcraftApp { /// Run a UI or engine command by id. The single entry point for every frontend path. pub fn run(&mut self, id: &str, params: Value) -> Result { + self.beacon.command(id); let touch_workspace = self.session.prefs.touch_workspace; // A file dialog it shows off the UI thread runs it again with the path picked. let result = if self.picks.is_entry_free() { diff --git a/crates/ui-egui/src/hang_beacon.rs b/crates/ui-egui/src/hang_beacon.rs new file mode 100644 index 000000000..c681bc813 --- /dev/null +++ b/crates/ui-egui/src/hang_beacon.rs @@ -0,0 +1,557 @@ +//! Where the interface thread is in its frame, for the desktop app's hang report (the app's +//! `hang_watch` module). +//! +//! The interface thread marks each stage of a frame (the host's input hook, the app's logic, the +//! interface, and the hand-off to the renderer), a system clipboard call in progress, whether any +//! window's pass had input ([`watch_input`]), and each command it runs. Another thread reads the +//! marks without waiting for the interface thread: the stage and the activity are atomics, and the +//! recent commands and frame times are read with `try_lock`. Marking costs a few atomic stores a +//! frame and one short lock per command and per frame. The web build marks the same way, and +//! nothing reads the marks there. +//! +//! Commands are kept by id only: no parameters, file names or document contents. A command run +//! again moves to the end of the list instead of taking another place, so a panel that runs a +//! query every frame does not push out the commands before it. + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Mutex, PoisonError}; + +use vectorcraft_engine::cmd::clipboard::Flavour; + +use crate::sysclip::{ClipboardRead, SystemClipboard}; + +/// How many different command ids [`Beacon::recent_commands`] keeps. +pub const RECENT_COMMANDS: usize = 8; +/// How many frames [`Beacon::recent_frames`] keeps. +pub const RECENT_FRAMES: usize = 16; +/// The longest command id [`Beacon::command`] keeps as it is. +pub const MAX_COMMAND_ID: usize = 64; + +/// The marks the desktop app's interface thread writes. +pub static BEACON: Beacon = Beacon::new(); + +/// The part of a frame the interface thread is in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Stage { + /// Outside the app's own frame code: waiting for input or a repaint, or a frame without input + /// handed to the renderer. + Idle = 0, + /// The host's input hook. + Input = 1, + /// The app's logic: background results, timers, the control channel. + Logic = 2, + /// The interface: input handling, commands, panels and dialogs. + Interface = 3, + /// A frame that had input was handed to the renderer. Drawing and presenting it follow, and + /// then the next frame, which input asks for at once. + Drawing = 4, +} + +impl Stage { + fn from_u8(n: u8) -> Stage { + match n { + 1 => Stage::Input, + 2 => Stage::Logic, + 3 => Stage::Interface, + 4 => Stage::Drawing, + _ => Stage::Idle, + } + } + + /// Whether the interface thread is expected to finish this stage soon. + pub fn busy(self) -> bool { + self != Stage::Idle + } + + /// The stage for a report, in English. + pub fn describe(self) -> &'static str { + match self { + Stage::Idle => "between frames", + Stage::Input => "reading the window's input", + Stage::Logic => "running the app's logic (background results, timers, the control channel)", + Stage::Interface => "running the interface (input handling, commands, panels and dialogs)", + Stage::Drawing => { + "after a frame with input went to the renderer: drawing or presenting it, or waiting for the next frame, which the system may hold back while the window is hidden" + } + } + } +} + +/// What the interface thread is waiting on inside a stage, where the app knows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Activity { + None = 0, + /// A read of the system clipboard, or a check of what it holds. + ClipboardRead = 1, + /// A write to the system clipboard. + ClipboardWrite = 2, + /// A system file dialog shown in line, which waits for the user and keeps the window + /// answering the system ([`Sample::busy`]). + Dialog = 3, + /// A menu of the system menu bar is open, which waits for the user ([`Sample::busy`]). The + /// host sets it on the sample it reads; the interface thread does not mark it. + Menu = 4, +} + +impl Activity { + fn from_u8(n: u8) -> Activity { + match n { + 1 => Activity::ClipboardRead, + 2 => Activity::ClipboardWrite, + 3 => Activity::Dialog, + 4 => Activity::Menu, + _ => Activity::None, + } + } + + /// Whether the activity waits for the user, so a long one is not a stall. + pub fn waits_for_user(self) -> bool { + matches!(self, Activity::Dialog | Activity::Menu) + } + + /// The activity for a report, in English; `None` when there is none. + pub fn describe(self) -> Option<&'static str> { + match self { + Activity::None => None, + Activity::ClipboardRead => Some("reading the system clipboard"), + Activity::ClipboardWrite => Some("writing to the system clipboard"), + Activity::Dialog => Some("a system file dialog, waiting for the user"), + Activity::Menu => Some("a menu of the system menu bar, waiting for the user"), + } + } +} + +/// What the interface thread was doing when it was read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Sample { + /// Counts every mark: the same value in two samples means the thread made no mark between them. + pub step: u64, + pub stage: Stage, + pub activity: Activity, +} + +impl Sample { + /// Whether the interface thread is expected to move on soon: inside a stage, and not showing a + /// system dialog or menu that waits for the user. + pub fn busy(&self) -> bool { + self.stage.busy() && !self.activity.waits_for_user() + } +} + +/// One frame's times, in milliseconds. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameTime { + /// The app's own work: from the input hook to the end of the interface. + pub work_ms: f32, + /// The renderer's measure of the frame before: the app's work and drawing, without waiting for + /// the display. `None` when the host has none. + pub previous_total_ms: Option, +} + +/// The marks: see the module documentation. +pub struct Beacon { + step: AtomicU64, + stage: AtomicU8, + activity: AtomicU8, + documents: AtomicUsize, + /// A pass of some window had input since [`Beacon::take_input`] last ran. + input: AtomicBool, + commands: Mutex>, + frames: Mutex>, +} + +impl Default for Beacon { + fn default() -> Self { + Self::new() + } +} + +impl Beacon { + pub const fn new() -> Beacon { + Beacon { + step: AtomicU64::new(0), + stage: AtomicU8::new(Stage::Idle as u8), + activity: AtomicU8::new(Activity::None as u8), + documents: AtomicUsize::new(0), + input: AtomicBool::new(false), + commands: Mutex::new(VecDeque::new()), + frames: Mutex::new(VecDeque::new()), + } + } + + fn bump(&self) { + self.step.fetch_add(1, Ordering::Release); + } + + /// The interface thread enters `stage`. + pub fn enter(&self, stage: Stage) { + self.stage.store(stage as u8, Ordering::Relaxed); + self.bump(); + } + + /// The interface thread starts `activity`; it ends when the guard is dropped, and the activity + /// before it applies again. + pub fn activity(&self, activity: Activity) -> ActivityGuard<'_> { + let before = Activity::from_u8(self.activity.swap(activity as u8, Ordering::Relaxed)); + self.bump(); + ActivityGuard { beacon: self, before } + } + + /// The interface thread runs the command `id`. An id that is not a command id's shape (longer + /// than [`MAX_COMMAND_ID`] characters, or with characters other than ASCII letters, digits, `.`, + /// `_` and `-`) is kept as `(other)`: ids can come from saved files and the control channel. An + /// id already in the list moves to its end. + pub fn command(&self, id: &str) { + self.bump(); + let shaped = id.len() <= MAX_COMMAND_ID && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')); + let kept = if shaped { id } else { "(other)" }; + let mut commands = self.commands.lock().unwrap_or_else(PoisonError::into_inner); + if commands.back().is_some_and(|last| last == kept) { + return; + } + let entry = match commands.iter().position(|c| c == kept) { + Some(i) => commands.remove(i).unwrap_or_else(|| kept.to_string()), + None => { + if commands.len() == RECENT_COMMANDS { + commands.pop_front(); + } + kept.to_string() + } + }; + commands.push_back(entry); + } + + /// A pass of some window had input ([`watch_input`]). + pub fn input(&self) { + self.input.store(true, Ordering::Relaxed); + } + + /// Whether a pass of some window had input since the last call. + pub fn take_input(&self) -> bool { + self.input.swap(false, Ordering::Relaxed) + } + + /// A frame ended with `time`, with `documents` documents open. + pub fn frame(&self, time: FrameTime, documents: usize) { + self.documents.store(documents, Ordering::Relaxed); + let mut frames = self.frames.lock().unwrap_or_else(PoisonError::into_inner); + if frames.len() == RECENT_FRAMES { + frames.pop_front(); + } + frames.push_back(time); + } + + /// The current marks. + pub fn sample(&self) -> Sample { + let step = self.step.load(Ordering::Acquire); + Sample { step, stage: Stage::from_u8(self.stage.load(Ordering::Relaxed)), activity: Activity::from_u8(self.activity.load(Ordering::Relaxed)) } + } + + /// The ids of the last [`RECENT_COMMANDS`] commands, oldest first; `None` while the interface + /// thread is writing them. + pub fn recent_commands(&self) -> Option> { + let commands = match self.commands.try_lock() { + Ok(c) => c, + Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => return None, + }; + Some(commands.iter().cloned().collect()) + } + + /// The last [`RECENT_FRAMES`] frames' times, oldest first; `None` while the interface thread is + /// writing them. + pub fn recent_frames(&self) -> Option> { + let frames = match self.frames.try_lock() { + Ok(f) => f, + Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => return None, + }; + Some(frames.iter().copied().collect()) + } + + /// The documents open at the end of the last frame. + pub fn documents(&self) -> usize { + self.documents.load(Ordering::Relaxed) + } +} + +/// Whether a pass's input asks for the next frame at once: events, or files dropped or hovered. +pub fn had_input(raw: &egui::RawInput) -> bool { + !raw.events.is_empty() || !raw.dropped_files.is_empty() || !raw.hovered_files.is_empty() +} + +/// Mark on `beacon` every pass of every window of `ctx` that had input ([`had_input`]): the main +/// window's, after the host's input hook added its own, and those of the windows shown in line +/// (floating panels and documents), whose input does not pass through that hook. +pub fn watch_input(beacon: &'static Beacon, ctx: &egui::Context) { + ctx.on_begin_pass( + "VectorCraft hang report input", + std::sync::Arc::new(move |ui| { + if ui.ctx().input(|i| had_input(&i.raw)) { + beacon.input(); + } + }), + ); +} + +/// The stage at the end of a frame: [`Stage::Drawing`] when a pass had `input` and the main +/// window is `shown` (focused, not minimized or hidden), since input asks for the next frame at +/// once; otherwise [`Stage::Idle`]. A frame that sends the main window `commands` that minimize or +/// hide it, or hand it to the system to move or resize, also ends idle: the system may hold back +/// the next frame until the user is done. +pub fn after_frame(input: bool, shown: bool, commands: &[egui::ViewportCommand]) -> Stage { + use egui::ViewportCommand as C; + let handed_off = commands.iter().any(|c| matches!(c, C::Minimized(true) | C::Visible(false) | C::StartDrag | C::BeginResize(_))); + if input && shown && !handed_off { Stage::Drawing } else { Stage::Idle } +} + +/// Ends an [`Activity`] when dropped ([`Beacon::activity`]). +pub struct ActivityGuard<'a> { + beacon: &'a Beacon, + before: Activity, +} + +impl Drop for ActivityGuard<'_> { + fn drop(&mut self) { + self.beacon.activity.store(self.before as u8, Ordering::Relaxed); + self.beacon.bump(); + } +} + +/// A [`SystemClipboard`] whose calls are marked on [`BEACON`] as clipboard activity. The desktop +/// app wraps the clipboard the interface thread uses; the background probe's own clipboard stays +/// unmarked. +pub struct Marked(pub Box); + +impl SystemClipboard for Marked { + fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> { + let _mark = BEACON.activity(Activity::ClipboardWrite); + self.0.write(flavours) + } + + fn holds_ours(&mut self) -> bool { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.holds_ours() + } + + fn read(&mut self, mimes: &[&'static str]) -> Option { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.read(mimes) + } + + fn has(&mut self, mimes: &[&'static str]) -> bool { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.has(mimes) + } + + fn read_deferred(&mut self, mimes: &[&'static str]) -> Option { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.read_deferred(mimes) + } + + fn change_count(&mut self) -> Option { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.change_count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_mark_moves_the_step_and_the_stage_reads_back() { + let b = Beacon::new(); + let first = b.sample(); + assert_eq!((first.stage, first.activity), (Stage::Idle, Activity::None)); + b.enter(Stage::Interface); + let s = b.sample(); + assert!(s.step > first.step); + assert_eq!(s.stage, Stage::Interface); + b.command("edit.copy"); + assert!(b.sample().step > s.step); + b.enter(Stage::Idle); + assert!(!b.sample().stage.busy()); + } + + #[test] + fn an_activity_ends_with_its_guard_and_restores_the_one_before() { + let b = Beacon::new(); + { + let _write = b.activity(Activity::ClipboardWrite); + { + let _read = b.activity(Activity::ClipboardRead); + assert_eq!(b.sample().activity, Activity::ClipboardRead); + } + assert_eq!(b.sample().activity, Activity::ClipboardWrite); + } + assert_eq!(b.sample().activity, Activity::None); + } + + #[test] + fn only_the_last_commands_and_frames_are_kept_oldest_first() { + let b = Beacon::new(); + for n in 0..RECENT_COMMANDS + 3 { + b.command(&format!("cmd.{n}")); + } + let commands = b.recent_commands().unwrap_or_default(); + assert_eq!(commands.len(), RECENT_COMMANDS); + assert_eq!(commands.first().map(String::as_str), Some("cmd.3")); + assert_eq!(commands.last().map(String::as_str), Some("cmd.10")); + for n in 0..RECENT_FRAMES + 2 { + b.frame(FrameTime { work_ms: n as f32, previous_total_ms: None }, 2); + } + let frames = b.recent_frames().unwrap_or_default(); + assert_eq!(frames.len(), RECENT_FRAMES); + assert_eq!(frames.first().map(|f| f.work_ms), Some(2.0)); + assert_eq!(b.documents(), 2); + } + + #[test] + fn a_command_run_every_frame_does_not_push_out_the_ones_before() { + let b = Beacon::new(); + for n in 0..RECENT_COMMANDS - 2 { + b.command(&format!("cmd.{n}")); + } + // Two panels that each run a query every frame. + for _ in 0..100 { + b.command("symbol.list"); + b.command("swatch.list"); + } + b.command("cmd.0"); + let commands = b.recent_commands().unwrap_or_default(); + let want: Vec = ["cmd.1", "cmd.2", "cmd.3", "cmd.4", "cmd.5", "symbol.list", "swatch.list", "cmd.0"].map(String::from).to_vec(); + assert_eq!(commands, want); + } + + #[test] + fn a_frame_with_input_ends_drawing_unless_the_window_is_hidden_or_handed_to_the_system() { + use egui::ViewportCommand as C; + assert_eq!(after_frame(true, true, &[]), Stage::Drawing); + assert_eq!(after_frame(true, true, &[C::Focus]), Stage::Drawing); + assert_eq!(after_frame(false, true, &[]), Stage::Idle); + assert_eq!(after_frame(true, false, &[]), Stage::Idle); + for c in [C::Minimized(true), C::Visible(false), C::StartDrag, C::BeginResize(egui::ResizeDirection::East)] { + assert_eq!(after_frame(true, true, std::slice::from_ref(&c)), Stage::Idle, "{c:?}"); + } + } + + #[test] + fn input_is_events_or_files() { + let mut raw = egui::RawInput::default(); + assert!(!had_input(&raw)); + raw.hovered_files.push(egui::HoveredFile::default()); + assert!(had_input(&raw)); + #[derive(Debug)] + struct Dropped; + impl egui::DroppedFile for Dropped { + fn path(&self) -> &std::path::Path { + std::path::Path::new("a.svg") + } + fn bytes(&self) -> Result, String> { + Ok(Vec::new()) + } + } + let mut raw = egui::RawInput::default(); + raw.dropped_files.push(std::sync::Arc::new(Dropped)); + assert!(had_input(&raw)); + let mut raw = egui::RawInput::default(); + raw.events.push(egui::Event::PointerGone); + assert!(had_input(&raw)); + } + + #[test] + fn input_in_a_window_shown_in_line_is_marked() { + static B: Beacon = Beacon::new(); + // The host runs a window shown in line as its own pass, with that window's input. + let child_events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let events = child_events.clone(); + egui::Context::set_immediate_viewport_renderer(move |ctx, mut viewport| { + let id = viewport.ids.this; + let viewports = std::iter::once((id, egui::ViewportInfo::default())).collect(); + let input = egui::RawInput { viewport_id: id, viewports, events: events.take(), ..Default::default() }; + ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)).textures_delta.clear(); + }); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + watch_input(&B, &ctx); + let child = egui::ViewportId::from_hash_of("floating panel"); + let frame = |main: Vec| { + ctx.run_ui(egui::RawInput { events: main, ..Default::default() }, |ui| { + ui.ctx().show_viewport_immediate(child, egui::ViewportBuilder::default(), |_, _| {}); + }) + .textures_delta + .clear(); + }; + frame(Vec::new()); + assert!(!B.take_input(), "no input anywhere"); + child_events.replace(vec![egui::Event::PointerGone]); + frame(Vec::new()); + assert!(B.take_input(), "the floating window's input"); + assert!(!B.take_input(), "taken"); + frame(vec![egui::Event::PointerGone]); + assert!(B.take_input(), "the main window's input"); + } + + #[test] + fn the_marked_clipboard_marks_its_calls() { + type Seen = std::sync::Arc>>; + struct Probe(Seen); + impl Probe { + fn see(&self) { + self.0.lock().unwrap().push(BEACON.sample().activity); + } + } + impl SystemClipboard for Probe { + fn write(&mut self, _: &[Flavour]) -> Result<(), String> { + self.see(); + Ok(()) + } + fn holds_ours(&mut self) -> bool { + self.see(); + false + } + fn read(&mut self, _: &[&'static str]) -> Option { + self.see(); + None + } + fn has(&mut self, _: &[&'static str]) -> bool { + self.see(); + false + } + } + let seen = Seen::default(); + let mut m = Marked(Box::new(Probe(seen.clone()))); + let _ = m.write(&[]); + let _ = m.read(&[]); + let _ = m.has(&[]); + let _ = m.holds_ours(); + assert_eq!(BEACON.sample().activity, Activity::None); + assert_eq!(*seen.lock().unwrap(), vec![Activity::ClipboardWrite, Activity::ClipboardRead, Activity::ClipboardRead, Activity::ClipboardRead]); + } + + #[test] + fn ids_that_are_not_command_ids_are_kept_as_other() { + let b = Beacon::new(); + b.command("file.open"); + b.command("/home/someone/private plan.vectorcraft"); + b.command(&"y".repeat(MAX_COMMAND_ID)); + b.command(&"x".repeat(MAX_COMMAND_ID + 1)); + let commands = b.recent_commands().unwrap_or_default(); + // Both are `(other)`, which moves to the end the second time. + assert_eq!(commands, vec!["file.open".to_string(), "y".repeat(MAX_COMMAND_ID), "(other)".into()]); + } + + #[test] + fn a_reader_does_not_wait_while_the_commands_are_being_written() { + let b = Beacon::new(); + b.command("edit.paste"); + let held = b.commands.lock().unwrap_or_else(PoisonError::into_inner); + assert_eq!(b.recent_commands(), None); + drop(held); + assert_eq!(b.recent_commands(), Some(vec!["edit.paste".to_string()])); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c..5e084d2fc 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6500,6 +6500,11 @@ breakText Glyphs Глифи No importer plug-ins installed Няма инсталирани плъгини за импортиране Read files you open in its format and make documents from them Четене на файлове, които отваряте в неговия формат, и създаване на документи от тях +# Copy Troubleshooting Info + Copy Troubleshooting Info Копиране на информация за отстраняване на проблеми +@msg Copied the troubleshooting info Информацията за отстраняване на проблеми е копирана +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Последната сесия на VectorCraft спря да отговаря, преди да се затвори. Помощ › Копиране на информация за отстраняване на проблеми копира подробностите за доклад за грешка + # Safe Mode Safe Mode… Безопасен режим… Safe Mode Безопасен режим diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95cea..dd1f2f03d 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6551,6 +6551,11 @@ shape Open Otvoren No importer plug-ins installed Nisu instalirani dodaci za uvoz Read files you open in its format and make documents from them Čitati datoteke koje otvarate u njegovom formatu i od njih praviti dokumente +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopiraj informacije za rješavanje problema +@msg Copied the troubleshooting info Informacije za rješavanje problema kopirane +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Posljednja sesija VectorCrafta prestala je odgovarati prije zatvaranja. Pomoć › Kopiraj informacije za rješavanje problema kopira detalje za prijavu greške + # Safe Mode Safe Mode… Sigurni način rada… Safe Mode Sigurni način rada diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e0..919aad189 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6752,6 +6752,11 @@ breakText Glyphs Glifs @msg only an exporter or an importer declares `extensions` només un exportador o un importador declara `extensions` @msg an importer always gets an import's budget: leave `budget` out un importador sempre rep el pressupost d’una importació: ometeu `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copia la informació de resolució de problemes +@msg Copied the troubleshooting info S’ha copiat la informació de resolució de problemes +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report La darrera sessió del VectorCraft va deixar de respondre abans de tancar-se. Ajuda › Copia la informació de resolució de problemes copia els detalls per a un informe d’error + # Safe Mode Safe Mode… Mode segur… Safe Mode Mode segur diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fa..94e2db03e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6771,6 +6771,11 @@ breakText Glyphs Glyfy @msg only an exporter or an importer declares `extensions` `extensions` deklaruje jen exportní nebo importní zásuvný modul @msg an importer always gets an import's budget: leave `budget` out importní zásuvný modul dostává vždy rozpočet importu: vynechte `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopírovat informace pro řešení potíží +@msg Copied the troubleshooting info Informace pro řešení potíží zkopírovány +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Poslední relace VectorCraftu přestala odpovídat, než se zavřela. Nápověda › Kopírovat informace pro řešení potíží zkopíruje podrobnosti pro hlášení chyby + # Safe Mode Safe Mode… Nouzový režim… Safe Mode Nouzový režim diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6b..2284f4c36 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6514,6 +6514,11 @@ breakText Glyphs Glyffer No importer plug-ins installed Ingen import-plug-ins installeret Read files you open in its format and make documents from them Læse filer, du åbner i dens format, og lave dokumenter ud fra dem +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopiér fejlfindingsoplysninger +@msg Copied the troubleshooting info Kopierede fejlfindingsoplysningerne +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Den seneste VectorCraft-session holdt op med at svare, før den blev lukket. Hjælp › Kopiér fejlfindingsoplysninger kopierer detaljerne til en fejlrapport + # Safe Mode Safe Mode… Sikker tilstand… Safe Mode Sikker tilstand diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81..210110d1c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6358,6 +6358,11 @@ breakText Glyphs Glyphen @msg only an exporter or an importer declares `extensions` nur ein Export- oder Import-Plug-in gibt `extensions` an @msg an importer always gets an import's budget: leave `budget` out ein Import-Plug-in erhält immer das Budget eines Imports: Lassen Sie `budget` weg +# Copy Troubleshooting Info + Copy Troubleshooting Info Informationen zur Fehlerbehebung kopieren +@msg Copied the troubleshooting info Informationen zur Fehlerbehebung kopiert +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Die letzte VectorCraft-Sitzung reagierte nicht mehr, bevor sie geschlossen wurde. Hilfe › Informationen zur Fehlerbehebung kopieren kopiert die Details für einen Fehlerbericht + # Safe Mode Safe Mode… Abgesicherter Modus… Safe Mode Abgesicherter Modus diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be..ece0b579c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6723,6 +6723,11 @@ breakText Glyphs Γλύφοι @msg only an exporter or an importer declares `extensions` μόνο ένα πρόσθετο εξαγωγής ή εισαγωγής δηλώνει `extensions` @msg an importer always gets an import's budget: leave `budget` out ένα πρόσθετο εισαγωγής παίρνει πάντα τον προϋπολογισμό μιας εισαγωγής: παραλείψτε το `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Αντιγραφή πληροφοριών αντιμετώπισης προβλημάτων +@msg Copied the troubleshooting info Οι πληροφορίες αντιμετώπισης προβλημάτων αντιγράφηκαν +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Η τελευταία συνεδρία του VectorCraft σταμάτησε να αποκρίνεται πριν κλείσει. Βοήθεια › Αντιγραφή πληροφοριών αντιμετώπισης προβλημάτων αντιγράφει τις λεπτομέρειες για αναφορά σφάλματος + # Safe Mode Safe Mode… Ασφαλής λειτουργία… Safe Mode Ασφαλής λειτουργία diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae3..b98786501 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6714,6 +6714,11 @@ breakText Glyphs Glifos @msg only an exporter or an importer declares `extensions` solo un plugin de exportación o de importación declara `extensions` @msg an importer always gets an import's budget: leave `budget` out un plugin de importación siempre recibe el presupuesto de una importación: omita `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copiar información de solución de problemas +@msg Copied the troubleshooting info Se copió la información de solución de problemas +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report La última sesión de VectorCraft dejó de responder antes de cerrarse. Ayuda › Copiar información de solución de problemas copia los detalles para un informe de error + # Safe Mode Safe Mode… Modo seguro… Safe Mode Modo seguro diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f60..6674e959b 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6806,6 +6806,11 @@ breakText Glyphs Merkit @msg only an exporter or an importer declares `extensions` vain vienti- tai tuontilaajennus ilmoittaa `extensions` @msg an importer always gets an import's budget: leave `budget` out tuontilaajennus saa aina tuonnin budjetin: jätä `budget` pois +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopioi vianmääritystiedot +@msg Copied the troubleshooting info Vianmääritystiedot kopioitiin +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraftin edellinen istunto lakkasi vastaamasta ennen kuin se suljettiin. Ohje › Kopioi vianmääritystiedot kopioi tiedot virheraporttia varten + # Safe Mode Safe Mode… Vikasietotila… Safe Mode Vikasietotila diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b..7cdb1f8a0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6766,6 +6766,11 @@ breakText Glyphs Glyphes @msg only an exporter or an importer declares `extensions` seul un module externe d’exportation ou d’importation déclare `extensions` @msg an importer always gets an import's budget: leave `budget` out un module externe d’importation reçoit toujours le budget d’une importation : omettez `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copier les informations de dépannage +@msg Copied the troubleshooting info Informations de dépannage copiées +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report La dernière session de VectorCraft a cessé de répondre avant de se fermer. Aide › Copier les informations de dépannage copie les détails pour un rapport de bogue + # Safe Mode Safe Mode… Mode sans échec… Safe Mode Mode sans échec diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779..2cc8866e7 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8188,6 +8188,11 @@ swatch Yellow पीला swatch Yellow Green पीला हरा theme Appearance अपीयरेंस +# Copy Troubleshooting Info + Copy Troubleshooting Info समस्या निवारण जानकारी कॉपी करें +@msg Copied the troubleshooting info समस्या निवारण जानकारी कॉपी की गई +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraft के पिछले सत्र ने बंद होने से पहले प्रतिक्रिया देना बंद कर दिया था। सहायता ›समस्या निवारण जानकारी कॉपी करें बग रिपोर्ट के लिए विवरण कॉपी करता है + # Safe Mode Safe Mode… सुरक्षित मोड… Safe Mode सुरक्षित मोड diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339b..04cea3fc8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6755,6 +6755,11 @@ breakText Glyphs Glifák @msg only an exporter or an importer declares `extensions` csak exportáló vagy importáló bővítmény adhat meg `extensions` értéket @msg an importer always gets an import's budget: leave `budget` out az importáló bővítmény mindig egy importálás keretét kapja: hagyja el a `budget` kulcsot +# Copy Troubleshooting Info + Copy Troubleshooting Info Hibaelhárítási adatok másolása +@msg Copied the troubleshooting info Hibaelhárítási adatok másolva +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report A VectorCraft legutóbbi munkamenete nem válaszolt, mielőtt bezárult. A Súgó › Hibaelhárítási adatok másolása kimásolja a részleteket egy hibajelentéshez + # Safe Mode Safe Mode… Csökkentett mód… Safe Mode Csökkentett mód diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97..c799fbde3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6514,6 +6514,11 @@ breakText Glyphs Glif No importer plug-ins installed Tidak ada plug-in impor yang terinstal Read files you open in its format and make documents from them Membaca file yang Anda buka dalam formatnya dan membuat dokumen darinya +# Copy Troubleshooting Info + Copy Troubleshooting Info Salin info pemecahan masalah +@msg Copied the troubleshooting info Berhasil menyalin info pemecahan masalah +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Sesi terakhir VectorCraft berhenti merespons sebelum ditutup. Bantuan › Salin info pemecahan masalah menyalin detailnya untuk laporan bug + # Safe Mode Safe Mode… Mode aman… Safe Mode Mode aman diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b5..0911606b0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6392,6 +6392,11 @@ breakText Glyphs Glifi @msg only an exporter or an importer declares `extensions` solo un plug-in di esportazione o di importazione dichiara `extensions` @msg an importer always gets an import's budget: leave `budget` out un plug-in di importazione riceve sempre il budget di un’importazione: ometti `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copia informazioni per la risoluzione dei problemi +@msg Copied the troubleshooting info Informazioni per la risoluzione dei problemi copiate +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report L’ultima sessione di VectorCraft ha smesso di rispondere prima di chiudersi. Aiuto › Copia informazioni per la risoluzione dei problemi copia i dettagli per una segnalazione di bug + # Safe Mode Safe Mode… Modalità provvisoria… Safe Mode Modalità provvisoria diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcad..7c711fef5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6457,6 +6457,11 @@ breakText Glyphs 字形 @msg only an exporter or an importer declares `extensions` `extensions` を宣言できるのは書き出しプラグインと読み込みプラグインだけです @msg an importer always gets an import's budget: leave `budget` out 読み込みプラグインには常に読み込みの予算が割り当てられます: `budget` は省略してください +# Copy Troubleshooting Info + Copy Troubleshooting Info トラブルシューティング情報をコピー +@msg Copied the troubleshooting info トラブルシューティング情報をコピーしました +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 前回の VectorCraft のセッションは、閉じる前に応答しなくなりました。ヘルプ › トラブルシューティング情報をコピー で、バグ報告用の詳細をコピーできます + # Safe Mode Safe Mode… セーフモード… Safe Mode セーフモード diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c42..e64092fb6 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6880,6 +6880,11 @@ font-script Japanese იაპონური @msg only an exporter or an importer declares `extensions` `extensions`-ს მხოლოდ ექსპორტის ან იმპორტის პლაგინი აცხადებს @msg an importer always gets an import's budget: leave `budget` out იმპორტის პლაგინი ყოველთვის იღებს იმპორტის ბიუჯეტს: გამოტოვეთ `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info პრობლემების მოგვარების ინფორმაციის კოპირება +@msg Copied the troubleshooting info პრობლემების მოგვარების ინფორმაცია კოპირებულია +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraft-ის ბოლო სესიამ დახურვამდე პასუხის გაცემა შეწყვიტა. დახმარება › პრობლემების მოგვარების ინფორმაციის კოპირება აკოპირებს დეტალებს შეცდომის ანგარიშისთვის + # Safe Mode Safe Mode… უსაფრთხო რეჟიმი… Safe Mode უსაფრთხო რეჟიმი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2..201bc2a78 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6353,6 +6353,11 @@ breakText Glyphs 글리프 @msg only an exporter or an importer declares `extensions` `extensions`는 내보내기 또는 가져오기 플러그인만 선언합니다 @msg an importer always gets an import's budget: leave `budget` out 가져오기 플러그인은 항상 가져오기 예산을 받습니다: `budget`을 생략하세요 +# Copy Troubleshooting Info + Copy Troubleshooting Info 문제 해결 정보 복사 +@msg Copied the troubleshooting info 문제 해결 정보를 복사했습니다 +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 지난 VectorCraft 세션이 닫히기 전에 응답을 멈췄습니다. 도움말 › 문제 해결 정보 복사로 버그 보고용 세부 정보를 복사할 수 있습니다 + # Safe Mode Safe Mode… 안전 모드… Safe Mode 안전 모드 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc..73539d8eb 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6565,6 +6565,11 @@ breakText Glyphs Glyphs No importer plug-ins installed Geen importplug-ins geïnstalleerd Read files you open in its format and make documents from them Bestanden lezen die u in zijn indeling opent en er documenten van maken +# Copy Troubleshooting Info + Copy Troubleshooting Info Probleemoplossingsgegevens kopiëren +@msg Copied the troubleshooting info Probleemoplossingsgegevens gekopieerd +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report De laatste sessie van VectorCraft reageerde niet meer voordat deze werd gesloten. Help › Probleemoplossingsgegevens kopiëren kopieert de details voor een foutrapport + # Safe Mode Safe Mode… Veilige modus… Safe Mode Veilige modus diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4..5e174282a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6627,6 +6627,11 @@ breakText Glyphs Glify @msg only an exporter or an importer declares `extensions` `extensions` deklaruje tylko wtyczka eksportu lub importu @msg an importer always gets an import's budget: leave `budget` out wtyczka importu zawsze dostaje budżet importu: pomiń `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopiuj informacje diagnostyczne +@msg Copied the troubleshooting info Skopiowano informacje diagnostyczne +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Ostatnia sesja VectorCraft przestała odpowiadać przed zamknięciem. Pomoc › Kopiuj informacje diagnostyczne kopiuje szczegóły do zgłoszenia błędu + # Safe Mode Safe Mode… Tryb awaryjny… Safe Mode Tryb awaryjny diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda..a7f19034f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6844,6 +6844,11 @@ swatch Yellow Green Verde-amarelado @msg only an exporter or an importer declares `extensions` só um plug-in de exportação ou de importação declara `extensions` @msg an importer always gets an import's budget: leave `budget` out um plug-in de importação sempre recebe o orçamento de uma importação: omita `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copiar informações de solução de problemas +@msg Copied the troubleshooting info Informações de solução de problemas copiadas +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report A última sessão do VectorCraft parou de responder antes de fechar. Ajuda › Copiar informações de solução de problemas copia os detalhes para um relatório de bug + # Safe Mode Safe Mode… Modo de Segurança… Safe Mode Modo de Segurança diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb542582..4bc7eef69 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6860,6 +6860,11 @@ shape Open Открытый @msg only an exporter or an importer declares `extensions` `extensions` объявляет только модуль экспорта или импорта @msg an importer always gets an import's budget: leave `budget` out модуль импорта всегда получает бюджет импорта: не указывайте `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Копировать сведения для устранения неполадок +@msg Copied the troubleshooting info Сведения для устранения неполадок скопированы +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Последний сеанс VectorCraft перестал отвечать перед закрытием. Справка › Копировать сведения для устранения неполадок копирует подробности для отчёта об ошибке + # Safe Mode Safe Mode… Безопасный режим… Safe Mode Безопасный режим diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de..db301f895 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8256,6 +8256,11 @@ digits Hindi Hindi @msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` en importfunktion anger 1 till {MAX_EXTENSIONS} `extensions` @msg an importer always gets an import's budget: leave `budget` out en importfunktion får alltid en imports budget: utelämna `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopiera felsökningsinformation +@msg Copied the troubleshooting info Kopierade felsökningsinformationen +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Den senaste VectorCraft-sessionen slutade svara innan den stängdes. Hjälp › Kopiera felsökningsinformation kopierar detaljerna till en felrapport + # Safe Mode Safe Mode… Felsäkert läge… Safe Mode Felsäkert läge diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc..1a9a7fc92 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6727,6 +6727,11 @@ breakText Glyphs Glifler @msg only an exporter or an importer declares `extensions` `extensions` yalnızca dışa veya içe aktarma eklentisi bildirir @msg an importer always gets an import's budget: leave `budget` out içe aktarma eklentisi her zaman bir içe aktarmanın bütçesini alır: `budget` değerini çıkarın +# Copy Troubleshooting Info + Copy Troubleshooting Info Sorun Giderme Bilgilerini Kopyala +@msg Copied the troubleshooting info Sorun giderme bilgileri kopyalandı +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraft'ın son oturumu kapanmadan önce yanıt vermeyi durdurdu. Yardım › Sorun Giderme Bilgilerini Kopyala, hata raporu için ayrıntıları kopyalar + # Safe Mode Safe Mode… Güvenli Mod… Safe Mode Güvenli Mod diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b..a79305a85 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6372,6 +6372,11 @@ breakText Glyphs Гліфи @msg only an exporter or an importer declares `extensions` `extensions` оголошує лише модуль експорту або імпорту @msg an importer always gets an import's budget: leave `budget` out модуль імпорту завжди отримує бюджет імпорту: не вказуйте `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Копіювати відомості для усунення несправностей +@msg Copied the troubleshooting info Відомості для усунення несправностей скопійовано +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Останній сеанс VectorCraft перестав відповідати перед закриттям. Довідка › Копіювати відомості для усунення несправностей копіює подробиці для звіту про помилку + # Safe Mode Safe Mode… Безпечний режим… Safe Mode Безпечний режим diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a6..f186cc5de 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6373,6 +6373,11 @@ breakText Glyphs Glyph @msg only an exporter or an importer declares `extensions` chỉ plug-in xuất hoặc nhập mới khai báo `extensions` @msg an importer always gets an import's budget: leave `budget` out plug-in nhập luôn nhận ngân sách của một lần nhập: hãy bỏ `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Sao chép thông tin khắc phục sự cố +@msg Copied the troubleshooting info Đã sao chép thông tin khắc phục sự cố +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Phiên VectorCraft trước đã ngừng phản hồi trước khi đóng. Trợ giúp › Sao chép thông tin khắc phục sự cố sẽ sao chép chi tiết cho báo cáo lỗi + # Safe Mode Safe Mode… Chế độ an toàn… Safe Mode Chế độ an toàn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe..0495881d8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6612,6 +6612,11 @@ breakText Glyphs 字形 @msg only an exporter or an importer declares `extensions` 只有导出或导入增效工具才声明 `extensions` @msg an importer always gets an import's budget: leave `budget` out 导入增效工具始终获得导入的预算:请省略 `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info 复制故障排除信息 +@msg Copied the troubleshooting info 已复制故障排除信息 +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 上次的 VectorCraft 会话在关闭前停止了响应。帮助 › 复制故障排除信息 可复制用于错误报告的详细信息 + # Safe Mode Safe Mode… 安全模式… Safe Mode 安全模式 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e770..a80fa13dc 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6410,6 +6410,11 @@ breakText Glyphs 字符 @msg only an exporter or an importer declares `extensions` 只有匯出或匯入增效模組才宣告 `extensions` @msg an importer always gets an import's budget: leave `budget` out 匯入增效模組一律取得匯入的預算:請勿指定 `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info 複製疑難排解資訊 +@msg Copied the troubleshooting info 已複製疑難排解資訊 +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 上次的 VectorCraft 工作階段在關閉前停止回應。說明 › 複製疑難排解資訊 可複製用於錯誤回報的詳細資料 + # Safe Mode Safe Mode… 安全模式… Safe Mode 安全模式 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 644125b54..b5035ee12 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -43,6 +43,7 @@ pub mod font_menu; mod frame_rate; mod free_transform; pub mod graphics; +pub mod hang_beacon; pub mod i18n; pub mod icon_data; pub mod icons; @@ -80,6 +81,7 @@ pub mod toolbar; mod tools_float; pub mod touch; pub mod trace_runs; +pub mod troubleshooting; mod ui_fonts; pub mod unsaved; pub mod updates_ui; @@ -222,6 +224,9 @@ pub struct VectorcraftApp { /// The graphics adapter the window renders with ("name (backend)"), as the host reports it: /// shown in Help › About and `ui.inspect` for GPU bug reports. `None` when unknown. pub graphics_adapter: Option, + /// Where [`Self::run`] marks each command for the desktop app's hang report: + /// [`hang_beacon::BEACON`], or a test's own. + pub(crate) beacon: &'static hang_beacon::Beacon, /// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details. pub place: place::PlaceState, /// The Paste commands can paste from the system clipboard alone: it holds something to paste diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 1223e1e42..1e84840b9 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -656,6 +656,7 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::updates_ui::check(app), "help.safeMode" => crate::dialogs::safe_mode::open(app), + "help.copyTroubleshootingInfo" => crate::troubleshooting::copy(app), "help.commandPalette" => { app.ui.palette_open = !app.ui.palette_open; app.ui.palette_query.clear(); diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 0c46950f3..4a1c66a29 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -712,6 +712,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Search Commands…", "help.commandPalette"), todos("VectorCraft Help…", "F1"), c("Safe Mode…", "help.safeMode"), + c("Copy Troubleshooting Info", "help.copyTroubleshootingInfo"), Sep, c("Check for Updates…", "help.checkForUpdates"), c("About VectorCraft", "help.about"), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index a96f13e67..9c9ba1f93 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -743,6 +743,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open the Safe Mode dialog (dialog `safeMode`): whether VectorCraft is in Safe Mode, and the disabled plug-ins, each with Enable on Re-launch (engine: plugin.setEnabled). Closing it leaves Safe Mode as it is; Safe Mode is chosen at launch (--safe-mode, or the choice after a launch that stopped while a plug-in was loading, dialog `pluginStopped`)", ), + ( + "help.copyTroubleshootingInfo", + "Copy Troubleshooting Info", + "", + "{} put text for a bug report on the clipboard: the version, and in the desktop app the platform, the window system, the graphics processor, the commands and frame times of the last few frames, and the hang reports of this session and the session before (no document contents or file paths) → {chars}", + ), ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), ( "ui.swatchOptions", diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 901c0a340..45395b8fa 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -179,6 +179,10 @@ pub struct Services { /// reports for a window on that monitor. Empty on the web, and where the host cannot place a /// window by these points, which sends floating panels to the main window's monitor. pub dock_monitors: Vec, + /// The text Help › Copy Troubleshooting Info copies (desktop: the platform, the graphics + /// processor, recent commands and frame times, and the hang reports of this session and the + /// one before). Without it the command copies the version only ([`crate::troubleshooting`]). + pub troubleshooting: Option String>>, } /// Rewrites one window's raw input before egui reads it ([`Services::native_input_hook`]). diff --git a/crates/ui-egui/src/troubleshooting.rs b/crates/ui-egui/src/troubleshooting.rs new file mode 100644 index 000000000..5624a7996 --- /dev/null +++ b/crates/ui-egui/src/troubleshooting.rs @@ -0,0 +1,72 @@ +//! Help › Copy Troubleshooting Info (`help.copyTroubleshootingInfo`): puts the text the host +//! gives ([`crate::Services::troubleshooting`]) on the clipboard for a bug report. The desktop app +//! gives the platform, the graphics processor, recent commands and frame times, and the hang reports +//! of this session and the one before (its `hang_watch` module); without the service the text is +//! the version only. + +use serde_json::{Value, json}; + +use crate::VectorcraftApp; + +/// The status message after the text was put on the clipboard. +pub const COPIED: &str = "Copied the troubleshooting info"; + +/// The status message at launch when the last session stopped responding and was closed before it +/// answered again. +pub const LAST_SESSION_STOPPED: &str = + "The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report"; + +/// The text the command copies. +pub fn text(app: &VectorcraftApp) -> String { + match &app.services.troubleshooting { + Some(text) => text(), + None => format!("VectorCraft {}\n", env!("CARGO_PKG_VERSION")), + } +} + +/// `help.copyTroubleshootingInfo`: put [`text`] on the clipboard. → `{chars}`, the text's length in +/// characters. +pub fn copy(app: &mut VectorcraftApp) -> Result { + let text = text(app); + let chars = text.chars().count(); + app.clipboard_out = Some(text); + app.status(COPIED); + Ok(json!({ "chars": chars })) +} + +/// Tell the user, at launch, that the last session stopped responding and was closed. +pub fn last_session_stopped(app: &mut VectorcraftApp) { + app.status(LAST_SESSION_STOPPED); +} + +#[cfg(test)] +mod tests { + use vectorcraft_engine::Session; + + use super::*; + + #[test] + fn the_command_copies_the_hosts_text_and_says_so() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.services.troubleshooting = Some(Box::new(|| "platform: test\n".to_string())); + let r = app.run("help.copyTroubleshootingInfo", json!({})).unwrap(); + assert_eq!(r["chars"], 15); + assert_eq!(app.clipboard_out.as_deref(), Some("platform: test\n")); + assert_eq!(app.ui.status, COPIED); + } + + #[test] + fn without_the_service_the_version_is_copied() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("help.copyTroubleshootingInfo", json!({})).unwrap(); + assert_eq!(app.clipboard_out, Some(format!("VectorCraft {}\n", env!("CARGO_PKG_VERSION")))); + } + + #[test] + fn running_a_command_marks_it_for_the_hang_report() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.beacon = Box::leak(Box::new(crate::hang_beacon::Beacon::new())); + app.run("help.copyTroubleshootingInfo", json!({})).unwrap(); + assert_eq!(app.beacon.recent_commands(), Some(vec!["help.copyTroubleshootingInfo".to_string()])); + } +} diff --git a/docs/development.md b/docs/development.md index c6523bd03..bdab8c174 100644 --- a/docs/development.md +++ b/docs/development.md @@ -89,6 +89,8 @@ The desktop app writes its `log` records to standard error and to `logs/vectorcr By default VectorCraft's own crates log at `info` and everything else at `warn`. `RUST_LOG` replaces that with env_logger-style directives, for example `RUST_LOG=debug`, `RUST_LOG=warn,vectorcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`vectorcraft*=debug`). The logger is `apps/vectorcraft/src/logging.rs`. `vectorcraft-cli mcp` has its own logger, which sends records to the MCP client (`docs/mcp.md`). +When the interface thread stays in one part of a frame for 3 s (VectorCraft's own value), a watching thread writes a hang report to the log and to `logs/hang-report-.txt`, which the process keeps locked. The report gives the part of the frame (the input hook, the app's logic, the interface, or the renderer after a frame in which any window had input), a system clipboard read or write in progress, the last 8 different command ids (a command run again moves to the end of the list), the times of the last 16 frames, the platform, the window system (Wayland, X11, AppKit, Win32, other or unknown), `XDG_CURRENT_DESKTOP`, `XDG_SESSION_TYPE`, whether the app runs in a Flatpak, the graphics adapter, the present mode and frame latency the app asks the window surface for, the session's running time and how many documents are open. It holds no document contents, file paths or command parameters, and no native stack. The time after a frame that minimizes or hides the main window, or starts moving or resizing it, is not timed, and neither is an in-line file dialog (Windows and Linux) or an open menu of the macOS menu bar. When the interface answers again, the log records after how long and the file is removed. A file that no process holds locked at a later launch was left by a session that was closed while it did not answer (a locked one belongs to another instance and is left alone): that launch writes the report to the log, moves the file to `logs/hang-report.1.txt` (older abandoned reports beyond the newest 16 are removed) and, unless another start-up message is showing, says so in the status bar. Help › Copy Troubleshooting Info (`help.copyTroubleshootingInfo`) copies the platform facts, the recent commands and frame times, and the hang reports of this session and the last one, for a bug report. The watch is `apps/vectorcraft/src/hang_watch.rs`; the marks it reads are `crates/ui-egui/src/hang_beacon.rs`. + ## Environment variables (desktop app) | Variable | Effect | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f74..f96bfefc5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1652,6 +1652,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode ` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. +- **Hang report and Copy Troubleshooting Info:** when the desktop app's interface thread stays in one part of a frame for 3 s (VectorCraft's own value), a watching thread writes a hang report to the log and to `logs/hang-report-.txt`, and removes the file when the interface answers again. A later launch that finds a report no process holds writes it to the log, keeps it as `logs/hang-report.1.txt` and, unless another start-up message is showing, says so in the status bar. Help › Copy Troubleshooting Info (`help.copyTroubleshootingInfo`) copies the platform facts, the recent commands and frame times, and the reports of this session and the last one. Regressions: `apps/vectorcraft/src/hang_watch/tests.rs`, `crates/ui-egui/src/hang_beacon.rs`, `crates/ui-egui/src/troubleshooting.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Bulk memory fuzz cases use the host's fuel constants:** `crates/engine/tests/plugin_fuzz.rs` samples runs around one slice of the instruction budget and lengths around the bytes one unit of fuel pays for with `vectorcraft_plugins`' own `FUEL_SLICE` and `BYTES_COPIED_PER_FUEL`, so the sampled cases move when either value changes. Regressions: `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; for a plug-in that lists permissions, `plugin.install` needs `grant` and folders install it only when it is granted. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. From d8be61ddb19db39cf28488c5e5af235dab59daf9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 16:27:24 -0800 Subject: [PATCH 2/5] Help: making a save dialog's folder is timed for the hang report An in-line save dialog that first makes its folder marked the folder work as the dialog too, so a stall in making the folder wrote no hang report. The folder is now made before the dialog is marked, on the background dialog thread as well, and a test checks the order. --- apps/vectorcraft/src/host_services.rs | 29 +++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 88dd4e6c0..50f1792eb 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -120,7 +120,6 @@ fn make_folder(request: &PickRequest) { #[cfg(not(target_os = "macos"))] #[cfg_attr(windows, allow(unused_variables))] fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec { - make_folder(request); #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] if let PickRequest::Save(pick) = request && let Some(answer) = portal_save::pick(pick, parent.as_deref()) @@ -139,9 +138,30 @@ fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) /// Show the dialog for `request` over `parent` now → the paths picked. #[cfg(not(target_os = "macos"))] fn pick_now(request: PickRequest, parent: &Parent) -> Vec { - // The dialog waits for the user: not a stall for the hang report (`hang_watch`). - let _mark = vectorcraft_ui_egui::hang_beacon::BEACON.activity(vectorcraft_ui_egui::hang_beacon::Activity::Dialog); - show_dialog(file_dialog(&request, parent), &request, parent) + let beacon = &vectorcraft_ui_egui::hang_beacon::BEACON; + marked_dialog(beacon, || make_folder(&request), || show_dialog(file_dialog(&request, parent), &request, parent)) +} + +/// Run `prepare` (making the dialog's folder), then `show`, marked on `beacon` as a dialog: the +/// dialog waits for the user and is not a stall for the hang report (`hang_watch`), while `prepare` +/// is still timed. +#[cfg(not(target_os = "macos"))] +fn marked_dialog(beacon: &vectorcraft_ui_egui::hang_beacon::Beacon, prepare: impl FnOnce(), show: impl FnOnce() -> T) -> T { + prepare(); + let _mark = beacon.activity(vectorcraft_ui_egui::hang_beacon::Activity::Dialog); + show() +} + +#[cfg(all(test, not(target_os = "macos")))] +#[test] +fn making_a_dialogs_folder_is_timed_and_the_dialog_is_not() { + use vectorcraft_ui_egui::hang_beacon::{Activity, Beacon}; + let beacon = Beacon::new(); + let mut preparing = None; + let showing = marked_dialog(&beacon, || preparing = Some(beacon.sample().activity), || beacon.sample().activity); + assert_eq!(preparing, Some(Activity::None), "making the folder is timed"); + assert_eq!(showing, Activity::Dialog, "the dialog is not"); + assert_eq!(beacon.sample().activity, Activity::None, "the mark ends with the dialog"); } /// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an @@ -241,6 +261,7 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti std::thread::Builder::new() .name("file-dialog".into()) .spawn(move || { + make_folder(&request); // The app gone meanwhile has no use for the answer. let _ = tx.send(show_dialog(dialog, &request, &parent)); ui.request_repaint(); From 7f5e4519a5a825ae1fcb0988580a242810c2f4ff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:26:09 -0800 Subject: [PATCH 3/5] Help: input in a focused floating window is timed for the hang report The end of a frame counted the renderer as busy only when the main window had the focus, so a stall drawing input in a focused floating panel or document window wrote no hang report. A floating window that has the focus and is not minimized now counts too. A frame that sends that window commands to minimize or hide it, or to move or resize it, ends idle, including commands its own pass sent, which are noted before egui passes them on. --- apps/vectorcraft/src/app.rs | 12 +++- crates/ui-egui/src/hang_beacon.rs | 113 +++++++++++++++++++++++++++--- 2 files changed, 114 insertions(+), 11 deletions(-) diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index bfd8ce1c1..288fa6cdc 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -128,10 +128,18 @@ impl App { BEACON.frame(FrameTime { work_ms, previous_total_ms }, self.app.session.documents().len()); } // Hide on macOS hides the window at once, before the window reports losing focus. - let shown = ctx.input(|i| i.focused && i.viewport().minimized != Some(true)) + let main_shown = ctx.input(|i| i.focused && i.viewport().minimized != Some(true)) && self.winit_window.as_ref().is_none_or(|w| w.is_visible() != Some(false)); + // A floating panel or document window with the focus counts as well, with the commands it + // was sent after its pass, and those its pass sent (`hang_beacon::watch_input`). + let shown = ctx.input(|i| hang_beacon::shown_window(main_shown, &i.raw.viewports)); + let handed_off = BEACON.take_handed_off(); let input = BEACON.take_input(); - BEACON.enter(ctx.viewport(|vp| hang_beacon::after_frame(input, shown, &vp.commands))); + let stage = match shown { + Some(id) => ctx.viewport_for(id, |vp| hang_beacon::after_frame(input && !handed_off.contains(&id), true, &vp.commands)), + None => hang_beacon::after_frame(input, false, &[]), + }; + BEACON.enter(stage); } fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { diff --git a/crates/ui-egui/src/hang_beacon.rs b/crates/ui-egui/src/hang_beacon.rs index c681bc813..3331a1e39 100644 --- a/crates/ui-egui/src/hang_beacon.rs +++ b/crates/ui-egui/src/hang_beacon.rs @@ -159,6 +159,9 @@ pub struct Beacon { documents: AtomicUsize, /// A pass of some window had input since [`Beacon::take_input`] last ran. input: AtomicBool, + /// The windows a pass handed to the system since [`Beacon::take_handed_off`] last ran. Only the + /// interface thread uses it. + handed_off: Mutex>, commands: Mutex>, frames: Mutex>, } @@ -177,6 +180,7 @@ impl Beacon { activity: AtomicU8::new(Activity::None as u8), documents: AtomicUsize::new(0), input: AtomicBool::new(false), + handed_off: Mutex::new(Vec::new()), commands: Mutex::new(VecDeque::new()), frames: Mutex::new(VecDeque::new()), } @@ -234,6 +238,20 @@ impl Beacon { self.input.swap(false, Ordering::Relaxed) } + /// A pass of window `id` sent it commands that hand it to the system ([`hands_off`], + /// [`watch_input`]). + pub fn hand_off(&self, id: egui::ViewportId) { + let mut windows = self.handed_off.lock().unwrap_or_else(PoisonError::into_inner); + if !windows.contains(&id) { + windows.push(id); + } + } + + /// The windows a pass handed to the system since the last call. + pub fn take_handed_off(&self) -> Vec { + std::mem::take(&mut *self.handed_off.lock().unwrap_or_else(PoisonError::into_inner)) + } + /// A frame ended with `time`, with `documents` documents open. pub fn frame(&self, time: FrameTime, documents: usize) { self.documents.store(documents, Ordering::Relaxed); @@ -285,7 +303,9 @@ pub fn had_input(raw: &egui::RawInput) -> bool { /// Mark on `beacon` every pass of every window of `ctx` that had input ([`had_input`]): the main /// window's, after the host's input hook added its own, and those of the windows shown in line -/// (floating panels and documents), whose input does not pass through that hook. +/// (floating panels and documents), whose input does not pass through that hook. Mark as well every +/// pass of a window shown in line that sends its window commands that hand it to the system +/// ([`hands_off`]), before egui passes them on; the main window's are read at the frame's end. pub fn watch_input(beacon: &'static Beacon, ctx: &egui::Context) { ctx.on_begin_pass( "VectorCraft hang report input", @@ -295,17 +315,39 @@ pub fn watch_input(beacon: &'static Beacon, ctx: &egui::Context) { } }), ); + ctx.on_end_pass( + "VectorCraft hang report hand-off", + std::sync::Arc::new(move |ui| { + if ui.ctx().viewport_id() != egui::ViewportId::ROOT && ui.ctx().viewport(|vp| hands_off(&vp.commands)) { + beacon.hand_off(ui.ctx().viewport_id()); + } + }), + ); } -/// The stage at the end of a frame: [`Stage::Drawing`] when a pass had `input` and the main -/// window is `shown` (focused, not minimized or hidden), since input asks for the next frame at -/// once; otherwise [`Stage::Idle`]. A frame that sends the main window `commands` that minimize or -/// hide it, or hand it to the system to move or resize, also ends idle: the system may hold back -/// the next frame until the user is done. -pub fn after_frame(input: bool, shown: bool, commands: &[egui::ViewportCommand]) -> Stage { +/// Whether `commands` minimize or hide their window, or hand it to the system to move or resize: +/// the system may hold back the next frame until the user is done. +pub fn hands_off(commands: &[egui::ViewportCommand]) -> bool { use egui::ViewportCommand as C; - let handed_off = commands.iter().any(|c| matches!(c, C::Minimized(true) | C::Visible(false) | C::StartDrag | C::BeginResize(_))); - if input && shown && !handed_off { Stage::Drawing } else { Stage::Idle } + commands.iter().any(|c| matches!(c, C::Minimized(true) | C::Visible(false) | C::StartDrag | C::BeginResize(_))) +} + +/// The window whose end of frame sets the stage ([`after_frame`]): the main window when it is +/// `main_shown`; otherwise a window shown in line (a floating panel or document) that `viewports` +/// (the main window's input) gives as focused and not minimized; otherwise none. +pub fn shown_window(main_shown: bool, viewports: &egui::ViewportIdMap) -> Option { + if main_shown { + return Some(egui::ViewportId::ROOT); + } + viewports.iter().find(|(id, v)| **id != egui::ViewportId::ROOT && v.focused == Some(true) && v.minimized != Some(true)).map(|(id, _)| *id) +} + +/// The stage at the end of a frame: [`Stage::Drawing`] when a pass had `input` and a window is +/// `shown` (focused, not minimized or hidden: [`shown_window`]), since input asks for the next +/// frame at once; otherwise [`Stage::Idle`]. A frame that sends that window `commands` that +/// minimize or hide it, or hand it to the system to move or resize ([`hands_off`]), also ends idle. +pub fn after_frame(input: bool, shown: bool, commands: &[egui::ViewportCommand]) -> Stage { + if input && shown && !hands_off(commands) { Stage::Drawing } else { Stage::Idle } } /// Ends an [`Activity`] when dropped ([`Beacon::activity`]). @@ -496,6 +538,59 @@ mod tests { assert!(B.take_input(), "the main window's input"); } + #[test] + fn a_window_shown_in_line_handed_to_the_system_is_marked() { + static B: Beacon = Beacon::new(); + egui::Context::set_immediate_viewport_renderer(|ctx, mut viewport| { + let id = viewport.ids.this; + let viewports = std::iter::once((id, egui::ViewportInfo::default())).collect(); + let input = egui::RawInput { viewport_id: id, viewports, ..Default::default() }; + ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)).textures_delta.clear(); + }); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + watch_input(&B, &ctx); + let child = egui::ViewportId::from_hash_of("floating panel"); + let frame = |command: Option, main: Option| { + ctx.run_ui(egui::RawInput::default(), |ui| { + ui.ctx().show_viewport_immediate(child, egui::ViewportBuilder::default(), |ui, _| { + if let Some(c) = command.clone() { + ui.ctx().send_viewport_cmd(c); + } + }); + if let Some(c) = main.clone() { + ui.ctx().send_viewport_cmd(c); + } + }) + .textures_delta + .clear(); + }; + frame(None, None); + assert!(B.take_handed_off().is_empty(), "nothing handed off"); + frame(Some(egui::ViewportCommand::StartDrag), None); + assert_eq!(B.take_handed_off(), [child], "the floating window starts a move"); + assert!(B.take_handed_off().is_empty(), "taken"); + frame(Some(egui::ViewportCommand::Focus), None); + assert!(B.take_handed_off().is_empty(), "another command"); + frame(None, Some(egui::ViewportCommand::StartDrag)); + assert!(B.take_handed_off().is_empty(), "the main window's commands are read at the frame's end"); + } + + #[test] + fn a_focused_window_shown_in_line_is_the_shown_window() { + use egui::{ViewportId, ViewportIdMap, ViewportInfo}; + let child = ViewportId::from_hash_of("document window"); + let info = |focused: bool, minimized: bool| ViewportInfo { focused: Some(focused), minimized: Some(minimized), ..Default::default() }; + let map = |root: ViewportInfo, window: ViewportInfo| -> ViewportIdMap { + [(ViewportId::ROOT, root), (child, window)].into_iter().collect() + }; + assert_eq!(shown_window(true, &map(info(true, false), info(false, false))), Some(ViewportId::ROOT)); + assert_eq!(shown_window(false, &map(info(false, false), info(true, false))), Some(child), "the document window has the focus"); + assert_eq!(shown_window(false, &map(info(false, false), info(true, true))), None, "minimized"); + assert_eq!(shown_window(false, &map(info(false, false), info(false, false))), None, "no window has the focus"); + assert_eq!(shown_window(false, &map(info(true, false), info(false, false))), None, "the main window is not shown"); + } + #[test] fn the_marked_clipboard_marks_its_calls() { type Seen = std::sync::Arc>>; From e9942b69d2fb0c64eae00a60feda4b488eee4d7e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:36:11 -0800 Subject: [PATCH 4/5] Help: a frame run while a file dialog waits is timed for the hang report The input hook, the logic and the interface of a frame now mark their time as timed whatever the activity around them, so a frame the system runs while an in-line file dialog waits for the user is timed too, and the dialog's wait is not timed again once the frame ends. --- apps/vectorcraft/src/app.rs | 6 +++--- crates/ui-egui/src/hang_beacon.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 288fa6cdc..0391b7534 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -35,7 +35,7 @@ impl eframe::App for App { // control channel asks for, and the frames carrying input it injected, to the automation roots // (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { - BEACON.enter(Stage::Logic); + let _timed = BEACON.enter_timed(Stage::Logic); file_access::unconfined(|| self.logic_frame(ctx, frame)); self.app.finish_ime_logic(ctx); BEACON.enter(Stage::Idle); @@ -43,7 +43,7 @@ impl eframe::App for App { /// Restore the borderless caption, apply platform menu and pen input, and hand the frame to the /// app's raw-input hook while the keyboard user is unconfined. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - BEACON.enter(Stage::Input); + let _timed = BEACON.enter_timed(Stage::Input); self.frame_start = Some(std::time::Instant::now()); // winit gives the window its caption back when it changes the window's flags (maximize, // restore, …): take it off again before the frame is laid out (#1758). @@ -68,7 +68,7 @@ impl eframe::App for App { BEACON.enter(Stage::Idle); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { - BEACON.enter(Stage::Interface); + let _timed = BEACON.enter_timed(Stage::Interface); #[cfg(target_os = "macos")] { let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT; diff --git a/crates/ui-egui/src/hang_beacon.rs b/crates/ui-egui/src/hang_beacon.rs index 3331a1e39..e47dbfc79 100644 --- a/crates/ui-egui/src/hang_beacon.rs +++ b/crates/ui-egui/src/hang_beacon.rs @@ -196,6 +196,14 @@ impl Beacon { self.bump(); } + /// The interface thread enters `stage` of a frame, timed whatever the activity around it, so a + /// frame the system runs while a file dialog waits for the user is timed too. The activity + /// before it applies again when the guard is dropped. + pub fn enter_timed(&self, stage: Stage) -> ActivityGuard<'_> { + self.enter(stage); + self.activity(Activity::None) + } + /// The interface thread starts `activity`; it ends when the guard is dropped, and the activity /// before it applies again. pub fn activity(&self, activity: Activity) -> ActivityGuard<'_> { @@ -538,6 +546,20 @@ mod tests { assert!(B.take_input(), "the main window's input"); } + #[test] + fn a_frame_run_while_a_dialog_waits_is_timed() { + let b = Beacon::new(); + let _dialog = b.activity(Activity::Dialog); + assert!(!b.sample().busy(), "the dialog waits for the user"); + { + let _timed = b.enter_timed(Stage::Interface); + assert!(b.sample().busy(), "a frame inside the dialog's wait"); + b.enter(Stage::Drawing); + } + assert_eq!(b.sample().activity, Activity::Dialog, "the dialog again"); + assert!(!b.sample().busy()); + } + #[test] fn a_window_shown_in_line_handed_to_the_system_is_marked() { static B: Beacon = Beacon::new(); From 172e2923c0fbfb940ca856f4e880755ab6070821 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 18:33:55 -0800 Subject: [PATCH 5/5] MCP: regenerate the UI catalog --- crates/mcp/src/ui_catalog.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index bf6d28cdd..9ea933381 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -748,6 +748,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open the Safe Mode dialog (dialog `safeMode`): whether VectorCraft is in Safe Mode, and the disabled plug-ins, each with Enable on Re-launch (engine: plugin.setEnabled). Closing it leaves Safe Mode as it is; Safe Mode is chosen at launch (--safe-mode, or the choice after a launch that stopped while a plug-in was loading, dialog `pluginStopped`)", ), + ( + "help.copyTroubleshootingInfo", + "Copy Troubleshooting Info", + "", + "{} put text for a bug report on the clipboard: the version, and in the desktop app the platform, the window system, the graphics processor, the commands and frame times of the last few frames, and the hang reports of this session and the session before (no document contents or file paths) → {chars}", + ), ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), ( "ui.swatchOptions",