From f40cd746a781472003bf4b326d4ec3091867ceec Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:07:45 -0400 Subject: [PATCH 1/8] Add SDK-native gRPC server hosting on a borrowed Restlet listener Add GrpcServerAdapter, a Restlet hosting a socketless grpc-java server on a borrowed Restlet/Netty HTTP/2 listener, so that gRPC services can share a port with REST routes. grpc-java owns service dispatch, framing and status semantics; Restlet owns HTTP I/O and finite message, queue, concurrency and call-duration budgets. Unary and server-streaming calls are supported. Configuration uses constructors, getter and void setter pairs, service and interceptor collections, and start/stop, not fluent builders. Map HTTP/2 requests with an absolute or empty URI to the request path, and report the negotiated protocol version, so that gRPC service routes match. Require the Guava failureaccess module for module-path consumers. Client-streaming, bidirectional streaming, TLS configuration and physical connection controls are not supported by the server adapter. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- changes.md | 9 + org.restlet.ext.grpc/pom.xml | 7 +- .../src/main/java/module-info.java | 2 + .../restlet/ext/grpc/GrpcServerAdapter.java | 253 ++++++ .../ext/grpc/internal/RestletServerHost.java | 535 ++++++++++++ .../restlet/ext/grpc/GrpcModuleTestCase.java | 89 +- .../ext/grpc/GrpcServerAdapterTestCase.java | 795 ++++++++++++++++++ .../restlet/engine/netty/NettyRequest.java | 30 +- .../restlet/engine/netty/NettyServerCall.java | 15 + .../netty/NettyRequestMappingTestCase.java | 87 ++ 10 files changed, 1812 insertions(+), 10 deletions(-) create mode 100644 org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java create mode 100644 org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java create mode 100644 org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java create mode 100644 org.restlet/src/test/java/org/restlet/engine/netty/NettyRequestMappingTestCase.java diff --git a/changes.md b/changes.md index 9911f0ced7..358c09c631 100644 --- a/changes.md +++ b/changes.md @@ -95,6 +95,15 @@ Changes log bounded messages/calls, one-shot SSE responses, and request cancellation. Sessions, listening GET streams, replay and Ikanos revision compatibility are not supported. Borrowed HTTP connectors are not stopped by the MCP client. + - Added experimental SDK-native gRPC unary and bounded server-streaming hosting on a + borrowed Restlet/Netty HTTP/2 listener. Generated and dynamic services can share the + listener with REST routes. grpc-java owns service dispatch, framing and status semantics; + Restlet owns HTTP I/O and finite message, queue, concurrency and call-duration budgets. + Cancellation and response completion terminate each logical transport exactly once; + admission remains held until response transmission or cancellation. Adapter shutdown + leaves the borrowed listener and supplied callback executor running and reports timeout + explicitly. Client-streaming, bidirectional streaming, TLS configuration and physical + gRPC connection controls are not supported by the server adapter. - 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, diff --git a/org.restlet.ext.grpc/pom.xml b/org.restlet.ext.grpc/pom.xml index 8e4f53f1f1..61a62faf25 100644 --- a/org.restlet.ext.grpc/pom.xml +++ b/org.restlet.ext.grpc/pom.xml @@ -9,9 +9,14 @@ org.restlet.ext.grpc Restlet Framework - gRPC extension - Native gRPC unary client on a borrowed Restlet HTTP/2 connector. + Native gRPC unary client and unary/server-streaming hosting on borrowed Restlet HTTP/2 connectors. + + com.google.guava + failureaccess + 1.0.3 + com.google.guava guava diff --git a/org.restlet.ext.grpc/src/main/java/module-info.java b/org.restlet.ext.grpc/src/main/java/module-info.java index e5da89d47b..c3092716f8 100644 --- a/org.restlet.ext.grpc/src/main/java/module-info.java +++ b/org.restlet.ext.grpc/src/main/java/module-info.java @@ -12,6 +12,8 @@ requires transitive io.grpc; requires io.grpc.internal; requires com.google.common; + // Guava is automatic and does not resolve this explicit module itself. + requires com.google.common.util.concurrent.internal; exports org.restlet.ext.grpc; } diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java new file mode 100644 index 0000000000..167681ed78 --- /dev/null +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java @@ -0,0 +1,253 @@ +/** + * 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.ServerInterceptor; +import io.grpc.ServerServiceDefinition; +import io.grpc.internal.ServerImplBuilder; +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.ext.grpc.internal.RestletServerHost; +import org.restlet.routing.Router; +import org.restlet.routing.Template; + +/** + * Socketless grpc-java server on a borrowed Restlet HTTP/2 listener. + * + *

Supports unary requests and server-streamed responses, with finite message, queue, concurrency + * and duration budgets. Does not implement TLS or physical connection controls. The host + * application owns the HTTP listener and any supplied callback executor. + * + *

Register services before {@link #start()}, attach them to a router with {@link #attachTo}, and + * configure the host listener for plaintext HTTP/2. This adapter is single-use. Stopping it cancels + * active calls, waits up to the shutdown timeout for SDK and owned callback termination, and shuts + * down only its own executor and timer, not the listener or an executor supplied with {@link + * #setExecutor}. + */ +public final class GrpcServerAdapter extends Restlet { + private final List services = new CopyOnWriteArrayList<>(); + private final List interceptors = new CopyOnWriteArrayList<>(); + private int maxMessageBytes = 1024 * 1024; + private int maxQueuedBytes = 256 * 1024; + private int maxConcurrentCalls = 128; + private Duration maxCallDuration = Duration.ofSeconds(5); + private Duration shutdownTimeout = Duration.ofSeconds(5); + private Executor executor; + private ExecutorService ownedExecutor; + private io.grpc.Server sdk; + private RestletServerHost host; + private boolean configured; + + public GrpcServerAdapter() { + this(new Context()); + } + + public GrpcServerAdapter(Context context) { + super(context); + } + + public List getServices() { + return services; + } + + public List getInterceptors() { + return interceptors; + } + + public synchronized void setMaxMessageBytes(int value) { + configurable(); + if (value <= 0 || value > Integer.MAX_VALUE - 6) { + throw new IllegalArgumentException("A positive finite message budget is required"); + } + maxMessageBytes = value; + } + + public synchronized int getMaxMessageBytes() { + return maxMessageBytes; + } + + public synchronized void setMaxQueuedBytes(int value) { + configurable(); + if (value < 16384) { + throw new IllegalArgumentException("Queue budget must be at least 16384 bytes"); + } + maxQueuedBytes = value; + } + + public synchronized int getMaxQueuedBytes() { + return maxQueuedBytes; + } + + public synchronized void setMaxConcurrentCalls(int value) { + configurable(); + if (value <= 0) { + throw new IllegalArgumentException("A positive call budget is required"); + } + maxConcurrentCalls = value; + } + + public synchronized int getMaxConcurrentCalls() { + return maxConcurrentCalls; + } + + public synchronized void setMaxCallDuration(Duration value) { + configurable(); + maxCallDuration = positive(value); + } + + public synchronized Duration getMaxCallDuration() { + return maxCallDuration; + } + + public synchronized void setShutdownTimeout(Duration value) { + configurable(); + shutdownTimeout = positive(value); + } + + public synchronized Duration getShutdownTimeout() { + return shutdownTimeout; + } + + public synchronized void setExecutor(Executor value) { + configurable(); + executor = java.util.Objects.requireNonNull(value, "executor"); + } + + /** + * Returns the callback executor: the supplied one, the adapter-owned one once started, or null + * before start when none was supplied. + */ + public synchronized Executor getExecutor() { + return executor; + } + + private static Duration positive(Duration value) { + if (value.isNegative() || value.isZero()) { + throw new IllegalArgumentException("A positive duration is required"); + } + return Duration.ofNanos(value.toNanos()); + } + + private void configurable() { + if (configured) { + throw new IllegalStateException("Server adapter is single-use; configuration is fixed"); + } + } + + /** Attaches gRPC service prefixes while leaving other routes on the same listener untouched. */ + public void attachTo(Router router) { + for (var service : List.copyOf(services)) { + router.attach( + "/" + service.getServiceDescriptor().getName() + "/", + this, + Template.MODE_STARTS_WITH); + } + } + + @Override + public synchronized void start() throws Exception { + if (isStarted()) { + return; + } + configurable(); + for (var service : List.copyOf(services)) { + for (var method : service.getMethods()) { + var type = method.getMethodDescriptor().getType(); + if (type != io.grpc.MethodDescriptor.MethodType.UNARY + && type != io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING) { + throw new UnsupportedOperationException( + "Only unary and server-streaming methods are supported"); + } + } + } + configured = true; + host = + new RestletServerHost( + maxMessageBytes, maxQueuedBytes, maxConcurrentCalls, maxCallDuration); + if (executor == null) { + ownedExecutor = Executors.newVirtualThreadPerTaskExecutor(); + executor = ownedExecutor; + } + var builder = + new ServerImplBuilder( + (tracers, recorder) -> { + host.setTracers(tracers); + return host; + }) + .executor(executor) + .maxInboundMessageSize(maxMessageBytes); + List.copyOf(services).forEach(builder::addService); + List.copyOf(interceptors).forEach(builder::intercept); + try { + sdk = builder.build().start(); + super.start(); + } catch (IOException | RuntimeException e) { + host.close(); + if (ownedExecutor != null) { + ownedExecutor.shutdownNow(); + } + throw e; + } + } + + @Override + public void handle(Request request, Response response) { + RestletServerHost current; + synchronized (this) { + current = isStarted() ? host : null; + } + if (current == null) { + response.setStatus(org.restlet.data.Status.SERVER_ERROR_SERVICE_UNAVAILABLE); + return; + } + current.handle(request, response); + } + + @Override + public synchronized void stop() throws Exception { + if (sdk == null) { + return; + } + sdk.shutdownNow(); + if (host != null) { + host.close(); + } + long started = System.nanoTime(); + try { + if (!sdk.awaitTermination(shutdownTimeout.toNanos(), TimeUnit.NANOSECONDS)) { + throw new IllegalStateException("gRPC server shutdown timed out"); + } + if (ownedExecutor != null) { + ownedExecutor.shutdown(); + long remaining = shutdownTimeout.toNanos() - (System.nanoTime() - started); + if (!ownedExecutor.awaitTermination(Math.max(0, remaining), TimeUnit.NANOSECONDS)) { + throw new IllegalStateException("gRPC callback executor shutdown timed out"); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw e; + } finally { + if (ownedExecutor != null) { + ownedExecutor.shutdownNow(); + } + super.stop(); + } + } +} diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java new file mode 100644 index 0000000000..c61f6ee1c0 --- /dev/null +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java @@ -0,0 +1,535 @@ +/** + * 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.InternalChannelz; +import io.grpc.InternalInstrumented; +import io.grpc.InternalLogId; +import io.grpc.InternalMetadata; +import io.grpc.InternalStatus; +import io.grpc.Metadata; +import io.grpc.ServerStreamTracer; +import io.grpc.Status; +import io.grpc.internal.AbstractServerStream; +import io.grpc.internal.InternalServer; +import io.grpc.internal.ReadableBuffers; +import io.grpc.internal.SerializingExecutor; +import io.grpc.internal.ServerListener; +import io.grpc.internal.ServerTransport; +import io.grpc.internal.ServerTransportListener; +import io.grpc.internal.StatsTraceContext; +import io.grpc.internal.TransportFrameUtil; +import io.grpc.internal.TransportTracer; +import io.grpc.internal.WritableBuffer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +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.representation.OutputRepresentation; +import org.restlet.util.Series; + +/** Pinned SDK socketless server transport; each transport represents one logical HTTP exchange. */ +@SuppressWarnings("null") +public final class RestletServerHost implements InternalServer, AutoCloseable { + private final int maxBytes; + private final int queueBytes; + private final Duration duration; + private final Semaphore admission; + private final Set calls = ConcurrentHashMap.newKeySet(); + private final ScheduledExecutorService timer = + Executors.newSingleThreadScheduledExecutor( + Thread.ofPlatform().daemon().name("restlet-grpc-server-timer").factory()); + private volatile ServerListener listener; + private List tracers = List.of(); + private boolean closed; + + public RestletServerHost(int maxBytes, int queueBytes, int maxCalls, Duration duration) { + this.maxBytes = maxBytes; + this.queueBytes = queueBytes; + this.duration = duration; + admission = new Semaphore(maxCalls); + } + + public void setTracers(List value) { + tracers = List.copyOf(value); + } + + @Override + public synchronized void start(ServerListener value) { + listener = value; + } + + @Override + public synchronized void shutdown() { + if (!closed) { + closed = true; + for (Call call : List.copyOf(calls)) { + call.cancel(Status.UNAVAILABLE.withDescription("Server shutdown")); + } + if (listener != null) { + listener.serverShutdown(); + } + } + } + + @Override + public void close() { + shutdown(); + calls.forEach(call -> call.cancel(Status.UNAVAILABLE.withDescription("Adapter stopped"))); + timer.shutdownNow(); + } + + @Override + public SocketAddress getListenSocketAddress() { + return new InetSocketAddress("localhost", 0); + } + + @Override + public List getListenSocketAddresses() { + return List.of(); + } + + @Override + public InternalInstrumented getListenSocketStats() { + return null; + } + + @Override + public List> getListenSocketStatsList() { + return List.of(); + } + + public void handle(Request request, Response response) { + if (!Method.POST.equals(request.getMethod())) { + response.setStatus(org.restlet.data.Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); + return; + } + String type = + request.getEntity() == null || request.getEntity().getMediaType() == null + ? "" + : request.getEntity().getMediaType().getName(); + if (!"application/grpc".equals(type) && !type.startsWith("application/grpc+")) { + response.setStatus(org.restlet.data.Status.CLIENT_ERROR_UNSUPPORTED_MEDIA_TYPE); + return; + } + Call call; + synchronized (this) { + if (closed) { + response.setStatus(org.restlet.data.Status.SERVER_ERROR_SERVICE_UNAVAILABLE); + return; + } + if (!admission.tryAcquire()) { + response.setStatus(org.restlet.data.Status.SERVER_ERROR_SERVICE_UNAVAILABLE); + return; + } + call = new Call(request, response); + calls.add(call); + call.transportListener = listener.transportCreated(call); + } + try { + call.begin(); + } catch (IOException | RuntimeException e) { + call.cancel( + Status.INVALID_ARGUMENT.withDescription("Invalid gRPC request").withCause(e)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + call.cancel(Status.CANCELLED.withCause(e)); + } + } + + private final class Call implements ServerTransport { + private final Request request; + private final Response response; + private final SerializingExecutor serial = new SerializingExecutor(Runnable::run); + private final InternalLogId id = InternalLogId.allocate(Call.class, "Restlet logical call"); + private final ArrayDeque queue = new ArrayDeque<>(); + private final CountDownLatch head = new CountDownLatch(1); + private final AtomicBoolean finished = new AtomicBoolean(); + private ServerTransportListener transportListener; + private SdkStream stream; + private ScheduledFuture deadline; + private Metadata trailers; + private int queued; + private boolean terminal; + + private Call(Request request, Response response) { + this.request = request; + this.response = response; + } + + private void begin() throws IOException, InterruptedException { + Metadata headers = fromHttp(request.getHeaders()); + String path = + request.getOriginalRef() == null + ? request.getResourceRef().getPath() + : request.getOriginalRef().getPath(); + if (path == null || path.isEmpty()) { + path = request.getResourceRef() == null ? "/" : request.getResourceRef().getPath(); + } + if (path == null || path.isEmpty()) { + path = "/"; + } + if (!path.startsWith("/")) { + path = "/" + path; + } + String method = path.substring(1); + response.setOnSent((sentRequest, sentResponse) -> serial.execute(() -> finish(null))); + serial.execute( + () -> { + if (finished.get()) { + return; + } + Attributes attributes = transportListener.transportReady(Attributes.EMPTY); + stream = + new SdkStream( + this, + attributes, + request.getHostRef() == null + ? request.getResourceRef().getAuthority() + : request.getHostRef().getAuthority(), + StatsTraceContext.newServerContext( + tracers, method, headers)); + transportListener.streamCreated(stream, method, headers); + stream.state.onStreamAllocated(); + synchronized (RestletServerHost.this) { + if (!closed) { + deadline = + timer.schedule( + () -> + cancel( + Status.DEADLINE_EXCEEDED + .withDescription( + "Call duration exceeded")), + duration.toNanos(), + TimeUnit.NANOSECONDS); + } + } + }); + request.getDisconnectSignal() + .thenAccept(cause -> cancel(Status.CANCELLED.withCause(cause))); + byte[] bytes; + try (var input = request.getEntity().getStream()) { + bytes = input == null ? new byte[0] : input.readNBytes(maxBytes + 6); + } + if (bytes.length > maxBytes + 5) { + cancel(Status.RESOURCE_EXHAUSTED.withDescription("Request byte budget exceeded")); + } else { + serial.execute( + () -> { + if (!finished.get()) { + stream.state.inboundDataReceived(ReadableBuffers.wrap(bytes), true); + } + }); + } + if (!head.await(duration.toNanos(), TimeUnit.NANOSECONDS)) { + cancel(Status.DEADLINE_EXCEEDED.withDescription("Response headers timed out")); + } + response.setEntity( + new OutputRepresentation(MediaType.valueOf("application/grpc")) { + @Override + public void write(OutputStream output) throws IOException { + try { + for (; ; ) { + byte[] data; + synchronized (queue) { + while (queue.isEmpty() && !terminal) { + queue.wait(); + } + data = queue.pollFirst(); + if (data == null) { + toHttp(trailers, response.getTrailers()); + return; + } + queued -= data.length; + } + output.write(data); + output.flush(); + int sent = data.length; + serial.execute(() -> stream.state.onSentBytes(sent)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + cancel(Status.CANCELLED.withCause(e)); + throw new IOException("Interrupted writing gRPC response", e); + } catch (IOException e) { + cancel(Status.CANCELLED.withCause(e)); + throw e; + } + } + }); + } + + private void end(Metadata metadata) { + synchronized (queue) { + if (!terminal) { + terminal = true; + trailers = metadata; + head.countDown(); + queue.notifyAll(); + } + } + } + + private void cancel(Status status) { + serial.execute( + () -> { + synchronized (queue) { + if (finished.get()) { + return; + } + queue.clear(); + queued = 0; + terminal = true; + trailers = new Metadata(); + trailers.put(InternalStatus.CODE_KEY, status); + if (status.getDescription() != null) { + trailers.put(InternalStatus.MESSAGE_KEY, status.getDescription()); + } + head.countDown(); + queue.notifyAll(); + } + finish(status); + }); + } + + private void finish(Status failure) { + if (finished.compareAndSet(false, true)) { + if (deadline != null) { + deadline.cancel(false); + } + try { + if (stream != null) { + if (failure == null) { + stream.state.complete(); + } else { + stream.state.transportReportStatus(failure); + } + } + } finally { + calls.remove(this); + admission.release(); + transportListener.transportTerminated(); + } + } + } + + @Override + public void shutdown() {} + + @Override + public void shutdownNow(Status reason) { + cancel(reason); + } + + @Override + public ScheduledExecutorService getScheduledExecutorService() { + return timer; + } + + @Override + public ListenableFuture getStats() { + return Futures.immediateFuture(null); + } + + @Override + public InternalLogId getLogId() { + return id; + } + } + + private final class SdkStream extends AbstractServerStream { + private final Call call; + private final Attributes attributes; + private final String authority; + private final State state; + + private SdkStream( + Call call, Attributes attributes, String authority, StatsTraceContext stats) { + super(hint -> new Buffer(Math.max(1, Math.min(hint, 16384))), stats); + this.call = call; + this.attributes = attributes; + this.authority = authority; + state = new State(stats); + framer().setMaxOutboundMessageSize(maxBytes); + setOnReadyThreshold(Math.max(1, queueBytes / 2)); + } + + @Override + protected State transportState() { + return state; + } + + @Override + public Attributes getAttributes() { + return attributes; + } + + @Override + public String getAuthority() { + return authority; + } + + @Override + public int streamId() { + return -1; + } + + @Override + protected Sink abstractServerStreamSink() { + return new Sink() { + @Override + public void writeHeaders(Metadata headers, boolean flush) { + synchronized (call.queue) { + if (!call.terminal) { + toHttp(headers, call.response.getHeaders()); + call.head.countDown(); + } + } + } + + @Override + public void writeFrame(WritableBuffer frame, boolean flush, int messages) { + if (frame == null) { + return; + } + var buffer = (Buffer) frame; + boolean overflow; + synchronized (call.queue) { + if (call.terminal) { + return; + } + overflow = buffer.size > queueBytes - call.queued; + if (!overflow) { + byte[] bytes = java.util.Arrays.copyOf(buffer.bytes, buffer.size); + onSendingBytes(bytes.length); + call.queue.addLast(bytes); + call.queued += bytes.length; + call.queue.notifyAll(); + } + } + if (overflow) { + call.cancel( + Status.RESOURCE_EXHAUSTED.withDescription( + "Response queue byte budget exceeded")); + } + } + + @Override + public void writeTrailers(Metadata trailers, boolean headersSent, Status status) { + call.end(trailers); + } + + @Override + public void cancel(Status status) { + call.cancel(status); + } + }; + } + + private final class State extends AbstractServerStream.TransportState { + private State(StatsTraceContext stats) { + super(maxBytes, stats, new TransportTracer()); + } + + @Override + public void runOnTransportThread(Runnable task) { + call.serial.execute(task); + } + + @Override + public void bytesRead(int count) {} + + @Override + public void deframeFailed(Throwable cause) { + call.cancel(Status.fromThrowable(cause)); + } + } + } + + private static final class Buffer implements WritableBuffer { + private final byte[] bytes; + private int size; + + private Buffer(int capacity) { + bytes = new byte[capacity]; + } + + @Override + public void write(byte value) { + bytes[size++] = value; + } + + @Override + public void write(byte[] source, int offset, int count) { + System.arraycopy(source, offset, bytes, size, count); + size += count; + } + + @Override + public int writableBytes() { + return bytes.length - size; + } + + @Override + public int readableBytes() { + return size; + } + + @Override + public void release() {} + } + + private static Metadata fromHttp(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 static void toHttp(Metadata metadata, Series
headers) { + if (metadata == null) { + return; + } + byte[][] values = TransportFrameUtil.toHttp2Headers(metadata); + for (int i = 0; i < values.length; i += 2) { + String name = new String(values[i], StandardCharsets.US_ASCII); + if (!name.startsWith(":") && !"content-type".equals(name)) { + headers.add(name, new String(values[i + 1], StandardCharsets.US_ASCII)); + } + } + } +} 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 index 81ff8796d1..e9a6c76a42 100644 --- 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 @@ -24,7 +24,7 @@ class GrpcModuleTestCase { @Test - void nativeChannelResolvesWithoutSdkNetworkTransportOrInternalExports(@TempDir Path temp) + void nativeAdaptersResolveWithoutSdkNetworkTransportOrInternalExports(@TempDir Path temp) throws Exception { String classPath = System.getProperty( @@ -79,12 +79,61 @@ void nativeChannelResolvesWithoutSdkNetworkTransportOrInternalExports(@TempDir P import org.restlet.Context; import org.restlet.data.Protocol; import org.restlet.ext.grpc.GrpcClientAdapter; + import org.restlet.ext.grpc.GrpcServerAdapter; + import org.restlet.routing.Router; + import io.grpc.CallOptions; + import io.grpc.ClientCall; + import io.grpc.Metadata; + import io.grpc.MethodDescriptor; + import io.grpc.ServerCall; + import io.grpc.ServerServiceDefinition; + import io.grpc.Status; 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 marshaller = new MethodDescriptor.Marshaller() { + public java.io.InputStream stream(String value) { + return new java.io.ByteArrayInputStream( + value.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + public String parse(java.io.InputStream input) { + try { + return new String(input.readAllBytes(), + java.nio.charset.StandardCharsets.UTF_8); + } catch (java.io.IOException e) { + throw new java.io.UncheckedIOException(e); + } + } + }; + var method = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("consumer.Echo/Call") + .setRequestMarshaller(marshaller) + .setResponseMarshaller(marshaller).build(); + var server = new GrpcServerAdapter(); + server.getServices().add(ServerServiceDefinition.builder("consumer.Echo") + .addMethod(method, (call, headers) -> { + call.request(1); + return new ServerCall.Listener() { + private String message; + public void onMessage(String value) { message = value; } + public void onHalfClose() { + call.sendHeaders(new Metadata()); + call.sendMessage(message); + call.close(Status.OK, new Metadata()); + } + }; + }).build()); + server.start(); + var router = new Router(); + server.attachTo(router); + var serverContext = new Context(); + serverContext.getParameters().set("http.transport.protocol", "HTTP2"); + var http = new org.restlet.Server(serverContext, Protocol.HTTP, 0, router); + http.start(); var context = new Context(); context.getParameters().set("httpClientTransportMode", "HTTP2"); context.getParameters().set("followRedirects", "false"); @@ -93,12 +142,44 @@ public static void main(String[] args) throws Exception { try (var adapter = new GrpcClientAdapter()) { adapter.setClient(client); adapter.setPlaintext(true); - adapter.setTarget("dns:///localhost:12345"); + String authority = "localhost:" + http.getActualPort(); + adapter.setTarget("dns:///" + authority); adapter.start(); - if (!adapter.getChannel().authority().equals("localhost:12345")) { + if (!adapter.getChannel().authority().equals(authority)) { throw new AssertionError("Native authority was lost"); } - } finally { client.stop(); } + var done = new java.util.concurrent.CountDownLatch(1); + var reply = new java.util.concurrent.atomic.AtomicReference(); + var result = new java.util.concurrent.atomic.AtomicReference(); + var call = adapter.getChannel().newCall(method, + CallOptions.DEFAULT.withDeadlineAfter(10, + java.util.concurrent.TimeUnit.SECONDS)); + call.start(new ClientCall.Listener() { + public void onMessage(String value) { reply.set(value); } + public void onClose(Status status, Metadata trailers) { + result.set(status); + done.countDown(); + } + }, new Metadata()); + call.request(1); + call.sendMessage("packaged echo"); + call.halfClose(); + if (!done.await(15, java.util.concurrent.TimeUnit.SECONDS) + || result.get() == null || !result.get().isOk() + || !"packaged echo".equals(reply.get())) { + throw new AssertionError("Native round trip failed: " + result.get()); + } + server.stop(); + if (!http.isStarted()) { + throw new AssertionError("Borrowed listener was stopped"); + } + } finally { + try { server.stop(); } + finally { + try { client.stop(); } + finally { http.stop(); } + } + } } } """); diff --git a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java new file mode 100644 index 0000000000..a2ac58fa69 --- /dev/null +++ b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java @@ -0,0 +1,795 @@ +/** + * 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.assertFalse; +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.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +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.NettyChannelBuilder; +import io.grpc.protobuf.ProtoUtils; +import io.grpc.protobuf.services.HealthStatusManager; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.ServerCallStreamObserver; +import io.grpc.stub.ServerCalls; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.time.Duration; +import java.util.ArrayList; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.InputRepresentation; +import org.restlet.routing.Router; + +class GrpcServerAdapterTestCase { + private static final MethodDescriptor ECHO = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("review.Server/Echo") + .setRequestMarshaller(ProtoUtils.marshaller(StringValue.getDefaultInstance())) + .setResponseMarshaller(ProtoUtils.marshaller(StringValue.getDefaultInstance())) + .build(); + private static final MethodDescriptor WATCH = + ECHO.toBuilder() + .setType(MethodDescriptor.MethodType.SERVER_STREAMING) + .setFullMethodName("review.Server/Watch") + .build(); + private final GrpcServerAdapter adapter = new GrpcServerAdapter(); + private Server http; + private ManagedChannel channel; + private final java.util.List lifecycleErrors = new CopyOnWriteArrayList<>(); + private final Handler errorHandler = + new Handler() { + @Override + public void publish(LogRecord record) { + if (record.getLevel().intValue() >= Level.SEVERE.intValue()) { + lifecycleErrors.add(record); + } + } + + @Override + public void flush() {} + + @Override + public void close() {} + }; + + @BeforeEach + void captureLifecycleErrors() { + Logger.getLogger("io.grpc.internal").addHandler(errorHandler); + } + + private void start(ServerServiceDefinition service) throws Exception { + adapter.getServices().add(service); + adapter.start(); + Router router = new Router(); + adapter.attachTo(router); + router.attach( + "/rest", + new Restlet() { + @Override + public void handle(Request request, Response response) { + response.setEntity("rest", MediaType.TEXT_PLAIN); + } + }); + Context context = new Context(); + context.getParameters().set("http.transport.protocol", "HTTP2"); + http = new Server(context, Protocol.HTTP, 0, router); + http.start(); + channel = + NettyChannelBuilder.forAddress("localhost", http.getActualPort()) + .usePlaintext() + .build(); + } + + private String call(String value) { + return ClientCalls.blockingUnaryCall( + channel, + ECHO, + CallOptions.DEFAULT.withDeadlineAfter(10, TimeUnit.SECONDS), + StringValue.of(value)) + .getValue(); + } + + private static ServerServiceDefinition echo() { + return ServerServiceDefinition.builder("review.Server") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + out.onNext(request); + out.onCompleted(); + })) + .build(); + } + + @AfterEach + void cleanup() throws Exception { + try { + if (channel != null) { + channel.shutdownNow(); + assertTrue(channel.awaitTermination(15, TimeUnit.SECONDS)); + } + } finally { + try { + adapter.stop(); + } finally { + if (http != null) { + http.stop(); + } + Logger.getLogger("io.grpc.internal").removeHandler(errorHandler); + assertTrue( + lifecycleErrors.isEmpty(), + () -> + lifecycleErrors.stream() + .map(LogRecord::getMessage) + .toList() + .toString()); + } + } + } + + @Test + void generatedAndDynamicUnaryShareListenerWithRest() throws Exception { + adapter.getServices().add(new HealthStatusManager().getHealthService().bindService()); + start(echo()); + assertEquals("hello", call("hello")); + assertEquals( + HealthCheckResponse.ServingStatus.SERVING, + HealthGrpc.newBlockingStub(channel) + .withDeadlineAfter(10, TimeUnit.SECONDS) + .check(HealthCheckRequest.getDefaultInstance()) + .getStatus()); + var method = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName(ECHO.getFullMethodName()) + .setRequestMarshaller( + ProtoUtils.marshaller( + DynamicMessage.getDefaultInstance( + StringValue.getDescriptor()))) + .setResponseMarshaller( + ProtoUtils.marshaller( + DynamicMessage.getDefaultInstance( + StringValue.getDescriptor()))) + .build(); + var request = + DynamicMessage.newBuilder(StringValue.getDescriptor()) + .setField(StringValue.getDescriptor().findFieldByName("value"), "dynamic") + .build(); + assertEquals( + request, + ClientCalls.blockingUnaryCall( + channel, + method, + CallOptions.DEFAULT.withDeadlineAfter(10, TimeUnit.SECONDS), + request)); + Context context = new Context(); + context.getParameters().set("httpClientTransportMode", "HTTP2"); + Client rest = new Client(context, Protocol.HTTP); + rest.start(); + try { + assertEquals( + "rest", + rest.handle( + new Request( + org.restlet.data.Method.GET, + "http://localhost:" + http.getActualPort() + "/rest")) + .getEntity() + .getText()); + adapter.stop(); + assertTrue(http.isStarted()); + assertEquals( + "rest", + rest.handle( + new Request( + org.restlet.data.Method.GET, + "http://localhost:" + http.getActualPort() + "/rest")) + .getEntity() + .getText()); + } finally { + rest.stop(); + } + } + + @Test + void preservesTrailersOnlyErrorsAndRecovers() throws Exception { + Metadata.Key key = Metadata.Key.of("x-detail", Metadata.ASCII_STRING_MARSHALLER); + start( + ServerServiceDefinition.builder("review.Server") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + Metadata trailers = new Metadata(); + trailers.put(key, "one"); + trailers.put(key, "two"); + out.onError( + Status.INVALID_ARGUMENT + .withDescription("native % error") + .asRuntimeException(trailers)); + })) + .build()); + var error = assertThrows(StatusRuntimeException.class, () -> call("bad")); + assertEquals(Status.Code.INVALID_ARGUMENT, error.getStatus().getCode()); + assertEquals("native % error", error.getStatus().getDescription()); + assertEquals( + java.util.List.of("one", "two"), + com.google.common.collect.ImmutableList.copyOf( + java.util.Objects.requireNonNull(error.getTrailers()).getAll(key))); + } + + @Test + void serverStreamingUsesReadinessAndPreservesOrder() throws Exception { + adapter.setMaxQueuedBytes(32768); + AtomicInteger produced = new AtomicInteger(); + start( + ServerServiceDefinition.builder("review.Server") + .addMethod( + WATCH, + ServerCalls.asyncServerStreamingCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + var observer = + (ServerCallStreamObserver) out; + observer.setOnReadyHandler( + () -> { + while (observer.isReady() + && produced.get() < 100) { + int index = produced.getAndIncrement(); + observer.onNext( + StringValue.of( + index + + ":" + + "x" + .repeat( + 4096))); + } + if (produced.compareAndSet(100, 101)) { + observer.onCompleted(); + } + }); + })) + .build()); + var responses = + ClientCalls.blockingServerStreamingCall( + channel, + WATCH, + CallOptions.DEFAULT.withDeadlineAfter(15, TimeUnit.SECONDS), + StringValue.of("watch")); + var values = new ArrayList(); + while (responses.hasNext()) { + values.add(responses.next().getValue()); + Thread.sleep(2); + } + assertEquals(100, values.size()); + for (int i = 0; i < 100; i++) { + assertEquals(i + ":" + "x".repeat(4096), values.get(i)); + } + } + + @Test + void cancellationAndShutdownReleaseAdmissionWithoutStoppingListener() throws Exception { + adapter.setMaxConcurrentCalls(1); + adapter.setShutdownTimeout(Duration.ofMillis(100)); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch cancelled = new CountDownLatch(1); + start( + ServerServiceDefinition.builder("review.Server") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + if ("block".equals(request.getValue())) { + ((ServerCallStreamObserver) out) + .setOnCancelHandler(cancelled::countDown); + entered.countDown(); + } else { + out.onNext(request); + out.onCompleted(); + } + })) + .build()); + var first = + ClientCalls.futureUnaryCall( + channel.newCall(ECHO, CallOptions.DEFAULT), StringValue.of("block")); + assertTrue(entered.await(10, TimeUnit.SECONDS)); + first.cancel(true); + assertTrue(cancelled.await(10, TimeUnit.SECONDS)); + assertEquals("next", call("next")); + var second = + ClientCalls.futureUnaryCall( + channel.newCall(ECHO, CallOptions.DEFAULT), StringValue.of("block")); + adapter.stop(); + assertThrows( + java.util.concurrent.ExecutionException.class, + () -> second.get(10, TimeUnit.SECONDS)); + assertTrue(http.isStarted()); + assertFalse(adapter.isStarted()); + } + + @Test + void requestAndResponseMessageBudgetsAreEnforced() throws Exception { + adapter.setMaxMessageBytes(128); + start(echo()); + assertEquals("x".repeat(126), call("x".repeat(126))); + assertEquals( + Status.Code.RESOURCE_EXHAUSTED, + assertThrows(StatusRuntimeException.class, () -> call("x".repeat(127))) + .getStatus() + .getCode()); + assertEquals("next", call("next")); + } + + @Test + void unsupportedStreamingRegistrationFailsExplicitly() { + adapter.getServices() + .add( + ServerServiceDefinition.builder("review.Server") + .addMethod( + ECHO.toBuilder() + .setType( + MethodDescriptor.MethodType + .CLIENT_STREAMING) + .build(), + ServerCalls.asyncClientStreamingCall( + out -> + new io.grpc.stub.StreamObserver< + StringValue>() { + @Override + public void onNext(StringValue value) {} + + @Override + public void onError(Throwable cause) {} + + @Override + public void onCompleted() {} + })) + .build()); + assertThrows(UnsupportedOperationException.class, adapter::start); + } + + private Response dispatch(MethodDescriptor method, String value) + throws Exception { + var bytes = new ByteArrayOutputStream(); + var data = new DataOutputStream(bytes); + byte[] message = StringValue.of(value).toByteArray(); + data.writeByte(0); + data.writeInt(message.length); + data.write(message); + Request request = + new Request( + org.restlet.data.Method.POST, + "http://localhost/" + method.getFullMethodName()); + request.setEntity( + new InputRepresentation( + new ByteArrayInputStream(bytes.toByteArray()), + MediaType.valueOf("application/grpc"))); + Response response = new Response(request); + adapter.handle(request, response); + return response; + } + + @Test + void completedResponseRetainsAdmissionUntilSentAndBorrowedExecutorSurvives() throws Exception { + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + adapter.setExecutor(executor); + adapter.setMaxConcurrentCalls(1); + adapter.getServices().add(echo()); + adapter.start(); + Response first = dispatch(ECHO, "first"); + first.getEntity().write(OutputStream.nullOutputStream()); + assertEquals( + org.restlet.data.Status.SERVER_ERROR_SERVICE_UNAVAILABLE, + dispatch(ECHO, "busy").getStatus()); + first.getOnSent().handle(first.getRequest(), first); + first.getOnSent().handle(first.getRequest(), first); + Response next = dispatch(ECHO, "next"); + next.getEntity().write(OutputStream.nullOutputStream()); + next.getOnSent().handle(next.getRequest(), next); + adapter.stop(); + adapter.stop(); + assertFalse(executor.isShutdown()); + assertEquals("still available", executor.submit(() -> "still available").get()); + } + } + + @Test + void stalledWriterBoundsReadinessAndResumesInOrder() throws Exception { + int queueBudget = 32768; + adapter.setMaxQueuedBytes(queueBudget); + AtomicInteger produced = new AtomicInteger(); + CountDownLatch backpressured = new CountDownLatch(1); + AtomicReference> observerRef = + new AtomicReference<>(); + adapter.getServices() + .add( + ServerServiceDefinition.builder("review.Server") + .addMethod( + WATCH, + ServerCalls.asyncServerStreamingCall( + (StringValue request, + io.grpc.stub.StreamObserver + out) -> { + var observer = + (ServerCallStreamObserver) + out; + observerRef.set(observer); + observer.setOnReadyHandler( + () -> { + while (observer.isReady() + && produced.get() < 100) { + observer.onNext( + StringValue.of( + produced + .getAndIncrement() + + ":" + + "x" + .repeat( + 4096))); + } + if (produced.compareAndSet( + 100, 101)) { + observer.onCompleted(); + } else if (!observer.isReady()) { + backpressured.countDown(); + } + }); + })) + .build()); + adapter.start(); + Response response = dispatch(WATCH, "watch"); + CountDownLatch stalled = new CountDownLatch(1); + CountDownLatch resume = new CountDownLatch(1); + var received = new ByteArrayOutputStream(); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var writer = + executor.submit( + () -> { + response.getEntity() + .write( + new OutputStream() { + @Override + public void write(int value) { + received.write(value); + } + + @Override + public void write( + byte[] bytes, int offset, int length) + throws IOException { + stalled.countDown(); + try { + if (!resume.await( + 10, TimeUnit.SECONDS)) { + throw new IOException( + "Stalled writer was not released"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } + received.write(bytes, offset, length); + } + }); + return null; + }); + try { + assertTrue(stalled.await(10, TimeUnit.SECONDS)); + assertTrue(backpressured.await(10, TimeUnit.SECONDS)); + assertFalse(observerRef.get().isReady()); + // Each queued response contains at least 4096 payload bytes. + assertTrue(produced.get() * 4096 <= queueBudget); + } finally { + resume.countDown(); + } + writer.get(10, TimeUnit.SECONDS); + } + var input = new java.io.DataInputStream(new ByteArrayInputStream(received.toByteArray())); + for (int i = 0; i < 100; i++) { + assertEquals(0, input.readUnsignedByte()); + int size = input.readInt(); + assertEquals( + i + ":" + "x".repeat(4096), + StringValue.parseFrom(input.readNBytes(size)).getValue()); + } + assertEquals(-1, input.read()); + assertEquals("0", response.getTrailers().getFirstValue("grpc-status")); + response.getOnSent().handle(response.getRequest(), response); + } + + @Test + void producerIgnoringReadinessFailsAtQueueBudget() throws Exception { + adapter.setMaxQueuedBytes(16384); + CountDownLatch produced = new CountDownLatch(1); + adapter.getServices() + .add( + ServerServiceDefinition.builder("review.Server") + .addMethod( + WATCH, + ServerCalls.asyncServerStreamingCall( + (StringValue request, + io.grpc.stub.StreamObserver + out) -> { + for (int i = 0; i < 16; i++) { + out.onNext( + StringValue.of("x".repeat(4096))); + } + out.onCompleted(); + produced.countDown(); + })) + .build()); + adapter.start(); + Response response = dispatch(WATCH, "watch"); + assertTrue(produced.await(10, TimeUnit.SECONDS)); + var bytes = new ByteArrayOutputStream(); + response.getEntity().write(bytes); + assertEquals(0, bytes.size()); + assertEquals("8", response.getTrailers().getFirstValue("grpc-status")); + assertEquals( + "Response queue byte budget exceeded", + response.getTrailers().getFirstValue("grpc-message")); + } + + @Test + void serverDeadlineCancelsHandlerAndReleasesAdmission() throws Exception { + adapter.setMaxConcurrentCalls(1); + adapter.setMaxCallDuration(Duration.ofMillis(200)); + CountDownLatch cancelled = new CountDownLatch(1); + start( + ServerServiceDefinition.builder("review.Server") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + if ("block".equals(request.getValue())) { + ((ServerCallStreamObserver) out) + .setOnCancelHandler(cancelled::countDown); + } else { + out.onNext(request); + out.onCompleted(); + } + })) + .build()); + assertEquals( + Status.Code.DEADLINE_EXCEEDED, + assertThrows(StatusRuntimeException.class, () -> call("block")) + .getStatus() + .getCode()); + assertTrue(cancelled.await(10, TimeUnit.SECONDS)); + assertEquals("next", call("next")); + } + + @Test + void oversizedResponseFailsAndNextCallSucceeds() throws Exception { + adapter.setMaxMessageBytes(128); + start( + ServerServiceDefinition.builder("review.Server") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + out.onNext( + "large".equals(request.getValue()) + ? StringValue.of("x".repeat(127)) + : request); + out.onCompleted(); + })) + .build()); + assertEquals( + Status.Code.RESOURCE_EXHAUSTED, + assertThrows(StatusRuntimeException.class, () -> call("large")) + .getStatus() + .getCode()); + assertEquals("next", call("next")); + } + + @Test + void shutdownWakesActiveStreamWriterAndDeliversCancellation() throws Exception { + CountDownLatch cancelled = new CountDownLatch(1); + adapter.getServices() + .add( + ServerServiceDefinition.builder("review.Server") + .addMethod( + WATCH, + ServerCalls.asyncServerStreamingCall( + (StringValue request, + io.grpc.stub.StreamObserver + out) -> { + ((ServerCallStreamObserver) out) + .setOnCancelHandler( + cancelled::countDown); + out.onNext(StringValue.of("first")); + })) + .build()); + adapter.start(); + Response response = dispatch(WATCH, "watch"); + CountDownLatch writing = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var writer = + executor.submit( + () -> { + response.getEntity() + .write( + new OutputStream() { + @Override + public void write(int value) { + writing.countDown(); + } + }); + return null; + }); + assertTrue(writing.await(10, TimeUnit.SECONDS)); + adapter.stop(); + writer.get(10, TimeUnit.SECONDS); + assertTrue(cancelled.await(10, TimeUnit.SECONDS)); + assertEquals("14", response.getTrailers().getFirstValue("grpc-status")); + assertFalse(adapter.isStarted()); + } + } + + @Test + void ownedCallbackShutdownTimeoutIsReportedAndInterruptsWorker() throws Exception { + adapter.setShutdownTimeout(Duration.ofMillis(200)); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch interrupted = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + start( + ServerServiceDefinition.builder("review.Server") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + entered.countDown(); + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + interrupted.countDown(); + } + })) + .build()); + ClientCalls.futureUnaryCall( + channel.newCall(ECHO, CallOptions.DEFAULT), StringValue.of("block")); + assertTrue(entered.await(10, TimeUnit.SECONDS)); + try { + assertEquals( + "gRPC callback executor shutdown timed out", + assertThrows(IllegalStateException.class, adapter::stop).getMessage()); + assertTrue(interrupted.await(10, TimeUnit.SECONDS)); + assertTrue(http.isStarted()); + assertFalse(adapter.isStarted()); + } finally { + release.countDown(); + } + } + + @Test + void clientCancellationAfterStreamMessageReleasesAdmission() throws Exception { + adapter.setMaxConcurrentCalls(1); + CountDownLatch cancelled = new CountDownLatch(1); + var service = ServerServiceDefinition.builder("review.Server"); + echo().getMethods().forEach(service::addMethod); + start( + service.addMethod( + WATCH, + ServerCalls.asyncServerStreamingCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + ((ServerCallStreamObserver) out) + .setOnCancelHandler(cancelled::countDown); + out.onNext(StringValue.of("first")); + })) + .build()); + CountDownLatch closed = new CountDownLatch(1); + AtomicReference status = new AtomicReference<>(); + var call = + channel.newCall(WATCH, CallOptions.DEFAULT.withDeadlineAfter(10, TimeUnit.SECONDS)); + call.start( + new ClientCall.Listener<>() { + @Override + public void onMessage(StringValue value) { + call.cancel("cancel after first message", null); + } + + @Override + public void onClose(Status value, Metadata trailers) { + status.set(value); + closed.countDown(); + } + }, + new Metadata()); + call.request(1); + call.sendMessage(StringValue.of("watch")); + call.halfClose(); + assertTrue(closed.await(10, TimeUnit.SECONDS)); + assertEquals(Status.Code.CANCELLED, status.get().getCode()); + assertTrue(cancelled.await(10, TimeUnit.SECONDS)); + assertEquals("next", call("next")); + } + + @Test + void disconnectRacingResponseSentTerminatesEachTransportOnce() throws Exception { + adapter.setMaxConcurrentCalls(1); + adapter.getServices() + .add( + ServerServiceDefinition.builder("review.Server") + .addMethod( + WATCH, + ServerCalls.asyncServerStreamingCall( + (StringValue request, + io.grpc.stub.StreamObserver + out) -> out.onNext(request))) + .build()); + adapter.start(); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + for (int i = 0; i < 32; i++) { + Response response = dispatch(WATCH, "watch"); + assertEquals(org.restlet.data.Status.SUCCESS_OK, response.getStatus()); + var writer = + executor.submit( + () -> { + response.getEntity().write(OutputStream.nullOutputStream()); + response.getOnSent().handle(response.getRequest(), response); + return null; + }); + var disconnect = + executor.submit( + () -> + response.getRequest() + .notifyDisconnect(new IOException("Peer reset"))); + disconnect.get(10, TimeUnit.SECONDS); + writer.get(10, TimeUnit.SECONDS); + } + } + adapter.stop(); + } +} diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyRequest.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyRequest.java index 9a6a442f6f..36d4236626 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyRequest.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyRequest.java @@ -109,15 +109,35 @@ class NettyRequest extends Request { this.headers = call.getRequestHeaders(); setMethod(Method.valueOf(call.getMethod())); + setProtocol(Protocol.valueOf(call.getProtocol().getSchemeName(), call.getVersion())); setHostRef(hostRef(call.getProtocol(), headers)); String uri = call.getRequestUri(); - Reference resourceRef = new Reference(getHostRef(), uri); - if (resourceRef.isRelative()) { - // Take care of the "/" between the host part and the segments. - resourceRef = - new Reference(getHostRef().toString() + (uri.startsWith("/") ? "" : "/") + uri); + if (uri == null || uri.isEmpty()) { + uri = "/"; + } else if (uri.startsWith("http://") || uri.startsWith("https://")) { + Reference absoluteRef = new Reference(uri); + if (absoluteRef.getAuthority() != null + && getHostRef() != null + && absoluteRef.getAuthority().equals(getHostRef().getAuthority())) { + String path = absoluteRef.getPath(); + String query = absoluteRef.getQuery(); + if (path == null || path.isEmpty()) { + path = "/"; + } else if (!path.startsWith("/")) { + path = "/" + path; + } + uri = path; + if (query != null) { + uri += "?" + query; + } + } + } else if (!uri.startsWith("/")) { + uri = "/" + uri; } + + Reference resourceRef = new Reference(getHostRef().toString() + uri); + resourceRef.setBaseRef(new Reference(getHostRef().toString())); setResourceRef(resourceRef); setOriginalRef(ReferenceUtils.getOriginalRef(getResourceRef(), headers)); diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java index 717164abde..ab9261d00f 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java @@ -301,6 +301,12 @@ String getMethod() { /** The request target, "/" when empty. */ String getRequestUri() { String uri = request.uri(); + if (uri == null || uri.isEmpty() || "/".equals(uri)) { + String path = request.headers().get(":path"); + if (path != null && !path.isEmpty()) { + uri = path; + } + } return (uri == null || uri.isEmpty()) ? "/" : uri; } @@ -328,6 +334,15 @@ Protocol getProtocol() { return confidential ? Protocol.HTTPS : Protocol.HTTP; } + /** The protocol version as reported to the application (for example "1.1" or "2.0"). */ + String getVersion() { + String version = kind.version(); + int slash = version.lastIndexOf('/'); + return (slash >= 0 && slash + 1 < version.length()) + ? version.substring(slash + 1) + : version; + } + /** The remote address, or null. */ InetSocketAddress getRemoteAddress() { SocketAddress address = diff --git a/org.restlet/src/test/java/org/restlet/engine/netty/NettyRequestMappingTestCase.java b/org.restlet/src/test/java/org/restlet/engine/netty/NettyRequestMappingTestCase.java new file mode 100644 index 0000000000..aa2b06b89b --- /dev/null +++ b/org.restlet/src/test/java/org/restlet/engine/netty/NettyRequestMappingTestCase.java @@ -0,0 +1,87 @@ +/** + * 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.engine.netty; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.http.DefaultHttpRequest; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpVersion; +import org.junit.jupiter.api.Test; +import org.restlet.Context; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.routing.Router; +import org.restlet.routing.Template; + +class NettyRequestMappingTestCase { + @Test + void serviceRoutesMatchOriginAndSameHostAbsoluteTargetsForEveryHttpVersion() { + for (StreamKind kind : StreamKind.values()) { + for (String target : + new String[] { + "/review.Server/Echo?mode=one", + "review.Server/Echo?mode=one", + "http://localhost:8080/review.Server/Echo?mode=one" + }) { + EmbeddedChannel channel = new EmbeddedChannel(); + try { + var head = + new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, target); + head.headers().set("host", "localhost:8080"); + var call = + new NettyServerCall( + null, + channel, + kind, + head, + new ChannelInputStream(channel, 1000), + false); + var request = call.toRequest(new Context()); + assertEquals("/review.Server/Echo", request.getResourceRef().getPath()); + assertEquals("mode=one", request.getResourceRef().getQuery()); + assertEquals(kind.version().substring(5), request.getProtocol().getVersion()); + Router router = new Router(); + Restlet service = new Restlet() {}; + router.attach("/review.Server/", service, Template.MODE_STARTS_WITH); + assertSame( + router.getRoutes().get(0), + router.getNext(request, new Response(request))); + assertEquals("/review.Server/Echo", request.getOriginalRef().getPath()); + } finally { + channel.finishAndReleaseAll(); + } + } + } + } + + @Test + void emptyTargetMapsToRoot() { + EmbeddedChannel channel = new EmbeddedChannel(); + try { + var head = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, ""); + head.headers().set("host", "localhost"); + var call = + new NettyServerCall( + null, + channel, + StreamKind.HTTP1, + head, + new ChannelInputStream(channel, 1000), + false); + var request = call.toRequest(new Context()); + assertEquals("/", request.getResourceRef().getPath()); + assertEquals("/", request.getResourceRef().getRemainingPart(false, false)); + } finally { + channel.finishAndReleaseAll(); + } + } +} From 495dbb89e79096dbfc823a10826ddd2dc5ceb345 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:39:57 -0400 Subject: [PATCH 2/8] Free gRPC server call slots in the thread that ends the call The end of a call was reported through the call's serializing executor. When another thread was draining it, the response-sent or disconnect callback returned before the call slot was released, and a client sending its next call at once could get 503 within the concurrency limit. Claim the end of the call and release its slot in the ending thread, and keep reporting to the SDK in the executor. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- .../ext/grpc/internal/RestletServerHost.java | 64 +++++++++++++------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java index c61f6ee1c0..e2e460abf8 100644 --- a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java @@ -180,6 +180,9 @@ private final class Call implements ServerTransport { private int queued; private boolean terminal; + /** Whether the end of the call was reported to the SDK; confined to {@link #serial}. */ + private boolean reported; + private Call(Request request, Response response) { this.request = request; this.response = response; @@ -201,10 +204,15 @@ private void begin() throws IOException, InterruptedException { path = "/" + path; } String method = path.substring(1); - response.setOnSent((sentRequest, sentResponse) -> serial.execute(() -> finish(null))); + response.setOnSent( + (sentRequest, sentResponse) -> { + if (claim()) { + serial.execute(() -> finish(null)); + } + }); serial.execute( () -> { - if (finished.get()) { + if (reported) { return; } Attributes attributes = transportListener.transportReady(Attributes.EMPTY); @@ -244,7 +252,7 @@ private void begin() throws IOException, InterruptedException { } else { serial.execute( () -> { - if (!finished.get()) { + if (!reported) { stream.state.inboundDataReceived(ReadableBuffers.wrap(bytes), true); } }); @@ -299,12 +307,12 @@ private void end(Metadata metadata) { } private void cancel(Status status) { + if (!claim()) { + return; + } serial.execute( () -> { synchronized (queue) { - if (finished.get()) { - return; - } queue.clear(); queued = 0; terminal = true; @@ -320,24 +328,38 @@ private void cancel(Status status) { }); } + /** + * Claims the end of the call and frees its slot at once, in the thread that ends it. + * Another thread may be draining {@link #serial}, so a slot released by {@link #finish} + * could still be held when the client sends its next call. + * + * @return True if this is the first claim. + */ + private boolean claim() { + if (!finished.compareAndSet(false, true)) { + return false; + } + calls.remove(this); + admission.release(); + return true; + } + + /** Reports the end of a claimed call to the SDK, in {@link #serial}. */ private void finish(Status failure) { - if (finished.compareAndSet(false, true)) { - if (deadline != null) { - deadline.cancel(false); - } - try { - if (stream != null) { - if (failure == null) { - stream.state.complete(); - } else { - stream.state.transportReportStatus(failure); - } + reported = true; + if (deadline != null) { + deadline.cancel(false); + } + try { + if (stream != null) { + if (failure == null) { + stream.state.complete(); + } else { + stream.state.transportReportStatus(failure); } - } finally { - calls.remove(this); - admission.release(); - transportListener.transportTerminated(); } + } finally { + transportListener.transportTerminated(); } } From c4f42c819c30436d657de19f8d896551a4da924f Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:08:17 -0500 Subject: [PATCH 3/8] Harden the gRPC server against stalled peers and failed delivery Core: aborting a server exchange resets only its HTTP/2 or HTTP/3 stream. The response's onSent callback runs once the terminal write is done; if it failed, the response status is first set to a connector communication error. HTTP/1 reports the version of its request line. An absolute target with an equivalent authority maps to its path, and another absolute target is kept. gRPC: a call whose delivery failed is cancelled instead of completed. A cancelled call still blocked in the same body read or response write after 200 ms resets its stream. Non-HTTP/2 requests get 505, a failure before dispatch still sends grpc-status, and one message may outgrow the queue budget while it is all the queue holds. Stopping the adapter waits outside its monitor. A client test accepts both grpc-java outcomes of cancel() before start(). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- .../restlet/ext/grpc/GrpcServerAdapter.java | 41 ++-- .../ext/grpc/internal/RestletServerHost.java | 151 ++++++++++--- .../ext/grpc/GrpcClientAdapterTestCase.java | 36 +-- .../ext/grpc/GrpcServerAdapterTestCase.java | 212 +++++++++++++++++- .../restlet/engine/netty/NettyRequest.java | 77 ++++--- .../restlet/engine/netty/NettyServerCall.java | 51 ++++- .../netty/NettyRequestMappingTestCase.java | 46 ++++ 7 files changed, 510 insertions(+), 104 deletions(-) diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java index 167681ed78..e082f9c7bd 100644 --- a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java @@ -219,24 +219,39 @@ public void handle(Request request, Response response) { current.handle(request, response); } + /** + * Stops the adapter. The waits happen outside the adapter's monitor, so callbacks finishing + * meanwhile may still use its synchronized methods. + */ @Override - public synchronized void stop() throws Exception { - if (sdk == null) { - return; + public void stop() throws Exception { + io.grpc.Server server; + RestletServerHost stopping; + ExecutorService owned; + Duration timeout; + synchronized (this) { + if (sdk == null) { + return; + } + server = sdk; + stopping = host; + owned = ownedExecutor; + timeout = shutdownTimeout; + sdk = null; } - sdk.shutdownNow(); - if (host != null) { - host.close(); + server.shutdownNow(); + if (stopping != null) { + stopping.close(); } long started = System.nanoTime(); try { - if (!sdk.awaitTermination(shutdownTimeout.toNanos(), TimeUnit.NANOSECONDS)) { + if (!server.awaitTermination(timeout.toNanos(), TimeUnit.NANOSECONDS)) { throw new IllegalStateException("gRPC server shutdown timed out"); } - if (ownedExecutor != null) { - ownedExecutor.shutdown(); - long remaining = shutdownTimeout.toNanos() - (System.nanoTime() - started); - if (!ownedExecutor.awaitTermination(Math.max(0, remaining), TimeUnit.NANOSECONDS)) { + if (owned != null) { + owned.shutdown(); + long remaining = timeout.toNanos() - (System.nanoTime() - started); + if (!owned.awaitTermination(Math.max(0, remaining), TimeUnit.NANOSECONDS)) { throw new IllegalStateException("gRPC callback executor shutdown timed out"); } } @@ -244,8 +259,8 @@ public synchronized void stop() throws Exception { Thread.currentThread().interrupt(); throw e; } finally { - if (ownedExecutor != null) { - ownedExecutor.shutdownNow(); + if (owned != null) { + owned.shutdownNow(); } super.stop(); } diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java index e2e460abf8..797a007fbb 100644 --- a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java @@ -31,6 +31,7 @@ import io.grpc.internal.TransportTracer; import io.grpc.internal.WritableBuffer; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.SocketAddress; @@ -48,17 +49,22 @@ import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; 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.Protocol; import org.restlet.representation.OutputRepresentation; import org.restlet.util.Series; /** Pinned SDK socketless server transport; each transport represents one logical HTTP exchange. */ @SuppressWarnings("null") public final class RestletServerHost implements InternalServer, AutoCloseable { + /** How long a cancelled call may stay blocked in one network read or write before a reset. */ + private static final Duration STUCK_IO = Duration.ofMillis(200); + private final int maxBytes; private final int queueBytes; private final Duration duration; @@ -140,6 +146,12 @@ public void handle(Request request, Response response) { response.setStatus(org.restlet.data.Status.CLIENT_ERROR_UNSUPPORTED_MEDIA_TYPE); return; } + // Over HTTP/1.1, Netty decides on trailers before the entity can add grpc-status. + Protocol protocol = request.getProtocol(); + if (protocol == null || !"2.0".equals(protocol.getVersion())) { + response.setStatus(org.restlet.data.Status.SERVER_ERROR_VERSION_NOT_SUPPORTED); + return; + } Call call; synchronized (this) { if (closed) { @@ -173,11 +185,20 @@ private final class Call implements ServerTransport { private final ArrayDeque queue = new ArrayDeque<>(); private final CountDownLatch head = new CountDownLatch(1); private final AtomicBoolean finished = new AtomicBoolean(); + private final AtomicLong operations = new AtomicLong(); + + /** The network read or write in progress, 0 if none. */ + private final AtomicLong blocked = new AtomicLong(); + private ServerTransportListener transportListener; private SdkStream stream; private ScheduledFuture deadline; private Metadata trailers; private int queued; + + /** Bytes framed for the latest message, from the frame that started it. */ + private int open; + private boolean terminal; /** Whether the end of the call was reported to the SDK; confined to {@link #serial}. */ @@ -189,6 +210,8 @@ private Call(Request request, Response response) { } private void begin() throws IOException, InterruptedException { + // Installed first, so that a call failing below still sends its grpc-status. + response.setEntity(responseEntity()); Metadata headers = fromHttp(request.getHeaders()); String path = request.getOriginalRef() == null @@ -206,7 +229,13 @@ private void begin() throws IOException, InterruptedException { String method = path.substring(1); response.setOnSent( (sentRequest, sentResponse) -> { - if (claim()) { + // An undelivered response leaves the client without the call's status. + if (sentResponse.getStatus().isConnectorError()) { + cancel( + Status.CANCELLED + .withDescription("gRPC response not delivered") + .withCause(sentResponse.getStatus().getThrowable())); + } else if (claim()) { serial.execute(() -> finish(null)); } }); @@ -245,7 +274,7 @@ private void begin() throws IOException, InterruptedException { .thenAccept(cause -> cancel(Status.CANCELLED.withCause(cause))); byte[] bytes; try (var input = request.getEntity().getStream()) { - bytes = input == null ? new byte[0] : input.readNBytes(maxBytes + 6); + bytes = input == null ? new byte[0] : read(input); } if (bytes.length > maxBytes + 5) { cancel(Status.RESOURCE_EXHAUSTED.withDescription("Request byte budget exceeded")); @@ -260,37 +289,85 @@ private void begin() throws IOException, InterruptedException { if (!head.await(duration.toNanos(), TimeUnit.NANOSECONDS)) { cancel(Status.DEADLINE_EXCEEDED.withDescription("Response headers timed out")); } - response.setEntity( - new OutputRepresentation(MediaType.valueOf("application/grpc")) { - @Override - public void write(OutputStream output) throws IOException { - try { - for (; ; ) { - byte[] data; - synchronized (queue) { - while (queue.isEmpty() && !terminal) { - queue.wait(); - } - data = queue.pollFirst(); - if (data == null) { - toHttp(trailers, response.getTrailers()); - return; - } - queued -= data.length; - } - output.write(data); - output.flush(); - int sent = data.length; - serial.execute(() -> stream.state.onSentBytes(sent)); + } + + private byte[] read(InputStream input) throws IOException { + enterIo(); + try { + return input.readNBytes(maxBytes + 6); + } finally { + blocked.set(0); + } + } + + private OutputRepresentation responseEntity() { + return new OutputRepresentation(MediaType.valueOf("application/grpc")) { + @Override + public void write(OutputStream output) throws IOException { + try { + for (; ; ) { + byte[] data; + synchronized (queue) { + while (queue.isEmpty() && !terminal) { + queue.wait(); } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - cancel(Status.CANCELLED.withCause(e)); - throw new IOException("Interrupted writing gRPC response", e); - } catch (IOException e) { - cancel(Status.CANCELLED.withCause(e)); - throw e; + data = queue.pollFirst(); + if (data == null) { + toHttp(trailers, response.getTrailers()); + return; + } + queued -= data.length; } + enterIo(); + try { + output.write(data); + output.flush(); + } finally { + blocked.set(0); + } + int sent = data.length; + serial.execute(() -> stream.state.onSentBytes(sent)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + cancel(Status.CANCELLED.withCause(e)); + throw new IOException("Interrupted writing gRPC response", e); + } catch (IOException e) { + cancel(Status.CANCELLED.withCause(e)); + throw e; + } + } + }; + } + + /** Marks the start of a network read or write, which only the peer may otherwise end. */ + private void enterIo() { + blocked.set(operations.incrementAndGet()); + if (finished.get()) { + abortIfStuck(); + } + } + + /** + * Resets the HTTP stream if the call stays in the same network read or write after it was + * ended, so a peer that stops sending or reading can't hold the handler. A call blocked + * elsewhere was already woken by the end of the call. + */ + private void abortIfStuck() { + long operation = blocked.get(); + if (operation == 0) { + return; + } + Thread.startVirtualThread( + () -> { + try { + Thread.sleep(STUCK_IO); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + if (blocked.get() == operation) { + request.abort(); } }); } @@ -310,11 +387,13 @@ private void cancel(Status status) { if (!claim()) { return; } + abortIfStuck(); serial.execute( () -> { synchronized (queue) { queue.clear(); queued = 0; + open = 0; terminal = true; trailers = new Metadata(); trailers.put(InternalStatus.CODE_KEY, status); @@ -448,8 +527,16 @@ public void writeFrame(WritableBuffer frame, boolean flush, int messages) { if (call.terminal) { return; } - overflow = buffer.size > queueBytes - call.queued; + // One message is framed whole, possibly before the writer starts: while the + // queue holds only that message, it may grow up to one framed message. A + // frame counting messages starts a new one. + int open = messages > 0 ? buffer.size : call.open + buffer.size; + boolean others = call.queued + buffer.size > open; + overflow = + buffer.size > queueBytes - call.queued + && (others || open > maxBytes + 5); if (!overflow) { + call.open = open; byte[] bytes = java.util.Arrays.copyOf(buffer.bytes, buffer.size); onSendingBytes(bytes.length); call.queue.addLast(bytes); 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 index bf06dc2919..5b19cde07b 100644 --- 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 @@ -281,22 +281,32 @@ public ServerCall.Listener interceptCall( 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()); + boolean rejected = false; + try { + call.start( + new ClientCall.Listener<>() { + @Override + public void onClose(Status value, Metadata trailers) { + status.set(value); + terminals.incrementAndGet(); + closed.countDown(); + } + }, + new Metadata()); + } catch (IllegalStateException e) { + // A call created once the channel has resolved its target rejects start() after + // cancel(); one created earlier is delayed and closes its listener instead. + assertEquals("call was cancelled", e.getMessage()); + rejected = true; + } + if (!rejected) { + 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()); + assertEquals(rejected ? 0 : 1, terminals.get()); } @Test diff --git a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java index a2ac58fa69..9b676445fb 100644 --- a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java +++ b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java @@ -36,6 +36,7 @@ import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.time.Duration; import java.util.ArrayList; @@ -394,27 +395,224 @@ public void onCompleted() {} assertThrows(UnsupportedOperationException.class, adapter::start); } - private Response dispatch(MethodDescriptor method, String value) - throws Exception { + private static byte[] frame(String value) throws IOException { var bytes = new ByteArrayOutputStream(); var data = new DataOutputStream(bytes); byte[] message = StringValue.of(value).toByteArray(); data.writeByte(0); data.writeInt(message.length); data.write(message); + return bytes.toByteArray(); + } + + /** A direct HTTP/2 gRPC request; its abort action, if any, stands for the stream reset. */ + private static Request grpcRequest( + MethodDescriptor method, InputStream body, Runnable abort) { Request request = new Request( org.restlet.data.Method.POST, - "http://localhost/" + method.getFullMethodName()); - request.setEntity( - new InputRepresentation( - new ByteArrayInputStream(bytes.toByteArray()), - MediaType.valueOf("application/grpc"))); + "http://localhost/" + method.getFullMethodName()) { + @Override + public boolean abort() { + abort.run(); + return true; + } + }; + request.setProtocol(Protocol.valueOf("HTTP", "2.0")); + request.setEntity(new InputRepresentation(body, MediaType.valueOf("application/grpc"))); + return request; + } + + private Response dispatch(MethodDescriptor method, String value) + throws Exception { + Request request = grpcRequest(method, new ByteArrayInputStream(frame(value)), () -> {}); Response response = new Response(request); adapter.handle(request, response); return response; } + private static String trailersAfterWriting(Response response) throws IOException { + response.getEntity().write(new ByteArrayOutputStream()); + return response.getTrailers().getFirstValue("grpc-status"); + } + + @Test + void http1RequestsAreRejectedBeforeDispatch() throws Exception { + AtomicInteger calls = new AtomicInteger(); + adapter.getServices() + .add( + ServerServiceDefinition.builder("review.Server") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver + out) -> { + calls.incrementAndGet(); + out.onNext(request); + out.onCompleted(); + })) + .build()); + adapter.start(); + Request request = grpcRequest(ECHO, new ByteArrayInputStream(frame("x")), () -> {}); + request.setProtocol(Protocol.valueOf("HTTP", "1.1")); + Response response = new Response(request); + adapter.handle(request, response); + assertEquals( + org.restlet.data.Status.SERVER_ERROR_VERSION_NOT_SUPPORTED, response.getStatus()); + assertEquals(0, calls.get()); + } + + @Test + void unreadableRequestStillSendsItsGrpcStatus() throws Exception { + adapter.getServices().add(echo()); + adapter.start(); + Request request = + grpcRequest( + ECHO, + new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("broken body"); + } + }, + () -> {}); + Response response = new Response(request); + adapter.handle(request, response); + assertEquals( + String.valueOf(Status.Code.INVALID_ARGUMENT.value()), + trailersAfterWriting(response)); + } + + @Test + void stalledRequestBodyIsResetAtTheDeadline() throws Exception { + adapter.setMaxCallDuration(Duration.ofMillis(300)); + adapter.getServices().add(echo()); + adapter.start(); + CountDownLatch reset = new CountDownLatch(1); + InputStream stalled = + new InputStream() { + @Override + public int read() throws IOException { + try { + reset.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + throw new IOException("stream reset"); + } + }; + Request request = grpcRequest(ECHO, stalled, reset::countDown); + Response response = new Response(request); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + executor.submit(() -> adapter.handle(request, response)).get(10, TimeUnit.SECONDS); + } + assertEquals(0, reset.getCount()); + assertEquals( + String.valueOf(Status.Code.DEADLINE_EXCEEDED.value()), + trailersAfterWriting(response)); + } + + @Test + void responseWriteBlockedByThePeerIsResetAtShutdown() throws Exception { + adapter.getServices() + .add( + ServerServiceDefinition.builder("review.Server") + .addMethod( + WATCH, + ServerCalls.asyncServerStreamingCall( + (StringValue request, + io.grpc.stub.StreamObserver + out) -> + out.onNext(StringValue.of("first")))) + .build()); + adapter.start(); + CountDownLatch reset = new CountDownLatch(1); + Request request = + grpcRequest(WATCH, new ByteArrayInputStream(frame("watch")), reset::countDown); + Response response = new Response(request); + adapter.handle(request, response); + CountDownLatch writing = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var writer = + executor.submit( + () -> { + response.getEntity() + .write( + new OutputStream() { + @Override + public void write(int value) + throws IOException { + writing.countDown(); + try { + reset.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + throw new IOException("stream reset"); + } + }); + return null; + }); + assertTrue(writing.await(10, TimeUnit.SECONDS)); + adapter.stop(); + assertTrue(reset.await(10, TimeUnit.SECONDS)); + assertThrows( + java.util.concurrent.ExecutionException.class, + () -> writer.get(10, TimeUnit.SECONDS)); + } + } + + @Test + void callbacksMayUseTheAdapterWhileItStops() throws Exception { + CountDownLatch entered = new CountDownLatch(1); + start( + ServerServiceDefinition.builder("review.Server") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + CountDownLatch cancelled = new CountDownLatch(1); + io.grpc.Context.current() + .addListener( + context -> cancelled.countDown(), + Runnable::run); + entered.countDown(); + try { + cancelled.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + adapter.getMaxConcurrentCalls(); + })) + .build()); + ClientCalls.futureUnaryCall( + channel.newCall(ECHO, CallOptions.DEFAULT), StringValue.of("block")); + assertTrue(entered.await(10, TimeUnit.SECONDS)); + adapter.stop(); + assertFalse(adapter.isStarted()); + assertTrue(http.isStarted()); + } + + @Test + void unaryResponseLargerThanTheQueueBudgetFitsWithADirectExecutor() throws Exception { + adapter.setExecutor(Runnable::run); + String large = "x".repeat(512 * 1024); + start( + ServerServiceDefinition.builder("review.Server") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + out.onNext(StringValue.of(large)); + out.onCompleted(); + })) + .build()); + assertEquals(large, call("large")); + } + @Test void completedResponseRetainsAdmissionUntilSentAndBorrowedExecutorSurvives() throws Exception { try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyRequest.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyRequest.java index 36d4236626..b250fc31f0 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyRequest.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyRequest.java @@ -112,33 +112,7 @@ class NettyRequest extends Request { setProtocol(Protocol.valueOf(call.getProtocol().getSchemeName(), call.getVersion())); setHostRef(hostRef(call.getProtocol(), headers)); - String uri = call.getRequestUri(); - if (uri == null || uri.isEmpty()) { - uri = "/"; - } else if (uri.startsWith("http://") || uri.startsWith("https://")) { - Reference absoluteRef = new Reference(uri); - if (absoluteRef.getAuthority() != null - && getHostRef() != null - && absoluteRef.getAuthority().equals(getHostRef().getAuthority())) { - String path = absoluteRef.getPath(); - String query = absoluteRef.getQuery(); - if (path == null || path.isEmpty()) { - path = "/"; - } else if (!path.startsWith("/")) { - path = "/" + path; - } - uri = path; - if (query != null) { - uri += "?" + query; - } - } - } else if (!uri.startsWith("/")) { - uri = "/" + uri; - } - - Reference resourceRef = new Reference(getHostRef().toString() + uri); - resourceRef.setBaseRef(new Reference(getHostRef().toString())); - setResourceRef(resourceRef); + setResourceRef(resourceRef(call.getRequestUri(), getHostRef())); setOriginalRef(ReferenceUtils.getOriginalRef(getResourceRef(), headers)); String dateHeader = headers.getFirstValue(HeaderConstants.HEADER_DATE, true); @@ -151,6 +125,55 @@ public Series

getTrailers() { return call.getRequestTrailers(); } + /** + * Returns the resource reference of a request target, relative to the host reference. An + * absolute target for the same authority is reduced to its path and query; another absolute + * target is kept as is. + */ + private static Reference resourceRef(String uri, Reference hostRef) { + String base = hostRef.toString(); + String target = uri; + if (target == null || target.isEmpty()) { + target = "/"; + } else if (target.regionMatches(true, 0, "http://", 0, 7) + || target.regionMatches(true, 0, "https://", 0, 8)) { + Reference absoluteRef = new Reference(target); + if (!sameAuthority(absoluteRef, hostRef)) { + absoluteRef.setBaseRef(new Reference(base)); + return absoluteRef; + } + String path = absoluteRef.getPath(); + target = (path == null || path.isEmpty()) ? "/" : path; + if (!target.startsWith("/")) { + target = "/" + target; + } + if (absoluteRef.getQuery() != null) { + target += "?" + absoluteRef.getQuery(); + } + } else if (!target.startsWith("/")) { + target = "/" + target; + } + Reference result = new Reference(base + target); + result.setBaseRef(new Reference(base)); + return result; + } + + /** Compares the scheme, host (ignoring case) and effective port of two references. */ + private static boolean sameAuthority(Reference target, Reference hostRef) { + String host = target.getHostDomain(); + return host != null + && hostRef.getHostDomain() != null + && target.getSchemeProtocol() != null + && target.getSchemeProtocol().equals(hostRef.getSchemeProtocol()) + && host.equalsIgnoreCase(hostRef.getHostDomain()) + && effectivePort(target) == effectivePort(hostRef); + } + + private static int effectivePort(Reference reference) { + int port = reference.getHostPort(); + return port == -1 ? reference.getSchemeProtocol().getDefaultPort() : port; + } + /** Returns the host reference built from the "Host" header. */ private static String hostRef(Protocol protocol, Series
headers) { StringBuilder sb = new StringBuilder(); diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java index ab9261d00f..f8a3adbf8c 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java @@ -54,6 +54,7 @@ import javax.net.ssl.SSLSession; import org.restlet.Context; import org.restlet.Response; +import org.restlet.Uniform; import org.restlet.data.Header; import org.restlet.data.MediaType; import org.restlet.data.Method; @@ -336,7 +337,8 @@ Protocol getProtocol() { /** The protocol version as reported to the application (for example "1.1" or "2.0"). */ String getVersion() { - String version = kind.version(); + String version = + kind == StreamKind.HTTP1 ? request.protocolVersion().text() : kind.version(); int slash = version.lastIndexOf('/'); return (slash >= 0 && slash + 1 < version.length()) ? version.substring(slash + 1) @@ -441,10 +443,29 @@ void commit(Response response) { } catch (IOException | RuntimeException e) { handleException(response, e); } finally { - complete(); - if (response.getOnSent() != null) { - response.getOnSent().handle(response.getRequest(), response); - } + notifySent(response, complete()); + } + } + + /** + * Invokes the "onSent" callback of the response once its terminal write is done, possibly on + * the event loop. If the write failed, the response status is first set to {@link + * Status#CONNECTOR_ERROR_COMMUNICATION} with the cause, since the response wasn't delivered. + * + * @param response The response. + * @param delivery The terminal write. + */ + private static void notifySent(Response response, ChannelFuture delivery) { + Uniform onSent = response.getOnSent(); + if (onSent != null) { + delivery.addListener( + f -> { + if (!f.isSuccess()) { + response.setStatus( + new Status(Status.CONNECTOR_ERROR_COMMUNICATION, f.cause())); + } + onSent.handle(response.getRequest(), response); + }); } } @@ -752,10 +773,14 @@ private boolean isRequestBodyDiscardable() { return length >= 0 && length - requestBody.getReceivedBytes() <= MAX_DISCARDED_BYTES; } - /** Completes the exchange: ends the response and frees the connection for the next request. */ - void complete() { + /** + * Completes the exchange: ends the response and frees the connection for the next request. + * + * @return The terminal write of the response, failed if the connection is closed. + */ + ChannelFuture complete() { if (completed) { - return; + return channel.newSucceededFuture(); } completed = true; @@ -773,13 +798,13 @@ void complete() { if (!channel.isActive()) { requestBody.close(); - return; + return channel.newFailedFuture(new ClosedChannelException()); } if (responseEndedWithHead) { trackDelivery(responseEndFuture); channel.flush(); requestBody.close(); - return; + return responseEndFuture; } // The response head announced whether the connection persists. If so, the unread @@ -802,6 +827,7 @@ void complete() { if (!kind.isStream()) { completeHttp1(future, keepAlive); } + return future; } finally { if (eventStreamPermit) { channel.attr(NettyServerHandler.EVENT_STREAM_ACTIVE).set(false); @@ -875,12 +901,13 @@ private static void copyHeaders( } /** - * Aborts the exchange by closing the connection. + * Aborts the exchange: resets its stream for HTTP/2 and HTTP/3, leaving the other streams of + * the connection, or closes the HTTP/1.1 connection. * * @return True. */ boolean abort() { - connection.close(); + channel.close(); return true; } diff --git a/org.restlet/src/test/java/org/restlet/engine/netty/NettyRequestMappingTestCase.java b/org.restlet/src/test/java/org/restlet/engine/netty/NettyRequestMappingTestCase.java index aa2b06b89b..d220b936c4 100644 --- a/org.restlet/src/test/java/org/restlet/engine/netty/NettyRequestMappingTestCase.java +++ b/org.restlet/src/test/java/org/restlet/engine/netty/NettyRequestMappingTestCase.java @@ -63,6 +63,52 @@ void serviceRoutesMatchOriginAndSameHostAbsoluteTargetsForEveryHttpVersion() { } } + private static org.restlet.Request map( + StreamKind kind, HttpVersion version, String target, String host) { + EmbeddedChannel channel = new EmbeddedChannel(); + try { + var head = new DefaultHttpRequest(version, HttpMethod.GET, target); + head.headers().set("host", host); + return new NettyServerCall( + null, channel, kind, head, new ChannelInputStream(channel, 1000), false) + .toRequest(new Context()); + } finally { + channel.finishAndReleaseAll(); + } + } + + @Test + void absoluteTargetsWithAnEquivalentAuthorityMapToTheirPath() { + for (String target : + new String[] { + "http://example.com:80/rest?q=1", + "http://EXAMPLE.com/rest?q=1", + "HTTP://example.com/rest?q=1" + }) { + var request = map(StreamKind.HTTP2, HttpVersion.HTTP_1_1, target, "example.com:80"); + assertEquals("http://example.com/rest?q=1", request.getResourceRef().toString()); + assertEquals("/rest", request.getResourceRef().getRemainingPart(false, false)); + } + } + + @Test + void absoluteTargetsForAnotherAuthorityAreKept() { + var request = + map(StreamKind.HTTP1, HttpVersion.HTTP_1_1, "http://other.test:8080/rest", "a"); + assertEquals("http://other.test:8080/rest", request.getResourceRef().toString()); + assertEquals("http://a", request.getResourceRef().getBaseRef().toString()); + } + + @Test + void http1ReportsTheVersionOfItsRequestLine() { + assertEquals( + "1.0", + map(StreamKind.HTTP1, HttpVersion.HTTP_1_0, "/", "a").getProtocol().getVersion()); + assertEquals( + "2.0", + map(StreamKind.HTTP2, HttpVersion.HTTP_1_1, "/", "a").getProtocol().getVersion()); + } + @Test void emptyTargetMapsToRoot() { EmbeddedChannel channel = new EmbeddedChannel(); From 3b8b324a1b4d119673490b43387dbece151626bb Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:22:55 -0500 Subject: [PATCH 4/8] Keep the gRPC server adapter's stop synchronized like its parent Restlet.stop() is synchronized, so the override is too. The adapter's configuration and lifecycle state move to a private lock, which stop() holds only to read that state: callbacks finishing during its waits can still use the adapter's accessors. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- .../restlet/ext/grpc/GrpcServerAdapter.java | 192 +++++++++++------- 1 file changed, 113 insertions(+), 79 deletions(-) diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java index e082f9c7bd..14faa55a45 100644 --- a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java @@ -43,6 +43,13 @@ public final class GrpcServerAdapter extends Restlet { private final List services = new CopyOnWriteArrayList<>(); private final List interceptors = new CopyOnWriteArrayList<>(); + + /** + * Guards the configuration and lifecycle state. The adapter's own monitor, held by {@link + * #start()} and {@link #stop()} like any Restlet's, is taken first. + */ + private final Object lock = new Object(); + private int maxMessageBytes = 1024 * 1024; private int maxQueuedBytes = 256 * 1024; private int maxConcurrentCalls = 128; @@ -70,71 +77,95 @@ public List getInterceptors() { return interceptors; } - public synchronized void setMaxMessageBytes(int value) { - configurable(); - if (value <= 0 || value > Integer.MAX_VALUE - 6) { - throw new IllegalArgumentException("A positive finite message budget is required"); + public void setMaxMessageBytes(int value) { + synchronized (lock) { + configurable(); + if (value <= 0 || value > Integer.MAX_VALUE - 6) { + throw new IllegalArgumentException("A positive finite message budget is required"); + } + maxMessageBytes = value; } - maxMessageBytes = value; } - public synchronized int getMaxMessageBytes() { - return maxMessageBytes; + public int getMaxMessageBytes() { + synchronized (lock) { + return maxMessageBytes; + } } - public synchronized void setMaxQueuedBytes(int value) { - configurable(); - if (value < 16384) { - throw new IllegalArgumentException("Queue budget must be at least 16384 bytes"); + public void setMaxQueuedBytes(int value) { + synchronized (lock) { + configurable(); + if (value < 16384) { + throw new IllegalArgumentException("Queue budget must be at least 16384 bytes"); + } + maxQueuedBytes = value; } - maxQueuedBytes = value; } - public synchronized int getMaxQueuedBytes() { - return maxQueuedBytes; + public int getMaxQueuedBytes() { + synchronized (lock) { + return maxQueuedBytes; + } } - public synchronized void setMaxConcurrentCalls(int value) { - configurable(); - if (value <= 0) { - throw new IllegalArgumentException("A positive call budget is required"); + public void setMaxConcurrentCalls(int value) { + synchronized (lock) { + configurable(); + if (value <= 0) { + throw new IllegalArgumentException("A positive call budget is required"); + } + maxConcurrentCalls = value; } - maxConcurrentCalls = value; } - public synchronized int getMaxConcurrentCalls() { - return maxConcurrentCalls; + public int getMaxConcurrentCalls() { + synchronized (lock) { + return maxConcurrentCalls; + } } - public synchronized void setMaxCallDuration(Duration value) { - configurable(); - maxCallDuration = positive(value); + public void setMaxCallDuration(Duration value) { + synchronized (lock) { + configurable(); + maxCallDuration = positive(value); + } } - public synchronized Duration getMaxCallDuration() { - return maxCallDuration; + public Duration getMaxCallDuration() { + synchronized (lock) { + return maxCallDuration; + } } - public synchronized void setShutdownTimeout(Duration value) { - configurable(); - shutdownTimeout = positive(value); + public void setShutdownTimeout(Duration value) { + synchronized (lock) { + configurable(); + shutdownTimeout = positive(value); + } } - public synchronized Duration getShutdownTimeout() { - return shutdownTimeout; + public Duration getShutdownTimeout() { + synchronized (lock) { + return shutdownTimeout; + } } - public synchronized void setExecutor(Executor value) { - configurable(); - executor = java.util.Objects.requireNonNull(value, "executor"); + public void setExecutor(Executor value) { + synchronized (lock) { + configurable(); + executor = java.util.Objects.requireNonNull(value, "executor"); + } } /** * Returns the callback executor: the supplied one, the adapter-owned one once started, or null * before start when none was supplied. */ - public synchronized Executor getExecutor() { - return executor; + public Executor getExecutor() { + synchronized (lock) { + return executor; + } } private static Duration positive(Duration value) { @@ -144,6 +175,7 @@ private static Duration positive(Duration value) { return Duration.ofNanos(value.toNanos()); } + /** Requires the configuration to be still open; called with {@link #lock} held. */ private void configurable() { if (configured) { throw new IllegalStateException("Server adapter is single-use; configuration is fixed"); @@ -162,54 +194,56 @@ public void attachTo(Router router) { @Override public synchronized void start() throws Exception { - if (isStarted()) { - return; - } - configurable(); - for (var service : List.copyOf(services)) { - for (var method : service.getMethods()) { - var type = method.getMethodDescriptor().getType(); - if (type != io.grpc.MethodDescriptor.MethodType.UNARY - && type != io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING) { - throw new UnsupportedOperationException( - "Only unary and server-streaming methods are supported"); + synchronized (lock) { + if (isStarted()) { + return; + } + configurable(); + for (var service : List.copyOf(services)) { + for (var method : service.getMethods()) { + var type = method.getMethodDescriptor().getType(); + if (type != io.grpc.MethodDescriptor.MethodType.UNARY + && type != io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING) { + throw new UnsupportedOperationException( + "Only unary and server-streaming methods are supported"); + } } } - } - configured = true; - host = - new RestletServerHost( - maxMessageBytes, maxQueuedBytes, maxConcurrentCalls, maxCallDuration); - if (executor == null) { - ownedExecutor = Executors.newVirtualThreadPerTaskExecutor(); - executor = ownedExecutor; - } - var builder = - new ServerImplBuilder( - (tracers, recorder) -> { - host.setTracers(tracers); - return host; - }) - .executor(executor) - .maxInboundMessageSize(maxMessageBytes); - List.copyOf(services).forEach(builder::addService); - List.copyOf(interceptors).forEach(builder::intercept); - try { - sdk = builder.build().start(); - super.start(); - } catch (IOException | RuntimeException e) { - host.close(); - if (ownedExecutor != null) { - ownedExecutor.shutdownNow(); + configured = true; + host = + new RestletServerHost( + maxMessageBytes, maxQueuedBytes, maxConcurrentCalls, maxCallDuration); + if (executor == null) { + ownedExecutor = Executors.newVirtualThreadPerTaskExecutor(); + executor = ownedExecutor; + } + var builder = + new ServerImplBuilder( + (tracers, recorder) -> { + host.setTracers(tracers); + return host; + }) + .executor(executor) + .maxInboundMessageSize(maxMessageBytes); + List.copyOf(services).forEach(builder::addService); + List.copyOf(interceptors).forEach(builder::intercept); + try { + sdk = builder.build().start(); + super.start(); + } catch (IOException | RuntimeException e) { + host.close(); + if (ownedExecutor != null) { + ownedExecutor.shutdownNow(); + } + throw e; } - throw e; } } @Override public void handle(Request request, Response response) { RestletServerHost current; - synchronized (this) { + synchronized (lock) { current = isStarted() ? host : null; } if (current == null) { @@ -220,16 +254,16 @@ public void handle(Request request, Response response) { } /** - * Stops the adapter. The waits happen outside the adapter's monitor, so callbacks finishing - * meanwhile may still use its synchronized methods. + * Stops the adapter. The waits only hold the adapter's monitor, not {@link #lock}, so callbacks + * finishing meanwhile may still use its accessors. */ @Override - public void stop() throws Exception { + public synchronized void stop() throws Exception { io.grpc.Server server; RestletServerHost stopping; ExecutorService owned; Duration timeout; - synchronized (this) { + synchronized (lock) { if (sdk == null) { return; } From fb150cf178ed0718a28ff4ecfeae6a2a355e43d2 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:16:45 -0500 Subject: [PATCH 5/8] Keep gRPC callbacks off the HTTP dispatch and onSent off the event loop Core: onSent runs in the committing thread when the terminal write is already done, else in a virtual thread, never on the event loop. gRPC: each call's SDK notifications, and application callbacks with a direct executor, run serialized in virtual threads. The deadline and disconnect handler are installed before the SDK sees the stream, and a cancellation ends the response at once, so a blocked callback can't hold the HTTP dispatch or its slot. The method comes from the raw routed path, which must be exactly /service/method without dot segments. Media type parameters are ignored. Cancelled deadlines leave the timer queue. The shutdown timeout also bounds the cancellation phase of stop(). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- .../restlet/ext/grpc/GrpcServerAdapter.java | 20 ++- .../ext/grpc/internal/RestletServerHost.java | 148 ++++++++++------- .../ext/grpc/GrpcServerAdapterTestCase.java | 150 ++++++++++++++++++ .../restlet/engine/netty/NettyServerCall.java | 33 ++-- 4 files changed, 276 insertions(+), 75 deletions(-) diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java index 14faa55a45..1ef695af79 100644 --- a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java @@ -273,13 +273,23 @@ public synchronized void stop() throws Exception { timeout = shutdownTimeout; sdk = null; } - server.shutdownNow(); - if (stopping != null) { - stopping.close(); - } long started = System.nanoTime(); + // Cancelling calls may run listeners, so the timeout bounds it like the waits after it. + Thread cancelling = + Thread.ofVirtual() + .name("restlet-grpc-server-stop") + .start( + () -> { + server.shutdownNow(); + if (stopping != null) { + stopping.close(); + } + }); try { - if (!server.awaitTermination(timeout.toNanos(), TimeUnit.NANOSECONDS)) { + if (!cancelling.join(timeout) + || !server.awaitTermination( + Math.max(0, timeout.toNanos() - (System.nanoTime() - started)), + TimeUnit.NANOSECONDS)) { throw new IllegalStateException("gRPC server shutdown timed out"); } if (owned != null) { diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java index 797a007fbb..acaddb0f30 100644 --- a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java @@ -43,9 +43,9 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -70,9 +70,9 @@ public final class RestletServerHost implements InternalServer, AutoCloseable { private final Duration duration; private final Semaphore admission; private final Set calls = ConcurrentHashMap.newKeySet(); - private final ScheduledExecutorService timer = - Executors.newSingleThreadScheduledExecutor( - Thread.ofPlatform().daemon().name("restlet-grpc-server-timer").factory()); + private final ScheduledThreadPoolExecutor timer = + new ScheduledThreadPoolExecutor( + 1, Thread.ofPlatform().daemon().name("restlet-grpc-server-timer").factory()); private volatile ServerListener listener; private List tracers = List.of(); private boolean closed; @@ -82,6 +82,8 @@ public RestletServerHost(int maxBytes, int queueBytes, int maxCalls, Duration du this.queueBytes = queueBytes; this.duration = duration; admission = new Semaphore(maxCalls); + // Each completed call cancels its deadline, which must not stay queued until it expires. + timer.setRemoveOnCancelPolicy(true); } public void setTracers(List value) { @@ -138,14 +140,15 @@ public void handle(Request request, Response response) { response.setStatus(org.restlet.data.Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); return; } - String type = - request.getEntity() == null || request.getEntity().getMediaType() == null - ? "" - : request.getEntity().getMediaType().getName(); - if (!"application/grpc".equals(type) && !type.startsWith("application/grpc+")) { + if (!isGrpc(request.getEntity() == null ? null : request.getEntity().getMediaType())) { response.setStatus(org.restlet.data.Status.CLIENT_ERROR_UNSUPPORTED_MEDIA_TYPE); return; } + String method = method(request); + if (method == null) { + response.setStatus(org.restlet.data.Status.CLIENT_ERROR_NOT_FOUND); + return; + } // Over HTTP/1.1, Netty decides on trailers before the entity can add grpc-status. Protocol protocol = request.getProtocol(); if (protocol == null || !"2.0".equals(protocol.getVersion())) { @@ -162,7 +165,7 @@ public void handle(Request request, Response response) { response.setStatus(org.restlet.data.Status.SERVER_ERROR_SERVICE_UNAVAILABLE); return; } - call = new Call(request, response); + call = new Call(request, response, method); calls.add(call); call.transportListener = listener.transportCreated(call); } @@ -177,10 +180,52 @@ public void handle(Request request, Response response) { } } + /** Accepts application/grpc and application/grpc+*, whatever their parameters. */ + private static boolean isGrpc(MediaType type) { + return type != null + && "application".equalsIgnoreCase(type.getMainType()) + && type.getSubType() != null + && (type.getSubType().equalsIgnoreCase("grpc") + || type.getSubType() + .toLowerCase(java.util.Locale.ROOT) + .startsWith("grpc+")); + } + + /** + * Returns the gRPC method of the raw path that the router matched, or null unless it is exactly + * "/service/method" without dot segments: a path normalized differently than routed could + * otherwise reach a method outside its route. + */ + private static String method(Request request) { + String path = request.getResourceRef() == null ? null : request.getResourceRef().getPath(); + if (path == null || !path.startsWith("/")) { + return null; + } + String[] segments = path.substring(1).split("/", -1); + if (segments.length != 2) { + return null; + } + for (String segment : segments) { + if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) { + return null; + } + } + return path.substring(1); + } + private final class Call implements ServerTransport { private final Request request; private final Response response; - private final SerializingExecutor serial = new SerializingExecutor(Runnable::run); + private final String method; + + /** + * Runs SDK notifications, and application callbacks with a direct executor, in virtual + * threads: they never block the HTTP dispatch, nor its cancellation. + */ + private final SerializingExecutor serial = + new SerializingExecutor( + task -> Thread.ofVirtual().name("restlet-grpc-server-call").start(task)); + private final InternalLogId id = InternalLogId.allocate(Call.class, "Restlet logical call"); private final ArrayDeque queue = new ArrayDeque<>(); private final CountDownLatch head = new CountDownLatch(1); @@ -192,7 +237,7 @@ private final class Call implements ServerTransport { private ServerTransportListener transportListener; private SdkStream stream; - private ScheduledFuture deadline; + private volatile ScheduledFuture deadline; private Metadata trailers; private int queued; @@ -204,29 +249,16 @@ private final class Call implements ServerTransport { /** Whether the end of the call was reported to the SDK; confined to {@link #serial}. */ private boolean reported; - private Call(Request request, Response response) { + private Call(Request request, Response response, String method) { this.request = request; this.response = response; + this.method = method; } private void begin() throws IOException, InterruptedException { // Installed first, so that a call failing below still sends its grpc-status. response.setEntity(responseEntity()); Metadata headers = fromHttp(request.getHeaders()); - String path = - request.getOriginalRef() == null - ? request.getResourceRef().getPath() - : request.getOriginalRef().getPath(); - if (path == null || path.isEmpty()) { - path = request.getResourceRef() == null ? "/" : request.getResourceRef().getPath(); - } - if (path == null || path.isEmpty()) { - path = "/"; - } - if (!path.startsWith("/")) { - path = "/" + path; - } - String method = path.substring(1); response.setOnSent( (sentRequest, sentResponse) -> { // An undelivered response leaves the client without the call's status. @@ -239,6 +271,21 @@ private void begin() throws IOException, InterruptedException { serial.execute(() -> finish(null)); } }); + // Bounded before the SDK sees the stream, which may run application code. + synchronized (RestletServerHost.this) { + if (!closed) { + deadline = + timer.schedule( + () -> + cancel( + Status.DEADLINE_EXCEEDED.withDescription( + "Call duration exceeded")), + duration.toNanos(), + TimeUnit.NANOSECONDS); + } + } + request.getDisconnectSignal() + .thenAccept(cause -> cancel(Status.CANCELLED.withCause(cause))); serial.execute( () -> { if (reported) { @@ -256,22 +303,7 @@ private void begin() throws IOException, InterruptedException { tracers, method, headers)); transportListener.streamCreated(stream, method, headers); stream.state.onStreamAllocated(); - synchronized (RestletServerHost.this) { - if (!closed) { - deadline = - timer.schedule( - () -> - cancel( - Status.DEADLINE_EXCEEDED - .withDescription( - "Call duration exceeded")), - duration.toNanos(), - TimeUnit.NANOSECONDS); - } - } }); - request.getDisconnectSignal() - .thenAccept(cause -> cancel(Status.CANCELLED.withCause(cause))); byte[] bytes; try (var input = request.getEntity().getStream()) { bytes = input == null ? new byte[0] : read(input); @@ -388,23 +420,21 @@ private void cancel(Status status) { return; } abortIfStuck(); - serial.execute( - () -> { - synchronized (queue) { - queue.clear(); - queued = 0; - open = 0; - terminal = true; - trailers = new Metadata(); - trailers.put(InternalStatus.CODE_KEY, status); - if (status.getDescription() != null) { - trailers.put(InternalStatus.MESSAGE_KEY, status.getDescription()); - } - head.countDown(); - queue.notifyAll(); - } - finish(status); - }); + // The response ends at once; only the SDK notification waits for earlier callbacks. + synchronized (queue) { + queue.clear(); + queued = 0; + open = 0; + terminal = true; + trailers = new Metadata(); + trailers.put(InternalStatus.CODE_KEY, status); + if (status.getDescription() != null) { + trailers.put(InternalStatus.MESSAGE_KEY, status.getDescription()); + } + head.countDown(); + queue.notifyAll(); + } + serial.execute(() -> finish(status)); } /** diff --git a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java index 9b676445fb..d8412507ea 100644 --- a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java +++ b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java @@ -436,6 +436,156 @@ private static String trailersAfterWriting(Response response) throws IOException return response.getTrailers().getFirstValue("grpc-status"); } + private static ServerServiceDefinition counting(AtomicInteger calls) { + return ServerServiceDefinition.builder("review.Server") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + calls.incrementAndGet(); + out.onNext(request); + out.onCompleted(); + })) + .build(); + } + + @Test + void dotSegmentsCannotReachAMethodOutsideTheirRoute() throws Exception { + AtomicInteger calls = new AtomicInteger(); + adapter.getServices().add(counting(calls)); + adapter.start(); + Request request = grpcRequest(ECHO, new ByteArrayInputStream(frame("x")), () -> {}); + request.setResourceRef("http://localhost/public.Service/../review.Server/Echo"); + Response response = new Response(request); + adapter.handle(request, response); + assertEquals(org.restlet.data.Status.CLIENT_ERROR_NOT_FOUND, response.getStatus()); + assertEquals(0, calls.get()); + } + + @Test + void grpcMediaTypeParametersAreAccepted() throws Exception { + AtomicInteger calls = new AtomicInteger(); + adapter.getServices().add(counting(calls)); + adapter.start(); + Request request = grpcRequest(ECHO, new ByteArrayInputStream(frame("x")), () -> {}); + request.getEntity().setMediaType(MediaType.valueOf("application/grpc; version=1")); + Response response = new Response(request); + adapter.handle(request, response); + assertEquals("0", trailersAfterWriting(response)); + assertEquals(1, calls.get()); + } + + private void assertBlockedDirectCallbackEndsAtTheDeadline(boolean inInterceptor) + throws Exception { + CountDownLatch release = new CountDownLatch(1); + CountDownLatch entered = new CountDownLatch(1); + Runnable block = + () -> { + entered.countDown(); + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }; + adapter.setExecutor(Runnable::run); + adapter.setMaxCallDuration(Duration.ofMillis(300)); + adapter.setMaxConcurrentCalls(1); + if (inInterceptor) { + adapter.getServices().add(echo()); + adapter.getInterceptors() + .add( + new io.grpc.ServerInterceptor() { + @Override + public io.grpc.ServerCall.Listener interceptCall( + io.grpc.ServerCall call, + Metadata headers, + io.grpc.ServerCallHandler next) { + block.run(); + return next.startCall(call, headers); + } + }); + } else { + adapter.getServices() + .add( + ServerServiceDefinition.builder("review.Server") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver + out) -> block.run())) + .build()); + } + adapter.start(); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + Response response = + executor.submit(() -> dispatch(ECHO, "blocked")).get(10, TimeUnit.SECONDS); + assertTrue(entered.await(10, TimeUnit.SECONDS)); + assertEquals( + String.valueOf(Status.Code.DEADLINE_EXCEEDED.value()), + trailersAfterWriting(response)); + assertTrue( + org.restlet.data.Status.SUCCESS_OK.equals( + dispatch(ECHO, "admitted").getStatus())); + } finally { + release.countDown(); + } + } + + @Test + void blockingInterceptorWithADirectExecutorEndsAtTheDeadline() throws Exception { + assertBlockedDirectCallbackEndsAtTheDeadline(true); + } + + @Test + void blockingHandlerWithADirectExecutorEndsAtTheDeadline() throws Exception { + assertBlockedDirectCallbackEndsAtTheDeadline(false); + } + + @Test + void stopReportsTheTimeoutWhenACancellationListenerBlocks() throws Exception { + adapter.setShutdownTimeout(Duration.ofMillis(300)); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + start( + ServerServiceDefinition.builder("review.Server") + .addMethod( + ECHO, + ServerCalls.asyncUnaryCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + io.grpc.Context.current() + .addListener( + context -> { + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread() + .interrupt(); + } + }, + Runnable::run); + entered.countDown(); + })) + .build()); + ClientCalls.futureUnaryCall( + channel.newCall(ECHO, CallOptions.DEFAULT), StringValue.of("block")); + assertTrue(entered.await(10, TimeUnit.SECONDS)); + try { + long started = System.nanoTime(); + assertTrue( + assertThrows(IllegalStateException.class, adapter::stop) + .getMessage() + .endsWith("shutdown timed out")); + assertTrue(System.nanoTime() - started < TimeUnit.SECONDS.toNanos(5)); + assertFalse(adapter.isStarted()); + } finally { + release.countDown(); + } + } + @Test void http1RequestsAreRejectedBeforeDispatch() throws Exception { AtomicInteger calls = new AtomicInteger(); diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java index f8a3adbf8c..2d2f257c6f 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java @@ -448,25 +448,36 @@ void commit(Response response) { } /** - * Invokes the "onSent" callback of the response once its terminal write is done, possibly on - * the event loop. If the write failed, the response status is first set to {@link + * Invokes the "onSent" callback of the response once its terminal write is done, never on the + * event loop: in the calling thread if the write is already done, else in a virtual thread. If + * the write failed, the response status is first set to {@link * Status#CONNECTOR_ERROR_COMMUNICATION} with the cause, since the response wasn't delivered. * * @param response The response. * @param delivery The terminal write. */ - private static void notifySent(Response response, ChannelFuture delivery) { + private void notifySent(Response response, ChannelFuture delivery) { Uniform onSent = response.getOnSent(); - if (onSent != null) { + if (onSent == null) { + return; + } + if (delivery.isDone() && !channel.eventLoop().inEventLoop()) { + sent(onSent, response, delivery); + } else { delivery.addListener( - f -> { - if (!f.isSuccess()) { - response.setStatus( - new Status(Status.CONNECTOR_ERROR_COMMUNICATION, f.cause())); - } - onSent.handle(response.getRequest(), response); - }); + f -> + Thread.ofVirtual() + .name("restlet-response-sent") + .start(() -> sent(onSent, response, f))); + } + } + + private static void sent( + Uniform onSent, Response response, io.netty.util.concurrent.Future delivery) { + if (!delivery.isSuccess()) { + response.setStatus(new Status(Status.CONNECTOR_ERROR_COMMUNICATION, delivery.cause())); } + onSent.handle(response.getRequest(), response); } /** From 346144d396a900e48b79be7f5c000449e883774f Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:00:05 -0500 Subject: [PATCH 6/8] Bound gRPC callbacks after cancellation and claim calls on delivery Core: a response's delivery signal completes on the connector's I/O thread once the terminal write is done, with null or the failure, before onSent runs off that thread. HeaderUtils tells whether a header is standard. gRPC: the call is claimed and its deadline cancelled as soon as the response is delivered, and claiming cancels a deadline installed meanwhile. An outermost interceptor holds a callback slot until the SDK reports the call's end to the application, bounding callbacks still running after cancellation to as many again as admitted calls. Initial metadata with standard HTTP names goes through the Restlet properties that the connector writes. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- .../restlet/ext/grpc/GrpcServerAdapter.java | 2 + .../ext/grpc/internal/RestletServerHost.java | 155 ++++++++++++++++-- .../ext/grpc/GrpcServerAdapterTestCase.java | 106 ++++++++++-- .../src/main/java/org/restlet/Response.java | 28 ++++ .../restlet/engine/header/HeaderUtils.java | 11 ++ .../restlet/engine/netty/NettyServerCall.java | 10 +- 6 files changed, 280 insertions(+), 32 deletions(-) diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java index 1ef695af79..8f698ed6dd 100644 --- a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java @@ -227,6 +227,8 @@ public synchronized void start() throws Exception { .maxInboundMessageSize(maxMessageBytes); List.copyOf(services).forEach(builder::addService); List.copyOf(interceptors).forEach(builder::intercept); + // Added last, so outermost: it holds a call's callback slot around all of them. + builder.intercept(host.callbackBudget()); try { sdk = builder.build().start(); super.start(); diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java index acaddb0f30..40ed170d81 100644 --- a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java @@ -11,12 +11,16 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import io.grpc.Attributes; +import io.grpc.ForwardingServerCallListener; import io.grpc.InternalChannelz; import io.grpc.InternalInstrumented; import io.grpc.InternalLogId; import io.grpc.InternalMetadata; import io.grpc.InternalStatus; import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; import io.grpc.ServerStreamTracer; import io.grpc.Status; import io.grpc.internal.AbstractServerStream; @@ -49,6 +53,7 @@ import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import org.restlet.Request; import org.restlet.Response; @@ -56,6 +61,7 @@ import org.restlet.data.MediaType; import org.restlet.data.Method; import org.restlet.data.Protocol; +import org.restlet.engine.header.HeaderUtils; import org.restlet.representation.OutputRepresentation; import org.restlet.util.Series; @@ -65,10 +71,29 @@ public final class RestletServerHost implements InternalServer, AutoCloseable { /** How long a cancelled call may stay blocked in one network read or write before a reset. */ private static final Duration STUCK_IO = Duration.ofMillis(200); + /** The logical call of an SDK stream, among its attributes. */ + private static final Attributes.Key CALL = Attributes.Key.create("restlet-grpc-call"); + + /** Metadata that would break the gRPC framing if mapped onto the HTTP response. */ + private static final Set FRAMING_HEADERS = + Set.of( + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "connection"); + private final int maxBytes; private final int queueBytes; private final Duration duration; private final Semaphore admission; + + /** + * Calls whose callbacks may still run. Cancelled or completed calls leave admission at once but + * keep one of these until their callbacks exit, up to as many again as admitted calls. + */ + private final Semaphore callbacks; + private final Set calls = ConcurrentHashMap.newKeySet(); private final ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor( @@ -82,6 +107,7 @@ public RestletServerHost(int maxBytes, int queueBytes, int maxCalls, Duration du this.queueBytes = queueBytes; this.duration = duration; admission = new Semaphore(maxCalls); + callbacks = new Semaphore(maxCalls > Integer.MAX_VALUE / 2 ? maxCalls : maxCalls * 2); // Each completed call cancels its deadline, which must not stay queued until it expires. timer.setRemoveOnCancelPolicy(true); } @@ -165,6 +191,11 @@ public void handle(Request request, Response response) { response.setStatus(org.restlet.data.Status.SERVER_ERROR_SERVICE_UNAVAILABLE); return; } + if (!callbacks.tryAcquire()) { + admission.release(); + response.setStatus(org.restlet.data.Status.SERVER_ERROR_SERVICE_UNAVAILABLE); + return; + } call = new Call(request, response, method); calls.add(call); call.transportListener = listener.transportCreated(call); @@ -180,6 +211,50 @@ public void handle(Request request, Response response) { } } + /** + * Returns the interceptor holding a call's callback slot until the SDK reports its end to the + * application, after its earlier callbacks exited. It must be the outermost interceptor. + */ + public ServerInterceptor callbackBudget() { + return new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + Call owner = call.getAttributes().get(CALL); + if (owner == null || !owner.callbacksStarted()) { + return next.startCall(call, headers); + } + ServerCall.Listener listener; + try { + listener = next.startCall(call, headers); + } catch (RuntimeException | Error e) { + owner.callbacksEnded(); + throw e; + } + return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>( + listener) { + @Override + public void onComplete() { + try { + super.onComplete(); + } finally { + owner.callbacksEnded(); + } + } + + @Override + public void onCancel() { + try { + super.onCancel(); + } finally { + owner.callbacksEnded(); + } + } + }; + } + }; + } + /** Accepts application/grpc and application/grpc+*, whatever their parameters. */ private static boolean isGrpc(MediaType type) { return type != null @@ -232,6 +307,9 @@ private final class Call implements ServerTransport { private final AtomicBoolean finished = new AtomicBoolean(); private final AtomicLong operations = new AtomicLong(); + /** 0 before the application callbacks, 1 while they may run, 2 once released. */ + private final AtomicInteger callbackState = new AtomicInteger(); + /** The network read or write in progress, 0 if none. */ private final AtomicLong blocked = new AtomicLong(); @@ -259,18 +337,20 @@ private void begin() throws IOException, InterruptedException { // Installed first, so that a call failing below still sends its grpc-status. response.setEntity(responseEntity()); Metadata headers = fromHttp(request.getHeaders()); - response.setOnSent( - (sentRequest, sentResponse) -> { - // An undelivered response leaves the client without the call's status. - if (sentResponse.getStatus().isConnectorError()) { - cancel( - Status.CANCELLED - .withDescription("gRPC response not delivered") - .withCause(sentResponse.getStatus().getThrowable())); - } else if (claim()) { - serial.execute(() -> finish(null)); - } - }); + // Runs on the connector's I/O thread as soon as the terminal write is done. + response.getDeliverySignal() + .thenAccept( + failure -> { + // An undelivered response leaves the client without the status. + if (failure != null) { + cancel( + Status.CANCELLED + .withDescription("gRPC response not delivered") + .withCause(failure)); + } else if (claim()) { + serial.execute(() -> finish(null)); + } + }); // Bounded before the SDK sees the stream, which may run application code. synchronized (RestletServerHost.this) { if (!closed) { @@ -282,6 +362,9 @@ private void begin() throws IOException, InterruptedException { "Call duration exceeded")), duration.toNanos(), TimeUnit.NANOSECONDS); + if (finished.get()) { + deadline.cancel(false); + } } } request.getDisconnectSignal() @@ -291,7 +374,10 @@ private void begin() throws IOException, InterruptedException { if (reported) { return; } - Attributes attributes = transportListener.transportReady(Attributes.EMPTY); + Attributes attributes = + transportListener.transportReady(Attributes.EMPTY).toBuilder() + .set(CALL, this) + .build(); stream = new SdkStream( this, @@ -450,12 +536,32 @@ private boolean claim() { } calls.remove(this); admission.release(); + ScheduledFuture timeout = deadline; + if (timeout != null) { + timeout.cancel(false); + } return true; } + /** Marks the start of the application callbacks; false if the call already ended. */ + private boolean callbacksStarted() { + return callbackState.compareAndSet(0, 1); + } + + /** Releases the callback slot once, after the application callbacks exited. */ + private void callbacksEnded() { + if (callbackState.getAndSet(2) != 2) { + callbacks.release(); + } + } + /** Reports the end of a claimed call to the SDK, in {@link #serial}. */ private void finish(Status failure) { reported = true; + // A call ended before the SDK started its application callbacks holds no callback. + if (callbackState.compareAndSet(0, 2)) { + callbacks.release(); + } if (deadline != null) { deadline.cancel(false); } @@ -540,7 +646,7 @@ protected Sink abstractServerStreamSink() { public void writeHeaders(Metadata headers, boolean flush) { synchronized (call.queue) { if (!call.terminal) { - toHttp(headers, call.response.getHeaders()); + toResponse(headers, call.response); call.head.countDown(); } } @@ -659,6 +765,27 @@ private static Metadata fromHttp(Series
headers) { TransportFrameUtil.toRawSerializedHeaders(values.toArray(byte[][]::new))); } + /** + * Maps initial metadata onto the response: standard HTTP names through the Restlet properties + * that the connector writes back, other names as extension headers. + */ + private static void toResponse(Metadata metadata, Response response) { + var extension = new Series
(Header.class); + var standard = new Series
(Header.class); + toHttp(metadata, extension); + for (Header header : List.copyOf(extension)) { + if (FRAMING_HEADERS.contains(header.getName())) { + extension.remove(header); + } else if (HeaderUtils.isStandardHeader(header.getName())) { + extension.remove(header); + standard.add(header); + } + } + response.getHeaders().addAll(extension); + HeaderUtils.copyResponseTransportHeaders(standard, response); + HeaderUtils.extractEntityHeaders(standard, response.getEntity()); + } + private static void toHttp(Metadata metadata, Series
headers) { if (metadata == null) { return; diff --git a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java index d8412507ea..1d3ad382fc 100644 --- a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java +++ b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java @@ -515,20 +515,37 @@ public io.grpc.ServerCall.Listener interceptCall( ServerCalls.asyncUnaryCall( (StringValue request, io.grpc.stub.StreamObserver - out) -> block.run())) + out) -> { + block.run(); + out.onNext(request); + out.onCompleted(); + })) .build()); } adapter.start(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { - Response response = - executor.submit(() -> dispatch(ECHO, "blocked")).get(10, TimeUnit.SECONDS); - assertTrue(entered.await(10, TimeUnit.SECONDS)); + // The call limit is 1, so blocked callbacks may hold up to 2 callback slots. + for (int i = 0; i < 2; i++) { + Response response = + executor.submit(() -> dispatch(ECHO, "blocked")).get(10, TimeUnit.SECONDS); + assertTrue(entered.await(10, TimeUnit.SECONDS)); + assertEquals( + String.valueOf(Status.Code.DEADLINE_EXCEEDED.value()), + trailersAfterWriting(response)); + } assertEquals( - String.valueOf(Status.Code.DEADLINE_EXCEEDED.value()), - trailersAfterWriting(response)); - assertTrue( - org.restlet.data.Status.SUCCESS_OK.equals( - dispatch(ECHO, "admitted").getStatus())); + org.restlet.data.Status.SERVER_ERROR_SERVICE_UNAVAILABLE, + dispatch(ECHO, "bounded").getStatus()); + release.countDown(); + long limit = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + Response admitted = dispatch(ECHO, "admitted"); + while (org.restlet.data.Status.SERVER_ERROR_SERVICE_UNAVAILABLE.equals( + admitted.getStatus()) + && System.nanoTime() < limit) { + Thread.onSpinWait(); + admitted = dispatch(ECHO, "admitted"); + } + assertEquals("0", trailersAfterWriting(admitted)); } finally { release.countDown(); } @@ -586,6 +603,67 @@ void stopReportsTheTimeoutWhenACancellationListenerBlocks() throws Exception { } } + @Test + void consecutiveCallsAtTheLimitAreAdmittedOnceDelivered() throws Exception { + adapter.setMaxConcurrentCalls(1); + start(echo()); + for (int i = 0; i < 50; i++) { + assertEquals("call-" + i, call("call-" + i)); + } + } + + @Test + void initialMetadataWithStandardHeaderNamesIsSent() throws Exception { + Metadata.Key cacheControl = + Metadata.Key.of("cache-control", Metadata.ASCII_STRING_MARSHALLER); + Metadata.Key etag = Metadata.Key.of("etag", Metadata.ASCII_STRING_MARSHALLER); + Metadata.Key custom = Metadata.Key.of("x-detail", Metadata.ASCII_STRING_MARSHALLER); + adapter.getInterceptors() + .add( + new io.grpc.ServerInterceptor() { + @Override + public io.grpc.ServerCall.Listener interceptCall( + io.grpc.ServerCall call, + Metadata headers, + io.grpc.ServerCallHandler next) { + return next.startCall( + new io.grpc.ForwardingServerCall + .SimpleForwardingServerCall<>(call) { + @Override + public void sendHeaders(Metadata metadata) { + metadata.put(cacheControl, "no-cache"); + metadata.put(cacheControl, "no-store"); + metadata.put(etag, "\"v1\""); + metadata.put(custom, "kept"); + super.sendHeaders(metadata); + } + }, + headers); + } + }); + start(echo()); + AtomicReference received = new AtomicReference<>(); + AtomicReference trailers = new AtomicReference<>(); + var intercepted = + io.grpc.ClientInterceptors.intercept( + channel, + io.grpc.stub.MetadataUtils.newCaptureMetadataInterceptor( + received, trailers)); + assertEquals( + "meta", + ClientCalls.blockingUnaryCall( + intercepted, + ECHO, + CallOptions.DEFAULT.withDeadlineAfter(10, TimeUnit.SECONDS), + StringValue.of("meta")) + .getValue()); + String directives = String.join(",", received.get().getAll(cacheControl)); + assertTrue(directives.contains("no-cache"), directives); + assertTrue(directives.contains("no-store"), directives); + assertEquals("\"v1\"", received.get().get(etag)); + assertEquals("kept", received.get().get(custom)); + } + @Test void http1RequestsAreRejectedBeforeDispatch() throws Exception { AtomicInteger calls = new AtomicInteger(); @@ -775,11 +853,11 @@ void completedResponseRetainsAdmissionUntilSentAndBorrowedExecutorSurvives() thr assertEquals( org.restlet.data.Status.SERVER_ERROR_SERVICE_UNAVAILABLE, dispatch(ECHO, "busy").getStatus()); - first.getOnSent().handle(first.getRequest(), first); - first.getOnSent().handle(first.getRequest(), first); + first.notifyDelivered(null); + first.notifyDelivered(null); Response next = dispatch(ECHO, "next"); next.getEntity().write(OutputStream.nullOutputStream()); - next.getOnSent().handle(next.getRequest(), next); + next.notifyDelivered(null); adapter.stop(); adapter.stop(); assertFalse(executor.isShutdown()); @@ -888,7 +966,7 @@ public void write( } assertEquals(-1, input.read()); assertEquals("0", response.getTrailers().getFirstValue("grpc-status")); - response.getOnSent().handle(response.getRequest(), response); + response.notifyDelivered(null); } @Test @@ -1126,7 +1204,7 @@ void disconnectRacingResponseSentTerminatesEachTransportOnce() throws Exception executor.submit( () -> { response.getEntity().write(OutputStream.nullOutputStream()); - response.getOnSent().handle(response.getRequest(), response); + response.notifyDelivered(null); return null; }); var disconnect = diff --git a/org.restlet/src/main/java/org/restlet/Response.java b/org.restlet/src/main/java/org/restlet/Response.java index ed4c669641..373c80eb75 100644 --- a/org.restlet/src/main/java/org/restlet/Response.java +++ b/org.restlet/src/main/java/org/restlet/Response.java @@ -12,6 +12,8 @@ import java.util.Date; import java.util.List; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArraySet; import org.restlet.data.AuthenticationInfo; @@ -37,6 +39,9 @@ public class Response extends Message { private static final ThreadLocal CURRENT = new ThreadLocal<>(); + /** Completes once the server connector has finished writing this response. */ + private final CompletableFuture deliverySignal = new CompletableFuture<>(); + /** * Returns the response associated with the current thread. * @@ -154,6 +159,29 @@ public static void setCurrent(Response response) { * * @param request The request associated with this response. */ + /** + * Returns a read-only completion stage for the delivery of this response by the server + * connector. It completes once, when the terminal write is done: with null if it succeeded, or + * with the failure. Dependent actions may run on a connector I/O thread, so they must be short + * and must not block; the "onSent" callback is the place for other work. Not all connectors + * support this signal. + * + * @return The delivery signal. + */ + public CompletionStage getDeliverySignal() { + return deliverySignal.minimalCompletionStage(); + } + + /** + * Reports the end of the delivery of this response; called by server connectors. The first + * report wins. + * + * @param failure The failure of the terminal write, or null if the response was delivered. + */ + public void notifyDelivered(Throwable failure) { + deliverySignal.complete(failure); + } + public Response(Request request) { this.age = 0; this.accessControlAllowCredentials = null; diff --git a/org.restlet/src/main/java/org/restlet/engine/header/HeaderUtils.java b/org.restlet/src/main/java/org/restlet/engine/header/HeaderUtils.java index 25c822b3b2..d369da220d 100644 --- a/org.restlet/src/main/java/org/restlet/engine/header/HeaderUtils.java +++ b/org.restlet/src/main/java/org/restlet/engine/header/HeaderUtils.java @@ -514,6 +514,17 @@ private static void addEntityContentLengthHeader( * @param existingHeaders The headers to update. * @param additionalHeaders The headers to add. */ + /** + * Indicates whether a header is a standard one, set through the Restlet API rather than added + * as an extension header. + * + * @param name The header name, in any case. + * @return True if the header is standard. + */ + public static boolean isStandardHeader(String name) { + return STANDARD_HEADERS.contains(name); + } + public static void addUserExtensionHeaders( Series
existingHeaders, Series
additionalHeaders) { if (additionalHeaders != null) { diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java index 2d2f257c6f..5d0a6bafbd 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java @@ -448,15 +448,17 @@ void commit(Response response) { } /** - * Invokes the "onSent" callback of the response once its terminal write is done, never on the - * event loop: in the calling thread if the write is already done, else in a virtual thread. If - * the write failed, the response status is first set to {@link - * Status#CONNECTOR_ERROR_COMMUNICATION} with the cause, since the response wasn't delivered. + * Reports the delivery of the response once its terminal write is done, then invokes its + * "onSent" callback, never on the event loop: in the calling thread if the write is already + * done, else in a virtual thread. If the write failed, the response status is first set to + * {@link Status#CONNECTOR_ERROR_COMMUNICATION} with the cause, since the response wasn't + * delivered. * * @param response The response. * @param delivery The terminal write. */ private void notifySent(Response response, ChannelFuture delivery) { + delivery.addListener(f -> response.notifyDelivered(f.isSuccess() ? null : f.cause())); Uniform onSent = response.getOnSent(); if (onSent == null) { return; From 9d11aec05ab283540bc4ff30eecd3b6d46635652 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:02:57 -0500 Subject: [PATCH 7/8] Relay raw gRPC metadata and report truncated responses as undelivered Core: a response's raw headers attribute is written as-is by the Netty server, each name replacing the values from the Restlet API, except framing headers. A body write failing on a broken connection is reported by the delivery signal and onSent, which now runs with the call's thread local variables (Engine.withLocalVariables). WrapperResponse delegates the delivery signal. gRPC: initial metadata goes to the raw headers. A call that ended before its callbacks could start is closed instead of started. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- .../ext/grpc/internal/RestletServerHost.java | 33 ++++----- .../ext/grpc/GrpcServerAdapterTestCase.java | 11 +++ .../main/java/org/restlet/engine/Engine.java | 42 ++++++----- .../engine/header/HeaderConstants.java | 10 +++ .../restlet/engine/header/HeaderUtils.java | 11 --- .../restlet/engine/netty/NettyServerCall.java | 74 ++++++++++++++++--- .../org/restlet/util/WrapperResponse.java | 10 +++ .../org/restlet/ResponseDeliveryTestCase.java | 67 +++++++++++++++++ 8 files changed, 203 insertions(+), 55 deletions(-) create mode 100644 org.restlet/src/test/java/org/restlet/ResponseDeliveryTestCase.java diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java index 40ed170d81..a2fd4f6cf2 100644 --- a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java @@ -61,7 +61,7 @@ import org.restlet.data.MediaType; import org.restlet.data.Method; import org.restlet.data.Protocol; -import org.restlet.engine.header.HeaderUtils; +import org.restlet.engine.header.HeaderConstants; import org.restlet.representation.OutputRepresentation; import org.restlet.util.Series; @@ -221,9 +221,16 @@ public ServerInterceptor callbackBudget() { public ServerCall.Listener interceptCall( ServerCall call, Metadata headers, ServerCallHandler next) { Call owner = call.getAttributes().get(CALL); - if (owner == null || !owner.callbacksStarted()) { + if (owner == null) { return next.startCall(call, headers); } + if (!owner.callbacksStarted()) { + // The call ended and released its slot before its callbacks could start. + call.close( + Status.CANCELLED.withDescription("gRPC call ended before it started"), + new Metadata()); + return new ServerCall.Listener<>() {}; + } ServerCall.Listener listener; try { listener = next.startCall(call, headers); @@ -766,24 +773,16 @@ private static Metadata fromHttp(Series
headers) { } /** - * Maps initial metadata onto the response: standard HTTP names through the Restlet properties - * that the connector writes back, other names as extension headers. + * Maps initial metadata onto the response as raw headers, written as-is whether or not their + * names are standard HTTP ones, with their repeated values. */ private static void toResponse(Metadata metadata, Response response) { - var extension = new Series
(Header.class); - var standard = new Series
(Header.class); - toHttp(metadata, extension); - for (Header header : List.copyOf(extension)) { - if (FRAMING_HEADERS.contains(header.getName())) { - extension.remove(header); - } else if (HeaderUtils.isStandardHeader(header.getName())) { - extension.remove(header); - standard.add(header); - } + var raw = new Series
(Header.class); + toHttp(metadata, raw); + raw.removeIf(header -> FRAMING_HEADERS.contains(header.getName())); + if (!raw.isEmpty()) { + response.getAttributes().put(HeaderConstants.ATTRIBUTE_RAW_RESPONSE_HEADERS, raw); } - response.getHeaders().addAll(extension); - HeaderUtils.copyResponseTransportHeaders(standard, response); - HeaderUtils.extractEntityHeaders(standard, response.getEntity()); } private static void toHttp(Metadata metadata, Series
headers) { diff --git a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java index 1d3ad382fc..5e198ce6b6 100644 --- a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java +++ b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java @@ -40,6 +40,7 @@ import java.io.OutputStream; import java.time.Duration; import java.util.ArrayList; +import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; @@ -618,6 +619,9 @@ void initialMetadataWithStandardHeaderNamesIsSent() throws Exception { Metadata.Key.of("cache-control", Metadata.ASCII_STRING_MARSHALLER); Metadata.Key etag = Metadata.Key.of("etag", Metadata.ASCII_STRING_MARSHALLER); Metadata.Key custom = Metadata.Key.of("x-detail", Metadata.ASCII_STRING_MARSHALLER); + Metadata.Key allow = Metadata.Key.of("allow", Metadata.ASCII_STRING_MARSHALLER); + Metadata.Key expose = + Metadata.Key.of("access-control-expose-headers", Metadata.ASCII_STRING_MARSHALLER); adapter.getInterceptors() .add( new io.grpc.ServerInterceptor() { @@ -635,6 +639,8 @@ public void sendHeaders(Metadata metadata) { metadata.put(cacheControl, "no-store"); metadata.put(etag, "\"v1\""); metadata.put(custom, "kept"); + metadata.put(allow, "POST"); + metadata.put(expose, "x-detail"); super.sendHeaders(metadata); } }, @@ -662,6 +668,11 @@ public void sendHeaders(Metadata metadata) { assertTrue(directives.contains("no-store"), directives); assertEquals("\"v1\"", received.get().get(etag)); assertEquals("kept", received.get().get(custom)); + assertEquals("POST", received.get().get(allow)); + assertEquals("x-detail", received.get().get(expose)); + var values = new ArrayList(); + received.get().getAll(cacheControl).forEach(values::add); + assertEquals(List.of("no-cache", "no-store"), values); } @Test diff --git a/org.restlet/src/main/java/org/restlet/engine/Engine.java b/org.restlet/src/main/java/org/restlet/engine/Engine.java index 262aaf74fa..dbef4e29be 100644 --- a/org.restlet/src/main/java/org/restlet/engine/Engine.java +++ b/org.restlet/src/main/java/org/restlet/engine/Engine.java @@ -119,29 +119,37 @@ public static synchronized void clear() { * @return The thread with proper variables ready to run the given runnable task. */ public static Thread createThreadWithLocalVariables(final Runnable runnable, String name) { + return new Thread(withLocalVariables(runnable), name); + } + + /** + * Returns a task running the given one with the thread local variables of the Restlet API and + * engine as they are now, in the calling thread, then clearing them. + * + * @param runnable The task to run. + * @return The task restoring the current thread local variables. + */ + public static Runnable withLocalVariables(final Runnable runnable) { // Save the thread local variables final org.restlet.Application currentApplication = org.restlet.Application.getCurrent(); final Context currentContext = Context.getCurrent(); final Integer currentVirtualHost = org.restlet.routing.VirtualHost.getCurrent(); final Response currentResponse = Response.getCurrent(); - Runnable r = - () -> { - // Copy the thread local variables - Response.setCurrent(currentResponse); - Context.setCurrent(currentContext); - org.restlet.routing.VirtualHost.setCurrent(currentVirtualHost); - org.restlet.Application.setCurrent(currentApplication); - - try { - // Run the user task - runnable.run(); - } finally { - Engine.clearThreadLocalVariables(); - } - }; - - return new Thread(r, name); + return () -> { + // Copy the thread local variables + Response.setCurrent(currentResponse); + Context.setCurrent(currentContext); + org.restlet.routing.VirtualHost.setCurrent(currentVirtualHost); + org.restlet.Application.setCurrent(currentApplication); + + try { + // Run the user task + runnable.run(); + } finally { + Engine.clearThreadLocalVariables(); + } + }; } /** Clears the thread local variables set by the Restlet API and engine. */ diff --git a/org.restlet/src/main/java/org/restlet/engine/header/HeaderConstants.java b/org.restlet/src/main/java/org/restlet/engine/header/HeaderConstants.java index ad65800621..928de2d3e8 100644 --- a/org.restlet/src/main/java/org/restlet/engine/header/HeaderConstants.java +++ b/org.restlet/src/main/java/org/restlet/engine/header/HeaderConstants.java @@ -200,6 +200,16 @@ private HeaderConstants() { public static final String ATTRIBUTE_VERSION = "org.restlet.http.version"; + /** + * Response attribute holding a {@code Series
} that server connectors supporting it + * write as-is, each name replacing the values produced from the Restlet API. Headers defining + * the message framing, such as Content-Type, Content-Length or Connection, are ignored. Unlike + * {@link org.restlet.Message#getHeaders()}, it may carry standard headers, for protocols + * relaying headers they don't interpret. + */ + public static final String ATTRIBUTE_RAW_RESPONSE_HEADERS = + "org.restlet.http.rawResponseHeaders"; + public static final String ATTRIBUTE_HTTPS_KEY_SIZE = "org.restlet.https.keySize"; public static final String ATTRIBUTE_HTTPS_SSL_SESSION_ID = "org.restlet.https.sslSessionId"; diff --git a/org.restlet/src/main/java/org/restlet/engine/header/HeaderUtils.java b/org.restlet/src/main/java/org/restlet/engine/header/HeaderUtils.java index d369da220d..25c822b3b2 100644 --- a/org.restlet/src/main/java/org/restlet/engine/header/HeaderUtils.java +++ b/org.restlet/src/main/java/org/restlet/engine/header/HeaderUtils.java @@ -514,17 +514,6 @@ private static void addEntityContentLengthHeader( * @param existingHeaders The headers to update. * @param additionalHeaders The headers to add. */ - /** - * Indicates whether a header is a standard one, set through the Restlet API rather than added - * as an extension header. - * - * @param name The header name, in any case. - * @return True if the header is standard. - */ - public static boolean isStandardHeader(String name) { - return STANDARD_HEADERS.contains(name); - } - public static void addUserExtensionHeaders( Series
existingHeaders, Series
additionalHeaders) { if (additionalHeaders != null) { diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java index 5d0a6bafbd..d7278fd777 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java @@ -48,6 +48,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import javax.net.ssl.SSLPeerUnverifiedException; @@ -114,6 +115,23 @@ class NettyServerCall { /** The response headers. */ private final Series
responseHeaders = new Series<>(Header.class); + /** Raw response headers that would break the message framing. */ + private static final Set FRAMING_HEADERS = + Set.of( + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "connection", + "keep-alive", + "proxy-connection", + "upgrade", + "te", + "trailer"); + + /** A failure writing the response body that left the connection open, if any. */ + private volatile Throwable bodyFailure; + /** The trailing headers received with the request entity. */ private final Series
requestTrailers = new Series<>(Header.class); @@ -458,30 +476,64 @@ void commit(Response response) { * @param delivery The terminal write. */ private void notifySent(Response response, ChannelFuture delivery) { - delivery.addListener(f -> response.notifyDelivered(f.isSuccess() ? null : f.cause())); + delivery.addListener(f -> response.notifyDelivered(deliveryFailure(f))); Uniform onSent = response.getOnSent(); if (onSent == null) { return; } if (delivery.isDone() && !channel.eventLoop().inEventLoop()) { - sent(onSent, response, delivery); + sent(onSent, response, deliveryFailure(delivery)); } else { + // The thread local variables of the call are captured here, on its worker. + var failure = new java.util.concurrent.atomic.AtomicReference(); + Runnable task = Engine.withLocalVariables(() -> sent(onSent, response, failure.get())); delivery.addListener( - f -> - Thread.ofVirtual() - .name("restlet-response-sent") - .start(() -> sent(onSent, response, f))); + f -> { + failure.set(deliveryFailure(f)); + Thread.ofVirtual().name("restlet-response-sent").start(task); + }); + } + } + + /** Returns the failure of the body or terminal write, or null if the response was sent. */ + private Throwable deliveryFailure(io.netty.util.concurrent.Future delivery) { + Throwable failure = bodyFailure; + if (failure == null && !delivery.isSuccess()) { + failure = delivery.cause(); } + return failure; } - private static void sent( - Uniform onSent, Response response, io.netty.util.concurrent.Future delivery) { - if (!delivery.isSuccess()) { - response.setStatus(new Status(Status.CONNECTOR_ERROR_COMMUNICATION, delivery.cause())); + private static void sent(Uniform onSent, Response response, Throwable failure) { + if (failure != null) { + response.setStatus(new Status(Status.CONNECTOR_ERROR_COMMUNICATION, failure)); } onSent.handle(response.getRequest(), response); } + /** + * Writes the headers of the {@link HeaderConstants#ATTRIBUTE_RAW_RESPONSE_HEADERS} attribute, + * each name replacing the values produced from the Restlet API. + */ + private void addRawHeaders(Response response) { + if (!(response.getAttributes().get(HeaderConstants.ATTRIBUTE_RAW_RESPONSE_HEADERS) + instanceof Series raw)) { + return; + } + Set replaced = new java.util.HashSet<>(); + for (Object item : raw) { + if (item instanceof Header header) { + String name = header.getName().toLowerCase(Locale.ROOT); + if (!FRAMING_HEADERS.contains(name)) { + if (replaced.add(name)) { + responseHeaders.removeAll(header.getName(), true); + } + responseHeaders.add(header.getName(), header.getValue()); + } + } + } + } + /** * Hands the HTTP/1.1 connection over to a protocol upgrade instead of writing the response. * @@ -585,6 +637,7 @@ private void addResponseHeaders(Response response) { try { HeaderUtils.addGeneralHeaders(response, responseHeaders); HeaderUtils.addResponseHeaders(response, responseHeaders); + addRawHeaders(response); if (response.getStatus() != null) { setStatus(response.getStatus().getCode(), response.getStatus().getReasonPhrase()); } @@ -603,6 +656,7 @@ private void setStatus(int code, String reason) { private void handleException(Response response, Exception exception) { if (isConnectionBroken(exception)) { + bodyFailure = exception; getLogger() .log( Level.INFO, diff --git a/org.restlet/src/main/java/org/restlet/util/WrapperResponse.java b/org.restlet/src/main/java/org/restlet/util/WrapperResponse.java index 00fd5c1862..7cc6d16c30 100644 --- a/org.restlet/src/main/java/org/restlet/util/WrapperResponse.java +++ b/org.restlet/src/main/java/org/restlet/util/WrapperResponse.java @@ -38,6 +38,16 @@ public class WrapperResponse extends Response { /** The wrapped response. */ private final Response wrappedResponse; + @Override + public java.util.concurrent.CompletionStage getDeliverySignal() { + return wrappedResponse.getDeliverySignal(); + } + + @Override + public void notifyDelivered(Throwable failure) { + wrappedResponse.notifyDelivered(failure); + } + /** * Constructor. * diff --git a/org.restlet/src/test/java/org/restlet/ResponseDeliveryTestCase.java b/org.restlet/src/test/java/org/restlet/ResponseDeliveryTestCase.java new file mode 100644 index 0000000000..86097945a4 --- /dev/null +++ b/org.restlet/src/test/java/org/restlet/ResponseDeliveryTestCase.java @@ -0,0 +1,67 @@ +/** + * 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; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.restlet.engine.Engine; +import org.restlet.util.WrapperResponse; + +class ResponseDeliveryTestCase { + + @Test + void firstDeliveryReportWins() throws Exception { + Response response = new Response(new Request()); + IOException failure = new IOException("Peer reset"); + response.notifyDelivered(failure); + response.notifyDelivered(null); + assertSame( + failure, + response.getDeliverySignal().toCompletableFuture().get(5, TimeUnit.SECONDS)); + } + + @Test + void wrapperSharesTheDeliverySignalOfItsResponse() throws Exception { + Response response = new Response(new Request()); + Response wrapper = new WrapperResponse(response); + response.notifyDelivered(null); + assertNull(wrapper.getDeliverySignal().toCompletableFuture().get(5, TimeUnit.SECONDS)); + + Response other = new Response(new Request()); + new WrapperResponse(other).notifyDelivered(null); + assertNull(other.getDeliverySignal().toCompletableFuture().get(5, TimeUnit.SECONDS)); + } + + @Test + void localVariablesAreRestoredInAnotherThreadThenCleared() throws Exception { + Response response = new Response(new Request()); + Response.setCurrent(response); + AtomicReference seen = new AtomicReference<>(); + AtomicReference after = new AtomicReference<>(response); + try { + Runnable task = Engine.withLocalVariables(() -> seen.set(Response.getCurrent())); + Thread.ofVirtual() + .start( + () -> { + task.run(); + after.set(Response.getCurrent()); + }) + .join(); + } finally { + Engine.clearThreadLocalVariables(); + } + assertSame(response, seen.get()); + assertNull(after.get()); + } +} From c6c2c0f110dad94af4f8a9e5f25ded1256ed2aa9 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:09:18 -0500 Subject: [PATCH 8/8] Isolate SDK timers and report delivery before the peer sees the response The SDK's scheduled work, such as client deadlines whose cancellation listeners may block, now runs on its own timer, so it can't delay the call budgets. A call whose callbacks haven't started frees its callback slot when it ends, so they can't start afterwards. The SDK transport attributes now carry the peer and local socket addresses. The Netty server attaches the delivery listener to the terminal write before submitting it, and reports delivery before invoking "onSent" on every path. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- .../ext/grpc/internal/RestletServerHost.java | 57 ++++++++- .../ext/grpc/GrpcServerAdapterTestCase.java | 116 ++++++++++++++++++ .../restlet/engine/netty/NettyServerCall.java | 34 +++-- 3 files changed, 194 insertions(+), 13 deletions(-) diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java index a2fd4f6cf2..21a5af88ca 100644 --- a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletServerHost.java @@ -12,6 +12,7 @@ import com.google.common.util.concurrent.ListenableFuture; import io.grpc.Attributes; import io.grpc.ForwardingServerCallListener; +import io.grpc.Grpc; import io.grpc.InternalChannelz; import io.grpc.InternalInstrumented; import io.grpc.InternalLogId; @@ -37,6 +38,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.nio.charset.StandardCharsets; @@ -98,6 +100,15 @@ public final class RestletServerHost implements InternalServer, AutoCloseable { private final ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor( 1, Thread.ofPlatform().daemon().name("restlet-grpc-server-timer").factory()); + + /** + * Runs the scheduled work of the SDK, such as client deadlines whose cancellation listeners may + * block, apart from the call budgets of {@link #timer}. + */ + private final ScheduledThreadPoolExecutor sdkTimer = + new ScheduledThreadPoolExecutor( + 1, Thread.ofPlatform().daemon().name("restlet-grpc-sdk-timer").factory()); + private volatile ServerListener listener; private List tracers = List.of(); private boolean closed; @@ -110,6 +121,7 @@ public RestletServerHost(int maxBytes, int queueBytes, int maxCalls, Duration du callbacks = new Semaphore(maxCalls > Integer.MAX_VALUE / 2 ? maxCalls : maxCalls * 2); // Each completed call cancels its deadline, which must not stay queued until it expires. timer.setRemoveOnCancelPolicy(true); + sdkTimer.setRemoveOnCancelPolicy(true); } public void setTracers(List value) { @@ -139,6 +151,7 @@ public void close() { shutdown(); calls.forEach(call -> call.cancel(Status.UNAVAILABLE.withDescription("Adapter stopped"))); timer.shutdownNow(); + sdkTimer.shutdownNow(); } @Override @@ -382,7 +395,7 @@ private void begin() throws IOException, InterruptedException { return; } Attributes attributes = - transportListener.transportReady(Attributes.EMPTY).toBuilder() + transportListener.transportReady(transportAttributes()).toBuilder() .set(CALL, this) .build(); stream = @@ -416,6 +429,26 @@ private void begin() throws IOException, InterruptedException { } } + /** Returns the standard transport attributes: the peer and local socket addresses. */ + private Attributes transportAttributes() { + Attributes.Builder result = Attributes.newBuilder(); + SocketAddress remote = + address( + request.getClientInfo().getAddress(), + request.getClientInfo().getPort()); + if (remote != null) { + result.set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, remote); + } + SocketAddress local = + address( + response.getServerInfo().getAddress(), + response.getServerInfo().getPort()); + if (local != null) { + result.set(Grpc.TRANSPORT_ATTR_LOCAL_ADDR, local); + } + return result.build(); + } + private byte[] read(InputStream input) throws IOException { enterIo(); try { @@ -543,6 +576,10 @@ private boolean claim() { } calls.remove(this); admission.release(); + // Callbacks not started by now never will, so their slot is free at once. + if (callbackState.compareAndSet(0, 2)) { + callbacks.release(); + } ScheduledFuture timeout = deadline; if (timeout != null) { timeout.cancel(false); @@ -565,10 +602,6 @@ private void callbacksEnded() { /** Reports the end of a claimed call to the SDK, in {@link #serial}. */ private void finish(Status failure) { reported = true; - // A call ended before the SDK started its application callbacks holds no callback. - if (callbackState.compareAndSet(0, 2)) { - callbacks.release(); - } if (deadline != null) { deadline.cancel(false); } @@ -595,7 +628,7 @@ public void shutdownNow(Status reason) { @Override public ScheduledExecutorService getScheduledExecutorService() { - return timer; + return sdkTimer; } @Override @@ -759,6 +792,18 @@ public int readableBytes() { public void release() {} } + /** Returns the socket address of an IP literal and port, or null if unknown. */ + private static SocketAddress address(String host, int port) { + if (host == null || port < 0) { + return null; + } + try { + return new InetSocketAddress(InetAddress.ofLiteral(host), port); + } catch (IllegalArgumentException e) { + return null; + } + } + private static Metadata fromHttp(Series

headers) { var values = new ArrayList(); for (Header header : headers) { diff --git a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java index 5e198ce6b6..434bfa6dd6 100644 --- a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java +++ b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcServerAdapterTestCase.java @@ -675,6 +675,122 @@ public void sendHeaders(Metadata metadata) { assertEquals(List.of("no-cache", "no-store"), values); } + @Test + void streamingErrorAfterMessagesKeepsItsStatusAndTrailers() throws Exception { + Metadata.Key key = Metadata.Key.of("x-detail", Metadata.ASCII_STRING_MARSHALLER); + start( + ServerServiceDefinition.builder("review.Server") + .addMethod( + WATCH, + ServerCalls.asyncServerStreamingCall( + (StringValue request, + io.grpc.stub.StreamObserver out) -> { + out.onNext(StringValue.of("first")); + out.onNext(StringValue.of("second")); + Metadata trailers = new Metadata(); + trailers.put(key, "late"); + out.onError( + Status.ABORTED + .withDescription("after messages") + .asRuntimeException(trailers)); + })) + .build()); + var responses = + ClientCalls.blockingServerStreamingCall( + channel, + WATCH, + CallOptions.DEFAULT.withDeadlineAfter(10, TimeUnit.SECONDS), + StringValue.of("watch")); + assertEquals("first", responses.next().getValue()); + assertEquals("second", responses.next().getValue()); + var error = assertThrows(StatusRuntimeException.class, responses::hasNext); + assertEquals(Status.Code.ABORTED, error.getStatus().getCode()); + assertEquals("after messages", error.getStatus().getDescription()); + assertEquals("late", java.util.Objects.requireNonNull(error.getTrailers()).get(key)); + } + + @Test + void transportAttributesCarryTheSocketAddresses() throws Exception { + AtomicReference seen = new AtomicReference<>(); + adapter.getInterceptors() + .add( + new io.grpc.ServerInterceptor() { + @Override + public io.grpc.ServerCall.Listener interceptCall( + io.grpc.ServerCall call, + Metadata headers, + io.grpc.ServerCallHandler next) { + seen.set(call.getAttributes()); + return next.startCall(call, headers); + } + }); + start(echo()); + assertEquals("peer", call("peer")); + var remote = + (java.net.InetSocketAddress) + seen.get().get(io.grpc.Grpc.TRANSPORT_ATTR_REMOTE_ADDR); + var local = + (java.net.InetSocketAddress) seen.get().get(io.grpc.Grpc.TRANSPORT_ATTR_LOCAL_ADDR); + assertTrue(remote.getAddress().isLoopbackAddress(), remote::toString); + assertTrue(remote.getPort() > 0); + assertTrue(local.getAddress().isLoopbackAddress(), local::toString); + assertEquals(http.getActualPort(), local.getPort()); + } + + @Test + void blockingClientDeadlineListenerCannotDelayCallBudgets() throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + adapter.setMaxCallDuration(Duration.ofSeconds(1)); + adapter.getServices() + .add( + ServerServiceDefinition.builder("review.Server") + .addMethod( + ECHO, + (call, headers) -> { + // Runs where the client deadline expires. + io.grpc.Context.current() + .addListener( + context -> { + entered.countDown(); + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread() + .interrupt(); + } + }, + Runnable::run); + call.sendHeaders(new Metadata()); + return new io.grpc.ServerCall.Listener<>() {}; + }) + .addMethod( + WATCH, + (call, headers) -> { + call.sendHeaders(new Metadata()); + return new io.grpc.ServerCall.Listener<>() {}; + }) + .build()); + adapter.start(); + // Released before the executor waits for its tasks, which the listener may hold. + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + try { + Request blocking = + grpcRequest(ECHO, new ByteArrayInputStream(frame("x")), () -> {}); + blocking.getHeaders().add("grpc-timeout", "100m"); + executor.submit(() -> adapter.handle(blocking, new Response(blocking))); + assertTrue(entered.await(10, TimeUnit.SECONDS)); + Response budgeted = dispatch(WATCH, "y"); + assertEquals( + String.valueOf(Status.Code.DEADLINE_EXCEEDED.value()), + executor.submit(() -> trailersAfterWriting(budgeted)) + .get(5, TimeUnit.SECONDS)); + } finally { + release.countDown(); + } + } + } + @Test void http1RequestsAreRejectedBeforeDispatch() throws Exception { AtomicInteger calls = new AtomicInteger(); diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java index d7278fd777..299392f227 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java @@ -19,6 +19,7 @@ import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelPipeline; +import io.netty.channel.ChannelPromise; import io.netty.handler.codec.http.DefaultFullHttpResponse; import io.netty.handler.codec.http.DefaultHttpContent; import io.netty.handler.codec.http.DefaultHttpResponse; @@ -461,7 +462,8 @@ void commit(Response response) { } catch (IOException | RuntimeException e) { handleException(response, e); } finally { - notifySent(response, complete()); + // Delivery is reported by the terminal write itself, before the peer can see it. + notifySent(response, complete(f -> response.notifyDelivered(deliveryFailure(f)))); } } @@ -476,13 +478,15 @@ void commit(Response response) { * @param delivery The terminal write. */ private void notifySent(Response response, ChannelFuture delivery) { - delivery.addListener(f -> response.notifyDelivered(deliveryFailure(f))); Uniform onSent = response.getOnSent(); if (onSent == null) { return; } if (delivery.isDone() && !channel.eventLoop().inEventLoop()) { - sent(onSent, response, deliveryFailure(delivery)); + // Done doesn't mean its listeners ran: the delivery is reported first, once. + Throwable failure = deliveryFailure(delivery); + response.notifyDelivered(failure); + sent(onSent, response, failure); } else { // The thread local variables of the call are captured here, on its worker. var failure = new java.util.concurrent.atomic.AtomicReference(); @@ -846,8 +850,18 @@ private boolean isRequestBodyDiscardable() { * @return The terminal write of the response, failed if the connection is closed. */ ChannelFuture complete() { + return complete(null); + } + + /** + * Completes the exchange: ends the response and frees the connection for the next request. + * + * @param delivery A listener of the terminal write, added before it is submitted, or null. + * @return The terminal write of the response, failed if the connection is closed. + */ + ChannelFuture complete(ChannelFutureListener delivery) { if (completed) { - return channel.newSucceededFuture(); + return listen(channel.newSucceededFuture(), delivery); } completed = true; @@ -865,10 +879,10 @@ ChannelFuture complete() { if (!channel.isActive()) { requestBody.close(); - return channel.newFailedFuture(new ClosedChannelException()); + return listen(channel.newFailedFuture(new ClosedChannelException()), delivery); } if (responseEndedWithHead) { - trackDelivery(responseEndFuture); + listen(trackDelivery(responseEndFuture), delivery); channel.flush(); requestBody.close(); return responseEndFuture; @@ -884,7 +898,9 @@ ChannelFuture complete() { copyHeaders(responseTrailers, withTrailers.trailingHeaders(), kind.isStream()); lastContent = withTrailers; } - ChannelFuture future = trackDelivery(channel.writeAndFlush(lastContent)); + ChannelPromise future = channel.newPromise(); + listen(trackDelivery(future), delivery); + channel.writeAndFlush(lastContent, future); if (keepAlive) { requestBody.discard(); } else { @@ -923,6 +939,10 @@ private ChannelFuture trackDelivery(ChannelFuture future) { return future; } + private static ChannelFuture listen(ChannelFuture future, ChannelFutureListener listener) { + return listener == null ? future : future.addListener(listener); + } + private synchronized void markResponseDelivered() { responseDelivered = true; }