Skip to content

tools: event-engine A/B benchmark harness (oha + ab)#32

Open
andypost wants to merge 7 commits into
masterfrom
bench/io-engine-harness
Open

tools: event-engine A/B benchmark harness (oha + ab)#32
andypost wants to merge 7 commits into
masterfrom
bench/io-engine-harness

Conversation

@andypost

Copy link
Copy Markdown
Owner

tools: event-engine A/B benchmark harness

tools/bench-engines.sh runs an identical request-hot-path matrix against two unitd build dirs and reports RPS, p50/p99 latency, error counts and router CPU-µs/req per build, with a markdown summary (mean ± stdev + delta%) via tools/bench-engines-report.py.

  • Build-agnostic: takes two build dirs; pointing both slots at one build gives a null A/A drift test (used to establish the noise floor before any A/B claim).
  • Interleaved A/B rounds to cancel thermal/cache drift; engine identity asserted per build from the using "<name>" event engine log line (aborts on an accidental A/A unless --allow-same).
  • Scenarios: return-200 keepalive at high and LOW concurrency (the low-concurrency run exists to expose listener wake-distribution effects), Connection: close, proxy-to-self, and a PHP app (process-tree CPU accounting) when a php module is present.
  • oha (JSON) primary, ApacheBench secondary for continuity with older results; server tree pinned at spawn so app workers inherit the mask; AF_UNIX path-length guard; C locale forced for float parsing.

Used to produce the results in the io_uring engine PRs (#30/#31).

🤖 Generated with Claude Code

andypost and others added 3 commits July 12, 2026 04:32
tools/bench-engines.sh runs an identical request-hot-path matrix against two
unitd build dirs and reports RPS, p50/p99 latency, error counts and router
CPU us/req per build. It is build-agnostic (only needs sbin/unitd, plus a php
module for the optional PHP scenario), so pointing both slots at one epoll
build doubles as a null A/A drift test.

Per round, per build: isolated statedir/control socket/log under a run dir,
PUT config, verify success, warm up, measure with oha (JSON) plus an ab
continuity pass, kill cleanly. Builds interleave A/B each round to cancel
thermal/cache drift. Scenario matrix: return-200 keepalive high-conc (-c256),
keepalive low-conc (-c8, listen-herd detector), close, proxy->self (-c64),
and an optional PHP hello app.

Engine assertion: greps the unitd log for 'using "<name>" event engine' and
records the engine per build; aborts if both builds report the same engine
unless --allow-same/ALLOW_SAME=1. Falls back to engine=unknown with a warning
on builds that predate that log line so A/A still works.

tools/bench-engines-report.py summarizes results into markdown tables with
mean +/- stdev per scenario per build and the B-vs-A delta %.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
taskset at unitd spawn makes app workers inherit the server-core mask
(previously only the router was re-pinned, so PHP workers could land on
the load-generator cores). A failed PHP config PUT now records SKIP
instead of measuring a dead listener as 100% failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The main process logs 'using "<name>" event engine' before the log file
opens, so the line only reaches stderr; router workers log it later.
Capture stderr per run and check both streams.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a benchmark harness (bench-engines.sh) and a reporting tool (bench-engines-report.py) to compare the performance of two different unitd event engines. The feedback highlights several critical areas for improvement, including fixing potential process leaks on early failures, resolving bash syntax errors when processes exit during CPU tick calculation, ensuring portability by dynamically partitioning CPU cores instead of hardcoding them, and correcting subshell file descriptor handling to avoid 'Bad file descriptor' errors. Additionally, using a context manager for file operations in the Python script is recommended to prevent resource leaks.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread tools/bench-engines.sh
Comment on lines +161 to +173
router_cpu_ticks() { # utime+stime of the router process; comm has a space
sed 's/.*) //' "/proc/$ROUTER/stat" | awk '{print $12+$13}'
}

tree_cpu_ticks() { # unitd main + all descendants (router + app workers)
local total=0 pid
for pid in $UNITD_PID $(pgrep -P "$UNITD_PID" 2>/dev/null) \
$(pgrep -P "$UNITD_PID" 2>/dev/null | xargs -rn1 pgrep -P 2>/dev/null); do
[ -r "/proc/$pid/stat" ] || continue
total=$((total + $(sed 's/.*) //' "/proc/$pid/stat" | awk '{print $12+$13}')))
done
echo "$total"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If a process dies or exits between the existence check and the sed execution, sed will fail and print nothing to stdout. This causes the $((total + ...)) arithmetic expansion to be empty, resulting in a bash syntax error (operand expected). Ensuring that we default to 0 when ticks are empty prevents these runtime failures.

Suggested change
router_cpu_ticks() { # utime+stime of the router process; comm has a space
sed 's/.*) //' "/proc/$ROUTER/stat" | awk '{print $12+$13}'
}
tree_cpu_ticks() { # unitd main + all descendants (router + app workers)
local total=0 pid
for pid in $UNITD_PID $(pgrep -P "$UNITD_PID" 2>/dev/null) \
$(pgrep -P "$UNITD_PID" 2>/dev/null | xargs -rn1 pgrep -P 2>/dev/null); do
[ -r "/proc/$pid/stat" ] || continue
total=$((total + $(sed 's/.*) //' "/proc/$pid/stat" | awk '{print $12+$13}')))
done
echo "$total"
}
router_cpu_ticks() { # utime+stime of the router process; comm has a space
local ticks
ticks=$(sed 's/.*) //' "/proc/$ROUTER/stat" 2>/dev/null | awk '{print $12+$13}')
echo "${ticks:-0}"
}
tree_cpu_ticks() { # unitd main + all descendants (router + app workers)
local total=0 pid ticks
for pid in $UNITD_PID $(pgrep -P "$UNITD_PID" 2>/dev/null) \
$(pgrep -P "$UNITD_PID" 2>/dev/null | xargs -rn1 pgrep -P 2>/dev/null); do
[ -r "/proc/$pid/stat" ] || continue
ticks=$(sed 's/.*) //' "/proc/$pid/stat" 2>/dev/null | awk '{print $12+$13}')
total=$((total + ${ticks:-0}))
done
echo "$total"
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c37b6bf: both tick readers now default to 0 when the process vanished between checks (2>/dev/null on the read, ${ticks:-0} in the arithmetic).

Comment thread tools/bench-engines.sh Outdated
Comment on lines +348 to +354
start_unitd "$label" "$build" "$run" "$mods" || return 1

# --- HTTP scenarios ---
put_config_http "$run/control.sock" "$port" "$pport" "$run/conf-http.json" \
|| { echo "CONF FAILED $label"; cat "$run/conf-http.json"; return 1; }
wait_port "$port" 10 || { echo "listener $port never came up"; return 1; }
pin_router "$run" || return 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If any of the setup steps (such as start_unitd, put_config_http, wait_port, or pin_router) fail, run_build_round returns early with return 1 without calling cleanup. This leaks the background unitd process, which will continue running, hogging ports and CPU, and potentially corrupting subsequent benchmark rounds. Ensuring cleanup is called on early return prevents these process leaks.

Suggested change
start_unitd "$label" "$build" "$run" "$mods" || return 1
# --- HTTP scenarios ---
put_config_http "$run/control.sock" "$port" "$pport" "$run/conf-http.json" \
|| { echo "CONF FAILED $label"; cat "$run/conf-http.json"; return 1; }
wait_port "$port" 10 || { echo "listener $port never came up"; return 1; }
pin_router "$run" || return 1
start_unitd "$label" "$build" "$run" "$mods" || { cleanup; return 1; }
# --- HTTP scenarios ---
put_config_http "$run/control.sock" "$port" "$pport" "$run/conf-http.json" \
|| { echo "CONF FAILED $label"; cat "$run/conf-http.json"; cleanup; return 1; }
wait_port "$port" 10 || { echo "listener $port never came up"; cleanup; return 1; }
pin_router "$run" || { cleanup; return 1; }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c37b6bf: every early-failure return in run_build_round() (and the preflight) now runs cleanup first, so a half-started unitd can no longer outlive its round, hold the ports, or escape the EXIT trap.

Comment thread tools/bench-engines-report.py Outdated
Comment on lines +46 to +67
for line in open(path):
f = line.split()
if len(f) >= 4 and f[2] == "SKIP":
skips.append((f[0], " ".join(f[3:])))
continue
if len(f) < 4 or "=" not in f[3]:
continue
label, scenario, tool = f[0], f[1], f[2]
kv = {}
for tok in f[3:]:
if "=" in tok:
k, v = tok.split("=", 1)
kv[k] = v
for m in ("rps", "p50ms", "p99ms", "failed", "cpu_us_per_req"):
v = kv.get(m)
try:
rows[(tool, scenario, label)][m].append(float(v))
except (TypeError, ValueError):
rows[(tool, scenario, label)][m].append(None)
if kv.get("engine"):
engines[label] = kv["engine"]
return rows, skips, engines

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The file is opened without using a with statement, which can lead to a resource leak if the function exits or raises an exception. Wrapping the file reading in a with block ensures the file is closed properly.

    with open(path) as f_in:
        for line in f_in:
            f = line.split()
            if len(f) >= 4 and f[2] == "SKIP":
                skips.append((f[0], " ".join(f[3:])))
                continue
            if len(f) < 4 or "=" not in f[3]:
                continue
            label, scenario, tool = f[0], f[1], f[2]
            kv = {}
            for tok in f[3:]:
                if "=" in tok:
                    k, v = tok.split("=", 1)
                    kv[k] = v
            for m in ("rps", "p50ms", "p99ms", "failed", "cpu_us_per_req"):
                v = kv.get(m)
                try:
                    rows[(tool, scenario, label)][m].append(float(v))
                except (TypeError, ValueError):
                    rows[(tool, scenario, label)][m].append(None)
            if kv.get("engine"):
                engines[label] = kv["engine"]
    return rows, skips, engines

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c37b6bf: the results file is read via with open(...).

Comment thread tools/bench-engines.sh Outdated
Comment on lines +107 to +108
SERVER_CORES=0-3
LOAD_CORES=4-7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Hardcoding CPU affinity to cores 0-3 and 4-7 makes the script non-portable and will cause taskset to fail with an Invalid argument error on machines with fewer than 8 cores (such as standard developer laptops or smaller VMs). Dynamically detecting the number of online cores and partitioning them ensures the script runs out-of-the-box on any environment.

Suggested change
SERVER_CORES=0-3
LOAD_CORES=4-7
num_cores=$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)
if [ "$num_cores" -ge 8 ]; then
SERVER_CORES=0-3
LOAD_CORES=4-7
elif [ "$num_cores" -gt 1 ]; then
half=$((num_cores / 2))
SERVER_CORES="0-$((half - 1))"
LOAD_CORES="$half-$((num_cores - 1))"
else
SERVER_CORES=0
LOAD_CORES=0
fi

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in c37b6bf: SERVER_CORES/LOAD_CORES are now env-overridable and both masks are validated with taskset -c ... true up front, failing fast with a clear message. The 0-3/4-7 defaults are kept deliberately — they encode the reference-box methodology behind the committed results, and silently auto-partitioning would make runs from different hosts incomparable without saying so.

Comment thread tools/bench-engines.sh Outdated
if command -v ss >/dev/null 2>&1; then
ss -ltn "sport = :$1" 2>/dev/null | grep -q LISTEN
else
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && { exec 3>&- 3<&-; return 0; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the TCP connection is opened in a subshell (...), the file descriptor 3 is automatically closed when the subshell exits. Attempting to close it in the parent shell with exec 3>&- 3<&- will result in a Bad file descriptor error printed to stderr. We can safely remove the parent shell close commands.

Suggested change
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && { exec 3>&- 3<&-; return 0; }
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && return 0

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The claimed 'Bad file descriptor' doesn't actually occur — closing an unopened fd via exec 3>&- is a silent no-op in bash — but the parent-side close was dead code either way (the fd lives only in the subshell). Removed in c37b6bf.

Comment thread tools/bench-engines.sh Outdated
if command -v ss >/dev/null 2>&1; then
ss -ltn "sport = :$p" 2>/dev/null | grep -q LISTEN && return 0
else
(exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null && { exec 3>&- 3<&-; return 0; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the TCP connection is opened in a subshell (...), the file descriptor 3 is automatically closed when the subshell exits. Attempting to close it in the parent shell with exec 3>&- 3<&- will result in a Bad file descriptor error printed to stderr. We can safely remove the parent shell close commands.

Suggested change
(exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null && { exec 3>&- 3<&-; return 0; }
(exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null && return 0

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: no error occurs in bash, but the dead parent-side close is removed in c37b6bf.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 46bba26cee

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tools/bench-engines.sh Outdated
Comment on lines +447 to +448
run_build_round "$LABEL_A" "$BUILD_A" A "$r" || { echo "round $r A failed" >&2; }
run_build_round "$LABEL_B" "$BUILD_B" B "$r" || { echo "round $r B failed" >&2; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate failed benchmark rounds

When a round fails after start_unitd (for example config failure, no listener, or no router), these handlers only log and keep going; the script can then reach exit 0 with incomplete results, and the failing daemon may remain running once the next build overwrites UNITD_PID. That makes a failed or contaminated A/B run look successful to automation, so propagate the failure or aggregate it and exit non-zero after cleanup.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c37b6bf: failed build-rounds are counted, the summary still prints for whatever data exists, and the script exits non-zero with an explicit 'results are incomplete' error. Combined with the cleanup-on-early-return fix, a failing daemon can no longer leak into the next round either.

Comment thread tools/bench-engines.sh Outdated
Comment on lines +447 to +448
run_build_round "$LABEL_A" "$BUILD_A" A "$r" || { echo "round $r A failed" >&2; }
run_build_round "$LABEL_B" "$BUILD_B" B "$r" || { echo "round $r B failed" >&2; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Alternate build order across rounds

This always measures A immediately before B in every round, so any monotonic warmup, cache, or thermal drift over the minutes-long matrix is still correlated with the build label instead of being canceled. Alternate the order on even rounds (or randomize it) so each build appears in both positions before comparing means.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c37b6bf: the order now alternates per round (A/B, B/A, ...), so each build appears in both positions and monotonic warmup/thermal drift decorrelates from the build label.

Comment thread tools/bench-engines.sh
local rps p99 failed us
rps=$(awk '/Requests per second/{print $4}' "$out")
p99=$(awk '/ 99%/{print $2}' "$out")
failed=$(awk '/Failed requests/{print $3}' "$out")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count ab non-2xx responses as failures

ApacheBench reports HTTP status failures on a separate Non-2xx responses line rather than including them in Failed requests (per the Apache httpd ab docs: https://httpd.apache.org/docs/2.4/programs/ab.html). With this parser, a proxy/listener regression that returns 5xx during the secondary pass is recorded as failed=0, hiding bad runs in the machine-readable results.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c37b6bf: the ab parser now adds the 'Non-2xx responses' count into failed=, so a 5xx regression in the secondary pass is visible in the machine-readable results.

Comment thread tools/bench-engines.sh Outdated
Comment on lines +107 to +108
SERVER_CORES=0-3
LOAD_CORES=4-7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate CPU affinity masks before running

I checked taskset --help, which says -c specifies CPUs in list format; with these hard-coded masks, hosts or cpuset-limited runners without CPUs 4-7 will make every load-generator taskset -c "$LOAD_CORES" ... command fail with invalid affinity. A 4-core benchmark box can still start Unit on 0-3, then silently record zero/empty measurements, so validate the masks against the available affinity or make them configurable.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c37b6bf: both masks are validated with taskset -c ... true before anything runs, and SERVER_CORES/LOAD_CORES are env-overridable for hosts/cpusets without the reference-box 0-3/4-7 split.

- cleanup() before every early return in run_build_round and preflight:
  a half-started unitd otherwise leaks, holds the ports (aborting later
  rounds) and escapes the EXIT trap, which only knows the last pid.

- Track failed build-rounds and exit non-zero after the summary so
  automation cannot mistake an incomplete/contaminated run for a good
  A/B comparison.

- Alternate the build order per round (A/B, B/A, ...): a fixed order
  keeps monotonic warmup/thermal drift correlated with the build label.

- Count ab's 'Non-2xx responses' line into failed=: ab reports HTTP
  status failures separately from 'Failed requests', so a 5xx
  regression in the secondary pass was recorded as failed=0.

- Default CPU ticks to 0 when a process vanishes between checks; an
  empty command substitution broke the shell arithmetic.

- Make SERVER_CORES/LOAD_CORES env-overridable and validate both masks
  with taskset up front, failing fast on hosts/cpusets without the
  reference-box 0-3/4-7 split.

- Drop the parent-side close of a subshell-scoped /dev/tcp fd (dead
  code) and read the results file via a context manager in the
  summarizer.

Smoke-tested: 2-round A/A run, all scenarios, exit 0, no leaked unitd.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@andypost

Copy link
Copy Markdown
Owner Author

Review addressed in c37b6bf: cleanup on every early return, non-zero exit on failed rounds, per-round A/B order alternation, ab Non-2xx counted as failures, tick-reader hardening, validated + env-overridable core masks, and the summarizer file handling. Smoke-tested with a 2-round A/A run (all scenarios, exit 0, no leaked unitd).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c37b6bfb7a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tools/bench-engines.sh Outdated
(ms(.latencyPercentiles."p50" // 0)),
(ms(.latencyPercentiles."p99" // 0)),
( (( [ .statusCodeDistribution // {} | to_entries[]
| select((.key|tonumber) >= 400 or (.key|tonumber) < 200) | .value ]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count redirects as failed oha responses

For scenarios that are supposed to return 200, a 3xx response is still a non-2xx result, but this filter only adds statuses below 200 or at least 400 to failed. If a build starts redirecting (for example from the PHP app or proxy path), the primary oha row reports failed=0 and the summary treats the run as clean; use >= 300 here and in the Python fallback to match the AB non-2xx accounting.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e55bb3c: the failed filter is now >= 300 or < 200 in both the jq and python3 branches, matching the ab Non-2xx accounting.

Comment thread tools/bench-engines.sh
Comment on lines +316 to +320
taskset -c "$LOAD_CORES" "$OHA" --no-tui --output-format json -n "$n" "${ohaka[@]}" "$url" \
> "$js" 2>"$run/oha-$scen.err"
if [ "$mode" = tree ]; then t1=$(tree_cpu_ticks); else t1=$(router_cpu_ticks); fi
local rps p50 p99 failed
IFS=$'\t' read -r rps p50 p99 failed < <(parse_oha "$js")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail rounds when oha produces no valid JSON

If oha exits before writing valid JSON (for example it is killed, cannot start, or the listener disappears early), this status is ignored and parse_oha emits nothing; read then leaves the metrics empty, and Bash printf records zero values while run_build_round still succeeds. That can turn a broken primary measurement into a successful zero-RPS sample, so check the oha command/parse result and propagate the failure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e55bb3c: oha_one (and ab_one) now check the tool's exit status and that rps parsed non-empty; a dead/unparsable measurement errors with the label/scenario and saved output path and fails the round through the FAILED_ROUNDS accounting instead of recording an all-zero row. Verified with OHA=/bin/false: run exits 1, results file stays free of zero rows.

Comment thread tools/bench-engines.sh

# ---- main loop --------------------------------------------------------------
FAILED_ROUNDS=0
for r in $(seq 1 "$ROUNDS"); do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject empty or invalid round counts

When rounds is 0, negative, or non-numeric, this seq invocation yields no iterations (or only prints an error for invalid input) and, because the script never checks it, FAILED_ROUNDS remains 0; the script then summarizes an empty results file and exits successfully. Validate ROUNDS as a positive integer before this loop so automation cannot record a successful benchmark with no measurements.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e55bb3c: rounds is validated as a positive decimal integer right after argument parsing (usage error, exit 2 for empty/non-numeric/zero).

Comment thread tools/bench-engines.sh Outdated
[ -d "$mods" ] || mods="$run/modules"

# refuse if ports busy
for p in "$port" "$pport"; do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail when the PHP listener port is busy

When a build has a PHP module and $pphp is already bound, this preflight does not catch it; the PHP reconfig then falls into the SKIP php (config/listener failed) path and the build round still succeeds. That omits the PHP scenario for an environmental port conflict even though the scenario is available, producing incomplete A/B results with exit 0; include $pphp in this busy-port check or fail the round on PHP listener conflicts.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e55bb3c: $pphp joined the busy-port preflight, so a bound PHP port aborts the round instead of degrading to the SKIP path.

Comment thread tools/bench-engines.sh Outdated
done

mkdir -p "$RUNBASE"
: > "$RESULTS"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop when the results file cannot be created

If RESULTS points to a directory that does not exist or is not writable, this redirection fails but the script keeps running; later tee -a "$RESULTS" and the Python summarizer also fail without being checked, so a benchmark can finish with exit 0 while no machine-readable results were written. Check this initialization and abort before starting any measurements.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e55bb3c: the results-file initialization is checked and the script aborts with exit 2 before any measurement if it cannot be written.

- parse_oha: count 3xx as failed (>= 300 or < 200) in both the jq and
  python3 branches, matching ab's "Non-2xx responses" accounting
- oha_one/ab_one: check the tool's exit status and that rps parsed
  non-empty; error with label/scenario and the saved output file and
  return 1 instead of recording an all-zero row
- run_build_round: collect measurement failures and return 1 after
  cleanup so FAILED_ROUNDS catches them (php SKIP paths stay SKIPs)
- validate rounds as a positive decimal integer, usage error exit 2
- include the php port in the busy-port preflight and abort the round
  instead of degrading to the php SKIP path
- abort with exit 2 when the results file cannot be created

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e55bb3c6a3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tools/bench-engines.sh
Comment on lines +335 to +337
if [ "$rc" -ne 0 ] || [ -z "$rps" ]; then
echo "oha measurement failed: $label $scen (rc=$rc, see $run/oha-$scen.err)" >&2
return 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail runs that record request failures

When oha completes but the scenario returns non-2xx responses or transport errors, parse_oha populates failed, yet this check only treats the measurement as bad when the tool exits nonzero or rps is empty. Because these benchmark scenarios are expected to return 200 and the markdown summary never surfaces failed, a build that starts returning redirects/5xx can still make the whole run exit 0 unless someone inspects the raw results file.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c17c30b: after recording the row (kept for forensics), oha_one and ab_one fail the measurement when failed > MAX_FAILED (new env knob, default 0, documented in the header) — so a redirecting/5xx build now fails the round and the run. Verified by stubbing oha with an all-404 JSON: run exits 1.

Comment thread tools/bench-engines.sh Outdated
if [ "$HAVE_JQ" = 0 ] && [ "$HAVE_PY" = 0 ]; then
echo "need jq or python3 to parse oha JSON" >&2; exit 2
fi
[ "$SKIP_AB" = 1 ] || [ "$HAVE_AB" = 1 ] || { echo "ab not found (use --skip-ab)" >&2; SKIP_AB=1; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require --skip-ab before omitting ApacheBench

On hosts without ab, this branch silently flips SKIP_AB and continues even when the caller did not request --skip-ab. The default documented run is the oha+ApacheBench matrix, so automation can get a successful exit with all ab rows and tables missing; abort here unless SKIP_AB=1 or --skip-ab was explicitly supplied.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c17c30b: a missing ab now aborts with exit 2 unless --skip-ab/SKIP_AB=1 was explicitly passed. Verified with ab stripped from PATH: exit 2 without the flag, full green run with it.

Comment thread tools/bench-engines.sh Outdated
fi
UNITD_PID=""
}
trap cleanup EXIT INT TERM

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exit after handling INT/TERM

When the benchmark receives SIGINT or SIGTERM, this trap only runs cleanup; Bash then resumes the script instead of terminating. In a CI timeout or Ctrl-C during a long oha/ab run, the handler can kill unitd and the script may continue into later measurements or even finish with a misleading status, so the INT/TERM handler should clean up and explicitly exit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c17c30b: INT and TERM now get their own handlers that clean up and exit 130/143 (cleanup is idempotent, so the subsequent EXIT trap is a no-op). Verified by SIGINT mid-measurement: prompt exit 130, no leftover unitd.

Comment thread tools/bench-engines.sh
Comment on lines +190 to +191
ticks=$(sed 's/.*) //' "/proc/$ROUTER/stat" 2>/dev/null | awk '{print $12+$13}')
echo "${ticks:-0}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail when the router PID disappears

If the router exits or is respawned during an HTTP measurement, /proc/$ROUTER/stat disappears and this helper returns 0; the caller then computes CPU as 0 - t0 while still accepting the row whenever the load generator reports an rps. That turns a router crash/restart under load into a successful sample with bogus negative or undercounted CPU instead of failing the round.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c17c30b: in router mode both measurement functions now fail when /proc/$ROUTER/stat has vanished by measurement end, instead of accepting a bogus 0-tick CPU sample. Tree mode is left alone — app-worker churn there is the daemon's business.

Comment thread tools/bench-engines.sh
for b in "$BUILD_A" "$BUILD_B"; do
[ -x "$b/sbin/unitd" ] || { echo "no executable $b/sbin/unitd" >&2; exit 2; }
done
[ "$LABEL_A" != "$LABEL_B" ] || { echo "labels must differ ($LABEL_A)" >&2; exit 2; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject labels that break result parsing

This accepts labels containing whitespace, but result rows are whitespace-delimited and the report parser assumes field 1 is the label, field 2 is the scenario, and field 3 is the tool. If a caller uses a descriptive label like epoll baseline, all measurement rows are skipped by the summarizer while the benchmark can still exit 0, so validate labels or encode them before writing results.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c17c30b: labels are validated against [A-Za-z0-9._-]+ right after argument parsing (usage error, exit 2). Verified with a whitespace label.

@andypost

Copy link
Copy Markdown
Owner Author

Roadmap: process/fd lifecycle hardening for this harness — and the test suite behind it

This harness is one corner of a bigger problem this PR round surfaced. Research into "unitd processes must be pkilled after the test suite" (worst on armv7/armhf builders) root-caused it — and it is not this harness, but the same class of gap exists here. Plan below; the state-machine/audit side of resource management is tracked separately in #33.

What the research found

The Alpine community/freeunit APKBUILD already codifies the builder symptom: it carries pkill -f 'unitd.*--no-daemon' with the comment "kill orphaned unitd left by crashed pytest (armhf/armv7 SIGBUS)" (aports 8791acf1), plus _allow_fail=yes # segfault on those arches and --fds-threshold=160 ("aarch64 build leaking 156", aports 02f67d4d). The leak paths are in the pytest suite's lifecycle handling (upstream test/conftest.py, same in this tree):

  1. Abnormal pytest death (SIGBUS on armv7) skips pytest_sessionfinish → the session-long unitd tree is orphaned. Builders run without a per-build PID namespace, so orphans are live daemons until someone kills them.
  2. unit_stop() escalates to SIGKILL on the main pid only (conftest.py:366-379): after a 15 s wait — routinely exceeded on slow 32-bit builders — router/controller/app processes re-parent to init and survive.
  3. Startup-timeout leak: waitforfiles() polls only 5 s (test/unit/utils.py:26-38); on timeout conftest sys.exits without killing the unitd it already spawned.
  4. Related known upstream history: shutdown-hang nginx/unit#600 (fixed 1.26.1), unix-socket leftovers nginx/unit#643 (fixed 1.30), fd-leak asserts nginx/unit#1129 (still open, unattributed).

This harness already gained cleanup-on-early-return, failure propagation, and measurement validation in this PR — but cleanup() still SIGTERMs only $UNITD_PID with an unbounded wait (a wedged main hangs the EXIT trap), a SIGKILLed script leaks everything, and main-dead-but-router-alive goes unnoticed (for io_uring builds that also pins ring/memlock resources).

Planned deliverables

D1 — this harness (follow-up commit on this PR or a successor):

  • spawn unitd via setsid, record the pgid to <run>/unitd.pgid;
  • cleanup() escalation ladder: TERM to the group → bounded wait (~10 s poll) → KILL to the group; never unbounded;
  • cleanup keys off the pgid, not kill -0 <main> — covers main-dead-children-alive;
  • pre/post sweep scoped to the run dir fingerprint (unitd.*$RUNBASE), never a blanket unitd.*--no-daemon (that pattern is itself a cross-session hazard, and blanket pkill on a shared builder can kill unrelated packages' daemons);
  • post-run leak assert: survivors in any run-dir pgid = loud run failure.

D2 — pytest suite hardening (own PR):

  • Popen(..., start_new_session=True) + pgid file written immediately;
  • unit_stop() ladder: SIGQUIT main → env-tunable wait (CI/arm ≫ 15 s) → SIGTERM group → SIGKILL group; drop the bare except: p.kill();
  • kill the spawned group before the startup-timeout sys.exit; make the 5 s start budget env-tunable;
  • scope test_idle_close_wait's global pgrep -f 'unit: router' to our own daemon (known cross-session flake);
  • atexit/signal best-effort group kill — SIGBUS still defeats in-process handlers, hence D4.

D3 — attribution instead of thresholds:

  • session-level fd accounting: /proc/<pid>/fd counts with a readlink histogram (sockets / eventfd / signalfd / anon_inode:[io_uring] / files) for main+router+controller, dumped at session start/end and on any leak assert — same hook after each round here. Goal: turn --fds-threshold=160 folklore and nginx/unit#1129 into attributable objects. Per-object "what counts as leaked" definitions come from the FSM contracts in Make FreeUnit's state machines bulletproof: FSM contracts + systematic flow/resource audits #33 (an object exiting in a non-terminal state).

D4 — containment wrapper for builders (opt-in):

  • tools/run-contained.sh: unshare --pid --fork --mount-proc --kill-child (fallback: transient cgroup2 scope + cgroup.kill) — kernel-guaranteed teardown even when the runner dies with SIGBUS/SIGKILL;
  • downstream note for the APKBUILD: consume D2's pgid file (kill -- -$(cat .../unitd.pgid)) or wrap check() in the containment script, instead of the blanket pkill.

D5 — the actual fd leaks (after D3 lands): reproduce the aarch64 growth and the access-log+keepalive pattern from nginx/unit#1129 with histograms on, plus the io_uring build's anon-inode lifecycle; file per-attribution fixes.

Verification gates (each deliverable)

check method
runner SIGKILL leaves nothing kill -9 the runner mid-run → zero survivors (pgid sweep / namespace)
wedged main SIGSTOP unitd main mid-run → cleanup escalates to group KILL within budget, never hangs
main-dead-children-alive kill -9 main only → router/controller still reaped via pgid
slow shutdown inflated app-process count → ladder completes, no orphan
no collateral two concurrent instances → sweeps touch only their own run dirs
fd report usefulness injected fd leak in a test app → histogram names it

Sequencing: D1 first (small, immediately useful on builders), D2 as its own PR, D3 riding both, D4+aports note after, D5 as findings-driven follow-ups.

…r, bad labels

- oha_one/ab_one: after recording the row, fail the measurement when a run
  logged >MAX_FAILED request failures (new env knob, default 0); the summary
  tables don't surface `failed`, so non-2xx/transport errors went unnoticed.
- tool discovery: a missing ab now exits 2 unless --skip-ab/SKIP_AB=1 was
  passed, instead of silently halving the matrix.
- traps: INT/TERM now run cleanup then exit 130/143 so a Ctrl-C/CI-timeout
  can't resume the script into later measurements with unitd already killed
  (cleanup is idempotent, so the EXIT trap stays a harmless no-op).
- oha_one/ab_one router mode: fail if the router pid vanished mid-measurement,
  so a crash/restart can't pass as a bogus 0/negative CPU sample.
- arg parsing: validate LABEL_A/LABEL_B against ^[A-Za-z0-9._-]+$ (usage exit
  2), since whitespace/glob chars silently break the summarizer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@andypost

Copy link
Copy Markdown
Owner Author

Round-3 review addressed in c17c30b: failed-request accounting now fails measurements (MAX_FAILED knob, default 0), missing ab aborts unless --skip-ab, INT/TERM traps exit instead of resuming the run, a router that dies mid-measurement fails the sample instead of recording zero CPU, and labels are validated against the results format. Each failure path was exercised, not just reasoned about (stubbed failing oha, PATH without ab, SIGINT mid-run, whitespace label); A/A smoke stays green.

- spawn each unitd via setsid into its own session/process group; read the
  real main pid back from the pidfile (not $!) and record the pgid to
  <run>/unitd.pgid for the cleanup ladder and external sweepers.
- rewrite cleanup() as a pgid-keyed escalation ladder: TERM the group, poll
  ~10s, then KILL loudly. Reaps the router/controller even when the main pid
  is already dead but children survive. Guards against empty/invalid pgid and
  reaps our own zombie group leader so the group can't look falsely non-empty.
- pre-run stale-instance sweep (run_build_round + preflight): reap a prior
  crashed instance tied to THIS run dir via its pgid file or a cmdline match,
  fingerprinted to the run dir so unrelated daemons are never touched; the
  port_busy abort remains the second gate for ports held by anything else.
- post-run leak assert: scan all <run>/unitd.pgid, print survivors, kill via
  the ladder, and force a non-zero exit — a leak fails the run even if every
  measurement passed.
- document the lifecycle model in the header comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@andypost

Copy link
Copy Markdown
Owner Author

D1 from the lifecycle roadmap landed as e0a68ed: each unitd now runs in its own session/process group (real main pid read from the pidfile, pgid recorded to <run>/unitd.pgid), teardown is a pgid-keyed TERM→~10s→KILL ladder that reaps router/controller even when main is already dead, pre-run sweeps reap prior crashed instances fingerprinted to the exact run dir (never a bare unitd pattern), and a post-run leak assert fails the run if any group survives — even with all measurements green. pgid files are left on disk for external sweepers.

All four verification gates from the roadmap were executed, not simulated: SIGSTOP-wedged main (escalated to group KILL, exit in ~15 s), main SIGKILLed with children frozen (orphans reaped via pgid), harness itself SIGKILLed (next run's sweep reaped the orphan group holding the port and completed green), and a no-collateral check confirming unrelated unitd daemons on the box were untouched throughout.

Next per the roadmap: D2 (the pytest conftest group-kill hardening) as its own PR, D3 fd histograms riding both.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e0a68edcff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tools/bench-engines.sh
Comment on lines +754 to +757
if group_alive "$pg"; then
echo "LEAK: process group $pg from ${pf%/unitd.pgid} still has survivors:" >&2
ps -o pid,pgid,cmd -g "$pg" >&2 2>/dev/null || true
kill_pgid "$pg"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Revalidate stale PGIDs before signaling

Because normal teardown intentionally leaves unitd.pgid files on disk, a later run can have one of these stale numeric PGIDs reused by an unrelated process group. I checked pgrep --help (-g, --pgroup ... match listed process group IDs), and this leak assert only tests that the PGID exists before printing and calling kill_pgid, without verifying any process still has this run dir in its cmdline; in that reuse scenario the benchmark kills someone else's process group and reports a leak. Revalidate ownership, or remove stale pgid files after a clean teardown, before signaling.

Useful? React with 👍 / 👎.

Comment thread tools/bench-engines.sh
| tee -a "$RESULTS"
# Recorded the row for forensics; now fail the measurement if it saw request
# failures (non-2xx/transport) — the summary tables don't surface `failed`.
if [ "${failed:-0}" -gt "$MAX_FAILED" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject invalid MAX_FAILED values

When MAX_FAILED is set to a non-numeric value, this integer comparison prints an error and evaluates false inside the if, so a measurement that recorded non-2xx or transport failures is still accepted after writing the row; ab_one has the same comparison. Because this env knob is the only gate that turns recorded request failures into a failed round, validate it as a non-negative integer before running measurements.

Useful? React with 👍 / 👎.

Comment thread tools/bench-engines.sh
local ohaka=()
[ "$ka" = 1 ] && ohaka=(-c "$c") || ohaka=(-c "$c" --disable-keepalive)
if [ "$mode" = tree ]; then t0=$(tree_cpu_ticks); else t0=$(router_cpu_ticks); fi
taskset -c "$LOAD_CORES" "$OHA" --no-tui --output-format json -n "$n" "${ohaka[@]}" "$url" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Set a finite timeout for oha requests

The upstream oha usage documents -t <TIMEOUT> as the per-request timeout and says the default is infinite, but this measurement invocation (and the warmups below) never set it. If a Unit regression accepts a connection and then stalls a response, the harness can hang forever instead of failing the round and running cleanup; pass a finite timeout, preferably env-overridable, to every oha call.

Useful? React with 👍 / 👎.

Comment thread tools/bench-engines.sh
# ---- config payloads --------------------------------------------------------
put_config_http() { # runsock port proxyport -> writes conf result, returns rc
local sock=$1 port=$2 pport=$3 out=$4
curl -s --unix-socket "$sock" -X PUT -d '{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound control API PUTs with curl --max-time

I checked curl --help all, which lists --max-time as the maximum time allowed for a transfer; these control-socket PUTs do not set any transfer timeout. When the control socket exists but the controller is wedged or never returns the reconfiguration response, the script blocks here before it can mark the build-round failed or clean up, so add a finite --max-time to both config helpers.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant