1717import io .helidon .http .Headers ;
1818import io .helidon .http .WritableHeaders ;
1919import io .helidon .http .http2 .Http2FrameData ;
20+ import io .helidon .http .http2 .Http2FrameHeader ;
2021import io .helidon .http .http2 .Http2Headers ;
22+ import io .helidon .http .http2 .Http2Ping ;
2123import io .helidon .http .http2 .Http2StreamState ;
2224import io .helidon .webclient .http2 .Http2ClientStream ;
2325import io .helidon .webclient .http2 .StreamTimeoutException ;
26+ import java .io .IOException ;
27+ import java .io .UncheckedIOException ;
2428import java .util .List ;
2529import java .util .concurrent .ExecutionException ;
2630import java .util .concurrent .TimeUnit ;
@@ -165,6 +169,58 @@ private boolean isStreamOpen() {
165169 && clientStream .streamState () != Http2StreamState .CLOSED ;
166170 }
167171
172+ /**
173+ * Send a ping to the server.
174+ * <p>
175+ * Do NOT use Http2ClientStream.sendPing()! It works once. A second ping results in sending garbage frames
176+ * to the server (indirectly), and the server closes the connection. The exact cause is still unknown, but it may
177+ * be related to the usage of this connection's flowControl object for sending the pings which may
178+ * interfere with the regular data transfers occurring via this same connection concurrently with the ping.
179+ * Another reason for this may be the fact that it uses a static HTTP2_PING object for sending pings, but
180+ * it never rewind()'s the buffer that holds the ping payload, so the server may read bytes from a subsequent
181+ * regular data frame and interpret them as the ping payload, which should break the HTTP2 connection as a whole.
182+ * <p>
183+ * There's Http2ClientConnection.ping() method that explicitly uses the FlowControl.Outbound.NOOP for sending
184+ * new ping objects. However, that method is package-private.
185+ * <p>
186+ * So we implement our own sendPing() here that uses new Http2Ping objects and doesn't use the flowControl.
187+ * <p>
188+ * NOTE: Http2ClientStream methods use an Http2ConnectionWriter object via Http2ClientConnection.writer()
189+ * to write data, and it's a wrapper around the ClientConnection's DataWriter object.
190+ * And the Http2ConnectionWriter has some additional synchronization around DataWriter.write() calls.
191+ * However, ironically, it doesn't synchronize access to the flowControl object. Regardless, there's no public
192+ * methods to obtain a reference to the Http2ConnectionWriter or its internal lock. So we have to write
193+ * to the ClientConnection's DataWriter object directly. Stress-testing hasn't revealed any thread-races so far.
194+ * <p>
195+ * It's difficult to imagine a situation where the thread-race could occur. Perhaps a single PbjGrpcClient
196+ * (aka a single HTTP2 connection) and two streaming PbjGrpcCalls (aka HTTP2 streams) open concurrently,
197+ * one being very chatty and another one being very silent. The latter may start sending pings while the former
198+ * is sending requests to the server. However, this scenario seems very rare. If we ever encounter this issue,
199+ * then it's easy to work-around by creating separate PbjGrpcClients for the two calls on the client side.
200+ * To fix it, ideally we'd work with Helidon to expose the necessary APIs for synchronous writes. Alternatively,
201+ * we could introduce a PbjGrpcClient-level outgoing queue and send all requests and pings through it as
202+ * a work-around. However, this work-around may not fully cover the issue because Helidon can write window update
203+ * frames for the flowControl changes concurrently still as it reads data from the stream/socket.
204+ */
205+ private void sendPing () {
206+ final Http2Ping ping = Http2Ping .create ();
207+ final Http2FrameData frameData = ping .toFrameData ();
208+ final Http2FrameHeader frameHeader = frameData .header ();
209+ if (frameHeader .length () == 0 ) {
210+ throw new IllegalStateException ("Ping with zero length. This should never happen." );
211+ } else {
212+ final BufferData headerData = frameHeader .write ();
213+ final BufferData data = frameData .data ().copy ();
214+ try {
215+ grpcClient .getClientConnection ().writer ().writeNow (BufferData .create (headerData , data ));
216+ } catch (IllegalStateException e ) {
217+ // It may throw IllegalStateException: Attempt to call writer() on a closed connection
218+ // But callers usually expect an UncheckedIOException:
219+ throw new UncheckedIOException (new IOException ("sendPing failed" , e ));
220+ }
221+ }
222+ }
223+
168224 private void receiveRepliesLoop () {
169225 try {
170226 Http2Headers http2Headers = null ;
@@ -180,10 +236,7 @@ private void receiveRepliesLoop() {
180236 // if the server died.
181237 // FUTURE WORK: consider a separate KeepAlive timeout for these pings, so that we don't flood the
182238 // network.
183- // NOTE: Google GRPC server drops the connection if pinged right after receiving the headers - it
184- // complains about the frame size of 64K with 16K max allowed. It's unclear how/why this even
185- // happens. However, pinging on timeout seems to work smoothly so far.
186- clientStream .sendPing ();
239+ sendPing ();
187240 }
188241 } while (http2Headers == null && isStreamOpen ());
189242
@@ -201,7 +254,7 @@ private void receiveRepliesLoop() {
201254 frameData = clientStream .readOne (grpcClient .getConfig ().readTimeout ());
202255 } catch (StreamTimeoutException e ) {
203256 // Check if the connection is alive. See a comment above about the KeepAlive timeout.
204- clientStream . sendPing ();
257+ sendPing ();
205258 // FUTURE WORK: implement an uber timeout to return
206259 continue ;
207260 }
0 commit comments