Fix undisposed array-pool list leaks#12034
Conversation
|
Claude finished @batrr's task in 4m 52s —— View job Review: Fix undisposed array-pool list leaks
SummaryThree targeted leak fixes, all logically correct. The FindingsMedium — No regression tests addedAGENTS.md states: "When fixing a bug, always add a regression test." None of the five changed files has a corresponding test addition, despite existing test infrastructure that makes them straightforward to write:
Low — Stale comment in
|
| File | Assessment |
|---|---|
BlockProcessor.cs |
Correct — DisposeAccountChanges() is idempotent; no double-dispose risk; placed before the throw so the exception propagates cleanly. |
MessageDictionary.cs |
Correct — TrySetResult is atomic; if it returns false the caller already owns nothing, so disposing here is safe and complete. |
Eth62ProtocolHandler.cs |
Correct — finally block reliably clears pre-hashes for unprocessed transactions in all paths (cancellation, exception, scheduling failure, normal completion). The isTransferred flag correctly prevents double-disposal when ownership is handed off to a rescheduled task. |
TransactionsMessageSerializer.cs |
Correct — result only contains successfully decoded transactions, so the foreach loop cannot touch an uninitialized object. |
Eth68ProtocolHandler.cs |
Correct — the validation loop runs before base.HandleSlow, so no transactions have been submitted yet, making startIdx-to-end the right cleanup range. |
| if (currentIdx == request.StartIndex) | ||
| { | ||
| // Cancelled before processing any transaction — dispose and bail out. | ||
| // Rescheduling would just loop (cancelled again immediately). |
There was a problem hiding this comment.
Low — stale comment
The comment says "dispose and bail out" but disposal no longer happens here — it happens in the finally block below. The phrase could mislead a reader into thinking the resource is freed at this return site.
| // Rescheduling would just loop (cancelled again immediately). | |
| // Rescheduling would just loop (cancelled again immediately); disposal handled in finally. |
| { | ||
| if (!ValidateSizeAndType(transactionsSpan[i])) | ||
| { | ||
| for (int j = startIdx; j < transactionsSpan.Length; j++) |
There was a problem hiding this comment.
Low — loop range starts at startIdx, but already-processed transactions from prior invocations are skipped
This is a rescheduled-task scenario where startIdx > 0 is possible. Transactions [0, startIdx) were submitted by the prior invocation of HandleSlow, so they are correctly excluded from this cleanup loop. This is fine as-is, but worth a short comment explaining the invariant, since for (int j = startIdx; ...) rather than for (int j = 0; ...) may look surprising:
| for (int j = startIdx; j < transactionsSpan.Length; j++) | |
| for (int j = startIdx; j < transactionsSpan.Length; j++) // [0, startIdx) were already handled in a prior scheduler slot |
| { | ||
| if (!options.ContainsFlag(ProcessingOptions.NoValidation) && !blockValidator.ValidateProcessedBlock(block, receipts, suggestedBlock, out string? error)) | ||
| { | ||
| block.DisposeAccountChanges(); |
Marchhill
left a comment
There was a problem hiding this comment.
possible to add unit tests for this?
|
@claude review pls Added regression tests for the Also double-check the |
|
Claude finished @batrr's task in 4m 56s —— View job Review: Fix undisposed array-pool list leaks (updated)
SummaryTests were added for the two highest-risk paths (MessageDictionary timeout-race and Eth62 cancellation). The AccountChanges leak — confirmed correct (
|
| Step | State |
|---|---|
ProcessBlock(block, ...) |
block.AccountChanges set via SetAccountChanges(block) at line 169 |
| Validation fails | block.DisposeAccountChanges() disposes and nulls block.AccountChanges → throw |
| Validation passes | suggestedBlock.AccountChanges = block.AccountChanges transfers ownership |
suggestedBlock.AccountChangesis never set in the failure path → no leak onsuggestedBlock.DisposeAccountChangesis null-safe (block.AccountChanges?.Dispose()) and idempotent (nulls the field after disposing), so no double-free risk.- The call site is before the
throw, so the exception propagates with the resource already freed. ✓
New test review
MessageDictionaryTests.Test_Send_MessageDisposing_OnOldRequest ✓
Correctly exercises the new branch: TrySetException completes the source, then Handle calls TrySetResult which returns false, triggering data?.TryDispose(). Since TData = IOwnedReadOnlyList<BlockHeader> is not constrained to IDisposable, the TryDispose(this object? item) overload is used, which checks item is IDisposable and calls .Dispose() — caught by NSubstitute.
MessageDictionaryTests.Handle_disposes_tuple_disposable_component_on_unmatched_request ✓
Verifies the tuple overload path in the else branch: TryDispose on (IDisposable, long) iterates the ITuple elements and disposes the IDisposable member.
Eth62ProtocolHandlerTests.Cancelled_mid_processing_releases_transactions_unless_rescheduled ✓
Covers both paths cleanly:
rescheduleSucceeds = false:TryScheduleBackgroundTaskreturnsfalse→isTransferred = false→ finally block clearstxs[1].PreHashand disposes the list.txs[1].Hashis null (preHash cleared,_hashwas already nulled bySetPreHashNoLock).rescheduleSucceeds = true: callback manually clears and disposes (simulating transferred ownership) →isTransferred = true→ finally is a no-op. Same end-state.
The SetPreHashNoLock([(byte)(i+1)]) sets _hash = null, so txs[0].Hash is computed only when the SubmitTx mock accesses it (_ = tx.Hash); txs[1].Hash stays null because its preHash is cleared before the hash is ever accessed. The assertions are consistent with this.
Not tested (acknowledged by author):
BlockProcessor.DisposeAccountChanges— disposal is idempotent and the fix is one-liner; pool-return observability is genuinely hard without a customArrayPoolshim.TransactionsMessageSerializer.DeserializeTxs— similarly hard to assert on pooled memory.
These omissions are acceptable given the reasoning; no Medium finding.
Remaining Low findings (carry-over, unaddressed)
- Stale comment
Eth62ProtocolHandler.cs:265-266— "dispose and bail out" implies disposal happens at thereturnsite; it actually happens in thefinallyblock. See inline suggestion. startIdxloopEth68ProtocolHandler.cs:263—for (int j = startIdx; ...)rather thanfor (int j = 0; ...)can look surprising without a comment. Previous suggestion still stands.
Changes
Fix leaks that were showing in logs as undisposed array-pool lists.
This PR fixes three cleanup cases:
AccountChangeswas not disposed when block processing failed on an invalid block. In this case, ownership is not passed to the normal valid-block flow, so it must be disposed before throwing.MessageDictionarycould receive a response after the request was already cancelled or timed out. WhenTrySetResultfailed, the response data was not used and was not disposed.Transaction decoding can create prehash data backed by
MemoryPool<byte>.Shared. This is normally cleaned up when the transaction is submitted to the txpool. If processing failed before submission, some decoded transactions could keep this memory. Now the prehash is cleared when those transactions are dropped.