diff --git a/netty/src/main/java/io/grpc/netty/ClientTransportLifecycleManager.java b/netty/src/main/java/io/grpc/netty/ClientTransportLifecycleManager.java index 01e7bc3ed12..2c456aebf54 100644 --- a/netty/src/main/java/io/grpc/netty/ClientTransportLifecycleManager.java +++ b/netty/src/main/java/io/grpc/netty/ClientTransportLifecycleManager.java @@ -69,6 +69,14 @@ public void notifyGracefulShutdown(Status s, DisconnectError disconnectError) { public boolean notifyShutdown(Status s, DisconnectError disconnectError) { notifyGracefulShutdown(s, disconnectError); if (shutdownStatus != null) { + // Check if the incoming error is just the routine channel closure exception + boolean isClosedChannel = s.getCause() instanceof java.nio.channels.ClosedChannelException; + + // Status Upgrade: Overwrite graceful shutdown if a hard network error occurs + if (shutdownStatus.getCause() == null && s.getCause() != null && !isClosedChannel) { + shutdownStatus = s; + return true; + } return false; } shutdownStatus = s; diff --git a/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java b/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java index db44c8f50fd..7d1ead1face 100644 --- a/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java @@ -299,6 +299,31 @@ public void maxMessageSizeShouldBeEnforced() throws Throwable { } } + @Test + public void networkErrorOverridesGracefulShutdownStatus() throws Exception { + startServer(); + NettyClientTransport transport = newTransport(newNegotiator()); + callMeMaybe(transport.start(clientTransportListener)); + + // 1. Trigger graceful shutdown + Status gracefulStatus = Status.UNAVAILABLE.withDescription("Channel shutdown invoked"); + transport.shutdown(gracefulStatus); + + // 2. Simulate a real network drop (e.g., Connection Reset) + java.io.IOException networkCause = new java.io.IOException("Connection reset by peer"); + transport.channel().pipeline().fireExceptionCaught(networkCause); + transport.channel().pipeline().fireChannelInactive(); + + // 3. Verify the listener receives the IO error, NOT the graceful status + verify(clientTransportListener, timeout(5000)).transportShutdown( + org.mockito.ArgumentMatchers.argThat(status -> + status != null && status.getCause() instanceof java.io.IOException + ), + org.mockito.ArgumentMatchers.any() + ); + } + + /** * Verifies that we can create multiple TLS client transports from the same builder. */