diff --git a/framework/src/main/java/org/tron/core/services/filter/CachedBodyRequestWrapper.java b/framework/src/main/java/org/tron/core/services/filter/CachedBodyRequestWrapper.java
deleted file mode 100644
index 683fe849f71..00000000000
--- a/framework/src/main/java/org/tron/core/services/filter/CachedBodyRequestWrapper.java
+++ /dev/null
@@ -1,97 +0,0 @@
-package org.tron.core.services.filter;
-
-import java.io.BufferedReader;
-import java.io.ByteArrayInputStream;
-import java.io.InputStreamReader;
-import java.nio.charset.Charset;
-import java.nio.charset.IllegalCharsetNameException;
-import java.nio.charset.StandardCharsets;
-import java.nio.charset.UnsupportedCharsetException;
-import javax.servlet.ReadListener;
-import javax.servlet.ServletInputStream;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletRequestWrapper;
-
-/**
- * Wraps a request to replay a pre-read body from a byte array,
- * allowing the body to be read more than once.
- *
- *
Scope: designed for synchronous, raw-body POST endpoints
- * (e.g. JSON-RPC). It is NOT compatible with:
- *
- * - {@code application/x-www-form-urlencoded} — cached body cannot back
- * {@code getParameter*}.
- * - multipart — {@code getPart()/getParts()} read from the original
- * (already-consumed) stream.
- * - async non-blocking I/O — see {@code setReadListener}.
- * - request dispatch / forward chains.
- *
- *
- * Multiple calls to {@code getInputStream()} (or {@code getReader()})
- * are allowed and each returns a fresh stream over the same cached body —
- * a deliberate extension of the standard servlet contract.
- */
-public class CachedBodyRequestWrapper extends HttpServletRequestWrapper {
-
- private enum BodyAccessor { NONE, STREAM, READER }
-
- private final byte[] body;
- private BodyAccessor accessor = BodyAccessor.NONE;
-
- public CachedBodyRequestWrapper(HttpServletRequest request, byte[] body) {
- super(request);
- this.body = body;
- }
-
- @Override
- public ServletInputStream getInputStream() {
- if (accessor == BodyAccessor.READER) {
- throw new IllegalStateException("getReader() has already been called on this request");
- }
- accessor = BodyAccessor.STREAM;
- final ByteArrayInputStream bais = new ByteArrayInputStream(body);
- return new ServletInputStream() {
- @Override
- public int read() {
- return bais.read();
- }
-
- @Override
- public int read(byte[] b, int off, int len) {
- return bais.read(b, off, len);
- }
-
- @Override
- public boolean isFinished() {
- return bais.available() == 0;
- }
-
- @Override
- public boolean isReady() {
- return true;
- }
-
- @Override
- public void setReadListener(ReadListener readListener) {
- throw new UnsupportedOperationException(
- "async I/O is not supported on cached body");
- }
- };
- }
-
- @Override
- public BufferedReader getReader() {
- if (accessor == BodyAccessor.STREAM) {
- throw new IllegalStateException("getInputStream() has already been called on this request");
- }
- accessor = BodyAccessor.READER;
- String encoding = getCharacterEncoding();
- Charset charset;
- try {
- charset = encoding != null ? Charset.forName(encoding) : StandardCharsets.UTF_8;
- } catch (IllegalCharsetNameException | UnsupportedCharsetException ex) {
- charset = StandardCharsets.UTF_8;
- }
- return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(body), charset));
- }
-}
diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java
index b92b3cf1af6..b915de149cf 100644
--- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java
+++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java
@@ -1,50 +1,140 @@
package org.tron.core.services.jsonrpc;
import com.fasterxml.jackson.databind.JsonNode;
-import com.googlecode.jsonrpc4j.ErrorData;
import com.googlecode.jsonrpc4j.ErrorResolver;
import com.googlecode.jsonrpc4j.JsonRpcError;
import com.googlecode.jsonrpc4j.JsonRpcErrors;
+import com.googlecode.jsonrpc4j.ProxyUtil;
import com.googlecode.jsonrpc4j.ReflectionUtil;
import java.lang.reflect.Method;
import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.tron.core.exception.TronError;
import org.tron.core.exception.jsonrpc.JsonRpcException;
/**
* {@link ErrorResolver} that uses annotations.
*/
+@Slf4j(topic = "API")
public enum JsonRpcErrorResolver implements ErrorResolver {
INSTANCE;
+ private static final Set SEEN_FAILURES = ConcurrentHashMap.newKeySet();
+
/**
* {@inheritDoc}
*/
@Override
public JsonError resolveError(
Throwable thrownException, Method method, List arguments) {
- JsonRpcError resolver = getResolverForException(thrownException, method);
- if (notFoundResolver(resolver)) {
- return null;
+ Error fatal = findFatalCause(thrownException);
+ if (fatal != null) {
+ throw fatal;
+ }
+
+ JsonRpcError resolver = method == null
+ ? null : getResolverForException(thrownException, method);
+ if (resolver == null) {
+ logUnhandledException(method, thrownException);
+ return new JsonError(JsonError.INTERNAL_ERROR.code, "Internal error", null);
}
String message = hasErrorMessage(resolver) ? resolver.message() : thrownException.getMessage();
+ if (StringUtils.isBlank(message)) {
+ message = defaultMessageFor(resolver.code());
+ }
- // data priority: exception > annotation > default ErrorData
+ // data priority: exception > annotation
Object data = null;
if (thrownException instanceof JsonRpcException) {
JsonRpcException jsonRpcException = (JsonRpcException) thrownException;
data = jsonRpcException.getData();
}
- if (data == null) {
- data = hasErrorData(resolver)
- ? resolver.data()
- : new ErrorData(resolver.exception().getName(), message);
+ if (data == null && hasErrorData(resolver)) {
+ data = resolver.data();
}
-
+
return new JsonError(resolver.code(), message, data);
}
+ static Error findFatalCause(Throwable throwable) {
+ // Avoid allocations when handling memory exhaustion, without truncating deep cause chains.
+ // Check both fast-pointer steps so cycle detection cannot skip a fatal cause inside a cycle.
+ Throwable slow = throwable;
+ Throwable fast = throwable;
+ while (fast != null) {
+ if (isFatal(fast)) {
+ return (Error) fast;
+ }
+ fast = fast.getCause();
+ if (fast == null) {
+ return null;
+ }
+ if (isFatal(fast)) {
+ return (Error) fast;
+ }
+ fast = fast.getCause();
+ slow = slow.getCause();
+ if (fast == slow) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ private static boolean isFatal(Throwable cause) {
+ return cause instanceof VirtualMachineError
+ || cause instanceof ThreadDeath
+ || cause instanceof LinkageError
+ || cause instanceof TronError;
+ }
+
+ private static void logUnhandledException(Method method, Throwable thrownException) {
+ String methodName = rpcMethodName(method);
+ String exceptionName = thrownException.getClass().getName();
+ String key = methodName + '\0' + exceptionName;
+ if (SEEN_FAILURES.add(key)) {
+ // The first occurrence retains the Throwable for diagnosis. Repeated failures omit both
+ // the stack and exception message so a request loop cannot flood the WARN log.
+ logger.warn("Unhandled exception in JSON-RPC method {}", methodName, thrownException);
+ } else {
+ logger.debug("Repeated unhandled exception in JSON-RPC method {} ({})",
+ methodName, exceptionName);
+ }
+ }
+
+ static void clearSeenFailuresForTest() {
+ SEEN_FAILURES.clear();
+ }
+
+ private static String rpcMethodName(Method method) {
+ if (method == null) {
+ return "unknown";
+ }
+ try {
+ return ProxyUtil.getMethodName(method);
+ } catch (RuntimeException e) {
+ return method.getName();
+ }
+ }
+
+ private static String defaultMessageFor(int code) {
+ switch (code) {
+ case -32600:
+ return "Invalid Request";
+ case -32601:
+ return "Method not found";
+ case -32602:
+ return "Invalid params";
+ default:
+ return "Internal error";
+ }
+ }
+
private JsonRpcError getResolverForException(Throwable thrownException, Method method) {
JsonRpcErrors errors = ReflectionUtil.getAnnotation(method, JsonRpcErrors.class);
if (hasAnnotations(errors)) {
@@ -57,10 +147,6 @@ private JsonRpcError getResolverForException(Throwable thrownException, Method m
return null;
}
- private boolean notFoundResolver(JsonRpcError resolver) {
- return resolver == null;
- }
-
private boolean hasErrorMessage(JsonRpcError em) {
// noinspection ConstantConditions
return em.message() != null && !em.message().trim().isEmpty();
@@ -78,4 +164,4 @@ private boolean hasAnnotations(JsonRpcErrors errors) {
private boolean isExceptionInstanceOfError(Throwable target, JsonRpcError em) {
return em.exception().isInstance(target);
}
-}
\ No newline at end of file
+}
diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java
index ca249da4e5d..4f642e7b391 100644
--- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java
+++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java
@@ -8,7 +8,6 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
-import com.googlecode.jsonrpc4j.HttpStatusCodeProvider;
import com.googlecode.jsonrpc4j.JsonRpcInterceptor;
import com.googlecode.jsonrpc4j.JsonRpcServer;
import com.googlecode.jsonrpc4j.ProxyUtil;
@@ -19,6 +18,8 @@
import java.util.Collections;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
+import javax.servlet.ServletResponse;
+import javax.servlet.ServletResponseWrapper;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@@ -27,7 +28,6 @@
import org.tron.common.parameter.CommonParameter;
import org.tron.core.Constant;
import org.tron.core.services.filter.BufferedResponseWrapper;
-import org.tron.core.services.filter.CachedBodyRequestWrapper;
import org.tron.core.services.http.RateLimiterServlet;
@Component
@@ -35,6 +35,7 @@
public class JsonRpcServlet extends RateLimiterServlet {
private static final ObjectMapper MAPPER = buildMapper();
+ private static final int MAX_RESPONSE_WRAPPER_DEPTH = 16;
private static ObjectMapper buildMapper() {
JsonFactory factory = JsonFactory.builder()
@@ -68,6 +69,10 @@ private enum JsonRpcError {
@Autowired
private JsonRpcInterceptor interceptor;
+ void setRpcServer(JsonRpcServer rpcServer) {
+ this.rpcServer = rpcServer;
+ }
+
@Override
public void init(ServletConfig config) throws ServletException {
super.init(config);
@@ -82,19 +87,6 @@ public void init(ServletConfig config) throws ServletException {
rpcServer = new JsonRpcServer(compositeService);
rpcServer.setErrorResolver(JsonRpcErrorResolver.INSTANCE);
- HttpStatusCodeProvider httpStatusCodeProvider = new HttpStatusCodeProvider() {
- @Override
- public int getHttpStatusCode(int resultCode) {
- return 200;
- }
-
- @Override
- public Integer getJsonRpcCode(int httpStatusCode) {
- return null;
- }
- };
- rpcServer.setHttpStatusCodeProvider(httpStatusCodeProvider);
-
rpcServer.setShouldLogInvocationErrors(false);
if (CommonParameter.getInstance().isMetricsPrometheusEnable()) {
rpcServer.setInterceptorList(Collections.singletonList(interceptor));
@@ -103,6 +95,15 @@ public Integer getJsonRpcCode(int httpStatusCode) {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
+ try {
+ doPostInternal(req, resp);
+ } catch (Error fatal) {
+ commitBareInternalServerError(resp);
+ throw fatal;
+ }
+ }
+
+ private void doPostInternal(HttpServletRequest req, HttpServletResponse resp) throws IOException {
CommonParameter parameter = CommonParameter.getInstance();
// Transport IOException from readBody propagates as HTTP 500 (genuine IO failure).
@@ -133,6 +134,15 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I
writeJsonRpcError(resp, JsonRpcError.INVALID_REQUEST, "Invalid Request", null, false);
return;
}
+ if (!isBatch && hasInvalidRequestId(rootNode)) {
+ writeJsonRpcError(resp, JsonRpcError.INVALID_REQUEST, "Invalid Request", null, false);
+ return;
+ }
+ if (!isBatch && hasScalarParams(rootNode)) {
+ writeJsonRpcError(resp, JsonRpcError.INVALID_REQUEST, "Invalid Request",
+ rootNode.get("id"), false);
+ return;
+ }
int batchSize = parameter.getJsonRpcMaxBatchSize();
if (isBatch && batchSize > 0 && rootNode.size() > batchSize) {
writeJsonRpcError(resp, JsonRpcError.EXCEED_LIMIT,
@@ -144,25 +154,35 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I
if (isBatch) {
handleBatch(resp, rootNode, maxResponseSize);
} else {
- handleSingle(req, resp, rootNode, body, maxResponseSize);
+ handleSingle(resp, rootNode, body, maxResponseSize);
}
}
- private void handleSingle(HttpServletRequest req, HttpServletResponse resp,
- JsonNode rootNode, byte[] body, int maxResponseSize) throws IOException {
- CachedBodyRequestWrapper cachedReq = new CachedBodyRequestWrapper(req, body);
+ private void handleSingle(HttpServletResponse resp, JsonNode rootNode, byte[] body,
+ int maxResponseSize) throws IOException {
BufferedResponseWrapper bufferedResp = new BufferedResponseWrapper(
resp, maxResponseSize);
try {
- rpcServer.handle(cachedReq, bufferedResp);
- } catch (RuntimeException e) {
+ // JsonRpcServer.handle catches Throwable and would swallow fatal errors rethrown by the
+ // resolver. Use the lower-level entry point so single and batch requests share a boundary.
+ rpcServer.handleRequest(new ByteArrayInputStream(body), bufferedResp.getOutputStream());
+ } catch (RuntimeException | IOException | Error e) {
+ rethrowIfFatal(e);
logger.error("RPC execution failed", e);
+ if (!rootNode.has("id")) {
+ resp.setContentType("application/json-rpc");
+ resp.setStatus(HttpServletResponse.SC_OK);
+ resp.setContentLength(0);
+ return;
+ }
writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error",
rootNode.get("id"), false);
return;
}
+ bufferedResp.setContentType("application/json-rpc");
+ bufferedResp.setStatus(HttpServletResponse.SC_OK);
bufferedResp.commitToResponse();
if (bufferedResp.isOverflow()) {
writeJsonRpcError(resp, JsonRpcError.RESPONSE_TOO_LARGE,
@@ -177,14 +197,30 @@ private void handleBatch(HttpServletResponse resp, JsonNode rootNode, int maxRes
ArrayNode batchResult = MAPPER.createArrayNode();
int accumulatedSize = 2; // "[]"
boolean overflow = false;
+ BatchFailureLog failureLog = new BatchFailureLog();
for (int i = 0; i < rootNode.size(); i++) {
JsonNode subRequest = rootNode.get(i);
+ if (!subRequest.isObject() || hasInvalidRequestId(subRequest)
+ || hasScalarParams(subRequest)) {
+ ObjectNode errNode = buildErrorNode(JsonRpcError.INVALID_REQUEST, "Invalid Request",
+ subRequest.get("id"));
+ if (!overflow) {
+ byte[] errBytes = MAPPER.writeValueAsBytes(errNode);
+ int addition = errBytes.length + (!batchResult.isEmpty() ? 1 : 0);
+ if (maxResponseSize > 0 && accumulatedSize + addition > maxResponseSize) {
+ overflow = true;
+ } else {
+ accumulatedSize += addition;
+ }
+ }
+ batchResult.add(errNode);
+ continue;
+ }
+
if (overflow) {
- if (!subRequest.isObject()) {
- batchResult.add(buildErrorNode(JsonRpcError.INVALID_REQUEST, "Invalid Request", null));
- } else if (subRequest.has("id")) {
+ if (subRequest.has("id")) {
// Notifications (no "id") do not get a response even on overflow.
batchResult.add(buildErrorNode(JsonRpcError.RESPONSE_TOO_LARGE,
"Response exceeds the limit of " + maxResponseSize + " bytes",
@@ -193,43 +229,14 @@ private void handleBatch(HttpServletResponse resp, JsonNode rootNode, int maxRes
continue;
}
- if (!subRequest.isObject()) {
- ObjectNode errNode = buildErrorNode(JsonRpcError.INVALID_REQUEST, "Invalid Request", null);
- byte[] errBytes = MAPPER.writeValueAsBytes(errNode);
- int addition = errBytes.length + (!batchResult.isEmpty() ? 1 : 0);
- if (maxResponseSize > 0 && accumulatedSize + addition > maxResponseSize) {
- overflow = true;
- } else {
- accumulatedSize += addition;
- }
- batchResult.add(errNode);
- continue;
- }
-
- byte[] subBody;
- try {
- subBody = MAPPER.writeValueAsBytes(subRequest);
- } catch (JsonProcessingException e) {
- writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", null, true);
- return;
- }
-
- ByteArrayOutputStream subOutput = new ByteArrayOutputStream();
- try {
- rpcServer.handleRequest(new ByteArrayInputStream(subBody), subOutput);
- } catch (RuntimeException e) {
- logger.error("RPC execution failed for batch sub-request {}", i, e);
- writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", null, true);
- return;
- }
-
- byte[] responseBytes = subOutput.toByteArray();
+ byte[] responseBytes = executeBatchRequest(subRequest, i, failureLog);
if (responseBytes.length == 0) {
continue; // notification — no response
}
// comma(,) separator between array elements
int addition = responseBytes.length + (!batchResult.isEmpty() ? 1 : 0);
+ // Reject bytes beyond the remaining budget before parsing, even if they are malformed.
if (maxResponseSize > 0 && accumulatedSize + addition > maxResponseSize) {
overflow = true;
batchResult.add(buildErrorNode(JsonRpcError.RESPONSE_TOO_LARGE,
@@ -237,15 +244,24 @@ private void handleBatch(HttpServletResponse resp, JsonNode rootNode, int maxRes
subRequest.get("id")));
continue;
}
- accumulatedSize += addition;
-
JsonNode responseNode;
try {
responseNode = MAPPER.readTree(responseBytes);
} catch (IOException e) {
- writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", null, true);
- return;
+ responseBytes = internalErrorResponse(subRequest.get("id"));
+ responseNode = MAPPER.readTree(responseBytes);
+ // Charge only the replacement, not the discarded malformed response bytes.
+ addition = responseBytes.length + (!batchResult.isEmpty() ? 1 : 0);
+ // A replacement error can be larger than the malformed response it replaces.
+ if (maxResponseSize > 0 && accumulatedSize + addition > maxResponseSize) {
+ overflow = true;
+ batchResult.add(buildErrorNode(JsonRpcError.RESPONSE_TOO_LARGE,
+ "Response exceeds the limit of " + maxResponseSize + " bytes",
+ subRequest.get("id")));
+ continue;
+ }
}
+ accumulatedSize += addition;
batchResult.add(responseNode);
}
@@ -265,6 +281,82 @@ private void handleBatch(HttpServletResponse resp, JsonNode rootNode, int maxRes
resp.getOutputStream().flush();
}
+ private byte[] executeBatchRequest(JsonNode subRequest, int index, BatchFailureLog failureLog)
+ throws IOException {
+ byte[] subBody;
+ try {
+ subBody = MAPPER.writeValueAsBytes(subRequest);
+ } catch (JsonProcessingException e) {
+ return internalErrorResponse(subRequest.get("id"));
+ }
+
+ ByteArrayOutputStream subOutput = new ByteArrayOutputStream();
+ try {
+ rpcServer.handleRequest(new ByteArrayInputStream(subBody), subOutput);
+ } catch (RuntimeException | IOException | Error e) {
+ rethrowIfFatal(e);
+ failureLog.record(index, e);
+ return internalErrorResponse(subRequest.get("id"));
+ }
+ return subOutput.toByteArray();
+ }
+
+ private static class BatchFailureLog {
+ private boolean logged;
+
+ private void record(int index, Throwable failure) {
+ if (!logged) {
+ logged = true;
+ logger.error("RPC execution failed for batch sub-request {}", index, failure);
+ } else {
+ logger.debug("RPC execution failed for batch sub-request {} ({})",
+ index, failure.getClass().getName());
+ }
+ }
+ }
+
+ private byte[] internalErrorResponse(JsonNode id) throws JsonProcessingException {
+ return MAPPER.writeValueAsBytes(
+ buildErrorNode(JsonRpcError.INTERNAL_ERROR, "Internal error", id));
+ }
+
+ private static void rethrowIfFatal(Throwable exception) {
+ Error fatal = JsonRpcErrorResolver.findFatalCause(exception);
+ if (fatal != null) {
+ throw fatal;
+ }
+ }
+
+ private static void commitBareInternalServerError(HttpServletResponse resp) {
+ try {
+ // Response decorators such as CharResponseWrapper do not propagate flushBuffer.
+ // Bound this allocation-free walk and never delegate cleanup through an unresolved wrapper.
+ HttpServletResponse target = resp;
+ int depth = 0;
+ while (target instanceof ServletResponseWrapper) {
+ if (depth++ >= MAX_RESPONSE_WRAPPER_DEPTH) {
+ return;
+ }
+ ServletResponse inner = ((ServletResponseWrapper) target).getResponse();
+ if (inner == target || !(inner instanceof HttpServletResponse)) {
+ return;
+ }
+ target = (HttpServletResponse) inner;
+ }
+ if (target.isCommitted()) {
+ return;
+ }
+ // Best effort: commit an empty response before rethrowing the Error so the container does
+ // not render its default error page, which may expose Throwable details.
+ target.resetBuffer();
+ target.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ target.setContentLength(0);
+ target.flushBuffer();
+ } catch (Throwable ignored) {
+ // Cleanup must never replace the original fatal Error.
+ }
+ }
+
private byte[] readBody(InputStream in) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] tmp = new byte[4096];
@@ -281,7 +373,7 @@ private ObjectNode buildErrorNode(JsonRpcError error, String message, JsonNode i
ObjectNode errNode = errorObj.putObject("error");
errNode.put("code", error.code);
errNode.put("message", message);
- if (id != null && !id.isNull() && !id.isMissingNode()) {
+ if (isValidRequestId(id) && !id.isNull()) {
errorObj.set("id", id);
} else {
errorObj.putNull("id");
@@ -289,6 +381,19 @@ private ObjectNode buildErrorNode(JsonRpcError error, String message, JsonNode i
return errorObj;
}
+ private static boolean hasInvalidRequestId(JsonNode request) {
+ return request.has("id") && !isValidRequestId(request.get("id"));
+ }
+
+ private static boolean hasScalarParams(JsonNode request) {
+ // Explicit null keeps the existing jsonrpc4j dispatch semantics.
+ return request.hasNonNull("params") && !request.get("params").isContainerNode();
+ }
+
+ private static boolean isValidRequestId(JsonNode id) {
+ return id != null && (id.isNull() || id.isTextual() || id.isNumber());
+ }
+
private void writeJsonRpcError(HttpServletResponse resp, JsonRpcError error, String message,
JsonNode id, boolean isBatch) throws IOException {
ObjectNode errorObj = buildErrorNode(error, message, id);
diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java
index 50da763b8b9..0ca897c0eb7 100644
--- a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java
+++ b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java
@@ -73,9 +73,17 @@ BlockResult ethGetBlockByNumber(String bnOrId, Boolean fullTransactionObjects)
throws JsonRpcInvalidParamsException;
@JsonRpcMethod("net_version")
+ @JsonRpcErrors({
+ @JsonRpcError(exception = JsonRpcInternalException.class, code = -32001,
+ message = "Chain identity unavailable", data = "{}"),
+ })
String getNetVersion() throws JsonRpcInternalException;
@JsonRpcMethod("eth_chainId")
+ @JsonRpcErrors({
+ @JsonRpcError(exception = JsonRpcInternalException.class, code = -32001,
+ message = "Chain identity unavailable", data = "{}"),
+ })
String ethChainId() throws JsonRpcInternalException;
@JsonRpcMethod("net_listening")
@@ -328,8 +336,10 @@ Object[] getFilterChanges(String filterId)
@JsonRpcError(exception = JsonRpcMethodNotFoundException.class, code = -32601, data = "{}"),
@JsonRpcError(exception = JsonRpcTooManyResultException.class, code = -32005, data = "{}"),
@JsonRpcError(exception = BadItemException.class, code = -32000, data = "{}"),
- @JsonRpcError(exception = ExecutionException.class, code = -32000, data = "{}"),
- @JsonRpcError(exception = InterruptedException.class, code = -32000, data = "{}"),
+ @JsonRpcError(exception = ExecutionException.class, code = -32000,
+ message = "Internal error", data = "{}"),
+ @JsonRpcError(exception = InterruptedException.class, code = -32000,
+ message = "Internal error", data = "{}"),
@JsonRpcError(exception = ItemNotFoundException.class, code = -32000, data = "{}"),
})
LogFilterElement[] getLogs(FilterRequest fr) throws JsonRpcInvalidParamsException,
@@ -342,8 +352,10 @@ LogFilterElement[] getLogs(FilterRequest fr) throws JsonRpcInvalidParamsExceptio
@JsonRpcError(exception = JsonRpcMethodNotFoundException.class, code = -32601, data = "{}"),
@JsonRpcError(exception = JsonRpcTooManyResultException.class, code = -32005, data = "{}"),
@JsonRpcError(exception = BadItemException.class, code = -32000, data = "{}"),
- @JsonRpcError(exception = ExecutionException.class, code = -32000, data = "{}"),
- @JsonRpcError(exception = InterruptedException.class, code = -32000, data = "{}"),
+ @JsonRpcError(exception = ExecutionException.class, code = -32000,
+ message = "Internal error", data = "{}"),
+ @JsonRpcError(exception = InterruptedException.class, code = -32000,
+ message = "Internal error", data = "{}"),
@JsonRpcError(exception = ItemNotFoundException.class, code = -32000, data = "{}"),
})
LogFilterElement[] getFilterLogs(String filterId) throws JsonRpcInvalidParamsException,
diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java
index 6be47886117..4086507c0fb 100644
--- a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java
+++ b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java
@@ -36,6 +36,7 @@
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@@ -193,6 +194,7 @@ public enum RequestSource {
private final ExecutorService sectionExecutor;
private final NodeInfoService nodeInfoService;
private final Wallet wallet;
+ private final AtomicBoolean chainIdentityLookupFailed = new AtomicBoolean();
@Autowired
private Manager manager;
private final String esName = "query-section";
@@ -424,9 +426,21 @@ public String ethChainId() throws JsonRpcInternalException {
// return hash of genesis block
try {
byte[] chainId = wallet.getBlockCapsuleByNum(0).getBlockId().getBytes();
- return ByteArray.toJsonHex(Arrays.copyOfRange(chainId, chainId.length - 4, chainId.length));
+ String result = ByteArray.toJsonHex(
+ Arrays.copyOfRange(chainId, chainId.length - 4, chainId.length));
+ if (chainIdentityLookupFailed.compareAndSet(true, false)) {
+ logger.info("Chain identity lookup recovered");
+ }
+ return result;
} catch (Exception e) {
- throw new JsonRpcInternalException(e.getMessage());
+ // Mapped errors bypass the resolver's unhandled-exception log, so record the complete
+ // cause once per failure episode at the lookup boundary.
+ if (chainIdentityLookupFailed.compareAndSet(false, true)) {
+ logger.warn("Chain identity lookup failed", e);
+ }
+ // Keep the cause so the resolver can still find a fatal error wrapped in an exception,
+ // and carry the public message on the exception itself rather than the underlying one.
+ throw new JsonRpcInternalException("Chain identity unavailable", e);
}
}
diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogBlockQuery.java b/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogBlockQuery.java
index 2daf2ca3271..4cb0d7039bc 100644
--- a/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogBlockQuery.java
+++ b/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogBlockQuery.java
@@ -158,7 +158,17 @@ private BitSet partialMatch(final int[][] bitIndexes, int section)
// 3. Wait for all results and cache them
Map resultCache = new HashMap<>();
for (Map.Entry> entry : bitIndexResults.entrySet()) {
- BitSet result = entry.getValue().get();
+ BitSet result;
+ try {
+ result = entry.getValue().get();
+ } catch (ExecutionException e) {
+ logger.warn("JSON-RPC log query failed", e.getCause());
+ throw e;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ logger.warn("JSON-RPC log query interrupted", e);
+ throw e;
+ }
if (result != null) {
resultCache.put(entry.getKey(), result);
}
diff --git a/framework/src/test/java/org/tron/core/services/filter/CachedBodyRequestWrapperTest.java b/framework/src/test/java/org/tron/core/services/filter/CachedBodyRequestWrapperTest.java
deleted file mode 100644
index 813b1a61bea..00000000000
--- a/framework/src/test/java/org/tron/core/services/filter/CachedBodyRequestWrapperTest.java
+++ /dev/null
@@ -1,109 +0,0 @@
-package org.tron.core.services.filter;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import org.junit.Test;
-import org.springframework.mock.web.MockHttpServletRequest;
-
-public class CachedBodyRequestWrapperTest {
-
- private static final byte[] BODY = "hello world".getBytes(StandardCharsets.UTF_8);
-
- private static byte[] readFully(javax.servlet.ServletInputStream in) throws IOException {
- ByteArrayOutputStream out = new ByteArrayOutputStream();
- byte[] buf = new byte[128];
- int n;
- while ((n = in.read(buf)) != -1) {
- out.write(buf, 0, n);
- }
- return out.toByteArray();
- }
-
- // --- getInputStream ---
-
- @Test
- public void getInputStream_returnsBodyContent() throws IOException {
- CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY);
- byte[] read = readFully(w.getInputStream());
- assertEquals(new String(BODY, StandardCharsets.UTF_8),
- new String(read, StandardCharsets.UTF_8));
- }
-
- @Test
- public void getInputStream_calledTwice_bothSucceed() throws IOException {
- CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY);
- w.getInputStream();
- // second call of the same accessor is allowed by the servlet spec
- w.getInputStream();
- }
-
- // --- getReader ---
-
- @Test
- public void getReader_returnsBodyContent() throws IOException {
- CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY);
- String line = w.getReader().readLine();
- assertEquals("hello world", line);
- }
-
- @Test
- public void getReader_calledTwice_bothSucceed() throws IOException {
- CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY);
- w.getReader();
- w.getReader();
- }
-
- // --- mutual exclusion ---
-
- @Test(expected = IllegalStateException.class)
- public void getReader_afterGetInputStream_throws() throws IOException {
- CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY);
- w.getInputStream();
- w.getReader();
- }
-
- @Test(expected = IllegalStateException.class)
- public void getInputStream_afterGetReader_throws() throws IOException {
- CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY);
- w.getReader();
- w.getInputStream();
- }
-
- // --- stream contract ---
-
- @Test
- public void getInputStream_isFinished_afterFullRead() throws IOException {
- CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY);
- javax.servlet.ServletInputStream in = w.getInputStream();
- while (in.read() != -1) {
- // drain
- }
- assertTrue(in.isFinished());
- }
-
- @Test
- public void getInputStream_isReady_returnsTrue() {
- CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY);
- assertTrue(w.getInputStream().isReady());
- }
-
- @Test
- public void getInputStream_emptyBody_isFinishedImmediately() {
- CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(),
- new byte[0]);
- assertTrue(w.getInputStream().isFinished());
- }
-
- @Test
- public void getReader_usesRequestCharacterEncoding() throws IOException {
- MockHttpServletRequest req = new MockHttpServletRequest();
- req.setCharacterEncoding("UTF-8");
- byte[] utf8Body = "tron".getBytes(StandardCharsets.UTF_8);
- CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(req, utf8Body);
- assertEquals("tron", w.getReader().readLine());
- }
-}
diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcDispatchContractTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcDispatchContractTest.java
new file mode 100644
index 00000000000..cd37945f85e
--- /dev/null
+++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcDispatchContractTest.java
@@ -0,0 +1,209 @@
+package org.tron.core.services.jsonrpc;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.googlecode.jsonrpc4j.JsonRpcBasicServer;
+import com.googlecode.jsonrpc4j.JsonRpcMethod;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Characterizes the jsonrpc4j dispatch behavior relied on by the JSON-RPC implementation.
+ */
+public class JsonRpcDispatchContractTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private final JsonRpcBasicServer server =
+ new JsonRpcBasicServer(new DispatchServiceImpl(), DispatchService.class);
+
+ @Test
+ public void testNullArrayElementReachesMethodBody() throws Exception {
+ JsonNode response = handle(request("oneArg", "[null]"));
+
+ Assert.assertEquals("one:null", response.get("result").asText());
+ Assert.assertEquals(7, response.get("id").asInt());
+ }
+
+ @Test
+ public void testMissingAndEmptyPositionalParametersFailArityValidation() throws Exception {
+ assertInvalidParams(handle("{\"jsonrpc\":\"2.0\",\"method\":\"oneArg\",\"id\":7}"));
+ assertInvalidParams(handle(request("oneArg", "null")));
+ assertInvalidParams(handle(request("oneArg", "[]")));
+ assertInvalidParams(handle(request("twoArg", "[\"a\"]")));
+ assertInvalidParams(handle(request("twoArg", "[\"a\",\"b\",\"c\"]")));
+ }
+
+ @Test
+ public void testObjectParametersDependOnMethodArity() throws Exception {
+ assertInvalidParams(handle(request("oneArg", "{}")));
+
+ JsonNode response = handle(request("noArg", "{}"));
+ Assert.assertEquals("noArg", response.get("result").asText());
+
+ assertInvalidParams(handle(request("noArg", "{\"a\":1}")));
+ }
+
+ @Test
+ public void testScalarParametersEscapeAsKnownFrameworkDefect() {
+ Assert.assertThrows(IllegalArgumentException.class,
+ () -> handle(request("oneArg", "5")));
+ }
+
+ @Test
+ public void testStringParameterConversionContract() throws Exception {
+ JsonNode objectResponse = handle(request("callStr", "[\"args\",{\"blockNumber\":\"0x1\"}]"));
+ assertParseErrorWithLostId(objectResponse);
+
+ JsonNode arrayResponse = handle(request("callStr", "[\"args\",[1,2]]"));
+ assertParseErrorWithLostId(arrayResponse);
+
+ JsonNode numberResponse = handle(request("callStr", "[\"args\",123]"));
+ Assert.assertEquals("str:123", numberResponse.get("result").asText());
+ Assert.assertEquals(7, numberResponse.get("id").asInt());
+ }
+
+ @Test
+ public void testObjectParameterPreservesRuntimeShape() throws Exception {
+ JsonNode objectResponse = handle(request(
+ "callObj", "[\"args\",{\"blockNumber\":\"0x1\"}]"));
+ Assert.assertEquals("obj:LinkedHashMap:0x1", objectResponse.get("result").asText());
+
+ JsonNode nullResponse = handle(request("callObj", "[\"args\",null]"));
+ Assert.assertEquals("obj:null", nullResponse.get("result").asText());
+
+ JsonNode stringResponse = handle(request("callObj", "[\"args\",\"latest\"]"));
+ Assert.assertEquals("obj:String:latest", stringResponse.get("result").asText());
+ }
+
+ @Test
+ public void testOverloadSelectionUsesParameterCount() throws Exception {
+ Assert.assertEquals("over1:a",
+ handle(request("over", "[\"a\"]")).get("result").asText());
+ Assert.assertEquals("over2:a,b",
+ handle(request("over", "[\"a\",\"b\"]")).get("result").asText());
+ Assert.assertEquals("over2:a,null",
+ handle(request("over", "[\"a\",null]")).get("result").asText());
+ Assert.assertEquals("over1:null",
+ handle(request("over", "[null]")).get("result").asText());
+ assertInvalidParams(handle(request("over", "[]")));
+ assertInvalidParams(handle("{\"jsonrpc\":\"2.0\",\"method\":\"over\",\"id\":7}"));
+ }
+
+ @Test
+ public void testNativeBatchIsolatesScalarParameterDefect() throws Exception {
+ JsonNode response = handle("["
+ + request("oneArg", "5") + ","
+ + requestWithId("oneArg", "[\"ok\"]", 8) + "]");
+
+ Assert.assertTrue(response.isArray());
+ Assert.assertEquals(2, response.size());
+ assertParseErrorWithLostId(response.get(0));
+ Assert.assertEquals("one:ok", response.get(1).get("result").asText());
+ Assert.assertEquals(8, response.get(1).get("id").asInt());
+ }
+
+ private JsonNode handle(String json) throws Exception {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ server.handleRequest(
+ new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), output);
+ return MAPPER.readTree(output.toByteArray());
+ }
+
+ private static String request(String method, String params) {
+ return requestWithId(method, params, 7);
+ }
+
+ private static String requestWithId(String method, String params, int id) {
+ return "{\"jsonrpc\":\"2.0\",\"method\":\"" + method
+ + "\",\"params\":" + params + ",\"id\":" + id + "}";
+ }
+
+ private static void assertInvalidParams(JsonNode response) {
+ JsonNode error = response.get("error");
+ Assert.assertEquals(-32602, error.get("code").asInt());
+ Assert.assertEquals("method parameters invalid", error.get("message").asText());
+ Assert.assertEquals(7, response.get("id").asInt());
+ }
+
+ private static void assertParseErrorWithLostId(JsonNode response) {
+ JsonNode error = response.get("error");
+ Assert.assertEquals(-32700, error.get("code").asInt());
+ Assert.assertEquals("JSON parse error", error.get("message").asText());
+ Assert.assertTrue(response.get("id").isTextual());
+ Assert.assertEquals("null", response.get("id").asText());
+ }
+
+ public interface DispatchService {
+
+ @JsonRpcMethod("noArg")
+ String noArg();
+
+ @JsonRpcMethod("oneArg")
+ String oneArg(String value);
+
+ @JsonRpcMethod("twoArg")
+ String twoArg(String first, String second);
+
+ @JsonRpcMethod("over")
+ String over(String value);
+
+ @JsonRpcMethod("over")
+ String over(String first, String second);
+
+ @JsonRpcMethod("callObj")
+ String callObj(Object arguments, Object block);
+
+ @JsonRpcMethod("callStr")
+ String callStr(Object arguments, String block);
+ }
+
+ public static class DispatchServiceImpl implements DispatchService {
+
+ @Override
+ public String noArg() {
+ return "noArg";
+ }
+
+ @Override
+ public String oneArg(String value) {
+ return "one:" + value;
+ }
+
+ @Override
+ public String twoArg(String first, String second) {
+ return "two:" + first + "," + second;
+ }
+
+ @Override
+ public String over(String value) {
+ return "over1:" + value;
+ }
+
+ @Override
+ public String over(String first, String second) {
+ return "over2:" + first + "," + second;
+ }
+
+ @Override
+ public String callObj(Object arguments, Object block) {
+ if (block == null) {
+ return "obj:null";
+ }
+ if (block instanceof Map) {
+ return "obj:" + block.getClass().getSimpleName() + ":"
+ + ((Map, ?>) block).get("blockNumber");
+ }
+ return "obj:" + block.getClass().getSimpleName() + ":" + block;
+ }
+
+ @Override
+ public String callStr(Object arguments, String block) {
+ return "str:" + block;
+ }
+ }
+}
diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolverTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolverTest.java
index d8e64308ab8..9237095300d 100644
--- a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolverTest.java
+++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolverTest.java
@@ -1,15 +1,24 @@
package org.tron.core.services.jsonrpc;
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.classic.spi.IThrowableProxy;
+import ch.qos.logback.core.read.ListAppender;
import com.fasterxml.jackson.databind.JsonNode;
-import com.googlecode.jsonrpc4j.ErrorData;
import com.googlecode.jsonrpc4j.ErrorResolver.JsonError;
import com.googlecode.jsonrpc4j.JsonRpcError;
import com.googlecode.jsonrpc4j.JsonRpcErrors;
+import com.googlecode.jsonrpc4j.JsonRpcMethod;
import java.lang.reflect.Method;
-import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
+import java.util.concurrent.ExecutionException;
import org.junit.Assert;
+import org.junit.Before;
import org.junit.Test;
+import org.slf4j.LoggerFactory;
+import org.tron.core.exception.TronError;
import org.tron.core.exception.jsonrpc.JsonRpcException;
import org.tron.core.exception.jsonrpc.JsonRpcInternalException;
import org.tron.core.exception.jsonrpc.JsonRpcInvalidParamsException;
@@ -17,27 +26,51 @@
public class JsonRpcErrorResolverTest {
+ private static final List NO_ARGUMENTS = Collections.emptyList();
+
private final JsonRpcErrorResolver resolver = JsonRpcErrorResolver.INSTANCE;
@JsonRpcErrors({
@JsonRpcError(exception = JsonRpcInvalidRequestException.class, code = -32600, data = "{}"),
@JsonRpcError(exception = JsonRpcInvalidParamsException.class, code = -32602, data = "{}"),
@JsonRpcError(exception = JsonRpcInternalException.class, code = -32000, data = "{}"),
+ @JsonRpcError(exception = ExecutionException.class, code = -32000, data = "{}"),
@JsonRpcError(exception = JsonRpcException.class, code = -1)
- })
+ })
public void dummyMethod() {
}
- @Test
- public void testResolveErrorWithTronException() throws Exception {
+ @JsonRpcErrors({
+ @JsonRpcError(exception = IllegalArgumentException.class, code = -32602,
+ message = "annotation message"),
+ @JsonRpcError(exception = NullPointerException.class, code = -32602),
+ @JsonRpcError(exception = IllegalStateException.class, code = -32600),
+ @JsonRpcError(exception = UnsupportedOperationException.class, code = -32601),
+ @JsonRpcError(exception = RuntimeException.class, code = -32000)
+ })
+ public void messageMethod() {
+ }
- String message = "JsonRpcInvalidRequestException";
+ @JsonRpcMethod("test_unmapped")
+ public void unmappedMethod() {
+ }
+ @JsonRpcMethod("test_unmapped_other")
+ public void otherUnmappedMethod() {
+ }
+
+ @Before
+ public void clearSeenFailures() {
+ JsonRpcErrorResolver.clearSeenFailuresForTest();
+ }
+
+ @Test
+ public void testMappedErrorsPreserveCodeAndDataPriority() throws Exception {
+ String message = "JsonRpcInvalidRequestException";
JsonRpcException exception = new JsonRpcInvalidRequestException(message);
- Method method = this.getClass().getMethod("dummyMethod");
- List arguments = new ArrayList<>();
+ Method method = getClass().getMethod("dummyMethod");
- JsonError error = resolver.resolveError(exception, method, arguments);
+ JsonError error = resolver.resolveError(exception, method, NO_ARGUMENTS);
Assert.assertNotNull(error);
Assert.assertEquals(-32600, error.code);
Assert.assertEquals(message, error.message);
@@ -46,7 +79,7 @@ public void testResolveErrorWithTronException() throws Exception {
message = "JsonRpcInternalException";
String data = "JsonRpcInternalException data";
exception = new JsonRpcInternalException(message, data);
- error = resolver.resolveError(exception, method, arguments);
+ error = resolver.resolveError(exception, method, NO_ARGUMENTS);
Assert.assertNotNull(error);
Assert.assertEquals(-32000, error.code);
@@ -54,7 +87,7 @@ public void testResolveErrorWithTronException() throws Exception {
Assert.assertEquals(data, error.data);
exception = new JsonRpcInternalException(message, null);
- error = resolver.resolveError(exception, method, arguments);
+ error = resolver.resolveError(exception, method, NO_ARGUMENTS);
Assert.assertNotNull(error);
Assert.assertEquals(-32000, error.code);
@@ -63,13 +96,251 @@ public void testResolveErrorWithTronException() throws Exception {
message = "JsonRpcException";
exception = new JsonRpcException(message, null);
- error = resolver.resolveError(exception, method, arguments);
+ error = resolver.resolveError(exception, method, NO_ARGUMENTS);
Assert.assertNotNull(error);
Assert.assertEquals(-1, error.code);
Assert.assertEquals(message, error.message);
- Assert.assertTrue(error.data instanceof ErrorData);
+ Assert.assertNull(error.data);
+ }
+
+ @Test
+ public void testUnmappedExceptionsUseSanitizedInternalError() throws Exception {
+ Method method = getClass().getMethod("unmappedMethod");
+ JsonError error = resolver.resolveError(
+ new RuntimeException("sensitive-marker"), method, NO_ARGUMENTS);
+
+ assertInternalError(error);
+ }
+
+ @Test
+ public void testUnmappedExceptionLoggingIsBoundedByMethodAndType() throws Exception {
+ Logger apiLogger = (Logger) LoggerFactory.getLogger("API");
+ Level originalLevel = apiLogger.getLevel();
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ apiLogger.addAppender(appender);
+ apiLogger.setLevel(Level.DEBUG);
+
+ try {
+ Method method = getClass().getMethod("unmappedMethod");
+ resolver.resolveError(new RuntimeException("first-sensitive-marker"), method, NO_ARGUMENTS);
+ resolver.resolveError(new RuntimeException("second-sensitive-marker"), method, NO_ARGUMENTS);
+ resolver.resolveError(new IllegalStateException("third-sensitive-marker"), method,
+ NO_ARGUMENTS);
+ resolver.resolveError(new RuntimeException("fourth-sensitive-marker"),
+ getClass().getMethod("otherUnmappedMethod"), NO_ARGUMENTS);
+
+ Assert.assertEquals(3, countEvents(appender, Level.WARN));
+ Assert.assertEquals(1, countEvents(appender, Level.DEBUG));
+
+ ILoggingEvent firstWarning = findEvent(appender, Level.WARN, "test_unmapped");
+ Assert.assertNotNull(firstWarning);
+ IThrowableProxy throwable = firstWarning.getThrowableProxy();
+ Assert.assertNotNull(throwable);
+ Assert.assertEquals(RuntimeException.class.getName(), throwable.getClassName());
+ Assert.assertEquals("first-sensitive-marker", throwable.getMessage());
+
+ ILoggingEvent repeated = findEvent(appender, Level.DEBUG, "test_unmapped");
+ Assert.assertNotNull(repeated);
+ Assert.assertNull(repeated.getThrowableProxy());
+ Assert.assertFalse(repeated.getFormattedMessage().contains("second-sensitive-marker"));
+ } finally {
+ apiLogger.setLevel(originalLevel);
+ apiLogger.detachAppender(appender);
+ appender.stop();
+ }
+ }
+
+ @Test
+ public void testNullMethodUsesSanitizedInternalError() {
+ JsonError error = resolver.resolveError(
+ new RuntimeException("sensitive-marker"), null, NO_ARGUMENTS);
+
+ assertInternalError(error);
+ }
+
+ @Test
+ public void testMappedMessagePriorityAndDefaults() throws Exception {
+ Method method = getClass().getMethod("messageMethod");
+
+ JsonError error = resolver.resolveError(
+ new IllegalArgumentException("exception message"), method, NO_ARGUMENTS);
+ Assert.assertEquals("annotation message", error.message);
+
+ error = resolver.resolveError(
+ new RuntimeException("filter not found"), method, NO_ARGUMENTS);
+ Assert.assertEquals("filter not found", error.message);
+
+ error = resolver.resolveError(new NullPointerException(), method, NO_ARGUMENTS);
+ Assert.assertEquals("Invalid params", error.message);
+
+ error = resolver.resolveError(new IllegalStateException(" "), method, NO_ARGUMENTS);
+ Assert.assertEquals("Invalid Request", error.message);
+
+ error = resolver.resolveError(new UnsupportedOperationException(), method, NO_ARGUMENTS);
+ Assert.assertEquals("Method not found", error.message);
+
+ error = resolver.resolveError(new RuntimeException(), method, NO_ARGUMENTS);
+ Assert.assertEquals("Internal error", error.message);
+ }
+
+ @Test
+ public void testNonFatalErrorUsesSanitizedInternalError() throws Exception {
+ Method method = getClass().getMethod("unmappedMethod");
+ JsonError error = resolver.resolveError(new AssertionError("sensitive-marker"),
+ method, NO_ARGUMENTS);
+
+ assertInternalError(error);
+ }
+
+ @Test
+ public void testFatalErrorsPropagate() throws Exception {
+ Method method = getClass().getMethod("unmappedMethod");
+
+ assertFatalPropagates(new StackOverflowError("fatal-marker"), method);
+ assertFatalPropagates(new ThreadDeath(), method);
+ assertFatalPropagates(new LinkageError("fatal-marker"), method);
+ assertFatalPropagates(
+ new TronError("fatal-marker", TronError.ErrCode.API_SERVER_INIT), method);
+ }
+
+ @Test
+ public void testWrappedFatalErrorPropagatesActualCause() throws Exception {
+ Method method = getClass().getMethod("dummyMethod");
+ StackOverflowError fatal = new StackOverflowError("fatal-marker");
+
+ Error thrown = Assert.assertThrows(Error.class,
+ () -> resolver.resolveError(new ExecutionException(fatal), method, NO_ARGUMENTS));
+
+ Assert.assertSame(fatal, thrown);
+ }
+
+ @Test(timeout = 5000)
+ public void testCyclicCauseChainTerminates() throws Exception {
+ Method method = getClass().getMethod("unmappedMethod");
+ CyclicException first = new CyclicException("first");
+ CyclicException second = new CyclicException("second");
+ first.setNext(second);
+ second.setNext(first);
+
+ JsonError error = resolver.resolveError(first, method, NO_ARGUMENTS);
+
+ assertInternalError(error);
+ }
+
+ @Test(timeout = 5000)
+ public void testFindFatalCauseWithNull() {
+ Assert.assertNull(JsonRpcErrorResolver.findFatalCause(null));
+ }
+
+ @Test(timeout = 5000)
+ public void testFindFatalCauseWithOrdinaryChain() {
+ Throwable chain = new Exception("outer", new Exception("middle", new Exception("inner")));
+
+ Assert.assertNull(JsonRpcErrorResolver.findFatalCause(chain));
+ }
+
+ @Test(timeout = 5000)
+ public void testFindFatalCauseAtEndOfChain() {
+ StackOverflowError fatal = new StackOverflowError("fatal-marker");
+ Throwable chain = new Exception("outer", new Exception("middle", fatal));
+
+ Assert.assertSame(fatal, JsonRpcErrorResolver.findFatalCause(chain));
+ }
+
+ @Test(timeout = 5000)
+ public void testFindFatalCauseWithWrappedTronError() {
+ TronError fatal = new TronError("fatal-marker", TronError.ErrCode.API_SERVER_INIT);
+
+ Assert.assertSame(fatal, JsonRpcErrorResolver.findFatalCause(new Exception("outer", fatal)));
+ }
+
+ @Test(timeout = 5000)
+ public void testFindFatalCauseWithOrdinaryCycle() {
+ Exception first = new Exception("first");
+ Exception second = new Exception("second");
+ first.initCause(second);
+ second.initCause(first);
+
+ Assert.assertNull(JsonRpcErrorResolver.findFatalCause(first));
+ }
+
+ @Test(timeout = 5000)
+ public void testFindFatalCauseInsideCycleAfterPrefix() {
+ StackOverflowError fatal = new StackOverflowError("fatal-marker");
+ Throwable[] chain = new Throwable[12];
+ for (int i = 0; i < chain.length; i++) {
+ chain[i] = i == 8 ? fatal : new Exception("cause-" + i);
+ }
+ for (int i = 0; i < chain.length - 1; i++) {
+ chain[i].initCause(chain[i + 1]);
+ }
+ chain[11].initCause(chain[6]);
+
+ Assert.assertSame(fatal, JsonRpcErrorResolver.findFatalCause(chain[0]));
+ }
+
+ @Test(timeout = 5000)
+ public void testFindFatalCauseBeyondWrapperDepthLimit() {
+ StackOverflowError fatal = new StackOverflowError("fatal-marker");
+ Throwable chain = fatal;
+ for (int i = 0; i < 64; i++) {
+ chain = new Exception("cause-" + i, chain);
+ }
+
+ Assert.assertSame(fatal, JsonRpcErrorResolver.findFatalCause(chain));
+ }
+
+ private void assertFatalPropagates(Error fatal, Method method) {
+ Error thrown = Assert.assertThrows(Error.class,
+ () -> resolver.resolveError(fatal, method, NO_ARGUMENTS));
+ Assert.assertSame(fatal, thrown);
+ }
+
+ private static void assertInternalError(JsonError error) {
+ Assert.assertNotNull(error);
+ Assert.assertEquals(-32603, error.code);
+ Assert.assertEquals("Internal error", error.message);
+ Assert.assertNull(error.data);
+ }
+
+ private static ILoggingEvent findEvent(ListAppender appender, Level level,
+ String marker) {
+ for (ILoggingEvent event : appender.list) {
+ if (event.getLevel() == level && event.getFormattedMessage().contains(marker)) {
+ return event;
+ }
+ }
+ return null;
+ }
+
+ private static int countEvents(ListAppender appender, Level level) {
+ int count = 0;
+ for (ILoggingEvent event : appender.list) {
+ if (event.getLevel() == level) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private static class CyclicException extends RuntimeException {
+
+ private Throwable next;
+
+ CyclicException(String message) {
+ super(message, null);
+ }
+
+ void setNext(Throwable next) {
+ this.next = next;
+ }
+ @Override
+ public synchronized Throwable getCause() {
+ return next;
+ }
}
-}
\ No newline at end of file
+}
diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorSanitizationIntegrationTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorSanitizationIntegrationTest.java
new file mode 100644
index 00000000000..e9ba99cdd66
--- /dev/null
+++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorSanitizationIntegrationTest.java
@@ -0,0 +1,729 @@
+package org.tron.core.services.jsonrpc;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.googlecode.jsonrpc4j.JsonRpcInterceptor;
+import com.googlecode.jsonrpc4j.JsonRpcMethod;
+import com.googlecode.jsonrpc4j.JsonRpcServer;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.tron.common.parameter.CommonParameter;
+import org.tron.core.exception.ItemNotFoundException;
+import org.tron.core.exception.TronError;
+import org.tron.core.exception.jsonrpc.JsonRpcInternalException;
+
+public class JsonRpcErrorSanitizationIntegrationTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final String SENSITIVE_MARKER = "sensitive-marker";
+
+ private int savedMaxBatchSize;
+ private int savedMaxResponseSize;
+
+ @Before
+ public void setUp() {
+ CommonParameter parameter = CommonParameter.getInstance();
+ savedMaxBatchSize = parameter.jsonRpcMaxBatchSize;
+ savedMaxResponseSize = parameter.jsonRpcMaxResponseSize;
+ parameter.jsonRpcMaxBatchSize = 0;
+ parameter.jsonRpcMaxResponseSize = 0;
+ }
+
+ @After
+ public void tearDown() {
+ CommonParameter parameter = CommonParameter.getInstance();
+ parameter.jsonRpcMaxBatchSize = savedMaxBatchSize;
+ parameter.jsonRpcMaxResponseSize = savedMaxResponseSize;
+ }
+
+ @Test
+ public void testUnmappedExceptionIsSanitizedOnWire() throws Exception {
+ JsonRpcServer server = newServer(new ErrorServiceImpl(), ErrorService.class);
+
+ JsonNode response = handle(server, request("test_unhandled", "[]", 1));
+
+ assertSanitizedInternalError(response, 1);
+ assertNoInternalDetails(response.toString());
+ }
+
+ @Test
+ public void testNonFatalErrorIsSanitizedByRealServer() throws Exception {
+ JsonRpcServer server = newServer(new ErrorServiceImpl(), ErrorService.class);
+
+ JsonNode response = handle(server, request("test_assertion", "[]", 1));
+
+ assertSanitizedInternalError(response, 1);
+ assertNoInternalDetails(response.toString());
+ }
+
+ @Test
+ public void testPreHandleJsonAssertionErrorEscapesRealServer() throws Exception {
+ AssertionError failure = new AssertionError(SENSITIVE_MARKER);
+ FailingInterceptor interceptor = new FailingInterceptor(2, failure);
+ JsonRpcServer server = newServer(new ErrorServiceImpl(), ErrorService.class,
+ Collections.singletonList(interceptor));
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+
+ Assert.assertSame(failure, Assert.assertThrows(AssertionError.class,
+ () -> server.handleRequest(new ByteArrayInputStream(
+ request("test_ok", "[]", 2).getBytes(StandardCharsets.UTF_8)), output)));
+
+ Assert.assertEquals(Collections.singletonList(2), interceptor.dispatched);
+ Assert.assertEquals(0, output.size());
+ }
+
+ @Test
+ public void testServletSingleRecoversInterceptorAssertionError() throws Exception {
+ FailingInterceptor interceptor =
+ new FailingInterceptor(2, new AssertionError(SENSITIVE_MARKER));
+ TestableServlet servlet = newServlet(newServer(new ErrorServiceImpl(), ErrorService.class,
+ Collections.singletonList(interceptor)));
+
+ MockHttpServletResponse response = post(servlet, request("test_ok", "[]", 2));
+
+ assertJsonRpcResponse(response, errorResponse(-32603, "Internal error", "2"));
+ Assert.assertEquals(Collections.singletonList(2), interceptor.dispatched);
+ }
+
+ @Test
+ public void testServletBatchRecoversInterceptorAssertionError() throws Exception {
+ FailingInterceptor interceptor =
+ new FailingInterceptor(2, new AssertionError(SENSITIVE_MARKER));
+ TestableServlet servlet = newServlet(newServer(new ErrorServiceImpl(), ErrorService.class,
+ Collections.singletonList(interceptor)));
+
+ MockHttpServletResponse response = post(servlet,
+ "[" + request("test_ok", "[]", 1) + "," + request("test_ok", "[]", 2)
+ + "," + request("test_ok", "[]", 3) + "]");
+
+ assertJsonRpcResponse(response, MAPPER.createArrayNode()
+ .add(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":1}"))
+ .add(errorResponse(-32603, "Internal error", "2"))
+ .add(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":3}")));
+ Assert.assertEquals(Arrays.asList(1, 2, 3), interceptor.dispatched);
+ }
+
+ @Test
+ public void testServletSinglePropagatesInterceptorFatalError() throws Exception {
+ TronError fatal = new TronError("fatal-marker", TronError.ErrCode.API_SERVER_INIT);
+ assertInterceptorFatalPropagates(fatal, fatal, false);
+ }
+
+ @Test
+ public void testServletBatchPropagatesInterceptorFatalError() throws Exception {
+ StackOverflowError fatal = new StackOverflowError("fatal-marker");
+ assertInterceptorFatalPropagates(fatal, fatal, true);
+ }
+
+ @Test
+ public void testServletSinglePropagatesInterceptorWrappedFatalError() throws Exception {
+ StackOverflowError fatal = new StackOverflowError("fatal-marker");
+ assertInterceptorFatalPropagates(new AssertionError("wrapper-marker", fatal), fatal, false);
+ }
+
+ @Test
+ public void testServletBatchPropagatesInterceptorWrappedFatalError() throws Exception {
+ TronError fatal = new TronError("fatal-marker", TronError.ErrCode.API_SERVER_INIT);
+ assertInterceptorFatalPropagates(new AssertionError("wrapper-marker", fatal), fatal, true);
+ }
+
+ @Test
+ public void testServletForwardsUnknownMethodNotificationError() throws Exception {
+ assertNotificationErrorIsForwarded("test_missing", -32601, "method not found");
+ }
+
+ @Test
+ public void testServletForwardsArityNotificationError() throws Exception {
+ assertNotificationErrorIsForwarded("test_echo", -32602, "method parameters invalid");
+ }
+
+ @Test
+ public void testServletForwardsUnmappedNotificationError() throws Exception {
+ assertNotificationErrorIsForwarded("test_unhandled", -32603, "Internal error");
+ }
+
+ @Test
+ public void testFatalErrorsEscapeRealServer() throws Exception {
+ ErrorServiceImpl service = new ErrorServiceImpl();
+ JsonRpcServer server = newServer(service, ErrorService.class);
+
+ StackOverflowError direct = Assert.assertThrows(StackOverflowError.class,
+ () -> handle(server, request("test_fatal", "[]", 1)));
+ Assert.assertSame(service.directFatal, direct);
+
+ TronError tronError = Assert.assertThrows(TronError.class,
+ () -> handle(server, request("test_tron_error", "[]", 2)));
+ Assert.assertSame(service.tronError, tronError);
+
+ TronJsonRpc mappedService = mock(TronJsonRpc.class);
+ StackOverflowError wrappedFatal = new StackOverflowError("wrapped-fatal-marker");
+ when(mappedService.getLogs(any(TronJsonRpc.FilterRequest.class)))
+ .thenThrow(new ExecutionException(wrappedFatal));
+ JsonRpcServer mappedServer = newServer(mappedService, TronJsonRpc.class);
+
+ StackOverflowError wrapped = Assert.assertThrows(StackOverflowError.class,
+ () -> handle(mappedServer, request("eth_getLogs", "[{}]", 2)));
+ Assert.assertSame(wrappedFatal, wrapped);
+ }
+
+ @Test
+ public void testMappedExecutionExceptionUsesFixedMessage() throws Exception {
+ TronJsonRpc service = mock(TronJsonRpc.class);
+ when(service.getLogs(any(TronJsonRpc.FilterRequest.class)))
+ .thenThrow(new ExecutionException(new NullPointerException(SENSITIVE_MARKER)));
+ JsonRpcServer server = newServer(service, TronJsonRpc.class);
+
+ JsonNode response = handle(server, request("eth_getLogs", "[{}]", 1));
+
+ assertMappedInternalError(response, 1);
+ assertNoInternalDetails(response.toString());
+ }
+
+ @Test
+ public void testMappedInterruptedExceptionUsesFixedMessage() throws Exception {
+ Thread.interrupted();
+ try {
+ TronJsonRpc service = mock(TronJsonRpc.class);
+ when(service.getLogs(any(TronJsonRpc.FilterRequest.class))).thenAnswer(invocation -> {
+ Thread.currentThread().interrupt();
+ throw new InterruptedException();
+ });
+ JsonRpcServer server = newServer(service, TronJsonRpc.class);
+
+ JsonNode response = handle(server, request("eth_getLogs", "[{}]", 1));
+
+ assertMappedInternalError(response, 1);
+ Assert.assertFalse(response.toString().contains("InterruptedException"));
+ Assert.assertTrue(Thread.currentThread().isInterrupted());
+ } finally {
+ Thread.interrupted();
+ }
+ }
+
+ @Test
+ public void testGetFilterLogsAsyncFailuresUseFixedMessage() throws Exception {
+ TronJsonRpc service = mock(TronJsonRpc.class);
+ when(service.getFilterLogs("0xdeadbeef"))
+ .thenThrow(new ExecutionException(new NullPointerException(SENSITIVE_MARKER)))
+ .thenThrow(new InterruptedException());
+ JsonRpcServer server = newServer(service, TronJsonRpc.class);
+
+ JsonNode executionResponse = handle(server,
+ request("eth_getFilterLogs", "[\"0xdeadbeef\"]", 1));
+ JsonNode interruptedResponse = handle(server,
+ request("eth_getFilterLogs", "[\"0xdeadbeef\"]", 2));
+
+ assertMappedInternalError(executionResponse, 1);
+ assertMappedInternalError(interruptedResponse, 2);
+ assertNoInternalDetails(executionResponse.toString());
+ Assert.assertFalse(interruptedResponse.toString().contains("InterruptedException"));
+ }
+
+ @Test
+ public void testChainIdentityKeepsDocumentedCodeAndSanitizesDetails() throws Exception {
+ TronJsonRpc service = mock(TronJsonRpc.class);
+ // Shaped like production: the exception carries a cause, so the response must withhold the
+ // exception message, the cause message and both type names.
+ when(service.ethChainId()).thenThrow(
+ new JsonRpcInternalException(SENSITIVE_MARKER, new RuntimeException(SENSITIVE_MARKER)));
+ when(service.getNetVersion()).thenThrow(
+ new JsonRpcInternalException(SENSITIVE_MARKER, new RuntimeException(SENSITIVE_MARKER)));
+ JsonRpcServer server = newServer(service, TronJsonRpc.class);
+
+ JsonNode chainId = handle(server, request("eth_chainId", "[]", 1));
+ JsonNode netVersion = handle(server, request("net_version", "[]", 2));
+
+ // -32001 is documented as JSON_RPC_UNDERLYING_INTERNAL_ERROR for chain identity lookups,
+ // so the code is kept while the underlying message and exception type are withheld.
+ assertChainIdentityError(chainId, 1);
+ assertChainIdentityError(netVersion, 2);
+ assertNoInternalDetails(chainId.toString());
+ assertNoInternalDetails(netVersion.toString());
+ }
+
+ @Test
+ public void testMappedBusinessMessageIsPreserved() throws Exception {
+ TronJsonRpc service = mock(TronJsonRpc.class);
+ when(service.getFilterLogs("0xdeadbeef"))
+ .thenThrow(new ItemNotFoundException("filter not found"));
+ JsonRpcServer server = newServer(service, TronJsonRpc.class);
+
+ JsonNode response = handle(server,
+ request("eth_getFilterLogs", "[\"0xdeadbeef\"]", 1));
+
+ JsonNode error = response.get("error");
+ Assert.assertEquals(-32000, error.get("code").asInt());
+ Assert.assertEquals("filter not found", error.get("message").asText());
+ Assert.assertEquals("{}", error.get("data").asText());
+ }
+
+ @Test
+ public void testServletBatchIsolatesInvalidElement() throws Exception {
+ TestableServlet servlet = newServlet(new ErrorServiceImpl());
+
+ MockHttpServletResponse response = post(servlet,
+ "[null," + request("test_ok", "[]", 2) + "]");
+ JsonNode body = MAPPER.readTree(response.getContentAsByteArray());
+
+ Assert.assertTrue(body.isArray());
+ Assert.assertEquals(2, body.size());
+ Assert.assertEquals(-32600, body.get(0).get("error").get("code").asInt());
+ Assert.assertTrue(body.get(0).get("id").isNull());
+ Assert.assertEquals("ok", body.get(1).get("result").asText());
+ Assert.assertEquals(2, body.get(1).get("id").asInt());
+ }
+
+ @Test
+ public void testServletBatchIsolatesUnhandledException() throws Exception {
+ TestableServlet servlet = newServlet(new ErrorServiceImpl());
+
+ MockHttpServletResponse response = post(servlet,
+ "[" + request("test_unhandled", "[]", 1) + ","
+ + request("test_ok", "[]", 2) + "]");
+ JsonNode body = MAPPER.readTree(response.getContentAsByteArray());
+
+ Assert.assertTrue(body.isArray());
+ Assert.assertEquals(2, body.size());
+ assertSanitizedInternalError(body.get(0), 1);
+ Assert.assertEquals("ok", body.get(1).get("result").asText());
+ Assert.assertEquals(2, body.get(1).get("id").asInt());
+ assertNoInternalDetails(body.toString());
+ }
+
+ @Test
+ public void testServletSingleRequestPreservesTransportContract() throws Exception {
+ TestableServlet servlet = newServlet(new ErrorServiceImpl());
+
+ MockHttpServletResponse response = post(servlet,
+ request("test_ok", "[]", 1));
+ JsonNode body = MAPPER.readTree(response.getContentAsByteArray());
+
+ Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+ Assert.assertEquals("application/json-rpc", response.getContentType());
+ Assert.assertEquals("ok", body.get("result").asText());
+ Assert.assertEquals(1, body.get("id").asInt());
+ }
+
+ @Test
+ public void testServletSingleNotificationReturnsEmptyBody() throws Exception {
+ TestableServlet servlet = newServlet(new ErrorServiceImpl());
+
+ MockHttpServletResponse response = post(servlet,
+ "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":[]}");
+
+ Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+ Assert.assertEquals("application/json-rpc", response.getContentType());
+ Assert.assertEquals(0, response.getContentAsByteArray().length);
+ }
+
+ @Test
+ public void testServletRejectsNonNullScalarParamsAsInvalidRequest() throws Exception {
+ TestableServlet servlet = newServlet(new ErrorServiceImpl());
+ String[] scalarParams = {"5", "\"value\"", "true"};
+
+ for (int i = 0; i < scalarParams.length; i++) {
+ int id = i + 1;
+ MockHttpServletResponse response = post(servlet,
+ request("test_ok", scalarParams[i], id));
+ JsonNode body = MAPPER.readTree(response.getContentAsByteArray());
+
+ Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+ Assert.assertEquals("application/json-rpc", response.getContentType());
+ assertInvalidRequest(body);
+ Assert.assertEquals(MAPPER.getNodeFactory().numberNode(id), body.get("id"));
+ }
+ }
+
+ @Test
+ public void testServletScalarParamsApplyRequestIdRules() throws Exception {
+ TestableServlet servlet = newServlet(new ErrorServiceImpl());
+
+ MockHttpServletResponse stringIdResponse = post(servlet,
+ "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":5,"
+ + "\"id\":\"request-1\"}");
+ assertInvalidRequestResponse(stringIdResponse,
+ MAPPER.getNodeFactory().textNode("request-1"));
+
+ MockHttpServletResponse missingIdResponse = post(servlet,
+ "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":5}");
+ assertInvalidRequestResponse(missingIdResponse, MAPPER.getNodeFactory().nullNode());
+
+ MockHttpServletResponse nullIdResponse = post(servlet,
+ "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":5,\"id\":null}");
+ assertInvalidRequestResponse(nullIdResponse, MAPPER.getNodeFactory().nullNode());
+
+ MockHttpServletResponse invalidIdResponse = post(servlet,
+ "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":5,\"id\":true}");
+ assertInvalidRequestResponse(invalidIdResponse, MAPPER.getNodeFactory().nullNode());
+ }
+
+ @Test
+ public void testServletValidatesScalarParamsBeforeMethodLookup() throws Exception {
+ TestableServlet servlet = newServlet(new ErrorServiceImpl());
+
+ MockHttpServletResponse scalarResponse = post(servlet,
+ request("test_unknown", "5", 1));
+ JsonNode scalarBody = MAPPER.readTree(scalarResponse.getContentAsByteArray());
+ assertInvalidRequest(scalarBody);
+ Assert.assertEquals(MAPPER.getNodeFactory().numberNode(1), scalarBody.get("id"));
+
+ MockHttpServletResponse structuredResponse = post(servlet,
+ request("test_unknown", "[]", 2));
+ JsonNode structuredBody = MAPPER.readTree(structuredResponse.getContentAsByteArray());
+ JsonNode structuredError = structuredBody.get("error");
+ Assert.assertEquals(MAPPER.getNodeFactory().numberNode(-32601),
+ structuredError.get("code"));
+ Assert.assertEquals(MAPPER.getNodeFactory().textNode("method not found"),
+ structuredError.get("message"));
+ Assert.assertFalse(structuredError.has("data"));
+ Assert.assertEquals(MAPPER.getNodeFactory().numberNode(2), structuredBody.get("id"));
+ }
+
+ @Test
+ public void testServletForwardsNullArrayAndObjectParamsToDispatch() throws Exception {
+ TestableServlet servlet = newServlet(new ErrorServiceImpl());
+
+ MockHttpServletResponse noArgResponse = post(servlet,
+ request("test_ok", "null", 1));
+ JsonNode noArgBody = MAPPER.readTree(noArgResponse.getContentAsByteArray());
+ Assert.assertEquals(MAPPER.getNodeFactory().textNode("ok"), noArgBody.get("result"));
+ Assert.assertEquals(MAPPER.getNodeFactory().numberNode(1), noArgBody.get("id"));
+
+ MockHttpServletResponse oneArgResponse = post(servlet,
+ request("test_echo", "[]", 2));
+ JsonNode oneArgBody = MAPPER.readTree(oneArgResponse.getContentAsByteArray());
+ JsonNode error = oneArgBody.get("error");
+ Assert.assertEquals(MAPPER.getNodeFactory().numberNode(-32602), error.get("code"));
+ Assert.assertEquals(MAPPER.getNodeFactory().textNode("method parameters invalid"),
+ error.get("message"));
+ Assert.assertEquals(MAPPER.getNodeFactory().numberNode(2), oneArgBody.get("id"));
+
+ MockHttpServletResponse objectResponse = post(servlet,
+ request("test_ok", "{}", 3));
+ JsonNode objectBody = MAPPER.readTree(objectResponse.getContentAsByteArray());
+ Assert.assertEquals(MAPPER.getNodeFactory().textNode("ok"), objectBody.get("result"));
+ Assert.assertEquals(MAPPER.getNodeFactory().numberNode(3), objectBody.get("id"));
+ }
+
+ @Test
+ public void testFatalErrorEscapesServlet() {
+ ErrorServiceImpl service = new ErrorServiceImpl();
+ TestableServlet servlet = newServlet(service);
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ StackOverflowError thrown = Assert.assertThrows(StackOverflowError.class,
+ () -> post(servlet, request("test_fatal", "[]", 1), response));
+
+ Assert.assertSame(service.directFatal, thrown);
+ assertBareInternalServerError(response);
+ }
+
+ @Test
+ public void testMappedFatalErrorEscapesServlet() throws Exception {
+ TronJsonRpc service = mock(TronJsonRpc.class);
+ StackOverflowError fatal = new StackOverflowError("wrapped-fatal-marker");
+ when(service.getLogs(any(TronJsonRpc.FilterRequest.class)))
+ .thenThrow(new ExecutionException(fatal));
+ TestableServlet servlet = newServlet(service, TronJsonRpc.class);
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ StackOverflowError thrown = Assert.assertThrows(StackOverflowError.class,
+ () -> post(servlet, request("eth_getLogs", "[{}]", 1), response));
+
+ Assert.assertSame(fatal, thrown);
+ assertBareInternalServerError(response);
+ }
+
+ @Test
+ public void testFatalErrorDiscardsAccumulatedBatchResponse() {
+ ErrorServiceImpl service = new ErrorServiceImpl();
+ TestableServlet servlet = newServlet(service);
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ StackOverflowError thrown = Assert.assertThrows(StackOverflowError.class,
+ () -> post(servlet,
+ "[" + request("test_ok", "[]", 1) + ","
+ + request("test_fatal", "[]", 2) + "]", response));
+
+ Assert.assertSame(service.directFatal, thrown);
+ assertBareInternalServerError(response);
+ }
+
+ private static JsonRpcServer newServer(Object service, Class> serviceInterface) {
+ return newServer(service, serviceInterface, Collections.emptyList());
+ }
+
+ private static JsonRpcServer newServer(Object service, Class> serviceInterface,
+ List interceptors) {
+ JsonRpcServer server = new JsonRpcServer(service, serviceInterface);
+ server.setErrorResolver(JsonRpcErrorResolver.INSTANCE);
+ server.setShouldLogInvocationErrors(false);
+ server.setInterceptorList(interceptors);
+ return server;
+ }
+
+ private static TestableServlet newServlet(ErrorServiceImpl service) {
+ return newServlet(service, ErrorService.class);
+ }
+
+ private static TestableServlet newServlet(Object service, Class> serviceInterface) {
+ return newServlet(newServer(service, serviceInterface));
+ }
+
+ private static TestableServlet newServlet(JsonRpcServer server) {
+ TestableServlet servlet = new TestableServlet();
+ servlet.setRpcServer(server);
+ return servlet;
+ }
+
+ private static JsonNode handle(JsonRpcServer server, String json) throws Exception {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ server.handleRequest(
+ new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), output);
+ return MAPPER.readTree(output.toByteArray());
+ }
+
+ private static MockHttpServletResponse post(TestableServlet servlet, String body)
+ throws Exception {
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ post(servlet, body, response);
+ return response;
+ }
+
+ private static void post(TestableServlet servlet, String body,
+ MockHttpServletResponse response) throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/jsonrpc");
+ request.setContent(body.getBytes(StandardCharsets.UTF_8));
+ servlet.callDoPost(request, response);
+ }
+
+ private static String request(String method, String params, int id) {
+ return "{\"jsonrpc\":\"2.0\",\"method\":\"" + method
+ + "\",\"params\":" + params + ",\"id\":" + id + "}";
+ }
+
+ private static void assertNotificationErrorIsForwarded(String method, int code, String message)
+ throws Exception {
+ TestableServlet servlet = newServlet(new ErrorServiceImpl());
+ String notification = "{\"jsonrpc\":\"2.0\",\"method\":\"" + method + "\",\"params\":[]}";
+ JsonNode expected = errorResponse(code, message, "null");
+
+ assertJsonRpcResponse(post(servlet, notification), expected);
+ assertJsonRpcResponse(post(servlet,
+ "[" + notification + "," + request("test_ok", "[]", 3) + "]"),
+ MAPPER.createArrayNode().add(expected)
+ .add(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":3}")));
+ }
+
+ private static JsonNode errorResponse(int code, String message, String id) throws IOException {
+ return MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":" + code
+ + ",\"message\":\"" + message + "\"},\"id\":" + id + "}");
+ }
+
+ private static void assertJsonRpcResponse(MockHttpServletResponse response, JsonNode expected)
+ throws IOException {
+ Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+ Assert.assertEquals("application/json-rpc", response.getContentType());
+ Assert.assertEquals(expected, MAPPER.reader()
+ .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
+ .readTree(response.getContentAsByteArray()));
+ assertNoInternalDetails(response.getContentAsString());
+ }
+
+ private static void assertInterceptorFatalPropagates(Error failure, Error fatal, boolean batch)
+ throws Exception {
+ FailingInterceptor interceptor = new FailingInterceptor(2, failure);
+ TestableServlet servlet = newServlet(newServer(new ErrorServiceImpl(), ErrorService.class,
+ Collections.singletonList(interceptor)));
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ String request = request("test_ok", "[]", 2);
+ if (batch) {
+ request = "[" + request("test_ok", "[]", 1) + "," + request + ","
+ + request("test_ok", "[]", 3) + "]";
+ }
+ String body = request;
+
+ Assert.assertSame(fatal, Assert.assertThrows(Error.class, () -> post(servlet, body, response)));
+
+ assertBareInternalServerError(response);
+ Assert.assertEquals(batch ? Arrays.asList(1, 2) : Collections.singletonList(2),
+ interceptor.dispatched);
+ }
+
+ private static void assertSanitizedInternalError(JsonNode response, int id) {
+ JsonNode error = response.get("error");
+ Assert.assertEquals(-32603, error.get("code").asInt());
+ Assert.assertEquals("Internal error", error.get("message").asText());
+ Assert.assertFalse(error.has("data"));
+ Assert.assertEquals(id, response.get("id").asInt());
+ }
+
+ private static void assertInvalidRequest(JsonNode response) {
+ Assert.assertTrue(response.isObject());
+ Assert.assertEquals(MAPPER.getNodeFactory().textNode("2.0"), response.get("jsonrpc"));
+ JsonNode error = response.get("error");
+ Assert.assertEquals(MAPPER.getNodeFactory().numberNode(-32600), error.get("code"));
+ Assert.assertEquals(MAPPER.getNodeFactory().textNode("Invalid Request"),
+ error.get("message"));
+ Assert.assertFalse(error.has("data"));
+ }
+
+ private static void assertInvalidRequestResponse(MockHttpServletResponse response,
+ JsonNode expectedId) throws Exception {
+ Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+ Assert.assertEquals("application/json-rpc", response.getContentType());
+ JsonNode body = MAPPER.readTree(response.getContentAsByteArray());
+ assertInvalidRequest(body);
+ Assert.assertEquals(expectedId, body.get("id"));
+ }
+
+ private static void assertMappedInternalError(JsonNode response, int id) {
+ JsonNode error = response.get("error");
+ Assert.assertEquals(-32000, error.get("code").asInt());
+ Assert.assertEquals("Internal error", error.get("message").asText());
+ Assert.assertEquals("{}", error.get("data").asText());
+ Assert.assertEquals(id, response.get("id").asInt());
+ }
+
+ private static void assertChainIdentityError(JsonNode response, int id) {
+ JsonNode error = response.get("error");
+ Assert.assertEquals(-32001, error.get("code").asInt());
+ Assert.assertEquals("Chain identity unavailable", error.get("message").asText());
+ Assert.assertEquals("{}", error.get("data").asText());
+ Assert.assertEquals(id, response.get("id").asInt());
+ }
+
+ private static void assertNoInternalDetails(String response) {
+ Assert.assertFalse(response.contains(SENSITIVE_MARKER));
+ Assert.assertFalse(response.contains("java."));
+ Assert.assertFalse(response.contains("NullPointerException"));
+ Assert.assertFalse(response.contains("RuntimeException"));
+ }
+
+ private static void assertBareInternalServerError(MockHttpServletResponse response) {
+ Assert.assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, response.getStatus());
+ Assert.assertEquals(0, response.getContentAsByteArray().length);
+ Assert.assertTrue(response.isCommitted());
+ }
+
+ public interface ErrorService {
+
+ @JsonRpcMethod("test_ok")
+ String ok();
+
+ @JsonRpcMethod("test_echo")
+ String echo(String value);
+
+ @JsonRpcMethod("test_unhandled")
+ String unhandled();
+
+ @JsonRpcMethod("test_assertion")
+ String assertion();
+
+ @JsonRpcMethod("test_fatal")
+ String fatal();
+
+ @JsonRpcMethod("test_tron_error")
+ String tronError();
+ }
+
+ public static class ErrorServiceImpl implements ErrorService {
+
+ private final StackOverflowError directFatal =
+ new StackOverflowError("direct-fatal-marker");
+ private final TronError tronError =
+ new TronError("tron-fatal-marker", TronError.ErrCode.API_SERVER_INIT);
+
+ @Override
+ public String ok() {
+ return "ok";
+ }
+
+ @Override
+ public String echo(String value) {
+ return value;
+ }
+
+ @Override
+ public String unhandled() {
+ throw new RuntimeException(SENSITIVE_MARKER);
+ }
+
+ @Override
+ public String assertion() {
+ throw new AssertionError(SENSITIVE_MARKER);
+ }
+
+ @Override
+ public String fatal() {
+ throw directFatal;
+ }
+
+ @Override
+ public String tronError() {
+ throw tronError;
+ }
+ }
+
+ private static class FailingInterceptor implements JsonRpcInterceptor {
+
+ private final int failureId;
+ private final Error failure;
+ private final List dispatched = new ArrayList<>();
+
+ private FailingInterceptor(int failureId, Error failure) {
+ this.failureId = failureId;
+ this.failure = failure;
+ }
+
+ @Override
+ public void preHandleJson(JsonNode json) {
+ int id = json.get("id").intValue();
+ dispatched.add(id);
+ if (id == failureId) {
+ throw failure;
+ }
+ }
+
+ @Override
+ public void preHandle(Object target, Method method, List params) {
+ }
+
+ @Override
+ public void postHandle(Object target, Method method, List params, JsonNode result) {
+ }
+
+ @Override
+ public void postHandleJson(JsonNode json) {
+ }
+ }
+
+ private static class TestableServlet extends JsonRpcServlet {
+
+ void callDoPost(HttpServletRequest request, HttpServletResponse response)
+ throws IOException {
+ doPost(request, response);
+ }
+ }
+}
diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java
new file mode 100644
index 00000000000..0411d5ceac7
--- /dev/null
+++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java
@@ -0,0 +1,300 @@
+package org.tron.core.services.jsonrpc;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.googlecode.jsonrpc4j.JsonRpcMethod;
+import com.googlecode.jsonrpc4j.JsonRpcServer;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.net.SocketException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import javax.servlet.DispatcherType;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.http.ConnectionClosedException;
+import org.apache.http.HttpEntity;
+import org.apache.http.NoHttpResponseException;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.eclipse.jetty.servlet.FilterHolder;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.tron.common.TestConstants;
+import org.tron.common.application.HttpService;
+import org.tron.common.utils.PublicMethod;
+import org.tron.core.config.args.Args;
+import org.tron.core.services.filter.HttpInterceptor;
+import org.tron.core.services.http.RateLimiterServlet;
+import org.tron.core.services.ratelimiter.RateLimiterContainer;
+
+public class JsonRpcServletJettyTest {
+
+ private static final String FATAL_MARKER = "fatal-response-marker";
+ private static final String OBSERVATION_HEADER = "X-Test-Observation";
+
+ @ClassRule
+ public static final TemporaryFolder TEMPORARY_FOLDER = new TemporaryFolder();
+
+ private TestJsonRpcHttpService httpService;
+ private CloseableHttpClient client;
+ private FatalServiceImpl fatalService;
+ private final Map> commitObservations =
+ new ConcurrentHashMap<>();
+
+ @Before
+ public void setUp() throws Exception {
+ Args.setParam(new String[]{"-d", TEMPORARY_FOLDER.newFolder().toString()},
+ TestConstants.TEST_CONF);
+
+ fatalService = new FatalServiceImpl();
+ client = HttpClients.custom().disableAutomaticRetries()
+ .setDefaultRequestConfig(RequestConfig.custom()
+ .setConnectTimeout(5000).setSocketTimeout(5000).build())
+ .build();
+ }
+
+ private URI startServer(boolean withFilter) throws Exception {
+ JsonRpcServer rpcServer = new JsonRpcServer(fatalService, FatalService.class);
+ rpcServer.setErrorResolver(JsonRpcErrorResolver.INSTANCE);
+ rpcServer.setShouldLogInvocationErrors(false);
+
+ TestJsonRpcServlet servlet = new TestJsonRpcServlet(rpcServer);
+ Field containerField = RateLimiterServlet.class.getDeclaredField("container");
+ containerField.setAccessible(true);
+ containerField.set(servlet, new RateLimiterContainer());
+
+ int port = PublicMethod.chooseRandomPort();
+ httpService = new TestJsonRpcHttpService(port, servlet, withFilter, commitObservations);
+ httpService.start().get(10, TimeUnit.SECONDS);
+ return new URI(String.format("http://localhost:%d/jsonrpc", port));
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ try {
+ if (client != null) {
+ client.close();
+ }
+ } finally {
+ try {
+ if (httpService != null) {
+ httpService.stop();
+ }
+ } finally {
+ Args.clearParam();
+ }
+ }
+ }
+
+ @Test
+ public void fatalErrorDoesNotReachJettyDefaultErrorPage() throws Exception {
+ URI endpoint = startServer(false);
+ HttpPost request = new HttpPost(endpoint);
+ request.setHeader("Accept", "application/json");
+ request.setEntity(new StringEntity(
+ "{\"jsonrpc\":\"2.0\",\"method\":\"test_fatal\",\"params\":[],\"id\":1}",
+ ContentType.APPLICATION_JSON));
+
+ try (CloseableHttpResponse response = client.execute(request)) {
+ HttpEntity entity = response.getEntity();
+ byte[] body = entity == null ? new byte[0] : EntityUtils.toByteArray(entity);
+ String text = new String(body, StandardCharsets.UTF_8);
+
+ Assert.assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ response.getStatusLine().getStatusCode());
+ Assert.assertEquals(0, body.length);
+ Assert.assertFalse(text.contains(FATAL_MARKER));
+ Assert.assertFalse(text.contains(StackOverflowError.class.getName()));
+ } catch (NoHttpResponseException | ConnectionClosedException | SocketException expected) {
+ // A fatal Error may close the connection after the best-effort empty 500 is committed.
+ }
+
+ Assert.assertTrue("the request must reach the JSON-RPC method", fatalService.invoked.get());
+ }
+
+ @Test
+ public void fatalErrorWithProductionFilterDoesNotReachJettyDefaultErrorPage() throws Exception {
+ URI endpoint = startServer(true);
+ String[] acceptTypes = {"application/json", "text/html", "text/plain"};
+ for (String acceptType : acceptTypes) {
+ fatalService.invoked.set(false);
+ CompletableFuture committed = new CompletableFuture<>();
+ commitObservations.put(acceptType, committed);
+ HttpPost request = new HttpPost(endpoint);
+ request.setHeader("Accept", acceptType);
+ request.setHeader("Connection", "close");
+ request.setHeader(OBSERVATION_HEADER, acceptType);
+ request.setEntity(new StringEntity(
+ "{\"jsonrpc\":\"2.0\",\"method\":\"test_fatal\",\"params\":[],\"id\":1}",
+ ContentType.APPLICATION_JSON));
+
+ try (CloseableHttpResponse response = client.execute(request)) {
+ HttpEntity entity = response.getEntity();
+ byte[] body = entity == null ? new byte[0] : EntityUtils.toByteArray(entity);
+ String text = new String(body, StandardCharsets.UTF_8);
+
+ Assert.assertEquals(acceptType, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ response.getStatusLine().getStatusCode());
+ Assert.assertEquals(acceptType, 0, body.length);
+ Assert.assertFalse(text.contains(FATAL_MARKER));
+ Assert.assertFalse(text.contains(StackOverflowError.class.getName()));
+ } catch (NoHttpResponseException | ConnectionClosedException | SocketException expected) {
+ // Jetty can abort the connection after the guard commits. Require server-side evidence
+ // below instead of treating an arbitrary network failure as a successful cleanup.
+ }
+ Assert.assertTrue("the underlying response must be committed as an empty 500",
+ committed.get(5, TimeUnit.SECONDS));
+ Assert.assertTrue("the request must reach the method for " + acceptType,
+ fatalService.invoked.get());
+ }
+ }
+
+ @Test
+ public void successfulRequestWithProductionFilterPreservesResponse() throws Exception {
+ HttpPost request = new HttpPost(startServer(true));
+ request.setEntity(new StringEntity(
+ "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":[],\"id\":7}",
+ ContentType.APPLICATION_JSON));
+
+ try (CloseableHttpResponse response = client.execute(request)) {
+ Assert.assertEquals(200, response.getStatusLine().getStatusCode());
+ Assert.assertEquals("application/json-rpc",
+ ContentType.get(response.getEntity()).getMimeType());
+ ObjectMapper mapper = new ObjectMapper();
+ JsonNode body = mapper.readTree(EntityUtils.toByteArray(response.getEntity()));
+ Assert.assertEquals(
+ mapper.readTree("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":7}"), body);
+ }
+ }
+
+ public interface FatalService {
+
+ @JsonRpcMethod("test_fatal")
+ String fatal();
+
+ @JsonRpcMethod("test_ok")
+ String ok();
+ }
+
+ private static class FatalServiceImpl implements FatalService {
+
+ private final AtomicBoolean invoked = new AtomicBoolean();
+
+ @Override
+ public String fatal() {
+ invoked.set(true);
+ throw new StackOverflowError(FATAL_MARKER);
+ }
+
+ @Override
+ public String ok() {
+ return "ok";
+ }
+ }
+
+ private static class TestJsonRpcServlet extends JsonRpcServlet {
+
+ private final JsonRpcServer testRpcServer;
+
+ TestJsonRpcServlet(JsonRpcServer testRpcServer) {
+ this.testRpcServer = testRpcServer;
+ }
+
+ @Override
+ public void init(ServletConfig config) throws ServletException {
+ setRpcServer(testRpcServer);
+ }
+ }
+
+ private static class TestJsonRpcHttpService extends HttpService {
+
+ private final JsonRpcServlet servlet;
+ private final boolean withFilter;
+ private final Map> commitObservations;
+
+ TestJsonRpcHttpService(int port, JsonRpcServlet servlet, boolean withFilter,
+ Map> commitObservations) {
+ this.port = port;
+ this.contextPath = "/";
+ this.servlet = servlet;
+ this.withFilter = withFilter;
+ this.commitObservations = commitObservations;
+ }
+
+ @Override
+ protected void addServlet(ServletContextHandler context) {
+ context.addServlet(new ServletHolder(servlet), "/jsonrpc");
+ }
+
+ @Override
+ protected void addFilter(ServletContextHandler context) {
+ if (withFilter) {
+ context.addFilter(new FilterHolder(new CommitObserver(commitObservations)), "/*",
+ EnumSet.of(DispatcherType.REQUEST));
+ context.addFilter(new FilterHolder(new HttpInterceptor()), "/*",
+ EnumSet.of(DispatcherType.REQUEST));
+ }
+ }
+ }
+
+ private static class CommitObserver implements Filter {
+
+ private final Map> observations;
+
+ CommitObserver(Map> observations) {
+ this.observations = observations;
+ }
+
+ @Override
+ public void init(FilterConfig config) {
+ }
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+ try {
+ chain.doFilter(request, response);
+ } catch (Throwable failure) {
+ String key = ((HttpServletRequest) request).getHeader(OBSERVATION_HEADER);
+ CompletableFuture observation = key == null ? null : observations.get(key);
+ if (observation != null) {
+ HttpServletResponse actual = (HttpServletResponse) response;
+ observation.complete(actual.isCommitted()
+ && actual.getStatus() == HttpServletResponse.SC_INTERNAL_SERVER_ERROR
+ && "0".equals(actual.getHeader("Content-Length")));
+ }
+ throw failure;
+ }
+ }
+
+ @Override
+ public void destroy() {
+ }
+ }
+}
diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java
index d6c843b5aea..3556695630b 100644
--- a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java
+++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java
@@ -4,29 +4,51 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import com.googlecode.jsonrpc4j.JsonRpcServer;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
-import java.lang.reflect.Field;
+import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
+import org.slf4j.LoggerFactory;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.tron.common.parameter.CommonParameter;
import org.tron.core.Constant;
+import org.tron.core.services.filter.CharResponseWrapper;
public class JsonRpcServletTest {
@@ -41,9 +63,7 @@ public class JsonRpcServletTest {
public void setUp() throws Exception {
servlet = new TestableServlet();
mockRpcServer = mock(JsonRpcServer.class);
- Field f = JsonRpcServlet.class.getDeclaredField("rpcServer");
- f.setAccessible(true);
- f.set(servlet, mockRpcServer);
+ servlet.setRpcServer(mockRpcServer);
savedMaxBatchSize = CommonParameter.getInstance().jsonRpcMaxBatchSize;
savedMaxResponseSize = CommonParameter.getInstance().jsonRpcMaxResponseSize;
}
@@ -120,6 +140,175 @@ public void emptyBatch_returnsInvalidRequest() throws Exception {
assertTrue(body.get("id").isNull());
}
+ @Test
+ public void invalidRequestIdTypes_returnInvalidRequestWithoutDispatch() throws Exception {
+ String[] invalidIds = {"true", "{}", "[]"};
+
+ for (String id : invalidIds) {
+ MockHttpServletResponse resp = doPost(
+ "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\","
+ + "\"params\":[],\"id\":" + id + "}");
+ assertEquals(200, resp.getStatus());
+ assertEquals("application/json-rpc", resp.getContentType());
+ assertInvalidRequestWithNullId(MAPPER.readTree(resp.getContentAsByteArray()));
+ }
+
+ verifyNoInteractions(mockRpcServer);
+ }
+
+ @Test
+ public void nullRequestId_isNotRejectedByServletValidation() throws Exception {
+ int[] callCount = {0};
+ doAnswer(inv -> {
+ callCount[0]++;
+ return 0;
+ }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class));
+
+ doPost("{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\","
+ + "\"params\":[],\"id\":null}");
+
+ assertEquals("a null id is valid JSON-RPC input and must reach dispatch", 1, callCount[0]);
+ }
+
+ @Test
+ public void structuredAndAbsentParams_reachRpcServer() throws Exception {
+ List dispatchedRequests = new ArrayList<>();
+ doAnswer(inv -> {
+ dispatchedRequests.add(MAPPER.readTree((InputStream) inv.getArgument(0)));
+ return 0;
+ }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class));
+
+ String[] requests = {
+ "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"id\":1}",
+ "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\","
+ + "\"params\":null,\"id\":2}",
+ "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\","
+ + "\"params\":[],\"id\":3}",
+ "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\","
+ + "\"params\":{},\"id\":4}"
+ };
+ for (String request : requests) {
+ doPost(request);
+ }
+
+ assertEquals("all supported params shapes must reach jsonrpc4j",
+ requests.length, dispatchedRequests.size());
+ for (int i = 0; i < requests.length; i++) {
+ assertEquals("forwarded request must be unchanged at index " + i,
+ MAPPER.readTree(requests[i]), dispatchedRequests.get(i));
+ }
+ }
+
+ @Test
+ public void singleScalarParams_isRejectedBeforeDispatch() throws Exception {
+ MockHttpServletResponse resp = doPost(
+ "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\","
+ + "\"params\":5,\"id\":42}");
+
+ assertEquals(200, resp.getStatus());
+ assertEquals("application/json-rpc", resp.getContentType());
+ JsonNode body = MAPPER.readTree(resp.getContentAsByteArray());
+ assertEquals(MAPPER.getNodeFactory().textNode("2.0"), body.get("jsonrpc"));
+ assertEquals(MAPPER.getNodeFactory().numberNode(-32600),
+ body.get("error").get("code"));
+ assertEquals(MAPPER.getNodeFactory().textNode("Invalid Request"),
+ body.get("error").get("message"));
+ assertFalse(body.get("error").has("data"));
+ assertEquals(MAPPER.getNodeFactory().numberNode(42), body.get("id"));
+ verifyNoInteractions(mockRpcServer);
+ }
+
+ @Test
+ public void batchInvalidRequestId_isIsolatedFromValidSiblings() throws Exception {
+ int[] callCount = {0};
+ doAnswer(inv -> {
+ JsonNode request = MAPPER.readTree((InputStream) inv.getArgument(0));
+ OutputStream out = inv.getArgument(1);
+ out.write(("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":"
+ + request.get("id") + "}").getBytes(StandardCharsets.UTF_8));
+ callCount[0]++;
+ return 0;
+ }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class));
+
+ MockHttpServletResponse resp = doPost("["
+ + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[],\"id\":1},"
+ + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[],\"id\":true},"
+ + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[],\"id\":\"two\"}"
+ + "]");
+
+ assertEquals(200, resp.getStatus());
+ JsonNode body = MAPPER.readTree(resp.getContentAsByteArray());
+ assertTrue(body.isArray());
+ assertEquals(3, body.size());
+ assertEquals("ok", body.get(0).get("result").asText());
+ assertEquals(1, body.get(0).get("id").asInt());
+ assertInvalidRequestWithNullId(body.get(1));
+ assertEquals("ok", body.get(2).get("result").asText());
+ assertEquals("two", body.get(2).get("id").asText());
+ assertEquals("only valid requests should reach jsonrpc4j", 2, callCount[0]);
+ }
+
+ @Test
+ public void batchScalarParams_isIsolatedFromValidSibling() throws Exception {
+ int[] callCount = {0};
+ JsonNode[] dispatchedRequest = {null};
+ doAnswer(inv -> {
+ dispatchedRequest[0] = MAPPER.readTree((InputStream) inv.getArgument(0));
+ OutputStream out = inv.getArgument(1);
+ out.write(("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":"
+ + dispatchedRequest[0].get("id") + "}").getBytes(StandardCharsets.UTF_8));
+ callCount[0]++;
+ return 0;
+ }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class));
+
+ MockHttpServletResponse resp = doPost("["
+ + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":5,\"id\":1},"
+ + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[],\"id\":2}"
+ + "]");
+
+ assertEquals(200, resp.getStatus());
+ JsonNode body = MAPPER.readTree(resp.getContentAsByteArray());
+ assertTrue(body.isArray());
+ assertEquals(2, body.size());
+ assertEquals(MAPPER.getNodeFactory().numberNode(-32600),
+ body.get(0).get("error").get("code"));
+ assertEquals(MAPPER.getNodeFactory().textNode("Invalid Request"),
+ body.get(0).get("error").get("message"));
+ assertFalse(body.get(0).get("error").has("data"));
+ assertEquals(MAPPER.getNodeFactory().numberNode(1), body.get(0).get("id"));
+ assertEquals(MAPPER.getNodeFactory().textNode("ok"), body.get(1).get("result"));
+ assertEquals(MAPPER.getNodeFactory().numberNode(2), body.get(1).get("id"));
+ assertEquals("only the valid sibling should reach jsonrpc4j", 1, callCount[0]);
+ assertEquals(MAPPER.getNodeFactory().numberNode(2), dispatchedRequest[0].get("id"));
+ assertTrue(dispatchedRequest[0].get("params").isArray());
+ }
+
+ @Test
+ public void batchScalarParamsWithoutId_returnsErrorAndDispatchesValidNotification()
+ throws Exception {
+ List dispatchedRequests = new ArrayList<>();
+ doAnswer(inv -> {
+ dispatchedRequests.add(MAPPER.readTree((InputStream) inv.getArgument(0)));
+ return 0;
+ }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class));
+
+ MockHttpServletResponse resp = doPost("["
+ + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":5},"
+ + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[]}"
+ + "]");
+
+ assertEquals(200, resp.getStatus());
+ assertEquals("application/json-rpc", resp.getContentType());
+ JsonNode body = MAPPER.readTree(resp.getContentAsByteArray());
+ assertTrue(body.isArray());
+ assertEquals(1, body.size());
+ assertInvalidRequestWithNullId(body.get(0));
+ assertEquals("only the valid notification should reach jsonrpc4j",
+ 1, dispatchedRequests.size());
+ assertFalse(dispatchedRequests.get(0).has("id"));
+ assertTrue(dispatchedRequests.get(0).get("params").isArray());
+ }
+
@Test
public void batchLimitDisabled_largeBatchAllowed() throws Exception {
CommonParameter.getInstance().jsonRpcMaxBatchSize = 0;
@@ -142,28 +331,550 @@ public void batchLimitDisabled_largeBatchAllowed() throws Exception {
assertEquals("", resp.getContentAsString());
}
- // --- rpcServer.handle exceptions ---
+ // --- rpcServer.handleRequest exceptions ---
@Test
public void rpcServerThrowsRuntimeException_returnsInternalError() throws Exception {
doThrow(new RuntimeException("server exploded")).when(mockRpcServer)
- .handle(any(HttpServletRequest.class), any(HttpServletResponse.class));
+ .handleRequest(any(InputStream.class), any(OutputStream.class));
MockHttpServletResponse resp = doPost("{\"method\":\"eth_blockNumber\",\"id\":42}");
assertEquals(200, resp.getStatus());
JsonNode body = MAPPER.readTree(resp.getContentAsString());
assertFalse(body.isArray());
assertEquals(-32603, body.get("error").get("code").asInt());
+ assertEquals("Internal error", body.get("error").get("message").asText());
+ assertEquals(42, body.get("id").asInt());
+ }
+
+ @Test
+ public void rpcServerThrowsIOException_returnsInternalError() throws Exception {
+ doThrow(new IOException("server exploded")).when(mockRpcServer)
+ .handleRequest(any(InputStream.class), any(OutputStream.class));
+
+ MockHttpServletResponse resp = doPost("{\"method\":\"eth_blockNumber\",\"id\":42}");
+
+ assertEquals(200, resp.getStatus());
+ JsonNode body = MAPPER.readTree(resp.getContentAsByteArray());
+ assertEquals(-32603, body.get("error").get("code").asInt());
+ assertEquals("Internal error", body.get("error").get("message").asText());
+ assertEquals(42, body.get("id").asInt());
+ }
+
+ @Test
+ public void notificationIOException_returnsEmptyResponse() throws Exception {
+ doThrow(new IOException("server exploded")).when(mockRpcServer)
+ .handleRequest(any(InputStream.class), any(OutputStream.class));
+
+ MockHttpServletResponse resp = doPost("{\"method\":\"eth_blockNumber\"}");
+
+ assertEquals(200, resp.getStatus());
+ assertEquals("application/json-rpc", resp.getContentType());
+ assertEquals(0, resp.getContentAsByteArray().length);
+ }
+
+ @Test
+ public void singleAssertionError_discardsPartialOutputAndReturnsInternalError() throws Exception {
+ doAnswer(inv -> {
+ OutputStream output = inv.getArgument(1);
+ output.write("{\"result\":\"partial-sensitive-marker".getBytes(StandardCharsets.UTF_8));
+ throw new AssertionError("assertion-sensitive-marker");
+ }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class));
+
+ assertSingleInternalError(doPost("{\"id\":42}"), "42");
+ assertSingleInternalError(doPost("{\"id\":\"request-1\"}"), "\"request-1\"");
+ }
+
+ @Test
+ public void singleAssertionErrorWithoutId_keepsEmptyResponse() throws Exception {
+ doThrow(new AssertionError("assertion-sensitive-marker")).when(mockRpcServer)
+ .handleRequest(any(InputStream.class), any(OutputStream.class));
+
+ MockHttpServletResponse response = doPost("{\"method\":\"eth_blockNumber\"}");
+
+ assertEquals(200, response.getStatus());
+ assertEquals("application/json-rpc", response.getContentType());
+ assertEquals(0, response.getContentAsByteArray().length);
+ }
+
+ @Test
+ public void singleAssertionErrorWithNullId_keepsErrorResponse() throws Exception {
+ doThrow(new AssertionError("assertion-sensitive-marker")).when(mockRpcServer)
+ .handleRequest(any(InputStream.class), any(OutputStream.class));
+
+ assertSingleInternalError(doPost("{\"method\":\"eth_blockNumber\",\"id\":null}"), "null");
+ }
+
+ @Test
+ public void singleWrappedAssertionError_propagatesFatalCauseWithoutLogging() throws Exception {
+ StackOverflowError fatal = new StackOverflowError("fatal-marker");
+ try (BatchLogCapture logs = new BatchLogCapture()) {
+ assertWrappedFatalPropagates(new AssertionError("wrapper-marker", fatal), fatal, false);
+ assertTrue(logs.executionEvents().isEmpty());
+ }
+ }
+
+ @Test
+ public void batchWrappedAssertionError_stopsBeforeLaterDispatchWithoutLogging() throws Exception {
+ StackOverflowError fatal = new StackOverflowError("fatal-marker");
+ try (BatchLogCapture logs = new BatchLogCapture()) {
+ assertWrappedFatalPropagates(new AssertionError("wrapper-marker", fatal), fatal, true);
+ assertTrue(logs.executionEvents().isEmpty());
+ }
}
@Test
public void batchRpcServerThrows_internalErrorIsArray() throws Exception {
doThrow(new RuntimeException("boom")).when(mockRpcServer)
.handleRequest(any(InputStream.class), any(OutputStream.class));
- MockHttpServletResponse resp = doPost("[{\"method\":\"eth_blockNumber\"}]");
+ MockHttpServletResponse resp = doPost(
+ "[{\"method\":\"eth_blockNumber\",\"id\":\"request-1\"}]");
assertEquals(200, resp.getStatus());
JsonNode body = MAPPER.readTree(resp.getContentAsString());
assertTrue("batch internal error must be an array", body.isArray());
assertEquals(-32603, body.get(0).get("error").get("code").asInt());
+ assertEquals("Internal error", body.get(0).get("error").get("message").asText());
+ assertEquals("request-1", body.get(0).get("id").asText());
+ }
+
+ @Test
+ public void batchRpcServerThrowsIOException_internalErrorIsArray() throws Exception {
+ doThrow(new IOException("boom")).when(mockRpcServer)
+ .handleRequest(any(InputStream.class), any(OutputStream.class));
+
+ MockHttpServletResponse resp = doPost(
+ "[{\"method\":\"eth_blockNumber\",\"id\":\"request-1\"}]");
+
+ assertEquals(200, resp.getStatus());
+ JsonNode body = MAPPER.readTree(resp.getContentAsByteArray());
+ assertTrue(body.isArray());
+ assertEquals(-32603, body.get(0).get("error").get("code").asInt());
+ assertEquals("Internal error", body.get(0).get("error").get("message").asText());
+ assertEquals("request-1", body.get(0).get("id").asText());
+ }
+
+ @Test
+ public void fatalError_commitsBare500AndRethrowsOriginal() throws Exception {
+ StackOverflowError fatal = new StackOverflowError("fatal-marker");
+ doThrow(fatal).when(mockRpcServer)
+ .handleRequest(any(InputStream.class), any(OutputStream.class));
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ StackOverflowError thrown = assertThrows(StackOverflowError.class,
+ () -> doPost("{\"method\":\"eth_blockNumber\",\"id\":1}", response));
+
+ assertSame(fatal, thrown);
+ assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, response.getStatus());
+ assertEquals(0, response.getContentAsByteArray().length);
+ assertTrue(response.isCommitted());
+ }
+
+ @Test
+ public void singleWrappedRuntimeException_propagatesFatalCause() throws Exception {
+ StackOverflowError fatal = new StackOverflowError("fatal-marker");
+ assertWrappedFatalPropagates(new RuntimeException("wrapper", fatal), fatal, false);
+ }
+
+ @Test
+ public void singleWrappedIOException_propagatesFatalCause() throws Exception {
+ StackOverflowError fatal = new StackOverflowError("fatal-marker");
+ assertWrappedFatalPropagates(new IOException("wrapper", fatal), fatal, false);
+ }
+
+ @Test
+ public void batchWrappedRuntimeException_stopsBeforeLaterDispatch() throws Exception {
+ StackOverflowError fatal = new StackOverflowError("fatal-marker");
+ assertWrappedFatalPropagates(new RuntimeException("wrapper", fatal), fatal, true);
+ }
+
+ @Test
+ public void batchWrappedIOException_stopsBeforeLaterDispatch() throws Exception {
+ StackOverflowError fatal = new StackOverflowError("fatal-marker");
+ assertWrappedFatalPropagates(new IOException("wrapper", fatal), fatal, true);
+ }
+
+ @Test
+ public void cleanupIOException_doesNotReplaceOriginalFatalError() throws Exception {
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ doThrow(new IOException("cleanup-io-marker")).when(response).flushBuffer();
+ assertCleanupFailureDoesNotReplaceFatal(response);
+ verify(response).flushBuffer();
+ }
+
+ @Test
+ public void cleanupError_doesNotReplaceOriginalFatalError() throws Exception {
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ doThrow(new OutOfMemoryError("cleanup-error-marker")).when(response).flushBuffer();
+ assertCleanupFailureDoesNotReplaceFatal(response);
+ verify(response).flushBuffer();
+ }
+
+ @Test
+ public void fatalError_unwrapsNestedResponsesBeforeCommitting() throws Exception {
+ MockHttpServletResponse actual = new MockHttpServletResponse();
+ actual.getOutputStream().write("partial-response-marker".getBytes(StandardCharsets.UTF_8));
+ HttpServletResponse wrapped = new HttpServletResponseWrapper(
+ new CharResponseWrapper(new CharResponseWrapper(actual)));
+
+ assertCleanupFailureDoesNotReplaceFatal(wrapped);
+
+ assertEquals(500, actual.getStatus());
+ assertEquals(0, actual.getContentAsByteArray().length);
+ assertTrue(actual.isCommitted());
+ }
+
+ @Test
+ public void fatalError_atWrapperDepthLimitStillCommits() throws Exception {
+ MockHttpServletResponse actual = new MockHttpServletResponse();
+ HttpServletResponse wrapped = actual;
+ for (int i = 0; i < 16; i++) {
+ wrapped = new CharResponseWrapper(wrapped);
+ }
+
+ assertCleanupFailureDoesNotReplaceFatal(wrapped);
+
+ assertEquals(500, actual.getStatus());
+ assertEquals(0, actual.getContentAsByteArray().length);
+ assertTrue(actual.isCommitted());
+ }
+
+ @Test(timeout = 5000)
+ public void fatalError_selfReferencingWrapperAbandonsCleanup() throws Exception {
+ TrackingResponseWrapper wrapper = new TrackingResponseWrapper(new MockHttpServletResponse());
+ wrapper.setResponse(wrapper);
+
+ assertCleanupFailureDoesNotReplaceFatal(wrapper);
+
+ assertEquals("cleanup must not delegate into a self-reference", 0, wrapper.cleanupCalls);
+ }
+
+ @Test(timeout = 5000)
+ public void fatalError_cyclicWrappersAbandonCleanup() throws Exception {
+ TrackingResponseWrapper first = new TrackingResponseWrapper(new MockHttpServletResponse());
+ TrackingResponseWrapper second = new TrackingResponseWrapper(first);
+ first.setResponse(second);
+
+ assertCleanupFailureDoesNotReplaceFatal(first);
+
+ assertEquals("cleanup must not delegate into a cycle", 0, first.cleanupCalls);
+ assertEquals("cleanup must not delegate into a cycle", 0, second.cleanupCalls);
+ }
+
+ @Test(timeout = 5000)
+ public void fatalError_excessiveWrapperDepthAbandonsCleanup() throws Exception {
+ MockHttpServletResponse actual = new MockHttpServletResponse();
+ List wrappers = new ArrayList<>();
+ HttpServletResponse wrapped = actual;
+ for (int i = 0; i < 17; i++) {
+ TrackingResponseWrapper wrapper = new TrackingResponseWrapper(wrapped);
+ wrappers.add(wrapper);
+ wrapped = wrapper;
+ }
+
+ assertCleanupFailureDoesNotReplaceFatal(wrapped);
+
+ for (TrackingResponseWrapper wrapper : wrappers) {
+ assertEquals("cleanup must not delegate beyond the depth limit", 0, wrapper.cleanupCalls);
+ }
+ assertFalse(actual.isCommitted());
+ }
+
+ @Test
+ public void unwrappingFailure_doesNotReplaceOriginalFatalError() throws Exception {
+ boolean[] attempted = {false};
+ HttpServletResponseWrapper response = new HttpServletResponseWrapper(
+ new MockHttpServletResponse()) {
+ @Override
+ public ServletResponse getResponse() {
+ attempted[0] = true;
+ throw new IllegalStateException("unwrap-failure-marker");
+ }
+ };
+
+ assertCleanupFailureDoesNotReplaceFatal(response);
+
+ assertTrue("cleanup must attempt to unwrap the response", attempted[0]);
+ }
+
+ @Test
+ public void fatalError_doesNotResetCommittedUnderlyingResponse() throws Exception {
+ MockHttpServletResponse actual = new MockHttpServletResponse();
+ byte[] body = "already-committed".getBytes(StandardCharsets.UTF_8);
+ actual.getOutputStream().write(body);
+ actual.flushBuffer();
+
+ assertCleanupFailureDoesNotReplaceFatal(new CharResponseWrapper(actual));
+
+ assertEquals(200, actual.getStatus());
+ assertArrayEquals(body, actual.getContentAsByteArray());
+ assertTrue(actual.isCommitted());
+ }
+
+ @Test
+ public void batchMalformedRpcServerResponse_preservesRequestId() throws Exception {
+ doAnswer(inv -> {
+ OutputStream out = inv.getArgument(1);
+ out.write("not-json".getBytes(StandardCharsets.UTF_8));
+ return 0;
+ }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class));
+
+ MockHttpServletResponse resp = doPost(
+ "[{\"method\":\"eth_blockNumber\",\"id\":42}]");
+
+ JsonNode body = MAPPER.readTree(resp.getContentAsByteArray());
+ assertTrue(body.isArray());
+ assertEquals(-32603, body.get(0).get("error").get("code").asInt());
+ assertEquals("Internal error", body.get(0).get("error").get("message").asText());
+ assertEquals(42, body.get(0).get("id").asInt());
+ }
+
+ @Test
+ public void batchRuntimeException_preservesSiblingsAndContinues() throws Exception {
+ List dispatched = stubBatchWithMiddleFailure(new RuntimeException("failure-marker"));
+
+ MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]");
+
+ assertRecoveredBatch(response, "2");
+ assertEquals(MAPPER.readTree("[{\"id\":1},{\"id\":2},{\"id\":3}]"),
+ MAPPER.valueToTree(dispatched));
+ }
+
+ @Test
+ public void batchIOException_preservesSiblingsAndContinues() throws Exception {
+ List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker"));
+
+ MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]");
+
+ assertRecoveredBatch(response, "2");
+ assertEquals(MAPPER.readTree("[{\"id\":1},{\"id\":2},{\"id\":3}]"),
+ MAPPER.valueToTree(dispatched));
+ }
+
+ @Test
+ public void batchAssertionError_discardsPartialOutputAndPreservesSiblings() throws Exception {
+ List dispatched = stubBatchWithMiddleFailure(
+ new AssertionError("assertion-sensitive-marker"), true);
+ String request = "[{\"id\":1},{\"id\":2},{\"id\":3}]";
+
+ MockHttpServletResponse response = doPost(request);
+
+ assertFalse(response.getContentAsString().contains("partial-sensitive-marker"));
+ assertFalse(response.getContentAsString().contains("assertion-sensitive-marker"));
+ assertRecoveredBatch(response, "2");
+ assertEquals(recoveredBatch("2"), MAPPER.reader()
+ .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
+ .readTree(response.getContentAsByteArray()));
+ assertEquals(MAPPER.readTree(request), MAPPER.valueToTree(dispatched));
+ }
+
+ @Test
+ public void batchAssertionErrorWithoutId_keepsErrorNodeAndContinues() throws Exception {
+ List dispatched = stubBatchWithMiddleFailure(
+ new AssertionError("assertion-sensitive-marker"));
+ String request = "[{\"id\":1},{\"method\":\"eth_blockNumber\"},{\"id\":3}]";
+
+ MockHttpServletResponse response = doPost(request);
+
+ assertRecoveredBatch(response, "null");
+ assertEquals(MAPPER.readTree(request), MAPPER.valueToTree(dispatched));
+ }
+
+ @Test
+ public void batchAssertionErrors_logOnlyFirstStackPerBatch() throws Exception {
+ List dispatched = stubBatchWithTwoFailures(
+ new AssertionError("first-sensitive-marker"),
+ new AssertionError("repeat-sensitive-marker"));
+
+ try (BatchLogCapture logs = new BatchLogCapture()) {
+ for (int i = 0; i < 2; i++) {
+ assertBatchWithTwoRecoveredFailures(
+ doPost("[{\"id\":1},{\"id\":2},{\"id\":3},{\"id\":4}]"));
+ }
+
+ assertEquals(Arrays.asList(1, 2, 3, 4, 1, 2, 3, 4), dispatched);
+ List events = logs.events();
+ assertEquals(4, events.size());
+ assertBoundedBatchLogs(events.subList(0, 2), AssertionError.class, AssertionError.class);
+ assertBoundedBatchLogs(events.subList(2, 4), AssertionError.class, AssertionError.class);
+ }
+ }
+
+ @Test
+ public void batchMultipleFailures_logsOnlyFirstStackAndPreservesResponses() throws Exception {
+ List dispatched = stubBatchWithTwoFailures(
+ new IOException("first-sensitive-marker"),
+ new IllegalStateException("repeat-sensitive-marker"));
+
+ try (BatchLogCapture logs = new BatchLogCapture()) {
+ MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3},{\"id\":4}]");
+
+ assertBatchWithTwoRecoveredFailures(response);
+ assertEquals(Arrays.asList(1, 2, 3, 4), dispatched);
+ assertBoundedBatchLogs(logs.events());
+ }
+ }
+
+ @Test
+ public void consecutiveBatches_eachLogTheirFirstFailure() throws Exception {
+ List dispatched = stubBatchWithTwoFailures(
+ new IOException("first-sensitive-marker"),
+ new IllegalStateException("repeat-sensitive-marker"));
+
+ try (BatchLogCapture logs = new BatchLogCapture()) {
+ for (int i = 0; i < 2; i++) {
+ assertBatchWithTwoRecoveredFailures(
+ doPost("[{\"id\":1},{\"id\":2},{\"id\":3},{\"id\":4}]"));
+ }
+
+ assertEquals(Arrays.asList(1, 2, 3, 4, 1, 2, 3, 4), dispatched);
+ List events = logs.events();
+ assertEquals(4, events.size());
+ assertBoundedBatchLogs(events.subList(0, 2));
+ assertBoundedBatchLogs(events.subList(2, 4));
+ }
+ }
+
+ @Test
+ public void batchFatalAfterOrdinaryFailure_doesNotLogFatalOrContinue() throws Exception {
+ StackOverflowError fatal = new StackOverflowError("fatal-sensitive-marker");
+ List dispatched = stubBatchWithTwoFailures(
+ new IOException("first-sensitive-marker"), new IOException("wrapped-fatal", fatal));
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ try (BatchLogCapture logs = new BatchLogCapture()) {
+ assertSame(fatal, assertThrows(StackOverflowError.class,
+ () -> doPost("[{\"id\":1},{\"id\":2},{\"id\":3},{\"id\":4}]", response)));
+
+ assertEquals(Arrays.asList(1, 2, 3), dispatched);
+ assertEquals(500, response.getStatus());
+ assertEquals(0, response.getContentAsByteArray().length);
+ assertTrue(response.isCommitted());
+ List events = logs.events();
+ assertEquals(1, events.size());
+ assertFirstBatchFailureLog(events.get(0));
+ }
+ }
+
+ @Test
+ public void batchMalformedResponse_preservesSiblingsAndContinues() throws Exception {
+ List dispatched = stubBatchWithMalformedMiddleResponse();
+
+ MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]");
+
+ assertRecoveredBatch(response, "2");
+ assertEquals(Arrays.asList(1, 2, 3), dispatched);
+ }
+
+ @Test
+ public void batchSerializationFailure_preservesSiblingsAndContinues() throws Exception {
+ int[] serializationAttempts = {0};
+ ObjectNode failingRequest = new ObjectNode(MAPPER.getNodeFactory()) {
+ @Override
+ public void serialize(JsonGenerator generator, SerializerProvider provider)
+ throws IOException {
+ serializationAttempts[0]++;
+ throw new JsonProcessingException("serialization-marker") {};
+ }
+ };
+ failingRequest.put("id", 2);
+ ArrayNode requests = MAPPER.createArrayNode();
+ requests.addObject().put("id", 1);
+ requests.add(failingRequest);
+ requests.addObject().put("id", 3);
+ List dispatched = stubBatchWithMalformedMiddleResponse();
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ Method handleBatch = JsonRpcServlet.class.getDeclaredMethod("handleBatch",
+ HttpServletResponse.class, JsonNode.class, int.class);
+ handleBatch.setAccessible(true);
+
+ handleBatch.invoke(servlet, response, requests, 0);
+
+ assertRecoveredBatch(response, "2");
+ assertEquals(Arrays.asList(1, 3), dispatched);
+ assertEquals(1, serializationAttempts[0]);
+ }
+
+ @Test
+ public void batchFailureWithoutId_keepsErrorNodeAndContinues() throws Exception {
+ List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker"));
+ String request = "[{\"id\":1},{\"method\":\"eth_blockNumber\"},{\"id\":3}]";
+
+ MockHttpServletResponse response = doPost(request);
+
+ assertRecoveredBatch(response, "null");
+ assertEquals(MAPPER.readTree(request), MAPPER.valueToTree(dispatched));
+ }
+
+ @Test
+ public void batchRecoveredError_atExactLimitDoesNotOverflow() throws Exception {
+ List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker"));
+ int limit = MAPPER.writeValueAsBytes(recoveredBatch("2")).length;
+ CommonParameter.getInstance().jsonRpcMaxResponseSize = limit;
+
+ MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]");
+
+ assertRecoveredBatch(response, "2");
+ assertEquals(limit, response.getContentAsByteArray().length);
+ assertEquals(3, dispatched.size());
+ }
+
+ @Test
+ public void batchMalformedResponse_countsOnlyReplacementBytes() throws Exception {
+ List dispatched = stubBatchWithMalformedMiddleResponse();
+ int limit = MAPPER.writeValueAsBytes(recoveredBatch("2")).length;
+ CommonParameter.getInstance().jsonRpcMaxResponseSize = limit;
+
+ MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]");
+
+ assertRecoveredBatch(response, "2");
+ assertEquals(limit, response.getContentAsByteArray().length);
+ assertEquals(Arrays.asList(1, 2, 3), dispatched);
+ }
+
+ @Test
+ public void batchRecoveredError_consumesBudgetForLaterResponse() throws Exception {
+ List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker"));
+ CommonParameter.getInstance().jsonRpcMaxResponseSize =
+ MAPPER.writeValueAsBytes(recoveredBatch("2")).length - 1;
+
+ MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]");
+
+ assertRecoveredErrorConsumesBudget(response);
+ assertEquals(MAPPER.readTree("[{\"id\":1},{\"id\":2},{\"id\":3}]"),
+ MAPPER.valueToTree(dispatched));
+ }
+
+ @Test
+ public void batchMalformedResponse_replacementConsumesBudgetForLaterResponse() throws Exception {
+ List dispatched = stubBatchWithMalformedMiddleResponse();
+ CommonParameter.getInstance().jsonRpcMaxResponseSize =
+ MAPPER.writeValueAsBytes(recoveredBatch("2")).length - 1;
+
+ MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]");
+
+ assertRecoveredErrorConsumesBudget(response);
+ assertEquals(Arrays.asList(1, 2, 3), dispatched);
+ }
+
+ @Test
+ public void batchRecoveredError_overflowStopsLaterDispatch() throws Exception {
+ List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker"));
+ CommonParameter.getInstance().jsonRpcMaxResponseSize = limitBeforeSecondErrorFits();
+
+ MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]");
+
+ assertBatchErrorOverflow(response);
+ assertEquals(2, dispatched.size());
+ }
+
+ @Test
+ public void batchMalformedResponse_replacementCanTriggerOverflow() throws Exception {
+ List dispatched = stubBatchWithMalformedMiddleResponse();
+ CommonParameter.getInstance().jsonRpcMaxResponseSize = limitBeforeSecondErrorFits();
+
+ MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]");
+
+ assertBatchErrorOverflow(response);
+ assertEquals(Arrays.asList(1, 2), dispatched);
}
// --- response size limit ---
@@ -173,10 +884,10 @@ public void responseTooLarge_returnsSingleErrorObject() throws Exception {
int limit = 50;
CommonParameter.getInstance().jsonRpcMaxResponseSize = limit;
doAnswer(inv -> {
- HttpServletResponse r = inv.getArgument(1);
- r.getOutputStream().write(new byte[limit + 1]);
- return null;
- }).when(mockRpcServer).handle(any(HttpServletRequest.class), any(HttpServletResponse.class));
+ OutputStream out = inv.getArgument(1);
+ out.write(new byte[limit + 1]);
+ return 0;
+ }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class));
MockHttpServletResponse resp = doPost("{\"method\":\"eth_getLogs\",\"id\":1}");
assertEquals(200, resp.getStatus());
@@ -233,16 +944,42 @@ public void batchShortCircuitsOnOverflow() throws Exception {
assertEquals("third sub-request must not be executed after overflow", 2, callCount[0]);
}
+ @Test
+ public void batchInvalidRequestId_afterOverflowStillReturnsInvalidRequest() throws Exception {
+ int limit = 50;
+ CommonParameter.getInstance().jsonRpcMaxResponseSize = limit;
+ int[] callCount = {0};
+ doAnswer(inv -> {
+ OutputStream out = inv.getArgument(1);
+ out.write(new byte[limit + 1]);
+ callCount[0]++;
+ return 0;
+ }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class));
+
+ MockHttpServletResponse resp = doPost("["
+ + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getLogs\",\"id\":1},"
+ + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getLogs\",\"id\":{}}"
+ + "]");
+
+ JsonNode body = MAPPER.readTree(resp.getContentAsByteArray());
+ assertTrue(body.isArray());
+ assertEquals(2, body.size());
+ assertEquals(-32003, body.get(0).get("error").get("code").asInt());
+ assertEquals(1, body.get(0).get("id").asInt());
+ assertInvalidRequestWithNullId(body.get(1));
+ assertEquals("invalid requests must not be dispatched after overflow", 1, callCount[0]);
+ }
+
// --- normal path ---
@Test
public void normalRequest_commitsRpcServerResponse() throws Exception {
byte[] rpcResp = "{\"result\":\"0x1\"}".getBytes(StandardCharsets.UTF_8);
doAnswer(inv -> {
- HttpServletResponse r = inv.getArgument(1);
- r.getOutputStream().write(rpcResp);
- return null;
- }).when(mockRpcServer).handle(any(HttpServletRequest.class), any(HttpServletResponse.class));
+ OutputStream out = inv.getArgument(1);
+ out.write(rpcResp);
+ return 0;
+ }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class));
MockHttpServletResponse resp = doPost("{\"method\":\"eth_blockNumber\",\"id\":1}");
assertEquals(200, resp.getStatus());
@@ -417,14 +1154,293 @@ public void tooManyTokens_returnsParseError() throws Exception {
// --- helpers ---
+ private List stubBatchWithTwoFailures(Throwable first, Throwable second)
+ throws Exception {
+ List dispatched = new ArrayList<>();
+ doAnswer(inv -> {
+ int id = MAPPER.readTree((InputStream) inv.getArgument(0)).get("id").asInt();
+ dispatched.add(id);
+ if (id == 2) {
+ throw first;
+ }
+ if (id == 3) {
+ throw second;
+ }
+ OutputStream out = inv.getArgument(1);
+ out.write(successResponse(id));
+ return 0;
+ }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class));
+ return dispatched;
+ }
+
+ private static void assertBatchWithTwoRecoveredFailures(MockHttpServletResponse response)
+ throws IOException {
+ assertEquals(200, response.getStatus());
+ assertEquals("application/json-rpc", response.getContentType());
+ assertEquals(MAPPER.readTree("[{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":1},"
+ + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error\"},"
+ + "\"id\":2},{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,"
+ + "\"message\":\"Internal error\"},\"id\":3},"
+ + "{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":4}]"),
+ MAPPER.readTree(response.getContentAsByteArray()));
+ }
+
+ private static void assertBoundedBatchLogs(List events) {
+ assertBoundedBatchLogs(events, IOException.class, IllegalStateException.class);
+ }
+
+ private static void assertBoundedBatchLogs(List events,
+ Class extends Throwable> firstType, Class extends Throwable> repeatedType) {
+ assertEquals(2, events.size());
+ assertFirstBatchFailureLog(events.get(0), firstType);
+ ILoggingEvent repeated = events.get(1);
+ assertEquals(Level.DEBUG, repeated.getLevel());
+ assertNull(repeated.getThrowableProxy());
+ assertArrayEquals(new Object[]{2, repeatedType.getName()},
+ repeated.getArgumentArray());
+ assertFalse(repeated.getFormattedMessage().contains("repeat-sensitive-marker"));
+ }
+
+ private static void assertFirstBatchFailureLog(ILoggingEvent event) {
+ assertFirstBatchFailureLog(event, IOException.class);
+ }
+
+ private static void assertFirstBatchFailureLog(ILoggingEvent event,
+ Class extends Throwable> failureType) {
+ assertEquals(Level.ERROR, event.getLevel());
+ assertEquals("RPC execution failed for batch sub-request 1", event.getFormattedMessage());
+ assertEquals(failureType.getName(), event.getThrowableProxy().getClassName());
+ assertEquals("first-sensitive-marker", event.getThrowableProxy().getMessage());
+ assertTrue(event.getThrowableProxy().getStackTraceElementProxyArray().length > 0);
+ }
+
+ private static class BatchLogCapture implements AutoCloseable {
+ private final Logger apiLogger = (Logger) LoggerFactory.getLogger("API");
+ private final Level originalLevel = apiLogger.getLevel();
+ private final ListAppender appender = new ListAppender<>();
+
+ private BatchLogCapture() {
+ appender.start();
+ apiLogger.addAppender(appender);
+ apiLogger.setLevel(Level.DEBUG);
+ }
+
+ private List events() {
+ return matchingEvents("RPC execution failed for batch sub-request ");
+ }
+
+ private List executionEvents() {
+ return matchingEvents("RPC execution failed");
+ }
+
+ private List matchingEvents(String prefix) {
+ List events = new ArrayList<>();
+ for (ILoggingEvent event : appender.list) {
+ if (event.getMessage().startsWith(prefix)) {
+ events.add(event);
+ }
+ }
+ return events;
+ }
+
+ @Override
+ public void close() {
+ apiLogger.setLevel(originalLevel);
+ apiLogger.detachAppender(appender);
+ appender.stop();
+ }
+ }
+
+ private List stubBatchWithMiddleFailure(Throwable failure) throws Exception {
+ return stubBatchWithMiddleFailure(failure, false);
+ }
+
+ private List stubBatchWithMiddleFailure(Throwable failure, boolean partialOutput)
+ throws Exception {
+ List dispatched = new ArrayList<>();
+ doAnswer(inv -> {
+ JsonNode request = MAPPER.readTree((InputStream) inv.getArgument(0));
+ dispatched.add(request);
+ if (dispatched.size() == 2) {
+ if (partialOutput) {
+ OutputStream output = inv.getArgument(1);
+ output.write("{\"result\":\"partial-sensitive-marker".getBytes(StandardCharsets.UTF_8));
+ }
+ throw failure;
+ }
+ OutputStream out = inv.getArgument(1);
+ out.write(successResponse(request.get("id").asInt()));
+ return 0;
+ }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class));
+ return dispatched;
+ }
+
+ private List stubBatchWithMalformedMiddleResponse() throws Exception {
+ List dispatched = new ArrayList<>();
+ doAnswer(inv -> {
+ JsonNode request = MAPPER.readTree((InputStream) inv.getArgument(0));
+ int id = request.get("id").asInt();
+ dispatched.add(id);
+ OutputStream out = inv.getArgument(1);
+ out.write(id == 2 ? "not-json".getBytes(StandardCharsets.UTF_8) : successResponse(id));
+ return 0;
+ }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class));
+ return dispatched;
+ }
+
+ private static byte[] successResponse(int id) {
+ return ("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":" + id + "}")
+ .getBytes(StandardCharsets.UTF_8);
+ }
+
+ private static JsonNode recoveredBatch(String middleId) throws IOException {
+ return MAPPER.readTree("[{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":1},"
+ + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error\"},"
+ + "\"id\":" + middleId + "},{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":3}]");
+ }
+
+ private static void assertRecoveredBatch(MockHttpServletResponse response, String middleId)
+ throws IOException {
+ assertEquals(200, response.getStatus());
+ assertEquals("application/json-rpc", response.getContentType());
+ assertEquals(recoveredBatch(middleId), MAPPER.readTree(response.getContentAsByteArray()));
+ }
+
+ private static int limitBeforeSecondErrorFits() throws IOException {
+ ArrayNode firstTwo = MAPPER.createArrayNode();
+ firstTwo.add(recoveredBatch("2").get(0));
+ firstTwo.add(recoveredBatch("2").get(1));
+ return MAPPER.writeValueAsBytes(firstTwo).length - 1;
+ }
+
+ private static void assertRecoveredErrorConsumesBudget(MockHttpServletResponse response)
+ throws IOException {
+ assertEquals(200, response.getStatus());
+ assertEquals("application/json-rpc", response.getContentType());
+ ArrayNode expected = (ArrayNode) recoveredBatch("2");
+ expected.set(2, MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32003,"
+ + "\"message\":\"Response exceeds the limit of "
+ + CommonParameter.getInstance().getJsonRpcMaxResponseSize()
+ + " bytes\"},\"id\":3}"));
+ assertEquals(expected, MAPPER.readTree(response.getContentAsByteArray()));
+ }
+
+ private static void assertBatchErrorOverflow(MockHttpServletResponse response)
+ throws IOException {
+ assertEquals(200, response.getStatus());
+ assertEquals("application/json-rpc", response.getContentType());
+ JsonNode body = MAPPER.readTree(response.getContentAsByteArray());
+ assertEquals(3, body.size());
+ assertEquals(recoveredBatch("2").get(0), body.get(0));
+ for (int i = 1; i < body.size(); i++) {
+ assertEquals("2.0", body.get(i).get("jsonrpc").asText());
+ assertEquals(-32003, body.get(i).get("error").get("code").asInt());
+ assertEquals("Response exceeds the limit of "
+ + CommonParameter.getInstance().getJsonRpcMaxResponseSize() + " bytes",
+ body.get(i).get("error").get("message").asText());
+ assertFalse(body.get(i).get("error").has("data"));
+ assertEquals(i + 1, body.get(i).get("id").asInt());
+ }
+ }
+
+ private static void assertInvalidRequestWithNullId(JsonNode response) {
+ assertEquals(MAPPER.getNodeFactory().textNode("2.0"), response.get("jsonrpc"));
+ assertEquals(MAPPER.getNodeFactory().numberNode(-32600),
+ response.get("error").get("code"));
+ assertEquals(MAPPER.getNodeFactory().textNode("Invalid Request"),
+ response.get("error").get("message"));
+ assertFalse(response.get("error").has("data"));
+ assertTrue(response.get("id").isNull());
+ }
+
private MockHttpServletResponse doPost(String body) throws Exception {
- MockHttpServletRequest req = new MockHttpServletRequest("POST", "/jsonrpc");
- req.setContent(body.getBytes(StandardCharsets.UTF_8));
MockHttpServletResponse resp = new MockHttpServletResponse();
- servlet.callDoPost(req, resp);
+ doPost(body, resp);
return resp;
}
+ private void doPost(String body, HttpServletResponse response) throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/jsonrpc");
+ request.setContent(body.getBytes(StandardCharsets.UTF_8));
+ servlet.callDoPost(request, response);
+ }
+
+ private void assertCleanupFailureDoesNotReplaceFatal(HttpServletResponse response)
+ throws Exception {
+ StackOverflowError fatal = new StackOverflowError("original-fatal-marker");
+ doThrow(fatal).when(mockRpcServer)
+ .handleRequest(any(InputStream.class), any(OutputStream.class));
+
+ StackOverflowError thrown = assertThrows(StackOverflowError.class,
+ () -> doPost("{\"method\":\"eth_blockNumber\",\"id\":1}", response));
+
+ assertSame(fatal, thrown);
+ }
+
+ private void assertWrappedFatalPropagates(Throwable failure, Error fatal, boolean batch)
+ throws Exception {
+ List dispatched = new ArrayList<>();
+ doAnswer(inv -> {
+ JsonNode request = MAPPER.readTree((InputStream) inv.getArgument(0));
+ int id = request.get("id").asInt();
+ dispatched.add(id);
+ if (id == (batch ? 2 : 1)) {
+ throw failure;
+ }
+ OutputStream out = inv.getArgument(1);
+ out.write(("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":" + id + "}")
+ .getBytes(StandardCharsets.UTF_8));
+ return 0;
+ }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class));
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ String request = batch ? "[{\"id\":1},{\"id\":2},{\"id\":3}]" : "{\"id\":1}";
+
+ Error thrown = assertThrows(Error.class, () -> doPost(request, response));
+
+ assertSame(fatal, thrown);
+ assertEquals(500, response.getStatus());
+ assertEquals(0, response.getContentAsByteArray().length);
+ assertTrue(response.isCommitted());
+ assertEquals(batch ? Arrays.asList(1, 2) : Arrays.asList(1), dispatched);
+ }
+
+ private static void assertSingleInternalError(MockHttpServletResponse response, String id)
+ throws IOException {
+ assertEquals(200, response.getStatus());
+ assertEquals("application/json-rpc", response.getContentType());
+ assertFalse(response.getContentAsString().contains("partial-sensitive-marker"));
+ assertFalse(response.getContentAsString().contains("assertion-sensitive-marker"));
+ assertEquals(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,"
+ + "\"message\":\"Internal error\"},\"id\":" + id + "}"),
+ MAPPER.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
+ .readTree(response.getContentAsByteArray()));
+ }
+
+ private static class TrackingResponseWrapper extends HttpServletResponseWrapper {
+
+ private int cleanupCalls;
+
+ TrackingResponseWrapper(HttpServletResponse response) {
+ super(response);
+ }
+
+ @Override
+ public boolean isCommitted() {
+ cleanupCalls++;
+ return true;
+ }
+
+ @Override
+ public void resetBuffer() {
+ cleanupCalls++;
+ }
+
+ @Override
+ public void flushBuffer() {
+ cleanupCalls++;
+ }
+ }
+
private static class TestableServlet extends JsonRpcServlet {
void callDoPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/TronJsonRpcImplChainIdentityTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/TronJsonRpcImplChainIdentityTest.java
new file mode 100644
index 00000000000..93ba96ace05
--- /dev/null
+++ b/framework/src/test/java/org/tron/core/services/jsonrpc/TronJsonRpcImplChainIdentityTest.java
@@ -0,0 +1,78 @@
+package org.tron.core.services.jsonrpc;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import org.junit.Assert;
+import org.junit.Test;
+import org.slf4j.LoggerFactory;
+import org.tron.core.Wallet;
+import org.tron.core.capsule.BlockCapsule;
+import org.tron.core.exception.jsonrpc.JsonRpcInternalException;
+
+public class TronJsonRpcImplChainIdentityTest {
+
+ @Test
+ public void testChainIdentityLogsFailureStateTransitions() throws Exception {
+ Wallet wallet = mock(Wallet.class);
+ BlockCapsule genesis = mock(BlockCapsule.class);
+ when(genesis.getBlockId()).thenReturn(new BlockCapsule.BlockId(new byte[32], 0));
+ when(wallet.getBlockCapsuleByNum(0))
+ .thenReturn(null)
+ .thenReturn(null)
+ .thenReturn(genesis)
+ .thenReturn(null);
+
+ TronJsonRpcImpl rpc = new TronJsonRpcImpl(null, wallet);
+ Logger apiLogger = (Logger) LoggerFactory.getLogger("API");
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ apiLogger.addAppender(appender);
+
+ try {
+ Assert.assertThrows(JsonRpcInternalException.class, rpc::ethChainId);
+ Assert.assertThrows(JsonRpcInternalException.class, rpc::ethChainId);
+ Assert.assertEquals("0x00000000", rpc.ethChainId());
+ Assert.assertThrows(JsonRpcInternalException.class, rpc::ethChainId);
+
+ Assert.assertEquals(2,
+ countEvents(appender, Level.WARN, "Chain identity lookup failed"));
+ Assert.assertEquals(1,
+ countEvents(appender, Level.INFO, "Chain identity lookup recovered"));
+
+ ILoggingEvent firstFailure = findEvent(
+ appender, Level.WARN, "Chain identity lookup failed");
+ Assert.assertNotNull(firstFailure);
+ Assert.assertNotNull(firstFailure.getThrowableProxy());
+ } finally {
+ apiLogger.detachAppender(appender);
+ appender.stop();
+ rpc.close();
+ }
+ }
+
+ private static ILoggingEvent findEvent(ListAppender appender, Level level,
+ String marker) {
+ for (ILoggingEvent event : appender.list) {
+ if (event.getLevel() == level && event.getFormattedMessage().contains(marker)) {
+ return event;
+ }
+ }
+ return null;
+ }
+
+ private static int countEvents(ListAppender appender, Level level,
+ String marker) {
+ int count = 0;
+ for (ILoggingEvent event : appender.list) {
+ if (event.getLevel() == level && event.getFormattedMessage().contains(marker)) {
+ count++;
+ }
+ }
+ return count;
+ }
+}
diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/filters/LogBlockQueryFailureTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/filters/LogBlockQueryFailureTest.java
new file mode 100644
index 00000000000..b377ca415d4
--- /dev/null
+++ b/framework/src/test/java/org/tron/core/services/jsonrpc/filters/LogBlockQueryFailureTest.java
@@ -0,0 +1,108 @@
+package org.tron.core.services.jsonrpc.filters;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.classic.spi.IThrowableProxy;
+import ch.qos.logback.core.read.ListAppender;
+import java.util.BitSet;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.ArgumentMatchers;
+import org.slf4j.LoggerFactory;
+import org.tron.core.services.jsonrpc.TronJsonRpc.FilterRequest;
+import org.tron.core.store.SectionBloomStore;
+
+public class LogBlockQueryFailureTest {
+
+ private static final long CURRENT_MAX_BLOCK_NUM = 100L;
+ private static final String SENSITIVE_MARKER = "worker-sensitive-marker";
+
+ @Test
+ public void testExecutionFailureLogsCauseAndRethrowsWrapper() throws Exception {
+ NullPointerException cause = new NullPointerException(SENSITIVE_MARKER);
+ ExecutionException failure = new ExecutionException(cause);
+ Future future = futureThrowing(failure);
+ LogBlockQuery query = newQuery(future);
+ ListAppender appender = attachApiAppender();
+
+ try {
+ ExecutionException thrown = Assert.assertThrows(ExecutionException.class,
+ query::getPossibleBlock);
+
+ Assert.assertSame(failure, thrown);
+ Assert.assertTrue(hasThrowable(appender, NullPointerException.class, SENSITIVE_MARKER));
+ } finally {
+ detachApiAppender(appender);
+ }
+ }
+
+ @Test
+ public void testInterruptionRestoresFlagAndRethrows() throws Exception {
+ Thread.interrupted();
+ InterruptedException failure = new InterruptedException();
+ Future future = futureThrowing(failure);
+ LogBlockQuery query = newQuery(future);
+ ListAppender appender = attachApiAppender();
+
+ try {
+ InterruptedException thrown = Assert.assertThrows(InterruptedException.class,
+ query::getPossibleBlock);
+
+ Assert.assertSame(failure, thrown);
+ Assert.assertTrue(Thread.currentThread().isInterrupted());
+ Assert.assertTrue(hasThrowable(appender, InterruptedException.class, null));
+ } finally {
+ Thread.interrupted();
+ detachApiAppender(appender);
+ }
+ }
+
+ private static LogBlockQuery newQuery(Future future) throws Exception {
+ ExecutorService executor = mock(ExecutorService.class);
+ when(executor.submit(ArgumentMatchers.>any())).thenReturn(future);
+ SectionBloomStore store = mock(SectionBloomStore.class);
+ LogFilterWrapper wrapper = new LogFilterWrapper(
+ new FilterRequest("0x0", "0x1",
+ "0x1111111111111111111111111111111111111111", null, null),
+ CURRENT_MAX_BLOCK_NUM, null, false);
+ return new LogBlockQuery(wrapper, store, CURRENT_MAX_BLOCK_NUM, executor);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Future futureThrowing(Exception failure) throws Exception {
+ Future future = mock(Future.class);
+ when(future.get()).thenThrow(failure);
+ return future;
+ }
+
+ private static ListAppender attachApiAppender() {
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ ((Logger) LoggerFactory.getLogger("API")).addAppender(appender);
+ return appender;
+ }
+
+ private static void detachApiAppender(ListAppender appender) {
+ ((Logger) LoggerFactory.getLogger("API")).detachAppender(appender);
+ appender.stop();
+ }
+
+ private static boolean hasThrowable(ListAppender appender,
+ Class extends Throwable> type, String message) {
+ for (ILoggingEvent event : appender.list) {
+ IThrowableProxy throwable = event.getThrowableProxy();
+ if (throwable != null && type.getName().equals(throwable.getClassName())
+ && (message == null || message.equals(throwable.getMessage()))) {
+ return true;
+ }
+ }
+ return false;
+ }
+}