Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -452,10 +452,66 @@ jobs:
path: artifacts/sei-${{ steps.log_artifact_meta.outputs.artifact_name }}
if-no-files-found: warn

# Autobahn integration suite from PR #3234 + the rpc-only forwarding sub-test.
# The test owns its own cluster lifecycle via TestMain (docker-cluster-start /
# -stop), so it can't share the matrix's cluster — it runs as a separate job.
autobahn-integration-tests:
name: Integration Test (Autobahn Basic)
runs-on: ubuntu-large
timeout-minutes: 45
needs: prepare-cluster
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v6
with:
go-version: '1.25.6'
- name: Install jq
run: sudo apt-get install -y jq
- name: Login to Docker Hub
uses: docker/login-action@v3
if: env.DOCKERHUB_USERNAME != ''
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Download integration CI artifacts
uses: actions/download-artifact@v4
with:
name: integration-ci-artifacts
- name: Load prebuilt seid and Docker images
run: |
tar -xzf integration-build.tar.gz
zstd -d -c integration-docker-images.tar.zst | docker load
- name: Run autobahn integration tests
run: make autobahn-integration-test
- name: Print node logs on failure
if: ${{ failure() }}
run: |
set -euo pipefail
for c in sei-node-0 sei-node-1 sei-node-2 sei-node-3 sei-rpc-node; do
echo "==================== ${c} (docker logs tail) ===================="
docker logs --tail 200 "${c}" || true
done
- name: Collect logs directory
if: ${{ always() }}
run: |
mkdir -p artifacts/sei-autobahn-integration
if [ -d build/generated/logs ]; then
cp -r build/generated/logs artifacts/sei-autobahn-integration/
fi
- name: Upload logs directory
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: integration-logs-autobahn-integration
path: artifacts/sei-autobahn-integration
if-no-files-found: warn

integration-test-check:
name: Integration Test Check
runs-on: ubuntu-latest
needs: [prepare-cluster, integration-tests]
needs: [prepare-cluster, integration-tests, autobahn-integration-tests]
if: always()
steps:
- name: Verify prepare and test jobs succeeded
Expand All @@ -468,4 +524,8 @@ jobs:
echo "integration-tests job did not succeed (${{ needs.integration-tests.result }})"
exit 1
fi
if [[ "${{ needs.autobahn-integration-tests.result }}" != "success" ]]; then
echo "autobahn-integration-tests job did not succeed (${{ needs.autobahn-integration-tests.result }})"
exit 1
fi
echo "All integration test jobs passed."
16 changes: 10 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# - Prefer tag if bases are equal; otherwise use whichever base is newer.
BRANCH_NAME := $(shell git rev-parse --abbrev-ref HEAD)
BRANCH_VERSION := $(shell echo "$(BRANCH_NAME)" | sed -E -n 's|.*(v[0-9]+\.[0-9]+\.[0-9]+[-A-Za-z0-9._]*).*|\1|p')
TAG_VERSION := $(shell echo $(shell git describe --tags))
TAG_VERSION := $(shell echo $(shell git describe --tags 2>/dev/null))
VERSION := $(shell \
bv="$(BRANCH_VERSION)"; tv="$(TAG_VERSION)"; \
bb=$$(echo "$$bv" | sed 's/^\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/'); \
Expand Down Expand Up @@ -225,7 +225,7 @@ build-docker-node:
.PHONY: build-docker-node

build-rpc-node:
@cd docker && docker build --tag sei-chain/rpcnode rpcnode --platform linux/x86_64
@cd docker && docker build --tag sei-chain/rpcnode rpcnode --platform $(DOCKER_PLATFORM)
.PHONY: build-rpc-node

# Integration-test CI: verify images loaded from prepare-cluster artifacts.
Expand Down Expand Up @@ -264,7 +264,7 @@ run-local-node: kill-sei-node build-docker-node
-v $(PROJECT_HOME):/sei-protocol/sei-chain:Z \
-v $(GO_PKG_PATH)/mod:/root/go/pkg/mod:Z \
-v $(shell go env GOCACHE):/root/.cache/go-build:Z \
--platform linux/x86_64 \
--platform $(DOCKER_PLATFORM) \
sei-chain/localnode
.PHONY: run-local-node

Expand All @@ -281,8 +281,10 @@ run-rpc-node: build-rpc-node
-v $(GO_PKG_PATH)/mod:/root/go/pkg/mod:Z \
-v $(shell go env GOCACHE):/root/.cache/go-build:Z \
-p 26668-26670:26656-26658 \
--platform linux/x86_64 \
--platform $(DOCKER_PLATFORM) \
--env GIGA_STORAGE=${GIGA_STORAGE} \
--env AUTOBAHN=${AUTOBAHN} \
--env CLUSTER_SIZE=${CLUSTER_SIZE} \
--env RECEIPT_BACKEND=${RECEIPT_BACKEND} \
sei-chain/rpcnode
.PHONY: run-rpc-node
Expand All @@ -299,9 +301,11 @@ run-rpc-node-skipbuild: build-rpc-node
-v $(GO_PKG_PATH)/mod:/root/go/pkg/mod:Z \
-v $(shell go env GOCACHE):/root/.cache/go-build:Z \
-p 26668-26670:26656-26658 \
--platform linux/x86_64 \
--platform $(DOCKER_PLATFORM) \
--env SKIP_BUILD=true \
--env GIGA_STORAGE=${GIGA_STORAGE} \
--env AUTOBAHN=${AUTOBAHN} \
--env CLUSTER_SIZE=${CLUSTER_SIZE} \
--env RECEIPT_BACKEND=${RECEIPT_BACKEND} \
sei-chain/rpcnode
.PHONY: run-rpc-node
Expand All @@ -328,7 +332,7 @@ run-rpc-node-integration-ci: kill-rpc-node ensure-integration-ci-images
-v $(GO_PKG_PATH)/mod:/root/go/pkg/mod:Z \
-v $(shell go env GOCACHE):/root/.cache/go-build:Z \
-p 26668-26670:26656-26658 \
--platform linux/x86_64 \
--platform $(DOCKER_PLATFORM) \
--env SKIP_BUILD=true \
--env GIGA_STORAGE=${GIGA_STORAGE} \
--env RECEIPT_BACKEND=${RECEIPT_BACKEND} \
Expand Down
84 changes: 69 additions & 15 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,10 +480,21 @@ type App struct {

forkInitializer func(sdk.Context)

httpServerStartSignal chan struct{}
wsServerStartSignal chan struct{}
httpServerStartSignalSent bool
wsServerStartSignalSent bool
httpServerStartSignal chan struct{}
wsServerStartSignal chan struct{}
// evmRPCReadyOnce ensures the gate signals fire exactly once even when
// multiple call sites (InitChainer, ProcessBlock, Info) race. The
// alternative — a pair of bool flags — was racy because the read +
// write + channel-send sequence isn't atomic, and a losing second send
// would block forever on the cap=1 buffer once the consumer drained
// it. The Info-side fire site makes this race reachable from any
// /abci_info RPC call.
evmRPCReadyOnce sync.Once

// autobahnRPCOnly scopes the Info-side EVM RPC gate-fire to autobahn
// rpc-only nodes. Set from tmConfig at New(). See the comment on Info
// for why we don't fire that path unconditionally.
autobahnRPCOnly bool

txPrioritizer sdk.TxPrioritizer

Expand Down Expand Up @@ -551,6 +562,7 @@ func New(
stateStore: stateStore,
httpServerStartSignal: make(chan struct{}, 1),
wsServerStartSignal: make(chan struct{}, 1),
autobahnRPCOnly: tmConfig != nil && tmConfig.AutobahnConfigFile != "" && tmConfig.IsAutobahnRPCOnly(),
}

for _, option := range appOptions {
Expand Down Expand Up @@ -1252,8 +1264,59 @@ func (app *App) MidBlocker(ctx sdk.Context, height int64) []abci.Event {
return app.mm.MidBlock(ctx, height)
}

// InitChainer application update at chain initialization
// signalEVMRPCReady fires the EVM HTTP/WS start-gate signals so the
// goroutines in RegisterLocalServices stop waiting and bind their listeners.
// sync.Once makes duplicate sends a no-op. The full set of fire sites
// covers every startup shape:
// - InitChainer: fresh start (validators via the handshaker/runExecute,
// rpc-only via GigaRouter.InitRPCOnly).
// - Info (below): restart with existing app state, where InitChainer is
// not called. Covers validators restarting and the future rpc-only
// restart with read-side state.
// - ProcessBlock: steady-state trigger; redundant after the above land
// but kept for safety on any path that reaches a block without firing
// either earlier.
func (app *App) signalEVMRPCReady() {
app.evmRPCReadyOnce.Do(func() {
app.httpServerStartSignal <- struct{}{}
app.wsServerStartSignal <- struct{}{}
})
}

// Info overrides BaseApp.Info to fire the EVM RPC gate on restart-with-
// state for Autobahn rpc-only nodes. Those nodes never call ProcessBlock
// (where the gate normally fires), so the gate has to be triggered from
// a startup event the app is guaranteed to receive. Info is the natural
// fit — the consensus engine queries it first thing on startup, and
// LastBlockHeight > 0 reliably indicates the app's state is already
// loaded from disk.
//
// We gate on app.autobahnRPCOnly rather than firing unconditionally
// because the CometBFT Handshaker also calls app.Info before
// ReplayBlocks. Firing the gate there would bind the EVM HTTP/WS
// listeners while replay is still in progress, serving stale (pre-
// restart) state until replay catches up — a regression vs. the
// original ProcessBlock-defer trigger, which fires after the first
// replayed block commits. Autobahn nodes skip the Handshaker entirely
// (see node.go's shouldHandshake), so this gating is also what makes
// the override safe for them. Fresh-start nodes (LastBlockHeight == 0)
// fall through to InitChainer's defer.
//
// TODO(autobahn-read-path): delete this override (and the
// autobahnRPCOnly field) once Autobahn rpc-only nodes subscribe to
// finalized blocks and run ProcessBlock like validators. Both this Info
// wrap and the InitChainer defer become redundant at that point.
func (app *App) Info(ctx context.Context, req *abci.RequestInfo) (*abci.ResponseInfo, error) {
resp, err := app.BaseApp.Info(ctx, req)
if app.autobahnRPCOnly && err == nil && resp != nil && resp.LastBlockHeight > 0 {
app.signalEVMRPCReady()
}
return resp, err
}

// InitChainer application update at chain initialization.
func (app *App) InitChainer(ctx sdk.Context, req abci.RequestInitChain) abci.ResponseInitChain {
defer app.signalEVMRPCReady()
var genesisState GenesisState
if !app.genesisImportConfig.StreamGenesisImport {
if err := json.Unmarshal(req.AppStateBytes, &genesisState); err != nil {
Expand Down Expand Up @@ -1910,16 +1973,7 @@ func (app *App) ProcessBlock(ctx sdk.Context, txs [][]byte, req *BlockProcessReq
}
}()

defer func() {
if !app.httpServerStartSignalSent {
app.httpServerStartSignalSent = true
app.httpServerStartSignal <- struct{}{}
}
if !app.wsServerStartSignalSent {
app.wsServerStartSignalSent = true
app.wsServerStartSignal <- struct{}{}
}
}()
defer app.signalEVMRPCReady()

ctx = ctx.WithIsOCCEnabled(app.OccEnabled())

Expand Down
38 changes: 38 additions & 0 deletions docker/rpcnode/scripts/step1_configure_init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,44 @@ if [ -n "$RECEIPT_BACKEND" ]; then
fi
fi

# Generate Autobahn (GigaRouter) config when the validators are running
# Autobahn consensus. The RPC node joins the cluster as
# autobahn-role="rpc-only" so it can forward eth_sendRawTransaction to the
# shard owner. Reuse the validator node directories under build/generated/
# (mounted into the container) so the committee description matches the
# cluster.
AUTOBAHN=${AUTOBAHN:-false}
if [ "$AUTOBAHN" = "true" ]; then
echo "Generating Autobahn config for RPC node (rpc-only)..."
AUTOBAHN_CONFIG="$HOME/.sei/config/autobahn.json"

# Default to 4 (the docker-compose cluster size) when CLUSTER_SIZE is unset.
CLUSTER_SIZE=${CLUSTER_SIZE:-4}
NODE_DIRS=""
i=0
while [ "$i" -lt "$CLUSTER_SIZE" ]; do
NODE_DIRS="$NODE_DIRS build/generated/node_${i}"
i=$((i + 1))
done

seid tendermint gen-autobahn-config $NODE_DIRS --output "$AUTOBAHN_CONFIG"

# Inject autobahn-config-file + autobahn-role as top-level keys in
# config.toml. They must precede any [section] header so the TOML parser
# sees them at root scope.
if grep -q "autobahn-config-file" ~/.sei/config/config.toml; then
sed -i 's|autobahn-config-file = .*|autobahn-config-file = "'"$AUTOBAHN_CONFIG"'"|' ~/.sei/config/config.toml
else
sed -i '1s|^|autobahn-config-file = "'"$AUTOBAHN_CONFIG"'"\n|' ~/.sei/config/config.toml
fi
if grep -q "autobahn-role" ~/.sei/config/config.toml; then
sed -i 's|autobahn-role = .*|autobahn-role = "rpc-only"|' ~/.sei/config/config.toml
else
sed -i '1s|^|autobahn-role = "rpc-only"\n|' ~/.sei/config/config.toml
fi
echo "Autobahn config written to $AUTOBAHN_CONFIG (rpc-only)"
fi

# Override state sync configs
STATE_SYNC_RPC="192.168.10.10:26657"
STATE_SYNC_PEER="2f9846450b7a3dcf4af1ac0082e3279c16744df8@172.31.9.18:26656,ec98c4a28a2023f4f976828c8a8e7127bfef4e1b@172.31.4.96:26656,b03014d67384fb0ef6ad992c77cefe4f9d2c1640@172.31.4.219:26656"
Expand Down
14 changes: 13 additions & 1 deletion integration_test/autobahn/autobahn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ func TestMain(m *testing.M) {
teardownCluster() // best-effort
os.Exit(1)
}
if err := setupRPCOnlyNode(); err != nil {
fmt.Fprintf(os.Stderr, "rpc-only sidecar setup failed: %v\n", err)
teardownCluster()
os.Exit(1)
}
code := m.Run()
teardownCluster()
os.Exit(code)
Expand Down Expand Up @@ -250,8 +255,14 @@ func countSeiContainers() (int, error) {
return len(strings.Fields(strings.TrimSpace(string(out)))), nil
}

// teardownCluster runs `make docker-cluster-stop`, ignoring errors.
// teardownCluster tears down every container TestMain brought up: first
// the rpc-only sidecar (so its run-rpc-node `docker run --rm` process
// exits cleanly), then the validator cluster. Best-effort — errors are
// ignored so a partially-failed setupCluster can still clean up. Adding
// new sidecars later goes here too.
func teardownCluster() {
fmt.Println("=== Stopping rpc-only sidecar ===")
_ = runMake(nil, "kill-rpc-node")
fmt.Println("=== Stopping cluster ===")
_ = runMake(nil, "docker-cluster-stop")
}
Expand Down Expand Up @@ -290,6 +301,7 @@ func TestAutobahn(t *testing.T) {

t.Run("BlockProduction", testBlockProduction)
t.Run("BankTransfer", testBankTransfer)
t.Run("RPCOnlyForwarding", testRPCOnlyForwarding)
t.Run("LivenessUnderMaxFaults", testLivenessUnderMaxFaults)
t.Run("HaltsBeyondMaxFaults", testHaltsBeyondMaxFaults)
t.Run("Recovery", testRecovery)
Expand Down
Loading
Loading