From 18090df7ca3d73cb500a479f4339df828e3dd346 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:00:47 -0500 Subject: [PATCH 1/3] Add a smithy-java server hosting feasibility spike Hosts pinned smithy-java 1.8.0 server protocols (restJson1, RPCv2 CBOR) and their handler chain in a Restlet behind a router, with a service built from the model as smithy-java's ProxyService does, and each call run on its own request thread instead of the SDK's single-thread orchestrator. Covers a restJson1 round trip with Smithy's JDK client, modeled errors, unknown operations, RPCv2 CBOR over HTTP/2, blocking operations that would deadlock a single-thread orchestrator, and streamed output reaching the client while the operation still produces it. Also pins the upstream restJson1 router matching labels on the decoded path, so an encoded "/" in a label isn't matched. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- org.restlet/pom.xml | 18 + .../SmithySdkTransportSpikeTestCase.java | 2 +- .../connector/SmithyServerSpikeTestCase.java | 632 ++++++++++++++++++ 3 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 org.restlet/src/test/java/org/restlet/engine/connector/SmithyServerSpikeTestCase.java diff --git a/org.restlet/pom.xml b/org.restlet/pom.xml index c5cf2770b5..1fc44678cc 100644 --- a/org.restlet/pom.xml +++ b/org.restlet/pom.xml @@ -133,6 +133,24 @@ ${smithy-java.version} test + + software.amazon.smithy.java + server-core + ${smithy-java.version} + test + + + software.amazon.smithy.java + aws-server-restjson + ${smithy-java.version} + test + + + software.amazon.smithy.java + server-rpcv2-cbor + ${smithy-java.version} + test + diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/SmithySdkTransportSpikeTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/SmithySdkTransportSpikeTestCase.java index 9eeed056ac..9589d9efaa 100644 --- a/org.restlet/src/test/java/org/restlet/engine/connector/SmithySdkTransportSpikeTestCase.java +++ b/org.restlet/src/test/java/org/restlet/engine/connector/SmithySdkTransportSpikeTestCase.java @@ -77,7 +77,7 @@ * transport below is a test fixture, not the production adapter. */ class SmithySdkTransportSpikeTestCase { - private static final String MODEL = + static final String MODEL = """ $version: "2" namespace spike diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/SmithyServerSpikeTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/SmithyServerSpikeTestCase.java new file mode 100644 index 0000000000..2dd7c8e594 --- /dev/null +++ b/org.restlet/src/test/java/org/restlet/engine/connector/SmithyServerSpikeTestCase.java @@ -0,0 +1,632 @@ +/** + * 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.connector; + +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 java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +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.Header; +import org.restlet.data.MediaType; +import org.restlet.data.Protocol; +import org.restlet.engine.Engine; +import org.restlet.engine.header.HeaderConstants; +import org.restlet.representation.OutputRepresentation; +import org.restlet.representation.Representation; +import org.restlet.routing.Router; +import org.restlet.util.Series; +import software.amazon.smithy.java.aws.client.restjson.RestJsonClientProtocol; +import software.amazon.smithy.java.client.http.JavaHttpClientTransport; +import software.amazon.smithy.java.client.rpcv2.RpcV2CborProtocol; +import software.amazon.smithy.java.core.error.ModeledException; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SchemaIndex; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.serde.TypeRegistry; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.dynamicclient.DocumentException; +import software.amazon.smithy.java.dynamicclient.DynamicClient; +import software.amazon.smithy.java.dynamicclient.DynamicOperation; +import software.amazon.smithy.java.dynamicschemas.SchemaConverter; +import software.amazon.smithy.java.dynamicschemas.StructDocument; +import software.amazon.smithy.java.framework.model.UnknownOperationException; +import software.amazon.smithy.java.http.api.HttpHeaders; +import software.amazon.smithy.java.io.datastream.DataStream; +import software.amazon.smithy.java.server.Operation; +import software.amazon.smithy.java.server.RequestContext; +import software.amazon.smithy.java.server.Route; +import software.amazon.smithy.java.server.Service; +import software.amazon.smithy.java.server.core.Handler; +import software.amazon.smithy.java.server.core.HandlerAssembler; +import software.amazon.smithy.java.server.core.HttpJob; +import software.amazon.smithy.java.server.core.ProtocolResolver; +import software.amazon.smithy.java.server.core.ServiceMatcher; +import software.amazon.smithy.java.server.core.ServiceProtocolResolutionRequest; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.TopDownIndex; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.ShapeId; + +/** + * Feasibility spike for an experimental Smithy server: hosts smithy-java 1.8.0 server protocols + * (restJson1, RPCv2 CBOR) and their handler chain in a Restlet, behind a Restlet router, running + * each call on its own request thread instead of the SDK's single-thread orchestrator. The host and + * the model-driven service below are test fixtures, not the production adapter. + */ +class SmithyServerSpikeTestCase { + private static final ShapeId JSON_SPIKE = ShapeId.from("spike#JsonSpike"); + private static final ShapeId CBOR_SPIKE = ShapeId.from("spike#CborSpike"); + private static Model model; + + private Server server; + private Client http2; + + @BeforeAll + static void assembleModel() { + ClassLoader loader = SmithyServerSpikeTestCase.class.getClassLoader(); + model = + Model.assembler(loader) + .discoverModels(loader) + .addUnparsedModel("spike.smithy", SmithySdkTransportSpikeTestCase.MODEL) + .assemble() + .unwrap(); + } + + @BeforeEach + void registerConnectors() { + Engine.clearThreadLocalVariables(); + Engine.register(false); + Engine.getInstance().getRegisteredServers().add(new HttpServerHelper(null)); + Engine.getInstance().getRegisteredClients().add(new HttpClientHelper(null)); + } + + @AfterEach + void stop() throws Exception { + if (http2 != null) { + http2.stop(); + } + if (server != null) { + server.stop(); + } + } + + /** Starts an HTTP/1.1 and HTTP/2 (h2c) server with the Smithy host under "/api". */ + private String start(Map> handlers) + throws Exception { + var services = + List.of( + new ModelService(model, JSON_SPIKE, handlers), + new ModelService(model, CBOR_SPIKE, handlers)); + Router router = new Router(); + // The host receives every path below "/api", like the gRPC adapter's routes. + router.attach( + "/api", new SmithyHost(services), org.restlet.routing.Template.MODE_STARTS_WITH); + Context context = new Context(); + context.getParameters().set("http.transport.protocol", "HTTP2"); + server = new Server(context, Protocol.HTTP, 0, router); + server.start(); + return "http://localhost:" + server.getActualPort() + "/api"; + } + + private static Map> echo() { + Map> handlers = new HashMap<>(); + handlers.put( + "Echo", + input -> { + if ("reject".equals(member(input, "tag"))) { + throw rejected("no"); + } + return Document.of( + Map.of( + "tag", Document.of(member(input, "tag")), + "message", + Document.of( + member(input, "message") + + "|" + + member(input, "id") + + "|" + + member(input, "q")))); + }); + return handlers; + } + + private static String member(StructDocument input, String name) { + Document value = input.getMember(name); + return value == null ? null : value.asString(); + } + + private static ModeledException rejected(String reason) { + ServiceShape service = model.expectShape(JSON_SPIKE, ServiceShape.class); + Schema schema = + new SchemaConverter(model) + .getSchema(model.expectShape(ShapeId.from("spike#Rejected"))); + return Document.of(Map.of("reason", Document.of(reason))) + .asShape( + new DocumentException.SchemaGuidedExceptionBuilder( + service.getId(), schema)); + } + + private static Document echoInput(String tag) { + return echoInput(tag, "a b+c"); + } + + private static Document echoInput(String tag, String id) { + return Document.of( + Map.of( + "id", Document.of(id), + "q", Document.of("x=y&z é"), + "tag", Document.of(tag), + "message", Document.of("hello ünïcode"))); + } + + private static DynamicClient jdkJsonClient(String endpoint) { + return DynamicClient.builder() + .model(model) + .serviceId(JSON_SPIKE) + .protocol(new RestJsonClientProtocol(JSON_SPIKE)) + .transport( + new JavaHttpClientTransport( + java.net.http.HttpClient.newBuilder() + .version(java.net.http.HttpClient.Version.HTTP_1_1) + .build())) + .endpoint(endpoint) + .build(); + } + + /** Smithy's own JDK client calls the hosted restJson1 service, through a Restlet router. */ + @Test + void restJsonRoundTripBehindARouter() throws Exception { + String endpoint = start(echo()); + try (var client = jdkJsonClient(endpoint)) { + Document output = client.call("Echo", echoInput("t1")); + assertEquals("hello ünïcode|a b+c|x=y&z é", output.getMember("message").asString()); + assertEquals("t1", output.getMember("tag").asString()); + } + } + + /** + * Pins an upstream limit: the restJson1 server matches labels on the decoded path, so an + * encoded "/" in a label splits it into two segments and no operation matches. + */ + @Test + void encodedSlashInALabelIsNotMatchedUpstream() throws Exception { + String endpoint = start(echo()); + try (var client = jdkJsonClient(endpoint)) { + var error = + assertThrows( + software.amazon.smithy.java.core.error.CallException.class, + () -> client.call("Echo", echoInput("t1", "a/b"))); + assertTrue(error.getMessage().contains("404"), error::getMessage); + } + } + + @Test + void modeledErrorReachesTheClient() throws Exception { + String endpoint = start(echo()); + try (var client = jdkJsonClient(endpoint)) { + var error = + assertThrows( + DocumentException.class, + () -> client.call("Echo", echoInput("reject"))); + assertEquals("no", error.getContents().getMember("reason").asString()); + } + } + + @Test + void unknownOperationIsNotFound() throws Exception { + String endpoint = start(echo()); + Client client = new Client(new Context(), Protocol.HTTP); + client.start(); + try { + Response response = + client.handle(new Request(org.restlet.data.Method.POST, endpoint + "/nowhere")); + assertEquals(org.restlet.data.Status.CLIENT_ERROR_NOT_FOUND, response.getStatus()); + } finally { + client.stop(); + } + } + + /** RPCv2 CBOR between a Restlet HTTP/2 client and the hosted service, over h2c. */ + @Test + void rpcV2CborOverHttp2() throws Exception { + String endpoint = start(echo()); + Context clientContext = new Context(); + clientContext.getParameters().set("httpClientTransportMode", "HTTP2"); + http2 = new Client(clientContext, Protocol.HTTP); + http2.start(); + try (var client = + DynamicClient.builder() + .model(model) + .serviceId(CBOR_SPIKE) + .protocol(new RpcV2CborProtocol(CBOR_SPIKE)) + .transport(new SmithySdkTransportSpikeTestCase.RestletTransport(http2)) + .endpoint(endpoint) + .build()) { + Document output = client.call("Echo", echoInput("t2")); + assertEquals("hello ünïcode|a b+c|x=y&z é", output.getMember("message").asString()); + } + } + + /** + * Blocking operations run on their own request threads: two calls that each wait for the other + * to start both complete. On the SDK's single-thread orchestrator, they would deadlock. + */ + @Test + void blockingOperationsDoNotSerializeCalls() throws Exception { + CountDownLatch bothEntered = new CountDownLatch(2); + Map> handlers = new HashMap<>(); + handlers.put( + "Echo", + input -> { + bothEntered.countDown(); + try { + assertTrue(bothEntered.await(10, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return Document.of(Map.of("message", Document.of("done"))); + }); + String endpoint = start(handlers); + try (var executor = Executors.newVirtualThreadPerTaskExecutor(); + var client = jdkJsonClient(endpoint)) { + List> calls = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + calls.add(executor.submit(() -> client.call("Echo", echoInput("x")))); + } + for (Future call : calls) { + assertEquals( + "done", call.get(15, TimeUnit.SECONDS).getMember("message").asString()); + } + } + } + + /** A streaming payload starts reaching the client before the operation's stream ends. */ + @Test + void streamingOutputIsNotBufferedWhole() throws Exception { + CountDownLatch firstChunkRead = new CountDownLatch(1); + AtomicBoolean restProduced = new AtomicBoolean(); + Map> handlers = new HashMap<>(); + Schema payload = + new SchemaConverter(model) + .getSchema(model.expectShape(ShapeId.from("spike#Payload"))); + handlers.put( + "Download", + input -> { + InputStream body = + new InputStream() { + private int sent; + + @Override + public int read() { + byte[] one = new byte[1]; + return read(one, 0, 1) < 0 ? -1 : one[0] & 0xff; + } + + @Override + public int read(byte[] buffer, int offset, int length) { + if (sent == 64 * 1024) { + try { + firstChunkRead.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + restProduced.set(true); + } + int total = 4 * 1024 * 1024 + 64 * 1024; + if (sent >= total) { + return -1; + } + int count = + Math.min( + length, + (sent < 64 * 1024 ? 64 * 1024 : total) - sent); + sent += count; + return count; + } + }; + return Document.of( + Map.of( + "body", + Document.of( + payload, + DataStream.ofInputStream( + body, "application/octet-stream")))); + }); + String endpoint = start(handlers); + Client restletClient = new Client(new Context(), Protocol.HTTP); + restletClient.start(); + try (var client = + DynamicClient.builder() + .model(model) + .serviceId(JSON_SPIKE) + .protocol(new RestJsonClientProtocol(JSON_SPIKE)) + .transport( + new SmithySdkTransportSpikeTestCase.RestletTransport(restletClient)) + .endpoint(endpoint) + .build()) { + Document output = client.call("Download"); + try (InputStream body = output.getMember("body").asDataStream().asInputStream()) { + assertEquals(64 * 1024, body.readNBytes(64 * 1024).length); + assertFalse(restProduced.get(), "the whole payload was buffered before sending"); + firstChunkRead.countDown(); + assertEquals(4 * 1024 * 1024, body.readAllBytes().length); + } + } finally { + firstChunkRead.countDown(); + } + } + + /** The spike host: SDK protocol resolution and handlers, run on the Restlet request thread. */ + private static final class SmithyHost extends Restlet { + private final ProtocolResolver resolver; + private final List handlers; + + private SmithyHost(List services) { + resolver = + new ProtocolResolver( + new ServiceMatcher( + List.of( + Route.builder() + .pathPrefix("/") + .services(services) + .build()))); + handlers = new HandlerAssembler().assembleHandlers(services); + } + + @Override + public void handle(Request request, Response response) { + super.handle(request, response); + // The SDK resolves operations on the path below the route that matched. + String remaining = request.getResourceRef().getRemainingPart(false, true); + URI uri = URI.create(remaining.startsWith("/") ? remaining : "/" + remaining); + var headers = HttpHeaders.ofModifiable(); + for (Header header : request.getHeaders()) { + headers.addHeader(header.getName(), header.getValue()); + } + Representation entity = request.getEntity(); + if (entity != null + && entity.getMediaType() != null + && !headers.hasHeader("content-type")) { + headers.addHeader("content-type", entity.getMediaType().toString()); + } + var smithyRequest = + new software.amazon.smithy.java.server.core.HttpRequest( + headers, uri, request.getMethod().getName()); + HttpJob job; + try { + var resolution = + resolver.resolve( + new ServiceProtocolResolutionRequest( + uri, + headers, + smithyRequest.context(), + smithyRequest.method())); + job = + new HttpJob( + resolution.operation(), + resolution.protocol(), + smithyRequest, + new software.amazon.smithy.java.server.core.HttpResponse( + HttpHeaders.ofModifiable())); + } catch (UnknownOperationException e) { + response.setStatus(org.restlet.data.Status.CLIENT_ERROR_NOT_FOUND); + return; + } + try { + smithyRequest.setDataStream( + entity == null || !entity.isAvailable() + ? DataStream.ofEmpty() + : DataStream.ofInputStream( + entity.getStream(), + headers.contentType(), + entity.getSize())); + } catch (IOException e) { + response.setStatus(org.restlet.data.Status.CLIENT_ERROR_BAD_REQUEST, e); + return; + } + run(job); + write(job, response); + } + + /** Runs the handlers as the SDK's orchestrator does, but joining on this thread. */ + private void run(HttpJob job) { + var done = new ArrayDeque(); + for (Handler handler : handlers) { + if (job.isFailure() || job.isCompleted()) { + break; + } + done.push(handler); + join(job, handler.before(job)); + } + while (!done.isEmpty()) { + join(job, done.pop().after(job)); + } + if (job.isFailure()) { + join(job, job.chosenProtocol().serializeError(job, job.getFailure())); + } + } + + private static void join(HttpJob job, CompletableFuture step) { + try { + step.join(); + } catch (CompletionException e) { + job.setFailure(e.getCause()); + } catch (RuntimeException e) { + job.setFailure(e); + } + } + + private static void write(HttpJob job, Response response) { + var smithyResponse = job.response(); + response.setStatus(org.restlet.data.Status.valueOf(smithyResponse.getStatusCode())); + var raw = new Series

(Header.class); + smithyResponse.headers().forEachEntry(raw::add); + response.getAttributes().put(HeaderConstants.ATTRIBUTE_RAW_RESPONSE_HEADERS, raw); + DataStream body = smithyResponse.getSerializedValue(); + if (body != null && body.contentLength() != 0) { + var entity = + new OutputRepresentation( + body.contentType() == null + ? null + : MediaType.valueOf(body.contentType()), + body.contentLength() < 0 + ? Representation.UNKNOWN_SIZE + : body.contentLength()) { + @Override + public void write(java.io.OutputStream out) throws IOException { + copy(body.asInputStream(), out); + } + }; + response.setEntity(entity); + } + } + + /** + * Copies a payload, flushing whenever the source has nothing more available: the connector + * only flushes full buffers once it holds more than its high water mark, so a producer + * pausing mid-stream would otherwise hold back what it already produced. + */ + private static void copy(InputStream in, java.io.OutputStream out) throws IOException { + try (in) { + byte[] buffer = new byte[16 * 1024]; + int count; + while ((count = in.read(buffer)) > 0) { + out.write(buffer, 0, count); + if (in.available() == 0) { + out.flush(); + } + } + } + } + } + + /** A server service built from the model, like smithy-java's ProxyService, without codegen. */ + private static final class ModelService implements Service { + private final Map> operations = + new HashMap<>(); + private final List> + all = new ArrayList<>(); + private final Schema schema; + private final SchemaIndex index; + private final TypeRegistry errors; + + private ModelService( + Model model, + ShapeId serviceId, + Map> handlers) { + var converter = new SchemaConverter(model); + var service = model.expectShape(serviceId, ServiceShape.class); + var registry = TypeRegistry.builder(); + for (ShapeId error : service.getErrorsSet()) { + register(error, serviceId, registry, model, converter); + } + errors = registry.build(); + for (var shape : TopDownIndex.of(model).getContainedOperations(service)) { + String name = shape.getId().getName(); + var api = + DynamicOperation.create( + shape, + converter, + model, + service, + errors, + (error, builder) -> + register(error, serviceId, builder, model, converter)); + Schema output = converter.getSchema(model.expectShape(shape.getOutputShape())); + Function handler = handlers.get(name); + Operation operation = + Operation.of( + name, + (StructDocument input, RequestContext context) -> { + if (handler == null) { + throw new UnsupportedOperationException(name); + } + return StructDocument.of( + output, handler.apply(input), serviceId); + }, + api, + this); + operations.put(name, operation); + all.add(operation); + } + schema = converter.getSchema(service); + index = converter.getSchemaIndex(); + } + + private static void register( + ShapeId error, + ShapeId serviceId, + TypeRegistry.Builder registry, + Model model, + SchemaConverter converter) { + Schema errorSchema = converter.getSchema(model.expectShape(error)); + registry.putType( + error, + ModeledException.class, + () -> + new DocumentException.SchemaGuidedExceptionBuilder( + serviceId, errorSchema)); + } + + @Override + @SuppressWarnings("unchecked") + public + Operation getOperation(String name) { + return (Operation) operations.get(name); + } + + @Override + public List> + getAllOperations() { + return all; + } + + @Override + public Schema schema() { + return schema; + } + + @Override + public TypeRegistry typeRegistry() { + return errors; + } + + @Override + public SchemaIndex schemaIndex() { + return index; + } + } +} From 80170542c79f7bc146d7ae3365145c98fead0381 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:00:58 -0500 Subject: [PATCH 2/3] Mention the Smithy server probes in changes.md Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- changes.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changes.md b/changes.md index cd2fc0ec14..5d558f2094 100644 --- a/changes.md +++ b/changes.md @@ -138,8 +138,8 @@ Changes log client writes these headers as-is, standard ones included, each name replacing the values produced from the Restlet API; headers defining the connection or the message length are ignored. SDKs that serialize and sign their own requests can rely on it. - - Added test-only smithy-java 1.8.0 client feasibility probes over the Restlet HTTP - connectors. + - Added test-only smithy-java 1.8.0 client and server feasibility probes over the + Restlet HTTP connectors. - Added the `EventStreamRepresentation`, `ServerSentEvent` and `EventStreamReader` APIs for streaming and reading Server-Sent Events over the HTTP connectors, including optional comment heartbeats and reconnects honoring `retry:` and `Last-Event-ID`. The From b8f073b49c7634beea58a59cd86f1f333adc8c1e Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:56:52 -0500 Subject: [PATCH 3/3] Stop the borrowed Restlet client in the streaming test Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- .../restlet/engine/connector/SmithyServerSpikeTestCase.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/SmithyServerSpikeTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/SmithyServerSpikeTestCase.java index 2dd7c8e594..d0cf13d0d7 100644 --- a/org.restlet/src/test/java/org/restlet/engine/connector/SmithyServerSpikeTestCase.java +++ b/org.restlet/src/test/java/org/restlet/engine/connector/SmithyServerSpikeTestCase.java @@ -386,6 +386,9 @@ public int read(byte[] buffer, int offset, int length) { } } finally { firstChunkRead.countDown(); + // The transport borrows the Restlet client, so closing the SDK client leaves it + // running. + restletClient.stop(); } }