Engineering · Companion to the bake-off writeup
Published Jul 21, 2026 · Updated Aug 1, 2026
The Bake-off Harness — Function-Level Engineering Reference
This is the reference for the bake-off harness. Where the results and narrative writeups cover the measurements, orchestration model, and war stories, this document covers every script under test/bakeoff/, every function it calls, every flag it reads, and the data files it produces. Read it alongside the scripts themselves — the function and flag names below are the stable contract; the code around them moves.
Results and methodology are documented in CLIENT_BAKEOFF_RESULTS.md (the source of truth for every number) and CLIENT_BAKEOFF_BLOG.md (the narrative writeup). This doc is about the harness, not the findings.
1. Layout
test/bakeoff/
├── run_bakeoff.sh # sequential multi-candidate orchestrator + resume guard
├── run_candidate.sh # single-candidate driver: reset → install → caps → sample-to-verdict
├── run_anchor_rotation.sh # multi-EL anchor-rotation driver (establish EL once, sweep CLs against it)
├── run_queue.sh # async TSV rerun-queue drainer (waits for preconditions, then calls run_candidate.sh)
├── apply_resource_caps.sh # systemd CPUQuota/MemoryMax/IOWeight apply|clear
├── lib.sh # shared probe/sample/alert/config-gate library (sourced, never executed)
├── summarize.sh # aggregates artifacts/ into summary.csv, process-summary.csv, report.md, a results skeleton
├── candidates.tsv # the manifest: <execution>\t<consensus> pairs to run
├── rerun_queue.tsv.example # queue-file format reference for run_queue.sh
└── test_data_dirs_sync.sh # CI guard: BAKEOFF_DATA_DIRS must match purge_ethereum_data.shAll told, the harness is about 1,550 lines of shell across eight scripts — run_candidate.sh (~480) and lib.sh (~380) carry most of the logic.
The executable driver scripts (run_bakeoff.sh, run_candidate.sh, run_anchor_rotation.sh, run_queue.sh, apply_resource_caps.sh, summarize.sh) each set set -Eeuo pipefail and source lib/common_functions.sh for logging (log_info/log_warn/log_error). lib.sh is the exception noted in the layout above: it's a sourced library, never executed on its own, that inherits strict mode from whichever script sources it and does not source common_functions.sh itself — its log_* calls resolve at runtime because every caller that uses its log_* paths (run_candidate.sh, run_anchor_rotation.sh, run_queue.sh) sourced common_functions.sh first. The one caller that doesn't — the CI guard test_data_dirs_sync.sh — only reads BAKEOFF_DATA_DIRS and never reaches a log_* call.
Data flow at a glance
run_bakeoff.sh owns the ordinary sequential sweep; run_anchor_rotation.sh reuses one synced execution-layer anchor while cycling consensus clients; run_queue.sh (§7) is a third, async entry point that drains a TSV rerun queue instead of a fixed manifest. All three call run_candidate.sh and produce the same per-candidate artifacts, so summarize.sh has one source of truth regardless of which path produced a row.
2. run_bakeoff.sh — the sequential orchestrator
Usage: run_bakeoff.sh --stage=triage|full [--only=el__cl,el__cl,...] [--force]
What it does, in order:
- Stage-dependent windows.
--stage=triagesets a 5400s (90 min) sync window with 120s sampling (ETH2QS_BAKEOFF_SYNC_WINDOW_SECONDS,ETH2QS_BAKEOFF_SAMPLE_INTERVAL_SECONDS);--stage=fullsets a 259200s (72h) window with 600s sampling. Both are overridable via env (the script only supplies the default with${VAR:-default}). - Config swap with restore-on-exit. It backs up the current
config/user_config.envtoconfig/user_config.env.bakeoff-backup, installsconfig/bakeoff.envin its place, and registerstrap restore_cfg EXITso the operator's real config is always restored — even on Ctrl-C or a crash mid-run. - Candidate selection from
candidates.tsv. The manifest is<execution>\t<consensus>pairs;run_bakeoff.shturns each row into anel__clpair id withawk -F'\ ' 'NF>=2{print $1"__"$2}'.--only=a__b,c__dexplicitly overrides the manifest.--stage=fullwithout--onlyauto-selects only candidates whose triageenv.txtalready recordedinstall_exit_code=0— this is the resume guard: a full run only re-attempts candidates that passed triage, and running--stage=fulltwice is safe (it re-derives the same selection from disk, it doesn't remember state in the orchestrator itself).--stage=triagewith no--onlyruns every manifest row.
- Sequential dispatch, tolerant of failure. For each selected pair it calls
run_candidate.sh "$execution" "$consensus"withset +earound the call, so one candidate's non-zero exit does not abort the sweep; the exit code is appended toartifacts/<run-id>/orchestrator-<stage>.log. - Always calls
summarize.shat the end (|| log_warn, non-fatal if it errors).
Artifact root: artifacts/${ETH2QS_BAKEOFF_RUN_ID:-client-bakeoff-2026-06-22}/. Every subsequent script keys off the same env var, so a fresh campaign is just a new ETH2QS_BAKEOFF_RUN_ID.
3. run_candidate.sh — the single-candidate state machine
Usage: run_candidate.sh <execution> <consensus>. This is the workhorse; every candidate row in the results tables is one invocation of this script. It refuses to run at all unless ETH2QS_BAKEOFF_CONFIRMED=yes is set (log_error … exit 2) — a deliberate manual safety gate because the script purges data directories and stops/disables services.
3.1 Modes
The script has three modes, selected by env vars:
Trigger: neither var set
stop+disable+purge both EL and CL, install both, sync both
Trigger: ETH2QS_BAKEOFF_KEEP_EL=yes
same as full, but on teardown only the CL datadir is purged — the EL is left running and synced, to become the next anchor
Trigger: ETH2QS_BAKEOFF_ANCHOR_EL=<el>
only stop/disable/purge the CL; the EL must already be active and synced; only the CL is installed and cycled
Anchor mode has its own precondition gate before touching anything: the $execution arg must equal $ETH2QS_BAKEOFF_ANCHOR_EL (mismatch → exit 3), eth1.service must be systemctl is-active, Engine API port 8551 must answer with an HTTP-shaped code (^[1-5][0-9]{2}$, since 401 auth-required counts as “responding”), and a bounded 5-retry/2s-sleep loop must observe eth_syncing resolve to “caught up” (result == false, or a progress object whose hex-decoded currentBlock is >= its hex-decoded highestBlock, with highestBlock != "0x0") before the CL sweep is allowed to start. Note this is a numeric comparison, unlike bakeoff_is_execution_synced (§5.4), which compares the hex strings for exact equality.
3.2 Resume guard
If $out/.done exists and ETH2QS_BAKEOFF_FORCE isn't yes, the candidate is skipped entirely (exit 0). Setting ETH2QS_BAKEOFF_FORCE=yes also clears .anchor-poisoned, .crash-looped and .done so a forced rerun doesn't inherit a stale poison marker from a previous attempt — without that clear, the anchor watchdog guard later in the script would bypass itself and finalize anchor_synced=no for what is actually a clean rerun.
3.3 Pre-install sequence
- Export
CI_E2E=true(unless the caller set it). The bake-off runsphase2without a priorrun_1, sorun_2.sh'srun_1-dependent post-install security validation — which expects active UFW andsecurity_monitor— would exit 1 and abort every install.CI_E2Eis the codebase's existing switch for arun_1-lessphase2; it also skips UFW setup. It does not change the installed binary, config, datadir, or sync footprint. - Stop+disable the relevant services (
eth1.service cl.service validator.service, or justcl.service validator.servicein anchor mode) —2>/dev/null || true, tolerant of units that don't exist yet. - Purge datadirs via
./scripts/eth2qs.sh clean-data—--dry-runfirst (logged, non-fatal), then--confirm(must succeed). Anchor mode passes--scope=consensusso the EL anchor is never touched.< /dev/nullon everyclean-dataand install call preventsSIGTTINwhen the harness runs detached (backgrounded/tmux) — a non-interactive process group reading from a controlling TTY gets stopped by the kernel, not an error the script can catch. bakeoff_snapshot_disk "$out/disk-before.tsv"— baseline footprint.- Disk-floor guard, checked via
df -B1 --output=avail /: anchor mode requiresETH2QS_BAKEOFF_MIN_DISK_BYTES:-429496729600(400 GiB — CL-only, since the EL already occupies disk), full mode requires:-1717986918400(1.6 TiB — full EL+CL). Below the floor, the script aborts withexit 3before starting an install that would fail hours later fromENOSPC. ./scripts/eth2qs.sh plan|doctor|stats --jsonsnapshots, best-effort (|| true).- Opportunistic
GITHUB_TOKENexport fromgh auth tokenif neitherGITHUB_TOKENnorGH_TOKENis already set — avoids the 60/hr unauthenticated GitHub API rate limit during client-version lookups inside the install scripts.
3.4 Install
timeout "$install_timeout" /usr/bin/time -v -o "$out/install-time.txt" \
./scripts/eth2qs.sh phase2 --execution="$execution" --consensus="$consensus" --mev=none \
</dev/null > "$out/install.log" 2>&1(Anchor mode omits --execution= since only the CL installs.) install_timeout defaults to 90m (ETH2QS_BAKEOFF_INSTALL_TIMEOUT). /usr/bin/time -v is what summarize.sh later parses for install wall-clock (see §8). The exit code is captured with set +e/set -e bracketing and written to env.txt as install_exit_code=.
Immediately after install: apply_resource_caps.sh apply (§4), then a battery of post-install snapshots (doctor, stats, monitor export, debug --service eth1, debug --service cl, systemctl status eth1 cl validator), then the config-optimality gate, bakeoff_check_config_optimal (§5.6) — non-blocking, it stamps config_optimal=yes|no into env.txt and never aborts the run.
3.5 Observation window
Skipped entirely if install_rc != 0. Otherwise a while loop runs until $(date +%s) >= end_at (end_at = now + window), sleeping interval seconds between iterations. Before the loop starts, the crash-loop watchdog (below) records a baseline systemctl show <unit> -p NRestarts --value for both eth1.service and cl.service, then checks only the units under test — both in full/establish mode, just cl.service in anchor mode (the pre-existing anchor eth1.service is out of scope for that row). Each iteration:
Runs only if the client pair installed — otherwise the watch is skipped.
watchdogs on this run
The run ends exactly one of four ways ↓
bakeoff_write_sample "$out" "$REPO_ROOT"— the sampling primitive (§5.5).bakeoff_services_alivecheck — setscrashed=yesif either service unit is no longer active (this becomesservice_crash_observedinenv.txt, a hard fail signal downstream).- Crash-loop watchdog (always-on, not opt-in). It detects a unit flapping under systemd's
Restart=— a config error that exits immediately and respawns every few seconds — as opposed to the stall watchdog below, which detects flat no progress. Each iteration re-readsNRestartsfor every unit under test and computes the delta from the pre-loop baseline. If any unit's delta exceedsETH2QS_BAKEOFF_MAX_RESTARTS(default 20: far above what a healthy candidate ever sees, far below a real crash loop), the row is invalidated immediately: ittouches$out/.crash-looped; writescrash_loop_detected=yes,crash_loop_unit=, andcrash_loop_restarts=(the delta) toenv.txt; logs an error; fires abakeoff_advisor_alert(error, kindcrash_loop, §5.2); and breaks the observation loop rather than waiting out the rest of the window. Unlike the stall watchdog, this watchdog never restarts anything itself — systemd is already doing the (unwanted) restarting, and the watchdog's only job is to notice and bail. Motivating incident: lodestar'scl.servicecrash-looped onUnknown argument: chain(exit 1, ~5s per cycle) and was respawned 20,892 times over ~2 days. Before this watchdog existed, the harness kept sampling to the full 72h window on generic “service is no longer active” warnings alone and would have produced nothing without a human noticing. - Anchor watchdog (anchor mode only, and only while
.anchor-poisoneddoesn't already exist): detects if the anchor EL silently dropped out of sync (service down, oreth_syncingno longer reporting caught-up). “Caught-up” allows the anchor to trail the network head by up toETH2QS_BAKEOFF_ANCHOR_LAG_BLOCKS(default 128) — some ELs keep returning aneth_syncingobject at tip withhighestBlockset to the network head, so while a CL is still warming up and nothing drives forkchoice the anchor legitimately trails by a few blocks. Demanding an exact catch-up there poisoned healthy rows. A real re-snap dropscurrentBlockto ~0, so it still trips far inside the tolerance. Two consecutive misses (anchor_miss_streak -ge 2) touches.anchor-poisonedand logs an error — detection only, it never restarts or kills anything, because the anchor EL is shared state across the whole CL sweep and killing it would invalidate every remaining candidate in the rotation. - Stall watchdog (opt-in via
ETH2QS_BAKEOFF_STALL_RESTART=yes, inert otherwise): tracksno_progress_streak— the CL'shead_slot(or, if absent, async_distance-derived proxy) in anchor mode, or the EL'scurrentBlock(hex-decoded) otherwise.stall_samples(default 10) consecutive non-advancing samples triggerssudo systemctl restarton the stalled unit, up tostall_max_restarts(default 3) times; exceeding that touches.stalled, writesstall_restarts=/stall_failed=yestoenv.txt, and breaks the loop — the row is invalid past that point but the harness still finalizes cleanly rather than hanging forever on a genuinely dead node. - Synced-streak check:
bakeoff_is_synced(§5.4) must return true on two consecutive samples (synced_streak -ge 2) before the row is accepted as synced — a single true reading can be a race between the EL and CL heads. On that streak, it snapshotsdisk-synced.tsv, writessynced_at_utc=/fully_synced=yes, and breaks out of the window early (no need to wait for the full 72h cap once synced).
After the loop (whether by break — from a crash-loop trip, a stall-watchdog exhaustion, a synced streak, or a timeout), one final bakeoff_write_sample call captures the end state, then env.txt gets fully_synced=no if nothing set it, service_crash_observed=, and (anchor mode only) anchor_synced=yes|no depending on whether .anchor-poisoned exists. A window_capped_unsynced warn-level advisor alert (§5.2) fires only when install succeeded, the window expired without fully_synced=yes, and neither the crash-loop nor the stall watchdog already alerted this same row — so one dead candidate never files three overlapping alerts.
3.6 Teardown
journalctl tails (-u eth1 -n 700, -u cl -n 700, -u validator -n 300) and ./scripts/eth2qs.sh repair preview are captured regardless of outcome. A findings.md skeleton is seeded with the install/crash verdict for a human reviewer to fill in (Result: TBD-by-reviewer). bakeoff_snapshot_disk "$out/disk-final.tsv" captures the footprint pre-cleanup (synced or capped — this is what summarize.sh falls back to when disk-synced.tsv doesn't exist). Then apply_resource_caps.sh clear, then clean-data again (scoped the same way as pre-install), then a final disk-after-cleanup.tsv snapshot, ended_at_utc, touch .done, and exit "$install_rc" — unless the crash-loop watchdog tripped, in which case the script exits 4 regardless of install_rc. A crash-looped row can still have install_exit_code=0 (the install itself succeeded; the client only started flapping afterward), so exiting 0 here would let run_bakeoff.sh/run_queue.sh record an invalid measurement as a clean run. All cleanup and artifact capture above still runs unconditionally — only the terminal exit status changes. 4 is distinct from 2 (operator-confirm gate) and 3 (anchor-mode precondition/disk-floor failures).
4. apply_resource_caps.sh — systemd runtime caps
Usage: apply_resource_caps.sh <apply|clear>. Purpose: keep the bake-off from starving co-resident work on a shared host by capping the node stack to roughly 8 cores / 36G total.
cap_unit <unit> <prop...>guards onsystemctl cat "$unit"existing (skips with a warning if the unit isn't defined yet) and applies each property withsudo systemctl set-property --runtime <unit> <prop>—--runtimemeans these are transient, gone on reboot, never written into the unit file.apply:eth1.servicegetsCPUQuota=600% MemoryMax=24G MemoryHigh=22G IOWeight=50;cl.servicegetsCPUQuota=200% MemoryMax=12G MemoryHigh=11G IOWeight=50.clear: resets both units'CPUQuota/MemoryMax/MemoryHigh/IOWeightback to infinity/infinity/infinity/100 — deliberately notsystemctl revert, because these are custom units andrevertcan strip unit overrides/definitions entirely, not just the runtime properties this script set. In anchor mode,clearexplicitly skips revertingeth1.service(ETH2QS_BAKEOFF_ANCHOR_ELset) so the anchor's caps stay consistent for the rest of the CL sweep; onlycl.servicereverts.
5. lib.sh — the shared probe/sample/gate library
Sourced by run_candidate.sh, run_anchor_rotation.sh, run_queue.sh (§7), and the CI guard test_data_dirs_sync.sh. Never executed directly.
5.1 BAKEOFF_DATA_DIRS
The canonical list of every client datadir the harness knows about ($HOME/.ethereum, $HOME/.local/share/nethermind, … through $HOME/ethgas). This array must stay in sync with the DATA_DIRS arrays in install/utils/purge_ethereum_data.sh — test_data_dirs_sync.sh is a CI guard that fails the build if they drift (it diffs the two lists rather than sourcing purge_ethereum_data.sh, since sourcing it would invoke main and run the actual purge).
5.2 bakeoff_advisor_alert <severity> <candidate> <kind> <detail>
The single structured “surface the problem” channel for the whole harness. The harness already detects trouble in a dozen different places (crash-loop, anchor poison, stall, install failure, window-capped-without-sync, malformed/timed-out queue rows) — this gives an operator or a supervising AI one thing to tail -f instead of a log line buried somewhere inside a multi-hour run.
- Args:
severity(warn|error),candidate(theel__clpair, or-when not candidate-scoped),kind(a short machine token —crash_loop,anchor_poisoned,stall,install_failed,window_capped_unsynced,queue_malformed_row,queue_precondition_timeout,queue_candidate_failed),detail(free text — may contain quotes, backslashes, or newlines). - Side effects, in order: first, a single-line
ADVISOR_ALERT severity=... kind=... candidate=... detail=...marker to stdout — printed before the JSONL write, so it survives even if the write below fails. Then, one JSON object (ts_utc,severity,run_id,candidate,kind,detail) appended to${ETH2QS_BAKEOFF_ALERT_LOG:-./advisor-alerts.jsonl}. Bothrun_candidate.shandrun_queue.shexportETH2QS_BAKEOFF_ALERT_LOGto the same$artifact_root/advisor-alerts.jsonl(keyed byETH2QS_BAKEOFF_RUN_ID), so one file aggregates alerts for the whole run — including rows driven asynchronously through the queue. - Never aborts the caller: always returns 0, even under
set -Eeuo pipefail, even when the target directory is unwritable, even whenjqis missing (falls back to manual shell string-escaping — backslashes escaped before quotes, since escaping quotes first would re-escape the backslash the quote-escape step just inserted), even whendetailitself contains characters that would otherwise corrupt a hand-rolled JSON string.
5.3 Snapshot/probe primitives
bakeoff_snapshot_disk <outfile>— for every path inBAKEOFF_DATA_DIRSthat exists, writespath\tbytes\thumanusingdu -sb/du -sh, then a finalfilesystem:/row fromdf -B1 /. Theducalls are2>/dev/nullwith|| trueafter theawk—ducan exit non-zero on a file that vanishes mid-walk (a live datadir churning during snapshot), and|| truekeeps the number that was printed instead of lettingpipefailpropagate a fatal exit into an unguarded caller.bakeoff_probe_execution_sync—curl -sS --max-time 5tohttp://127.0.0.1:8545with aneth_syncingJSON-RPC payload; on a curl connection failure, prints{"error":"execution_rpc_unavailable"}instead of failing. There's no--failflag, so an HTTP-error response with a non-JSON body (e.g. an HTML error page) is not caught by this fallback — the actual guard against malformed output downstream isbakeoff_write_sample'sfromjson? // {raw: ...}fallback (§5.5).bakeoff_probe_beacon_sync— tries four ports in order (3500,5051,5052,9596— the different consensus clients' REST API defaults) against/eth/v1/node/syncing, returns the first non-empty body; falls back to{"error":"beacon_rest_unavailable"}.bakeoff_snapshot_processes—ps -eo pid=,comm=,%cpu=,%mem=,rss=,vsz=,etime=,args=piped to anawkregex over known client names; the pattern matches against the whole formattedpsrow (commandargscolumns together), not just thecommfield — so any process whose arguments happen to contain one of those tokens is picked up too, not only ones named after a client binary. Matching lines are converted to a JSON array viajq -R -s 'split("\n")[:-1]'.bakeoff_services_alive—systemctl is-active --quiet eth1.service && systemctl is-active --quiet cl.service.
5.4 bakeoff_is_execution_synced / bakeoff_is_synced
bakeoff_is_execution_synced checks the EXECUTION client alone: eth_syncing result is boolean false, OR a progress object where currentBlock == highestBlock and highestBlock != "0x0" (the != "0x0" guard rejects the pre-sync zero state, where an EL that hasn't started downloading anything yet would otherwise read as trivially “caught up”). It is split out from bakeoff_is_synced specifically so an anchor-mode caller can check the preserved EL without also requiring a live beacon: between anchor-mode candidates the CL is stopped and its datadir purged, so any beacon-inclusive predicate could never pass there. run_queue.sh's anchor precondition (§7) calls this function directly for exactly that reason.
bakeoff_is_synced returns 0 only when both layers report caught-up:
- Beacon:
.datapresent,sync_distance <= 4,is_optimistic == false,el_offline == false. - Execution: delegates to
bakeoff_is_execution_syncedabove.
5.5 bakeoff_write_sample <out_dir> <repo_root>
The per-tick sampling routine. Writes disk/execution-sync/beacon-sync/process snapshots plus ./scripts/eth2qs.sh doctor --json and stats --json (each timeout 30, best-effort) into a tmp/ scratch dir, determines alive="up"/"down" via bakeoff_services_alive, and assembles one JSON object per call with jq -cn --rawfile (each raw file parsed with fromjson? // {raw: ...} so a malformed sub-output degrades to a raw-string field instead of aborting the whole sample — with one exception: processes degrades to an empty array (($processes | fromjson?) // []), not a {raw: ...} object, since it's already expected to be a JSON array) appended as one line to samples.jsonl. A jq failure on the whole assembly is caught and logged (log_warn) rather than propagated — one bad sample should never kill the observation loop.
5.6 bakeoff_check_config_optimal <el> <cl> <out_dir>
The config-optimality gate. A disk-footprint benchmark is meaningless if you can't prove the client was actually running in its most disk-efficient mode, so this gate stamps that proof onto every row. Non-blocking (always returns 0), but stamps a verdict that later filters the results tables.
Mechanism:
- Pull the configured
ExecStartline from each systemd unit:systemctl cat eth1.service | grep -i ExecStart(same forcl.service).systemctl catreads the unit file text (what the service is configured to run, including drop-ins) — not the live process's actual command line — so this reflects the configuration, not a runtime introspection of the running process. - Extract the first config file path referenced on that line — the extraction pipes through
grep -oP | head -1, so only the first match is kept even if more than one flag appears on the line. Three flag styles are matched in onegrep -oPalternation:--config-file=/--config-file,--rcConfig=/--rcConfig(lodestar), and--config=/--config(erigon). The comment in the source notes why the--configalternative doesn't falsely match a--config-file=xline: PCRE lookbehind is position-anchored, so(?<=--config[= ])only fires when the character immediately after--configis=or a space — for--config-file=xthat character is-, so the lookbehind never fires there. - If the extracted path exists as a file, its contents are appended to the “combined” haystack alongside the
ExecStartline — so tokens can live in either the command line or the config file. _has_token <label> <pattern> <haystack>runsecho "$haystack" | grep -qP -- "$pattern"— the haystack is theExecStartline plus any referenced config file's contents, piped in as text, not a file path. The--before the pattern is required because several patterns start with a dash (e.g.--prune-storage) and without--those would be parsed as agrepoption and silently swallowed by the2>/dev/nullas a false “miss.”- Per-client token table (kept current with what the install scripts actually emit):
Client Token checked geth history.chainpresentnethermind AncientBodiesBarrierpresent andPivotNumberextracted and> 0(a zero pivot means snap sync is inert) — the pivot regex handles Nethermind's quoted-JSON form ("PivotNumber": 15900000,) with an optional closing quote between the key and the separatorerigon prune.modeset tofull, matching both YAML-colon (prune.mode: "full") and=separator styles, quotes optionalreth both --prune.bodies.pre-mergeand--prune.receipts.pre-mergepresentbesu history-expiry-prune=truenimbus_eth1 prune = trueethrex no history-prune lever exists, so “optimal” is redefined as --syncmode snapbeing presentprysm beacon-db-pruning(flag or config-key form)lighthouse checkpoint-sync-urlpresent (default pruning behavior is already fine)teku data-storage-modeset tominimalorprune(leading letter either case)nimbus (CL) history = prunein the persistent config — deliberately not checkingtrustedNodeSync/--trusted-node-url, because those only appear in the one-shot bootstrap subcommand that runs before the service starts, never in the persistentExecStartornimbus.toml, so they're unobservable here and out of scope for this “is the running config optimal” gatelodestar pruneHistory=true(orchain.pruneHistory)grandine --prune-storagepresent - Any client not in the table falls through to
unchecked(counted as found, not a miss) — the gate is additive; adding a new client without a token entry doesn't silently fail existing runs. - Writes
config_optimal=yes|noandconfig_optimal_detail=found=...;missed=...into$out_dir/env.txt. On a miss, alsotouch $out_dir/.config-not-optimalandlog_error.
This is the mechanism behind the “Superseded — non-optimal config” and “optimal config only” table split in summarize.sh's generated results skeleton (§8) — a row only counts toward the ranked results if every applicable token for its EL and CL was found.
6. run_anchor_rotation.sh — multi-EL anchor rotation
Usage: run_anchor_rotation.sh <anchors_csv> [<cls_csv>] (CLs default to lighthouse,teku,nimbus,lodestar,grandine). This is how the CL matrix gets measured against more than one EL anchor without re-syncing every EL from scratch for every CL.
Per anchor in <anchors_csv>:
- Establish: unset
ETH2QS_BAKEOFF_ANCHOR_EL, setETH2QS_BAKEOFF_KEEP_EL=yes, callrun_candidate.sh <anchor> prysm— a normal full-mode candidate run (wipes any prior EL, installs fresh, syncs to tip) except that teardown preserves the EL datadir instead of purging it. The script also overridesETH2QS_BAKEOFF_SYNC_WINDOW_SECONDSto259200(72h) by default even for what looks like a “just establish it” step — the source comment explains why:run_candidate.sh's own default window is 5400s (a 90-minute smoke-test default), which would make a genuine multi-hour EL sync reportfully_synced=noand silently skip the entire CL sweep for that anchor. - Tip check: reads
artifacts/<run-id>/<anchor>__prysm/env.txtand requiresfully_synced=yes. If missing or notyes, logs a warning and skips the CL sweep for this anchor entirely (continue) rather than running a CL sweep against an EL that never reached head. - CL sweep: exports
ETH2QS_BAKEOFF_ANCHOR_EL=<anchor>, then callsrun_candidate.sh <anchor> <cl>for every CL in<cls_csv>— each invocation runs in anchor mode (§3.1), purging only the CL datadir between candidates and reusing the live, already-synced EL. - Unsets
ETH2QS_BAKEOFF_ANCHOR_ELand moves to the next anchor in the list — establishing the next anchor'srun_candidate.shcall will purge the previous anchor's EL datadir as part of its own normal pre-install cleanup, which is the “rotation.”
Same ETH2QS_BAKEOFF_CONFIRMED=yes gate as run_candidate.sh. All results land in the same artifacts/<run-id>/<el>__<cl>/ layout as a direct run_candidate.sh call, so summarize.sh needs no special-casing for anchor-mode rows. Ends by calling summarize.sh itself.
7. run_queue.sh — async rerun-queue drain
Usage: ETH2QS_BAKEOFF_CONFIRMED=yes test/bakeoff/run_queue.sh [--dry-run]. A third way to drive run_candidate.sh, alongside run_bakeoff.sh and run_anchor_rotation.sh — this one drains a queue of candidates that need (re-)measuring instead of a fixed manifest, waiting until it's safe to run each one. Designed to be left running (tmux/systemd) while other bake-off activity — a still-running candidate, an anchor EL still catching up — finishes elsewhere.
7.1 Queue file format
${ETH2QS_BAKEOFF_QUEUE_FILE:-test/bakeoff/rerun_queue.tsv}, TAB-separated, # comments and blank lines ignored:
# execution\tconsensus\treason (TAB-separated; # comments and blank lines ignored)
geth\tprysm\trerun after extract_archive -o fix (97541bd)
nethermind\tlodestar\trerun: lodestar pruneHistory CLI-flag fix (77d939d)
erigon\tteku\trerun: previous row crash-looped before the watchdog existedA row missing the consensus column is malformed: counted, logged, fires a queue_malformed_row warn alert (§5.2), and the drain moves on — one bad line never stops the queue. A trailing \r is stripped from the execution field (tolerates a queue file edited on/copied from a CRLF host).
7.2 Preconditions
Same operator gate as run_candidate.sh — ETH2QS_BAKEOFF_CONFIRMED=yes is required even for --dry-run: previewing the plan for a destructive harness still implies the operator has looked at this host. Before every row, polled every ETH2QS_BAKEOFF_QUEUE_POLL_SECONDS (default 60) up to a bounded ETH2QS_BAKEOFF_QUEUE_WAIT_SECONDS (default 7200) timeout:
- No other candidate running:
pgrep -f 'run_candidate\.sh', excluding this process's own PID (a pure safety net — a synchronously-invoked child has already exited by the time the next row's check runs, so it's never load-bearing in practice). - Anchor readiness, only when
ETH2QS_BAKEOFF_ANCHOR_ELis set:eth1.serviceactive andbakeoff_is_execution_synced(§5.4) — deliberately execution-only, not the beacon-inclusivebakeoff_is_synced: anchor-mode cleanup stops the CL and purges its datadir between candidates, so a beacon-inclusive predicate could never be satisfied here and every queued row would wait out the timeout and be skipped for no reason. This matchesrun_candidate.sh's own anchor preflight (§3.1), which also checks the EL only.
A timeout skips the row (queue_precondition_timeout warn alert, logged to run_queue.log) and the drain continues to the next row.
7.3 Force and the FORCE default
Once preconditions hold, the row runs as ETH2QS_BAKEOFF_FORCE="${ETH2QS_BAKEOFF_QUEUE_FORCE:-yes}" run_candidate.sh <execution> <consensus>. ETH2QS_BAKEOFF_QUEUE_FORCE defaults to “yes” — deliberately, because the entire point of a rerun queue is to re-measure pairs that already have a .done marker, and run_candidate.sh otherwise exits 0 without taking any measurement at all when .done exists and FORCE is unset (§3.2). Without this default, the queue would record a “successful” rerun while measuring nothing. Forcing overwrites that pair's previous artifacts — set ETH2QS_BAKEOFF_QUEUE_FORCE=no to skip already-complete pairs instead of re-measuring (and destroying) them.
7.4 Drain behavior
- Shares the same
ETH2QS_BAKEOFF_ALERT_LOGchannel asrun_candidate.sh(§5.2), defaulting under the sameartifacts/${ETH2QS_BAKEOFF_RUN_ID:-...}/root, so one file captures alerts for the whole run whether a row came from the manifest or the queue. - Every drained row appends one line to
$artifact_root/run_queue.log. A row that actually ran logs timestamp, row number, pair,rc, and the queue row's free-text reason; a malformed row logspair=-andresult=malformed, and a timed-out row logs the pair andresult=skipped_precondition_timeout— neither of those two carries the reason column. - A failing row (
run_candidate.shexits non-zero) is recorded and fires aqueue_candidate_failederror alert, but never aborts the drain — the loop always continues to the next row, mirroringrun_bakeoff.sh's own tolerance of a single candidate's failure. --dry-runprints the plan — queue file path, which preconditions would be checked, and each row's execution/consensus/reason plus the exactrun_candidate.shinvocation it would make — with no side effects.
8. summarize.sh — aggregation
Reads every artifacts/<run-id>/<el>__<cl>/ directory and produces four outputs, none of which overwrite the hand-curated docs/CLIENT_BAKEOFF_RESULTS.md (that file is the blog's source of truth and this script never touches it):
summary.csv— one row per candidate:pair,execution,consensus,install_exit_code,crash,sample_count,last_doctor_status, last_disk_bytes,residual_bytes,config_optimal,config_optimal_detail,fully_synced, sync_duration,sync_only,last_el_block,el_bytes,cl_bytes,anchor_synced,crash_loop_detected- Sync-time derivation:
_epochconvertsstarted_at_utc/synced_at_utcISO-8601 stamps to epoch seconds (date -u -d ... +%s); their difference istotal_s._install_wall_sparses theElapsed (wall clock)line out of/usr/bin/time -v'sinstall-time.txt(handles bothh:mm:ssandm:ss.ddforms).sync_only_s = total_s - install_sisolates sync time from install time; negative results are discarded (clamped to empty) rather than shown as a nonsense negative duration._fmt_hmrenders seconds asXhYYm. - Last EL block: hex-decodes
execution_sync.result.currentBlockfrom the lastsamples.jsonlline — a progress proxy even for candidates that hit the 72h cap without finishing. - Per-layer disk split:
el_bytes/cl_bytespartition the chosen disk TSV (disk-synced.tsvif synced, elsedisk-final.tsv) by basename match against EL/CL basenames extracted frominstall/utils/purge_ethereum_data.sh'sEL_DATA_DIRS/CL_DATA_DIRSarrays viaawk+gensub— exact basename matching so e.g.nimbus-eth1andnimbusare never confused.
- Sync-time derivation:
process-summary.csv— per-pair count of process-telemetry rows captured across the run (jq -r '.processes[]?'count fromsamples.jsonl).report.md— a human-readable dump: thesummary.csvtable (viacolumn -s, -t) plus every candidate'sfindings.md, concatenated.- A generated results skeleton at
$artifact_root/CLIENT_BAKEOFF_RESULTS.generated.md(gitignored — never the committeddocs/CLIENT_BAKEOFF_RESULTS.md), row-split into three sections by theconfig_optimalcolumn:- “Results (optimal config only)” —
config_optimal=yesandanchor_synced != no. - “Superseded — non-optimal config” —
config_optimal=no, explicitly excluded from ranking with a note that footprints aren't comparable. - “Pre-gate — unknown config” — no verdict recorded at all (pre-gate or triage-only rows).
Plus placeholder
## Recommendation/## Final synced disk footprint/## Changes driven by this bake-offsections with HTML comments telling the human reviewer what to fill in — this file is a skeleton for the curated doc, not a replacement for it. - “Results (optimal config only)” —
9. Data model reference
| File | Written by | Contents |
|---|---|---|
env.txt | run_candidate.sh (append-only through the run) | Key-value run metadata: execution=, consensus=, mev=, started_at_utc=, sync_window_seconds=, sample_interval_seconds=, install_timeout=, harness_mode=full|establish|anchor, anchor_el= (if set), install_exit_code=, config_optimal=yes|no, config_optimal_detail=, fully_synced=yes|no, synced_at_utc= (if synced), service_crash_observed=, stall_restarts=/stall_failed=yes (if the stall watchdog fired), crash_loop_detected=yes/crash_loop_unit=/crash_loop_restarts= (if the crash-loop watchdog fired — see §3.5), anchor_synced=yes|no (anchor mode only), ended_at_utc= |
samples.jsonl | bakeoff_write_sample (one JSON line per tick) | timestamp_utc, services_alive, disk_tsv (raw TSV text), execution_sync, beacon_sync, processes, doctor, stats |
disk-before.tsv / disk-synced.tsv / disk-final.tsv / disk-after-cleanup.tsv | bakeoff_snapshot_disk at four checkpoints | path\tbytes\thuman rows per BAKEOFF_DATA_DIRS entry that exists, plus a filesystem:/ row |
candidates.tsv | manifest (hand-edited) | <execution>\t<consensus> rows — the source list run_bakeoff.sh expands into el__cl pair ids |
install.log / install-time.txt | run_candidate.sh install step | stdout+stderr of phase2, and /usr/bin/time -v resource/wall-clock accounting |
findings.md | run_candidate.sh teardown (seeded, human-completed) | Install/crash verdict skeleton for reviewer sign-off |
.done / .anchor-poisoned / .config-not-optimal / .stalled / .crash-looped | marker files | Resume guard, anchor-invalidation flag, config-gate miss flag, stall-watchdog exhaustion flag, crash-loop-watchdog trip flag — all checked by filename existence, never by content |
advisor-alerts.jsonl | bakeoff_advisor_alert (§5.2), shared across every candidate in a run | One JSON object per line: ts_utc, severity (warn|error), run_id, candidate, kind (crash_loop, anchor_poisoned, stall, install_failed, window_capped_unsynced, queue_malformed_row, queue_precondition_timeout, queue_candidate_failed), detail. Every entry is also echoed as a greppable ADVISOR_ALERT ... line to the driver's stdout. |
rerun_queue.tsv / run_queue.log | queue file: operator (hand-edited); log: run_queue.sh (§7) | Queue: TAB-separated <execution>\t<consensus>[\t<reason>] rows (# comments/blank lines ignored). Log: one line per drained row — a row that actually ran logs timestamp, row number, pair, rc, and the reason; a malformed row logs pair=- and result=malformed; a timed-out row logs the pair and result=skipped_precondition_timeout — neither carries the reason column |
summary.csv / process-summary.csv / report.md / CLIENT_BAKEOFF_RESULTS.generated.md | summarize.sh | Aggregated, gitignored — inputs for a human to curate into the committed CLIENT_BAKEOFF_RESULTS.md |
10. Hardening fixes visible in the code
These are defensive patterns baked into the harness as a result of real failures during the campaign (see CLIENT_BAKEOFF_ISSUES_LOG.md for the incidents that motivated them):
du/pipefail resilience (bakeoff_snapshot_disk):du -sb ... || truesurvives a datadir file vanishing mid-walk during a live sync.- Capped-path capture (
_has_tokeninbakeoff_check_config_optimal): the--before dash-prefixed patterns preventsgrepfrom misparsing a pattern like--prune-storageas an option flag, which would otherwise exit 2 and get silently absorbed by2>/dev/nullinto a false “miss.” </dev/nullon every non-interactive command (clean-data,phase2install): preventsSIGTTINwhen the harness runs detached from a controlling terminal (background process group).- Stall watchdog (
ETH2QS_BAKEOFF_STALL_RESTART): entirely opt-in and inert by default; bounded restart count so a genuinely dead client still terminates the observation loop with a.stalledmarker rather than looping until the window cap. - Crash-loop watchdog (§3.5): always-on, not opt-in — a unit flapping under systemd's own
Restart=looks “active” again within seconds of each crash, so the plainbakeoff_services_alivecheck can miss it entirely if a sample doesn't happen to land during the brief down window.NRestartsis cumulative and monotonic, so comparing it against a pre-loop baseline catches the pattern regardless of sample timing; exceedingETH2QS_BAKEOFF_MAX_RESTARTS(default 20) touches.crash-loopedand forces the row to exit4instead of a possibly-zeroinstall_exit_code. - Structured advisor-alert channel (
bakeoff_advisor_alert, §5.2): every failure mode listed in §9'sadvisor-alerts.jsonlrow funnels into one JSONL file plus a greppable stdout marker. - Anchor-poison detection is read-only: the watchdog that guards a shared anchor EL during a CL sweep only ever marks state (
.anchor-poisoned) and logs — it never intervenes, because restarting or killing a shared anchor would invalidate every remaining candidate in the rotation, not just the current one. - FORCE-rerun poison-marker clear:
ETH2QS_BAKEOFF_FORCE=yesclears.anchor-poisoned,.crash-loopedand.donetogether — without this, a stale poison marker from a previous attempt would silently survive a forced rerun and falsely finalizeanchor_synced=nofor an otherwise clean run. - Two-consecutive-sample sync confirmation (
synced_streak -ge 2inrun_candidate.sh,synced_streaklocal to the observation loop): guards against a single racy sample where the EL and CL heads transiently appear caught up. - Execution-only anchor precondition in the queue (
run_queue.sh, §7.2):bakeoff_is_execution_synced, not the beacon-inclusivebakeoff_is_synced— see §5.4 for why a beacon-inclusive check on a shared anchor can never pass.
See also
- HOW_WE_TESTED_WITH_CLAUDE.md — the agent-orchestration model, the six-week timeline (23-day initial campaign, then steady-state and restart-resume follow-ups), and the war stories behind the hardening fixes above.
- CLIENT_BAKEOFF_RESULTS.md — the numbers, source-of-truth.
- CLIENT_BAKEOFF_BLOG.md — the narrative writeup of the findings.
- CLIENT_BAKEOFF_ISSUES_LOG.md — the incident log that motivated several of the hardening fixes above.
Read next
Sources: this page renders docs/CLIENT_BAKEOFF_HARNESS.md on GitHub. See also the bake-off results writeup.