the pool decides whether an empty build is idleness or a halt

An assembled block with no transactions is two conditions wearing one shape. No
demand is the demand-driven property working — discard it, or an idle chain grows
forever. Demand the assembler failed to collect is a bug, and discarding THAT
makes it permanent: no block means no nonce advances, so the pending transaction
is never mined, so the pool never drains, so the condition under which a block
would be built can never return.

The gate read the assembled block, which cannot tell the two apart, so it took
the second for the first. Measured on lux-devnet: the chain stopped at 20:38 UTC
on 2026-08-11 holding ONE executable transaction, sat 41 hours, and behind it the
traffic generator logged 7,100 consecutive failures while luxd-0 and luxd-3
logged errEmptyBlock ~2000 times per six hours. Every node was healthy. Nothing
was wrong with consensus — CERTs gossiped the whole time.

It reads the pool now. Nothing pending is idleness and the block is discarded;
anything pending builds, whatever assembly managed to collect, and logs the
mismatch where somebody can see it. Liveness stops depending on the assembly path
being correct.

The rule moves into proposeEmptyBuild so it can be tested without standing up a
VM, which is why it had no test before.

This does not fix the proximate cause, and is not meant to: the transaction was
in exactly one node's pool (luxd-2) and never reached either proposer, so EVM
transaction gossip is the next thing to look at. What changes here is that such a
failure degrades throughput instead of halting the chain.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-13 09:39:50 -07:00
co-authored by Hanzo Dev
parent 12218a74b6
commit 89bbe023f4
2 changed files with 96 additions and 2 deletions
+51
View File
@@ -0,0 +1,51 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package evm
import "testing"
// An empty assembled block is TWO conditions wearing one shape, and the whole point of
// this rule is to tell them apart.
//
// Discarding an empty build is correct when nobody asked for anything — that is the
// demand-driven property, and without it an idle chain grows forever. It is a halt when
// the pool HAS work: no block means no nonce advances, so the pending transaction is
// never mined, so the pool never drains, so the condition that would allow a block can
// never return. The chain is then dead with a queue in front of it and every proposer
// logging errEmptyBlock, which is exactly how lux-devnet spent 41 hours on 2026-08-11.
//
// The gate used to read the assembled block, which cannot distinguish the two. It reads
// the pool now, which can.
func TestProposeEmptyBuild(t *testing.T) {
for _, tc := range []struct {
name string
pending int
automining bool
want bool
}{
{"idle chain: nothing pending, nothing to build", 0, false, false},
{"one executable transaction the assembler missed", 1, false, true},
{"a queue of them", 7100, false, true},
{"automining builds regardless — that is what it is for", 0, true, true},
{"automining with demand", 3, true, true},
} {
t.Run(tc.name, func(t *testing.T) {
if got := proposeEmptyBuild(tc.pending, tc.automining); got != tc.want {
t.Fatalf("proposeEmptyBuild(pending=%d, automining=%v) = %v, want %v",
tc.pending, tc.automining, got, tc.want)
}
})
}
}
// The regression, stated as the property that was violated: a chain holding work must
// never refuse to build. Any pending count above zero has to propose, or the deadlock
// above is reachable again.
func TestPendingWorkAlwaysBuilds(t *testing.T) {
for _, pending := range []int{1, 2, 10, 1_000, 7_100} {
if !proposeEmptyBuild(pending, false) {
t.Fatalf("refused to build with %d transactions pending — that is the 41-hour halt", pending)
}
}
}
+45 -2
View File
@@ -1662,6 +1662,27 @@ func (vm *VM) BuildBlockWithContext(ctx context.Context, proposerVMBlockCtx *nod
return vm.buildBlockWithContext(ctx, consensusCtx)
}
// proposeEmptyBuild reports whether a just-assembled block carrying NO transactions
// should still be proposed.
//
// The C-Chain is demand-driven: a proposer does not manufacture blocks nobody asked
// for. But "the assembled block is empty" answers a different question than "there is
// no demand", and treating them as one is what halts a chain. If the pool holds
// executable transactions and assembly still produced none, that is an assembly bug —
// and refusing to build makes it PERMANENT, because no block means no nonce advances,
// so the pending transaction is never mined, so the pool never drains, so nothing can
// ever restore the condition under which a block would be built.
//
// Measured on lux-devnet 2026-08-11: the chain stopped for 41 hours holding ONE
// executable transaction, with two proposers logging errEmptyBlock ~2000x per six
// hours and 7,100 consecutive traffic-generator failures queued behind it.
//
// So the pool decides. Nothing pending is genuine idleness and the block is discarded;
// anything pending means the chain must advance, whatever assembly managed to collect.
func proposeEmptyBuild(poolPending int, automining bool) bool {
return automining || poolPending > 0
}
// buildBlock builds a block to be wrapped by ChainState
func (vm *VM) buildBlock(ctx context.Context) (nodeblock.Block, error) {
return vm.buildBlockWithContext(ctx, nil)
@@ -1693,8 +1714,30 @@ func (vm *VM) buildBlockWithContext(ctx context.Context, proposerVMBlockCtx *nod
// only on real demand. The check lives ONLY on this build path so a consensus-FINALIZED empty
// block still Accepts (finality overrides anti-spam — see block_verification.go). Fail before
// the verify+state work below to avoid touching the triedb for a block we will not propose.
if len(block.Transactions()) == 0 && !vm.config.EnableAutomining {
return nil, errEmptyBlock
//
// ASK THE POOL, NOT THE BLOCK. An empty assembled block means one of two very
// different things and refusing both is what halts a chain: either there is no
// demand — correct to refuse, that is the whole policy — or there IS demand this
// proposer failed to assemble. The second is a bug, and declining to build makes it
// PERMANENT: no block, so no nonce advances, so the pending transaction is never
// mined, so the pool never drains — nothing can restore the condition that would let
// a block be built. Measured on lux-devnet 2026-08-11: the chain stopped for 41h
// holding ONE executable transaction, two proposers logging errEmptyBlock ~2000x per
// six hours, and 7,100 consecutive traffic-generator failures queued behind it.
//
// So refuse only when the POOL agrees there is nothing to do. If the pool holds
// executable transactions and assembly still produced none, build: the chain
// advances, the mismatch is logged where somebody can see it, and liveness stops
// depending on the assembly path being correct.
if len(block.Transactions()) == 0 {
pending, _ := vm.txPool.Stats()
if !proposeEmptyBuild(pending, vm.config.EnableAutomining) {
return nil, errEmptyBlock
}
if pending > 0 {
log.Warn("assembled an empty block while the pool holds executable transactions - building anyway to keep the chain live",
"pending", pending, "height", blk.Height())
}
}
// Verify is called on a non-wrapped block here, such that this