diff --git a/.github/workflows/perf-baseline.yml b/.github/workflows/perf-baseline.yml
new file mode 100644
index 00000000..f6aa1c43
--- /dev/null
+++ b/.github/workflows/perf-baseline.yml
@@ -0,0 +1,102 @@
+name: Performance Baseline
+
+on:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ performance:
+ name: Performance Baseline
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: ubuntu-latest
+ target: x86_64-unknown-linux-musl
+ - os: windows-latest
+ target: x86_64-pc-windows-msvc
+ - os: macos-latest
+ target: x86_64-apple-darwin
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set Python to PATH
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Add Conda to PATH (Windows)
+ if: startsWith(matrix.os, 'windows')
+ run: |
+ $path = $env:PATH + ";" + $env:CONDA + "\condabin"
+ echo "PATH=$path" >> $env:GITHUB_ENV
+
+ - name: Add Conda to PATH (Linux)
+ if: startsWith(matrix.os, 'ubuntu')
+ run: echo "PATH=$PATH:$CONDA/condabin" >> $GITHUB_ENV
+ shell: bash
+
+ - name: Install Conda + add to PATH (macOS)
+ if: startsWith(matrix.os, 'macos')
+ run: |
+ curl -o ~/miniconda.sh https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-x86_64.sh
+ bash ~/miniconda.sh -b -p ~/miniconda
+ echo "PATH=$PATH:$HOME/miniconda/bin" >> $GITHUB_ENV
+ echo "CONDA=$HOME/miniconda" >> $GITHUB_ENV
+ shell: bash
+
+ - name: Create test Conda environment
+ run: conda create -n perf-test-env python=3.12 -y
+
+ - name: Create test venv
+ run: python -m venv .venv
+ shell: bash
+
+ - name: Rust Tool Chain setup
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: stable
+ targets: ${{ matrix.target }}
+
+ - name: Cargo Fetch
+ run: cargo fetch
+ shell: bash
+
+ - name: Build Release
+ run: cargo build --release --target ${{ matrix.target }}
+ shell: bash
+
+ - name: Run Performance Tests
+ run: cargo test --release --features ci-perf --target ${{ matrix.target }} --test e2e_performance test_performance_summary -- --nocapture 2>&1 | tee perf-output.txt
+ env:
+ RUST_BACKTRACE: 1
+ RUST_LOG: warn
+ shell: bash
+
+ - name: Extract Performance Metrics
+ id: metrics
+ run: |
+ # Extract JSON metrics from test output
+ if grep -q "JSON metrics:" perf-output.txt; then
+ # Extract lines after "JSON metrics:" until the closing brace
+ sed -n '/JSON metrics:/,/^}/p' perf-output.txt | tail -n +2 > metrics.json
+ echo "Metrics extracted:"
+ cat metrics.json
+ else
+ echo '{"server_startup_ms": 0, "full_refresh_ms": 0, "environments_count": 0}' > metrics.json
+ echo "No metrics found, created empty metrics"
+ fi
+ shell: bash
+
+ - name: Upload Performance Baseline Artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: perf-baseline-${{ matrix.os }}
+ path: metrics.json
+ retention-days: 90
diff --git a/.github/workflows/perf-tests.yml b/.github/workflows/perf-tests.yml
new file mode 100644
index 00000000..4497c4d9
--- /dev/null
+++ b/.github/workflows/perf-tests.yml
@@ -0,0 +1,375 @@
+name: Performance Tests
+
+on:
+ pull_request:
+ branches:
+ - main
+ - release*
+ - release/*
+ - release-*
+ workflow_dispatch:
+
+permissions:
+ actions: read
+ contents: read
+ pull-requests: write
+
+jobs:
+ performance:
+ name: E2E Performance Tests
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ target: x86_64-pc-windows-msvc
+ - os: ubuntu-latest
+ target: x86_64-unknown-linux-musl
+ - os: macos-latest
+ target: x86_64-apple-darwin
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set Python to PATH
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Add Conda to PATH (Windows)
+ if: startsWith(matrix.os, 'windows')
+ run: |
+ $path = $env:PATH + ";" + $env:CONDA + "\condabin"
+ echo "PATH=$path" >> $env:GITHUB_ENV
+
+ - name: Add Conda to PATH (Ubuntu)
+ if: startsWith(matrix.os, 'ubuntu')
+ run: echo "PATH=$PATH:$CONDA/condabin" >> $GITHUB_ENV
+ shell: bash
+
+ - name: Install Conda + add to PATH (macOS)
+ if: startsWith(matrix.os, 'macos')
+ run: |
+ curl -o ~/miniconda.sh https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-x86_64.sh
+ bash ~/miniconda.sh -b -p ~/miniconda
+ echo "PATH=$PATH:$HOME/miniconda/bin" >> $GITHUB_ENV
+ echo "CONDA=$HOME/miniconda" >> $GITHUB_ENV
+ shell: bash
+
+ - name: Create test Conda environment
+ run: conda create -n perf-test-env python=3.12 -y
+
+ - name: Create test venv
+ run: python -m venv .venv
+ shell: bash
+
+ - name: Rust Tool Chain setup
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: stable
+ targets: ${{ matrix.target }}
+
+ - name: Cargo Fetch
+ run: cargo fetch
+ shell: bash
+
+ - name: Build Release
+ run: cargo build --release --target ${{ matrix.target }}
+ shell: bash
+
+ - name: Run Performance Tests
+ run: cargo test --release --features ci-perf --target ${{ matrix.target }} --test e2e_performance test_performance_summary -- --nocapture 2>&1 | tee perf-output.txt
+ env:
+ RUST_BACKTRACE: 1
+ RUST_LOG: warn
+ shell: bash
+
+ - name: Extract Performance Metrics
+ id: metrics
+ run: |
+ # Extract JSON metrics from test output
+ if grep -q "JSON metrics:" perf-output.txt; then
+ # Extract lines after "JSON metrics:" until the closing brace
+ sed -n '/JSON metrics:/,/^}/p' perf-output.txt | tail -n +2 > metrics.json
+ echo "Metrics extracted:"
+ cat metrics.json
+ else
+ echo '{"server_startup_ms": 0, "full_refresh_ms": 0, "environments_count": 0}' > metrics.json
+ echo "No metrics found, created empty metrics"
+ fi
+ shell: bash
+
+ - name: Upload PR Performance Results
+ uses: actions/upload-artifact@v4
+ with:
+ name: perf-pr-${{ matrix.os }}
+ path: metrics.json
+
+ - name: Download Baseline Performance
+ uses: dawidd6/action-download-artifact@v6
+ id: download-baseline
+ continue-on-error: true
+ with:
+ workflow: perf-baseline.yml
+ branch: main
+ name: perf-baseline-${{ matrix.os }}
+ path: baseline-perf
+
+ - name: Generate Performance Report (Linux)
+ if: startsWith(matrix.os, 'ubuntu')
+ id: perf-linux
+ run: |
+ # Extract PR metrics
+ PR_STARTUP=$(jq -r '.server_startup_ms // 0' metrics.json)
+ PR_REFRESH=$(jq -r '.full_refresh_ms // 0' metrics.json)
+ PR_ENVS=$(jq -r '.environments_count // 0' metrics.json)
+
+ # Extract baseline metrics (default to 0 if not available)
+ if [ -f baseline-perf/metrics.json ]; then
+ BASELINE_STARTUP=$(jq -r '.server_startup_ms // 0' baseline-perf/metrics.json)
+ BASELINE_REFRESH=$(jq -r '.full_refresh_ms // 0' baseline-perf/metrics.json)
+ BASELINE_ENVS=$(jq -r '.environments_count // 0' baseline-perf/metrics.json)
+ else
+ BASELINE_STARTUP=0
+ BASELINE_REFRESH=0
+ BASELINE_ENVS=0
+ fi
+
+ # Calculate diff (positive means slowdown, negative means speedup)
+ STARTUP_DIFF=$(echo "$PR_STARTUP - $BASELINE_STARTUP" | bc)
+ REFRESH_DIFF=$(echo "$PR_REFRESH - $BASELINE_REFRESH" | bc)
+
+ # Calculate percentage change
+ if [ "$BASELINE_STARTUP" != "0" ]; then
+ STARTUP_PCT=$(echo "scale=1; ($STARTUP_DIFF / $BASELINE_STARTUP) * 100" | bc)
+ else
+ STARTUP_PCT="N/A"
+ fi
+
+ if [ "$BASELINE_REFRESH" != "0" ]; then
+ REFRESH_PCT=$(echo "scale=1; ($REFRESH_DIFF / $BASELINE_REFRESH) * 100" | bc)
+ else
+ REFRESH_PCT="N/A"
+ fi
+
+ # Determine delta indicators (for perf, negative is good = faster)
+ if (( $(echo "$REFRESH_DIFF < -100" | bc -l) )); then
+ DELTA_INDICATOR=":rocket:"
+ elif (( $(echo "$REFRESH_DIFF < 0" | bc -l) )); then
+ DELTA_INDICATOR=":white_check_mark:"
+ elif (( $(echo "$REFRESH_DIFF > 500" | bc -l) )); then
+ DELTA_INDICATOR=":warning:"
+ elif (( $(echo "$REFRESH_DIFF > 100" | bc -l) )); then
+ DELTA_INDICATOR=":small_red_triangle:"
+ else
+ DELTA_INDICATOR=":heavy_minus_sign:"
+ fi
+
+ # Set outputs
+ echo "pr_startup=$PR_STARTUP" >> $GITHUB_OUTPUT
+ echo "pr_refresh=$PR_REFRESH" >> $GITHUB_OUTPUT
+ echo "baseline_startup=$BASELINE_STARTUP" >> $GITHUB_OUTPUT
+ echo "baseline_refresh=$BASELINE_REFRESH" >> $GITHUB_OUTPUT
+ echo "startup_diff=$STARTUP_DIFF" >> $GITHUB_OUTPUT
+ echo "refresh_diff=$REFRESH_DIFF" >> $GITHUB_OUTPUT
+ echo "startup_pct=$STARTUP_PCT" >> $GITHUB_OUTPUT
+ echo "refresh_pct=$REFRESH_PCT" >> $GITHUB_OUTPUT
+ echo "delta_indicator=$DELTA_INDICATOR" >> $GITHUB_OUTPUT
+
+ # Write step summary
+ echo "## Performance Report (Linux)" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Metric | PR | Baseline | Delta | Change |" >> $GITHUB_STEP_SUMMARY
+ echo "|--------|-----|----------|-------|--------|" >> $GITHUB_STEP_SUMMARY
+ echo "| Server Startup | ${PR_STARTUP}ms | ${BASELINE_STARTUP}ms | ${STARTUP_DIFF}ms | ${STARTUP_PCT}% |" >> $GITHUB_STEP_SUMMARY
+ echo "| Full Refresh | ${PR_REFRESH}ms | ${BASELINE_REFRESH}ms | ${REFRESH_DIFF}ms | ${REFRESH_PCT}% ${DELTA_INDICATOR} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Environments | ${PR_ENVS} | ${BASELINE_ENVS} | - | - |" >> $GITHUB_STEP_SUMMARY
+ shell: bash
+
+ - name: Generate Performance Report (Windows)
+ if: startsWith(matrix.os, 'windows')
+ id: perf-windows
+ run: |
+ # Extract PR metrics
+ $prMetrics = Get-Content -Path "metrics.json" -Raw | ConvertFrom-Json
+ $prStartup = $prMetrics.server_startup_ms
+ $prRefresh = $prMetrics.full_refresh_ms
+ $prEnvs = $prMetrics.environments_count
+
+ # Extract baseline metrics (default to 0 if not available)
+ if (Test-Path "baseline-perf/metrics.json") {
+ $baselineMetrics = Get-Content -Path "baseline-perf/metrics.json" -Raw | ConvertFrom-Json
+ $baselineStartup = $baselineMetrics.server_startup_ms
+ $baselineRefresh = $baselineMetrics.full_refresh_ms
+ $baselineEnvs = $baselineMetrics.environments_count
+ } else {
+ $baselineStartup = 0
+ $baselineRefresh = 0
+ $baselineEnvs = 0
+ }
+
+ # Calculate diff
+ $startupDiff = $prStartup - $baselineStartup
+ $refreshDiff = $prRefresh - $baselineRefresh
+
+ # Calculate percentage change
+ if ($baselineStartup -gt 0) {
+ $startupPct = [math]::Round(($startupDiff / $baselineStartup) * 100, 1)
+ } else {
+ $startupPct = "N/A"
+ }
+
+ if ($baselineRefresh -gt 0) {
+ $refreshPct = [math]::Round(($refreshDiff / $baselineRefresh) * 100, 1)
+ } else {
+ $refreshPct = "N/A"
+ }
+
+ # Determine delta indicator
+ if ($refreshDiff -lt -100) {
+ $deltaIndicator = ":rocket:"
+ } elseif ($refreshDiff -lt 0) {
+ $deltaIndicator = ":white_check_mark:"
+ } elseif ($refreshDiff -gt 500) {
+ $deltaIndicator = ":warning:"
+ } elseif ($refreshDiff -gt 100) {
+ $deltaIndicator = ":small_red_triangle:"
+ } else {
+ $deltaIndicator = ":heavy_minus_sign:"
+ }
+
+ # Set outputs
+ echo "pr_startup=$prStartup" >> $env:GITHUB_OUTPUT
+ echo "pr_refresh=$prRefresh" >> $env:GITHUB_OUTPUT
+ echo "baseline_startup=$baselineStartup" >> $env:GITHUB_OUTPUT
+ echo "baseline_refresh=$baselineRefresh" >> $env:GITHUB_OUTPUT
+ echo "startup_diff=$startupDiff" >> $env:GITHUB_OUTPUT
+ echo "refresh_diff=$refreshDiff" >> $env:GITHUB_OUTPUT
+ echo "startup_pct=$startupPct" >> $env:GITHUB_OUTPUT
+ echo "refresh_pct=$refreshPct" >> $env:GITHUB_OUTPUT
+ echo "delta_indicator=$deltaIndicator" >> $env:GITHUB_OUTPUT
+
+ # Write step summary
+ echo "## Performance Report (Windows)" >> $env:GITHUB_STEP_SUMMARY
+ echo "" >> $env:GITHUB_STEP_SUMMARY
+ echo "| Metric | PR | Baseline | Delta | Change |" >> $env:GITHUB_STEP_SUMMARY
+ echo "|--------|-----|----------|-------|--------|" >> $env:GITHUB_STEP_SUMMARY
+ echo "| Server Startup | ${prStartup}ms | ${baselineStartup}ms | ${startupDiff}ms | ${startupPct}% |" >> $env:GITHUB_STEP_SUMMARY
+ echo "| Full Refresh | ${prRefresh}ms | ${baselineRefresh}ms | ${refreshDiff}ms | ${refreshPct}% ${deltaIndicator} |" >> $env:GITHUB_STEP_SUMMARY
+ echo "| Environments | ${prEnvs} | ${baselineEnvs} | - | - |" >> $env:GITHUB_STEP_SUMMARY
+ shell: pwsh
+
+ - name: Generate Performance Report (macOS)
+ if: startsWith(matrix.os, 'macos')
+ id: perf-macos
+ run: |
+ # Extract PR metrics
+ PR_STARTUP=$(jq -r '.server_startup_ms // 0' metrics.json)
+ PR_REFRESH=$(jq -r '.full_refresh_ms // 0' metrics.json)
+ PR_ENVS=$(jq -r '.environments_count // 0' metrics.json)
+
+ # Extract baseline metrics (default to 0 if not available)
+ if [ -f baseline-perf/metrics.json ]; then
+ BASELINE_STARTUP=$(jq -r '.server_startup_ms // 0' baseline-perf/metrics.json)
+ BASELINE_REFRESH=$(jq -r '.full_refresh_ms // 0' baseline-perf/metrics.json)
+ BASELINE_ENVS=$(jq -r '.environments_count // 0' baseline-perf/metrics.json)
+ else
+ BASELINE_STARTUP=0
+ BASELINE_REFRESH=0
+ BASELINE_ENVS=0
+ fi
+
+ # Calculate diff
+ STARTUP_DIFF=$((PR_STARTUP - BASELINE_STARTUP))
+ REFRESH_DIFF=$((PR_REFRESH - BASELINE_REFRESH))
+
+ # Set outputs
+ echo "pr_startup=$PR_STARTUP" >> $GITHUB_OUTPUT
+ echo "pr_refresh=$PR_REFRESH" >> $GITHUB_OUTPUT
+ echo "baseline_startup=$BASELINE_STARTUP" >> $GITHUB_OUTPUT
+ echo "baseline_refresh=$BASELINE_REFRESH" >> $GITHUB_OUTPUT
+ echo "startup_diff=$STARTUP_DIFF" >> $GITHUB_OUTPUT
+ echo "refresh_diff=$REFRESH_DIFF" >> $GITHUB_OUTPUT
+
+ # Write step summary
+ echo "## Performance Report (macOS)" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Metric | PR | Baseline | Delta |" >> $GITHUB_STEP_SUMMARY
+ echo "|--------|-----|----------|-------|" >> $GITHUB_STEP_SUMMARY
+ echo "| Server Startup | ${PR_STARTUP}ms | ${BASELINE_STARTUP}ms | ${STARTUP_DIFF}ms |" >> $GITHUB_STEP_SUMMARY
+ echo "| Full Refresh | ${PR_REFRESH}ms | ${BASELINE_REFRESH}ms | ${REFRESH_DIFF}ms |" >> $GITHUB_STEP_SUMMARY
+ echo "| Environments | ${PR_ENVS} | ${BASELINE_ENVS} | - |" >> $GITHUB_STEP_SUMMARY
+ shell: bash
+
+ - name: Post Performance Comment (Linux)
+ if: startsWith(matrix.os, 'ubuntu') && github.event_name == 'pull_request'
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: perf-linux
+ message: |
+ ## Performance Report (Linux) ${{ steps.perf-linux.outputs.delta_indicator }}
+
+ | Metric | PR | Baseline | Delta | Change |
+ |--------|-----|----------|-------|--------|
+ | Server Startup | ${{ steps.perf-linux.outputs.pr_startup }}ms | ${{ steps.perf-linux.outputs.baseline_startup }}ms | ${{ steps.perf-linux.outputs.startup_diff }}ms | ${{ steps.perf-linux.outputs.startup_pct }}% |
+ | Full Refresh | ${{ steps.perf-linux.outputs.pr_refresh }}ms | ${{ steps.perf-linux.outputs.baseline_refresh }}ms | ${{ steps.perf-linux.outputs.refresh_diff }}ms | ${{ steps.perf-linux.outputs.refresh_pct }}% |
+
+ ---
+
+ Legend
+
+ - :rocket: Significant speedup (>100ms faster)
+ - :white_check_mark: Faster than baseline
+ - :heavy_minus_sign: No significant change
+ - :small_red_triangle: Slower than baseline (>100ms)
+ - :warning: Significant slowdown (>500ms)
+
+
+ - name: Post Performance Comment (Windows)
+ if: startsWith(matrix.os, 'windows') && github.event_name == 'pull_request'
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: perf-windows
+ message: |
+ ## Performance Report (Windows) ${{ steps.perf-windows.outputs.delta_indicator }}
+
+ | Metric | PR | Baseline | Delta | Change |
+ |--------|-----|----------|-------|--------|
+ | Server Startup | ${{ steps.perf-windows.outputs.pr_startup }}ms | ${{ steps.perf-windows.outputs.baseline_startup }}ms | ${{ steps.perf-windows.outputs.startup_diff }}ms | ${{ steps.perf-windows.outputs.startup_pct }}% |
+ | Full Refresh | ${{ steps.perf-windows.outputs.pr_refresh }}ms | ${{ steps.perf-windows.outputs.baseline_refresh }}ms | ${{ steps.perf-windows.outputs.refresh_diff }}ms | ${{ steps.perf-windows.outputs.refresh_pct }}% |
+
+ ---
+
+ Legend
+
+ - :rocket: Significant speedup (>100ms faster)
+ - :white_check_mark: Faster than baseline
+ - :heavy_minus_sign: No significant change
+ - :small_red_triangle: Slower than baseline (>100ms)
+ - :warning: Significant slowdown (>500ms)
+
+
+ - name: Post Performance Comment (macOS)
+ if: startsWith(matrix.os, 'macos') && github.event_name == 'pull_request'
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: perf-macos
+ message: |
+ ## Performance Report (macOS)
+
+ | Metric | PR | Baseline | Delta |
+ |--------|-----|----------|-------|
+ | Server Startup | ${{ steps.perf-macos.outputs.pr_startup }}ms | ${{ steps.perf-macos.outputs.baseline_startup }}ms | ${{ steps.perf-macos.outputs.startup_diff }}ms |
+ | Full Refresh | ${{ steps.perf-macos.outputs.pr_refresh }}ms | ${{ steps.perf-macos.outputs.baseline_refresh }}ms | ${{ steps.perf-macos.outputs.refresh_diff }}ms |
+
+ ---
+
+ Legend
+
+ - :rocket: Significant speedup (>100ms faster)
+ - :white_check_mark: Faster than baseline
+ - :heavy_minus_sign: No significant change
+ - :small_red_triangle: Slower than baseline (>100ms)
+ - :warning: Significant slowdown (>500ms)
+
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 5e5a216b..9262f343 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -9,5 +9,8 @@
},
"git.branchProtection": ["main", "release/*"],
"git.branchProtectionPrompt": "alwaysCommitToNewBranch",
- "git.branchRandomName.enable": true
+ "git.branchRandomName.enable": true,
+ "chat.tools.terminal.autoApprove": {
+ "cargo test": true
+ }
}
diff --git a/Cargo.lock b/Cargo.lock
index c91c6a03..22067f5f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -389,6 +389,15 @@ version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
[[package]]
name = "memchr"
version = "2.7.4"
@@ -404,6 +413,15 @@ dependencies = [
"cc",
]
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "once_cell"
version = "1.19.0"
@@ -446,6 +464,8 @@ dependencies = [
"regex",
"serde",
"serde_json",
+ "tracing",
+ "tracing-subscriber",
"winresource",
]
@@ -778,6 +798,12 @@ dependencies = [
"winreg",
]
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
[[package]]
name = "proc-macro2"
version = "1.0.101"
@@ -940,6 +966,21 @@ dependencies = [
"digest",
]
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
[[package]]
name = "strsim"
version = "0.11.1"
@@ -979,6 +1020,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "toml"
version = "0.8.14"
@@ -1052,6 +1102,80 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109"
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-serde"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
+dependencies = [
+ "serde",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "serde",
+ "serde_json",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+ "tracing-serde",
+]
+
[[package]]
name = "typenum"
version = "1.17.0"
@@ -1070,6 +1194,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
[[package]]
name = "version_check"
version = "0.9.4"
diff --git a/crates/pet/Cargo.toml b/crates/pet/Cargo.toml
index 9b554432..375a5f1d 100644
--- a/crates/pet/Cargo.toml
+++ b/crates/pet/Cargo.toml
@@ -40,6 +40,8 @@ pet-telemetry = { path = "../pet-telemetry" }
pet-global-virtualenvs = { path = "../pet-global-virtualenvs" }
pet-uv = { path = "../pet-uv" }
log = "0.4.21"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
clap = { version = "4.5.4", features = ["derive", "cargo"] }
serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.93"
@@ -56,3 +58,4 @@ ci-homebrew-container = []
ci-poetry-global = []
ci-poetry-project = []
ci-poetry-custom = []
+ci-perf = []
diff --git a/crates/pet/src/find.rs b/crates/pet/src/find.rs
index 8d2650b0..9361ff0e 100644
--- a/crates/pet/src/find.rs
+++ b/crates/pet/src/find.rs
@@ -22,6 +22,7 @@ use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Duration;
use std::{sync::Arc, thread};
+use tracing::{info_span, instrument};
use crate::locators::identify_python_environment_using_locators;
@@ -40,6 +41,7 @@ pub enum SearchScope {
Workspace,
}
+#[instrument(skip(reporter, configuration, locators, environment), fields(search_scope = ?search_scope))]
pub fn find_and_report_envs(
reporter: &dyn Reporter,
configuration: Configuration,
@@ -72,6 +74,7 @@ pub fn find_and_report_envs(
// 1. Find using known global locators.
s.spawn(|| {
// Find in all the finders
+ let _span = info_span!("locators_phase").entered();
let start = std::time::Instant::now();
if search_global {
thread::scope(|s| {
@@ -90,6 +93,8 @@ pub fn find_and_report_envs(
let locator = locator.clone();
let summary = summary.clone();
s.spawn(move || {
+ let locator_name = format!("{:?}", locator.get_kind());
+ let _span = info_span!("locator_find", locator = %locator_name).entered();
let start = std::time::Instant::now();
trace!("Searching using locator: {:?}", locator.get_kind());
locator.find(reporter);
@@ -115,6 +120,7 @@ pub fn find_and_report_envs(
});
// Step 2: Search in PATH variable
s.spawn(|| {
+ let _span = info_span!("path_search_phase").entered();
let start = std::time::Instant::now();
if search_global {
let global_env_search_paths: Vec =
@@ -144,6 +150,7 @@ pub fn find_and_report_envs(
let environment_directories_for_step3 = environment_directories.clone();
let summary_for_step3 = summary.clone();
s.spawn(move || {
+ let _span = info_span!("global_virtualenvs_phase").entered();
let start = std::time::Instant::now();
if search_global {
let mut possible_environments = vec![];
@@ -202,6 +209,7 @@ pub fn find_and_report_envs(
// that could the discovery.
let summary_for_step4 = summary.clone();
s.spawn(move || {
+ let _span = info_span!("workspace_search_phase").entered();
let start = std::time::Instant::now();
thread::scope(|s| {
// Find environments in the workspace folders.
@@ -253,6 +261,7 @@ pub fn find_and_report_envs(
summary
}
+#[instrument(skip(reporter, locators, global_env_search_paths, environment_directories), fields(workspace = %workspace_folder.display()))]
pub fn find_python_environments_in_workspace_folder_recursive(
workspace_folder: &PathBuf,
reporter: &dyn Reporter,
@@ -391,6 +400,7 @@ fn find_python_environments_in_paths_with_locators(
}
}
+#[instrument(skip(locators, reporter, global_env_search_paths), fields(executable_count = executables.len()))]
pub fn identify_python_executables_using_locators(
executables: Vec,
locators: &Arc>>,
diff --git a/crates/pet/src/jsonrpc.rs b/crates/pet/src/jsonrpc.rs
index eaf87446..5719033b 100644
--- a/crates/pet/src/jsonrpc.rs
+++ b/crates/pet/src/jsonrpc.rs
@@ -8,6 +8,7 @@ use crate::find::SearchScope;
use crate::locators::create_locators;
use lazy_static::lazy_static;
use log::{error, info, trace};
+use pet::initialize_tracing;
use pet::resolve::resolve_environment;
use pet_conda::Conda;
use pet_conda::CondaLocator;
@@ -46,6 +47,7 @@ use std::{
thread,
time::SystemTime,
};
+use tracing::info_span;
lazy_static! {
/// Used to ensure we can have only one refreh at a time.
@@ -63,7 +65,9 @@ pub struct Context {
static MISSING_ENVS_REPORTED: AtomicBool = AtomicBool::new(false);
pub fn start_jsonrpc_server() {
- jsonrpc::initialize_logger(log::LevelFilter::Trace);
+ // Initialize tracing for performance profiling (controlled by RUST_LOG env var)
+ // Note: This includes log compatibility, so we don't call jsonrpc::initialize_logger
+ initialize_tracing(false);
// These are globals for the the lifetime of the server.
// Hence passed around as Arcs via the context.
@@ -172,6 +176,12 @@ pub fn handle_refresh(context: Arc, id: u32, params: Value) {
});
// Start in a new thread, we can have multiple requests.
thread::spawn(move || {
+ let _span = info_span!("handle_refresh",
+ search_kind = ?refresh_options.search_kind,
+ has_search_paths = refresh_options.search_paths.is_some()
+ )
+ .entered();
+
// Ensure we can have only one refresh at a time.
let lock = REFRESH_LOCK.lock().unwrap();
diff --git a/crates/pet/src/lib.rs b/crates/pet/src/lib.rs
index 68f9aed4..dbc6a120 100644
--- a/crates/pet/src/lib.rs
+++ b/crates/pet/src/lib.rs
@@ -17,11 +17,51 @@ use pet_reporter::{self, cache::CacheReporter, stdio};
use resolve::resolve_environment;
use std::path::PathBuf;
use std::{collections::BTreeMap, env, sync::Arc, time::SystemTime};
+use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
pub mod find;
pub mod locators;
pub mod resolve;
+/// Initialize tracing subscriber for performance profiling.
+/// Set RUST_LOG=info or RUST_LOG=pet=debug for more detailed traces.
+/// Set PET_TRACE_FORMAT=json for JSON output (useful for analysis tools).
+///
+/// Note: This replaces the env_logger initialization since tracing-subscriber
+/// provides a log compatibility layer via tracing-log.
+pub fn initialize_tracing(verbose: bool) {
+ use std::sync::Once;
+ static INIT: Once = Once::new();
+
+ INIT.call_once(|| {
+ let filter = if verbose {
+ EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("pet=debug"))
+ } else {
+ EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"))
+ };
+
+ let use_json = env::var("PET_TRACE_FORMAT")
+ .map(|v| v == "json")
+ .unwrap_or(false);
+
+ if use_json {
+ tracing_subscriber::registry()
+ .with(filter)
+ .with(fmt::layer().json())
+ .init();
+ } else {
+ tracing_subscriber::registry()
+ .with(filter)
+ .with(
+ fmt::layer()
+ .with_target(true)
+ .with_timer(fmt::time::uptime()),
+ )
+ .init();
+ }
+ });
+}
+
#[derive(Debug, Clone)]
pub struct FindOptions {
pub print_list: bool,
@@ -35,11 +75,13 @@ pub struct FindOptions {
}
pub fn find_and_report_envs_stdio(options: FindOptions) {
- stdio::initialize_logger(if options.verbose {
- log::LevelFilter::Trace
- } else {
- log::LevelFilter::Warn
- });
+ // Initialize tracing for performance profiling (includes log compatibility)
+ initialize_tracing(options.verbose);
+
+ // Note: We don't call stdio::initialize_logger here anymore since
+ // tracing-subscriber provides log compatibility via tracing-log crate.
+ // stdio::initialize_logger would conflict with our tracing subscriber.
+
let now = SystemTime::now();
let config = create_config(&options);
let search_scope = if options.workspace_only {
@@ -196,11 +238,12 @@ fn find_envs(
}
pub fn resolve_report_stdio(executable: PathBuf, verbose: bool, cache_directory: Option) {
- stdio::initialize_logger(if verbose {
- log::LevelFilter::Trace
- } else {
- log::LevelFilter::Warn
- });
+ // Initialize tracing for performance profiling (includes log compatibility)
+ initialize_tracing(verbose);
+
+ // Note: We don't call stdio::initialize_logger here anymore since
+ // tracing-subscriber provides log compatibility via tracing-log crate.
+
let now = SystemTime::now();
if let Some(cache_directory) = cache_directory.clone() {
diff --git a/crates/pet/src/locators.rs b/crates/pet/src/locators.rs
index ea0ea61c..00448b2a 100644
--- a/crates/pet/src/locators.rs
+++ b/crates/pet/src/locators.rs
@@ -25,6 +25,7 @@ use pet_virtualenv::VirtualEnv;
use pet_virtualenvwrapper::VirtualEnvWrapper;
use std::path::PathBuf;
use std::sync::Arc;
+use tracing::{info_span, instrument};
pub fn create_locators(
conda_locator: Arc,
@@ -95,6 +96,7 @@ pub fn create_locators(
/// Identify the Python environment using the locators.
/// search_path : Generally refers to original folder that was being searched when the env was found.
+#[instrument(skip(locators, global_env_search_paths), fields(executable = %env.executable.display()))]
pub fn identify_python_environment_using_locators(
env: &PythonEnv,
locators: &[Arc],
@@ -105,9 +107,16 @@ pub fn identify_python_environment_using_locators(
"Identifying Python environment using locators: {:?}",
executable
);
- if let Some(env) = locators.iter().find_map(|loc| loc.try_from(env)) {
- return Some(env);
+
+ // Try each locator and record which one matches
+ for loc in locators.iter() {
+ let locator_name = format!("{:?}", loc.get_kind());
+ let _span = info_span!("try_from_locator", locator = %locator_name).entered();
+ if let Some(env) = loc.try_from(env) {
+ return Some(env);
+ }
}
+
trace!(
"Failed to identify Python environment using locators, now trying to resolve: {:?}",
executable
@@ -116,6 +125,8 @@ pub fn identify_python_environment_using_locators(
// Yikes, we have no idea what this is.
// Lets get the actual interpreter info and try to figure this out.
// We try to get the interpreter info, hoping that the real exe returned might be identifiable.
+ let _resolve_span =
+ info_span!("resolve_python_env", executable = %executable.display()).entered();
if let Some(resolved_env) = ResolvedPythonEnv::from(&executable) {
let env = resolved_env.to_python_env();
if let Some(env) = locators.iter().find_map(|loc| loc.try_from(&env)) {
diff --git a/crates/pet/tests/e2e_performance.rs b/crates/pet/tests/e2e_performance.rs
new file mode 100644
index 00000000..c902b39c
--- /dev/null
+++ b/crates/pet/tests/e2e_performance.rs
@@ -0,0 +1,727 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! End-to-end performance tests for the pet JSONRPC server.
+//!
+//! These tests spawn the pet server as a subprocess and communicate via JSONRPC
+//! to measure discovery performance from a client perspective.
+
+use serde::Deserialize;
+use serde_json::{json, Value};
+use std::collections::HashMap;
+use std::env;
+use std::io::{BufRead, BufReader, Read, Write};
+use std::path::PathBuf;
+use std::process::{Child, Command, Stdio};
+use std::sync::atomic::{AtomicU32, Ordering};
+use std::sync::{Arc, Mutex};
+use std::time::{Duration, Instant};
+
+mod common;
+
+/// JSONRPC request ID counter
+static REQUEST_ID: AtomicU32 = AtomicU32::new(1);
+
+/// Performance metrics collected during tests
+#[derive(Debug, Clone, Default)]
+pub struct PerformanceMetrics {
+ /// Time to spawn server and get first response (configure)
+ pub server_startup_ms: u128,
+ /// Time for full machine refresh
+ pub full_refresh_ms: u128,
+ /// Time for workspace-scoped refresh
+ pub workspace_refresh_ms: Option,
+ /// Time for kind-specific refresh
+ pub kind_refresh_ms: HashMap,
+ /// Number of environments discovered
+ pub environments_count: usize,
+ /// Number of managers discovered
+ pub managers_count: usize,
+ /// Time to first environment notification
+ pub time_to_first_env_ms: Option,
+ /// Resolve times (cold and warm)
+ pub resolve_times_ms: Vec,
+}
+
+/// Refresh result from server
+#[derive(Debug, Clone, Deserialize)]
+pub struct RefreshResult {
+ pub duration: u128,
+}
+
+/// Environment notification from server
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Environment {
+ pub executable: Option,
+ pub kind: Option,
+ #[allow(dead_code)]
+ pub version: Option,
+}
+
+/// Manager notification from server
+#[derive(Debug, Clone, Deserialize)]
+pub struct Manager {
+ #[allow(dead_code)]
+ pub tool: Option,
+ #[allow(dead_code)]
+ pub executable: Option,
+}
+
+/// Shared state for handling notifications
+struct SharedState {
+ environments: Mutex>,
+ managers: Mutex>,
+ first_env_time: Mutex