Part I — Simulation: SimCluster, EventKey, and single_shard_tx_sim. Part II — Production: per-shard shard-loop, RPC ingress, production E2E (same module—read both before hands-on labs).
single_shard_tx_sim from SimCluster through ShardLoop::step to commit and executionrunner_boots_listens_and_shuts_down_cleanly through ProductionRunner::build, genesis, and the pinned event loopshard-loop thread is, how run_shard_loop relates to it, and how CPU affinity is applied with core_affinity::set_for_currenthyperscale-simulationSimCluster::run_until advances a simulated clock (Duration).SimulatedNetwork + SimNetworkAdapter—latency queues, no real sockets.SimStorage + SyncDispatch (in-process).ShardLoop::step called directly from the runner loop.flush_all_batches() — drains validation / header batches and related commits immediately, every time (see ShardLoop::step caller protocol; path in study table). Keeps deterministic tests simple: no deferred batch work whose ordering depends on the next simulated clock tick.SyncDispatch runs validation/execution closures on the driver thread—zero pool nondeterminism, perfect replay. You do not exercise thread-pool ordering or contention that production’s PooledDispatch can surface.
hyperscale-productionrun_shard_loop sets time each iteration).Libp2pAdapter / Libp2pNetwork—libp2p gossipsub over QUIC between validators (separate from wallet HTTP submit); gossip arrives on crossbeam channels into the pinned loop.RocksDbStorage + SharedStorage + PooledDispatch.std::thread (spawn_shard_loop).flush_expired_batches(wall_clock_duration()) — only flushes batch pipelines whose deadlines have passed; amasses work until then for throughput and steadier load (details below).std::thread that must not .await; async I/O and sleeps stay on Tokio and push NodeInput through crossbeam. Pros: clear ownership of state-machine mutation, no async locks inside step. Cons: two concurrency models and channel backpressure to tune.
Shared core: Simulation uses SimCluster + NodeHost with deterministic ShardEvent scheduling; production uses per-shard ShardLoop::step on dedicated threads. Both drive the same NodeStateMachine—so SubmitTransaction, shard consensus, BlockCommitted, and execution hooks match once an event is delivered. The harness decides how events arrive and when time advances.
run_shard_loop, and CPU affinityBelow is the actual production entrypoint: a named std::thread, optional CPU affinity via core_affinity::set_for_current, then run_shard_loop. Read the code first; captions spell out what each part testifies to.
Snippet context: spawn_shard_loop — pinned-thread entry (file in study table).
pub fn spawn_shard_loop(
shard_loop: ProdShardLoop,
config: PinnedLoopConfig,
) -> std::thread::JoinHandle<()> {
std::thread::Builder::new()
.name("shard-loop".to_string())
.spawn(move || {
// Try to pin to core 0
if let Some(core_ids) = core_affinity::get_core_ids() {
if let Some(&core_id) = core_ids.first() {
if core_affinity::set_for_current(core_id) {
info!(?core_id, "Pinned shard-loop thread to core");
} else {
warn!("Failed to pin shard-loop thread to core 0");
}
}
}
run_shard_loop(shard_loop, config);
})
.expect("failed to spawn shard-loop thread")
}
std::thread::Builder + .name("shard-loop") + .spawn — this is an OS thread whose closure only calls run_shard_loop after the optional pin.set_for_current(core_id) runs inside that new thread; the info!(…) line means the affinity syscall succeeded, not that the core is yours alone.Snippet context: Tokio task after spawn_shard_loop — metrics and shutdown select! (runner file in study table).
// ── 3. Spawn pinned thread ───────────────────────────────────────
let loop_handle = spawn_shard_loop(shard_loop, pinned_config);
// ── 4. Metrics + shutdown loop ───────────────────────────────────
let mut metrics_tick = tokio::time::interval(Duration::from_secs(1));
metrics_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut shutdown_rx = self.shutdown_rx.take().expect("shutdown_rx already taken");
loop {
tokio::select! {
biased;
_ = &mut shutdown_rx => {
info!("Shutdown signal received");
break;
}
_ = metrics_tick.tick() => {
self.collect_metrics();
}
}
}
The same binary keeps a Tokio loop here (metrics, shutdown); libp2p/RPC/timers live on that runtime too. NodeInput for consensus reaches the shard-loop thread through crossbeam channels (timer_rx / callback_rx / consensus_rx in PinnedLoopConfig) read inside run_shard_loop — see the next subsection.
spawn_shard_loop so ShardLoop and storage are initialized deterministically on one side, then the loop is handed off already consistent—avoids splitting genesis across threads. Cost: startup blocks the runtime briefly; acceptable before listeners are fully “hot.”
Schematic for one validator process on a typical multi-core host. Affinity affects only the shard-loop std::thread; Tokio worker threads are usually unpinned and the OS may move them across logical CPUs.
shard-loop thread |
std::thread running run_shard_loop; synchronous ShardLoop::step; reads crossbeam receivers (timer_rx, callback_rx, consensus_rx). |
|
|---|---|---|
| Tokio / async side | libp2p, RPC, timer sleeps, ProdTimerManager reinjections, runner’s metrics + shutdown tokio::select!; tasks send NodeInput into crossbeam channels consumed by shard-loop. |
|
Pools (PooledDispatch) |
Heavy crypto, validation, execution work off the hot path; results often come back as callback crossbeam channel events into run_shard_loop. |
|
| Everything else | Other threads in this process, other processes, kernel/interrupt work: the scheduler may still run them on any logical CPU, including the affinity target, unless you isolate CPUs (e.g. isolcpus, cgroups). |
run_shard_loop · step · channel recv / crossbeam::select!
Pinning does not reserve a core exclusively for this process unless you add stronger isolation (cgroups, isolated CPUs, etc.).
run_shard_loop actually doesRead in-repo: run_shard_loop (spawned from spawn_shard_loop in the same module) — path in study table.
It is the main engine loop for advancing the ShardLoop while the node is up. The very first thing inside the function (before the big loop) is to apply genesis-time timer ops once:
ProductionRunner::maybe_initialize_genesis() builds the genesis block, calls initialize_genesis on the state machine, runs handle_actions / flush, drains timer_ops from that step, then steps StateCommitComplete at height 0 (syncing BFT with the real JVT root) and merges any more timer ops. That vector becomes PinnedLoopConfig.initial_timer_ops. (Implementation on the production-runner side — study table.)run_shard_loop does for op in take(initial_timer_ops) { timer_mgr.process_op(op); } a single time at startup. Each TimerOp (commonly Set for the initial proposal timer) tells ProdTimerManager to spawn a Tokio sleep; when it fires, it sends a timer NodeInput on crossbeam timer_rx. So “once” is one drain at loop entry, not “timers only fire once.”timer_rx re-enters the synchronous step world. Trade-off: timer latency includes Tokio scheduling jitter (usually small vs consensus timeouts).
After that, the loop repeats until shutdown:
shard_loop.set_time(now)).NodeInput with a priority order on crossbeam channels (timer, then callback, then consensus); if nothing is ready, block on crossbeam::select! with a timeout derived from nearest_batch_deadline().shard_loop.step(event), then apply TimerOps from the step (Tokio-backed sleeps that re-inject timer events into the timer channel).flush_expired_batches(wall_clock_duration()) — see Why simulation flushes everything; production batches below.run_jvt_gc).try_recv favors time-driven liveness (proposal / round timers) over inbound work—otherwise a flood of gossip could delay handling a fired timer and stall progress. Risk if mis-tuned: chronic timer traffic could starve lower priorities; the codebase assumes timer volume stays bounded relative to protocol needs.
Read in-repo: flush_expired_batches, flush_all_batches, nearest_batch_deadline, and step caller-protocol docs — study table.
The ShardLoop holds batch accumulators (notably validation and committed-header verification) with time-based deadlines. The two harnesses choose different flush policies after step:
flush_all_batches() flushes all pending batches and related paths every time, ignoring whether a deadline has arrived. The runner is single-threaded and fully owns simulated time. Flushing aggressively makes the state after each step “fully settled,” which simplifies assertions and avoids subtle ordering bugs when run_until jumps the clock—work is not left sitting in a batch whose expiry might fall awkwardly between synthetic events. Throughput of the fake network is not a goal, so paying the cost of an immediate flush every step is cheap and predictable.flush_expired_batches(now) only calls flush_validation_batch / flush_committed_header_verifications when now has passed each batch’s deadline. Reason: real validators see bursty traffic (gossip, RPC); batching until a short deadline amortizes verification work, reduces spikes on the pinned thread and worker pools, and acts as a form of backpressure—the pipeline does not have to react to every packet edge individually. The same file exposes nearest_batch_deadline() so run_shard_loop can set the crossbeam::select! timeout: the loop wakes when a batch must flush even if no new NodeInput arrived, keeping latency bounded without flushing blindly after every event.flush_all_batches() still exists on ShardLoop for paths that need an immediate drain (e.g. shutdown); production’s steady-state path prefers deadline-driven flushes.
run_shard_loop is production architecture, not something that exists only to simulate users. E2E tests such as runner_boots_listens_and_shuts_down_cleanly start and stop the same path briefly; a real validator runs it continuously. RPC or other tasks send work (e.g. SubmitTransaction) into the crossbeam channels this loop reads—they are real inputs, not pretend traffic generated inside the loop.
vs simulation: Deterministic simulation never spawns this thread: a single driver calls ShardLoop::step from SimCluster::run_until with simulated time and an in-memory network. Same step logic, different harness.
For a broader ladder of multitasking / threading terms mapped to hyperscale-rs (processes, async vs blocking, pools, backpressure), see the Basic module State Machines & Event-Driven Architecture (section on threading and concurrency).
single_shard_tx_simSource & story
Test: single_shard_tx_sim in the simulation E2E integration tests — path in study table.
Story: one shard, four validators; build a signed notarized tx, submit on node 0, run simulated time until the tx is executed (or evicted after success).
| Step (test / harness) | Protocol meaning → code path |
|---|---|
SimCluster::new(config, 42) |
Construct topology and one ShardLoop per validator with SimStorage, RadixExecutor, SimNetworkAdapter, SyncDispatch, and a per-node crossbeam channel for injected events. Start from SimCluster::new in the simulation runner (study table). |
runner.initialize_genesis() |
Engine genesis: For each node, executor.run_genesis via GenesisWrapper, then finalize_genesis_jvt (JVT state commitment at version 0). Consensus genesis: finalize_genesis builds Block::genesis per shard with the genesis JVT root, calls state.initialize_genesis, handle_actions, drains IO, schedules StateCommitComplete for height 0. Implemented in initialize_genesis / finalize_genesis on the same runner (study table). |
|
Manifest +
sign_and_notarize → RoutableTransaction |
Builds a valid Radix-style transaction outside the consensus loop (test helper + Radix types). Not the hyperscale runner; it’s the payload the node will treat like any user tx. |
schedule_initial_event(0, ZERO, SubmitTransaction { tx }) |
Inserts NodeInput::SubmitTransaction into the global deterministic event queue for node 0 at the current sim time. Follow schedule_initial_event → schedule_event on the simulation runner (study table). |
runner.run_until(Duration::from_secs(2)) (and later polling) |
Simulation driver: Repeatedly picks the next time from the minimum of (next queued event, next simulated network delivery), advances now, optionally prunes gossip dedup caches, flushes gossip/notifications/responses into channels, drains those into new queued events, then pops all due events and runs shard_loops[i].step(event) + flush_all_batches + drain_node_io + timer scheduling from StepOutput. Core logic in run_until and drain_node_io on the simulation runner (study table). |
NodeInput::SubmitTransaction handled in ShardLoop::step |
Ingress: Computes shard set from declared_reads/declared_writes, broadcast_to_shard for each (simulated gossip). Marks tx as locally submitted; queue_validation(tx) if not already pending. ShardLoop::step match arm SubmitTransaction (study table). |
|
Validation pipeline completes
|
Valid txs re-enter as NodeInput::TransactionValidated, which becomes ProtocolEvent::TransactionValidated for the state machine (feed_event). Invalid → TransactionValidationsFailed. Same ShardLoop module as submit (validation completion paths). Why reshape: keeps one mempool/BFT entry shape whether the tx arrived from gossip or from the validator pipeline—extra hop, less duplicated consensus logic. |
|
Mempool + BFT + block commit
|
NodeStateMachine applies protocol events: tx lands in mempool, proposer builds blocks, votes form QCs, two-chain commit rule fires. Committed blocks surface as ProtocolEvent::BlockCommitted (and related paths). Event dispatch in node state; consensus details in the BFT crate (study table). |
on_block_committed → execution |
After commit, state runs execution.on_block_committed(...) (and mempool status / wave updates). This is where single-shard work runs through the execution engine; cross-shard would add provision coordination (not this particular test’s focus). Follow on_block_committed in node state (study table). |
|
Test assertions:
mempool().status / execution().is_executed |
Observes observable end state on node 0: mempool may show progression or None if the tx was evicted after completion; execution’s is_executed confirms the engine path ran. Assertions live in the same simulation E2E test file (study table). |
schedule_initial_event’d before the first long run_until so it sits in the pipeline like a “user submitted just as the network wakes”—cheap way to test mempool → propose path. It does not cover every ordering (e.g. submit only after many rounds); other tests vary timing.
Run this test only
cargo test -p hyperscale-simulation --test scenarios single_shard_tx_sim -- --nocapture
runner_boots_listens_and_shuts_down_cleanlySource & story
Test crate: production integration E2Es — path in study table.
This test does not submit a transaction; it proves production wiring: RocksDB, ProductionRunner::build, libp2p listening, run() spawning the pinned loop, then graceful shutdown. The same ShardLoop::step path would process SubmitTransaction if an RPC task (or test) sent it on the consensus channel—see ProductionRunner::tx_submission_sender() on the production runner (study table).
| Step (test) | What it validates → code path |
|---|---|
TestFixtures::new(42, 1) + RocksDbStorage::open |
Deterministic topology/keys for one validator; persistent storage under a temp directory. Production I/O path, unlike SimStorage. Test fixtures module + RocksDB-backed storage crate (study table). Why real RocksDB here: catches compaction, locking, and path bugs that in-memory stores miss; cost is slower, flaky-disk-sensitive tests if not temp-scoped. |
ProductionRunner::builder()...build().await |
Wires Prometheus metrics install, PooledDispatch, NodeStateMachine, SharedStorage, Libp2pAdapter::new, Libp2pNetwork, RadixExecutor, TransactionValidation, and ShardLoop::new(...) with production generic params. Crossbeam senders/receivers are created for timer / callback / consensus / shutdown. ProductionRunnerBuilder::build on the production runner (study table). |
runner.network(), listen addresses |
Confirms a QUIC listen address for the validator libp2p stack (port 0 = OS-assigned)—validator-to-validator transport, not the wallet HTTP path. Network stack is live; gossip would use gossipsub/QUIC, not simulated delivery queues. |
tokio::spawn(runner.run()) |
ProductionRunner::run: calls maybe_initialize_genesis() on the still-local ShardLoop (engine genesis + genesis block + initialize_genesis + StateCommitComplete—parallel idea to simulation’s genesis, but on RocksDB). Then moves ShardLoop into spawn_shard_loop. Main Tokio task then waits on shutdown + metrics ticks. See maybe_initialize_genesis and run on the production runner (study table). |
|
Pinned thread:
run_shard_loop |
Once at entry: drain initial_timer_ops from genesis setup (maybe_initialize_genesis) through ProdTimerManager — each TimerOp arms a Tokio sleep that later injects timer NodeInput on crossbeam timer_rx. Then loop: shutdown check → set_time(now) → priority try_recv (timer, callback, consensus) → optional blocking crossbeam::select! → shard_loop.step(event) → flush_expired_batches → periodic metrics / channel depth export / optional JVT GC (run_jvt_gc). Production analogue of simulation’s run_until + step; events from real adapters and Tokio timers, not a BTreeMap queue. Implemented in run_shard_loop (production event loop — study table). |
drop(shutdown) + join handle |
Shutdown handle drops → run’s Tokio loop exits → sends on xb_shutdown_tx → pinned loop observes shutdown and exits → test asserts clean completion. Validates lifecycle, not tx finality. |
This integration test is not a full “tx through consensus to finality” production E2E: it does not submit a transaction or assert block heights, QCs, or executed user state (aside from genesis wiring at startup). The pinned loop is real and would handle SubmitTransaction if something sent it on the consensus channel, but proving submit→mempool→BFT→commit→execute with assertions is what the simulation E2E above is for, or a heavier prod/RPC test if the repo adds one next to the existing production tests (study table).
Skipping a full tx here keeps CI fast and failure modes obvious (build/wire/shutdown). Cross-shard / multishard: simulation includes scenarios such as cross_shard_tx_sim alongside the single-shard E2E in the same integration test file (study table). This module does not trace a production multishard integration test; that would mean multiple validators, real networking, and timing—outside the scope of runner_boots_listens_and_shuts_down_cleanly.
Run this test (use one test thread; serial tests)
cargo test -p hyperscale-production --test runner runner_boots_listens_and_shuts_down_cleanly -- --test-threads=1 --nocapture
hyperscale-sim binary vs production metrics (different from these integration tests).cross_shard_tx_sim in the same scenarios target) use the same SimCluster pattern with different configs/assertions — paths in study table.Pass threshold: 70%.
Paths relative to the hyperscale-rs repo root. Use these when following the traces and code snippets above.
| Topic | Path |
|---|---|
| Simulation E2E tests | crates/simulation/tests/scenarios.rs — single_shard_tx_sim, cross_shard_tx_sim, … |
| Simulation runner | crates/simulation/src/runner.rs — SimCluster::new, genesis, schedule_initial_event, run_until, drain_node_io |
| Production E2E tests | crates/production/tests/runner.rs — runner_boots_listens_and_shuts_down_cleanly |
| Production test fixtures | crates/network-libp2p/src/test_utils.rs |
| Production runner | crates/production/src/runner.rs — ProductionRunnerBuilder::build, maybe_initialize_genesis, run, tx_submission_sender |
| Production event loop | crates/production/src/runner.rs — spawn_shard_loop, run_shard_loop |
Node ShardLoop |
crates/node/src/shard/mod.rs — step, SubmitTransaction, validation pipeline, flush_all_batches / flush_expired_batches |
| Node state machine | crates/node/src/state/mod.rs — on_block_committed, protocol event dispatch |
| BFT | crates/shard |
| RocksDB storage | crates/storage-rocksdb (package hyperscale_storage_rocksdb) |
Part II — Production harness (continued). ↑ Back to simulation section.
Reading module (3/4): what the production harness uses, where user / RPC input begins, and what canonical production E2Es can show today—after the simulation pair.
| Ingress | What happens |
|---|---|
POST /api/v1/transactions | Tokio RPC → submit_transaction_handler → tx_submission_tx.send(NodeInput::SubmitTransaction { tx }). |
ProductionRunner::tx_submission_sender() | Same crossbeam path as RPC; for tests/tools without HTTP. |
run_shard_loop | Consumes NodeInput on timer/callback/consensus channels—does not generate user txs. |
What runner_boots_listens_and_shuts_down_cleanly shows: RocksDB + libp2p + pinned loop + shutdown—not a full signed tx path (hands-on module next adds your markers).
tx_submission_sender ingress to consensus_rxrunner_boots_listens_and_shuts_down_cleanly and run_shard_loophyperscale-productionrun_shard_loop sets time each iteration).Libp2pAdapter / Libp2pNetwork—libp2p gossipsub over QUIC between validators (separate from wallet HTTP POST /api/v1/transactions); gossip arrives on crossbeam channels into the pinned loop.RocksDbStorage + SharedStorage + PooledDispatch.std::thread (spawn_shard_loop).flush_expired_batches(wall_clock_duration()) — only flushes batch pipelines whose deadlines have passed; amasses work until then for throughput and steadier load (details below).std::thread that must not .await; async I/O and sleeps stay on Tokio and push NodeInput through crossbeam. Pros: clear ownership of state-machine mutation, no async locks inside step. Cons: two concurrency models and channel backpressure to tune.
Shared core: Simulation uses SimCluster + NodeHost with deterministic ShardEvent scheduling; production uses per-shard ShardLoop::step on dedicated threads. Both drive the same NodeStateMachine—so SubmitTransaction, shard consensus, BlockCommitted, and execution hooks match once an event is delivered. The harness decides how events arrive and when time advances.
run_shard_loop, and CPU affinityBelow is the actual production entrypoint: a named std::thread, optional CPU affinity via core_affinity::set_for_current, then run_shard_loop. Read the code first; captions spell out what each part testifies to.
Snippet context: spawn_shard_loop — pinned-thread entry (file in study table).
pub fn spawn_shard_loop(
shard_loop: ProdShardLoop,
config: PinnedLoopConfig,
) -> std::thread::JoinHandle<()> {
std::thread::Builder::new()
.name("shard-loop".to_string())
.spawn(move || {
// Try to pin to core 0
if let Some(core_ids) = core_affinity::get_core_ids() {
if let Some(&core_id) = core_ids.first() {
if core_affinity::set_for_current(core_id) {
info!(?core_id, "Pinned shard-loop thread to core");
} else {
warn!("Failed to pin shard-loop thread to core 0");
}
}
}
run_shard_loop(shard_loop, config);
})
.expect("failed to spawn shard-loop thread")
}
std::thread::Builder + .name("shard-loop") + .spawn — this is an OS thread whose closure only calls run_shard_loop after the optional pin.set_for_current(core_id) runs inside that new thread; the info!(…) line means the affinity syscall succeeded, not that the core is yours alone.Snippet context: Tokio task after spawn_shard_loop — metrics and shutdown select! (runner file in study table).
// ── 3. Spawn pinned thread ───────────────────────────────────────
let loop_handle = spawn_shard_loop(shard_loop, pinned_config);
// ── 4. Metrics + shutdown loop ───────────────────────────────────
let mut metrics_tick = tokio::time::interval(Duration::from_secs(1));
metrics_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut shutdown_rx = self.shutdown_rx.take().expect("shutdown_rx already taken");
loop {
tokio::select! {
biased;
_ = &mut shutdown_rx => {
info!("Shutdown signal received");
break;
}
_ = metrics_tick.tick() => {
self.collect_metrics();
}
}
}
The same binary keeps a Tokio loop here (metrics, shutdown); libp2p/RPC/timers live on that runtime too. NodeInput for consensus reaches the shard-loop thread through crossbeam channels (timer_rx / callback_rx / consensus_rx in PinnedLoopConfig) read inside run_shard_loop — see the next subsection.
spawn_shard_loop so ShardLoop and storage are initialized deterministically on one side, then the loop is handed off already consistent—avoids splitting genesis across threads. Cost: startup blocks the runtime briefly; acceptable before listeners are fully “hot.”
Schematic for one validator process on a typical multi-core host. Affinity affects only the shard-loop std::thread; Tokio worker threads are usually unpinned and the OS may move them across logical CPUs.
shard-loop thread |
std::thread running run_shard_loop; synchronous ShardLoop::step; reads crossbeam receivers (timer_rx, callback_rx, consensus_rx). |
|
|---|---|---|
| Tokio / async side | libp2p, RPC, timer sleeps, ProdTimerManager reinjections, runner’s metrics + shutdown tokio::select!; tasks send NodeInput into crossbeam channels consumed by shard-loop. |
|
Pools (PooledDispatch) |
Heavy crypto, validation, execution work off the hot path; results often come back as callback crossbeam channel events into run_shard_loop. |
|
| Everything else | Other threads in this process, other processes, kernel/interrupt work: the scheduler may still run them on any logical CPU, including the affinity target, unless you isolate CPUs (e.g. isolcpus, cgroups). |
run_shard_loop · step · channel recv / crossbeam::select!
Pinning does not reserve a core exclusively for this process unless you add stronger isolation (cgroups, isolated CPUs, etc.).
run_shard_loop actually doesRead in-repo: run_shard_loop (spawned from spawn_shard_loop in the same module) — path in study table.
It is the main engine loop for advancing the ShardLoop while the node is up. The very first thing inside the function (before the big loop) is to apply genesis-time timer ops once:
ProductionRunner::maybe_initialize_genesis() builds the genesis block, calls initialize_genesis on the state machine, runs handle_actions / flush, drains timer_ops from that step, then steps StateCommitComplete at height 0 (syncing BFT with the real JVT root) and merges any more timer ops. That vector becomes PinnedLoopConfig.initial_timer_ops. (Implementation on the production-runner side — study table.)run_shard_loop does for op in take(initial_timer_ops) { timer_mgr.process_op(op); } a single time at startup. Each TimerOp (commonly Set for the initial proposal timer) tells ProdTimerManager to spawn a Tokio sleep; when it fires, it sends a timer NodeInput on crossbeam timer_rx. So “once” is one drain at loop entry, not “timers only fire once.”timer_rx re-enters the synchronous step world. Trade-off: timer latency includes Tokio scheduling jitter (usually small vs consensus timeouts).
After that, the loop repeats until shutdown:
shard_loop.set_time(now)).NodeInput with a priority order on crossbeam channels (timer, then callback, then consensus); if nothing is ready, block on crossbeam::select! with a timeout derived from nearest_batch_deadline().shard_loop.step(event), then apply TimerOps from the step (Tokio-backed sleeps that re-inject timer events into the timer channel).flush_expired_batches(wall_clock_duration()) — see Why simulation flushes everything; production batches below.run_jvt_gc).try_recv favors time-driven liveness (proposal / round timers) over inbound work—otherwise a flood of gossip could delay handling a fired timer and stall progress. Risk if mis-tuned: chronic timer traffic could starve lower priorities; the codebase assumes timer volume stays bounded relative to protocol needs.
Read in-repo: flush_expired_batches, flush_all_batches, nearest_batch_deadline, and step caller-protocol docs — study table.
The ShardLoop holds batch accumulators (notably validation and committed-header verification) with time-based deadlines. The two harnesses choose different flush policies after step:
flush_all_batches() flushes all pending batches and related paths every time, ignoring whether a deadline has arrived. The runner is single-threaded and fully owns simulated time. Flushing aggressively makes the state after each step “fully settled,” which simplifies assertions and avoids subtle ordering bugs when run_until jumps the clock—work is not left sitting in a batch whose expiry might fall awkwardly between synthetic events. Throughput of the fake network is not a goal, so paying the cost of an immediate flush every step is cheap and predictable.flush_expired_batches(now) only calls flush_validation_batch / flush_committed_header_verifications when now has passed each batch’s deadline. Reason: real validators see bursty traffic (gossip, RPC); batching until a short deadline amortizes verification work, reduces spikes on the pinned thread and worker pools, and acts as a form of backpressure—the pipeline does not have to react to every packet edge individually. The same file exposes nearest_batch_deadline() so run_shard_loop can set the crossbeam::select! timeout: the loop wakes when a batch must flush even if no new NodeInput arrived, keeping latency bounded without flushing blindly after every event.flush_all_batches() still exists on ShardLoop for paths that need an immediate drain (e.g. shutdown); production’s steady-state path prefers deadline-driven flushes.
run_shard_loop is production architecture, not something that exists only to simulate users. E2E tests such as runner_boots_listens_and_shuts_down_cleanly start and stop the same path briefly; a real validator runs it continuously. RPC or other tasks send work (e.g. SubmitTransaction) into the crossbeam channels this loop reads—they are real inputs, not pretend traffic generated inside the loop.
vs simulation: Deterministic simulation never spawns this thread: a single driver calls ShardLoop::step from SimCluster::run_until with simulated time and an in-memory network. Same step logic, different harness.
For a broader ladder of multitasking / threading terms mapped to hyperscale-rs (processes, async vs blocking, pools, backpressure), see the Basic module State Machines & Event-Driven Architecture (section on threading and concurrency).
run_shard_loop, and CPU affinityBelow is the actual production entrypoint: a named std::thread, optional CPU affinity via core_affinity::set_for_current, then run_shard_loop. Read the code first; captions spell out what each part testifies to.
Snippet context: spawn_shard_loop — pinned-thread entry (file in study table).
pub fn spawn_shard_loop(
shard_loop: ProdShardLoop,
config: PinnedLoopConfig,
) -> std::thread::JoinHandle<()> {
std::thread::Builder::new()
.name("shard-loop".to_string())
.spawn(move || {
// Try to pin to core 0
if let Some(core_ids) = core_affinity::get_core_ids() {
if let Some(&core_id) = core_ids.first() {
if core_affinity::set_for_current(core_id) {
info!(?core_id, "Pinned shard-loop thread to core");
} else {
warn!("Failed to pin shard-loop thread to core 0");
}
}
}
run_shard_loop(shard_loop, config);
})
.expect("failed to spawn shard-loop thread")
}
std::thread::Builder + .name("shard-loop") + .spawn — this is an OS thread whose closure only calls run_shard_loop after the optional pin.set_for_current(core_id) runs inside that new thread; the info!(…) line means the affinity syscall succeeded, not that the core is yours alone.Snippet context: Tokio task after spawn_shard_loop — metrics and shutdown select! (runner file in study table).
// ── 3. Spawn pinned thread ───────────────────────────────────────
let loop_handle = spawn_shard_loop(shard_loop, pinned_config);
// ── 4. Metrics + shutdown loop ───────────────────────────────────
let mut metrics_tick = tokio::time::interval(Duration::from_secs(1));
metrics_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut shutdown_rx = self.shutdown_rx.take().expect("shutdown_rx already taken");
loop {
tokio::select! {
biased;
_ = &mut shutdown_rx => {
info!("Shutdown signal received");
break;
}
_ = metrics_tick.tick() => {
self.collect_metrics();
}
}
}
The same binary keeps a Tokio loop here (metrics, shutdown); libp2p/RPC/timers live on that runtime too. NodeInput for consensus reaches the shard-loop thread through crossbeam channels (timer_rx / callback_rx / consensus_rx in PinnedLoopConfig) read inside run_shard_loop — see the next subsection.
spawn_shard_loop so ShardLoop and storage are initialized deterministically on one side, then the loop is handed off already consistent—avoids splitting genesis across threads. Cost: startup blocks the runtime briefly; acceptable before listeners are fully “hot.”
Schematic for one validator process on a typical multi-core host. Affinity affects only the shard-loop std::thread; Tokio worker threads are usually unpinned and the OS may move them across logical CPUs.
shard-loop thread |
std::thread running run_shard_loop; synchronous ShardLoop::step; reads crossbeam receivers (timer_rx, callback_rx, consensus_rx). |
|
|---|---|---|
| Tokio / async side | libp2p, RPC, timer sleeps, ProdTimerManager reinjections, runner’s metrics + shutdown tokio::select!; tasks send NodeInput into crossbeam channels consumed by shard-loop. |
|
Pools (PooledDispatch) |
Heavy crypto, validation, execution work off the hot path; results often come back as callback crossbeam channel events into run_shard_loop. |
|
| Everything else | Other threads in this process, other processes, kernel/interrupt work: the scheduler may still run them on any logical CPU, including the affinity target, unless you isolate CPUs (e.g. isolcpus, cgroups). |
run_shard_loop · step · channel recv / crossbeam::select!
Pinning does not reserve a core exclusively for this process unless you add stronger isolation (cgroups, isolated CPUs, etc.).
run_shard_loop actually doesRead in-repo: run_shard_loop (spawned from spawn_shard_loop in the same module) — path in study table.
It is the main engine loop for advancing the ShardLoop while the node is up. The very first thing inside the function (before the big loop) is to apply genesis-time timer ops once:
ProductionRunner::maybe_initialize_genesis() builds the genesis block, calls initialize_genesis on the state machine, runs handle_actions / flush, drains timer_ops from that step, then steps StateCommitComplete at height 0 (syncing BFT with the real JVT root) and merges any more timer ops. That vector becomes PinnedLoopConfig.initial_timer_ops. (Implementation on the production-runner side — study table.)run_shard_loop does for op in take(initial_timer_ops) { timer_mgr.process_op(op); } a single time at startup. Each TimerOp (commonly Set for the initial proposal timer) tells ProdTimerManager to spawn a Tokio sleep; when it fires, it sends a timer NodeInput on crossbeam timer_rx. So “once” is one drain at loop entry, not “timers only fire once.”timer_rx re-enters the synchronous step world. Trade-off: timer latency includes Tokio scheduling jitter (usually small vs consensus timeouts).
After that, the loop repeats until shutdown:
shard_loop.set_time(now)).NodeInput with a priority order on crossbeam channels (timer, then callback, then consensus); if nothing is ready, block on crossbeam::select! with a timeout derived from nearest_batch_deadline().shard_loop.step(event), then apply TimerOps from the step (Tokio-backed sleeps that re-inject timer events into the timer channel).flush_expired_batches(wall_clock_duration()) — see Why simulation flushes everything; production batches below.run_jvt_gc).try_recv favors time-driven liveness (proposal / round timers) over inbound work—otherwise a flood of gossip could delay handling a fired timer and stall progress. Risk if mis-tuned: chronic timer traffic could starve lower priorities; the codebase assumes timer volume stays bounded relative to protocol needs.
Read in-repo: flush_expired_batches, flush_all_batches, nearest_batch_deadline, and step caller-protocol docs — study table.
The ShardLoop holds batch accumulators (notably validation and committed-header verification) with time-based deadlines. The two harnesses choose different flush policies after step:
flush_all_batches() flushes all pending batches and related paths every time, ignoring whether a deadline has arrived. The runner is single-threaded and fully owns simulated time. Flushing aggressively makes the state after each step “fully settled,” which simplifies assertions and avoids subtle ordering bugs when run_until jumps the clock—work is not left sitting in a batch whose expiry might fall awkwardly between synthetic events. Throughput of the fake network is not a goal, so paying the cost of an immediate flush every step is cheap and predictable.flush_expired_batches(now) only calls flush_validation_batch / flush_committed_header_verifications when now has passed each batch’s deadline. Reason: real validators see bursty traffic (gossip, RPC); batching until a short deadline amortizes verification work, reduces spikes on the pinned thread and worker pools, and acts as a form of backpressure—the pipeline does not have to react to every packet edge individually. The same file exposes nearest_batch_deadline() so run_shard_loop can set the crossbeam::select! timeout: the loop wakes when a batch must flush even if no new NodeInput arrived, keeping latency bounded without flushing blindly after every event.flush_all_batches() still exists on ShardLoop for paths that need an immediate drain (e.g. shutdown); production’s steady-state path prefers deadline-driven flushes.
run_shard_loop is production architecture, not something that exists only to simulate users. E2E tests such as runner_boots_listens_and_shuts_down_cleanly start and stop the same path briefly; a real validator runs it continuously. RPC or other tasks send work (e.g. SubmitTransaction) into the crossbeam channels this loop reads—they are real inputs, not pretend traffic generated inside the loop.
vs simulation: Deterministic simulation never spawns this thread: a single driver calls ShardLoop::step from SimCluster::run_until with simulated time and an in-memory network. Same step logic, different harness.
For a broader ladder of multitasking / threading terms mapped to hyperscale-rs (processes, async vs blocking, pools, backpressure), see the Basic module State Machines & Event-Driven Architecture (section on threading and concurrency).
runner_boots_listens_and_shuts_down_cleanlySource & story
Test crate: production integration E2Es — path in study table.
This test does not submit a transaction; it proves production wiring: RocksDB, ProductionRunner::build, libp2p listening, run() spawning the pinned loop, then graceful shutdown. The same ShardLoop::step path would process SubmitTransaction if an RPC task (or test) sent it on the consensus channel—see ProductionRunner::tx_submission_sender() on the production runner (study table).
| Step (test) | What it validates → code path |
|---|---|
TestFixtures::new(42, 1) + RocksDbStorage::open |
Deterministic topology/keys for one validator; persistent storage under a temp directory. Production I/O path, unlike SimStorage. Test fixtures module + RocksDB-backed storage crate (study table). Why real RocksDB here: catches compaction, locking, and path bugs that in-memory stores miss; cost is slower, flaky-disk-sensitive tests if not temp-scoped. |
ProductionRunner::builder()...build().await |
Wires Prometheus metrics install, PooledDispatch, NodeStateMachine, SharedStorage, Libp2pAdapter::new, Libp2pNetwork, RadixExecutor, TransactionValidation, and ShardLoop::new(...) with production generic params. Crossbeam senders/receivers are created for timer / callback / consensus / shutdown. ProductionRunnerBuilder::build on the production runner (study table). |
runner.network(), listen addresses |
Confirms a QUIC listen address for the validator libp2p stack (port 0 = OS-assigned)—validator-to-validator transport, not the wallet HTTP listener. Network stack is live; gossip would use gossipsub/QUIC, not simulated delivery queues. |
tokio::spawn(runner.run()) |
ProductionRunner::run: calls maybe_initialize_genesis() on the still-local ShardLoop (engine genesis + genesis block + initialize_genesis + StateCommitComplete—parallel idea to simulation’s genesis, but on RocksDB). Then moves ShardLoop into spawn_shard_loop. Main Tokio task then waits on shutdown + metrics ticks. See maybe_initialize_genesis and run on the production runner (study table). |
|
Pinned thread:
run_shard_loop |
Once at entry: drain initial_timer_ops from genesis setup (maybe_initialize_genesis) through ProdTimerManager — each TimerOp arms a Tokio sleep that later injects timer NodeInput on crossbeam timer_rx. Then loop: shutdown check → set_time(now) → priority try_recv (timer, callback, consensus) → optional blocking crossbeam::select! → shard_loop.step(event) → flush_expired_batches → periodic metrics / channel depth export / optional JVT GC (run_jvt_gc). Production analogue of simulation’s run_until + step; events from real adapters and Tokio timers, not a BTreeMap queue. Implemented in run_shard_loop (production event loop — study table). |
drop(shutdown) + join handle |
Shutdown handle drops → run’s Tokio loop exits → sends on xb_shutdown_tx → pinned loop observes shutdown and exits → test asserts clean completion. Validates lifecycle, not tx finality. |
This integration test is not a full “tx through consensus to finality” production E2E: it does not submit a transaction or assert block heights, QCs, or executed user state (aside from genesis wiring at startup). The pinned loop is real and would handle SubmitTransaction if something sent it on the consensus channel, but proving submit→mempool→BFT→commit→execute with assertions is what the simulation E2E above is for, or a heavier prod/RPC test if the repo adds one next to the existing production tests (study table).
Skipping a full tx here keeps CI fast and failure modes obvious (build/wire/shutdown). Cross-shard / multishard: simulation includes scenarios such as cross_shard_tx_sim alongside the single-shard E2E in the same integration test file (study table). This module does not trace a production multishard integration test; that would mean multiple validators, real networking, and timing—outside the scope of runner_boots_listens_and_shuts_down_cleanly.
Run this test (use one test thread; serial tests)
cargo test -p hyperscale-production --test runner runner_boots_listens_and_shuts_down_cleanly -- --test-threads=1 --nocapture
hyperscale-sim binary vs production metrics (different from these integration tests).cross_shard_tx_sim in the same scenarios target) use the same SimCluster pattern with different configs/assertions — paths in study table.Pass threshold: 70%.
Paths relative to the hyperscale-rs repo root. Use these when following the traces and code snippets above.
| Topic | Path |
|---|---|
| Simulation E2E tests | crates/simulation/tests/scenarios.rs — single_shard_tx_sim, cross_shard_tx_sim, … |
| Simulation runner | crates/simulation/src/runner.rs — SimCluster::new, genesis, schedule_initial_event, run_until, drain_node_io |
| Production E2E tests | crates/production/tests/runner.rs — runner_boots_listens_and_shuts_down_cleanly |
| Production test fixtures | crates/network-libp2p/src/test_utils.rs |
| Production runner | crates/production/src/runner.rs — ProductionRunnerBuilder::build, maybe_initialize_genesis, run, tx_submission_sender |
| Production event loop | crates/production/src/runner.rs — spawn_shard_loop, run_shard_loop |
Node ShardLoop |
crates/node/src/shard/mod.rs — step, SubmitTransaction, validation pipeline, flush_all_batches / flush_expired_batches |
| Node state machine | crates/node/src/state/mod.rs — on_block_committed, protocol event dispatch |
| BFT | crates/shard |
| RocksDB storage | crates/storage-rocksdb (package hyperscale_storage_rocksdb) |