Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions jsonrpc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@
return nil
}

type response struct {
// Response is a JSON-RPC 2.0 response envelope. A client decoding into it should
// pre-seed Result with a *json.RawMessage to keep the raw bytes instead of the
// lossy any (large integers otherwise round-trip through float64).
type Response struct {
Comment thread
brbrr marked this conversation as resolved.
Comment thread
brbrr marked this conversation as resolved.
Version string `json:"jsonrpc"`
Result any `json:"result,omitempty"`
Error *Error `json:"error,omitempty"`
Expand Down Expand Up @@ -326,7 +329,7 @@
var errorRecoverBuffer windowBuffer
bufferedReader := bufio.NewReaderSize(io.TeeReader(reader, &errorRecoverBuffer), bufferSize)
requestIsBatch := isBatch(bufferedReader)
resp := &response{
resp := &Response{
Version: "2.0",
}

Expand Down Expand Up @@ -399,7 +402,7 @@

req := new(Request)
if err := reqDec.Decode(req); err != nil {
addResponse(&response{
addResponse(&Response{
Version: "2.0",
Error: Err(InvalidRequest, err.Error()),
}, http.Header{})
Expand All @@ -412,7 +415,7 @@

resp, header, err := s.handleRequest(ctx, req)
if err != nil {
resp = &response{
resp = &Response{
Version: "2.0",
Error: Err(InvalidRequest, err.Error()),
}
Expand Down Expand Up @@ -479,7 +482,7 @@

// TODO: add recover() to catch panics from handlers/validators and return a JSON-RPC internal error
// instead of crashing the HTTP connection
func (s *Server) handleRequest(ctx context.Context, req *Request) (*response, http.Header, error) {
func (s *Server) handleRequest(ctx context.Context, req *Request) (*Response, http.Header, error) {
s.logger.Trace("Received request", zap.Object("req", req))

header := http.Header{}
Expand All @@ -488,7 +491,7 @@
return nil, header, err
}

res := &response{
res := &Response{
Version: "2.0",
ID: req.ID,
}
Expand Down Expand Up @@ -524,7 +527,7 @@
errorIndex := 1
if len(tuple) == 3 {
errorIndex = 2
header = (tuple[1].Interface()).(http.Header)
header = tuple[1].Interface().(http.Header)

Check warning on line 530 in jsonrpc/server.go

View check run for this annotation

Codecov / codecov/patch

jsonrpc/server.go#L530

Added line #L530 was not covered by tests
}

if errAny := tuple[errorIndex].Interface(); !utils.IsNil(errAny) {
Expand All @@ -533,7 +536,8 @@
s.listener.OnRequestFailed(req.Method, res.Error)
reqJSON, _ := json.Marshal(req)
errJSON, _ := json.Marshal(res.Error)
s.logger.Debug("Failed handing RPC request",
s.logger.Debug(
"Failed handling RPC request",
zap.String("req", log.SanitizeString(string(reqJSON))),
zap.String("res", log.SanitizeString(string(errJSON))),
)
Expand Down
266 changes: 266 additions & 0 deletions l1/internal/clienttest/server.go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to define all this test utility and not make it part of a _test.go file?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is explained in the package doc comment, but tl;dr:

It (will be) used in both eth/client and eth_l1_state_provider tests. And from my understanding go won't allow importing from _test.go into another package.

Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
// Package clienttest provides a minimal JSON-RPC test server for the client
// package. It is its own package (not a _test.go file) so l1 provider tests can
// share it, and lives under internal/ so production code cannot import it
// (it pulls in testing and httptest).
package clienttest

import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"

"github.com/coder/websocket"
)

// wsReadLimit mirrors the client's limit so the server accepts the same frame sizes.
const wsReadLimit = 16 << 20

// TestServer serves JSON-RPC over POST or a websocket upgrade on the same URL.
// Live ws conns are tracked so tests can push notifications or sever mid-call.
type TestServer struct {
srv *httptest.Server

mu sync.Mutex
handler TestHandler
wsConns []*websocket.Conn

pingsReceived atomic.Int64
dropPings atomic.Bool
}

// TestHandler returns the JSON-RPC reply for a request: result becomes "result",
// or a non-nil rerr becomes "error" (result ignored).
type TestHandler func(req TestRequest) (result any, rerr *TestRPCError)

type TestRequest struct {
Method string
Params []json.RawMessage
}

// Code is required; Data is optional.
type TestRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}

func NewTestServer(tb testing.TB) *TestServer {
tb.Helper()
ts := &TestServer{
handler: func(req TestRequest) (any, *TestRPCError) {
return nil, &TestRPCError{Code: -32601, Message: "method not found: " + req.Method}
},

Check warning on line 58 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L53-L58

Added lines #L53 - L58 were not covered by tests
}
ts.srv = httptest.NewServer(http.HandlerFunc(ts.serveHTTP))
tb.Cleanup(ts.Close)
return ts

Check warning on line 62 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L60-L62

Added lines #L60 - L62 were not covered by tests
}

func (ts *TestServer) SetHandler(h TestHandler) {
ts.mu.Lock()
ts.handler = h
ts.mu.Unlock()

Check warning on line 68 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L65-L68

Added lines #L65 - L68 were not covered by tests
}

func (ts *TestServer) URL() string { return ts.srv.URL }

Check warning on line 71 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L71

Added line #L71 was not covered by tests

func (ts *TestServer) WSURL() string {
return "ws" + strings.TrimPrefix(ts.srv.URL, "http")

Check warning on line 74 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L73-L74

Added lines #L73 - L74 were not covered by tests
}

func (ts *TestServer) PingsReceived() int64 { return ts.pingsReceived.Load() }

Check warning on line 77 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L77

Added line #L77 was not covered by tests

func (ts *TestServer) WSConnCount() int {
ts.mu.Lock()
defer ts.mu.Unlock()
return len(ts.wsConns)

Check warning on line 82 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L79-L82

Added lines #L79 - L82 were not covered by tests
}

// SetDropPings makes the server count incoming pings but suppress the pong
// reply, to provoke client-side ping timeouts.
func (ts *TestServer) SetDropPings(b bool) { ts.dropPings.Store(b) }

Check warning on line 87 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L87

Added line #L87 was not covered by tests

func (ts *TestServer) Close() {
ts.mu.Lock()
conns := ts.wsConns
ts.wsConns = nil
ts.mu.Unlock()
for _, c := range conns {
_ = c.CloseNow()

Check warning on line 95 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L89-L95

Added lines #L89 - L95 were not covered by tests
}
ts.srv.Close()

Check warning on line 97 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L97

Added line #L97 was not covered by tests
}

func (ts *TestServer) KillWSConns() {
ts.mu.Lock()
conns := ts.wsConns
ts.wsConns = nil
ts.mu.Unlock()
for _, c := range conns {
_ = c.Close(websocket.StatusInternalError, "test server")

Check warning on line 106 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L100-L106

Added lines #L100 - L106 were not covered by tests
}
}

func (ts *TestServer) PushNotification(ctx context.Context, subID string, payload any) error {
frame := map[string]any{
"jsonrpc": "2.0",
"method": "eth_subscription",
"params": map[string]any{
"subscription": subID,
"result": payload,
},

Check warning on line 117 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L110-L117

Added lines #L110 - L117 were not covered by tests
}
data, err := json.Marshal(frame)
if err != nil {
return err

Check warning on line 121 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L119-L121

Added lines #L119 - L121 were not covered by tests
}
return ts.broadcast(ctx, data)

Check warning on line 123 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L123

Added line #L123 was not covered by tests
}

// PushRawFrame writes data verbatim, so tests can inject malformed frames a
// well-behaved server would never emit.
func (ts *TestServer) PushRawFrame(ctx context.Context, data []byte) error {
return ts.broadcast(ctx, data)

Check warning on line 129 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L128-L129

Added lines #L128 - L129 were not covered by tests
}

// broadcast writes data to every live ws conn, returning the first write
// failure. It errors when no conn is live: a push that reaches nobody is a
// test-ordering bug (e.g. pushing before eth_subscribe completes), not success.
func (ts *TestServer) broadcast(ctx context.Context, data []byte) error {
ts.mu.Lock()
conns := append([]*websocket.Conn(nil), ts.wsConns...)
ts.mu.Unlock()
if len(conns) == 0 {
return errors.New("no live websocket conns to broadcast to")

Check warning on line 140 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L135-L140

Added lines #L135 - L140 were not covered by tests
}
var firstErr error
for _, c := range conns {
if werr := c.Write(ctx, websocket.MessageText, data); werr != nil && firstErr == nil {
firstErr = werr

Check warning on line 145 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L142-L145

Added lines #L142 - L145 were not covered by tests
}
}
return firstErr

Check warning on line 148 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L148

Added line #L148 was not covered by tests
}
Comment thread
brbrr marked this conversation as resolved.
Comment thread
brbrr marked this conversation as resolved.

func (ts *TestServer) callHandler(req TestRequest) (any, *TestRPCError) {
ts.mu.Lock()
h := ts.handler
ts.mu.Unlock()
if h == nil {
return nil, &TestRPCError{Code: -32603, Message: "no handler set"}

Check warning on line 156 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L151-L156

Added lines #L151 - L156 were not covered by tests
}
return h(req)

Check warning on line 158 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L158

Added line #L158 was not covered by tests
}

func (ts *TestServer) serveHTTP(w http.ResponseWriter, r *http.Request) {
if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
ts.serveWebsocket(w, r)
return

Check warning on line 164 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L161-L164

Added lines #L161 - L164 were not covered by tests
}
ts.serveOnePost(w, r)

Check warning on line 166 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L166

Added line #L166 was not covered by tests
}

func (ts *TestServer) serveOnePost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return

Check warning on line 172 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L169-L172

Added lines #L169 - L172 were not covered by tests
}
var raw rawRPCRequest
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return

Check warning on line 177 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L174-L177

Added lines #L174 - L177 were not covered by tests
}
resp, reply := ts.respondTo(&raw)
if !reply {
w.WriteHeader(http.StatusNoContent)
return

Check warning on line 182 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L179-L182

Added lines #L179 - L182 were not covered by tests
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)

Check warning on line 185 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L184-L185

Added lines #L184 - L185 were not covered by tests
}
Comment thread
brbrr marked this conversation as resolved.

func (ts *TestServer) serveWebsocket(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OnPingReceived: func(_ context.Context, _ []byte) bool {
ts.pingsReceived.Add(1)

Check warning on line 191 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L188-L191

Added lines #L188 - L191 were not covered by tests
// true: auto-reply with a pong; false: drop it silently.
return !ts.dropPings.Load()
},

Check warning on line 194 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L193-L194

Added lines #L193 - L194 were not covered by tests
})
if err != nil {
return

Check warning on line 197 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L196-L197

Added lines #L196 - L197 were not covered by tests
}
conn.SetReadLimit(wsReadLimit)
ts.mu.Lock()
ts.wsConns = append(ts.wsConns, conn)
ts.mu.Unlock()
defer func() {
ts.mu.Lock()
for i, c := range ts.wsConns {
if c == conn {
ts.wsConns = append(ts.wsConns[:i], ts.wsConns[i+1:]...)
break

Check warning on line 208 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L199-L208

Added lines #L199 - L208 were not covered by tests
}
}
ts.mu.Unlock()
_ = conn.CloseNow()

Check warning on line 212 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L211-L212

Added lines #L211 - L212 were not covered by tests
}()

ctx := r.Context()
for {
_, data, err := conn.Read(ctx)
if err != nil {
return

Check warning on line 219 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L215-L219

Added lines #L215 - L219 were not covered by tests
}
var raw rawRPCRequest
if jerr := json.Unmarshal(data, &raw); jerr != nil {
continue

Check warning on line 223 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L221-L223

Added lines #L221 - L223 were not covered by tests
}
Comment thread
brbrr marked this conversation as resolved.
resp, reply := ts.respondTo(&raw)
if !reply {
continue

Check warning on line 227 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L225-L227

Added lines #L225 - L227 were not covered by tests
}
respData, jerr := json.Marshal(resp)
if jerr != nil {
continue

Check warning on line 231 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L229-L231

Added lines #L229 - L231 were not covered by tests
}
Comment thread
brbrr marked this conversation as resolved.
Comment thread
brbrr marked this conversation as resolved.
Comment thread
brbrr marked this conversation as resolved.
if werr := conn.Write(ctx, websocket.MessageText, respData); werr != nil {
return

Check warning on line 234 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L233-L234

Added lines #L233 - L234 were not covered by tests
}
}
}

// rawRPCRequest is duplicated from the client's rpcRequest so the server can
// read malformed frames without the client package's constraints. id stays
// opaque so it round-trips verbatim rather than being narrowed to uint64.
type rawRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params []json.RawMessage `json:"params"`
}

// respondTo builds the reply frame for req, echoing its id verbatim. The bool
// is false for a notification (no id), which a conforming server never answers.
func (ts *TestServer) respondTo(req *rawRPCRequest) (map[string]any, bool) {
if len(req.ID) == 0 {
return nil, false

Check warning on line 253 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L251-L253

Added lines #L251 - L253 were not covered by tests
}
out := map[string]any{"jsonrpc": "2.0", "id": req.ID}
result, rerr := ts.callHandler(TestRequest{
Method: req.Method,
Params: req.Params,
})
if rerr != nil {
out["error"] = rerr
return out, true

Check warning on line 262 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L255-L262

Added lines #L255 - L262 were not covered by tests
}
out["result"] = result
return out, true

Check warning on line 265 in l1/internal/clienttest/server.go

View check run for this annotation

Codecov / codecov/patch

l1/internal/clienttest/server.go#L264-L265

Added lines #L264 - L265 were not covered by tests
}
Loading