diff --git a/changes.md b/changes.md index c4484e2572..4106e5e0a2 100644 --- a/changes.md +++ b/changes.md @@ -81,6 +81,14 @@ Changes log supporting helper class. Applications with multiple STDIO protocol helpers must select one explicitly. - Enhancements + - Added an experimental SDK-native gRPC unary client on a borrowed Restlet HTTP/2 + connector, with explicit plaintext opt-in, bounded messages and call admission. + Regression tests cover authority mapping, cancellation before and after headers, + disconnect recovery, malformed responses and shared connector ownership. Empty + responses retain SDK missing-status diagnostics instead of failing on a null stream. + Shutdown arriving before SDK listener installation is replayed once; interrupted + shutdown still closes owned transport resources and preserves the interrupt. + TLS, streaming methods and physical gRPC connection controls remain unsupported. - Added the supported `org.restlet.util.StreamTransport` seam for protocol-neutral, bounded synchronous byte I/O, serialized writes, borrowed-stream cancellation and owned subprocess lifecycle with separate bounded stderr capture. No protocol diff --git a/org.restlet.ext.grpc/pom.xml b/org.restlet.ext.grpc/pom.xml new file mode 100644 index 0000000000..8e4f53f1f1 --- /dev/null +++ b/org.restlet.ext.grpc/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + org.restlet + org.restlet.parent + 3.0.0-SNAPSHOT + + org.restlet.ext.grpc + Restlet Framework - gRPC extension + Native gRPC unary client on a borrowed Restlet HTTP/2 connector. + + + + com.google.guava + guava + 33.6.0-android + + + com.google.errorprone + error_prone_annotations + 2.50.0 + + + io.grpc + grpc-bom + ${lib-grpc-version} + pom + import + + + + + + com.google.guava + guava + + + org.restlet + org.restlet + ${project.version} + + + io.grpc + grpc-core + + + io.grpc + grpc-stub + test + + + io.grpc + grpc-protobuf + test + + + io.grpc + grpc-netty-shaded + test + + + io.grpc + grpc-services + test + + + org.junit.jupiter + junit-jupiter-api + ${lib-junit-version} + test + + + diff --git a/org.restlet.ext.grpc/src/main/java/module-info.java b/org.restlet.ext.grpc/src/main/java/module-info.java new file mode 100644 index 0000000000..e5da89d47b --- /dev/null +++ b/org.restlet.ext.grpc/src/main/java/module-info.java @@ -0,0 +1,17 @@ +/** + * Copyright 2005-2026 Qlik + * + *

The content of this file is subject to the terms of the Apache 2.0 open source license + * available at https://www.opensource.org/licenses/apache-2.0 + * + *

Restlet is a registered trademark of QlikTech International AB. + */ +@SuppressWarnings({"requires-automatic", "requires-transitive-automatic"}) +module org.restlet.ext.grpc { + requires transitive org.restlet; + requires transitive io.grpc; + requires io.grpc.internal; + requires com.google.common; + + exports org.restlet.ext.grpc; +} diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcClientAdapter.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcClientAdapter.java new file mode 100644 index 0000000000..3847e932e0 --- /dev/null +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcClientAdapter.java @@ -0,0 +1,292 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.grpc; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannel; +import io.grpc.MethodDescriptor; +import io.grpc.internal.ManagedChannelImplBuilder; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import org.restlet.Client; +import org.restlet.Context; +import org.restlet.ext.grpc.internal.RestletTransportFactory; + +/** + * Single-use native gRPC channel on a borrowed Restlet HTTP/2 client. + * + *

This initial profile is unary and plaintext only; applications must explicitly opt in with + * {@link #setPlaintext(boolean)}. TLS/address separation, proxy CONNECT, streaming, retry, + * keepalive and physical-connection controls are not implemented. No SDK network transport is + * selected. The host starts and stops the borrowed connector. + */ +public final class GrpcClientAdapter implements AutoCloseable { + private final Context context; + private final List interceptors = new CopyOnWriteArrayList<>(); + private Client client; + private String target; + private boolean plaintext; + private int maxMessageBytes = 1024 * 1024; + private int maxConcurrentCalls = 128; + private Duration maxCallDuration = Duration.ofSeconds(30); + private Duration shutdownTimeout = Duration.ofSeconds(5); + private Executor executor; + private boolean configured; + private ManagedChannel channel; + private RestletTransportFactory transportFactory; + + public GrpcClientAdapter() { + this(new Context()); + } + + public GrpcClientAdapter(Context context) { + this.context = Objects.requireNonNull(context, "context"); + } + + public Context getContext() { + return context; + } + + public synchronized void setClient(Client value) { + requireConfigurable(); + client = Objects.requireNonNull(value, "client"); + } + + public synchronized Client getClient() { + return client; + } + + /** Sets a native DNS target, for example {@code dns:///localhost:8080}. */ + public synchronized void setTarget(String value) { + requireConfigurable(); + target = Objects.requireNonNull(value, "target"); + } + + public synchronized String getTarget() { + return target; + } + + public synchronized void setPlaintext(boolean value) { + requireConfigurable(); + plaintext = value; + } + + public synchronized boolean isPlaintext() { + return plaintext; + } + + public synchronized void setMaxMessageBytes(int value) { + requireConfigurable(); + if (value <= 0 || value > Integer.MAX_VALUE - 6) { + throw new IllegalArgumentException("A positive finite message limit is required"); + } + maxMessageBytes = value; + } + + public synchronized int getMaxMessageBytes() { + return maxMessageBytes; + } + + public synchronized void setMaxConcurrentCalls(int value) { + requireConfigurable(); + if (value <= 0) { + throw new IllegalArgumentException("A positive call limit is required"); + } + maxConcurrentCalls = value; + } + + public synchronized int getMaxConcurrentCalls() { + return maxConcurrentCalls; + } + + public synchronized void setMaxCallDuration(Duration value) { + requireConfigurable(); + maxCallDuration = positive(value); + } + + public synchronized Duration getMaxCallDuration() { + return maxCallDuration; + } + + public synchronized void setShutdownTimeout(Duration value) { + requireConfigurable(); + shutdownTimeout = positive(value); + } + + public synchronized Duration getShutdownTimeout() { + return shutdownTimeout; + } + + /** Sets a borrowed SDK callback executor. The adapter never shuts it down. */ + public synchronized void setExecutor(Executor value) { + requireConfigurable(); + executor = Objects.requireNonNull(value, "executor"); + } + + public List getInterceptors() { + return interceptors; + } + + private static Duration positive(Duration value) { + Objects.requireNonNull(value, "duration"); + if (value.isNegative() || value.isZero()) { + throw new IllegalArgumentException("A positive finite duration is required"); + } + return Duration.ofNanos(value.toNanos()); + } + + private void requireConfigurable() { + if (configured) { + throw new IllegalStateException("gRPC adapter is single-use; configuration is fixed"); + } + } + + public synchronized void start() { + if (isStarted()) { + return; + } + requireConfigurable(); + if (!plaintext) { + throw new UnsupportedOperationException( + "R3 supports only explicitly enabled plaintext; TLS is not implemented"); + } + Objects.requireNonNull(target, "target"); + if (client == null + || !client.isStarted() + || client.getContext() == null + || !"HTTP2" + .equals( + client.getContext() + .getParameters() + .getFirstValue("httpClientTransportMode")) + || !"false" + .equalsIgnoreCase( + client.getContext() + .getParameters() + .getFirstValue("followRedirects", "true"))) { + throw new IllegalStateException( + "Borrow a started HTTP2 client with followRedirects=false"); + } + if (!target.matches("(dns:///)?[^/?#]+:\\d+")) { + throw new IllegalArgumentException("Expected host:port or dns:///host:port target"); + } + configured = true; + var factory = + new RestletTransportFactory( + client, maxMessageBytes, maxConcurrentCalls, shutdownTimeout); + try { + var builder = + new ManagedChannelImplBuilder(target, () -> factory, () -> 80) + .disableRetry() + .disableServiceConfigLookUp() + .proxyDetector(address -> null) + .maxInboundMessageSize(maxMessageBytes) + // Registered first so that it clamps the final call options. + .intercept( + new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, + CallOptions options, + Channel next) { + var cap = + io.grpc.Deadline.after( + maxCallDuration.toNanos(), + TimeUnit.NANOSECONDS); + var deadline = options.getDeadline(); + return next.newCall( + method, + options.withDeadline( + deadline == null + ? cap + : deadline.minimum(cap))); + } + }) + // Registered last so they run first, before the duration cap. + .intercept(List.copyOf(interceptors)); + if (executor != null) { + builder.executor(executor); + } + channel = builder.build(); + transportFactory = factory; + } catch (RuntimeException e) { + try { + factory.close(); + factory.awaitTermination(); + } catch (RuntimeException cleanup) { + e.addSuppressed(cleanup); + } + throw e; + } + } + + public synchronized boolean isStarted() { + return channel != null && !channel.isShutdown(); + } + + public synchronized ManagedChannel getChannel() { + if (!isStarted()) { + throw new IllegalStateException("gRPC adapter is not started"); + } + return channel; + } + + public void stop() { + ManagedChannel current; + Duration timeout; + synchronized (this) { + current = channel; + timeout = shutdownTimeout; + } + if (current == null) { + return; + } + current.shutdown(); + RuntimeException failure = null; + try { + if (!current.awaitTermination(timeout.toNanos(), TimeUnit.NANOSECONDS)) { + current.shutdownNow(); + if (!current.awaitTermination(timeout.toNanos(), TimeUnit.NANOSECONDS)) { + throw new IllegalStateException("gRPC channel shutdown timed out"); + } + } + } catch (InterruptedException e) { + current.shutdownNow(); + Thread.currentThread().interrupt(); + failure = new IllegalStateException("Interrupted awaiting gRPC channel shutdown", e); + } catch (RuntimeException e) { + failure = e; + } + try { + transportFactory.close(); + transportFactory.awaitTermination(); + } catch (RuntimeException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + if (failure != null) { + throw failure; + } + } + + @Override + public void close() { + stop(); + } +} diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/GuardedClientStream.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/GuardedClientStream.java new file mode 100644 index 0000000000..e640b9e924 --- /dev/null +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/GuardedClientStream.java @@ -0,0 +1,128 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.grpc.internal; + +import io.grpc.Attributes; +import io.grpc.Compressor; +import io.grpc.Deadline; +import io.grpc.DecompressorRegistry; +import io.grpc.Status; +import io.grpc.internal.ClientStream; +import io.grpc.internal.ClientStreamListener; +import io.grpc.internal.InsightBuilder; +import java.io.InputStream; + +/** Delayed SDK streams must turn synchronous framing failures into terminal call status. */ +final class GuardedClientStream implements ClientStream { + private final ClientStream delegate; + private final int maxBytes; + private int maxOutboundBytes; + + GuardedClientStream(ClientStream delegate, int maxBytes) { + this.delegate = delegate; + this.maxBytes = maxBytes; + maxOutboundBytes = maxBytes; + } + + @Override + public void writeMessage(InputStream message) { + try { + delegate.writeMessage(message); + } catch (RuntimeException e) { + delegate.cancel(Status.fromThrowable(e)); + } + } + + @Override + public void cancel(Status reason) { + delegate.cancel(reason); + } + + @Override + public void halfClose() { + delegate.halfClose(); + } + + @Override + public void setAuthority(String authority) { + delegate.setAuthority(authority); + } + + @Override + public void setFullStreamDecompression(boolean enabled) { + delegate.setFullStreamDecompression(enabled); + } + + @Override + public void setDecompressorRegistry(DecompressorRegistry registry) { + delegate.setDecompressorRegistry(registry); + } + + @Override + public void start(ClientStreamListener listener) { + // The SDK framer accepts this limit only once, after per-call options are applied. + delegate.setMaxOutboundMessageSize(maxOutboundBytes); + delegate.start(listener); + } + + @Override + public void setMaxInboundMessageSize(int size) { + delegate.setMaxInboundMessageSize(Math.min(size, maxBytes)); + } + + @Override + public void setMaxOutboundMessageSize(int size) { + maxOutboundBytes = Math.min(size, maxBytes); + } + + @Override + public void setDeadline(Deadline deadline) { + delegate.setDeadline(deadline); + } + + @Override + public Attributes getAttributes() { + return delegate.getAttributes(); + } + + @Override + public void appendTimeoutInsight(InsightBuilder insight) { + delegate.appendTimeoutInsight(insight); + } + + @Override + public void request(int count) { + delegate.request(count); + } + + @Override + public void flush() { + delegate.flush(); + } + + @Override + public boolean isReady() { + return delegate.isReady(); + } + + @Override + public void optimizeForDirectExecutor() { + delegate.optimizeForDirectExecutor(); + } + + @Override + public void setCompressor(Compressor compressor) { + delegate.setCompressor(compressor); + } + + @Override + public void setMessageCompression(boolean enabled) { + delegate.setMessageCompression(enabled); + } +} diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletClientStream.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletClientStream.java new file mode 100644 index 0000000000..d22ab4e941 --- /dev/null +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletClientStream.java @@ -0,0 +1,428 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.grpc.internal; + +import io.grpc.Attributes; +import io.grpc.CallOptions; +import io.grpc.ClientStreamTracer; +import io.grpc.InternalMetadata; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import io.grpc.internal.AbstractClientStream; +import io.grpc.internal.Http2ClientStreamTransportState; +import io.grpc.internal.ReadableBuffers; +import io.grpc.internal.SerializingExecutor; +import io.grpc.internal.StatsTraceContext; +import io.grpc.internal.TransportFrameUtil; +import io.grpc.internal.TransportTracer; +import io.grpc.internal.WritableBuffer; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.function.Consumer; +import org.restlet.Client; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.data.Header; +import org.restlet.data.MediaType; +import org.restlet.data.Method; +import org.restlet.data.Reference; +import org.restlet.representation.ByteArrayRepresentation; +import org.restlet.util.Series; + +/** SDK-framed, bounded unary exchange. No gRPC frame/status codec is implemented here. */ +final class RestletClientStream extends AbstractClientStream { + private final SerializingExecutor serial = new SerializingExecutor(Runnable::run); + private final State state; + private final Client client; + private final String target; + private final String authority; + private final int wireLimit; + private final ExecutorService io; + private final Consumer finished; + private final ByteArrayOutputStream outbound = new ByteArrayOutputStream(); + private final StatsTraceContext stats; + private Metadata headers; + private volatile Request request; + private volatile Thread worker; + private volatile boolean done; + private boolean sent; + + RestletClientStream( + Client client, + InetSocketAddress address, + String authority, + MethodDescriptor method, + Metadata headers, + CallOptions options, + ClientStreamTracer[] tracers, + int limit, + ExecutorService io, + Consumer finished) { + this( + client, + address, + authority, + method, + headers, + options, + limit, + io, + finished, + StatsTraceContext.newClientContext(tracers, Attributes.EMPTY, headers), + TransportTracer.getDefaultFactory().create()); + } + + private RestletClientStream( + Client client, + InetSocketAddress address, + String authority, + MethodDescriptor method, + Metadata headers, + CallOptions options, + int limit, + ExecutorService io, + Consumer finished, + StatsTraceContext stats, + TransportTracer tracer) { + super( + hint -> new Buffer(Math.max(1, Math.min(hint, 16384))), + stats, + tracer, + headers, + options, + false); + this.client = client; + this.authority = authority; + String ip = address.getAddress().getHostAddress(); + if (ip.contains(":")) { + ip = "[" + ip + "]"; + } + target = "http://" + ip + ":" + address.getPort() + "/" + method.getFullMethodName(); + wireLimit = limit + 5; + this.io = io; + this.finished = finished; + this.stats = stats; + state = new State(limit, stats, tracer, options); + } + + @Override + protected State transportState() { + return state; + } + + @Override + public Attributes getAttributes() { + return Attributes.EMPTY; + } + + @Override + public void setAuthority(String value) { + if (!authority.equals(value)) { + throw new UnsupportedOperationException("Per-call authority override is not supported"); + } + } + + @Override + protected Sink abstractClientStreamSink() { + return new Sink() { + @Override + public void writeHeaders(Metadata metadata, byte[] payload) { + headers = metadata; + stats.clientOutboundHeaders(); + serial.execute(state::allocated); + } + + @Override + public void writeFrame(WritableBuffer frame, boolean end, boolean flush, int messages) { + Status failure = null; + synchronized (outbound) { + if (!done && frame instanceof Buffer buffer) { + if (buffer.readableBytes() > wireLimit - outbound.size()) { + failure = + Status.RESOURCE_EXHAUSTED.withDescription( + "Encoded unary request exceeds byte budget"); + } else { + outbound.write(buffer.data, 0, buffer.size); + } + } + if (frame != null) { + frame.release(); + } + if (failure == null && end && !done && !sent) { + sent = true; + try { + io.execute(RestletClientStream.this::exchange); + } catch (RejectedExecutionException e) { + failure = Status.UNAVAILABLE.withCause(e); + } + } + } + if (failure != null) { + fail(failure); + } + } + + @Override + public void cancel(Status status) { + fail(status); + } + }; + } + + private void fail(Status status) { + serial.execute( + () -> { + if (!done) { + state.fail(status); + } + }); + Request active = request; + if (active != null) { + active.abort(); + } + Thread activeWorker = worker; + if (activeWorker != null && activeWorker != Thread.currentThread()) { + activeWorker.interrupt(); + } + } + + private void exchange() { + worker = Thread.currentThread(); + Response response = null; + try { + if (done) { + return; + } + byte[] body; + synchronized (outbound) { + body = outbound.toByteArray(); + outbound.reset(); + } + Request call = + new Request( + Method.POST, + target, + new ByteArrayRepresentation( + body, MediaType.valueOf("application/grpc"))); + // Resource origin selects the resolved peer; base reference selects HTTP authority. + call.getResourceRef().setBaseRef(new Reference("http://" + authority)); + call.getHeaders().set("te", "trailers"); + byte[][] metadata = TransportFrameUtil.toHttp2Headers(headers); + for (int i = 0; i < metadata.length; i += 2) { + String key = new String(metadata[i], StandardCharsets.US_ASCII); + if (!key.startsWith(":") + && !"content-type".equals(key) + && !"user-agent".equals(key) + && !"te".equals(key)) { + call.getHeaders() + .add(key, new String(metadata[i + 1], StandardCharsets.US_ASCII)); + } + } + call.getClientInfo().setAgent("restlet-grpc/1.85.1"); + request = call; + if (done || Thread.currentThread().isInterrupted()) { + call.abort(); + return; + } + call.getDisconnectSignal() + .thenAccept( + cause -> + fail( + Status.UNAVAILABLE + .withDescription("gRPC peer disconnected") + .withCause(cause))); + response = client.handle(call); + if (done) { + return; + } + if (response.getProtocol() == null + || !"2.0".equals(response.getProtocol().getVersion())) { + fail( + Status.UNAVAILABLE + .withDescription("gRPC requires an HTTP/2 response") + .withCause(response.getStatus().getThrowable())); + return; + } + Metadata initial = metadata(response.getHeaders()); + initial.put( + InternalMetadata.keyOf(":status", Metadata.ASCII_STRING_MARSHALLER), + Integer.toString(response.getStatus().getCode())); + if (response.getEntity() != null && response.getEntity().getMediaType() != null) { + initial.put( + Metadata.Key.of("content-type", Metadata.ASCII_STRING_MARSHALLER), + response.getEntity().getMediaType().toString()); + } + if (response.getHeaders().getFirstValue("grpc-status", true) != null) { + serial.execute(() -> state.trailers(initial)); + return; + } + serial.execute(() -> state.headers(initial)); + if (response.getEntity() != null) { + try (var input = response.getEntity().getStream()) { + if (input != null) { + byte[] bytes = input.readNBytes(wireLimit + 1); + if (bytes.length > wireLimit) { + fail( + Status.RESOURCE_EXHAUSTED.withDescription( + "Encoded unary response exceeds byte budget")); + return; + } + serial.execute(() -> state.data(bytes)); + } + } + } + Metadata trailers = metadata(response.getTrailers()); + serial.execute(() -> state.trailers(trailers)); + } catch (IOException | RuntimeException e) { + fail(Status.UNAVAILABLE.withDescription("Restlet gRPC exchange failed").withCause(e)); + } finally { + if (response != null && response.getEntity() != null) { + response.getEntity().release(); + } + worker = null; + } + } + + private static Metadata metadata(Series

headers) { + var values = new ArrayList(); + for (Header header : headers) { + values.add( + header.getName() + .toLowerCase(java.util.Locale.ROOT) + .getBytes(StandardCharsets.US_ASCII)); + values.add(header.getValue().getBytes(StandardCharsets.US_ASCII)); + } + return InternalMetadata.newMetadata( + TransportFrameUtil.toRawSerializedHeaders(values.toArray(byte[][]::new))); + } + + private final class State extends Http2ClientStreamTransportState { + private boolean started; + private Status pendingFailure; + + private State( + int limit, StatsTraceContext stats, TransportTracer tracer, CallOptions options) { + super(limit, stats, tracer, options); + } + + private void allocated() { + started = true; + if (!done) { + if (pendingFailure != null) { + transportReportStatus(pendingFailure, true, new Metadata()); + } else { + onStreamAllocated(); + } + } + } + + private void fail(Status status) { + if (started) { + transportReportStatus(status, true, new Metadata()); + } else if (pendingFailure == null) { + // Shutdown may arrive after stream creation but before SDK listener installation. + pendingFailure = status; + } + } + + private void headers(Metadata metadata) { + if (!done) { + transportHeadersReceived(metadata); + } + } + + private void data(byte[] bytes) { + if (!done) { + transportDataReceived(ReadableBuffers.wrap(bytes), false); + } + } + + private void trailers(Metadata metadata) { + if (!done) { + transportTrailersReceived(metadata); + } + } + + @Override + public void runOnTransportThread(Runnable runnable) { + serial.execute(runnable); + } + + @Override + public void bytesRead(int count) { + // Unary wire input is bounded and materialized before SDK deframing. + } + + @Override + public void deframeFailed(Throwable cause) { + // Also abort the HTTP exchange, whose worker may be blocked reading the body. + RestletClientStream.this.fail(Status.fromThrowable(cause)); + } + + @Override + protected void http2ProcessingFailed( + Status status, boolean stopDelivery, Metadata trailers) { + transportReportStatus(status, stopDelivery, trailers); + } + + @Override + public void deframerClosed(boolean partial) { + try { + super.deframerClosed(partial); + } finally { + if (!done) { + done = true; + finished.accept(RestletClientStream.this); + } + } + } + } + + private static final class Buffer implements WritableBuffer { + private final byte[] data; + private int size; + + private Buffer(int capacity) { + data = new byte[capacity]; + } + + @Override + public void write(byte[] source, int offset, int length) { + System.arraycopy(source, offset, data, size, length); + size += length; + } + + @Override + public void write(byte value) { + data[size++] = value; + } + + @Override + public int writableBytes() { + return data.length - size; + } + + @Override + public int readableBytes() { + return size; + } + + @Override + public void release() { + Arrays.fill(data, (byte) 0); + } + } +} diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletTransportFactory.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletTransportFactory.java new file mode 100644 index 0000000000..dd28450b53 --- /dev/null +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletTransportFactory.java @@ -0,0 +1,265 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.grpc.internal; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import io.grpc.Attributes; +import io.grpc.CallOptions; +import io.grpc.ChannelCredentials; +import io.grpc.ChannelLogger; +import io.grpc.ClientStreamTracer; +import io.grpc.InternalChannelz; +import io.grpc.InternalLogId; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import io.grpc.internal.ClientStream; +import io.grpc.internal.ClientTransportFactory; +import io.grpc.internal.ConnectionClientTransport; +import io.grpc.internal.FailingClientStream; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.time.Duration; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import org.restlet.Client; + +/** Pinned grpc-java internal SPI. A logical transport is not a physical Netty connection. */ +public final class RestletTransportFactory implements ClientTransportFactory { + private final Client client; + private final int maxBytes; + private final Duration shutdownTimeout; + private final Semaphore admission; + private final Set transports = new HashSet<>(); + private final ExecutorService io = Executors.newVirtualThreadPerTaskExecutor(); + private final ScheduledThreadPoolExecutor timer = + new ScheduledThreadPoolExecutor( + 1, Thread.ofPlatform().daemon().name("restlet-grpc-timer").factory()); + private boolean closed; + + public RestletTransportFactory(Client client, int maxBytes, int maxCalls, Duration timeout) { + this.client = client; + this.maxBytes = maxBytes; + admission = new Semaphore(maxCalls); + shutdownTimeout = timeout; + // Cancelled deadline timers of completed calls must not stay queued until they expire. + timer.setRemoveOnCancelPolicy(true); + } + + @Override + public synchronized ConnectionClientTransport newClientTransport( + SocketAddress address, ClientTransportOptions options, ChannelLogger logger) { + if (closed) { + throw new IllegalStateException("gRPC transport factory is closed"); + } + if (!(address instanceof InetSocketAddress inet) + || inet.isUnresolved() + || options.getHttpConnectProxiedSocketAddress() != null) { + throw new IllegalArgumentException( + "Only resolved Internet addresses without proxy CONNECT are supported"); + } + Transport transport = new Transport(inet, options.getAuthority()); + transports.add(transport); + return transport; + } + + @Override + public ScheduledExecutorService getScheduledExecutorService() { + return timer; + } + + @Override + public SwapChannelCredentialsResult swapChannelCredentials(ChannelCredentials credentials) { + throw new UnsupportedOperationException("Channel credentials/TLS are not supported in R3"); + } + + @Override + public Collection> getSupportedSocketAddressTypes() { + return Set.of(InetSocketAddress.class); + } + + @Override + public void close() { + Set snapshot; + synchronized (this) { + if (closed) { + return; + } + closed = true; + snapshot = Set.copyOf(transports); + } + snapshot.forEach(t -> t.shutdownNow(Status.UNAVAILABLE.withDescription("Adapter closed"))); + timer.shutdownNow(); + io.shutdownNow(); + } + + /** Called by the facade, never from the SDK transport callback that may close the factory. */ + public void awaitTermination() { + try { + if (!io.awaitTermination(shutdownTimeout.toNanos(), TimeUnit.NANOSECONDS) + || !timer.awaitTermination(shutdownTimeout.toNanos(), TimeUnit.NANOSECONDS)) { + throw new IllegalStateException("gRPC transport worker shutdown timed out"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted closing gRPC transport workers", e); + } + } + + private final class Transport implements ConnectionClientTransport { + private final InetSocketAddress address; + private final String authority; + private final Set streams = new HashSet<>(); + private final InternalLogId id = InternalLogId.allocate(Transport.class, "Restlet HTTP/2"); + private Listener listener; + private Status shutdown; + private boolean terminated; + + private Transport(InetSocketAddress address, String authority) { + this.address = address; + this.authority = authority; + } + + @Override + public synchronized Runnable start(Listener value) { + listener = value; + return () -> { + synchronized (Transport.this) { + if (shutdown == null) { + listener.transportReady(); + } + } + }; + } + + @Override + public synchronized ClientStream newStream( + MethodDescriptor method, + Metadata headers, + CallOptions options, + ClientStreamTracer[] tracers) { + Status rejected = shutdown; + if (rejected == null + && options.getAuthority() != null + && !authority.equals(options.getAuthority())) { + rejected = + Status.UNIMPLEMENTED.withDescription( + "Per-call authority override is not supported"); + } + if (rejected == null && method.getType() != MethodDescriptor.MethodType.UNARY) { + rejected = Status.UNIMPLEMENTED.withDescription("R3 supports unary methods only"); + } + if (rejected == null + && options.getCompressor() != null + && !"identity".equals(options.getCompressor())) { + rejected = + Status.UNIMPLEMENTED.withDescription( + "Request compression is not supported"); + } + if (rejected != null) { + return new FailingClientStream(rejected, tracers); + } + if (!admission.tryAcquire()) { + return new FailingClientStream( + Status.RESOURCE_EXHAUSTED.withDescription("gRPC call limit exceeded"), + tracers); + } + RestletClientStream stream = + new RestletClientStream( + client, + address, + authority, + method, + headers, + options, + tracers, + maxBytes, + io, + this::finished); + streams.add(stream); + if (streams.size() == 1) { + listener.transportInUse(true); + } + return new GuardedClientStream(stream, maxBytes); + } + + private synchronized void finished(RestletClientStream stream) { + if (streams.remove(stream)) { + admission.release(); + if (streams.isEmpty()) { + listener.transportInUse(false); + terminateIfIdle(); + } + } + } + + @Override + public synchronized void shutdown(Status status) { + if (shutdown == null) { + shutdown = status; + listener.transportShutdown(status, null); + terminateIfIdle(); + } + } + + private void terminateIfIdle() { + if (shutdown != null && streams.isEmpty() && !terminated) { + terminated = true; + listener.transportTerminated(); + synchronized (RestletTransportFactory.this) { + transports.remove(this); + } + } + } + + @Override + public void shutdownNow(Status status) { + Set active; + synchronized (this) { + shutdown(status); + active = Set.copyOf(streams); + } + active.forEach(s -> s.cancel(status)); + } + + @Override + public void ping(PingCallback callback, Executor executor) { + executor.execute( + () -> + callback.onFailure( + Status.UNIMPLEMENTED.withDescription( + "Physical connection PING is not supported"))); + } + + @Override + public ListenableFuture getStats() { + // Restlet owns the pool; exposing fabricated per-socket counters would be misleading. + return Futures.immediateFuture(null); + } + + @Override + public InternalLogId getLogId() { + return id; + } + + @Override + public Attributes getAttributes() { + return Attributes.EMPTY; + } + } +} diff --git a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcClientAdapterTestCase.java b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcClientAdapterTestCase.java new file mode 100644 index 0000000000..eb15d91ec9 --- /dev/null +++ b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcClientAdapterTestCase.java @@ -0,0 +1,780 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.grpc; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.protobuf.DynamicMessage; +import com.google.protobuf.StringValue; +import io.grpc.CallOptions; +import io.grpc.ClientCall; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerServiceDefinition; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.health.v1.HealthCheckRequest; +import io.grpc.health.v1.HealthCheckResponse; +import io.grpc.health.v1.HealthGrpc; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.protobuf.ProtoUtils; +import io.grpc.protobuf.services.HealthStatusManager; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.ServerCalls; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.restlet.Client; +import org.restlet.Context; +import org.restlet.data.Protocol; + +class GrpcClientAdapterTestCase { + private static final MethodDescriptor ECHO = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("review.Unary/Echo") + .setRequestMarshaller(ProtoUtils.marshaller(StringValue.getDefaultInstance())) + .setResponseMarshaller(ProtoUtils.marshaller(StringValue.getDefaultInstance())) + .build(); + private static final Metadata.Key TEXT = + Metadata.Key.of("x-review", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key BINARY = + Metadata.Key.of("grpc-status-details-bin", Metadata.BINARY_BYTE_MARSHALLER); + private final GrpcClientAdapter adapter = new GrpcClientAdapter(); + private Client client; + private io.grpc.Server peer; + + private void start(ServerServiceDefinition service, ServerInterceptor... interceptors) + throws Exception { + start(service, "dns:///localhost", interceptors); + } + + private void start( + ServerServiceDefinition service, String targetHost, ServerInterceptor... interceptors) + throws Exception { + var builder = + NettyServerBuilder.forPort(0) + .addService(service) + .addService(new HealthStatusManager().getHealthService()); + for (var interceptor : interceptors) { + builder.intercept(interceptor); + } + peer = builder.build().start(); + Context context = new Context(); + context.getParameters().set("httpClientTransportMode", "HTTP2"); + context.getParameters().set("followRedirects", "false"); + client = new Client(context, Protocol.HTTP); + client.start(); + adapter.setClient(client); + adapter.setTarget(targetHost + ":" + peer.getPort()); + adapter.setPlaintext(true); + adapter.start(); + } + + private static ServerServiceDefinition echo() { + return ServerServiceDefinition.builder("review.Unary") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + if ("bad".equals(request.getValue())) { + Metadata trailers = new Metadata(); + trailers.put(TEXT, "one"); + trailers.put(TEXT, "two"); + trailers.put(BINARY, new byte[] {0, (byte) 255, 7}); + out.onError( + Status.INVALID_ARGUMENT + .withDescription( + "native % error: caf\u00e9") + .asRuntimeException(trailers)); + } else { + out.onNext(request); + out.onCompleted(); + } + })) + .build(); + } + + @AfterEach + void cleanup() throws Exception { + try { + adapter.stop(); + } finally { + try { + if (client != null) { + client.stop(); + } + } finally { + if (peer != null) { + peer.shutdownNow(); + assertTrue(peer.awaitTermination(30, TimeUnit.SECONDS)); + } + } + } + } + + private StringValue call(String value) { + return ClientCalls.blockingUnaryCall( + adapter.getChannel(), ECHO, CallOptions.DEFAULT, StringValue.of(value)); + } + + @Test + void generatedAndDynamicUnaryCallsUseOfficialSdk() throws Exception { + start(echo()); + assertEquals("hello", call("hello").getValue()); + var health = + HealthGrpc.newBlockingStub(adapter.getChannel()) + .check(HealthCheckRequest.getDefaultInstance()); + assertEquals(HealthCheckResponse.ServingStatus.SERVING, health.getStatus()); + var dynamic = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName(HealthGrpc.getCheckMethod().getFullMethodName()) + .setRequestMarshaller( + ProtoUtils.marshaller( + DynamicMessage.getDefaultInstance( + HealthCheckRequest.getDescriptor()))) + .setResponseMarshaller( + ProtoUtils.marshaller( + DynamicMessage.getDefaultInstance( + HealthCheckResponse.getDescriptor()))) + .build(); + DynamicMessage result = + ClientCalls.blockingUnaryCall( + adapter.getChannel(), + dynamic, + CallOptions.DEFAULT, + DynamicMessage.getDefaultInstance(HealthCheckRequest.getDescriptor())); + assertEquals( + health.getStatus().getNumber(), + ((com.google.protobuf.Descriptors.EnumValueDescriptor) + result.getField( + HealthCheckResponse.getDescriptor() + .findFieldByName("status"))) + .getNumber()); + assertTrue(adapter.getChannel().authority().startsWith("localhost:")); + adapter.stop(); + assertTrue(client.isStarted()); + assertFalse(adapter.isStarted()); + assertThrows(IllegalStateException.class, adapter::getChannel); + assertThrows(IllegalStateException.class, adapter::start); + } + + @Test + void preservesTrailersOnlyErrorAndRecovers() throws Exception { + start(echo()); + StatusRuntimeException error = + assertThrows(StatusRuntimeException.class, () -> call("bad")); + assertEquals(Status.Code.INVALID_ARGUMENT, error.getStatus().getCode()); + assertEquals("native % error: caf\u00e9", error.getStatus().getDescription()); + assertEquals( + List.of("one", "two"), + com.google.common.collect.ImmutableList.copyOf(error.getTrailers().getAll(TEXT))); + assertArrayEquals(new byte[] {0, (byte) 255, 7}, error.getTrailers().get(BINARY)); + assertEquals("recovered", call("recovered").getValue()); + } + + @Test + void passesMetadataAndAuthorityWithoutLosingResolvedPeer() throws Exception { + AtomicReference authority = new AtomicReference<>(); + AtomicReference observed = new AtomicReference<>(); + start( + echo(), + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + authority.set(call.getAuthority()); + observed.set(headers); + return next.startCall(call, headers); + } + }); + Metadata metadata = new Metadata(); + metadata.put(TEXT, "first"); + metadata.put(TEXT, "second"); + metadata.put(BINARY, new byte[] {1, 2, (byte) 255}); + metadata.put(BINARY, new byte[] {(byte) 254, 0}); + var channel = + io.grpc.ClientInterceptors.intercept( + adapter.getChannel(), + io.grpc.stub.MetadataUtils.newAttachHeadersInterceptor(metadata)); + assertEquals( + "meta", + ClientCalls.blockingUnaryCall( + channel, ECHO, CallOptions.DEFAULT, StringValue.of("meta")) + .getValue()); + assertEquals("localhost:" + peer.getPort(), authority.get()); + assertEquals( + List.of("first", "second"), + com.google.common.collect.ImmutableList.copyOf(observed.get().getAll(TEXT))); + var binary = com.google.common.collect.ImmutableList.copyOf(observed.get().getAll(BINARY)); + assertEquals(2, binary.size()); + assertArrayEquals(new byte[] {1, 2, (byte) 255}, binary.get(0)); + assertArrayEquals(new byte[] {(byte) 254, 0}, binary.get(1)); + } + + @Test + void bareIpTargetPreservesAuthorityAndAcceptsMatchingCallAuthority() throws Exception { + AtomicReference authority = new AtomicReference<>(); + start( + echo(), + "127.0.0.1", + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + authority.set(call.getAuthority()); + return next.startCall(call, headers); + } + }); + String expected = "127.0.0.1:" + peer.getPort(); + assertEquals(expected, adapter.getChannel().authority()); + assertEquals( + "ip", + ClientCalls.blockingUnaryCall( + adapter.getChannel(), + ECHO, + CallOptions.DEFAULT.withAuthority(expected), + StringValue.of("ip")) + .getValue()); + assertEquals(expected, authority.get()); + } + + @Test + void cancelledBeforeStartNeverReachesPeerAndHasOneTerminalCallback() throws Exception { + AtomicInteger requests = new AtomicInteger(); + start( + echo(), + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + requests.incrementAndGet(); + return next.startCall(call, headers); + } + }); + ClientCall call = + adapter.getChannel().newCall(ECHO, CallOptions.DEFAULT); + AtomicInteger terminals = new AtomicInteger(); + AtomicReference status = new AtomicReference<>(); + CountDownLatch closed = new CountDownLatch(1); + call.cancel("cancelled before start", null); + call.start( + new ClientCall.Listener<>() { + @Override + public void onClose(Status value, Metadata trailers) { + status.set(value); + terminals.incrementAndGet(); + closed.countDown(); + } + }, + new Metadata()); + assertTrue(closed.await(30, TimeUnit.SECONDS)); + assertEquals(Status.Code.CANCELLED, status.get().getCode()); + assertEquals(0, requests.get()); + assertEquals("next", call("next").getValue()); + assertEquals(1, requests.get()); + assertEquals(1, terminals.get()); + } + + @Test + void cancellationAfterHeadersReleasesAdmissionAndClosesExactlyOnce() throws Exception { + adapter.setMaxConcurrentCalls(1); + CountDownLatch cancelled = new CountDownLatch(1); + start( + ServerServiceDefinition.builder("review.Unary") + .addMethod( + // Flush one response without completing the client's unary call. + ECHO.toBuilder() + .setType(MethodDescriptor.MethodType.SERVER_STREAMING) + .build(), + ServerCalls.asyncServerStreamingCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + if ("block".equals(request.getValue())) { + ((io.grpc.stub.ServerCallStreamObserver< + StringValue>) + out) + .setOnCancelHandler(cancelled::countDown); + out.onNext(StringValue.of("pending")); + } else { + out.onNext(request); + out.onCompleted(); + } + })) + .build()); + CountDownLatch headers = new CountDownLatch(1); + CountDownLatch closed = new CountDownLatch(1); + AtomicInteger terminals = new AtomicInteger(); + AtomicInteger messages = new AtomicInteger(); + AtomicReference status = new AtomicReference<>(); + ClientCall call = + adapter.getChannel().newCall(ECHO, CallOptions.DEFAULT); + call.start( + new ClientCall.Listener<>() { + @Override + public void onHeaders(Metadata value) { + headers.countDown(); + } + + @Override + public void onMessage(StringValue value) { + messages.incrementAndGet(); + } + + @Override + public void onClose(Status value, Metadata trailers) { + status.set(value); + terminals.incrementAndGet(); + closed.countDown(); + } + }, + new Metadata()); + call.request(1); + call.sendMessage(StringValue.of("block")); + call.halfClose(); + assertTrue(headers.await(30, TimeUnit.SECONDS)); + call.cancel("cancel after headers", null); + call.cancel("duplicate", null); + assertTrue(closed.await(30, TimeUnit.SECONDS)); + assertTrue(cancelled.await(30, TimeUnit.SECONDS)); + assertEquals(Status.Code.CANCELLED, status.get().getCode()); + assertEquals("recovered", call("recovered").getValue()); + assertEquals(1, terminals.get()); + assertEquals(0, messages.get()); + } + + private record Pending(CountDownLatch entered, CountDownLatch cancelled) {} + + private Pending startBlocked() throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch cancelled = new CountDownLatch(1); + var service = + ServerServiceDefinition.builder("review.Unary") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + if ("block".equals(request.getValue())) { + ((io.grpc.stub.ServerCallStreamObserver< + StringValue>) + out) + .setOnCancelHandler(cancelled::countDown); + entered.countDown(); + } else { + out.onNext(request); + out.onCompleted(); + } + })) + .build(); + start(service); + return new Pending(entered, cancelled); + } + + @Test + void cancellationBeforeHeadersHasOneTerminalCallbackAndRecovery() throws Exception { + Pending pending = startBlocked(); + AtomicInteger terminals = new AtomicInteger(); + CountDownLatch closed = new CountDownLatch(1); + AtomicReference status = new AtomicReference<>(); + ClientCall call = + adapter.getChannel().newCall(ECHO, CallOptions.DEFAULT); + call.start( + new ClientCall.Listener<>() { + @Override + public void onClose(Status value, Metadata trailers) { + status.set(value); + terminals.incrementAndGet(); + closed.countDown(); + } + }, + new Metadata()); + call.request(1); + call.sendMessage(StringValue.of("block")); + call.halfClose(); + assertTrue(pending.entered().await(30, TimeUnit.SECONDS)); + call.cancel("review cancellation", null); + call.cancel("duplicate cancellation", null); + assertTrue(closed.await(30, TimeUnit.SECONDS)); + assertTrue(pending.cancelled().await(30, TimeUnit.SECONDS)); + assertEquals(Status.Code.CANCELLED, status.get().getCode()); + assertEquals("next", call("next").getValue()); + assertEquals(1, terminals.get()); + } + + @Test + void deadlineAndConcurrentAdmissionAreNativeFailures() throws Exception { + adapter.setMaxConcurrentCalls(1); + Pending pending = startBlocked(); + var first = + ClientCalls.futureUnaryCall( + adapter.getChannel() + .newCall( + ECHO, + CallOptions.DEFAULT.withDeadlineAfter(3, TimeUnit.SECONDS)), + StringValue.of("block")); + assertTrue(pending.entered().await(30, TimeUnit.SECONDS)); + assertEquals( + Status.Code.RESOURCE_EXHAUSTED, + assertThrows(StatusRuntimeException.class, () -> call("second")) + .getStatus() + .getCode()); + var error = + assertThrows( + java.util.concurrent.ExecutionException.class, + () -> first.get(30, TimeUnit.SECONDS)); + assertEquals(Status.Code.DEADLINE_EXCEEDED, Status.fromThrowable(error).getCode()); + assertTrue(pending.cancelled().await(30, TimeUnit.SECONDS)); + assertEquals("recovered", call("recovered").getValue()); + } + + @Test + void stopCancelsActiveCallsAndLeavesBorrowedConnectorRunning() throws Exception { + adapter.setShutdownTimeout(Duration.ofMillis(100)); + Pending pending = startBlocked(); + var first = + ClientCalls.futureUnaryCall( + adapter.getChannel().newCall(ECHO, CallOptions.DEFAULT), + StringValue.of("block")); + assertTrue(pending.entered().await(30, TimeUnit.SECONDS)); + adapter.stop(); + assertThrows( + java.util.concurrent.ExecutionException.class, + () -> first.get(30, TimeUnit.SECONDS)); + assertTrue(pending.cancelled().await(30, TimeUnit.SECONDS)); + assertTrue(client.isStarted()); + } + + @Test + void interruptedStopStillClosesOwnedChannelAndPreservesBorrowedResources() throws Exception { + Pending pending = startBlocked(); + var channel = adapter.getChannel(); + var first = + ClientCalls.futureUnaryCall( + channel.newCall(ECHO, CallOptions.DEFAULT), StringValue.of("block")); + assertTrue(pending.entered().await(30, TimeUnit.SECONDS)); + try { + Thread.currentThread().interrupt(); + assertThrows(IllegalStateException.class, adapter::stop); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + assertTrue(channel.awaitTermination(30, TimeUnit.SECONDS)); + assertThrows( + java.util.concurrent.ExecutionException.class, + () -> first.get(30, TimeUnit.SECONDS)); + assertTrue(pending.cancelled().await(30, TimeUnit.SECONDS)); + assertTrue(client.isStarted()); + adapter.stop(); + } + + @Test + void rejectsStreamingAndOutboundLimitBeforeSending() throws Exception { + adapter.setMaxMessageBytes(128); + start(echo()); + assertEquals( + Status.Code.RESOURCE_EXHAUSTED, + assertThrows(StatusRuntimeException.class, () -> call("x".repeat(129))) + .getStatus() + .getCode()); + assertEquals("small", call("small").getValue()); + var streamMethod = + ECHO.toBuilder().setType(MethodDescriptor.MethodType.SERVER_STREAMING).build(); + assertEquals( + Status.Code.UNIMPLEMENTED, + assertThrows( + StatusRuntimeException.class, + () -> + ClientCalls.blockingServerStreamingCall( + adapter.getChannel(), + streamMethod, + CallOptions.DEFAULT, + StringValue.of("x")) + .next()) + .getStatus() + .getCode()); + } + + @Test + void honorsExactMessageLimitAndStricterCallOptions() throws Exception { + adapter.setMaxMessageBytes(128); + start(echo()); + StringValue boundary = StringValue.of("x".repeat(126)); + StringValue oversized = StringValue.of("x".repeat(127)); + assertEquals(128, boundary.getSerializedSize()); + assertEquals(129, oversized.getSerializedSize()); + for (int limit : List.of(128, 4096)) { + CallOptions options = + CallOptions.DEFAULT + .withMaxInboundMessageSize(limit) + .withMaxOutboundMessageSize(limit); + assertEquals( + boundary, + ClientCalls.blockingUnaryCall(adapter.getChannel(), ECHO, options, boundary)); + assertEquals( + Status.Code.RESOURCE_EXHAUSTED, + assertThrows( + StatusRuntimeException.class, + () -> + ClientCalls.blockingUnaryCall( + adapter.getChannel(), ECHO, options, oversized)) + .getStatus() + .getCode()); + } + for (CallOptions options : + List.of( + CallOptions.DEFAULT.withMaxInboundMessageSize(64), + CallOptions.DEFAULT.withMaxOutboundMessageSize(64))) { + assertEquals( + Status.Code.RESOURCE_EXHAUSTED, + assertThrows( + StatusRuntimeException.class, + () -> + ClientCalls.blockingUnaryCall( + adapter.getChannel(), ECHO, options, boundary)) + .getStatus() + .getCode()); + assertEquals("recovered", call("recovered").getValue()); + } + } + + @Test + void boundsInboundResponseAndDoesNotAllowCallOptionsToRaiseLimits() throws Exception { + adapter.setMaxMessageBytes(128); + var service = + ServerServiceDefinition.builder("review.Unary") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + out.onNext(StringValue.of("x".repeat(256))); + out.onCompleted(); + })) + .build(); + start(service); + assertEquals( + Status.Code.RESOURCE_EXHAUSTED, + assertThrows( + StatusRuntimeException.class, + () -> + ClientCalls.blockingUnaryCall( + adapter.getChannel(), + ECHO, + CallOptions.DEFAULT.withMaxInboundMessageSize(4096), + StringValue.of("large"))) + .getStatus() + .getCode()); + assertEquals( + Status.Code.RESOURCE_EXHAUSTED, + assertThrows( + StatusRuntimeException.class, + () -> + ClientCalls.blockingUnaryCall( + adapter.getChannel(), + ECHO, + CallOptions.DEFAULT.withMaxOutboundMessageSize( + 4096), + StringValue.of("x".repeat(256)))) + .getStatus() + .getCode()); + } + + @Test + void enforcesDefaultCallDurationAndCallerMayShortenIt() throws Exception { + adapter.setMaxCallDuration(Duration.ofSeconds(3)); + Pending pending = startBlocked(); + var failure = assertThrows(StatusRuntimeException.class, () -> call("block")); + assertEquals(Status.Code.DEADLINE_EXCEEDED, failure.getStatus().getCode()); + assertTrue(pending.cancelled().await(30, TimeUnit.SECONDS)); + assertEquals("next", call("next").getValue()); + } + + @Test + void interceptorCannotExtendAdapterDuration() throws Exception { + adapter.setMaxCallDuration(Duration.ofSeconds(3)); + adapter.getInterceptors() + .add( + new io.grpc.ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, + CallOptions options, + io.grpc.Channel next) { + return next.newCall( + method, options.withDeadlineAfter(1, TimeUnit.HOURS)); + } + }); + Pending pending = startBlocked(); + var failure = assertThrows(StatusRuntimeException.class, () -> call("block")); + assertEquals(Status.Code.DEADLINE_EXCEEDED, failure.getStatus().getCode()); + assertTrue(pending.cancelled().await(30, TimeUnit.SECONDS)); + } + + @Test + void callerDeadlineCanShortenAdapterDuration() throws Exception { + adapter.setMaxCallDuration(Duration.ofSeconds(20)); + Pending pending = startBlocked(); + var first = + ClientCalls.futureUnaryCall( + adapter.getChannel() + .newCall( + ECHO, + CallOptions.DEFAULT.withDeadlineAfter(3, TimeUnit.SECONDS)), + StringValue.of("block")); + assertTrue(pending.entered().await(30, TimeUnit.SECONDS)); + var error = + assertThrows( + java.util.concurrent.ExecutionException.class, + () -> first.get(10, TimeUnit.SECONDS)); + assertEquals(Status.Code.DEADLINE_EXCEEDED, Status.fromThrowable(error).getCode()); + assertTrue(pending.cancelled().await(30, TimeUnit.SECONDS)); + assertEquals("next", call("next").getValue()); + } + + @Test + void rejectsCompressionAndAuthorityOverridesExplicitly() throws Exception { + start(echo()); + for (CallOptions options : + List.of( + CallOptions.DEFAULT.withCompression("gzip"), + CallOptions.DEFAULT.withAuthority("different.example"))) { + assertEquals( + Status.Code.UNIMPLEMENTED, + assertThrows( + StatusRuntimeException.class, + () -> + ClientCalls.blockingUnaryCall( + adapter.getChannel(), + ECHO, + options, + StringValue.of("x"))) + .getStatus() + .getCode()); + } + assertEquals("next", call("next").getValue()); + } + + @Test + void remoteShutdownTerminatesCallWithoutStoppingBorrowedConnector() throws Exception { + adapter.setMaxConcurrentCalls(1); + Pending pending = startBlocked(); + int port = peer.getPort(); + var call = + ClientCalls.futureUnaryCall( + adapter.getChannel().newCall(ECHO, CallOptions.DEFAULT), + StringValue.of("block")); + assertTrue(pending.entered().await(30, TimeUnit.SECONDS)); + peer.shutdownNow(); + var error = + assertThrows( + java.util.concurrent.ExecutionException.class, + () -> call.get(30, TimeUnit.SECONDS)); + assertEquals(Status.Code.UNAVAILABLE, Status.fromThrowable(error).getCode()); + assertTrue(client.isStarted()); + assertTrue(peer.awaitTermination(30, TimeUnit.SECONDS)); + peer = NettyServerBuilder.forPort(port).addService(echo()).build().start(); + assertEquals("reconnected", call("reconnected").getValue()); + } + + @Test + void stoppingOneAdapterLeavesSharedConnectorAndOtherChannelUsable() throws Exception { + start(echo()); + try (var other = new GrpcClientAdapter()) { + other.setClient(client); + other.setTarget(adapter.getTarget()); + other.setPlaintext(true); + other.start(); + assertEquals("first", call("first").getValue()); + adapter.stop(); + adapter.stop(); + assertTrue(client.isStarted()); + assertEquals( + "second", + ClientCalls.blockingUnaryCall( + other.getChannel(), + ECHO, + CallOptions.DEFAULT, + StringValue.of("second")) + .getValue()); + } + assertTrue(client.isStarted()); + } + + @Test + void nativeInterceptorAndBorrowedExecutorArePreserved() throws Exception { + try (var callbacks = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) { + adapter.setExecutor(callbacks); + AtomicInteger intercepted = new AtomicInteger(); + adapter.getInterceptors() + .add( + new io.grpc.ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, + CallOptions options, + io.grpc.Channel next) { + intercepted.incrementAndGet(); + return next.newCall(method, options); + } + }); + start(echo()); + adapter.start(); + assertEquals("one", call("one").getValue()); + adapter.getInterceptors().clear(); + assertEquals("two", call("two").getValue()); + assertEquals(2, intercepted.get()); + assertThrows(IllegalStateException.class, () -> adapter.setMaxMessageBytes(256)); + adapter.stop(); + assertFalse(callbacks.isShutdown()); + assertSame(client, adapter.getClient()); + } + } + + @Test + void validatesConfigurationAndRequiresExplicitPlaintext() { + assertFalse(adapter.isStarted()); + assertThrows(IllegalStateException.class, adapter::getChannel); + assertThrows(UnsupportedOperationException.class, adapter::start); + assertThrows(IllegalArgumentException.class, () -> adapter.setMaxMessageBytes(0)); + assertThrows(IllegalArgumentException.class, () -> adapter.setMaxConcurrentCalls(0)); + assertThrows( + IllegalArgumentException.class, () -> adapter.setMaxCallDuration(Duration.ZERO)); + assertThrows( + IllegalArgumentException.class, () -> adapter.setShutdownTimeout(Duration.ZERO)); + adapter.setPlaintext(true); + assertThrows(NullPointerException.class, adapter::start); + adapter.setTarget("dns:///localhost:8080"); + assertThrows(IllegalStateException.class, adapter::start); + assertNotNull(adapter.getContext()); + assertEquals(128, adapter.getMaxConcurrentCalls()); + assertEquals(1024 * 1024, adapter.getMaxMessageBytes()); + assertEquals(Duration.ofSeconds(30), adapter.getMaxCallDuration()); + assertEquals(Duration.ofSeconds(5), adapter.getShutdownTimeout()); + assertEquals("dns:///localhost:8080", adapter.getTarget()); + assertTrue(adapter.isPlaintext()); + } +} diff --git a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcHttpValidationTestCase.java b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcHttpValidationTestCase.java new file mode 100644 index 0000000000..8d1f1d7f96 --- /dev/null +++ b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcHttpValidationTestCase.java @@ -0,0 +1,319 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.protobuf.StringValue; +import io.grpc.CallOptions; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.protobuf.ProtoUtils; +import io.grpc.stub.ClientCalls; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.restlet.Client; +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.Server; +import org.restlet.data.MediaType; +import org.restlet.data.Protocol; +import org.restlet.representation.ByteArrayRepresentation; +import org.restlet.representation.OutputRepresentation; + +class GrpcHttpValidationTestCase { + private final GrpcClientAdapter adapter = new GrpcClientAdapter(); + private Server peer; + private Client client; + private static final MethodDescriptor METHOD = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("review.Invalid/Call") + .setRequestMarshaller(ProtoUtils.marshaller(StringValue.getDefaultInstance())) + .setResponseMarshaller(ProtoUtils.marshaller(StringValue.getDefaultInstance())) + .build(); + + private void start(Restlet handler) throws Exception { + Context serverContext = new Context(); + serverContext.getParameters().set("http.transport.protocol", "HTTP2"); + peer = new Server(serverContext, Protocol.HTTP, 0, handler); + peer.start(); + Context clientContext = new Context(); + clientContext.getParameters().set("httpClientTransportMode", "HTTP2"); + clientContext.getParameters().set("followRedirects", "false"); + client = new Client(clientContext, Protocol.HTTP); + client.start(); + adapter.setPlaintext(true); + adapter.setClient(client); + adapter.setTarget("localhost:" + peer.getActualPort()); + adapter.start(); + } + + private Status failure() { + return assertThrows( + StatusRuntimeException.class, + () -> + ClientCalls.blockingUnaryCall( + adapter.getChannel(), + METHOD, + CallOptions.DEFAULT, + StringValue.of("request"))) + .getStatus(); + } + + private static byte[] frame(String value) { + byte[] message = StringValue.of(value).toByteArray(); + return ByteBuffer.allocate(5 + message.length) + .put((byte) 0) + .putInt(message.length) + .put(message) + .array(); + } + + private void startMalformedThenHealthy(Consumer malformed) throws Exception { + adapter.setMaxConcurrentCalls(1); + AtomicInteger requests = new AtomicInteger(); + start( + new Restlet() { + @Override + public void handle(Request request, Response response) { + if (requests.incrementAndGet() == 1) { + malformed.accept(response); + } else { + response.setEntity( + new ByteArrayRepresentation( + frame("recovered"), + MediaType.valueOf("application/grpc"))); + response.getTrailers().add("grpc-status", "0"); + } + } + }); + } + + private void assertRecovered() { + assertEquals( + "recovered", + ClientCalls.blockingUnaryCall( + adapter.getChannel(), + METHOD, + CallOptions.DEFAULT, + StringValue.of("next")) + .getValue()); + assertTrue(client.isStarted()); + } + + @AfterEach + void cleanup() throws Exception { + try { + adapter.stop(); + } finally { + try { + if (client != null) { + client.stop(); + } + } finally { + if (peer != null) { + peer.stop(); + } + } + } + } + + @Test + void sdkMapsHttpErrorsAndNoPostIsReplayed() throws Exception { + AtomicInteger requests = new AtomicInteger(); + start( + new Restlet() { + @Override + public void handle(Request request, Response response) { + requests.incrementAndGet(); + response.setStatus(org.restlet.data.Status.CLIENT_ERROR_UNAUTHORIZED); + response.setEntity("denied", MediaType.TEXT_PLAIN); + } + }); + assertEquals(Status.Code.UNAUTHENTICATED, failure().getCode()); + assertEquals(1, requests.get()); + } + + @Test + void missingGrpcStatusIsNotSuccess() throws Exception { + startMalformedThenHealthy( + response -> response.setEntity("", MediaType.valueOf("application/grpc"))); + Status status = failure(); + assertEquals(Status.Code.UNKNOWN, status.getCode(), status::toString); + assertTrue(status.getDescription().contains("missing GRPC status")); + assertRecovered(); + } + + @Test + void sdkRejectsTruncatedResponseFrame() throws Exception { + startMalformedThenHealthy( + response -> { + response.setEntity( + new ByteArrayRepresentation( + new byte[] {0, 0, 0, 0, 10, 1}, + MediaType.valueOf("application/grpc"))); + response.getTrailers().add("grpc-status", "0"); + }); + assertEquals(Status.Code.INTERNAL, failure().getCode()); + assertRecovered(); + } + + @Test + void malformedBinaryMetadataFailsExplicitly() throws Exception { + startMalformedThenHealthy( + response -> { + response.setEntity("", MediaType.valueOf("application/grpc")); + response.getHeaders().add("x-invalid-bin", "!not base64!"); + response.getTrailers().add("grpc-status", "0"); + }); + assertEquals(Status.Code.UNAVAILABLE, failure().getCode()); + assertRecovered(); + } + + @Test + void wrongContentTypeFailsAndRecovers() throws Exception { + startMalformedThenHealthy(response -> response.setEntity("not grpc", MediaType.TEXT_PLAIN)); + assertEquals(Status.Code.UNKNOWN, failure().getCode()); + assertRecovered(); + } + + @Test + void invalidFrameFlagsFailAndRecover() throws Exception { + startMalformedThenHealthy( + response -> { + response.setEntity( + new ByteArrayRepresentation( + new byte[] {2, 0, 0, 0, 0}, + MediaType.valueOf("application/grpc"))); + response.getTrailers().add("grpc-status", "0"); + }); + assertEquals(Status.Code.INTERNAL, failure().getCode()); + assertRecovered(); + } + + @Test + void multipleUnaryResponseMessagesFailAndRecover() throws Exception { + startMalformedThenHealthy( + response -> { + byte[] first = frame("one"); + byte[] second = frame("two"); + response.setEntity( + new ByteArrayRepresentation( + ByteBuffer.allocate(first.length + second.length) + .put(first) + .put(second) + .array(), + MediaType.valueOf("application/grpc"))); + response.getTrailers().add("grpc-status", "0"); + }); + Status status = failure(); + assertEquals(Status.Code.CANCELLED, status.getCode(), status::toString); + assertEquals(Status.Code.INTERNAL, Status.fromThrowable(status.getCause()).getCode()); + assertTrue( + Status.fromThrowable(status.getCause()) + .getDescription() + .contains("More than one value")); + assertRecovered(); + } + + @Test + void successfulStatusWithoutUnaryMessageFailsAndRecovers() throws Exception { + startMalformedThenHealthy( + response -> { + response.setEntity("", MediaType.valueOf("application/grpc")); + response.getTrailers().add("grpc-status", "0"); + }); + assertEquals(Status.Code.INTERNAL, failure().getCode()); + assertRecovered(); + } + + @Test + void malformedStatusFailsAndRecovers() throws Exception { + startMalformedThenHealthy( + response -> { + response.setEntity("", MediaType.valueOf("application/grpc")); + response.getTrailers().add("grpc-status", "not-a-code"); + }); + assertEquals(Status.Code.UNKNOWN, failure().getCode()); + assertRecovered(); + } + + @Test + void oversizedDeclaredMessageLengthFailsBeforePayloadAndRecovers() throws Exception { + adapter.setMaxMessageBytes(128); + startMalformedThenHealthy( + response -> { + response.setEntity( + new ByteArrayRepresentation( + ByteBuffer.allocate(5).put((byte) 0).putInt(129).array(), + MediaType.valueOf("application/grpc"))); + response.getTrailers().add("grpc-status", "0"); + }); + assertEquals(Status.Code.RESOURCE_EXHAUSTED, failure().getCode()); + assertRecovered(); + } + + @Test + void inboundWireBudgetRejectsOversizeResponseAndRecovers() throws Exception { + adapter.setMaxMessageBytes(128); + startMalformedThenHealthy( + response -> { + response.setEntity( + new ByteArrayRepresentation( + frame("x".repeat(127)), MediaType.valueOf("application/grpc"))); + response.getTrailers().add("grpc-status", "0"); + }); + assertEquals(Status.Code.RESOURCE_EXHAUSTED, failure().getCode()); + assertRecovered(); + } + + @Test + void unsupportedEncodingAbortsAnUnfinishedResponseAndRecovers() throws Exception { + CountDownLatch aborted = new CountDownLatch(1); + startMalformedThenHealthy( + response -> { + response.getHeaders().add("grpc-encoding", "unsupported-encoding"); + response.setEntity( + new OutputRepresentation(MediaType.valueOf("application/grpc")) { + @Override + public void write(OutputStream output) throws IOException { + try { + for (int i = 0; i < 100; i++) { + output.write(0); + output.flush(); + Thread.sleep(100); + } + } catch (IOException e) { + aborted.countDown(); + throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } + } + }); + }); + assertEquals(Status.Code.INTERNAL, failure().getCode()); + assertTrue(aborted.await(5, TimeUnit.SECONDS), "The HTTP exchange must be aborted"); + assertRecovered(); + } +} diff --git a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcModuleTestCase.java b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcModuleTestCase.java new file mode 100644 index 0000000000..1133978ae6 --- /dev/null +++ b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcModuleTestCase.java @@ -0,0 +1,142 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.restlet.Client; + +class GrpcModuleTestCase { + @Test + void nativeChannelResolvesWithoutSdkNetworkTransportOrInternalExports(@TempDir Path temp) + throws Exception { + String classPath = + System.getProperty( + "surefire.test.class.path", System.getProperty("java.class.path")); + var paths = + new ArrayList<>( + Arrays.stream(classPath.split(File.pathSeparator)) + .map(Path::of) + .filter(Files::isRegularFile) + .filter(path -> path.toString().endsWith(".jar")) + .filter( + path -> + !path.getFileName() + .toString() + .startsWith("grpc-netty")) + .toList()); + paths.add( + Path.of( + System.getProperty( + "restlet.consumer.core", + Path.of( + Client.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI()) + .toString()))); + paths.add( + Path.of( + System.getProperty( + "restlet.consumer.grpc", + Path.of( + GrpcClientAdapter.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI()) + .toString()))); + String modulePath = + paths.stream() + .distinct() + .map(Path::toString) + .collect(Collectors.joining(File.pathSeparator)); + Path descriptor = temp.resolve("module-info.java"); + Path example = temp.resolve("Main.java"); + Files.writeString(descriptor, "module r3.consumer { requires org.restlet.ext.grpc; }"); + Files.writeString( + example, + """ + package example; + import org.restlet.Client; + import org.restlet.Context; + import org.restlet.data.Protocol; + import org.restlet.ext.grpc.GrpcClientAdapter; + public final class Main { + public static void main(String[] args) throws Exception { + try { + Class.forName("io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder"); + throw new AssertionError("SDK network transport is present"); + } catch (ClassNotFoundException expected) { } + var context = new Context(); + context.getParameters().set("httpClientTransportMode", "HTTP2"); + context.getParameters().set("followRedirects", "false"); + var client = new Client(context, Protocol.HTTP); + client.start(); + try (var adapter = new GrpcClientAdapter()) { + adapter.setClient(client); + adapter.setPlaintext(true); + adapter.setTarget("dns:///localhost:12345"); + adapter.start(); + if (!adapter.getChannel().authority().equals("localhost:12345")) { + throw new AssertionError("Native authority was lost"); + } + } finally { client.stop(); } + } + } + """); + Path classes = temp.resolve("classes"); + var diagnostics = new java.io.ByteArrayOutputStream(); + assertEquals( + 0, + javax.tools.ToolProvider.getSystemJavaCompiler() + .run( + null, + diagnostics, + diagnostics, + "--module-path", + modulePath, + "-d", + classes.toString(), + descriptor.toString(), + example.toString()), + diagnostics.toString()); + Path output = temp.resolve("consumer.log"); + Process child = + new ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "--module-path", + modulePath + File.pathSeparator + classes, + "--module", + "r3.consumer/example.Main") + .redirectErrorStream(true) + .redirectOutput(output.toFile()) + .start(); + try { + assertTrue(child.waitFor(30, TimeUnit.SECONDS), "Named consumer did not terminate"); + assertEquals(0, child.exitValue(), Files.readString(output)); + } finally { + if (child.isAlive()) { + child.destroyForcibly(); + assertTrue(child.waitFor(10, TimeUnit.SECONDS)); + } + } + } +} diff --git a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/internal/RestletClientStreamTestCase.java b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/internal/RestletClientStreamTestCase.java new file mode 100644 index 0000000000..41bfd05f82 --- /dev/null +++ b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/internal/RestletClientStreamTestCase.java @@ -0,0 +1,116 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.grpc.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import io.grpc.CallOptions; +import io.grpc.ClientStreamTracer; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import io.grpc.internal.ClientStreamListener; +import io.grpc.internal.StreamListener.MessageProducer; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.restlet.Client; +import org.restlet.data.Protocol; + +class RestletClientStreamTestCase { + @Test + void cancelledTimersDoNotStayQueued() { + var factory = new RestletTransportFactory(null, 1024, 1, java.time.Duration.ofSeconds(1)); + try { + var timer = (ScheduledThreadPoolExecutor) factory.getScheduledExecutorService(); + for (int i = 0; i < 100; i++) { + timer.schedule(() -> {}, 1, TimeUnit.HOURS).cancel(false); + } + assertEquals(0, timer.getQueue().size()); + } finally { + factory.close(); + } + } + + @Test + void shutdownBeforeListenerInstallationIsReplayedOnceAndReleasesAdmission() { + var marshaller = + new MethodDescriptor.Marshaller() { + @Override + public InputStream stream(byte[] value) { + return new ByteArrayInputStream(value); + } + + @Override + public byte[] parse(InputStream input) { + throw new UnsupportedOperationException("No response is expected"); + } + }; + var method = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("review.Unary/Echo") + .setRequestMarshaller(marshaller) + .setResponseMarshaller(marshaller) + .build(); + AtomicInteger released = new AtomicInteger(); + AtomicInteger terminals = new AtomicInteger(); + AtomicReference status = new AtomicReference<>(); + try (var io = Executors.newVirtualThreadPerTaskExecutor()) { + var stream = + new RestletClientStream( + new Client(Protocol.HTTP), + new InetSocketAddress(InetAddress.getLoopbackAddress(), 8080), + "localhost:8080", + method, + new Metadata(), + CallOptions.DEFAULT, + new ClientStreamTracer[0], + 128, + io, + ignored -> released.incrementAndGet()); + Status shutdown = Status.UNAVAILABLE.withDescription("shutdown before start"); + stream.cancel(shutdown); + stream.cancel(Status.CANCELLED); + assertEquals(0, terminals.get()); + assertEquals(0, released.get()); + new GuardedClientStream(stream, 128) + .start( + new ClientStreamListener() { + @Override + public void headersRead(Metadata headers) {} + + @Override + public void closed( + Status value, RpcProgress progress, Metadata trailers) { + status.set(value); + terminals.incrementAndGet(); + } + + @Override + public void messagesAvailable(MessageProducer producer) {} + + @Override + public void onReady() {} + }); + stream.cancel(Status.CANCELLED); + assertSame(shutdown, status.get()); + assertEquals(1, terminals.get()); + assertEquals(1, released.get()); + } + } +} diff --git a/pom.xml b/pom.xml index 8170543aba..eb7f2c4122 100644 --- a/pom.xml +++ b/pom.xml @@ -53,6 +53,7 @@ 2.3.35 2.14.0 + 1.85.1 2.22.3 3.2.3 2.22 @@ -77,6 +78,7 @@ org.restlet.ext.crypto org.restlet.ext.freemarker org.restlet.ext.gson + org.restlet.ext.grpc org.restlet.ext.jaas org.restlet.ext.jackson org.restlet.ext.json