Skip to content

fix(store): make node.copy() actually copy - #569

Open
0xrlawrence wants to merge 1 commit into
canopy-network:mainfrom
0xrlawrence:fix/smt-node-copy-no-op
Open

fix(store): make node.copy() actually copy#569
0xrlawrence wants to merge 1 commit into
canopy-network:mainfrom
0xrlawrence:fix/smt-node-copy-no-op

Conversation

@0xrlawrence

Copy link
Copy Markdown

Description

node.copy() was:

func (x *node) copy() *node { return &(*x) }

&(*x) is just x — dereferencing and immediately re-addressing a pointer yields the same pointer. Every caller that asked for a copy got an alias to the original node, so mutating the "copy" mutated the original. staticcheck reports the expression as SA4001.

This is already known to be load-bearing. store/store.go carries a comment saying the SMT node cache "MUST NOT be persisted across blocks", precisely because:

node.copy() is a no-op alias, so the parallel commit mutates cached *node objects in place. Reusing them later can serve stale nodes [...] diverging from the on-disk snapshot.

Fixing the copy removes the reason that workaround exists. This PR leaves the workaround in place — it only makes the primitive honest.

Changes Made

  • copy() now rebuilds the node field by field and returns a distinct pointer.

Rebuilt field by field rather than with *x because lib.Node embeds a protobuf MessageState; a plain struct copy would be a lock copy that go vet rejects. Byte slices and the *key are still shared, which is what the existing "shallow copy" doc comment describes.

Testing

  • Full suite passes: go test ./... -p=1
  • State root over a deterministic 500-write / 167-delete workload is byte-identical before and after:
main                     ROOT=3944f19bc128472bd163bd081b5bc648fd97f535ca823b88f6b5da8548e391a6
fix/smt-node-copy-no-op  ROOT=3944f19bc128472bd163bd081b5bc648fd97f535ca823b88f6b5da8548e391a6

Note for reviewers

This is consensus-critical storage code. The root comparison above and a green suite are good signals, but not proof — you may well want to run it against a real chain replay before merging.

🤖 Generated with Claude Code

node.copy() was 'return &(*x)', which is just x: dereferencing and
immediately re-addressing a pointer yields the same pointer. Every caller
that asked for a copy got an alias to the original node, so mutating the
'copy' mutated the original. staticcheck reports the expression as SA4001.

This is already known to be load-bearing. store.go carries a comment saying
the SMT node cache 'MUST NOT be persisted across blocks' precisely because
'node.copy() is a no-op alias, so the parallel commit mutates cached *node
objects in place', and works around it with a fresh per-block cache. Fixing
the copy removes the reason that workaround exists, though this change
leaves the workaround in place.

The node is rebuilt field by field rather than with '*x', because lib.Node
embeds a protobuf MessageState and a plain struct copy would be a lock copy
that 'go vet' rejects. Byte slices and the *key are still shared, which is
what the existing 'shallow copy' doc comment describes.

Verification: full suite passes ('go test ./... -p=1'), and the state root
over a deterministic 500-write / 167-delete workload is byte-identical
before and after the change:

    main                     ROOT=3944f19bc128472bd163bd081b5bc648fd97f535ca823b88f6b5da8548e391a6
    fix/smt-node-copy-no-op  ROOT=3944f19bc128472bd163bd081b5bc648fd97f535ca823b88f6b5da8548e391a6

Given this is consensus-critical storage code, reviewers may still want to
run it against a real chain replay before merging.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant