Skip to content

Commit 95199ae

Browse files
authored
Harden ethreceipts rollback, filter ownership and cancellation (#220)
* fix(ethreceipts): harden rollback, filter ownership and cancellation * fix(ethreceipts): preserve retry candidates and filter identity Keep successful canceled retries tied to their learned block generation, reopen canonical adopted blocks after retention, and support stable custom value filter removal. Document supported limits and add regression coverage. * ci: test Go 1.25 and 1.26 * fix(ethreceipts): retry current candidates after stale RPC receipts * refactor(ethreceipts): pass block processing contexts explicitly * refactor(ethreceipts): restore breaker retries with v0.3.2 * test(ethreceipts): organize regressions by behavior * refactor(ethreceipts): clarify pending receipt retries * fix(ethreceipts): harden subscription cleanup and finality * fix(ethreceipts): preserve fetch filter compatibility * fix(ethmonitor): reject invalid empty log responses
1 parent 77bbef8 commit 95199ae

20 files changed

Lines changed: 4251 additions & 1061 deletions

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717

1818
strategy:
1919
matrix:
20-
go-version: [1.24.x,1.25.x]
20+
go-version: [1.25.x,1.26.x]
2121
os: [ubuntu-latest, macos-latest]
2222

2323
runs-on: ${{ matrix.os }}

‎Makefile‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,14 @@ install:
5656
# Run baseline tests
5757
test: check-testchain-running go-test
5858

59+
# Packages share one testchain and deployment sender, so serialize package binaries.
5960
# Go test short-hand, and skip testing go-ethereum
6061
go-test: test-clean
61-
GOGC=off go test $(TEST_FLAGS) $(MOD_VENDOR) -race -run=$(TEST) `go list ./... | grep -v go-ethereum`
62+
GOGC=off go test $(TEST_FLAGS) $(MOD_VENDOR) -p=1 -race -run=$(TEST) `go list ./... | grep -v go-ethereum`
6263

6364
# Go test short-hand, including go-ethereum
6465
go-test-all: test-clean
65-
GOGC=off go test $(TEST_FLAGS) $(MOD_VENDOR) -run=$(TEST) ./...
66+
GOGC=off go test $(TEST_FLAGS) $(MOD_VENDOR) -p=1 -run=$(TEST) ./...
6667

6768
test-clean:
6869
GOGC=off go clean -testcache

‎ethmonitor/ethmonitor.go‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -869,16 +869,9 @@ func (m *Monitor) filterLogs(ctx context.Context, blockHash common.Hash, topics
869869
if err != nil {
870870
return nil, err
871871
}
872-
if blockBloom != (types.Bloom{}) && (len(logsPayload) == 0 || (len(logsPayload) == 2 && logsPayload[0] == '[' && logsPayload[1] == ']')) {
873-
// If we have no logs and the block bloom is set, then we need to return an error
874-
// as the node is incorrectly telling us the block-logs response is '[]' but in fact
875-
// the block log bloom filter tells us we should be expecting logs. We do this to
876-
// ensure we do not incorrectly cache an empty block-logs response as valid.
877-
return nil, fmt.Errorf("ethmonitor: filterLogs detected empty block-logs response but block bloom is set, ignoring node response")
878-
}
879872
// Validate before caching so a malformed response cannot block log
880873
// backfilling until cache expiry.
881-
fetchedLogs, err = m.unmarshalLogs(logsPayload)
874+
fetchedLogs, err = m.unmarshalLogs(logsPayload, blockBloom)
882875
if err != nil {
883876
return nil, err
884877
}
@@ -901,7 +894,7 @@ func (m *Monitor) filterLogs(ctx context.Context, blockHash common.Hash, topics
901894
if fetchedLogs != nil {
902895
return fetchedLogs, resp, nil
903896
}
904-
logs, err := m.unmarshalLogs(resp)
897+
logs, err := m.unmarshalLogs(resp, blockBloom)
905898
if err != nil {
906899
// Recover entries cached by peers or older monitors that did not
907900
// validate logs before writing them.
@@ -1424,11 +1417,19 @@ func (m *Monitor) unmarshalBlock(blockPayload []byte) (*types.Block, error) {
14241417
return block, nil
14251418
}
14261419

1427-
func (m *Monitor) unmarshalLogs(logsPayload []byte) ([]types.Log, error) {
1420+
func (m *Monitor) unmarshalLogs(logsPayload []byte, blockBloom types.Bloom) ([]types.Log, error) {
14281421
var logs []types.Log
14291422
err := json.Unmarshal(logsPayload, &logs)
14301423
if err != nil {
14311424
return nil, err
14321425
}
1426+
// JSON null decodes without error, but a logs response must be an array.
1427+
if logs == nil {
1428+
return nil, fmt.Errorf("ethmonitor: filterLogs expected a JSON array of block logs")
1429+
}
1430+
// Apply the bloom guard to decoded responses, including cache hits.
1431+
if len(logs) == 0 && blockBloom != (types.Bloom{}) {
1432+
return nil, fmt.Errorf("ethmonitor: filterLogs detected empty block-logs response but block bloom is set, ignoring node response")
1433+
}
14331434
return logs, nil
14341435
}

‎ethmonitor/prefetch_internal_test.go‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func TestPrefetchRejectsInvalidBlockBeforeCaching(t *testing.T) {
4040
}
4141

4242
func TestMonitorRecoversInvalidPrefetchedLogs(t *testing.T) {
43-
for _, payload := range []string{`{}`, `[{}]`} {
43+
for _, payload := range []string{`{}`, `[{}]`, `null`, `[]`, `[ ]`} {
4444
for _, cached := range []bool{false, true} {
4545
t.Run(fmt.Sprintf("payload=%s/cached=%v", payload, cached), func(t *testing.T) {
4646
const first, target, last = uint64(1000), uint64(1002), uint64(1004)
@@ -114,12 +114,12 @@ func TestMonitorRecoversInvalidPrefetchedLogs(t *testing.T) {
114114
require.NoError(t, monitor.cache.SetEx(context.Background(), key, []byte(payload), time.Hour))
115115
} else {
116116
// Complete the speculative fetch before starting the serial loop,
117-
// so the worker deterministically receives the malformed response.
117+
// so the worker deterministically receives the invalid response.
118118
monitor.prefetch.fetch(context.Background(), prefetchJob{num: target})
119119
require.Equal(t, int64(1), originCalls.Load())
120120
_, found, err := monitor.cache.Get(context.Background(), key)
121121
require.NoError(t, err)
122-
require.False(t, found, "failed prefetch must not cache malformed logs")
122+
require.False(t, found, "failed prefetch must not cache invalid logs")
123123
}
124124

125125
sub := monitor.Subscribe("TestMonitorRecoversInvalidPrefetchedLogs")
@@ -157,7 +157,7 @@ func TestMonitorRecoversInvalidPrefetchedLogs(t *testing.T) {
157157
}
158158
}
159159
if !cached {
160-
require.GreaterOrEqual(t, originCalls.Load(), int64(2), "malformed prefetch must allow an origin retry")
160+
require.GreaterOrEqual(t, originCalls.Load(), int64(2), "invalid prefetch must allow an origin retry")
161161
} else {
162162
require.Positive(t, originCalls.Load(), "invalid cache entry must allow an origin retry")
163163
}
@@ -187,7 +187,14 @@ func TestPrefetchPanicStopsWorkers(t *testing.T) {
187187
defer close(done)
188188
monitor.prefetch.run(ctx)
189189
}()
190-
defer func() { cancel(); <-done }()
190+
defer func() {
191+
cancel()
192+
select {
193+
case <-done:
194+
case <-time.After(time.Second):
195+
t.Error("prefetch did not stop after cancellation")
196+
}
197+
}()
191198
select {
192199
case <-done:
193200
case <-time.After(time.Second):

0 commit comments

Comments
 (0)