diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:35:49 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:35:49 +0000 |
commit | d8bbc7858622b6d9c278469aab701ca0b609cddf (patch) | |
tree | eff41dc61d9f714852212739e6b3738b82a2af87 /toolkit/crashreporter/client/app/src/logic.rs | |
parent | Releasing progress-linux version 125.0.3-1~progress7.99u1. (diff) | |
download | firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.tar.xz firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.zip |
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/crashreporter/client/app/src/logic.rs')
-rw-r--r-- | toolkit/crashreporter/client/app/src/logic.rs | 660 |
1 files changed, 660 insertions, 0 deletions
diff --git a/toolkit/crashreporter/client/app/src/logic.rs b/toolkit/crashreporter/client/app/src/logic.rs new file mode 100644 index 0000000000..4ad1baa9c6 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/logic.rs @@ -0,0 +1,660 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Business logic for the crash reporter. + +use crate::std::{ + cell::RefCell, + path::PathBuf, + process::Command, + sync::{ + atomic::{AtomicBool, Ordering::Relaxed}, + Arc, Mutex, + }, +}; +use crate::{ + async_task::AsyncTask, + config::Config, + net, + settings::Settings, + std, + ui::{ReportCrashUI, ReportCrashUIState, SubmitState}, +}; +use anyhow::Context; +use uuid::Uuid; + +/// The main crash reporting logic. +pub struct ReportCrash { + pub settings: RefCell<Settings>, + config: Arc<Config>, + extra: serde_json::Value, + settings_file: PathBuf, + attempted_to_send: AtomicBool, + ui: Option<AsyncTask<ReportCrashUIState>>, +} + +impl ReportCrash { + pub fn new(config: Arc<Config>, extra: serde_json::Value) -> anyhow::Result<Self> { + let settings_file = config.data_dir().join("crashreporter_settings.json"); + let settings: Settings = match std::fs::File::open(&settings_file) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => { + anyhow::bail!( + "failed to open settings file ({}): {e}", + settings_file.display() + ); + } + Err(_) => Default::default(), + Ok(f) => Settings::from_reader(f)?, + }; + Ok(ReportCrash { + config, + extra, + settings_file, + settings: settings.into(), + attempted_to_send: Default::default(), + ui: None, + }) + } + + /// Returns whether an attempt was made to send the report. + pub fn run(mut self) -> anyhow::Result<bool> { + self.set_log_file(); + let hash = self.compute_minidump_hash().map(Some).unwrap_or_else(|e| { + log::warn!("failed to compute minidump hash: {e}"); + None + }); + let ping_uuid = self.send_crash_ping(hash.as_deref()).unwrap_or_else(|e| { + log::warn!("failed to send crash ping: {e}"); + None + }); + if let Err(e) = self.update_events_file(hash.as_deref(), ping_uuid) { + log::warn!("failed to update events file: {e}"); + } + self.sanitize_extra(); + self.check_eol_version()?; + if !self.config.auto_submit { + self.run_ui(); + } else { + anyhow::ensure!(self.try_send().unwrap_or(false), "failed to send report"); + } + + Ok(self.attempted_to_send.load(Relaxed)) + } + + /// Set the log file based on the current configured paths. + /// + /// This is the earliest that this can occur as the configuration data dir may be set based on + /// fields in the extra file. + fn set_log_file(&self) { + if let Some(log_target) = &self.config.log_target { + log_target.set_file(&self.config.data_dir().join("submit.log")); + } + } + + /// Compute the SHA256 hash of the minidump file contents, and return it as a hex string. + fn compute_minidump_hash(&self) -> anyhow::Result<String> { + let hash = { + use sha2::{Digest, Sha256}; + let mut dump_file = std::fs::File::open(self.config.dump_file())?; + let mut hasher = Sha256::new(); + std::io::copy(&mut dump_file, &mut hasher)?; + hasher.finalize() + }; + + let mut s = String::with_capacity(hash.len() * 2); + for byte in hash { + use crate::std::fmt::Write; + write!(s, "{:02x}", byte).unwrap(); + } + + Ok(s) + } + + /// Send a crash ping to telemetry. + /// + /// Returns the crash ping uuid. + fn send_crash_ping(&self, minidump_hash: Option<&str>) -> anyhow::Result<Option<Uuid>> { + if self.config.ping_dir.is_none() { + log::warn!("not sending crash ping because no ping directory configured"); + return Ok(None); + } + + //TODO support glean crash pings (or change pingsender to do so) + + let dump_id = self.config.local_dump_id(); + let ping = net::legacy_telemetry::Ping::crash(&self.extra, dump_id.as_ref(), minidump_hash) + .context("failed to create telemetry crash ping")?; + + let submission_url = ping + .submission_url(&self.extra) + .context("failed to generate ping submission URL")?; + + let target_file = self + .config + .ping_dir + .as_ref() + .unwrap() + .join(format!("{}.json", ping.id())); + + let file = std::fs::File::create(&target_file).with_context(|| { + format!( + "failed to open ping file {} for writing", + target_file.display() + ) + })?; + + serde_json::to_writer(file, &ping).context("failed to serialize telemetry crash ping")?; + + let pingsender_path = self.config.sibling_program_path("pingsender"); + + crate::process::background_command(&pingsender_path) + .arg(submission_url) + .arg(target_file) + .spawn() + .with_context(|| { + format!( + "failed to launch pingsender process at {}", + pingsender_path.display() + ) + })?; + + // TODO asynchronously get pingsender result and log it? + + Ok(Some(ping.id().clone())) + } + + /// Remove unneeded entries from the extra file, and add some that indicate from where the data + /// is being sent. + fn sanitize_extra(&mut self) { + if let Some(map) = self.extra.as_object_mut() { + // Remove these entries, they don't need to be sent. + map.remove("ServerURL"); + map.remove("StackTraces"); + } + + self.extra["SubmittedFrom"] = "Client".into(); + self.extra["Throttleable"] = "1".into(); + } + + /// Update the events file with information about the crash ping, minidump hash, and + /// stacktraces. + fn update_events_file( + &self, + minidump_hash: Option<&str>, + ping_uuid: Option<Uuid>, + ) -> anyhow::Result<()> { + use crate::std::io::{BufRead, Error, ErrorKind, Write}; + struct EventsFile { + event_version: String, + time: String, + uuid: String, + pub data: serde_json::Value, + } + + impl EventsFile { + pub fn parse(mut reader: impl BufRead) -> std::io::Result<Self> { + let mut lines = (&mut reader).lines(); + + let mut read_field = move |name: &str| -> std::io::Result<String> { + lines.next().transpose()?.ok_or_else(|| { + Error::new(ErrorKind::InvalidData, format!("missing {name} field")) + }) + }; + + let event_version = read_field("event version")?; + let time = read_field("time")?; + let uuid = read_field("uuid")?; + let data = serde_json::from_reader(reader)?; + Ok(EventsFile { + event_version, + time, + uuid, + data, + }) + } + + pub fn write(&self, mut writer: impl Write) -> std::io::Result<()> { + writeln!(writer, "{}", self.event_version)?; + writeln!(writer, "{}", self.time)?; + writeln!(writer, "{}", self.uuid)?; + serde_json::to_writer(writer, &self.data)?; + Ok(()) + } + } + + let Some(events_dir) = &self.config.events_dir else { + log::warn!("not updating the events file; no events directory configured"); + return Ok(()); + }; + + let event_path = events_dir.join(self.config.local_dump_id().as_ref()); + + // Read events file. + let file = std::fs::File::open(&event_path) + .with_context(|| format!("failed to open event file at {}", event_path.display()))?; + + let mut events_file = + EventsFile::parse(std::io::BufReader::new(file)).with_context(|| { + format!( + "failed to parse events file contents in {}", + event_path.display() + ) + })?; + + // Update events file fields. + if let Some(hash) = minidump_hash { + events_file.data["MinidumpSha256Hash"] = hash.into(); + } + if let Some(uuid) = ping_uuid { + events_file.data["CrashPingUUID"] = uuid.to_string().into(); + } + events_file.data["StackTraces"] = self.extra["StackTraces"].clone(); + + // Write altered events file. + let file = std::fs::File::create(&event_path).with_context(|| { + format!("failed to truncate event file at {}", event_path.display()) + })?; + + events_file + .write(file) + .with_context(|| format!("failed to write event file at {}", event_path.display())) + } + + /// Check whether the version of the software that generated the crash is EOL. + fn check_eol_version(&self) -> anyhow::Result<()> { + if let Some(version) = self.extra["Version"].as_str() { + if self.config.version_eol_file(version).exists() { + self.config.delete_files(); + anyhow::bail!(self.config.string("crashreporter-error-version-eol")); + } + } + Ok(()) + } + + /// Save the current settings. + fn save_settings(&self) { + let result: anyhow::Result<()> = (|| { + Ok(self + .settings + .borrow() + .to_writer(std::fs::File::create(&self.settings_file)?)?) + })(); + if let Err(e) = result { + log::error!("error while saving settings: {e}"); + } + } + + /// Handle a response from submitting a crash report. + /// + /// Returns the crash ID to use for the recorded submission event. Errors in this function may + /// result in None being returned to consider the crash report submission as a failure even + /// though the server did provide a response. + fn handle_crash_report_response( + &self, + response: net::report::Response, + ) -> anyhow::Result<Option<String>> { + if let Some(version) = response.stop_sending_reports_for { + // Create the EOL version file. The content seemingly doesn't matter, but we mimic what + // was written by the old crash reporter. + if let Err(e) = std::fs::write(self.config.version_eol_file(&version), "1\n") { + log::warn!("failed to write EOL file: {e}"); + } + } + + if response.discarded { + log::debug!("response indicated that the report was discarded"); + return Ok(None); + } + + let Some(crash_id) = response.crash_id else { + log::debug!("response did not provide a crash id"); + return Ok(None); + }; + + // Write the id to the `submitted` directory + let submitted_dir = self.config.submitted_crash_dir(); + std::fs::create_dir_all(&submitted_dir).with_context(|| { + format!( + "failed to create submitted crash directory {}", + submitted_dir.display() + ) + })?; + + let crash_id_file = submitted_dir.join(format!("{crash_id}.txt")); + + let mut file = std::fs::File::create(&crash_id_file).with_context(|| { + format!( + "failed to create submitted crash file for {crash_id} ({})", + crash_id_file.display() + ) + })?; + + // Shadow `std::fmt::Write` to use the correct trait below. + use crate::std::io::Write; + + if let Err(e) = writeln!( + &mut file, + "{}", + self.config + .build_string("crashreporter-crash-identifier") + .arg("id", &crash_id) + .get() + ) { + log::warn!( + "failed to write to submitted crash file ({}) for {crash_id}: {e}", + crash_id_file.display() + ); + } + + if let Some(url) = response.view_url { + if let Err(e) = writeln!( + &mut file, + "{}", + self.config + .build_string("crashreporter-crash-details") + .arg("url", url) + .get() + ) { + log::warn!( + "failed to write view url to submitted crash file ({}) for {crash_id}: {e}", + crash_id_file.display() + ); + } + } + + Ok(Some(crash_id)) + } + + /// Write the submission event. + /// + /// A `None` crash_id indicates that the submission failed. + fn write_submission_event(&self, crash_id: Option<String>) -> anyhow::Result<()> { + let Some(events_dir) = &self.config.events_dir else { + // If there's no events dir, don't do anything. + return Ok(()); + }; + + let local_id = self.config.local_dump_id(); + let event_path = events_dir.join(format!("{local_id}-submission")); + + let unix_epoch_seconds = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .expect("system time is before the unix epoch") + .as_secs(); + std::fs::write( + &event_path, + format!( + "crash.submission.1\n{unix_epoch_seconds}\n{local_id}\n{}\n{}", + crash_id.is_some(), + crash_id.as_deref().unwrap_or("") + ), + ) + .with_context(|| format!("failed to write event to {}", event_path.display())) + } + + /// Restart the program. + fn restart_process(&self) { + if self.config.restart_command.is_none() { + // The restart button should be hidden in this case, so this error should not occur. + log::error!("no process configured for restart"); + return; + } + + let mut cmd = Command::new(self.config.restart_command.as_ref().unwrap()); + cmd.args(&self.config.restart_args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + if let Some(xul_app_file) = &self.config.app_file { + cmd.env("XUL_APP_FILE", xul_app_file); + } + log::debug!("restarting process: {:?}", cmd); + if let Err(e) = cmd.spawn() { + log::error!("failed to restart process: {e}"); + } + } + + /// Run the crash reporting UI. + fn run_ui(&mut self) { + use crate::std::{sync::mpsc, thread}; + + let (logic_send, logic_recv) = mpsc::channel(); + // Wrap work_send in an Arc so that it can be captured weakly by the work queue and + // drop when the UI finishes, including panics (allowing the logic thread to exit). + // + // We need to wrap in a Mutex because std::mpsc::Sender isn't Sync (until rust 1.72). + let logic_send = Arc::new(Mutex::new(logic_send)); + + let weak_logic_send = Arc::downgrade(&logic_send); + let logic_remote_queue = AsyncTask::new(move |f| { + if let Some(logic_send) = weak_logic_send.upgrade() { + // This is best-effort: ignore errors. + let _ = logic_send.lock().unwrap().send(f); + } + }); + + let crash_ui = ReportCrashUI::new( + &*self.settings.borrow(), + self.config.clone(), + logic_remote_queue, + ); + + // Set the UI remote queue. + self.ui = Some(crash_ui.async_task()); + + // Spawn a separate thread to handle all interactions with `self`. This prevents blocking + // the UI for any reason. + + // Use a barrier to ensure both threads are live before either starts (ensuring they + // can immediately queue work for each other). + let barrier = std::sync::Barrier::new(2); + let barrier = &barrier; + thread::scope(move |s| { + // Move `logic_send` into this scope so that it will drop when the scope completes + // (which will drop the `mpsc::Sender` and cause the logic thread to complete and join + // when the UI finishes so the scope can exit). + let _logic_send = logic_send; + s.spawn(move || { + barrier.wait(); + while let Ok(f) = logic_recv.recv() { + f(self); + } + // Clear the UI remote queue, using it after this point is an error. + // + // NOTE we do this here because the compiler can't reason about `self` being safely + // accessible after `thread::scope` returns. This is effectively the same result + // since the above loop will only exit when `logic_send` is dropped at the end of + // the scope. + self.ui = None; + }); + + barrier.wait(); + crash_ui.run() + }); + } +} + +// These methods may interact with `self.ui`. +impl ReportCrash { + /// Update the submission details shown in the UI. + pub fn update_details(&self) { + use crate::std::fmt::Write; + + let extra = self.current_extra_data(); + + let mut details = String::new(); + let mut entries: Vec<_> = extra.as_object().unwrap().into_iter().collect(); + entries.sort_unstable_by_key(|(k, _)| *k); + for (key, value) in entries { + let _ = write!(details, "{key}: "); + if let Some(v) = value.as_str() { + details.push_str(v); + } else { + match serde_json::to_string(value) { + Ok(s) => details.push_str(&s), + Err(e) => { + let _ = write!(details, "<serialization error: {e}>"); + } + } + } + let _ = writeln!(details); + } + let _ = writeln!( + details, + "{}", + self.config.string("crashreporter-report-info") + ); + + self.ui().push(move |ui| *ui.details.borrow_mut() = details); + } + + /// Restart the application and send the crash report. + pub fn restart(&self) { + self.save_settings(); + // Get the program restarted before sending the report. + self.restart_process(); + let result = self.try_send(); + self.close_window(result.is_some()); + } + + /// Quit and send the crash report. + pub fn quit(&self) { + self.save_settings(); + let result = self.try_send(); + self.close_window(result.is_some()); + } + + fn close_window(&self, report_sent: bool) { + if report_sent && !self.config.auto_submit && !cfg!(test) { + // Add a delay to allow the user to see the result. + std::thread::sleep(std::time::Duration::from_secs(5)); + } + + self.ui().push(|r| r.close_window.fire(&())); + } + + /// Try to send the report. + /// + /// This function may be called without a UI active (if auto_submit is true), so it will not + /// panic if `self.ui` is unset. + /// + /// Returns whether the report was received (regardless of whether the response was processed + /// successfully), if a report could be sent at all (based on the configuration). + fn try_send(&self) -> Option<bool> { + self.attempted_to_send.store(true, Relaxed); + let send_report = self.settings.borrow().submit_report; + + if !send_report { + log::trace!("not sending report due to user setting"); + return None; + } + + // TODO? load proxy info from libgconf on linux + + let Some(url) = &self.config.report_url else { + log::warn!("not sending report due to missing report url"); + return None; + }; + + if let Some(ui) = &self.ui { + ui.push(|r| *r.submit_state.borrow_mut() = SubmitState::InProgress); + } + + // Send the report to the server. + let extra = self.current_extra_data(); + let memory_file = self.config.memory_file(); + let report = net::report::CrashReport { + extra: &extra, + dump_file: self.config.dump_file(), + memory_file: memory_file.as_deref(), + url, + }; + + let report_response = report + .send() + .map(Some) + .unwrap_or_else(|e| { + log::error!("failed to initialize report transmission: {e}"); + None + }) + .and_then(|sender| { + // Normally we might want to do the following asynchronously since it will block, + // however we don't really need the Logic thread to do anything else (the UI + // becomes disabled from this point onward), so we just do it here. Same goes for + // the `std::thread::sleep` in close_window() later on. + sender.finish().map(Some).unwrap_or_else(|e| { + log::error!("failed to send report: {e}"); + None + }) + }); + + let report_received = report_response.is_some(); + let crash_id = report_response.and_then(|response| { + self.handle_crash_report_response(response) + .unwrap_or_else(|e| { + log::error!("failed to handle crash report response: {e}"); + None + }) + }); + + if report_received { + // If the response could be handled (indicated by the returned crash id), clean up by + // deleting the minidump files. Otherwise, prune old minidump files. + if crash_id.is_some() { + self.config.delete_files(); + } else { + if let Err(e) = self.config.prune_files() { + log::warn!("failed to prune files: {e}"); + } + } + } + + if let Err(e) = self.write_submission_event(crash_id) { + log::warn!("failed to write submission event: {e}"); + } + + // Indicate whether the report was sent successfully, regardless of whether the response + // was processed successfully. + // + // FIXME: this is how the old crash reporter worked, but we might want to change this + // behavior. + if let Some(ui) = &self.ui { + ui.push(move |r| { + *r.submit_state.borrow_mut() = if report_received { + SubmitState::Success + } else { + SubmitState::Failure + } + }); + } + + Some(report_received) + } + + /// Form the extra data, taking into account user input. + fn current_extra_data(&self) -> serde_json::Value { + let include_address = self.settings.borrow().include_url; + let comment = if !self.config.auto_submit { + self.ui().wait(|r| r.comment.get()) + } else { + Default::default() + }; + + let mut extra = self.extra.clone(); + + if !comment.is_empty() { + extra["Comments"] = comment.into(); + } + + if !include_address { + extra.as_object_mut().unwrap().remove("URL"); + } + + extra + } + + fn ui(&self) -> &AsyncTask<ReportCrashUIState> { + self.ui.as_ref().expect("UI remote queue missing") + } +} |