Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ Inside pane-scaled render and layout loops:

Prefer deterministic operation or architecture tests to wall-clock CI limits.
Performance benchmarks are supporting evidence, not substitutes for behavioral
coverage.
coverage. Before a stable release, `just bench-release-smoke` must compare the
candidate with the current stable binary under hidden and visible output; use
the longer release matrix only when the smoke test moves materially or when
validating performance work.

### Runtime/client boundary guardrail

Expand Down
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

## Unreleased

## [0.8.1] - 2026-08-18

### Added
- CLI help now points coding agents to Herdr's plain-text guide, documentation index, and built-in control skill.
- Added Qwen Code detection for idle, working, and user-confirmation states, plus optional native session restore. (#2730, #2743)
Expand All @@ -28,6 +26,7 @@
- Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay.

### Fixed
- High-rate output from many hidden panes no longer floods the server loop with redundant wakeups, and terminal input-mode synchronization no longer formats pane scrollback to read one keyboard flag.
- Chinese IME commits now reach panes on macOS when the focused application requests printable key-release events. (#2924)
- Windows now recognizes `Ctrl+1` through `Ctrl+9` keybindings instead of decoding those key records as control characters. (#2910)
- PowerShell panes now keep their process-reported working directory synchronized with the shell's logical location. (#2879, thanks @Pimpmuckl)
Expand Down
3 changes: 1 addition & 2 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

## Unreleased

## [0.8.1] - 2026-08-18

### Added
- CLI help now points coding agents to Herdr's plain-text guide, documentation index, and built-in control skill.
- Added Qwen Code detection for idle, working, and user-confirmation states, plus optional native session restore. (#2730, #2743)
Expand All @@ -28,6 +26,7 @@
- Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay.

### Fixed
- High-rate output from many hidden panes no longer floods the server loop with redundant wakeups, and terminal input-mode synchronization no longer formats pane scrollback to read one keyboard flag.
- Chinese IME commits now reach panes on macOS when the focused application requests printable key-release events. (#2924)
- Windows now recognizes `Ctrl+1` through `Ctrl+9` keybindings instead of decoding those key records as control characters. (#2910)
- PowerShell panes now keep their process-reported working directory synchronized with the shell's logical location. (#2879, thanks @Pimpmuckl)
Expand Down
6 changes: 6 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ build:
bench-render-scale:
cargo test --release --locked --bin herdr render_scale_profile -- --ignored --nocapture --test-threads=1

# Fast end-to-end CPU comparison against the current stable release
bench-release-smoke:
cargo build --release --locked
scripts/release_perf_smoke.sh "${CARGO_TARGET_DIR:-target}/release/herdr"

# Build the website and documentation
website-build:
cd website && bun install --frozen-lockfile && bun run build
Expand Down Expand Up @@ -134,6 +139,7 @@ release-docs-check:
pre-release-check:
just release-docs-check
just bench-render-scale
just bench-release-smoke
@echo "release review required: investigate material render-scaling regressions before publishing."
@echo "release review required: update skills/herdr/SKILL.md for this stable release so it matches the current CLI, IDs, agent lifecycle semantics, and safety guidance."
@echo "release policy: do not update skills/herdr/SKILL.md between stable releases; preview builds keep the latest stable skill."
Expand Down
146 changes: 146 additions & 0 deletions scripts/release_perf_case.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
set -euo pipefail

if [[ $# -ne 8 ]]; then
echo "usage: $0 <binary> <variant> <scenario> <round> <seconds> <warmup> <output-root> <platform>" >&2
exit 2
fi

bin_dir=$(cd "$(dirname "$1")" && pwd)
bin="$bin_dir/$(basename "$1")"
variant=$2
scenario=$3
round=$4
seconds=$5
warmup=$6
out_root=$(cd "$7" && pwd)
platform=$8
script_dir=$(cd "$(dirname "$0")" && pwd)
producer="$script_dir/release_perf_producer.pl"
cols=86
rows=47

case "$scenario" in
visible30) scenario_tag=v3; total_panes=1; writers=visible; rate=30 ;;
hidden50) scenario_tag=h5; total_panes=50; writers=hidden; rate=60 ;;
*) echo "unknown scenario: $scenario" >&2; exit 2 ;;
esac
case "$platform" in linux) platform_tag=l ;; macos) platform_tag=m ;; *) exit 2 ;; esac
variant_tag=${variant:0:1}
name="rps${platform_tag}${variant_tag}${scenario_tag}r${round}x$$"
state="/var/tmp/herdr-release-perf-$name"
xdg="$state/xdg"
runtime="$state/run"
gate="$state/start-output"
out="$out_root/$variant/$scenario/r$round"
mkdir -p "$xdg" "$runtime" "$out"

launch_env=(env -u HERDR_BIN_PATH -u HERDR_ENV -u HERDR_SOCKET_PATH -u HERDR_CLIENT_SOCKET_PATH -u HERDR_SESSION -u HERDR_STARTUP_CWD -u HERDR_WORKSPACE_ID -u HERDR_TAB_ID -u HERDR_PANE_ID XDG_CONFIG_HOME="$xdg" XDG_RUNTIME_DIR="$runtime" HERDR_DISABLE_SOUND=1 SHELL=/bin/sh)
control_env=(env -u HERDR_BIN_PATH -u HERDR_ENV -u HERDR_SOCKET_PATH -u HERDR_CLIENT_SOCKET_PATH -u HERDR_STARTUP_CWD -u HERDR_WORKSPACE_ID -u HERDR_TAB_ID -u HERDR_PANE_ID XDG_CONFIG_HOME="$xdg" XDG_RUNTIME_DIR="$runtime" HERDR_DISABLE_SOUND=1 SHELL=/bin/sh HERDR_SESSION="$name")

cleaned=0
cleanup() {
if [[ $cleaned -eq 1 ]]; then return; fi
cleaned=1
"${control_env[@]}" "$bin" session stop "$name" >/dev/null 2>&1 || true
for _ in $(seq 1 50); do
if "${control_env[@]}" "$bin" session delete "$name" >/dev/null 2>&1; then break; fi
sleep 0.1
done
tmux kill-session -t "$name" >/dev/null 2>&1 || true
rm -rf "$state"
}
trap cleanup EXIT INT TERM

printf -v launch 'exec '
printf -v quoted '%q ' "${launch_env[@]}" "$bin" --session "$name"
launch+=$quoted
tmux new-session -d -s "$name" -x "$cols" -y "$rows" "$launch"

panes_json=
for _ in $(seq 1 150); do
if panes_json=$("${control_env[@]}" "$bin" pane list 2>/dev/null); then break; fi
sleep 0.1
done
[[ -n "$panes_json" ]] || { echo "session API did not become ready" >&2; exit 1; }
root_pane=$(printf '%s\n' "$panes_json" | jq -r '.result.panes[0].pane_id')
workspace_id=$("${control_env[@]}" "$bin" workspace list | jq -r '.result.workspaces[0].workspace_id')
pane_file="$state/pane-ids.txt"
printf '%s\n' "$root_pane" > "$pane_file"

for ((index = 2; index <= total_panes; index++)); do
created=$("${control_env[@]}" "$bin" tab create --workspace "$workspace_id" --label "bench-$index" --no-focus)
pane_id=$(printf '%s\n' "$created" | jq -r '.result.root_pane.pane_id')
[[ -n "$pane_id" && "$pane_id" != null ]] || { echo "tab $index did not return a pane" >&2; exit 1; }
printf '%s\n' "$pane_id" >> "$pane_file"
done

index=0
while IFS= read -r pane_id; do
index=$((index + 1))
if [[ $writers == visible && $index -eq 1 ]] || [[ $writers == hidden && $index -gt 1 ]]; then
"${control_env[@]}" "$bin" pane run "$pane_id" "$producer" "$rate" "$gate" "p$index" >/dev/null
fi
done < "$pane_file"
touch "$gate"

socket="$xdg/herdr/sessions/$name/herdr.sock"
server_pid=
for _ in $(seq 1 80); do
server_pid=$(lsof -t "$socket" 2>/dev/null | head -n1 || true)
[[ -n "$server_pid" ]] && break
sleep 0.1
done
[[ -n "$server_pid" ]] || { echo "could not find server pid" >&2; exit 1; }
client_pid=$(tmux list-panes -s -t "$name" -F '#{pane_pid}')
[[ -n "$client_pid" ]] || { echo "could not find client pid" >&2; exit 1; }
all_pids="$server_pid $client_pid"

sleep "$warmup"
raw="$out/cpu-raw.txt"
if [[ $platform == linux ]]; then
pid_csv=$(printf '%s\n' $all_pids | paste -sd, -)
LC_ALL=C pidstat -h -u -p "$pid_csv" 1 "$seconds" > "$raw"
else
top_args=(top -l $((seconds + 1)) -s 1 -stats pid,cpu,time -n 2)
for pid in $all_pids; do top_args+=(-pid "$pid"); done
LC_ALL=C "${top_args[@]}" > "$raw"
fi

mean_linux() {
awk -v target="$2" '
/^Linux/ || /^#/ || NF < 5 { next }
{ found=0; for (i=1; i<=NF; i++) if ($i == target) { found=1; break }
if (found && $(NF-2) ~ /^[0-9]+([.][0-9]+)?$/) { sum += $(NF-2); count++ } }
END { if (!count) exit 1; printf "%.6f,%d", sum/count, count }
' "$1"
}
mean_macos() {
awk -v target="$2" '
$1 == target && $2 ~ /^[0-9]+([.][0-9]+)?%?$/ {
seen++; if (seen == 1) next; value=$2; gsub(/%/, "", value); sum += value; count++ }
END { if (!count) exit 1; printf "%.6f,%d", sum/count, count }
' "$1"
}

total=0
for pid in $all_pids; do
if [[ $platform == linux ]]; then parsed=$(mean_linux "$raw" "$pid"); else parsed=$(mean_macos "$raw" "$pid"); fi
mean=${parsed%,*}
samples=${parsed#*,}
[[ $samples -eq $seconds ]] || { echo "expected $seconds samples for pid $pid, got $samples" >&2; exit 1; }
total=$(awk -v total="$total" -v mean="$mean" 'BEGIN { printf "%.6f", total + mean }')
done

index=0
while IFS= read -r pane_id; do
index=$((index + 1))
if [[ $writers == visible && $index -eq 1 ]] || [[ $writers == hidden && $index -gt 1 ]]; then
read_file="$out/pane-$index.txt"
"${control_env[@]}" "$bin" pane read "$pane_id" --source visible --format text > "$read_file"
grep -q 'bench-output-' "$read_file" || { echo "writer pane $index produced no output" >&2; exit 1; }
fi
done < "$pane_file"

printf '%s\n' "$total" > "$out/total-cpu.txt"
printf '%s,%s,%s,%s\n' "$variant" "$scenario" "$round" "$total"
20 changes: 20 additions & 0 deletions scripts/release_perf_producer.pl
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env perl
use strict;
use warnings;
use Time::HiRes qw(clock_gettime sleep CLOCK_MONOTONIC);

my ($rate, $gate, $label) = @ARGV;
die "usage: $0 <rate-hz> <gate-file> <label>\n" unless $rate && $gate && $label;

sleep 0.01 until -e $gate;
$| = 1;
my $period = 1 / $rate;
my $next = clock_gettime(CLOCK_MONOTONIC);
my $sequence = 0;
while (1) {
$sequence++;
printf "\rbench-output-%08d-%s", $sequence, $label;
$next += $period;
my $remaining = $next - clock_gettime(CLOCK_MONOTONIC);
sleep $remaining if $remaining > 0;
}
82 changes: 82 additions & 0 deletions scripts/release_perf_smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail

if [[ $# -ne 1 ]]; then
echo "usage: $0 <candidate-binary>" >&2
exit 2
fi

candidate=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
[[ -x "$candidate" ]] || { echo "candidate binary is not executable: $candidate" >&2; exit 1; }
for command in curl jq lsof perl tmux; do
command -v "$command" >/dev/null || { echo "required command not found: $command" >&2; exit 1; }
done

case "$(uname -s)" in
Linux) platform=linux; command -v pidstat >/dev/null || { echo "required command not found: pidstat" >&2; exit 1; } ;;
Darwin) platform=macos ;;
*) echo "release performance smoke supports Linux and macOS" >&2; exit 1 ;;
esac
case "$(uname -m)" in
x86_64|amd64) arch=x86_64 ;;
arm64|aarch64) arch=aarch64 ;;
*) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac

root=$(mktemp -d /var/tmp/herdr-release-perf-smoke.XXXXXX)
cleanup() { rm -rf "$root"; }
trap cleanup EXIT INT TERM
mkdir -p "$root/results"

baseline=${HERDR_PERF_BASELINE_BIN:-}
if [[ -z "$baseline" ]]; then
baseline_version=$(jq -er '.version' website/latest.json)
baseline="$root/herdr-baseline"
curl -fL --retry 3 \
"https://github.com/herdrdev/herdr/releases/download/v${baseline_version}/herdr-${platform}-${arch}" \
-o "$baseline"
chmod +x "$baseline"
else
baseline=$(cd "$(dirname "$baseline")" && pwd)/$(basename "$baseline")
fi
[[ -x "$baseline" ]] || { echo "baseline binary is not executable: $baseline" >&2; exit 1; }

script_dir=$(cd "$(dirname "$0")" && pwd)
case_script="$script_dir/release_perf_case.sh"
seconds=${HERDR_PERF_SAMPLE_SECONDS:-10}
warmup=${HERDR_PERF_WARMUP_SECONDS:-3}

for round in 1 2; do
if [[ $round -eq 1 ]]; then variants="baseline candidate"; else variants="candidate baseline"; fi
for scenario in hidden50 visible30; do
for variant in $variants; do
if [[ $variant == baseline ]]; then binary=$baseline; else binary=$candidate; fi
"$case_script" "$binary" "$variant" "$scenario" "$round" "$seconds" "$warmup" "$root/results" "$platform"
done
done
done

mean_total() {
awk '{ sum += $1; count++ } END { if (!count) exit 1; printf "%.3f", sum/count }' \
"$root/results/$1/$2/r1/total-cpu.txt" \
"$root/results/$1/$2/r2/total-cpu.txt"
}

failed=0
printf '\nrelease performance smoke (%s/%s, two rounds, %ss samples)\n' "$platform" "$arch" "$seconds"
printf '%-12s %12s %12s %12s\n' scenario baseline candidate change
for scenario in hidden50 visible30; do
baseline_total=$(mean_total baseline "$scenario")
candidate_total=$(mean_total candidate "$scenario")
change=$(awk -v before="$baseline_total" -v after="$candidate_total" 'BEGIN { if (before == 0) print "n/a"; else printf "%+.1f%%", (after-before)/before*100 }')
printf '%-12s %12s %12s %12s\n' "$scenario" "$baseline_total" "$candidate_total" "$change"
if awk -v before="$baseline_total" -v after="$candidate_total" 'BEGIN { exit !(after > before * 1.25 && after - before > 0.5) }'; then
echo "error: $scenario candidate CPU exceeds baseline by more than 25% and 0.5 CPU points" >&2
failed=1
fi
done
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if [[ $failed -ne 0 ]]; then
exit 1
fi
echo "release performance smoke passed"
42 changes: 31 additions & 11 deletions scripts/test_ui_hot_path_architecture.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@
*sorted((PROJECT_ROOT / "src" / "ui").rglob("*.rs")),
PROJECT_ROOT / "src" / "server" / "render_stream.rs",
)
APP_SERVER_SOURCES = (
*sorted((PROJECT_ROOT / "src" / "app").rglob("*.rs")),
*sorted((PROJECT_ROOT / "src" / "server").rglob("*.rs")),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
TEST_MODULE = re.compile(r"(?m)^#\[cfg\(test\)\]\s*\nmod\s+\w+\s*\{")
FORBIDDEN_CALLS = (
AGGREGATE_STATE_CALLS = (
(
re.compile(r"(?:\.|::)input_state\b"),
"aggregate terminal input state; add a narrow accessor",
Expand All @@ -21,6 +25,9 @@
re.compile(r"(?:\.|::)(?:keyboard_state_ansi|kitty_keyboard_state_ansi)\b"),
"formatted keyboard state",
),
)
FORBIDDEN_CALLS = (
*AGGREGATE_STATE_CALLS,
(
re.compile(r"(?:\.|::)screen_text_snapshot\b"),
"formatted terminal screen snapshot",
Expand Down Expand Up @@ -130,18 +137,21 @@ def production_code(source: str) -> str:
return code


def find_violations(paths, rules) -> list[str]:
violations: list[str] = []
for path in paths:
code = production_code(path.read_text(encoding="utf-8"))
for pattern, description in rules:
for match in pattern.finditer(code):
line = code.count("\n", 0, match.start()) + 1
relative_path = path.relative_to(PROJECT_ROOT)
violations.append(f"{relative_path}:{line}: {description}")
return violations


class UiHotPathArchitectureTests(unittest.TestCase):
def test_render_hot_paths_avoid_known_expensive_runtime_queries(self) -> None:
violations: list[str] = []

for path in HOT_PATH_SOURCES:
source = path.read_text(encoding="utf-8")
code = production_code(source)
for pattern, description in FORBIDDEN_CALLS:
for match in pattern.finditer(code):
line = code.count("\n", 0, match.start()) + 1
relative_path = path.relative_to(PROJECT_ROOT)
violations.append(f"{relative_path}:{line}: {description}")
violations = find_violations(HOT_PATH_SOURCES, FORBIDDEN_CALLS)

self.assertEqual(
violations,
Expand All @@ -150,6 +160,16 @@ def test_render_hot_paths_avoid_known_expensive_runtime_queries(self) -> None:
+ "\n".join(violations),
)

def test_app_and_server_avoid_aggregate_terminal_state(self) -> None:
violations = find_violations(APP_SERVER_SOURCES, AGGREGATE_STATE_CALLS)

self.assertEqual(
violations,
[],
"App/server code must use narrow terminal-state accessors:\n"
+ "\n".join(violations),
)

def test_scanner_ignores_non_production_references(self) -> None:
source = '''
// runtime.input_state()
Expand Down
Loading
Loading