diff --git a/docs/contributing/macos-memory-profiling.md b/docs/contributing/macos-memory-profiling.md new file mode 100644 index 000000000..c22d6598e --- /dev/null +++ b/docs/contributing/macos-memory-profiling.md @@ -0,0 +1,62 @@ +# macOS memory profiling with Instruments + +Use macOS Instruments for interactive ORGII memory investigations. Unlike the +opt-in DHAT allocator, Instruments can attach to a normally running app without +capturing a Rust backtrace under a global lock for every allocation. + +For a quick, zero-profiler-overhead comparison, click the gauge button in the +ORGII navigation sidebar. Its panel separates the native backend, WebView +helpers, file cache, terminal buffers, and other runtime estimates. Record the +values before the workload, after repeating it, and again after returning to +idle. Use Instruments when that comparison points to persistent native-backend +growth that needs allocation call stacks. + +## Record native allocations + +1. Start ORGII normally and wait for the main window to become usable. +2. From the repository root, run: + + ```bash + ./scripts/dev/profile-macos-memory.sh + ``` + +3. Exercise one controlled workload during the two-minute recording. +4. Let the recording finish. ORGII remains open, and the script prints the + generated `.trace` path. +5. Open the trace using the printed command, then inspect persistent bytes, + allocation growth, and responsible call trees. + +The default `Allocations` recording is limited to two minutes and attaches only +to the native `org2` process. Override the limit when necessary: + +```bash +./scripts/dev/profile-macos-memory.sh --duration 5m +``` + +If multiple ORGII instances are running, select the intended backend explicitly: + +```bash +./scripts/dev/profile-macos-memory.sh --pid 12345 +``` + +Run the `Leaks` template for a bounded native leak scan: + +```bash +./scripts/dev/profile-macos-memory.sh --template Leaks --duration 2m +``` + +The first recording may cause macOS to request Developer Tools permission. If +attachment is denied, enable the terminal under **System Settings → Privacy & +Security → Developer Tools**, restart the terminal, and retry. + +## What the trace covers + +Attaching to `org2` covers native allocations in the Tauri/Rust backend and +native frameworks loaded into that process. It does not provide the JavaScript +object-retainer graph inside the WebKit WebContent helper. Use Web Inspector +heap snapshots for JavaScript/DOM leaks, and use the built-in App memory +snapshot or Activity Monitor when comparing total backend plus helper RSS. + +For comparable results, record the same duration and workload before and after +a change. Let the app return to idle before the recording ends so persistent +growth is easier to distinguish from temporary peaks. diff --git a/docs/contributing/rust-heap-profiling.md b/docs/contributing/rust-heap-profiling.md new file mode 100644 index 000000000..d98c1bc77 --- /dev/null +++ b/docs/contributing/rust-heap-profiling.md @@ -0,0 +1,109 @@ +# Rust heap profiling with DHAT + +ORGII includes an opt-in DHAT build for diagnosing allocations retained by the +Rust desktop backend. It is a developer tool, not a production feature and not +a replacement for the App memory/RSS monitor. + +DHAT can impose extreme per-allocation overhead on the full ORGII GUI and may +make the Tauri window temporarily unresponsive. For normal interactive memory +investigations on macOS, prefer +[Instruments](./macos-memory-profiling.md). Reserve this DHAT path for short, +controlled Rust-backend scenarios where that slowdown is acceptable. + +## Run a profile + +From the repository root: + +```bash +./scripts/dev/profile-rust-heap.sh +``` + +The script first builds the production frontend, then builds the `org2` desktop +binary with the optimized, symbolized `dhat` Cargo profile and enables the +`dhat-heap` feature. This keeps the measured Tauri/WebView path close to the +release application while preserving Rust allocation symbols. + +After Tauri backend setup completes, DHAT waits 15 seconds before it starts. +Wait for the terminal's `[dhat] Rust heap profiling started` message, exercise +one controlled scenario, then quit ORGII normally. The final Tauri exit event +synchronously writes a timestamped JSON profile below +`${TMPDIR:-/tmp}/orgii-dhat-profiles/`, and the script prints the exact path. A +forced process kill cannot flush the profile. + +On macOS, quit the application with `Cmd+Q`. The red window button only hides +the main window and does not end the profiling process. + +Override the post-setup delay when a scenario needs more or less settling +time. Values from 0 through 3600 seconds are accepted: + +```bash +ORGII_DHAT_START_DELAY_SECS=30 ./scripts/dev/profile-rust-heap.sh +``` + +To choose the output file explicitly: + +```bash +ORGII_DHAT_OUTPUT=/absolute/path/session-switch.json \ + ./scripts/dev/profile-rust-heap.sh +``` + +For repeated profiling while `build/` is already current, skip the webpack +rebuild explicitly: + +```bash +ORGII_DHAT_SKIP_FRONTEND_BUILD=true ./scripts/dev/profile-rust-heap.sh +``` + +The skip mode refuses to run unless `build/index.html` exists. + +Open the JSON file in the +[DHAT viewer](https://nnethercote.github.io/dh_view/dh_view.html). Compare +`At t-gmax` for the peak and `At t-end` for allocations still live when the app +closed. Allocation call stacks are more useful than the headline byte count. + +## Recommended scenario shape + +1. Launch the app and wait for the terminal to confirm profiling has started. +2. Repeat one operation enough times to expose growth, such as opening and + leaving replay-heavy sessions. +3. Return to an idle screen and wait for configured eviction grace periods. +4. Quit the app normally with `Cmd+Q` so the final Tauri exit event writes the profile. The red window button only hides the app on macOS. +5. Repeat with the same workload after a proposed fix and compare call stacks, + peak bytes, and end-of-run live bytes. + +DHAT starts once, after backend setup plus the configured settling delay. +Startup allocations before that point are intentionally excluded so allocation +backtrace collection cannot stall the initial WebView render. Before profiling +starts, the feature-gated allocator delegates directly to the system allocator +without entering DHAT's tracking lock. Use repeated before/after scenarios +instead of treating the final total as a standalone pass/fail number. + +## Scope and limitations + +DHAT replaces the Rust process-wide global allocator only when the +`dhat-heap` feature is enabled. Default development and release builds retain +their normal allocator and do not link the optional `dhat` dependency. + +DHAT measures allocations made through the Rust allocator in the main ORGII +backend process. It does not attribute: + +- JavaScript objects, DOM nodes, Jotai atoms, or WebView caches; +- xterm/WebGL/GPU memory; +- terminal, CLI-agent, MCP, or other child processes; +- memory-mapped files, kernel/file caches, or the complete process RSS. + +Use WebKit/Chromium DevTools heap snapshots for the WebView object graph and +the built-in App memory snapshot for whole-process and owned-helper trends. + +## Manual Cargo invocation + +After `pnpm run build`, the equivalent backend command is: + +```bash +ORGII_DHAT_OUTPUT=/absolute/path/dhat-heap.json \ + cargo run --manifest-path src-tauri/Cargo.toml \ + --profile dhat --features dhat-heap --bin org2 +``` + +Do not add `dhat-heap` to default features or production build scripts. The +profiling allocator intentionally adds substantial runtime and memory overhead. diff --git a/scripts/dev/profile-macos-memory.sh b/scripts/dev/profile-macos-memory.sh new file mode 100755 index 000000000..8a4bac9b4 --- /dev/null +++ b/scripts/dev/profile-macos-memory.sh @@ -0,0 +1,148 @@ +#!/bin/bash + +set -euo pipefail + +readonly DEFAULT_DURATION="2m" +readonly DEFAULT_TEMPLATE="Allocations" +readonly DEFAULT_OUTPUT_DIR="${TMPDIR:-/tmp}/orgii-instruments" + +TARGET_PID="${ORGII_INSTRUMENTS_PID:-}" +DURATION="${ORGII_INSTRUMENTS_DURATION:-${DEFAULT_DURATION}}" +TEMPLATE="${ORGII_INSTRUMENTS_TEMPLATE:-${DEFAULT_TEMPLATE}}" +OUTPUT_PATH="${ORGII_INSTRUMENTS_OUTPUT:-}" + +show_help() { + cat <<'EOF' +Profile the native ORGII backend with macOS Instruments. + +Start ORGII normally first, then run: + ./scripts/dev/profile-macos-memory.sh + +Options: + --pid PID Attach to this org2 process instead of auto-detecting. + --duration TIME Recording limit, such as 30s, 2m, or 1h (default: 2m). + --template NAME Allocations or Leaks (default: Allocations). + --output PATH.trace Explicit trace output path. + --help, -h Show this help. + +Environment equivalents: + ORGII_INSTRUMENTS_PID + ORGII_INSTRUMENTS_DURATION + ORGII_INSTRUMENTS_TEMPLATE + ORGII_INSTRUMENTS_OUTPUT +EOF +} + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --pid) + [[ $# -ge 2 ]] || fail "--pid requires a value" + TARGET_PID="$2" + shift 2 + ;; + --duration) + [[ $# -ge 2 ]] || fail "--duration requires a value" + DURATION="$2" + shift 2 + ;; + --template) + [[ $# -ge 2 ]] || fail "--template requires a value" + TEMPLATE="$2" + shift 2 + ;; + --output) + [[ $# -ge 2 ]] || fail "--output requires a value" + OUTPUT_PATH="$2" + shift 2 + ;; + --help|-h) + show_help + exit 0 + ;; + *) + fail "unknown option: $1 (run with --help for usage)" + ;; + esac +done + +[[ "$(uname -s)" == "Darwin" ]] || fail "this profiler requires macOS" +command -v xcrun >/dev/null 2>&1 || fail "xcrun was not found; install Xcode command-line tools" +[[ "${DURATION}" =~ ^[1-9][0-9]*(ms|s|m|h)$ ]] \ + || fail "duration must be a positive value with ms, s, m, or h suffix" + +case "${TEMPLATE}" in + Allocations) + TEMPLATE_SLUG="allocations" + ;; + Leaks) + TEMPLATE_SLUG="leaks" + ;; + *) fail "template must be Allocations or Leaks" ;; +esac + +if [[ -z "${TARGET_PID}" ]]; then + MATCHING_PIDS=() + while IFS= read -r matching_pid; do + MATCHING_PIDS+=("${matching_pid}") + done < <( + { + pgrep -x org2 2>/dev/null || true + pgrep -x ORG2 2>/dev/null || true + } | sort -nu + ) + + case "${#MATCHING_PIDS[@]}" in + 0) + fail "no running ORGII backend found; start ORGII normally, then rerun this script" + ;; + 1) + TARGET_PID="${MATCHING_PIDS[0]}" + ;; + *) + echo "Multiple ORGII backend processes are running:" >&2 + ps -p "$(IFS=,; echo "${MATCHING_PIDS[*]}")" -o pid=,etime=,command= >&2 || true + fail "choose one explicitly with --pid PID" + ;; + esac +fi + +[[ "${TARGET_PID}" =~ ^[1-9][0-9]*$ ]] || fail "PID must be a positive integer" +kill -0 "${TARGET_PID}" 2>/dev/null || fail "PID ${TARGET_PID} is not running or is not accessible" + +if [[ -z "${OUTPUT_PATH}" ]]; then + readonly OUTPUT_DIR="${ORGII_INSTRUMENTS_DIR:-${DEFAULT_OUTPUT_DIR}}" + mkdir -p "${OUTPUT_DIR}" + OUTPUT_PATH="${OUTPUT_DIR}/orgii-${TEMPLATE_SLUG}-$(date '+%Y%m%d-%H%M%S')-${TARGET_PID}.trace" +else + [[ "${OUTPUT_PATH}" == *.trace ]] || fail "output path must end in .trace" + mkdir -p "$(dirname -- "${OUTPUT_PATH}")" +fi + +[[ ! -e "${OUTPUT_PATH}" ]] || fail "output already exists: ${OUTPUT_PATH}" + +echo "Recording ORGII native memory with Instruments..." +echo " PID: ${TARGET_PID}" +echo " Template: ${TEMPLATE}" +echo " Duration: ${DURATION}" +echo " Output: ${OUTPUT_PATH}" +echo "Exercise one controlled workload in ORGII now. The app remains running when recording ends." + +if ! xcrun xctrace record \ + --template "${TEMPLATE}" \ + --attach "${TARGET_PID}" \ + --time-limit "${DURATION}" \ + --output "${OUTPUT_PATH}"; then + echo "Instruments could not attach to PID ${TARGET_PID}." >&2 + echo "Enable your terminal in System Settings → Privacy & Security → Developer Tools, restart the terminal, and retry." >&2 + exit 1 +fi + +[[ -e "${OUTPUT_PATH}" ]] || fail "Instruments finished without creating ${OUTPUT_PATH}" + +echo "Instruments trace saved: ${OUTPUT_PATH}" +echo "Open it with: open \"${OUTPUT_PATH}\"" diff --git a/scripts/dev/profile-rust-heap.sh b/scripts/dev/profile-rust-heap.sh new file mode 100755 index 000000000..b29378db2 --- /dev/null +++ b/scripts/dev/profile-rust-heap.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" +readonly PROFILE_DIR="${ORGII_DHAT_DIR:-${TMPDIR:-/tmp}/orgii-dhat-profiles}" +readonly PROFILE_STAMP="$(date '+%Y%m%d-%H%M%S')" +readonly PROFILE_FILE="${ORGII_DHAT_OUTPUT:-${PROFILE_DIR}/orgii-dhat-${PROFILE_STAMP}-$$.json}" + +mkdir -p "$(dirname -- "${PROFILE_FILE}")" + +if [[ "${ORGII_DHAT_SKIP_FRONTEND_BUILD:-false}" != "true" ]]; then + echo "Building the production frontend used by the optimized Tauri profile..." + ( + cd "${REPO_ROOT}" + pnpm run build + ) +elif [[ ! -f "${REPO_ROOT}/build/index.html" ]]; then + echo "ORGII_DHAT_SKIP_FRONTEND_BUILD=true requires an existing build/index.html" >&2 + exit 1 +fi + +echo "Building and running the optimized DHAT profile..." +echo "Wait for the '[dhat] Rust heap profiling started' message before testing." +echo "Quit ORGII with Cmd+Q to finalize the heap profile; closing the window only hides it." +echo "Profile output: ${PROFILE_FILE}" + +( + cd "${REPO_ROOT}" + ORGII_DHAT_OUTPUT="${PROFILE_FILE}" cargo run \ + --manifest-path src-tauri/Cargo.toml \ + --profile dhat \ + --features dhat-heap \ + --bin org2 \ + -- "$@" +) + +if [[ -f "${PROFILE_FILE}" ]]; then + echo "DHAT profile saved: ${PROFILE_FILE}" +else + echo "DHAT did not write a profile. Make sure ORGII exited normally instead of being force-killed." >&2 + exit 1 +fi diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 04747b6dc..ed3b0a86f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -645,6 +654,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + [[package]] name = "base64" version = "0.21.7" @@ -1860,6 +1884,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dhat" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cd11d84628e233de0ce467de10b8633f4ddaecafadefc86e13b84b8739b827" +dependencies = [ + "backtrace", + "lazy_static", + "mintex", + "parking_lot 0.12.5", + "rustc-hash 1.1.0", + "serde", + "serde_json", + "thousands", +] + [[package]] name = "digest" version = "0.10.7" @@ -2773,6 +2813,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gio" version = "0.18.4" @@ -4449,6 +4495,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mintex" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c505b3e17ed6b70a7ed2e67fbb2c560ee327353556120d6e72f5232b6880d536" + [[package]] name = "mio" version = "1.2.1" @@ -5053,6 +5105,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -5150,6 +5211,7 @@ dependencies = [ "database", "db_browser", "db_clients", + "dhat", "dirs", "dispatch2", "file_ops", @@ -6666,6 +6728,12 @@ dependencies = [ "zip 7.2.0", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -8688,6 +8756,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thousands" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" + [[package]] name = "thread_local" version = "1.1.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ef475abb5..c176c3248 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -186,6 +186,9 @@ cc = "1.0" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" +# Opt-in Rust heap profiling for the desktop binary. This dependency is not +# linked unless `--features dhat-heap` is explicitly requested. +dhat = { version = "0.3.3", optional = true } tauri = { workspace = true } tauri-plugin-shell = "2" tauri-plugin-fs = { version = "=2.4.5", features = ["watch"] } @@ -528,6 +531,9 @@ libc = "0.2" [features] default = [] +# Developer-only heap profiler. Never enable this in production builds: DHAT +# replaces the process-wide allocator and records every Rust heap allocation. +dhat-heap = ["dep:dhat"] # E2E WebDriver automation plugin (debug/test only). Enable with --features webdriver. webdriver = ["dep:tauri-plugin-webdriver-automation"] semantic-search = ["advanced_search/semantic-search"] @@ -613,6 +619,14 @@ codegen-units = 4 panic = "abort" strip = true +# Optimized, symbolized build used only by scripts/dev/profile-rust-heap.sh. +# Keeping this separate preserves the size and performance of normal release +# artifacts while giving DHAT useful allocation backtraces. +[profile.dhat] +inherits = "release" +debug = 1 +strip = false + # Development profile - faster builds. # # History: this used to set `[profile.dev.package."*"] opt-level = 2`, which diff --git a/src-tauri/src/dhat_profiling.rs b/src-tauri/src/dhat_profiling.rs new file mode 100644 index 000000000..8ee2b4e12 --- /dev/null +++ b/src-tauri/src/dhat_profiling.rs @@ -0,0 +1,226 @@ +//! Lifecycle control for the opt-in DHAT heap profiler. +//! +//! Tauri's `Application::run()` exits the process directly instead of +//! returning to `main`, so a profiler guard owned by `main` is never dropped. +//! Keep the guard here and finish it from the final `RunEvent::Exit` instead. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::Duration; + +const DEFAULT_START_DELAY_SECS: u64 = 15; +const MAX_START_DELAY_SECS: u64 = 60 * 60; + +enum ProfilerState { + NotScheduled, + Scheduled, + Running(dhat::Profiler), + Finished, +} + +static PROFILER_STATE: OnceLock> = OnceLock::new(); +static PROFILING_ACTIVE: AtomicBool = AtomicBool::new(false); + +/// Global allocator that bypasses DHAT until the delayed profiling window. +/// +/// Allocations made before profiling can safely be freed while profiling is +/// active: DHAT explicitly ignores deallocations for blocks it did not record. +/// The inverse is also safe because DHAT ultimately delegates storage to the +/// system allocator. +pub struct DhatAllocator; + +impl DhatAllocator { + pub const fn new() -> Self { + Self + } + + fn is_active() -> bool { + PROFILING_ACTIVE.load(Ordering::Acquire) + } +} + +// SAFETY: both delegated allocators use the platform system allocator for the +// actual storage. Switching changes only whether DHAT records the operation; +// it does not change allocation layout or which allocator owns the pointer. +unsafe impl GlobalAlloc for DhatAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + if Self::is_active() { + unsafe { GlobalAlloc::alloc(&dhat::Alloc, layout) } + } else { + unsafe { GlobalAlloc::alloc(&System, layout) } + } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + if Self::is_active() { + unsafe { GlobalAlloc::dealloc(&dhat::Alloc, ptr, layout) } + } else { + unsafe { GlobalAlloc::dealloc(&System, ptr, layout) } + } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + if Self::is_active() { + unsafe { GlobalAlloc::realloc(&dhat::Alloc, ptr, layout, new_size) } + } else { + unsafe { GlobalAlloc::realloc(&System, ptr, layout, new_size) } + } + } +} + +fn profiler_state() -> &'static Mutex { + PROFILER_STATE.get_or_init(|| Mutex::new(ProfilerState::NotScheduled)) +} + +fn lock_profiler_state() -> MutexGuard<'static, ProfilerState> { + profiler_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn parse_start_delay_secs(value: Option<&str>) -> u64 { + value + .and_then(|raw| raw.parse::().ok()) + .filter(|seconds| *seconds <= MAX_START_DELAY_SECS) + .unwrap_or(DEFAULT_START_DELAY_SECS) +} + +fn configured_start_delay() -> Duration { + let raw = std::env::var("ORGII_DHAT_START_DELAY_SECS").ok(); + let seconds = parse_start_delay_secs(raw.as_deref()); + if let Some(raw) = raw.filter(|raw| raw.parse::().ok() != Some(seconds)) { + eprintln!("[dhat] ignoring invalid ORGII_DHAT_START_DELAY_SECS={raw:?}; using {seconds}s"); + } + Duration::from_secs(seconds) +} + +fn output_path() -> PathBuf { + std::env::var_os("ORGII_DHAT_OUTPUT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("dhat-heap.json")) +} + +fn start_profiler() -> Result { + let output_path = output_path(); + if let Some(parent) = output_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to create DHAT output directory {}: {error}", + parent.display() + ) + })?; + } + + let profiler = dhat::Profiler::builder() + .file_name(output_path.clone()) + .build(); + PROFILING_ACTIVE.store(true, Ordering::Release); + eprintln!( + "[dhat] Rust heap profiling started; output will be written to {} on exit", + output_path.display() + ); + Ok(profiler) +} + +/// Schedule one profiler start after Tauri's backend setup has completed. +/// +/// The one-shot delay lets the WebView and normal startup restoration settle +/// before DHAT begins capturing allocation backtraces. Repeated calls are +/// ignored, and the worker exits immediately after attempting the start. +pub(crate) fn schedule_from_env() { + { + let mut state = lock_profiler_state(); + if !matches!(*state, ProfilerState::NotScheduled) { + return; + } + *state = ProfilerState::Scheduled; + } + + let delay = configured_start_delay(); + eprintln!( + "[dhat] Rust heap profiling will start in {}s; wait for the 'profiling started' message before testing", + delay.as_secs() + ); + + if let Err(error) = std::thread::Builder::new() + .name("orgii-dhat-start".to_string()) + .spawn(move || { + if !delay.is_zero() { + std::thread::sleep(delay); + } + + let mut state = lock_profiler_state(); + if !matches!(*state, ProfilerState::Scheduled) { + return; + } + + match start_profiler() { + Ok(profiler) => *state = ProfilerState::Running(profiler), + Err(error) => { + *state = ProfilerState::Finished; + eprintln!("[dhat] failed to start Rust heap profiling: {error}"); + } + } + }) + { + *lock_profiler_state() = ProfilerState::Finished; + eprintln!("[dhat] failed to spawn delayed profiler worker: {error}"); + } +} + +/// Stop profiling and synchronously write the JSON profile exactly once. +/// +/// This must run from Tauri's final `RunEvent::Exit`, before Tauri terminates +/// the process with `std::process::exit` and skips ordinary Rust destructors. +pub(crate) fn finish() { + let previous = { + let mut state = lock_profiler_state(); + std::mem::replace(&mut *state, ProfilerState::Finished) + }; + + match previous { + ProfilerState::Running(profiler) => { + PROFILING_ACTIVE.store(false, Ordering::Release); + eprintln!("[dhat] finalizing Rust heap profile..."); + drop(profiler); + eprintln!("[dhat] Rust heap profile finalized"); + } + ProfilerState::Scheduled => { + eprintln!("[dhat] app exited before delayed heap profiling started"); + } + ProfilerState::NotScheduled | ProfilerState::Finished => {} + } +} + +#[cfg(test)] +mod tests { + use super::{parse_start_delay_secs, DEFAULT_START_DELAY_SECS, MAX_START_DELAY_SECS}; + + #[test] + fn start_delay_accepts_bounded_seconds() { + assert_eq!(parse_start_delay_secs(Some("0")), 0); + assert_eq!(parse_start_delay_secs(Some("45")), 45); + assert_eq!( + parse_start_delay_secs(Some(&MAX_START_DELAY_SECS.to_string())), + MAX_START_DELAY_SECS + ); + } + + #[test] + fn start_delay_rejects_invalid_or_unbounded_values() { + assert_eq!(parse_start_delay_secs(None), DEFAULT_START_DELAY_SECS); + assert_eq!( + parse_start_delay_secs(Some("not-a-number")), + DEFAULT_START_DELAY_SECS + ); + assert_eq!( + parse_start_delay_secs(Some(&(MAX_START_DELAY_SECS + 1).to_string())), + DEFAULT_START_DELAY_SECS + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55ff73d94..751fd7f9c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -108,6 +108,9 @@ pub mod api; pub mod app_update; // Channel-aware (stable/beta) app update checks pub mod benchmark; pub mod cli_managed_proxy; +#[cfg(feature = "dhat-heap")] +#[doc(hidden)] +pub mod dhat_profiling; pub mod infrastructure; // In-tree-only cross-cutting infrastructure (paths, platform, archive, index_manager, jsonrpc, housekeeping). Leaf pieces live in their own workspace crates. pub mod orgtrack; mod runtime_instance; @@ -978,6 +981,9 @@ pub fn run() { } } + #[cfg(feature = "dhat-heap")] + crate::dhat_profiling::schedule_from_env(); + if dev_startup_debug_enabled() { app.listen("orgii-startup-first-paint", |event| { println!("[TauriStartup] frontend first paint ready {}", event.payload()); @@ -1114,6 +1120,10 @@ pub fn run() { .state::<::terminal::pty_commands::pty::PtyState>() .shutdown_kill_all(); } + tauri::RunEvent::Exit => { + #[cfg(feature = "dhat-heap")] + crate::dhat_profiling::finish(); + } _ => {} } }); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 558034d97..12a1cf54b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,6 +5,11 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +#[cfg(feature = "dhat-heap")] +#[global_allocator] +static DHAT_ALLOCATOR: app_lib::dhat_profiling::DhatAllocator = + app_lib::dhat_profiling::DhatAllocator::new(); + fn main() { let mut args = std::env::args().skip(1); if args.next().as_deref() == Some("--session-provenance-hook") {