Skip to content

RPC: Debug* streaming approach - #11693

Merged
svlachakis merged 37 commits into
masterfrom
debug-streaming
May 23, 2026
Merged

RPC: Debug* streaming approach#11693
svlachakis merged 37 commits into
masterfrom
debug-streaming

Conversation

@svlachakis

@svlachakis svlachakis commented May 19, 2026

Copy link
Copy Markdown
Contributor

Fixes Closes Resolves #11718

Overview

Default-tracer struct-log traces are now streamed per-opcode through:

IStreamableResult → Utf8JsonWriter → PipeWriter

instead of being accumulated into a List<GethTxTraceEntry> and serialized at the end.

Peak memory is now O(1) entries regardless of trace length, and time-to-first-byte drops from “after trace completes” to “first opcode boundary”.

Throughput (single trace, single thread)

OpcodeCount Buffered Streaming Δ time Buffered alloc Streaming alloc
1,000 363 µs 245 µs −33% 815 KB 540 KB (−34%)
10,000 3.75 ms 2.67 ms −29% 7.0 MB 4.3 MB (−38%)
100,000 38.3 ms 25.2 ms −34% 61 MB 70 MB (+14% — Utf8JsonWriter output buffer growth)

PeakHeap (16 concurrent traces, discarding sink)

OpcodeCount Buffered time Streaming time Δ time Buffered alloc Streaming alloc Δ alloc
1,000 6.07 ms 4.19 ms −31% 3.95 MB 69 KB −98.2%
10,000 50.6 ms 30.9 ms −39% 38.8 MB 69 KB −99.8%
100,000 503 ms 302 ms −40% 387 MB 69 KB −99.98%

Real-node benchmark

Block: 25,151,364 (0x17fc784) — 59.8 M gas, 498 txs
Output size: 2,092,406,085 bytes (2.09 GB) — identical across modes


Summary

Metric Buffered (avg) Streaming (avg) Win
TTFB 6.575 s 14.7 ms 447×
Total time 15.80 s 9.49 s 40% (−6.30 s)
Output bytes 2,092,406,085 2,092,406,085 identical

Raw runs

Run Buffered TTFB / TOTAL Streaming TTFB / TOTAL
1 6.55 s / 14.74 s 13.0 ms / 9.70 s
2 6.45 s / 16.35 s 14.0 ms / 9.70 s
3 5.48 s / 16.10 s 17.6 ms / 9.62 s
4 7.82 s / 16.00 s 14.3 ms / 8.95 s

Summary

Workload Buffered Streaming Win
TTFB (full trace) 6.6 s 15 ms 447×
Total time (full trace) 15.8 s 9.5 s 40%
16-concurrent peak heap 387 MB 69 KB 99.98%
Output bytes 2.09 GB 2.09 GB identical

Concurrency Benchmark (8 cores VM)

Concurrency Buffered Streaming
1 TTFB 4.53 s, TOTAL 8.74 s ✓ TTFB 3 ms, TOTAL 4.6 s ✓
2 TTFB 3.6 s, TOTAL 10.8 s ✓ TTFB 4 ms, TOTAL 3.4 s ✓
4 TTFB 8.7 s, TOTAL 16.8 s ✓ TTFB 2 ms, TOTAL 4.2 s ✓
8 ALL TIMED OUT (128 B at 20.4 s) ✗ TTFB 5 ms, TOTAL 9.9 s ✓
16 ALL DROPPED (0 B at 5.2 s) ✗✗ (cap = 8: 8 ok, 8 instant rejection)

8 simultaneous ~1000 MB traces = ~7–8 GB of JSON flowing concurrently for ~10 seconds straight, with no OOM, no thread starvation, all returning byte-identical complete responses.

Changes

Scope

Streaming activates only when options.Tracer is null/empty (default struct-log tracer).

Custom tracers still use the existing buffered path:

  • callTracer
  • prestateTracer
  • 4byteTracer
  • JS tracers

Streamed Methods

  • debug_traceTransaction
  • debug_traceCall
  • debug_traceTransactionByBlockAndIndex
  • debug_traceTransactionByBlockhashAndIndex
  • debug_traceTransactionInBlockByHash
  • debug_traceTransactionInBlockByIndex
  • debug_traceBlock
  • debug_traceBlockByNumber
  • debug_traceBlockByHash
  • debug_traceCallMany
    • Simple path only
    • Overrides path remains buffered

Behavior Changes

JSON field order for the default struct-log tracer: streamed responses emit structLogs first, then gas / failed / returnValue. The non-streaming (custom-tracer) path and Geth's reference implementation emit gas / failed / returnValue / structLogs. The order is forced by the streaming design — the array must be opened before EVM execution begins so entries can be emitted per opcode. Clients that parse by field name are unaffected; byte-for-byte comparators against Geth (Hive conformance, vendor dashboards) will see a diff.

Pre-flight failures on debug_traceCall / debug_traceCallMany: previously returned a JSON-RPC error envelope; now return a result envelope with failed: true, error: "tracing failed: …", errorCode: -32000. On-chain trace methods (debug_traceTransaction*, debug_traceBlock*) are unaffected — their txs already passed validation at block-creation time.

Partial structLogs on mid-execution failure: failing traces may now contain the N opcode entries that executed before the failure, instead of an empty array. Inherent to streaming.

Follow-up — error/message architecture

Geth-compat parity on error semantics is scoped as separate work, not bundled here:

  1. Surface TransactionResult validation failures (nonce, intrinsic gas, EIP-3607, etc.) through to the tracer — fixes a pre-existing silent-failure gap that affects buffered too.
  2. Lazy-envelope architecture so pre-flight failures propagate as JSON-RPC error envelope instead of mid-stream failed: true.
  3. Per-scenario error-message mirroring of Geth's exact format strings ("insufficient funds for gas * price + value: address X have N want M", etc.).
  4. Differentiated errorCodes beyond the current InvalidInput / InternalError split.

These touch ITxTracer / TransactionProcessor and exception classes used by sequencer/simulate/MEV adapters.

Types of changes

What types of changes does your code introduce?

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes
  • Other: Description

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 5m 57s —— View job


PR Review: RPC Debug* Streaming Approach

Tasks

  • Gather context and read changed files
  • Review new streaming tracer classes
  • Review GethStyleTracer changes
  • Review DebugRpcModule and DebugBridge changes
  • Review streaming result types
  • Check test coverage
  • Post findings with inline comments

Summary

The architecture is well-designed: GethLikeTxStreamingMemoryTracer correctly overrides AddTraceEntry to serialise per-opcode, GethLikeBlockEnvelopeStreamingTracer wraps each tx in its envelope, and the dual-mode result types (GethLikeTxTraceStreamingSingleResult inheriting GethLikeTxTrace, StreamingResultBase hierarchy) cleanly satisfy the existing return-type contracts while enabling IStreamableResult-aware callers to stream. The cancellation composition (CancellationTokenSource.CreateLinkedTokenSource) is correct and the CTS ownership/lifetime model is sound.

Two High severity issues need addressing before merge, plus several Medium findings.


Findings

High

1. Unclosed JSON array/object on cancellationGethLikeTxTraceStreamingBlockResult.EmitContent (line 55) and StructLogEnvelopeWriter.EmitTraceObject (line 40) both use catch (Exception ex) when (!cancellationToken.IsCancellationRequested). When the token IS cancelled and the trace throws (e.g. OperationCanceledException), the exception propagates without reaching writer.WriteEndArray(). The client receives a truncated JSON fragment (missing ]). HTTP clients that buffer before parsing (e.g. some proxies, test infrastructure) will see a parse error. Fix: wrap with finally { writer.WriteEndArray(); }. Inline comments posted on both sites.

2. debug_traceCall InsufficientBalanceException behavior change — the non-streaming path (line 127) catches InsufficientBalanceException before any response bytes are emitted and returns a proper JSON-RPC error object. On the streaming path the exception happens inside StructLogEnvelopeWriter, after {"structLogs":[ is already written to the wire, so the only possible response is {"structLogs":[],"failed":true,"error":"...","errorCode":-32000}. This is acknowledged in the PR description, but callers checking response.error at the JSON-RPC envelope level (rather than result.failed) will break silently. Investigation needed: if InsufficientBalanceException is always thrown before EVM execution starts, it could be pre-checked and surfaced correctly before committing to the streaming path.

Medium

3. Sync-over-async on EVM threadpipeWriter.FlushAsync(_cancellationToken).GetAwaiter().GetResult() in GethLikeTxStreamingMemoryTracer.FlushToWire (called every 256 opcodes) blocks EVM execution waiting on network I/O. Adversarial inputs (tight loops against slow clients) will stall execution repeatedly. The comment documents this, but the flush interval could be made configurable or defaulted higher (e.g. 4096) to reduce exposure. Same pattern in GethLikeBlockEnvelopeStreamingTracer.FlushPerTxEnvelope and GethLikeTxTraceStreamingBundleResult.FlushBetweenBundles.

4. Count = 0 / empty enumerator contract mismatchGethLikeTxTraceStreamingBlockResult and GethLikeTxTraceStreamingBundleResult implement IReadOnlyCollection<T> with Count = 0 and empty enumerators. Any caller that checks Count or calls ToList() will silently get nothing. The test infrastructure calls the JSON converter fallback (WriteAsJson), which is fine, but this is a subtle footgun. XML <remarks> explaining the invariant would prevent future misuse.

5. No streaming-specific tests — tests verify the final serialised JSON output via RpcTest.TestSerializedRequest, but there are no tests that:

  • assert struct-log entries are absent from the GethLikeTxTrace Entries list (proving O(1) heap)
  • verify streaming delivers bytes before the trace completes (TTFB regression)
  • test mid-stream timeout or cancellation and inspect the resulting fragment

Low

6. WriteRawLong duplicated — identical logic in GethLikeBlockEnvelopeStreamingTracer and StructLogEnvelopeWriter. Extract to a shared internal static helper. Inline comment posted.

7. GetBlockTrace outside try-catch in debug_traceBlockByNumber/debug_traceBlockByHash — pre-existing issue preserved by this PR: an ArgumentNullException thrown by GetBlockTrace itself won't be caught. Inline comment posted.


What's good

  • The StreamingResultBase / GethLikeTxTraceStreamingSingleResult dual-mode design is elegant: the same object works through both the IStreamableResult async path and the JSON-converter synchronous fallback with no duplication of the trace logic.
  • CTS ownership model (created by BuildStreamingResult, transferred to the result wrapper, disposed on completion) is correct.
  • SkipValidation: true on JsonWriterOptions is the right call — the writer is used mid-stream and the state machine would fire false positives otherwise.
  • TraceCallManyWithOverrides correctly isolates the overrides path to a SimulateTxExecutor so streaming doesn't need to handle state/block overrides.
  • Error surfacing (failed:true, error, errorCode) on mid-trace failures is a meaningful improvement over silently truncating the stream.

Comment thread src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/DebugRpcModule.cs Outdated
@svlachakis

Copy link
Copy Markdown
Contributor Author

@claude review again carefully, check possible functionality regressions.

@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 10m 41s —— View job


PR Review: RPC Debug* Streaming Approach — Regression Analysis

Tasks

  • Load rules files
  • Read all changed files carefully
  • Check for functionality regressions in debug_trace* methods
  • Check serialization correctness vs. non-streaming path
  • Check cancellation/timeout behavior
  • Check error propagation paths
  • Post findings

Summary

The core streaming architecture is sound, and several issues from the prior review (unclosed JSON arrays, finally fixes) have already been addressed. This pass focused specifically on functionality regressions — cases where the streaming path produces different observable behavior from the non-streaming path.

4 High findings and 2 Medium findings that need addressing before merge.


High Severity

1. JSON field order changed — breaks Geth-compatible tooling → inline

File: StructLogEnvelopeWriter.cs:33

Non-streaming path (GethLikeTxTraceConverter.Write) emits:

{"gas":…,"failed":…,"returnValue":"0x","structLogs":[…]}

Streaming path emits:

{"structLogs":[],"gas":…,"failed":…,"returnValue":"0x"}

Geth's reference implementation uses the first order. Any conformance tooling, Hive test, or vendor dashboard that compares traces byte-for-byte against Geth will fail. The PR ticks Optimization but not Breaking change — this is a silent format regression. The rewritten tests use field-by-field assertion so they cannot detect a future regression back. The field order is forced by the streaming design (the array must be opened before execution), but the breaking-change checkbox must be ticked and the change documented.

2. Block body not found silently returns [] instead of an error → inline

File: GethStyleTracer.cs:130

When blockTree.FindBlock() returns null (header present but body pruned), the non-streaming path catches the resulting ArgumentNullException in debug_traceBlockByNumber/debug_traceBlockByHash and returns a proper JSON-RPC error. In the streaming path, FindBlock is called inside the deferred _runBlockTrace lambda. The ArgumentNullException is caught by GethLikeTxTraceStreamingBlockResult.EmitContent's exception filter and swallowed — the client gets {"result":[]} (success, empty array) instead of an error. Pruned-node users silently receive a wrong response. Fix: call FindBlock eagerly in TraceBlockStreaming before deferring to the lambda.

3. Mid-block exception leaves inner JSON object unclosed → inline

File: GethLikeTxTraceStreamingBlockResult.cs:50

If the block processor throws after GethLikeBlockEnvelopeStreamingTracer.OnStart (which writes {"result":{"structLogs":[) but before OnEnd closes it, EmitContent's finally only writes ] for the outer array. The inner result object is never closed — the client receives invalid JSON. The single-tx path handles this correctly via StructLogEnvelopeWriter.EmitTraceObject's finally which always closes ] + calls WriteFooter. The block path lacks equivalent per-tx protection.

4. OperationCanceledException from timeout propagates out of WriteToAsync — truncated response instead of timeout error → inline

File: StreamingResultBase.cs:53

When the timeout fires, the catch filter when (!cancellationToken.IsCancellationRequested) evaluates to false and the OperationCanceledException propagates out of WriteToAsync. The HTTP response is already committed so the connection is aborted mid-stream. The non-streaming path returns {"error":{"code":-32017,"message":"Method execution timed out"}}. Clients on the streaming path see only a truncated response — no actionable error. A try/catch in WriteToAsync that swallows OperationCanceledException (and optionally logs it) would restore observable parity with the non-streaming behavior.


Medium Severity

5. Partial struct-log entries committed to wire before error detected → inline

File: GethLikeTxTraceStreamingBundleResult.cs:102

The non-streaming GetBundleTrace catches all tracing exceptions and returns a clean {failed:true, gas:X, returnValue:"0x", structLogs:[]}. The streaming path has already written opcodes 0..N-1 by the time exception at opcode N fires — those cannot be retracted. Clients receive {"structLogs":[…N partial entries…],"failed":true,"error":"…"}. Code that inspects structLogs.length > 0 to determine whether execution was attempted will give a different answer than before. No code fix is possible without buffering; this should be documented as an accepted behavior change.

6. errorCode always -32000 regardless of exception type → inline

File: StructLogEnvelopeWriter.cs:88

ErrorCodes.InvalidInput = -32000 is hardcoded for every streaming failure. Clients that branch on errorCode to distinguish timeouts (-32016), internal errors (-32603), or resource-not-found (-32000) from user errors cannot do so. At minimum consider passing the code explicitly to WriteFooter rather than hardcoding.


Previously flagged and resolved ✓

  • finally blocks now correctly close WriteEndArray() in both GethLikeTxTraceStreamingBlockResult.EmitContent and StructLogEnvelopeWriter.EmitTraceObject — the earlier High finding from the prior review is fixed.
  • CTS ownership/lifetime model is correct.
  • SkipValidation: true on JsonWriterOptions is correct.

Comment thread src/Nethermind/Nethermind.Consensus/Tracing/GethStyleTracer.cs Outdated
Comment thread src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/StreamingResultBase.cs Outdated
@svlachakis svlachakis changed the title RPC: Debug* streaming approach [WIP] - RPC: Debug* streaming approach May 19, 2026
@svlachakis

Copy link
Copy Markdown
Contributor Author

@claude review again.

Follow-up:

Errors and messages need to be architected properly as separate work - surfacing TransactionResult validation failures, lazy-envelope for pre-flight error parity with Geth, per-scenario Geth-format messages, and differentiated errorCodes. Touches ITxTracer / TransactionProcessor and exception classes used by sequencer/simulate/MEV adapters; scoped to its own ticket.

@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 5m 15s —— View job


PR Review: RPC Debug* Streaming Approach — Third Pass

Tasks

  • Read rules files and CLAUDE.md
  • Read all changed files carefully
  • Re-evaluate prior High/Medium findings in light of deferred error-architecture ticket
  • Check for remaining correctness/regression issues
  • Check serialization correctness
  • Check resource management and cancellation
  • Post findings with inline comments

Note: Author has explicitly scoped error/message architecture (errorCode differentiation, TransactionResult surfacing, lazy-envelope parity with Geth, differentiated errorCodes) to a separate follow-up ticket. Those findings from the prior review are not re-raised here.


What has been fixed since the previous passes ✓

  • Finally blocks now correctly close JSON arrays on cancellationStructLogEnvelopeWriter.EmitTraceObject and GethLikeTxTraceStreamingBlockResult.EmitContent both have finally { writer.WriteEndArray(); }.
  • OperationCanceledException is caught in WriteToAsync — Both StreamingResultBase and GethLikeTxTraceStreamingSingleResult catch OperationCanceledException when (combinedToken.IsCancellationRequested) and swallow it, preventing the HTTP layer from seeing an unhandled exception.
  • Block-not-found now returns a proper error for streaming debug_traceBlockByNumber/debug_traceBlockByHashDebugRpcModule now eagerly calls blockFinder.FindBlock(...) before entering the streaming path and returns ResourceNotFound if the block body is missing.
  • Mid-block exception closes the inner envelope correctlyGethLikeBlockEnvelopeStreamingTracer implements IDisposable; TraceBlockWithFactory calls tracer.TryDispose() on exception, which writes the closing tokens for any in-progress tx envelope.
  • ResolveErrorCode differentiates exception typesInsufficientBalanceException / InvalidBlockExceptionInvalidInput; everything else → InternalError.
  • New streaming-specific testsGethLikeTxTraceStreamingSingleResult_WhenCancelledMidTrace_ClosesJsonEnvelope and WhenTraceThrows_WritesErrorAndErrorCode cover the two most important streaming failure modes.

Remaining Findings

High

1. JSON field order is a silent breaking change — PR checkbox not ticked → inline

The streaming path emits:

{"structLogs":[], "gas":…, "failed":…, "returnValue":"0x"}

The non-streaming path (and Geth's reference format) emits:

{"gas":…, "failed":…, "returnValue":"0x", "structLogs":[…]}

The field order is forced by the streaming design (structLogs must be opened before the EVM starts), so it cannot be fixed without buffering. The new test pins the new behavior explicitly. However, the PR type-of-change checklist still does not tick "Breaking change", and the "Changes" section does not document this format shift. Any tooling that compares traces byte-for-byte against Geth (Hive conformance, vendor dashboards) will see failures. At minimum: tick the checkbox and add a sentence in the PR description.

Medium

2. GethLikeBlockEnvelopeStreamingTracer.Dispose() omits txHash in the error-close path → inline

OnEnd (normal path) writes txHash after the result object. Dispose() (exception path) does not. When a mid-block exception fires after OnStart and before OnEnd, the per-tx envelope is {"result":{"structLogs":[],"gas":0,"failed":true,"returnValue":"0x"}} — missing txHash. The JSON is valid but the envelope shape is inconsistent with every other entry in the array. Callers that correlate results by txHash will break.

Fix: store tx?.Hash in OnStart as a field (e.g. _currentTxHash) and write it in Dispose() before the outer WriteEndObject().

3. TraceBlockStreaming(BlockParameter) / IDebugBridge.GetBlockTraceStreaming(BlockParameter) still has the silent-empty-array bug → inline

DebugRpcModule no longer calls this overload for streaming (it resolves to Block first). But the interface method is still public. If called directly, a pruned block returns nullArgumentNullException inside the lambda → caught by the non-cancellation filter → swallowed as []. Future callers would hit this silently.

Simplest fix: add an eager null-guard in GethStyleTracer.TraceBlockStreaming(BlockParameter) to throw before the lambda is deferred.

4. No streaming tests for block-trace error paths

The new tests cover single-tx cancellation and mid-trace throws. Missing:

  • Block trace with a mid-block exception (verifying outer array is closed and txHash is present on all entries including the failed one)
  • Block trace cancellation mid-block
  • Streaming vs. non-streaming produce the same tx-count for a complete trace (regression guard)

Low

5. IDebugBridge.GetBlockTraceStreaming(BlockParameter) is unused from DebugRpcModule

Since the streaming path always resolves to a Block first, the BlockParameter overload is dead code from the RPC module's perspective. Consider removing it from the interface to prevent misuse, or leave it with a prominent <remarks> warning.


Summary

Second pass raised 6 High/Medium issues; 5 are now resolved. One High remains (breaking-change checkbox + documentation for field order). Two Mediums remain (missing txHash in Dispose(), TraceBlockStreaming(BlockParameter) footgun). The deferred error-architecture work is explicitly acknowledged. Inline comments posted on all three outstanding issues.

Recommended before merge:

  1. Tick "Breaking change" and document the structLogs-first field order in the PR body.
  2. Add txHash to GethLikeBlockEnvelopeStreamingTracer.Dispose().
  3. Add null guard in GethStyleTracer.TraceBlockStreaming(BlockParameter).

svlachakis and others added 4 commits May 23, 2026 03:02
- drop per-tx PipeWriter flush in envelope tracer; StreamingResultBase
  already flushes once at end and the inner tracer flushes periodically
  mid-tx, so the envelope-level flush was redundant.
- reuse a single GethLikeTxDirectStreamingTracer across all txs in the
  same block via ResetForNextTx; add a protected ResetTrace() on the
  base class so the cached GethLikeTxTrace is dropped per tx.
- back the storage view with ArrayPoolList<PooledDictionary<UInt256,
  UInt256>>: outer stack and per-frame slot maps both come from
  ArrayPool, Clear() keeps the rented arrays alive across pop/push and
  across txs, Dispose() returns everything to the pool. Eliminates
  per-frame and per-tx Dictionary allocations.
- use DisposableExtensions.DisposeAndNull from #11725 for the
  block-tracer dispose path.
@svlachakis
svlachakis requested a review from rubo as a code owner May 23, 2026 00:28
@svlachakis

Copy link
Copy Markdown
Contributor Author

@claude one last review please

@claude

claude Bot commented May 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 6m 26s —— View job


PR Review: RPC Debug* Streaming — Final Pass

Tasks

  • Load tools
  • Read rules files
  • Read all changed files in current state
  • Verify all prior findings remain resolved
  • Check for new issues
  • Post findings

Status of All Prior Findings

All 17+ High/Medium findings from passes 1–10 remain resolved. No regression from any previously fixed item was found.


Architecture Correctness — Re-verified ✓

  • Single-tx streaming path: StructLogEnvelopeWriter.EmitTraceObject opens the structLogs array, GethLikeTxDirectStreamingTracer writes per-opcode objects into it, FinalizePendingOpcode() is called from BuildResult() before returning, finally closes the array and writes footer. Correct.
  • Block streaming path: GethLikeBlockEnvelopeStreamingTracer.OnStart opens the per-tx envelope + structLogs array, OnEnd calls BuildResult() (which finalizes the last opcode), then closes the array and writes gas/failed/returnValue/txHash. Dispose() covers the mid-tx exception path with the same field set. Correct.
  • Tracer reuse: GethLikeTxDirectStreamingTracer.ResetForNextTx() resets all per-tx state. Pool buffers survive across txs (by design — Dispose() is a no-op, ReleaseResources() is the real cleanup, driven by EndBlockTrace()/GethLikeBlockEnvelopeStreamingTracer.Dispose()). Correct.
  • O(1) block streaming: GethLikeBlockEnvelopeStreamingTracer.AddTrace is a no-op (line 88), so TxTraces is never populated. TraceBlockImpl calls new GethLikeTxTraceCollection(tracer.BuildResult()) on the streaming path — BuildResult() returns empty, result discarded by the Action<...> lambda. Correct.
  • Cancellation: OperationCanceledException passes through StructLogEnvelopeWriter.EmitTraceObject's exception filter, fires the finally (closes structLogs + writes footer), then propagates to StreamingResultBase.WriteToAsync's catch which swallows it. Correct.
  • Bundle path: EmitBundle's finally closes the inner array; EmitContent's finally closes the outer array; OperationCanceledException from FlushBetweenBundles propagates through both and is caught by WriteToAsync. Correct.
  • Block-not-found: Eager null-checks in DebugRpcModule (lines 361-365, 403-407) and GethStyleTracer.TraceBlockImpl (ArgumentNullException.ThrowIfNull(block)) surface errors before any streaming bytes are written. Correct.

New Findings

Low — 1: TraceBlockImpl constructs a discarded GethLikeTxTraceCollection on the streaming path

GethStyleTracer.TraceBlockImpl:233:

return new GethLikeTxTraceCollection(tracer.BuildResult());

When tracer is GethLikeBlockEnvelopeStreamingTracer, BuildResult() returns an empty collection (since AddTrace is a no-op). TraceBlockImpl wraps it in another GethLikeTxTraceCollection, and the entire result is discarded by the Action<...> lambda in DebugRpcModule. Minor wasteful allocation on the streaming path; harmless but could be eliminated with an early return if tracer is the streaming type.

Low — 2: IsAllZero scalar loop — could use SequenceEqual or IndexOfAnyExcept

GethLikeTxDirectStreamingTracer:304-308:

private static bool IsAllZero(ReadOnlySpan<byte> slot)
{
    for (int i = 0; i < slot.Length; i++) if (slot[i] != 0) return false;
    return true;
}

MemoryExtensions.IndexOfAnyExcept(slot, (byte)0) < 0 is equivalent and uses SIMD-accelerated BCL implementations on .NET 8+. Only relevant when EnableMemory = true (non-default). Pre-existing micro-optimization opportunity.

Low — 3 (pre-existing, newly visible): GethLikeTxTraceStreamingResult.WriteToAsync early-exits without closing the array on IsCanceled

Pre-existing non-streaming path (GethLikeTxTraceStreamingResult:52-54):

FlushResult flushResult = await writer.FlushAsync(cancellationToken);
if (flushResult.IsCompleted || flushResult.IsCanceled) return;

When IsCanceled, WriteEndArray() on line 57 is never reached — client receives a truncated array. This is NOT introduced by this PR (the file is unchanged) and the new streaming path avoids this pattern correctly via finally blocks. Calling it out for completeness.


What's Good

The implementation is correct and well-designed across all paths:

  • FormatHexAscii correctness: Verified for all edge cases — all-zeros ("0x0"), single-nibble values ("0x1" through "0xf"), leading-zero stripping, full 32-byte words.
  • OpcodeJsonNames lookup table: (byte)opcode is always 0–255 (safe index), Instruction : byte guarantee, unknown opcodes fall back to hex ordinal.
  • GethLikeTxDirectStreamingTracer constructor: IsTracingMemory = IsTracingFullMemory is set at construction and is option-stable across ResetForNextTx calls — flags don't need resetting.
  • CTS ownership chain: Defensive try { return new ...; } catch { timeoutCts.Dispose(); throw; } in all three Build*Result helpers; disposed via using in Startup.cs after WriteToAsync completes.
  • Geth error parity: FormatErrorDescription covers InsufficientSenderBalance, InsufficientMaxFeePerGasForSenderBalance, SenderHasDeployedCode, NonceOverflow, MinerPremiumNegative, TransactionSizeOverMaxInitCodeSize, BlockGasLimitExceeded — all with prefix-replacing transforms.
  • ResolveErrorCode: InvalidTransactionException : InvalidBlockException hierarchy correctly maps all tx-validation failures to InvalidInput; all others to InternalError. ArgumentNullException.ThrowIfNull guards the null case.
  • FlushBetweenBundles position: Called after the inner bundle finally (array already closed) — a cancellation here produces valid JSON with the partial result, then propagates cleanly to WriteToAsync.

Summary

All High and Medium findings from passes 1–10 are resolved. Three Low nits remain (discarded empty collection on streaming path, IsAllZero SIMD opportunity, pre-existing GethLikeTxTraceStreamingResult truncation gap — none introduced by this PR). The PR is mergeable.
| Branch

LukaszRozmej and others added 2 commits May 23, 2026 08:21
- Skip allocating GethLikeTxTraceCollection on the block-streaming
  path in TraceBlockImpl, where the returned collection is always
  discarded by the streaming caller.
- Replace the scalar IsAllZero loop in
  GethLikeTxDirectStreamingTracer with ReadOnlySpan<byte>.IndexOfAnyExcept,
  which picks the SIMD-accelerated BCL implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…esult

Wrap the per-trace loop in try/finally so WriteEndArray always runs.
Previously, an early return on FlushResult.IsCompleted/IsCanceled (or any
exception from the loop body) left the response as a truncated, invalid
JSON array. Matches the finally-block pattern used by the new direct
streaming envelope writers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@LukaszRozmej

Copy link
Copy Markdown
Member

@claude re-review

@claude

claude Bot commented May 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 4m 23s —— View job


PR Review: RPC Debug* Streaming — Re-review (LukaszRozmej)

Tasks

  • Post initial todo list
  • Read rules files
  • Read all changed files in current state
  • Check for new issues since last review
  • Verify all prior findings remain resolved
  • Post findings

What LukaszRozmej Fixed in This Push

Commit Fix
961dde6 GethLikeTxTraceStreamingResult: wrapped per-trace loop in try/finally so WriteEndArray always runs — even on IsCompleted/IsCanceled early returns or exceptions. Previously the closing ] was silently lost.
442fe7c GethLikeTxDirectStreamingTracer: replaced scalar IsAllZero loop with ReadOnlySpan<byte>.IndexOfAnyExcept, picking up the BCL SIMD path on .NET 8+.
442fe7c TraceBlockImpl: skip new GethLikeTxTraceCollection(...) allocation on the streaming path by returning Array.Empty<GethLikeTxTrace>() when the result is discarded.

Both the Low nits from pass 9/10 (per-trace loop correctness and scalar-SIMD inconsistency) are resolved.


Status of All Prior Findings

All 17+ High/Medium findings from passes 1–10 remain resolved. No prior finding has regressed.


New Finding

Low — GethLikeTxTraceStreamingResult.WriteToAsync does not catch OperationCanceledException

WriteToAsync in GethLikeTxTraceStreamingResult (line 54) awaits writer.FlushAsync(cancellationToken). Depending on the PipeWriter implementation, a cancelled token can cause this call to throw OperationCanceledException rather than return a cancelled FlushResult. That exception propagates unhandled out of WriteToAsync — the finally block still closes the array (WriteEndArray), but the caller (the HTTP middleware) receives the exception rather than a clean completion.

The new direct-streaming path (StreamingResultBase.WriteToAsync, GethLikeTxTraceStreamingSingleResult.WriteToAsync) both catch OperationCanceledException when combinedToken.IsCancellationRequested and log a debug message instead. GethLikeTxTraceStreamingResult does not.

Impact: Low — GethLikeTxTraceStreamingResult is used only when a custom tracer is requested (non-streaming path for debug_traceBlock*), not the default struct-log path. In practice the HTTP framework handles the unhandled exception gracefully. But the inconsistency could show up as an error-level log on cancellation. Fix this →


Architecture Correctness — Re-verified ✓

  • Cancellation path: StructLogEnvelopeWriter.EmitTraceObject.finally closes structLogs array + writes footer; WriteToAsync swallows OperationCanceledException. Client receives structurally valid JSON. ✓
  • Mid-trace throw (single-tx): Same finally path — failed:true + error + differentiated errorCode. ✓
  • Mid-block throw: GethLikeBlockEnvelopeStreamingTracer.Dispose() seals the in-flight per-tx envelope (including txHash from _currentTxHash); sentinel error entry appended to the outer array. ✓
  • Block-not-found: Eager null checks in DebugRpcModule (lines 361-365, 403-407) and GethStyleTracer.TraceBlockImpl (ArgumentNullException.ThrowIfNull) surface errors before any streaming bytes are written. ✓
  • O(1) struct-log entries: GethLikeTxDirectStreamingTracer writes per-opcode JSON directly to Utf8JsonWriter, never accumulating. ✓
  • O(1) block-level objects: GethLikeBlockEnvelopeStreamingTracer.AddTrace is a no-op (line 88); per-tx GethLikeTxTrace objects discarded immediately after OnEnd writes the envelope. ✓
  • Tracer reuse: ResetForNextTx correctly resets all per-tx state; ReleaseResources() is idempotent via _disposed guard. ✓
  • CTS ownership chain: Defensive try { return new ...; } catch { cts.Dispose(); throw; } in all three Build*Result helpers; disposed via using in Startup.cs after WriteToAsync completes. ✓
  • Buffer lifetime: ReturnPooledBuffers in ReleaseResources() is guarded by _disposed; both EndBlockTrace and Dispose() call it safely. ✓
  • GethLikeTxTraceStreamingResult array closure: try/finally in WriteToAsync now guarantees WriteEndArray runs on all paths. ✓

Summary

All High and Medium findings from all ten prior passes are resolved. LukaszRozmej's two additional commits (961dde6, 442fe7c) address the last remaining Low nits. One new Low finding: GethLikeTxTraceStreamingResult.WriteToAsync (custom-tracer path) doesn't catch OperationCanceledException unlike the new streaming counterparts. Not a blocker.

The PR is mergeable.
| Branch

LukaszRozmej and others added 4 commits May 23, 2026 09:32
Match the pattern used by StreamingResultBase.WriteToAsync and
GethLikeTxTraceStreamingSingleResult.WriteToAsync: catch
OperationCanceledException when the caller's token is cancelled and
log at Debug instead of propagating to the HTTP middleware. The inner
try/finally still closes the JSON array, so the partial body remains
structurally valid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…param

Revert the ILogger constructor parameter added in ab855e0 — the
outer try/catch in Startup.WriteStreamableResponseAsync already handles
OperationCanceledException for all IStreamableResult implementations.
Keep the inner try/finally that closes the JSON array so the partial
body remains structurally valid before the OCE bubbles up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@svlachakis
svlachakis merged commit ffb5b67 into master May 23, 2026
1034 of 1050 checks passed
@svlachakis
svlachakis deleted the debug-streaming branch May 23, 2026 10:59
@benaadams benaadams mentioned this pull request May 23, 2026
16 tasks
@claude claude Bot mentioned this pull request May 24, 2026
15 tasks
LukaszRozmej added a commit that referenced this pull request May 24, 2026
High-priority correctness:
- StreamingParityLikeTxTracer.OnActionPopped now nulls _trace.Action when
  the root pops; previously ResetForNextTx/Dispose would walk and re-pool
  the already-pooled root, handing the same ParityTraceAction out twice on
  later RentAction calls and silently corrupting traces.
- StreamingParityLikeBlockTracer.OnStart disposes the previous reusable
  tx tracer before replacing it on a per-tx trace-type mismatch (callMany);
  previously the prior tracer's rented ArrayPool buffers leaked.

Cross-PR code reuse with #11693:
- Moved StreamingResultBase from Nethermind.JsonRpc.Modules.DebugModule to
  Nethermind.JsonRpc so both debug_trace* and trace_* streaming results can
  share the linked-cancellation + Utf8JsonWriter scaffolding.
- ParityTxTraceStreamingResult<T> now inherits StreamingResultBase. The
  outer JSON array / catch(OperationCanceledException) lifecycle lives in
  the base; subclasses only supply EmitContent.

Other:
- CappedArrayConverter now routes CappedArray<byte> to a dedicated
  CappedArrayByteConverter that emits the Ethereum "0x..." hex string, so a
  future caller that serialises a CappedArray<byte> through JsonSerializer
  gets the same wire shape as byte[] (the array-of-numbers fallback is kept
  only for non-byte element types).
- StreamingVmFrame.Code is now PooledByteBuffer; ReportByteCode rents from
  ArrayPool and ReturnFrame eagerly disposes so per-call-frame bytecode no
  longer allocates a fresh heap array.
- OnStart performs ThrowIfCancellationRequested unconditionally (was only
  inside the Replay-mode branch).
- _cachedActionFilter caches the StoreItemPredicate->Func wrapper on the
  block tracer instead of allocating one per OnStart.
- _fillVmTraceSlot / _streamActionsInline / _actionFilter fields hoisted to
  the top of the file alongside the other readonly state.
- IncludedSubtraceCount xmldoc now spells out the divergence from
  Subtraces.Count under inline streaming mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LukaszRozmej added a commit that referenced this pull request May 24, 2026
…ess materialisation

- Added Hash256-overload constructor on StreamingParityLikeBlockTracer that
  passes the tx-hash filter through to ParityLikeBlockTracer's ShouldTraceTx
  hook so trace_replayTransaction / trace_transaction can stream a single
  envelope from inside a multi-tx block.
- Added ParityTxTraceFromReplayStreamingResult, the single-envelope analogue
  of ParityTxTraceStreamingResult<T> (mirrors GethLikeTxTraceStreamingSingleResult
  from #11693): inherits ParityTxTraceFromReplay so ResultWrapper<...> stays
  typed, implements IStreamableResult for the HTTP path, lazily materialises
  the inherited properties from an optional buffered factory so in-process
  consumers reading .Data.Action still work without changing every test.
- Made ParityTxTraceFromReplay's properties virtual so the streaming variant
  can override them with lazy materialisation.
- trace_replayTransaction now uses the new streaming envelope; trace_transaction
  uses ParityTxTraceStreamingResult<ParityTxTraceFromStore> via the existing
  Store-mode infrastructure.

- trace_call / trace_rawTransaction stay buffered: state-override scope is
  tied to the RPC method's lifetime and disposes when the method returns;
  deferring the trace to serialise-time throws MissingTrieNodeException on
  the now-invalid overlay. Multi-tx streaming already covers the heap savings
  and single-tx working sets are tiny.

Tests:
- Context.Build(... bool enableStreaming = true) plumbs the flag via
  WithConfig + DI singleton registration of IJsonRpcConfig.
- Representative tests for the newly-streaming methods are parameterised
  with [TestCase(true)] [TestCase(false)] so both paths get coverage:
  Trace_replayTransaction_test, Trace_replayTransaction_reward_test,
  Trace_callMany_is_blockParameter_optional_test, Trace_callMany_accumulates_state_changes.
- AssertJsonEquivalent helper (JToken.DeepEquals) replaces literal string
  equality where the streaming path emits vmTrace first and the buffered
  path emits it last; one expected JSON works for both modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@svlachakis svlachakis mentioned this pull request May 25, 2026
16 tasks
LukaszRozmej added a commit to LukaszRozmej/membership-1 that referenced this pull request Jul 8, 2026
Name / Identifier
Stavros Vlachakis

Team / Project
Nethermind

Start date of relevant projects
July 2025 (part-time)
February 2026 (full-time)

Proposed weight
Full (1.0)

Summary of work / eligibility

Stavros joined Nethermind in March 2025. Since July 2025, he has contributed part-time to the Nethermind Ethereum Execution Client (Core team) alongside other responsibilities. Since February 2026, he has worked full-time on Nethermind Client, with 100+ merged PRs in total. He owns the JSON-RPC for Nethermind Client. He is also expected to contribute extensively to Frame Transactions on Hegota.

Representative work:

- EIP-4444 history expiry (EraE): implemented the EraE archive format end-to-end — era export/import and remote download with SHA-256 verification. (#10812 (NethermindEth/nethermind#10812))
- JSON-RPC (owner): broad spec-compliance and Geth-parity work plus new endpoints — e.g. Geth-compatible error codes (#11335 (NethermindEth/nethermind#11335)) and eth_signTransaction / raw-transaction methods (#11517 (NethermindEth/nethermind#11517), #11521 (NethermindEth/nethermind#11521)).
- Streaming for large RPC responses: streaming approach for trace_* and debug_* results, avoiding buffering huge responses in memory. (#11755 (NethermindEth/nethermind#11755), #11693 (NethermindEth/nethermind#11693)).
- EVM & execution performance: eth_call interpreter/dispatch optimizations (#11965 (NethermindEth/nethermind#11965)), EVM memory pooling and SLOAD / flat-state read caching (#11991 (NethermindEth/nethermind#11991), #12043 (NethermindEth/nethermind#12043)).
- State & consensus reliability: correct persisted-code tracking in the code DB (#11714 (NethermindEth/nethermind#11714)), forkchoice canonical-chain corruption healing after beacon sync (#10876 (NethermindEth/nethermind#10876)).

All merged PRs: https://github.com/NethermindEth/nethermind/pulls?q=is%3Apr+author%3Asvlachakis+is%3Aclosed (edited)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Streaming on debug* endpoints

4 participants