Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions docs/contributing/macos-memory-profiling.md
Original file line number Diff line number Diff line change
@@ -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.
109 changes: 109 additions & 0 deletions docs/contributing/rust-heap-profiling.md
Original file line number Diff line number Diff line change
@@ -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.
148 changes: 148 additions & 0 deletions scripts/dev/profile-macos-memory.sh
Original file line number Diff line number Diff line change
@@ -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}\""
44 changes: 44 additions & 0 deletions scripts/dev/profile-rust-heap.sh
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading