fix(dchain): index processing blocks so plugin-transport Accept resolves them

The luxd plugin transport (vm/rpc) resolves Block.Accept by ID via VM.GetBlock —
unlike Block.Verify, which carries the full block bytes (ParseBlock). The dexvm's
GetBlock only indexed ACCEPTED blocks, so GetBlock(builtID) for a block the engine
had just built returned ErrNotFound; the engine's self-finalize Accept
(finalizeOwnProposal) became a silent no-op, the clob_* submitTx waiter
(handler.go) hung forever, and the consensus forwarder wedged inside the hung
Accept so NO further D-Chain blocks were ever built. The EVM survives the same
ID-only Accept because geth resolves built-but-unaccepted blocks from its block
cache; the dexvm had no equivalent.

Add a processingBlocks index (built/verified-but-unaccepted blocks): BuildBlock
and ParseBlock record into it (ParseBlock returns the stable instance so Verify's
stashed overlay is the one Accept commits), GetBlock consults it after
acceptedBlocks, and Accept/Reject prune it. Smallest change that makes the
plugin Accept(ID) round-trip resolve the same instance built/verified — no
transport, engine, or matcher change.

TDD: TestVMAcceptViaGetBlock drives the exact plugin path BuildBlock ->
GetBlock(id) -> Accept (proven to FAIL without the lookup: 'not found, the
production stall'); TestVMParseVerifyAcceptByID covers the follower
ParseBlock -> Verify -> GetBlock(id) -> Accept. Full pkg/dchain suite green
CGO=0. Every prior test accepted the *Block from BuildBlock directly, skipping
the ID-resolve path the live node uses.
This commit is contained in:
2026-06-22 12:28:05 -07:00
parent 57bbf394b8
commit 27de7256d5
3 changed files with 139 additions and 3 deletions
+2
View File
@@ -240,6 +240,7 @@ func (b *Block) Accept(ctx context.Context) error {
b.vm.lastRoot = b.execRoot
b.vm.acceptedBlocks[b.id] = b
b.vm.heightIndex[b.height] = b.id
delete(b.vm.processingBlocks, b.id) // now accepted; drop the processing entry
b.applyToMemBooks()
b.status = statusAccepted
b.overlay = nil
@@ -271,6 +272,7 @@ func (b *Block) Reject(ctx context.Context) error {
b.overlay = nil
}
b.status = statusRejected
delete(b.vm.processingBlocks, b.id) // rejected; drop the processing entry
b.vm.mempool.Requeue(b.txs)
return nil
}
+31 -3
View File
@@ -64,6 +64,16 @@ type VM struct {
lastAcceptedHeight uint64
lastRoot [Size]byte
// processingBlocks indexes built/verified blocks not yet accepted, so a
// plugin-transport Accept — which carries only the block ID and re-resolves
// the block via GetBlock (vm/rpc handleBlockAccept), unlike Verify which
// carries the full bytes — can find the SAME instance (and its stashed Verify
// overlay) to commit. Without it GetBlock(builtID) returns ErrNotFound, the
// engine's self-finalize Accept is a silent no-op, and the submitTx waiter
// hangs forever. The EVM gets this for free from geth's block cache. Pruned on
// Accept/Reject. Guarded by vm.mu.
processingBlocks map[ids.ID]*Block
// preferred is the block the engine asked us to build on next.
preferred ids.ID
@@ -94,6 +104,7 @@ func (vm *VM) Initialize(ctx context.Context, init block.Init) error {
vm.outcomes = newOutcomeRegistry()
vm.books = map[[32]byte]*lx.OrderBook{}
vm.acceptedBlocks = map[ids.ID]*Block{}
vm.processingBlocks = map[ids.ID]*Block{}
vm.heightIndex = map[uint64]ids.ID{}
// Load the head, or bootstrap genesis.
@@ -386,6 +397,9 @@ func (vm *VM) BuildBlock(ctx context.Context) (block.Block, error) {
}
blk := newBlock(vm, vm.lastAcceptedID, height, ts, res.root, txs)
// Index the built block so a later Accept (which the plugin transport resolves
// by ID via GetBlock, not by bytes) finds THIS instance to commit.
vm.processingBlocks[blk.id] = blk
vm.log.Debug("dchain built block", "height", height, "txs", len(txs), "fills", len(res.fills))
return blk, nil
}
@@ -407,28 +421,42 @@ func (vm *VM) blockTimestamp() time.Time {
}
// ParseBlock decodes block bytes into a Block. Used by the engine to reconstruct
// a peer's proposed block; Verify then re-executes it.
// a peer's proposed block; Verify then re-executes it. It returns a STABLE
// instance per id: the accepted block if already accepted, else the processing
// block already indexed (so the overlay Verify stashes on it is the one a later
// Accept commits), else a freshly parsed block which it records as processing.
func (vm *VM) ParseBlock(ctx context.Context, b []byte) (block.Block, error) {
vm.mu.Lock()
defer vm.mu.Unlock()
// Return the cached accepted block if we already have it (stable identity).
blk, err := parseBlock(vm, b)
if err != nil {
return nil, err
}
// Return the cached accepted block if we already have it (stable identity).
if existing, ok := vm.acceptedBlocks[blk.id]; ok {
return existing, nil
}
// Reuse the processing instance if present, so Verify's stashed overlay is the
// same one Accept commits; otherwise record this one as processing.
if existing, ok := vm.processingBlocks[blk.id]; ok {
return existing, nil
}
vm.processingBlocks[blk.id] = blk
return blk, nil
}
// GetBlock returns an accepted block by id, or database.ErrNotFound.
// GetBlock returns a block by id — accepted or still processing (built/verified
// but not yet accepted) — or database.ErrNotFound. The processing lookup is what
// lets the plugin transport's ID-only Accept resolve the block it must commit.
func (vm *VM) GetBlock(ctx context.Context, id ids.ID) (block.Block, error) {
vm.mu.Lock()
defer vm.mu.Unlock()
if blk, ok := vm.acceptedBlocks[id]; ok {
return blk, nil
}
if blk, ok := vm.processingBlocks[id]; ok {
return blk, nil
}
return nil, database.ErrNotFound
}
+106
View File
@@ -523,6 +523,112 @@ func TestVMRestartLegacyGenesisDB(t *testing.T) {
}
}
// TestVMAcceptViaGetBlock reproduces the production consensus stall: the luxd
// plugin transport (vm/rpc) resolves Block.Accept by ID via VM.GetBlock — unlike
// Verify, which carries the full block bytes — and then calls Accept on whatever
// GetBlock returns. Before the processing-block index, GetBlock(builtID) returned
// ErrNotFound (only accepted blocks were indexed), so the engine's self-finalize
// Accept was a silent no-op, the clob_* submitTx waiter hung forever, and no
// further blocks were built. This drives the EXACT plugin-shaped path the live
// node uses — BuildBlock -> GetBlock(id) -> Accept — which every existing test
// skipped by accepting the *Block returned from BuildBlock directly.
func TestVMAcceptViaGetBlock(t *testing.T) {
ctx := context.Background()
db := memdb.New()
pool := [32]byte{0x09}
vm, toEngine := newTestVM(t, db)
// A clob tx in the mempool, then the proposer's lifecycle exactly as the
// engine drives it over the plugin transport.
vm.mempool.Add(ensureMarketTx(t, pool))
vm.mempool.Add(placePoolTx(t, pool, zapwire.SideSell, 101.0, 5.0, "maker-a"))
drainSignals(toEngine)
built, err := vm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
builtID := built.ID()
// THE PLUGIN-TRANSPORT CONTRACT: Verify the built block, then resolve it by ID
// (not by holding the BuildBlock return) and Accept that instance.
if err := built.Verify(ctx); err != nil {
t.Fatalf("Verify: %v", err)
}
got, err := vm.GetBlock(ctx, builtID)
if err != nil {
t.Fatalf("GetBlock(builtID) before accept: %v (the production stall)", err)
}
if got.ID() != builtID {
t.Fatalf("GetBlock returned %s, want %s", got.ID(), builtID)
}
if err := got.Accept(ctx); err != nil {
t.Fatalf("Accept via GetBlock: %v", err)
}
// The head advanced and the accepted block is now resolvable by id; the
// processing entry was pruned.
if vm.lastAcceptedID != builtID {
t.Fatalf("head = %s, want %s", vm.lastAcceptedID, builtID)
}
if _, ok := vm.processingBlocks[builtID]; ok {
t.Fatalf("processing entry not pruned after Accept")
}
if _, err := vm.GetBlock(ctx, builtID); err != nil {
t.Fatalf("GetBlock(head) after accept: %v", err)
}
if len(vm.books[pool].Orders) != 1 {
t.Fatalf("resting orders = %d, want 1", len(vm.books[pool].Orders))
}
}
// TestVMParseVerifyAcceptByID covers the follower path: ParseBlock(bytes) ->
// Verify -> Accept resolved by ID through GetBlock. ParseBlock must return a
// STABLE instance so the overlay Verify stashes is the one Accept commits.
func TestVMParseVerifyAcceptByID(t *testing.T) {
ctx := context.Background()
pool := [32]byte{0x0A}
// Proposer builds the block bytes.
dbP := memdb.New()
vmP, engP := newTestVM(t, dbP)
vmP.mempool.Add(ensureMarketTx(t, pool))
vmP.mempool.Add(placePoolTx(t, pool, zapwire.SideBuy, 99.0, 4.0, "maker-c"))
drainSignals(engP)
prop, err := vmP.BuildBlock(ctx)
if err != nil {
t.Fatalf("proposer BuildBlock: %v", err)
}
raw := prop.Bytes()
propID := prop.ID()
// Follower adopts the bytes the way the engine does: ParseBlock, Verify, then
// resolve by ID and Accept.
dbF := memdb.New()
vmF, _ := newTestVM(t, dbF)
parsed, err := vmF.ParseBlock(ctx, raw)
if err != nil {
t.Fatalf("follower ParseBlock: %v", err)
}
if parsed.ID() != propID {
t.Fatalf("parsed id %s, want %s", parsed.ID(), propID)
}
if err := parsed.Verify(ctx); err != nil {
t.Fatalf("follower Verify: %v", err)
}
got, err := vmF.GetBlock(ctx, propID)
if err != nil {
t.Fatalf("follower GetBlock(id): %v", err)
}
if err := got.Accept(ctx); err != nil {
t.Fatalf("follower Accept via GetBlock: %v", err)
}
if vmF.lastAcceptedID != propID {
t.Fatalf("follower head = %s, want %s", vmF.lastAcceptedID, propID)
}
}
// ensure the VM satisfies the consensus ChainVM interface at compile time.
var _ block.ChainVM = (*VM)(nil)