diff --git a/eth/catalyst/api_benchmark_test.go b/eth/catalyst/api_benchmark_test.go
index 6d6ad59f7f98..0d3fc1d29bc2 100644
--- a/eth/catalyst/api_benchmark_test.go
+++ b/eth/catalyst/api_benchmark_test.go
@@ -23,12 +23,15 @@ import (
"fmt"
"io"
"math/big"
+ "net/http"
+ "net/http/httptest"
"strings"
"testing"
"time"
"github.com/ethereum/go-ethereum/beacon/engine"
"github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
@@ -736,3 +739,159 @@ func BenchmarkGetBlobsV3RPCServerOnly(b *testing.B) {
b.StopTimer()
b.ReportMetric(float64(b.Elapsed().Milliseconds())/float64(b.N), "ms/op")
}
+
+// benchTxDataSizes is a spread of calldata sizes approximating the mix in a
+// mainnet block, where cheap transfers sit alongside large contract calls.
+// Cycling these gives a mean transaction of roughly 700 bytes, which is what
+// mainnet blocks of 30 to 40 million gas have been carrying.
+var benchTxDataSizes = []int{0, 68, 132, 356, 900, 2500}
+
+// makeBenchNewPayload builds a payload holding numTx transactions, along with
+// the other arguments engine_newPayloadV4 takes.
+func makeBenchNewPayload(b *testing.B, numTx int) (engine.ExecutableData, []common.Hash, *common.Hash) {
+ config := params.MergedTestChainConfig
+ signer := types.LatestSigner(config)
+
+ txs := make([][]byte, numTx)
+ for i := range txs {
+ size := benchTxDataSizes[i%len(benchTxDataSizes)]
+ data := make([]byte, size)
+ for j := range data {
+ data[j] = byte(i + j)
+ }
+ tx := types.MustSignNewTx(testKey, signer, &types.DynamicFeeTx{
+ ChainID: config.ChainID,
+ Nonce: uint64(i),
+ GasTipCap: big.NewInt(params.GWei),
+ GasFeeCap: big.NewInt(10 * params.GWei),
+ Gas: 21000 + uint64(size)*16,
+ To: &testAddr,
+ Value: big.NewInt(1),
+ Data: data,
+ })
+ enc, err := tx.MarshalBinary()
+ if err != nil {
+ b.Fatalf("encoding transaction %d failed: %v", i, err)
+ }
+ txs[i] = enc
+ }
+
+ withdrawals := make([]*types.Withdrawal, 16)
+ for i := range withdrawals {
+ withdrawals[i] = &types.Withdrawal{
+ Index: uint64(i),
+ Validator: uint64(i),
+ Address: testAddr,
+ Amount: 1e9,
+ }
+ }
+
+ beaconRoot := common.Hash{0x42}
+ data := engine.ExecutableData{
+ ParentHash: common.Hash{0x01},
+ FeeRecipient: testAddr,
+ StateRoot: common.Hash{0x02},
+ ReceiptsRoot: common.Hash{0x03},
+ LogsBloom: make([]byte, 256),
+ Random: common.Hash{0x04},
+ Number: 1,
+ GasLimit: 60_000_000,
+ GasUsed: 30_000_000,
+ Timestamp: 1700000000,
+ ExtraData: []byte("benchmark"),
+ BaseFeePerGas: big.NewInt(params.GWei),
+ Transactions: txs,
+ Withdrawals: withdrawals,
+ BlobGasUsed: new(uint64),
+ ExcessBlobGas: new(uint64),
+ }
+ vhashes := []common.Hash{}
+ block, err := engine.ExecutableDataToBlockNoHash(data, vhashes, &beaconRoot, [][]byte{})
+ if err != nil {
+ b.Fatalf("assembling the payload failed: %v", err)
+ }
+ data.BlockHash = block.Hash()
+ return data, vhashes, &beaconRoot
+}
+
+// makeBenchNewPayloadRequest serializes a payload the way a consensus client
+// would send it.
+func makeBenchNewPayloadRequest(b *testing.B, numTx int) []byte {
+ data, vhashes, beaconRoot := makeBenchNewPayload(b, numTx)
+ payload, err := json.Marshal(data)
+ if err != nil {
+ b.Fatalf("marshaling the payload failed: %v", err)
+ }
+ hashes, err := json.Marshal(vhashes)
+ if err != nil {
+ b.Fatalf("marshaling the versioned hashes failed: %v", err)
+ }
+ root, err := json.Marshal(beaconRoot)
+ if err != nil {
+ b.Fatalf("marshaling the beacon root failed: %v", err)
+ }
+ return []byte(fmt.Sprintf(
+ `{"jsonrpc":"2.0","id":1,"method":"engine_newPayloadV4","params":[%s,%s,%s,[]]}`,
+ payload, hashes, root))
+}
+
+// newPayloadDecodeStub answers engine_newPayloadV4 with no chain behind it. With
+// toBlock set it also assembles the block.
+type newPayloadDecodeStub struct {
+ toBlock bool
+ err error
+}
+
+func (s *newPayloadDecodeStub) NewPayloadV4(ctx context.Context, params engine.ExecutableData, versionedHashes []common.Hash, beaconRoot *common.Hash, executionRequests []hexutil.Bytes) (engine.PayloadStatusV1, error) {
+ if s.toBlock {
+ requests := make([][]byte, len(executionRequests))
+ for i, r := range executionRequests {
+ requests[i] = r
+ }
+ if _, err := engine.ExecutableDataToBlock(params, versionedHashes, beaconRoot, requests); err != nil {
+ s.err = err
+ }
+ }
+ return engine.PayloadStatusV1{Status: engine.VALID}, nil
+}
+
+// BenchmarkNewPayloadDecode measures what an engine_newPayloadV4 request costs
+// the server before the block reaches the chain. It arrives over HTTP, as it does
+// from a consensus client. The decode variant stops once the arguments are
+// decoded, decode+block also assembles and hash checks the block.
+func BenchmarkNewPayloadDecode(b *testing.B) {
+ for _, numTx := range []int{64, 192, 384} {
+ req := makeBenchNewPayloadRequest(b, numTx)
+ for _, variant := range []struct {
+ label string
+ toBlock bool
+ }{{"decode", false}, {"decode+block", true}} {
+ b.Run(fmt.Sprintf("txs=%d/kb=%d/%s", numTx, len(req)/1024, variant.label), func(b *testing.B) {
+ stub := &newPayloadDecodeStub{toBlock: variant.toBlock}
+ srv := rpc.NewServer()
+ if err := srv.RegisterName("engine", stub); err != nil {
+ b.Fatalf("registering the stub failed: %v", err)
+ }
+ defer srv.Stop()
+
+ body := string(req)
+ b.ReportAllocs()
+ b.SetBytes(int64(len(req)))
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
+ r.Header.Set("content-type", "application/json")
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ b.Fatalf("status %d: %s", w.Code, w.Body.String())
+ }
+ }
+ b.StopTimer()
+ if stub.err != nil {
+ b.Fatalf("assembling the block failed: %v", stub.err)
+ }
+ })
+ }
+ }
+}
diff --git a/rpc/http.go b/rpc/http.go
index 6340175736db..8f4f1bc92ebb 100644
--- a/rpc/http.go
+++ b/rpc/http.go
@@ -284,10 +284,45 @@ func (s *Server) newHTTPServerConn(r *http.Request, w http.ResponseWriter) Serve
return httpWrite(ctx, w, buf, isError)
}
- dec := json.NewDecoder(conn)
- dec.UseNumber()
+ // The body holds one message, so it can be read in one go and checked once.
+ readFrame := func() ([]byte, error) {
+ hint := 0
+ if r.ContentLength > 0 && r.ContentLength <= int64(s.httpBodyLimit) {
+ hint = int(r.ContentLength)
+ }
+ frame, err := readAllBody(body, hint)
+ if err != nil {
+ return nil, err
+ }
+ if len(bytes.TrimSpace(frame)) == 0 {
+ // An empty body carries no message, which is not an error. The
+ // decoder used to report this as EOF and callers rely on that.
+ return nil, io.EOF
+ }
+ return frame, nil
+ }
+ return newFuncCodec(conn, encodeMsg, encodeBatch, nil, readFrame)
+}
- return NewFuncCodec(conn, encodeMsg, encodeBatch, dec.Decode)
+// readAllBody reads r to the end, sizing the buffer from the hint when there is
+// one so that a large body does not have to be grown into.
+func readAllBody(r io.Reader, hint int) ([]byte, error) {
+ // A byte past the hint, so a body of exactly hint bytes sees EOF without
+ // the buffer doubling right at the end.
+ buf := make([]byte, 0, max(hint+1, 512))
+ for {
+ if len(buf) == cap(buf) {
+ buf = append(buf, 0)[:len(buf)]
+ }
+ n, err := r.Read(buf[len(buf):cap(buf)])
+ buf = buf[:len(buf)+n]
+ if err != nil {
+ if err == io.EOF {
+ return buf, nil
+ }
+ return buf, err
+ }
+ }
}
// httpWrite writes pre-encoded response data over HTTP.
diff --git a/rpc/http_test.go b/rpc/http_test.go
index 15ddd59bd0a9..2cee4abf544b 100644
--- a/rpc/http_test.go
+++ b/rpc/http_test.go
@@ -17,8 +17,11 @@
package rpc
import (
+ "bytes"
"context"
+ "encoding/json"
"fmt"
+ "io"
"net/http"
"net/http/httptest"
"strings"
@@ -263,3 +266,202 @@ func TestNewContextWithHeaders(t *testing.T) {
t.Error("call failed:", err)
}
}
+
+// TestHTTPRequestFraming covers what the server answers for the shapes of body
+// that reach it, including the ones that are not valid JSON.
+func TestHTTPRequestFraming(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ want string // substring the response must contain, empty means no response
+ }{
+ {
+ name: "call",
+ body: `{"jsonrpc":"2.0","id":1,"method":"test_echo","params":["x",3]}`,
+ want: `"result"`,
+ },
+ {
+ name: "call with surrounding space",
+ body: " \n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"test_echo\",\"params\":[\"x\",3]}\n ",
+ want: `"result"`,
+ },
+ {
+ name: "batch",
+ body: `[{"jsonrpc":"2.0","id":1,"method":"test_echo","params":["x",3]}]`,
+ want: `"result"`,
+ },
+ {
+ name: "empty body",
+ body: ``,
+ want: ``,
+ },
+ {
+ name: "whitespace only",
+ body: " \n\t ",
+ want: ``,
+ },
+ {
+ name: "truncated object",
+ body: `{"jsonrpc":"2.0","id":1,"method":"test_echo"`,
+ want: `parse error`,
+ },
+ {
+ name: "not json",
+ body: `hello`,
+ want: `parse error`,
+ },
+ {
+ name: "unbalanced bracket",
+ body: `[{"jsonrpc":"2.0","id":1,"method":"test_echo","params":["x",3]}`,
+ want: `parse error`,
+ },
+ {
+ name: "control character in string",
+ body: "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"test_\x01echo\",\"params\":[]}",
+ want: `parse error`,
+ },
+ {
+ // A body holding more than one value is rejected. The decoder this
+ // replaced stopped after the first value and ignored the rest.
+ name: "trailing second value",
+ body: `{"jsonrpc":"2.0","id":1,"method":"test_echo","params":["x",3]}{"a":1}`,
+ want: `parse error`,
+ },
+ {
+ name: "trailing garbage",
+ body: `{"jsonrpc":"2.0","id":1,"method":"test_echo","params":["x",3]} oops`,
+ want: `parse error`,
+ },
+ }
+
+ srv := newTestServer()
+ defer srv.Stop()
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tc.body))
+ req.Header.Set("content-type", "application/json")
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, req)
+
+ body := rec.Body.String()
+ if tc.want == "" {
+ if strings.TrimSpace(body) != "" {
+ t.Fatalf("want no response, got %q", body)
+ }
+ return
+ }
+ if !strings.Contains(body, tc.want) {
+ t.Fatalf("want response containing %q, got %q", tc.want, body)
+ }
+ })
+ }
+}
+
+// TestHTTPRequestFramingChunked checks a body with no content length, which is
+// the case the size hint cannot help with.
+func TestHTTPRequestFramingChunked(t *testing.T) {
+ srv := newTestServer()
+ defer srv.Stop()
+
+ body := `{"jsonrpc":"2.0","id":1,"method":"test_echo","params":["x",3]}`
+ req := httptest.NewRequest(http.MethodPost, "/", io.NopCloser(strings.NewReader(body)))
+ req.Header.Set("content-type", "application/json")
+ req.ContentLength = -1
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, req)
+
+ if got := rec.Body.String(); !strings.Contains(got, `"result"`) {
+ t.Fatalf("want a result, got %q", got)
+ }
+}
+
+// TestReadAllBody checks the body reader against io.ReadAll for both a helpful
+// and an unhelpful size hint.
+func TestReadAllBody(t *testing.T) {
+ for _, size := range []int{0, 1, 511, 512, 513, 4096, 100000} {
+ want := bytes.Repeat([]byte("ab"), size/2)
+ for _, hint := range []int{0, 1, size, size + 1, size * 2} {
+ got, err := readAllBody(bytes.NewReader(want), hint)
+ if err != nil {
+ t.Fatalf("size %d hint %d: %v", size, hint, err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Fatalf("size %d hint %d: got %d bytes, want %d", size, hint, len(got), len(want))
+ }
+ // A hint covering the body must be enough, reading to EOF must
+ // not grow the buffer past it.
+ if hint >= len(want) && cap(got) > max(hint+1, 512) {
+ t.Fatalf("size %d hint %d: buffer grew to %d", size, hint, cap(got))
+ }
+ }
+ }
+}
+
+// TestReadAllBodyError checks that a read failure is reported rather than
+// treated as the end of the body.
+func TestReadAllBodyError(t *testing.T) {
+ r := io.MultiReader(strings.NewReader(`{"a":`), &errReader{})
+ if _, err := readAllBody(r, 0); err == nil {
+ t.Fatal("want an error")
+ }
+}
+
+type errReader struct{}
+
+func (*errReader) Read([]byte) (int, error) { return 0, io.ErrClosedPipe }
+
+// TestHTTPBatchRequestFraming checks a batch whose items each carry a sizeable
+// argument. Every message in a batch points into the same buffer, so this would
+// catch one item's arguments bleeding into another's.
+func TestHTTPBatchRequestFraming(t *testing.T) {
+ srv := newTestServer()
+ defer srv.Stop()
+
+ const items = 12
+ var body strings.Builder
+ body.WriteByte('[')
+ for i := 0; i < items; i++ {
+ if i > 0 {
+ body.WriteByte(',')
+ }
+ // A distinct payload per item, large enough to span several reads.
+ pad := strings.Repeat(string(rune('a'+i)), 4096)
+ fmt.Fprintf(&body, `{"jsonrpc":"2.0","id":%d,"method":"test_echo","params":["%s",%d,{"S":"%s"}]}`,
+ i, pad, i, pad)
+ }
+ body.WriteByte(']')
+
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body.String()))
+ req.Header.Set("content-type", "application/json")
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, req)
+ confirmStatusCode(t, rec.Code, http.StatusOK)
+
+ var resps []struct {
+ ID int `json:"id"`
+ Result struct {
+ String string
+ Int int
+ Args *echoArgs
+ } `json:"result"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resps); err != nil {
+ t.Fatalf("decoding the batch response failed: %v", err)
+ }
+ if len(resps) != items {
+ t.Fatalf("got %d responses, want %d", len(resps), items)
+ }
+ for _, r := range resps {
+ want := strings.Repeat(string(rune('a'+r.ID)), 4096)
+ if r.Result.Int != r.ID {
+ t.Errorf("id %d: Int = %d", r.ID, r.Result.Int)
+ }
+ if r.Result.String != want {
+ t.Errorf("id %d: String is not its own argument", r.ID)
+ }
+ if r.Result.Args == nil || r.Result.Args.S != want {
+ t.Errorf("id %d: Args is not its own argument", r.ID)
+ }
+ }
+}
diff --git a/rpc/json.go b/rpc/json.go
index f0e82afb5c4d..41c7fe7ab7f4 100644
--- a/rpc/json.go
+++ b/rpc/json.go
@@ -199,6 +199,7 @@ type jsonCodec struct {
closer sync.Once // close closed channel once
closeCh chan interface{} // closed on Close
decode decodeFunc // decoder to allow multiple transports
+ readFrame readFrameFunc // set when the transport delimits messages itself
encMu sync.Mutex // guards the encoder
encodeMsg encodeMsgFunc // single-message encoder
encodeBatch encodeBatchFunc // batch encoder
@@ -211,15 +212,28 @@ type encodeBatchFunc = func(ctx context.Context, msgs []*jsonrpcMessage, isError
type decodeFunc = func(v interface{}) error
+// readFrameFunc returns the bytes of the next message. Only transports that
+// delimit messages themselves have one. The bytes must not be reused on the next
+// call, the message points into them.
+type readFrameFunc = func() ([]byte, error)
+
// NewFuncCodec creates a codec which uses the given functions to read and write. If conn
// implements ConnRemoteAddr, log messages will use it to include the remote address of
-// the connection.
+// the connection. The decode function must reject invalid JSON, reading a message
+// relies on it.
func NewFuncCodec(conn deadlineCloser, encodeMsg encodeMsgFunc, encodeBatch encodeBatchFunc, decode decodeFunc) ServerCodec {
+ return newFuncCodec(conn, encodeMsg, encodeBatch, decode, nil)
+}
+
+// newFuncCodec is NewFuncCodec with the frame reader the built in transports use.
+// A transport with a frame reader never calls decode, so it may be nil.
+func newFuncCodec(conn deadlineCloser, encodeMsg encodeMsgFunc, encodeBatch encodeBatchFunc, decode decodeFunc, readFrame readFrameFunc) *jsonCodec {
codec := &jsonCodec{
closeCh: make(chan interface{}),
encodeMsg: encodeMsg,
encodeBatch: encodeBatch,
decode: decode,
+ readFrame: readFrame,
conn: conn,
}
if ra, ok := conn.(ConnRemoteAddr); ok {
@@ -299,10 +313,8 @@ func (c *jsonCodec) remoteAddr() string {
}
func (c *jsonCodec) readBatch() (messages []*jsonrpcMessage, batch bool, err error) {
- // Decode the next JSON object in the input stream.
- // This verifies basic syntax, etc.
- var rawmsg json.RawMessage
- if err := c.decode(&rawmsg); err != nil {
+ rawmsg, err := c.readMessage()
+ if err != nil {
return nil, false, err
}
messages, batch = parseMessage(rawmsg)
@@ -316,6 +328,38 @@ func (c *jsonCodec) readBatch() (messages []*jsonrpcMessage, batch bool, err err
return messages, batch, nil
}
+// readMessage returns the bytes of the next message, checked to be valid JSON.
+func (c *jsonCodec) readMessage() (json.RawMessage, error) {
+ // A stream has no framing, so the decoder finds the message end and checks it.
+ if c.readFrame == nil {
+ // Decode the next JSON object in the input stream.
+ // This verifies basic syntax, etc.
+ var rawmsg json.RawMessage
+ if err := c.decode(&rawmsg); err != nil {
+ return nil, err
+ }
+ return rawmsg, nil
+ }
+ // The transport delimits the message, so one read and one check will do.
+ // Decoding into a json.RawMessage would scan twice and copy.
+ frame, err := c.readFrame()
+ if err != nil {
+ return nil, err
+ }
+ if !json.Valid(frame) {
+ // Decode the broken message to report where it went wrong. Unmarshal
+ // checks syntax the same way Valid does, so it fails here too. The
+ // fallback only guards against the two ever disagreeing.
+ var rawmsg json.RawMessage
+ err := json.Unmarshal(frame, &rawmsg)
+ if err == nil {
+ err = errors.New("invalid JSON request")
+ }
+ return nil, err
+ }
+ return frame, nil
+}
+
func (c *jsonCodec) writeJSON(ctx context.Context, msg *jsonrpcMessage, isError bool) error {
c.encMu.Lock()
defer c.encMu.Unlock()
@@ -358,20 +402,54 @@ func (c *jsonCodec) closed() <-chan interface{} {
// jsonrpcMessage.
func parseMessage(raw json.RawMessage) ([]*jsonrpcMessage, bool) {
if !isBatch(raw) {
+ // readBatch rejects a nil message, which is what null must become.
+ if isJSONNull(raw) {
+ return []*jsonrpcMessage{nil}, false
+ }
msgs := []*jsonrpcMessage{{}}
- json.Unmarshal(raw, &msgs[0])
+ fillMessage(raw, msgs[0])
return msgs, false
}
- dec := json.NewDecoder(bytes.NewReader(raw))
- dec.Token() // skip '['
var msgs []*jsonrpcMessage
- for dec.More() {
- msgs = append(msgs, new(jsonrpcMessage))
- dec.Decode(&msgs[len(msgs)-1])
- }
+ forEachJSONElement(raw, func(elem []byte) {
+ // readBatch rejects a nil message, which is what null must become.
+ if isJSONNull(elem) {
+ msgs = append(msgs, nil)
+ return
+ }
+ msg := new(jsonrpcMessage)
+ fillMessage(elem, msg)
+ msgs = append(msgs, msg)
+ })
return msgs, true
}
+// fillMessage picks a message apart into msg. Input that does not hold an object
+// leaves msg zero, and the handler rejects it later.
+func fillMessage(input []byte, msg *jsonrpcMessage) {
+ // The raw fields point into input rather than being copied out of it, which
+ // matters because params is nearly all of a large request.
+ forEachJSONField(input, func(key, value []byte) {
+ switch string(key) {
+ case "jsonrpc":
+ // The string fields go through encoding/json to unescape them.
+ json.Unmarshal(value, &msg.Version)
+ case "id":
+ msg.ID = value
+ case "method":
+ json.Unmarshal(value, &msg.Method)
+ case "params":
+ msg.Params = value
+ case "error":
+ msg.Error = value
+ case "result":
+ msg.Result = value
+ default:
+ // ignore unknown fields
+ }
+ })
+}
+
// isBatch returns true when the first non-whitespace characters is '['
func isBatch(raw json.RawMessage) bool {
for _, c := range raw {
@@ -388,18 +466,15 @@ func isBatch(raw json.RawMessage) bool {
// given types. It returns the parsed values or an error when the args could not be
// parsed. Missing optional arguments are returned as reflect.Zero values.
func parsePositionalArguments(rawArgs json.RawMessage, types []reflect.Type) ([]reflect.Value, error) {
- dec := json.NewDecoder(bytes.NewReader(rawArgs))
var args []reflect.Value
- tok, err := dec.Token()
switch {
- case err == io.EOF || tok == nil && err == nil:
+ case len(bytes.TrimSpace(rawArgs)) == 0 || isJSONNull(rawArgs):
// "params" is optional and may be empty. Also allow "params":null even though it's
// not in the spec because our own client used to send it.
- case err != nil:
- return nil, err
- case tok == json.Delim('['):
+ case isBatch(rawArgs):
// Read argument array.
- if args, err = parseArgumentArray(dec, types); err != nil {
+ var err error
+ if args, err = parseArgumentArray(rawArgs, types); err != nil {
return nil, err
}
default:
@@ -415,24 +490,43 @@ func parsePositionalArguments(rawArgs json.RawMessage, types []reflect.Type) ([]
return args, nil
}
-func parseArgumentArray(dec *json.Decoder, types []reflect.Type) ([]reflect.Value, error) {
+// parseArgumentArray decodes an already syntax-checked argument array.
+func parseArgumentArray(rawArgs json.RawMessage, types []reflect.Type) ([]reflect.Value, error) {
+ // Cutting the array into elements first means each argument is decoded once.
+ // A json.Decoder would walk every argument twice, once to find where it ends.
args := make([]reflect.Value, 0, len(types))
- for i := 0; dec.More(); i++ {
+ var scanErr error
+ forEachJSONElement(rawArgs, func(elem []byte) {
+ if scanErr != nil {
+ return
+ }
+ i := len(args)
if i >= len(types) {
- return args, fmt.Errorf("too many arguments, want at most %d", len(types))
+ scanErr = fmt.Errorf("too many arguments, want at most %d", len(types))
+ return
}
argval := reflect.New(types[i])
- if err := dec.Decode(argval.Interface()); err != nil {
- return args, fmt.Errorf("invalid argument %d: %v", i, err)
+ if err := decodeArgument(elem, argval.Interface()); err != nil {
+ scanErr = fmt.Errorf("invalid argument %d: %v", i, err)
+ return
}
if argval.IsNil() && types[i].Kind() != reflect.Pointer {
- return args, fmt.Errorf("missing value for required argument %d", i)
+ scanErr = fmt.Errorf("missing value for required argument %d", i)
+ return
}
args = append(args, argval.Elem())
+ })
+ return args, scanErr
+}
+
+// decodeArgument decodes one already syntax-checked argument value.
+func decodeArgument(elem []byte, arg any) error {
+ // A type that unmarshals itself is called directly, which skips the
+ // validation pass json.Unmarshal runs first.
+ if u, ok := arg.(json.Unmarshaler); ok && !isJSONNull(elem) {
+ return u.UnmarshalJSON(elem)
}
- // Read end of args array.
- _, err := dec.Token()
- return args, err
+ return json.Unmarshal(elem, arg)
}
// parseSubscriptionName extracts the subscription name from an encoded argument array.
diff --git a/rpc/jsonscan.go b/rpc/jsonscan.go
new file mode 100644
index 000000000000..650495427b43
--- /dev/null
+++ b/rpc/jsonscan.go
@@ -0,0 +1,166 @@
+// Copyright 2026 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// The go-ethereum library is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see .
+
+package rpc
+
+import "bytes"
+
+// Helpers for finding the bounds of JSON values without parsing them, so a
+// request is not walked by encoding/json once per layer. They require input
+// encoding/json has already accepted, and hand out sub-slices of it which are
+// still decoded afterwards. Do not use these on unchecked input.
+
+func isJSONSpace(c byte) bool {
+ return c == ' ' || c == '\t' || c == '\n' || c == '\r'
+}
+
+// skipJSONSpace returns the offset of the first byte at or after i that is not
+// insignificant whitespace.
+func skipJSONSpace(data []byte, i int) int {
+ for i < len(data) && isJSONSpace(data[i]) {
+ i++
+ }
+ return i
+}
+
+// scanJSONString returns the offset just past the string beginning at data[i],
+// which must be its opening quote.
+func scanJSONString(data []byte, i int) int {
+ i++ // opening quote
+ for i < len(data) {
+ j := bytes.IndexByte(data[i:], '"')
+ if j < 0 {
+ return len(data)
+ }
+ // The quote ends the string unless an odd number of backslashes run up
+ // to it, in which case it is escaped.
+ k, n := i+j-1, 0
+ for k >= i && data[k] == '\\' {
+ n++
+ k--
+ }
+ i += j + 1
+ if n%2 == 0 {
+ return i
+ }
+ }
+ return len(data)
+}
+
+// scanJSONValue returns the offset just past the JSON value beginning at
+// data[i], skipping any whitespace in front of it.
+func scanJSONValue(data []byte, i int) int {
+ i = skipJSONSpace(data, i)
+ if i >= len(data) {
+ return len(data)
+ }
+ switch data[i] {
+ case '"':
+ return scanJSONString(data, i)
+ case '{', '[':
+ depth := 0
+ for i < len(data) {
+ switch data[i] {
+ case '"':
+ i = scanJSONString(data, i)
+ continue
+ case '{', '[':
+ depth++
+ case '}', ']':
+ depth--
+ if depth == 0 {
+ return i + 1
+ }
+ }
+ i++
+ }
+ return len(data)
+ default:
+ // A number, true, false or null, ending at the next structural byte.
+ for ; i < len(data); i++ {
+ c := data[i]
+ if c == ',' || c == '}' || c == ']' || isJSONSpace(c) {
+ return i
+ }
+ }
+ return len(data)
+ }
+}
+
+// forEachJSONField calls fn with the key and raw value of every member of the
+// JSON object in data. Nothing is called if data does not hold an object.
+func forEachJSONField(data []byte, fn func(key, value []byte)) {
+ i := skipJSONSpace(data, 0)
+ if i >= len(data) || data[i] != '{' {
+ return
+ }
+ i++
+ for {
+ i = skipJSONSpace(data, i)
+ if i >= len(data) || data[i] == '}' {
+ return
+ }
+ if data[i] == ',' {
+ i++
+ continue
+ }
+ if data[i] != '"' {
+ return
+ }
+ keyStart := i
+ keyEnd := scanJSONString(data, i)
+ i = skipJSONSpace(data, keyEnd)
+ if i >= len(data) || data[i] != ':' {
+ return
+ }
+ valStart := skipJSONSpace(data, i+1)
+ valEnd := scanJSONValue(data, valStart)
+ i = valEnd
+ if keyEnd-1 <= keyStart {
+ return
+ }
+ fn(data[keyStart+1:keyEnd-1], data[valStart:valEnd])
+ }
+}
+
+// forEachJSONElement calls fn with the raw value of every element of the JSON
+// array in data. Nothing is called if data does not hold an array.
+func forEachJSONElement(data []byte, fn func(value []byte)) {
+ i := skipJSONSpace(data, 0)
+ if i >= len(data) || data[i] != '[' {
+ return
+ }
+ i++
+ for {
+ i = skipJSONSpace(data, i)
+ if i >= len(data) || data[i] == ']' {
+ return
+ }
+ if data[i] == ',' {
+ i++
+ continue
+ }
+ start := i
+ i = scanJSONValue(data, i)
+ fn(data[start:i])
+ }
+}
+
+// isJSONNull reports whether data holds the JSON null literal.
+func isJSONNull(data []byte) bool {
+ i := skipJSONSpace(data, 0)
+ return bytes.Equal(bytes.TrimRight(data[i:], " \t\n\r"), []byte("null"))
+}
diff --git a/rpc/jsonscan_test.go b/rpc/jsonscan_test.go
new file mode 100644
index 000000000000..5392522c41b2
--- /dev/null
+++ b/rpc/jsonscan_test.go
@@ -0,0 +1,505 @@
+// Copyright 2026 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// The go-ethereum library is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see .
+
+package rpc
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+// messageCorpus seeds the fuzz targets below. Every entry is valid JSON, which
+// is the state of a message by the time parseMessage sees it.
+var messageCorpus = []string{
+ `{}`,
+ `{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}`,
+ `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0x00"},"latest"]}`,
+ `{"id":null}`,
+ `{"result":null}`,
+ `{"params":null}`,
+ `{"jsonrpc":"2.0","id":1,"result":null}`,
+ `{"id":"str-id","method":"m"}`,
+ `{"id":1.5e3,"method":"m"}`,
+ `{"id":true,"method":"m"}`,
+ // escapes in the string fields
+ `{"method":"a\"b","id":1}`,
+ `{"method":"a\\b","id":1}`,
+ `{"method":"hello","id":1}`,
+ `{"method":"tab\there","id":1}`,
+ `{"method":"back\\\\slash","id":1}`,
+ // structural bytes inside strings must not confuse the scan
+ `{"method":"m","params":["{[,:}]"],"id":1}`,
+ `{"method":"m","params":["a\"},{\"b"],"id":1}`,
+ `{"method":"m","params":["ends with backslash\\"],"id":1}`,
+ // whitespace
+ "{ \"method\" : \"m\" , \"id\" : 1 }",
+ "\n\t{\"method\":\"m\",\"id\":2}\r\n",
+ `{"params": [ 1 , 2 ] ,"id":3}`,
+ // duplicate keys, last one wins
+ `{"method":"first","method":"second","id":1}`,
+ `{"id":1,"id":2}`,
+ // keys in other spellings are unknown fields, only the exact names match
+ `{"METHOD":"m","ID":1}`,
+ `{"Method":"m","Id":1,"Jsonrpc":"2.0"}`,
+ "{\"metho\\u0064\":\"m\",\"i\\u0064\":7}",
+ `{"metho\\u0064":"not the method key"}`,
+ // unknown fields are ignored
+ `{"method":"m","id":1,"extra":{"a":[1,2,3]},"more":"x"}`,
+ // nested params
+ `{"method":"m","id":1,"params":[[[[1]]]],"x":1}`,
+ `{"method":"m","id":1,"params":[{"a":{"b":{"c":[]}}}]}`,
+ // empty and odd values
+ `{"method":"","id":1}`,
+ `{"":1,"method":"m"}`,
+ // not an object at all
+ `1`,
+ `"str"`,
+ `null`,
+ `true`,
+ // batches
+ `[]`,
+ `[{"method":"a","id":1}]`,
+ `[{"method":"a","id":1},{"method":"b","id":2}]`,
+ `[null]`,
+ `[{"method":"a","id":1},null,{"method":"b","id":2}]`,
+ `[1,2,3]`,
+ `["a","b"]`,
+ `[[1],[2]]`,
+ `[ { "method" : "a" , "id" : 1 } , null ]`,
+ `[{"method":"m","params":["},{"]}]`,
+}
+
+// TestParseMessage covers the inputs where the envelope split has to make a
+// decision.
+func TestParseMessage(t *testing.T) {
+ msg := func(version, method, id, params, result string) *jsonrpcMessage {
+ m := &jsonrpcMessage{Version: version, Method: method}
+ if id != "" {
+ m.ID = json.RawMessage(id)
+ }
+ if params != "" {
+ m.Params = json.RawMessage(params)
+ }
+ if result != "" {
+ m.Result = json.RawMessage(result)
+ }
+ return m
+ }
+ zero := func() *jsonrpcMessage { return msg("", "", "", "", "") }
+
+ tests := []struct {
+ name string
+ input string
+ batch bool
+ want []*jsonrpcMessage
+ }{
+ {"empty object", `{}`, false, []*jsonrpcMessage{zero()}},
+ {
+ "call",
+ `{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}`,
+ false, []*jsonrpcMessage{msg("2.0", "eth_chainId", "1", "[]", "")},
+ },
+ // null stays in the raw fields as the literal. isResponse needs a null
+ // result to count as present.
+ {"null id", `{"id":null}`, false, []*jsonrpcMessage{msg("", "", "null", "", "")}},
+ {"null result", `{"result":null}`, false, []*jsonrpcMessage{msg("", "", "", "", "null")}},
+ {"null params", `{"params":null}`, false, []*jsonrpcMessage{msg("", "", "", "null", "")}},
+
+ {"escaped quote in method", `{"method":"a\"b","id":1}`, false, []*jsonrpcMessage{msg("", `a"b`, "1", "", "")}},
+ {"escaped tab in method", `{"method":"tab\there","id":1}`, false, []*jsonrpcMessage{msg("", "tab\there", "1", "", "")}},
+ {"duplicate key, last wins", `{"method":"first","method":"second","id":1}`, false, []*jsonrpcMessage{msg("", "second", "1", "", "")}},
+
+ // field names have one spelling in the spec, any other spelling is an
+ // unknown key, even where encoding/json would have matched it
+ {"cased keys ignored", `{"Method":"m","ID":1,"Params":[1]}`, false, []*jsonrpcMessage{zero()}},
+ {"upper case keys ignored", `{"METHOD":"m","JSONRPC":"2.0"}`, false, []*jsonrpcMessage{zero()}},
+ {"escaped keys ignored", "{\"metho\\u0064\":\"m\",\"i\\u0064\":7}", false, []*jsonrpcMessage{zero()}},
+
+ // a string holding structural bytes must not end the value early
+ {
+ "structural bytes inside a string",
+ `{"method":"m","params":["a\"},{\"b"],"id":1}`,
+ false, []*jsonrpcMessage{msg("", "m", "1", `["a\"},{\"b"]`, "")},
+ },
+ {"space inside params is kept", `{"params": [ 1 , 2 ] ,"id":3}`, false, []*jsonrpcMessage{msg("", "", "3", "[ 1 , 2 ]", "")}},
+ {"space around the message", "\n\t{\"method\":\"m\",\"id\":2}\r\n", false, []*jsonrpcMessage{msg("", "m", "2", "", "")}},
+ {"unknown fields ignored", `{"method":"m","id":1,"extra":{"a":[1,2,3]},"more":"x"}`, false, []*jsonrpcMessage{msg("", "m", "1", "", "")}},
+
+ // valid JSON that is not a message leaves a zero one for the handler to reject
+ {"not an object", `1`, false, []*jsonrpcMessage{zero()}},
+ {"bare null", `null`, false, []*jsonrpcMessage{nil}},
+
+ {"empty batch", `[]`, true, nil},
+ {"batch holding one null", `[null]`, true, []*jsonrpcMessage{nil}},
+ {
+ "batch with a null in it",
+ `[{"method":"a","id":1},null,{"method":"b","id":2}]`,
+ true, []*jsonrpcMessage{msg("", "a", "1", "", ""), nil, msg("", "b", "2", "", "")},
+ },
+ {"batch of non-objects", `[1,2,3]`, true, []*jsonrpcMessage{zero(), zero(), zero()}},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if !json.Valid([]byte(tc.input)) {
+ t.Fatalf("test input is not valid JSON: %s", tc.input)
+ }
+ got, batch := parseMessage(json.RawMessage(tc.input))
+ if batch != tc.batch {
+ t.Fatalf("batch = %v, want %v", batch, tc.batch)
+ }
+ if len(got) != len(tc.want) {
+ t.Fatalf("got %d messages, want %d", len(got), len(tc.want))
+ }
+ for i := range tc.want {
+ if (got[i] == nil) != (tc.want[i] == nil) {
+ t.Fatalf("message %d nil = %v, want %v", i, got[i] == nil, tc.want[i] == nil)
+ }
+ if got[i] == nil {
+ continue
+ }
+ if err := sameMessage(got[i], tc.want[i]); err != nil {
+ t.Errorf("message %d: %v", i, err)
+ }
+ }
+ })
+ }
+}
+
+func sameMessage(got, want *jsonrpcMessage) error {
+ if got.Version != want.Version {
+ return fmt.Errorf("Version = %q, want %q", got.Version, want.Version)
+ }
+ if got.Method != want.Method {
+ return fmt.Errorf("Method = %q, want %q", got.Method, want.Method)
+ }
+ for _, f := range []struct {
+ name string
+ got, want json.RawMessage
+ }{
+ {"ID", got.ID, want.ID},
+ {"Params", got.Params, want.Params},
+ {"Error", got.Error, want.Error},
+ {"Result", got.Result, want.Result},
+ } {
+ if (f.got == nil) != (f.want == nil) {
+ return fmt.Errorf("%s nil = %v, want %v (got %q want %q)", f.name, f.got == nil, f.want == nil, f.got, f.want)
+ }
+ // A raw field is a slice of the input, so it should come back byte for byte.
+ if !bytes.Equal(f.got, f.want) {
+ return fmt.Errorf("%s = %q, want %q", f.name, f.got, f.want)
+ }
+ }
+ return nil
+}
+
+// selfDecoding stands in for an argument type that unmarshals itself.
+type selfDecoding struct {
+ Text string
+}
+
+func (s *selfDecoding) UnmarshalJSON(input []byte) error {
+ if len(input) < 2 || input[0] != '"' {
+ return fmt.Errorf("selfDecoding: not a string: %s", input)
+ }
+ s.Text = string(input[1 : len(input)-1])
+ return nil
+}
+
+// TestParsePositionalArguments covers how an argument array is cut up, including
+// the cases that error.
+func TestParsePositionalArguments(t *testing.T) {
+ var (
+ tInt = reflect.TypeOf(int(0))
+ tPtr = reflect.TypeOf(new(int))
+ tStr = reflect.TypeOf("")
+ tMap = reflect.TypeOf(map[string]int{})
+ tSelf = reflect.TypeOf(selfDecoding{})
+ )
+ tests := []struct {
+ name string
+ args string
+ types []reflect.Type
+ want []any
+ wantErr string
+ }{
+ {"no arguments", `[]`, nil, nil, ""},
+ {"two ints", `[1,2]`, []reflect.Type{tInt, tInt}, []any{1, 2}, ""},
+ {"space between arguments", `[ 1 , 2 ]`, []reflect.Type{tInt, tInt}, []any{1, 2}, ""},
+ {"object argument", `[{"a":1}]`, []reflect.Type{tMap}, []any{map[string]int{"a": 1}}, ""},
+ {"structural bytes inside a string", `["},{"]`, []reflect.Type{tStr}, []any{`},{`}, ""},
+ {"null into a pointer", `[null]`, []reflect.Type{tPtr}, []any{(*int)(nil)}, ""},
+ {"null into a value", `[null]`, []reflect.Type{tInt}, []any{0}, ""},
+ {"missing optional argument", `[1]`, []reflect.Type{tInt, tPtr}, []any{1, (*int)(nil)}, ""},
+
+ {"too many arguments", `[1,2,3]`, []reflect.Type{tInt}, nil, "too many arguments"},
+ {"missing required argument", `[1]`, []reflect.Type{tInt, tInt}, nil, "missing value for required argument 1"},
+ {"no arguments at all", `[]`, []reflect.Type{tInt}, nil, "missing value for required argument 0"},
+ {"wrong type", `["not an int"]`, []reflect.Type{tInt}, nil, "invalid argument 0"},
+
+ // a self decoding type is handed the value directly, except for null
+ {"self decoding", `["0x1234"]`, []reflect.Type{tSelf}, []any{selfDecoding{Text: "0x1234"}}, ""},
+ {"self decoding null", `[null]`, []reflect.Type{tSelf}, nil, "invalid argument 0"},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if !json.Valid([]byte(tc.args)) {
+ t.Fatalf("test input is not valid JSON: %s", tc.args)
+ }
+ got, err := parsePositionalArguments(json.RawMessage(tc.args), tc.types)
+ if tc.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+ t.Fatalf("err = %v, want it to mention %q", err, tc.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(got) != len(tc.want) {
+ t.Fatalf("got %d arguments, want %d", len(got), len(tc.want))
+ }
+ for i := range tc.want {
+ if v := got[i].Interface(); !reflect.DeepEqual(v, tc.want[i]) {
+ t.Errorf("argument %d = %#v, want %#v", i, v, tc.want[i])
+ }
+ }
+ })
+ }
+}
+
+// TestParsePositionalArgumentsEmpty covers the inputs that reach the function
+// when a request carries no params at all.
+func TestParsePositionalArgumentsEmpty(t *testing.T) {
+ for _, args := range []string{"", " ", "null", " null "} {
+ got, err := parsePositionalArguments(json.RawMessage(args), nil)
+ if err != nil {
+ t.Errorf("%q: unexpected error %v", args, err)
+ }
+ if len(got) != 0 {
+ t.Errorf("%q: got %d args, want 0", args, len(got))
+ }
+ }
+}
+
+// benchMessages are request shapes the server sees, from a one line call to a
+// payload sized one.
+func benchMessages() []struct {
+ name string
+ req string
+} {
+ bigArg := func(n int) string {
+ var buf bytes.Buffer
+ buf.WriteString(`{"parentHash":"0x1234","transactions":[`)
+ for i := 0; i < n; i++ {
+ if i > 0 {
+ buf.WriteByte(',')
+ }
+ buf.WriteByte('"')
+ buf.WriteString("0x")
+ for j := 0; j < 1024; j++ {
+ buf.WriteString("ab")
+ }
+ buf.WriteByte('"')
+ }
+ buf.WriteString(`]}`)
+ return buf.String()
+ }
+ return []struct {
+ name string
+ req string
+ }{
+ {"tiny", `{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}`},
+ {"small", `{"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber","params":["0x1b4",true]}`},
+ {"batch10", func() string {
+ var buf bytes.Buffer
+ buf.WriteByte('[')
+ for i := 0; i < 10; i++ {
+ if i > 0 {
+ buf.WriteByte(',')
+ }
+ fmt.Fprintf(&buf, `{"jsonrpc":"2.0","id":%d,"method":"eth_chainId","params":[]}`, i)
+ }
+ buf.WriteByte(']')
+ return buf.String()
+ }()},
+ {"payload64kb", fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"engine_newPayloadV4","params":[%s,[],null,[]]}`, bigArg(32))},
+ {"payload512kb", fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"engine_newPayloadV4","params":[%s,[],null,[]]}`, bigArg(256))},
+ }
+}
+
+// BenchmarkParseMessage measures the envelope split.
+func BenchmarkParseMessage(b *testing.B) {
+ for _, tc := range benchMessages() {
+ raw := json.RawMessage(tc.req)
+ b.Run(tc.name, func(b *testing.B) {
+ b.ReportAllocs()
+ b.SetBytes(int64(len(raw)))
+ for b.Loop() {
+ parseMessage(raw)
+ }
+ })
+ }
+}
+
+// BenchmarkParsePositionalArguments measures argument decoding for a type that
+// decodes itself, which is the shape an engine API payload has.
+func BenchmarkParsePositionalArguments(b *testing.B) {
+ types := []reflect.Type{reflect.TypeOf(selfDecoding{})}
+ for _, size := range []int{1, 64, 512} {
+ var buf bytes.Buffer
+ buf.WriteString(`["0x`)
+ for i := 0; i < size*512; i++ {
+ buf.WriteString("ab")
+ }
+ buf.WriteString(`"]`)
+ raw := json.RawMessage(buf.String())
+ b.Run(fmt.Sprintf("kb%d", len(raw)/1024), func(b *testing.B) {
+ b.ReportAllocs()
+ b.SetBytes(int64(len(raw)))
+ for b.Loop() {
+ if _, err := parsePositionalArguments(raw, types); err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+ }
+}
+
+// FuzzJSONScanFields checks that the field scan agrees with encoding/json on any
+// valid JSON object.
+func FuzzJSONScanFields(f *testing.F) {
+ for _, s := range messageCorpus {
+ f.Add(s)
+ }
+ f.Add(`{"a":"😀","b":[1,{"c":null}]}`)
+ f.Fuzz(func(t *testing.T, input string) {
+ data := []byte(input)
+ if !json.Valid(data) {
+ return
+ }
+ var want map[string]json.RawMessage
+ if err := json.Unmarshal(data, &want); err != nil {
+ return // not an object
+ }
+ got := make(map[string]json.RawMessage)
+ forEachJSONField(data, func(key, value []byte) {
+ // The scan hands back the key still escaped, so unescape it the same
+ // way the map decode did before comparing.
+ var k string
+ if err := json.Unmarshal(append(append([]byte{'"'}, key...), '"'), &k); err != nil {
+ t.Fatalf("key %q does not unescape: %v", key, err)
+ }
+ got[k] = value
+ })
+ if len(got) != len(want) {
+ t.Fatalf("got %d fields, want %d (input %s)", len(got), len(want), input)
+ }
+ for k, wv := range want {
+ gv, ok := got[k]
+ if !ok {
+ t.Fatalf("missing field %q (input %s)", k, input)
+ }
+ if !bytes.Equal(bytes.TrimSpace(gv), bytes.TrimSpace(wv)) {
+ t.Fatalf("field %q = %q, want %q (input %s)", k, gv, wv, input)
+ }
+ }
+ })
+}
+
+// FuzzFillMessage checks the envelope split against encoding/json field by
+// field. Field names have one spelling, so the reference picks each one out of
+// a decoded map by its exact name.
+func FuzzFillMessage(f *testing.F) {
+ for _, s := range messageCorpus {
+ f.Add(s)
+ }
+ f.Fuzz(func(t *testing.T, input string) {
+ data := []byte(input)
+ if !json.Valid(data) {
+ return
+ }
+ if bytes.IndexByte(data, '\\') >= 0 {
+ // encoding/json unescapes map keys, so an escaped key would match
+ // in the reference but is an unknown key to fillMessage. Escape
+ // handling is pinned by the tests above.
+ return
+ }
+ var want jsonrpcMessage
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err == nil {
+ if v, ok := obj["jsonrpc"]; ok {
+ json.Unmarshal(v, &want.Version)
+ }
+ if v, ok := obj["id"]; ok {
+ want.ID = v
+ }
+ if v, ok := obj["method"]; ok {
+ json.Unmarshal(v, &want.Method)
+ }
+ if v, ok := obj["params"]; ok {
+ want.Params = v
+ }
+ if v, ok := obj["error"]; ok {
+ want.Error = v
+ }
+ if v, ok := obj["result"]; ok {
+ want.Result = v
+ }
+ }
+ got := new(jsonrpcMessage)
+ fillMessage(data, got)
+ if err := sameMessage(got, &want); err != nil {
+ t.Fatalf("%v (input %s)", err, input)
+ }
+ })
+}
+
+// FuzzJSONScanElements checks that the element scan agrees with encoding/json on
+// any valid JSON array.
+func FuzzJSONScanElements(f *testing.F) {
+ for _, s := range messageCorpus {
+ f.Add(s)
+ }
+ f.Add(`[1,"two",{"three":3},[4],null,true]`)
+ f.Fuzz(func(t *testing.T, input string) {
+ data := []byte(input)
+ if !json.Valid(data) {
+ return
+ }
+ var want []json.RawMessage
+ if err := json.Unmarshal(data, &want); err != nil {
+ return // not an array
+ }
+ var got []json.RawMessage
+ forEachJSONElement(data, func(value []byte) {
+ got = append(got, value)
+ })
+ if len(got) != len(want) {
+ t.Fatalf("got %d elements, want %d (input %s)", len(got), len(want), input)
+ }
+ for i := range want {
+ if !bytes.Equal(bytes.TrimSpace(got[i]), bytes.TrimSpace(want[i])) {
+ t.Fatalf("element %d = %q, want %q (input %s)", i, got[i], want[i], input)
+ }
+ }
+ })
+}
diff --git a/rpc/websocket.go b/rpc/websocket.go
index 5e1e09c89dca..19eb0ccdc113 100644
--- a/rpc/websocket.go
+++ b/rpc/websocket.go
@@ -302,8 +302,13 @@ func newWebsocketCodec(conn *websocket.Conn, host string, req http.Header, readL
buf = appendBatch(buf[:0], msgs)
return conn.WriteMessage(websocket.TextMessage, buf)
}
+ // Every frame is one message, so it can be read in one go and checked once.
+ readFrame := func() ([]byte, error) {
+ _, frame, err := conn.ReadMessage()
+ return frame, err
+ }
wc := &websocketCodec{
- jsonCodec: NewFuncCodec(conn, encodeMsg, encodeBatch, conn.ReadJSON).(*jsonCodec),
+ jsonCodec: newFuncCodec(conn, encodeMsg, encodeBatch, nil, readFrame),
conn: conn,
pingReset: make(chan struct{}, 1),
pongReceived: make(chan struct{}),