Skip to content

[codex] add evm-only executor load test harness - #3658

Merged
codchen merged 34 commits into
mainfrom
codex/evm-only-executor-loadtest
Aug 6, 2026
Merged

[codex] add evm-only executor load test harness#3658
codchen merged 34 commits into
mainfrom
codex/evm-only-executor-loadtest

Conversation

@codchen

@codchen codchen commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a standalone evmonly-loadtest command that feeds generated EVM-only blocks into the EVM-only executor with generated genesis state, configurable result sinks, and Prometheus/stdout throughput metrics.

The executor owns the result-sink boundary. giga/evmonly exposes ResultSink, BlockResultSink, and WithResultSink, and executor completion invokes the sink for produced outputs before returning. The loadtest harness implements discard/file sink modes through those interfaces.

The executor can also use a bounded reusable BlockResult pool via BlockResultPoolSize. Pooled results are reference-counted: callers release their returned result with BlockResult.Release(), and async sinks retain/release through BlockResultSink after changesets and receipts are no longer referenced. The loadtest harness enables this by default with --result-pool-size=0, sized for executor workers plus in-flight async sink records; negative disables result pooling.

This branch also pipelines stateless sender recovery ahead of execution:

  • Executor.PrepareBlock decodes raw tx RLP and recovers senders into PreparedBlock.
  • Executor.ExecutePreparedBlock executes an already prepared block.
  • ExecuteBlock remains the convenience prepare-then-execute path.
  • evmonly-loadtest now has --prepare-workers; raw blocks flow through parallel prepare workers into an ordered prepared-block queue consumed by the single external executor worker.

The harness supports optimistic no-overlap native transfers and ERC20 transfers. It defaults to unique senders and recipients, supports --prebuild-blocks, and includes a lightweight async file sink via --result-sink=file --persist-dir=<dir>. Persistence records are append-only RLP records for changesets and receipts, and the sink removes files on normal completion, execution errors, and SIGINT/SIGTERM.

Metrics include block input/prepared/finished throughput, prepared tx/s, executed tx/s, gas/s, OCC attempts/fallbacks/conflicts, result-sink queue depth, enqueue wait, write time, bytes written, and record counts. sink_enqueue_wait is the backpressure signal: if it rises, executor workers are blocked on persistence queue capacity.

Validation

  • go test ./giga/evmonly/...
  • For the persistent smoke commands below, set DIR=$(mktemp -d) first and
    remove it with rmdir "$DIR" after the command succeeds.
  • Native smoke:
go run ./giga/evmonly/cmd/evmonly-loadtest \
  --metrics-addr= \
  --report-interval=0 \
  --prebuild-blocks \
  --blocks=30 \
  --txs-per-block=1000 \
  --builders=8 \
  --prepare-workers=8 \
  --workers=1 \
  --executor-workers=12 \
  --gas-price-wei=0 \
  --min-gas-price-wei=0 \
  --queue-size=64 \
  --result-sink=file \
  --persist-dir="$DIR"

Observed locally with pooled async file persistence: 218,638 TPS, prepare_errors=0, errors=0, sink_enqueue_wait=0s, and after close sink_written=60.

  • ERC20 smoke:
go run ./giga/evmonly/cmd/evmonly-loadtest \
  --metrics-addr= \
  --report-interval=0 \
  --prebuild-blocks \
  --blocks=30 \
  --txs-per-block=1000 \
  --builders=8 \
  --prepare-workers=8 \
  --workers=1 \
  --executor-workers=12 \
  --workload=erc20-transfer \
  --gas-price-wei=0 \
  --min-gas-price-wei=0 \
  --queue-size=64 \
  --result-sink=file \
  --persist-dir="$DIR"

Observed locally with pooled async file persistence: 202,486 TPS, prepare_errors=0, errors=0, sink_enqueue_wait=0s, and after close sink_written=60.

EC2 Persistent Runs

Run on commit fa7c7556b using a temporary c8i.48xlarge in us-east-1a, SMT disabled with CoreCount=96,ThreadsPerCore=1, Go 1.25.6, GOMAXPROCS=96, GOGC=400, one external executor worker, prebuilt raw blocks, unique senders/recipients, zero gas price/min gas price, default result pooling, and --result-sink=file. The temporary instance, key pair, and security group were deleted after collecting logs. Logs were copied locally to /tmp/evmonly-pool-20260702210816/logs.

Native transfer tuned command:

GOMAXPROCS=96 GOGC=400 /tmp/evmonly-loadtest \
  --metrics-addr= \
  --blocks=1000 \
  --txs-per-block=5000 \
  --prebuild-blocks \
  --builders=96 \
  --prepare-workers=48 \
  --workers=1 \
  --executor-workers=40 \
  --gas-price-wei=0 \
  --min-gas-price-wei=0 \
  --report-interval=5s \
  --queue-size=64 \
  --result-sink=file \
  --persist-dir="$DIR"

Observed:

prebuild complete elapsed=12.931s blocks=1000 txs=5000000 build_blocks/s=77.34 build_tx/s=386680.48
complete elapsed=25.416s input_blocks=1000 prepared_blocks=1000 prepared_txs=5000000 finished_blocks=1000 txs=5000000 gas=105000000000 prepare_errors=0 errors=0 occ_attempts=1000 occ_fallbacks=0 occ_conflicts=0 sink_queue=0 sink_enqueued=2000 sink_written=1998 sink_bytes=1727519751 sink_enqueue_wait=0s sink_enqueue_wait_events=0 sink_write=7.028219s avg_input_blocks/s=39.35 avg_prepared_blocks/s=39.35 avg_prepared_tx/s=196727.50 avg_finished_blocks/s=39.35 avg_tx/s=196727.50 avg_gas/s=4131277396.60
result sink close elapsed=74ms sink_queue=0 sink_enqueued=2000 sink_written=2000 sink_bytes=1729249000 sink_enqueue_wait=0s sink_enqueue_wait_events=0 sink_write=7.030719s
Percent of CPU: 3631%
Max RSS: 11517140 KB

ERC20 transfer tuned command:

GOMAXPROCS=96 GOGC=400 /tmp/evmonly-loadtest \
  --metrics-addr= \
  --blocks=1000 \
  --txs-per-block=5000 \
  --prebuild-blocks \
  --builders=96 \
  --prepare-workers=48 \
  --workers=1 \
  --executor-workers=48 \
  --workload=erc20-transfer \
  --gas-price-wei=0 \
  --min-gas-price-wei=0 \
  --report-interval=5s \
  --queue-size=64 \
  --result-sink=file \
  --persist-dir="$DIR"

Observed:

prebuild complete elapsed=25.683s blocks=1000 txs=5000000 build_blocks/s=38.94 build_tx/s=194683.09
complete elapsed=27.625s input_blocks=1000 prepared_blocks=1000 prepared_txs=5000000 finished_blocks=1000 txs=5000000 gas=229960303044 prepare_errors=0 errors=0 occ_attempts=1000 occ_fallbacks=0 occ_conflicts=0 sink_queue=0 sink_enqueued=2000 sink_written=1998 sink_bytes=3146535315 sink_enqueue_wait=0s sink_enqueue_wait_events=0 sink_write=10.264742s avg_input_blocks/s=36.20 avg_prepared_blocks/s=36.20 avg_prepared_tx/s=180995.14 avg_finished_blocks/s=36.20 avg_tx/s=180995.14 avg_gas/s=8324339464.38
result sink close elapsed=136ms sink_queue=0 sink_enqueued=2000 sink_written=2000 sink_bytes=3149685000 sink_enqueue_wait=0s sink_enqueue_wait_events=0 sink_write=10.269598s
Percent of CPU: 2824%
Max RSS: 17481900 KB

Tuning notes from the same EC2 instance:

  • A first result-pool build exposed a lease bug: the OCC merge path reset a pooled result after acquire, clearing the lease and stalling exactly at result-pool-size blocks. Commit a38d56ec6 fixes that.
  • Per-worker nativeStateDB scratch reuse was tested and removed. It raised CPU use but lowered EC2 throughput to about 166k TPS, likely from map-clearing/retained-heap costs.
  • On this c8i host, --prepare-workers=24 became sender-recovery limited; increasing to 48 recovered throughput.
  • Native short sweep best: --prepare-workers=48 --executor-workers=40, 185.6k TPS over 2.5M tx. The full 5M-tx confirmation reached 196.7k TPS.
  • ERC20 short sweep best: --prepare-workers=48 --executor-workers=48, 173.5k TPS over 2.5M tx. The full 5M-tx confirmation reached 181.0k TPS.
  • No persistent sink backpressure was observed in the tuned full runs: sink_enqueue_wait=0s.

Historical Non-Persistent 199.3k Repro

The earlier 199.3k EC2 benchmark was run before persistent sink and prepared pipeline changes, from commit 4ec8da52c, on a c8i.48xlarge in us-east-1a with SMT disabled, Go 1.25.6, one external executor worker, prebuilt blocks, unique senders/recipients, and zero gas price/min gas price.

GOMAXPROCS=96 GOGC=200 /tmp/evmonly-loadtest \
  --metrics-addr= \
  --blocks=1000 \
  --txs-per-block=5000 \
  --prebuild-blocks \
  --builders=96 \
  --workers=1 \
  --executor-workers=96 \
  --gas-price-wei=0 \
  --min-gas-price-wei=0 \
  --report-interval=5s

Observed:

prebuild complete elapsed=9.366s blocks=1000 txs=5000000 build_blocks/s=106.77 build_tx/s=533873.27
complete elapsed=25.09s input_blocks=1000 finished_blocks=1000 txs=5000000 gas=105000000000 errors=0 avg_input_blocks/s=39.86 avg_finished_blocks/s=39.86 avg_tx/s=199284.61 avg_gas/s=4184976805.58

EC2 Worker Pool / Pinning Follow-up

A follow-up on temporary commit 63b8662b4bc90936175181b1fff6347c502fb03f tested persistent OCC workers with and without Linux worker pinning on the same c8i.48xlarge shape. The persistent OCC worker pool stayed in this branch; the worker pinning flags/files were removed afterward because they did not improve throughput or CPU utilization.

Observed 5M-tx persistent-sink results:

  • Native transfer, unpinned persistent pool: 186,607 TPS, sink_enqueue_wait=0s, Percent of CPU: 2404%.
  • Native transfer, pinned workers: 186,683 TPS, sink_enqueue_wait=0s, Percent of CPU: 2283%.
  • ERC20 transfer, unpinned persistent pool: 170,501 TPS, sink_enqueue_wait=0s, Percent of CPU: 2003%.
  • ERC20 transfer, pinned workers: 165,290 TPS, sink_enqueue_wait=0s, Percent of CPU: 2009%.

Conclusion: the persistent OCC pool removes per-block worker creation overhead, but thread pinning was flat for native transfer and worse for ERC20. CPU remained around 20-24 effective cores on a 96-core instance, so the current branch keeps the simpler unpinned worker-pool model.

@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedAug 6, 2026, 7:46 AM

@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 69.85583% with 460 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.00%. Comparing base (18fc8cc) to head (201f2e2).

Files with missing lines Patch % Lines
giga/evmonly/cmd/evmonly-loadtest/metrics.go 72.76% 122 Missing and 3 partials ⚠️
giga/evmonly/cmd/evmonly-loadtest/pipeline.go 70.22% 57 Missing and 21 partials ⚠️
giga/evmonly/cmd/evmonly-loadtest/config.go 66.82% 34 Missing and 34 partials ⚠️
giga/evmonly/cmd/evmonly-loadtest/profiles.go 10.29% 60 Missing and 1 partial ⚠️
giga/evmonly/cmd/evmonly-loadtest/sinks.go 75.94% 36 Missing and 15 partials ⚠️
.../evmonly/cmd/evmonly-loadtest/scenarios/helpers.go 71.83% 14 Missing and 6 partials ⚠️
...y/cmd/evmonly-loadtest/scenarios/erc20_transfer.go 69.56% 8 Missing and 6 partials ⚠️
...evmonly/cmd/evmonly-loadtest/scenarios/transfer.go 75.43% 8 Missing and 6 partials ⚠️
.../cmd/evmonly-loadtest/scenarios/snapshot_revert.go 80.00% 6 Missing and 4 partials ⚠️
giga/evmonly/cmd/evmonly-loadtest/state.go 88.31% 6 Missing and 3 partials ⚠️
... and 2 more
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main    #3658       +/-   ##
===========================================
+ Coverage   61.67%   67.00%    +5.32%     
===========================================
  Files        2369       37     -2332     
  Lines      200142     4197   -195945     
===========================================
- Hits       123439     2812   -120627     
+ Misses      65717     1125    -64592     
+ Partials    10986      260    -10726     
Flag Coverage Δ
sei-chain ?
sei-chain-pr 66.57% <69.85%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
app/benchmark/generator.go 44.85% <100.00%> (ø)
...ga/evmonly/cmd/evmonly-loadtest/scenarios/types.go 80.00% <80.00%> (ø)
giga/evmonly/cmd/evmonly-loadtest/main.go 0.00% <0.00%> (ø)
giga/evmonly/cmd/evmonly-loadtest/state.go 88.31% <88.31%> (ø)
.../cmd/evmonly-loadtest/scenarios/snapshot_revert.go 80.00% <80.00%> (ø)
...y/cmd/evmonly-loadtest/scenarios/erc20_transfer.go 69.56% <69.56%> (ø)
...evmonly/cmd/evmonly-loadtest/scenarios/transfer.go 75.43% <75.43%> (ø)
.../evmonly/cmd/evmonly-loadtest/scenarios/helpers.go 71.83% <71.83%> (ø)
giga/evmonly/cmd/evmonly-loadtest/sinks.go 75.94% <75.94%> (ø)
giga/evmonly/cmd/evmonly-loadtest/profiles.go 10.29% <10.29%> (ø)
... and 3 more

... and 2350 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codchen
codchen force-pushed the codex/evmonly-staking-dynamic-gas branch from bd57e4c to 7c6c44c Compare June 30, 2026 06:11
@codchen
codchen force-pushed the codex/evm-only-executor-loadtest branch from 8e28929 to 4ec8da5 Compare June 30, 2026 06:11
@codchen
codchen force-pushed the codex/evmonly-staking-dynamic-gas branch from 7c6c44c to 6f9547a Compare July 2, 2026 05:53
@codchen
codchen force-pushed the codex/evm-only-executor-loadtest branch from 8cc2094 to a65459e Compare July 2, 2026 05:55
@codchen
codchen force-pushed the codex/evmonly-staking-dynamic-gas branch from 6f9547a to 31f16a8 Compare July 2, 2026 08:39
@codchen
codchen force-pushed the codex/evm-only-executor-loadtest branch from fc79ac5 to e312a84 Compare July 2, 2026 08:40
@codchen
codchen force-pushed the codex/evmonly-staking-dynamic-gas branch from 31f16a8 to 9cb00d6 Compare July 2, 2026 09:51
@codchen
codchen force-pushed the codex/evm-only-executor-loadtest branch from e312a84 to 63b8662 Compare July 2, 2026 09:53
@codchen
codchen force-pushed the codex/evmonly-staking-dynamic-gas branch from 9cb00d6 to 35864cf Compare July 2, 2026 12:35
@codchen
codchen force-pushed the codex/evm-only-executor-loadtest branch from 63b8662 to 6a6c193 Compare July 2, 2026 12:37
@codchen
codchen force-pushed the codex/evmonly-staking-dynamic-gas branch from 35864cf to 3eaa2ab Compare July 2, 2026 12:57
@codchen
codchen force-pushed the codex/evm-only-executor-loadtest branch from 6a6c193 to ef82dfd Compare July 2, 2026 13:00
@codchen
codchen force-pushed the codex/evmonly-staking-dynamic-gas branch from 3eaa2ab to 54456cf Compare July 2, 2026 13:16
@codchen
codchen force-pushed the codex/evm-only-executor-loadtest branch from ef82dfd to 2d0570c Compare July 2, 2026 13:16
@codchen
codchen force-pushed the codex/evmonly-staking-dynamic-gas branch from 54456cf to 6037fbb Compare July 2, 2026 13:21
@codchen
codchen force-pushed the codex/evm-only-executor-loadtest branch from 2d0570c to fa7c755 Compare July 2, 2026 13:21
@codchen
codchen force-pushed the codex/evmonly-staking-dynamic-gas branch from 6037fbb to f72a746 Compare July 2, 2026 13:44
@codchen
codchen force-pushed the codex/evm-only-executor-loadtest branch from fa7c755 to a9debf4 Compare July 2, 2026 13:45
@codchen
codchen marked this pull request as ready for review July 22, 2026 06:02
@cursor

cursor Bot commented Jul 22, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
New benchmark tooling and test-only OTEL detection tweaks; the benchmark generator call-site change is a small API adaptation with limited production blast radius.

Overview
Adds evmonly-loadtest, a standalone CLI that prebuilds synthetic blocks and drives the EVM-only executor through a prepare → ordered execute pipeline with in-memory genesis state, optional discard or async RLP file result sinks, Prometheus/stdout metrics (throughput, OCC stats, sink backpressure, result-pool stats), and workloads for native transfer, ERC20, and snapshot/revert contract calls—including knobs for OCC stress (hot recipient, same-sender nonces, conflict rate).

Transaction generation leans on sei-load v0.0.1 offline scenarios; app/benchmark/generator.go passes nil as the first argument to NewWeightedGenerator to match the updated API. Docs under giga/evmonly describe the harness.

Also bumps go.mod transitive deps (OpenTelemetry, golang.org/x/*, gRPC/genproto) and hardens TestBaseAppTracingEnabledInstallsAProviderNothingShutsDown to count batch-span processor goroutines via processQueue stack frames with polling, reducing flakes after OTEL SDK changes.

Reviewed by Cursor Bugbot for commit 201f2e2. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread giga/evmonly/cmd/evmonly-loadtest/main.go Outdated
Comment thread giga/evmonly/cmd/evmonly-loadtest/main.go Outdated

@seidroid seidroid 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.

Adds a well-structured standalone evmonly-loadtest harness (prepare/execute pipeline, result sinks, metrics) with good test coverage. Two robustness issues surfaced by Codex are confirmed but both are confined to this dev/benchmark tool, so neither blocks merge.

Findings: 0 blocking | 5 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • cursor-review.md is empty (Cursor produced no output) and REVIEW_GUIDELINES.md is empty/missing, so this review proceeded without repo-specific guidelines or a Cursor second opinion.
  • No direct test exercises the streaming (runStreaming) path or the enqueue-under-writer-failure path; the confirmed sink race would be a good target for a focused test.
  • Minor: in resultSinks.StoreBlockResult the fallback (non-BlockResultSink) path does not call release() if StoreChangeSet/StoreReceipts errors, leaking the pooled result. Harmless today because the discard sink never errors, but worth guarding with a deferred release.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

return err
}
select {
case s.records <- record:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Race between this non-blocking send and the writer's failure path. enqueue checks getErr() above, then sends here; meanwhile run() can fail a write, call setErr + releaseQueuedResults, and return. A record that lands in the buffer after the drain but after run() has exited is never written and never released, leaking a pooled BlockResult slot (Codex's high finding — confirmed). Consider selecting on s.done in this first (and the buffered) send, or re-checking getErr()/draining, so a record enqueued after the writer stops is released. In practice the next StoreBlockResult returns the stored error and execution unwinds, so a full hang is unlikely, but the leak/latent race is real.

}

func newAppendRLPFile(path string, bufferSize int, syncOnWrite bool) (*appendRLPFile, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] O_CREATE|O_TRUNC silently truncates pre-existing changesets.rlp/receipts.rlp, and cleanup later os.Removes them — so pointing --persist-dir at a directory containing prior output destroys those files (Codex's medium finding — confirmed). Since this is a load-test tool and the docs already recommend a fresh mktemp -d, this is acceptable, but consider O_EXCL (or rejecting a dir that already contains these files) to avoid clobbering user data by accident.

Comment thread giga/evmonly/cmd/evmonly-loadtest/main.go Outdated
Comment thread giga/evmonly/cmd/evmonly-loadtest/main.go Outdated
Comment thread giga/evmonly/cmd/evmonly-loadtest/main.go Outdated
Comment thread giga/evmonly/cmd/evmonly-loadtest/main.go Outdated
@codchen
codchen force-pushed the codex/evm-only-executor-loadtest branch 2 times, most recently from 83967c0 to 205f54c Compare July 27, 2026 03:59

@claude claude 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.

Beyond the inline finding, a candidate issue was also investigated and ruled out this run: the staking precompile's delegate/redelegate/createValidator paths appearing to skip the MaxVotingPowerRatio cap — verified this is not a real bug (the cap is enforced elsewhere in the flow).

Extended reasoning...

This run added one new inline finding (a nit about occWorkerPool serializing OCC execution across --workers in the standalone evmonly-loadtest harness) plus a ruled-out candidate (staking precompile MaxVotingPowerRatio cap). Given this PR's scope (300 files, including consensus-sensitive app.go giga-fallback changes, EVM RPC hardening, and config-management changes) and that prior runs already flagged unresolved issues (lint failures, an ERC20 fixture bytecode inversion, an async sink release race, and a prepareBlocks error-masking bug), this PR clearly still requires human review; this note is purely to record what else was checked this run, not a new verdict.

Comment thread giga/evmonly/occ_pool.go Outdated
Base automatically changed from codex/evmonly-staking-dynamic-gas to codex/evmonly-staking-precompile July 27, 2026 13:30
@codchen
codchen force-pushed the codex/evm-only-executor-loadtest branch from 205f54c to beef917 Compare July 28, 2026 03:53
@codchen
codchen force-pushed the codex/evm-only-executor-loadtest branch from ed80600 to 4bc9274 Compare August 6, 2026 05:46

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4bc9274. Configure here.

if err := group.Wait(); err != nil {
return nil, err
}
return prebuilt, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cancel during prebuild continues run

Medium Severity

prebuildBlockRequests treats cancellation as success and returns a partially filled slice. Unbuilt slots stay as zero-value envelopes (number=0), so SIGINT/SIGTERM during prebuild can still enter the execute phase and fail with confusing prepared-block ordering errors instead of stopping cleanly.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4bc9274. Configure here.

s.metrics.recordSinkEnqueueWait(time.Since(startedAt))
}
return ctx.Err()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sink enqueue ignores prior write errors

Medium Severity

After the async file writer records a persistence failure, enqueue can still accept more records and return success whenever the queue has space. Those later records are only released and never written, so StoreBlockResult reports success for outputs that were dropped.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4bc9274. Configure here.

Comment on lines +82 to +89
func (s *generatedState) GetCode(addr common.Address) []byte {
if s.frozen.Load() {
return cloneBytes(s.code[addr])
}
s.mu.RLock()
defer s.mu.RUnlock()
return cloneBytes(s.code[addr])
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 generatedState.GetCode always clones the code slice on every call, even in the frozen (post-genesis) branch, whereas the sibling GetBalance deliberately returns the shared pointer once frozen (documented as safe since consumers must copy before mutation). Since code is immutable after Freeze() (SetCode is blocked by requireMutable), GetCode's frozen branch can return s.code[addr] directly like GetBalance does, removing a per-call allocation+copy of full contract bytecode from the hot path of every contract-call tx in the erc20-transfer and snapshot-revert workloads.

Extended reasoning...

The bug: In giga/evmonly/cmd/evmonly-loadtest/state.go, generatedState.GetBalance has two branches: a frozen branch that returns the shared *big.Int pointer directly (with a comment explicitly documenting that this is intentional — Frozen reads return shared, non-owned pointers; StateReader consumers must copy before mutation), and a mutable branch that defensively copies with new(big.Int).Set(balance). GetCode, right next to it, does not follow this pattern: both its frozen and mutable branches call cloneBytes(s.code[addr]), allocating and copying the full runtime bytecode on every single call regardless of whether the state has been frozen.

Why the frozen clone is unnecessary: SetCode is the only writer of s.code, and it begins with s.requireMutable(), which panics if s.frozen has been set via Freeze(). In the loadtest harness's actual usage (pipeline.go's runPrebuilt), all contract code is written by SetCode during block-request construction, and state.Freeze() is called once immediately after prebuilding completes, before any executor worker starts reading. From that point forward the underlying []byte slice in s.code[addr] can never be mutated again — requireMutable guarantees it. This is exactly the same invariant that already justifies GetBalance's frozen fast path, and the giga/evmonly/README.md StateReader contract ("Values returned by GetBalance and GetCode must remain stable while being read; the executor treats them as immutable and copies them into transaction-local state") explicitly extends the same immutability guarantee to GetCode. Executor call sites bear this out: state_db.go's ingest path does Code: cloneBytes(s.source.GetCode(addr)) itself (so a second clone inside GetCode is redundant), and occ.go's only other caller does a read-only bytes.Equal comparison.

Why nothing currently prevents this from being fixed: There's no correctness reason for the clone once frozen — it's purely defensive copying left over from (presumably) not distinguishing the frozen/mutable cases the way GetBalance already does one line above it in the same file.

Impact: This is a dev-only calibration/load-testing harness (package main, giga/evmonly/cmd/evmonly-loadtest), so there's no correctness or production risk. The cost is a real but modest one: every contract-call transaction in the erc20-transfer and snapshot-revert workloads calls GetCode on the executor's hot path, and each such call allocates and copys the full runtime bytecode (tens to low hundreds of bytes for the synthetic contracts here, but the pattern would matter more for larger contracts) instead of reusing the already-immutable shared slice — extra garbage and copy time in a tool whose entire purpose is to measure the executor's own allocation/throughput behavior.

Step-by-step proof:

  1. runPrebuilt builds all blocks up front for the snapshot-revert workload; NewSnapshotRevertWorkload calls state.SetCode(cfg.SnapshotRevertContract, snapshotRevertOuterRuntimeCode) and state.SetCode(cfg.SnapshotRevertHelper, snapshotRevertHelperRuntimeCode), each internally cloning the passed-in code into s.code[addr].
  2. runPrebuilt then calls state.Freeze(), setting s.frozen and permanently blocking any further SetCode (via requireMutable's panic).
  3. The executor pipeline starts; every executed transaction that calls the contract triggers a StateDB code load, which calls generatedState.GetCode(addr).
  4. GetCode's current implementation takes the if s.frozen.Load() branch but still executes cloneBytes(s.code[addr]), allocating a new []byte and copying every byte of the runtime code — on every single call, for every single transaction, for the life of the run.
  5. Because s.code[addr] cannot change after step 2 (guaranteed by requireMutable), returning s.code[addr] directly here would be exactly as safe as GetBalance's existing frozen branch (line 47-52 in the diff) already is for balances — no caller can observe a mutation because none is possible, and the same StateReader contract documented in the README already commits to treating this return value as immutable.

Fix: Mirror GetBalance's pattern — in the frozen branch, return s.code[addr] directly (falling back to nil for a missing key, matching cloneBytes's existing nil-for-empty behavior); keep the cloneBytes call only in the mutable (pre-freeze) branch.

This was independently flagged as a nit by reviewer seidroid in the PR timeline (state.go:83) using the same GetBalance-vs-GetCode comparison.

🔬 also observed by seidroid

@codchen
codchen enabled auto-merge August 6, 2026 07:47
Comment on lines +66 to +92
func (p *profileSession) Close() error {
if p == nil {
return nil
}
var errs []error
if p.traceActive {
trace.Stop()
p.traceActive = false
}
if p.cpuActive {
pprof.StopCPUProfile()
p.cpuActive = false
}
if p.cpuFile != nil {
errs = append(errs, p.cpuFile.Close())
p.cpuFile = nil
}
if p.traceFile != nil {
errs = append(errs, p.traceFile.Close())
p.traceFile = nil
}
if p.heapPath != "" && !p.heapComplete {
errs = append(errs, writeHeapProfile(p.heapPath))
p.heapComplete = true
}
return errors.Join(errs...)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 startProfiles reuses session.Close() as its cleanup path when CPU/trace profile setup fails after another profile file was already opened (profiles.go:34,42,47). Close() (profiles.go:87-90) unconditionally writes the heap profile if --heap-profile is set, forcing a GC and truncating/overwriting that file via O_CREATE|O_TRUNC even though the run never started, and the caller discards the Close() error so this happens silently. Fix by only writing the heap profile from the normal end-of-run path (finishProfiles), not from setup-failure cleanup.

Extended reasoning...

The bug

startProfiles (giga/evmonly/cmd/evmonly-loadtest/profiles.go:22-53) builds up a profileSession incrementally: it opens --cpu-profile, starts CPU profiling, then opens --trace-profile and starts the runtime trace. If any step after the first file is opened fails — e.g. CPU profiling starts fine but createProfileFile for the trace path fails, or trace.Start itself fails — the function calls session.Close() as its cleanup path (lines 34, 42, 47) and returns the original setup error.

Close() (lines 66-92) is the same method used for the normal end-of-run teardown. It unconditionally does:

if p.heapPath != "" && !p.heapComplete {
    errs = append(errs, writeHeapProfile(p.heapPath))
    p.heapComplete = true
}

There is nothing in this branch that distinguishes "the run finished and we should snapshot the heap" from "setup failed and we are just releasing already-opened file handles." writeHeapProfile forces a runtime.GC() and opens the --heap-profile path with os.O_CREATE|os.O_TRUNC (via createProfileFile, line 59) — so any pre-existing file at that path is truncated and overwritten with a near-empty, startup-time heap snapshot.

Trigger path

evmonly-loadtest --cpu-profile=ok.out --trace-profile=/no/perm/trace.out --heap-profile=heap.out ...:

  1. cfg.cpuProfile != ""createProfileFile succeeds, pprof.StartCPUProfile succeeds, session.cpuActive = true.
  2. cfg.traceProfile != ""createProfileFile("/no/perm/trace.out") fails (bad path/permissions).
  3. startProfiles calls _ = session.Close() and returns the create-file error.
  4. Inside Close(), p.heapPath == "heap.out" and p.heapComplete == false, so writeHeapProfile("heap.out") runs — GC forced, heap.out truncated and replaced with a startup-time snapshot.
  5. The caller (runPrebuilt, via startProfiles(cfg) at the top of the function) receives the setup error and returns immediately — the harness never executes a single block.

Why nothing else catches it

The error from session.Close() is explicitly discarded with _ = session.Close() in the failure branches, so there is no log line or exit code signaling that a heap file was just written. heapComplete only exists to make Close() idempotent (so a later call from finishProfiles does not write twice) — it is not a guard against calling Close() before execution ever started.

Impact

This is a standalone dev/calibration harness (giga/evmonly/cmd/evmonly-loadtest), not app or consensus code, so nothing here affects a production path. The impact is limited to: (a) a user re-running the harness pointed at the same --heap-profile path loses whatever heap snapshot a prior successful run had produced, with no warning, and (b) the on-disk artifact after a failed setup is a misleading near-empty startup snapshot rather than being absent or clearly marked as invalid.

Fix

The heap write belongs only to the normal end-of-run path (finishProfiles, called after the pipeline actually runs), not to the setup-failure cleanup path inside startProfiles. A simple fix is to give Close() a mode flag (or split it into closeFiles() used by setup-failure cleanup and the full Close() used by finishProfiles), so the heap profile is only ever written once execution has actually started/finished.

@codchen
codchen added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit a8f5cd1 Aug 6, 2026
73 checks passed
@codchen
codchen deleted the codex/evm-only-executor-loadtest branch August 6, 2026 08:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai: skip-review Skips Seidroid AI review triggered via Unified CI. non-app-hash-breaking

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants