From dcd61d6131d4895df2f3743e8aa606fbb298c815 Mon Sep 17 00:00:00 2001 From: ykisana Date: Thu, 8 Oct 2026 11:28:51 -0600 Subject: [PATCH] core: Count cancelled server calls as failed in channelz When a server call was cancelled (e.g. by the client, a deadline, or a transport reset) before the application called close(), the server's CallTracer recorded callsStarted but never callsSucceeded or callsFailed, so the gap between them grew unbounded on long-lived servers. ServerStreamListenerImpl.closed() now reports the end of the call. A guard ensures the call end is reported exactly once, which also fixes the missing-response path that previously recorded both a failure and a success for the same call. Fixes #13063 Co-Authored-By: Claude Opus 5.5 --- .../java/io/grpc/internal/ServerCallImpl.java | 17 +++++- .../io/grpc/internal/ServerCallImplTest.java | 61 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/io/grpc/internal/ServerCallImpl.java b/core/src/main/java/io/grpc/internal/ServerCallImpl.java index f15ad1d4f1f..11eb17c1f88 100644 --- a/core/src/main/java/io/grpc/internal/ServerCallImpl.java +++ b/core/src/main/java/io/grpc/internal/ServerCallImpl.java @@ -46,6 +46,7 @@ import io.perfmark.Tag; import io.perfmark.TaskCloseable; import java.io.InputStream; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.logging.Logger; @@ -66,6 +67,9 @@ final class ServerCallImpl extends ServerCall { private final DecompressorRegistry decompressorRegistry; private final CompressorRegistry compressorRegistry; private CallTracer serverCallTracer; + // Ensures the call end is reported to serverCallTracer exactly once. Both the application + // (via close()) and the transport (via ServerStreamListener.closed()) may report it. + private final AtomicBoolean callEndReported = new AtomicBoolean(); // state private volatile boolean cancelled; @@ -226,7 +230,13 @@ private void closeInternal(Status status, Metadata trailers) { stream.close(status, trailers); } finally { - serverCallTracer.reportCallEnded(status.isOk()); + reportCallEnded(status.isOk()); + } + } + + private void reportCallEnded(boolean success) { + if (callEndReported.compareAndSet(false, true)) { + serverCallTracer.reportCallEnded(success); } } @@ -284,7 +294,7 @@ private void handleInternalError(Throwable internalError) { : Status.INTERNAL.withCause(internalError) .withDescription("Internal error so cancelling stream."); stream.cancel(status); - serverCallTracer.reportCallEnded(false); // error so always false + reportCallEnded(false); // error so always false } /** @@ -406,6 +416,9 @@ private void closedInternal(Status status) { GrpcUtil.closeQuietly(delayedMessage); delayedMessage = null; } + // The application may never call close() if the call was cancelled, e.g. by the client or + // the transport, so report the end of the call here too. + call.reportCallEnded(status.isOk()); Throwable cancelCause = null; try { if (status.isOk()) { diff --git a/core/src/test/java/io/grpc/internal/ServerCallImplTest.java b/core/src/test/java/io/grpc/internal/ServerCallImplTest.java index babf8db8f2c..26f464b9ba7 100644 --- a/core/src/test/java/io/grpc/internal/ServerCallImplTest.java +++ b/core/src/test/java/io/grpc/internal/ServerCallImplTest.java @@ -156,6 +156,67 @@ private void callTracer0(Status status) { } } + @Test + public void callTracer_clientCancelled_reportsCallFailed() { + ServerStreamListenerImpl streamListener = + new ServerCallImpl.ServerStreamListenerImpl<>(call, callListener, context); + + streamListener.closed(Status.CANCELLED); + + ServerStats after = getServerStats(serverCallTracer); + assertEquals(1, after.callsStarted); + assertEquals(0, after.callsSucceeded); + assertEquals(1, after.callsFailed); + } + + @Test + public void callTracer_clientCancelledThenServerClosed_reportsOnlyOnce() { + ServerStreamListenerImpl streamListener = + new ServerCallImpl.ServerStreamListenerImpl<>(call, callListener, context); + + streamListener.closed(Status.CANCELLED); + call.sendHeaders(new Metadata()); + call.sendMessage(123L); + call.close(Status.OK, new Metadata()); + + ServerStats after = getServerStats(serverCallTracer); + assertEquals(1, after.callsStarted); + assertEquals(0, after.callsSucceeded); + assertEquals(1, after.callsFailed); + } + + @Test + public void callTracer_serverClosedThenStreamClosed_reportsOnlyOnce() { + ServerStreamListenerImpl streamListener = + new ServerCallImpl.ServerStreamListenerImpl<>(call, callListener, context); + + call.sendHeaders(new Metadata()); + call.sendMessage(123L); + call.close(Status.OK, new Metadata()); + streamListener.closed(Status.CANCELLED); + + ServerStats after = getServerStats(serverCallTracer); + assertEquals(1, after.callsStarted); + assertEquals(1, after.callsSucceeded); + assertEquals(0, after.callsFailed); + } + + @Test + public void callTracer_missingResponse_reportsOnlyFailure() { + call.close(Status.OK, new Metadata()); + + ServerStats after = getServerStats(serverCallTracer); + assertEquals(1, after.callsStarted); + assertEquals(0, after.callsSucceeded); + assertEquals(1, after.callsFailed); + } + + private static ServerStats getServerStats(CallTracer tracer) { + ServerStats.Builder builder = new ServerStats.Builder(); + tracer.updateBuilder(builder); + return builder.build(); + } + @Test public void request() { call.request(10);