diff --git a/servicetalk-http-netty/gradle/spotbugs/test-exclusions.xml b/servicetalk-http-netty/gradle/spotbugs/test-exclusions.xml index e3a8d42e02..cf24eaa958 100644 --- a/servicetalk-http-netty/gradle/spotbugs/test-exclusions.xml +++ b/servicetalk-http-netty/gradle/spotbugs/test-exclusions.xml @@ -79,4 +79,8 @@ + + + + diff --git a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpMessageDiscardWatchdogClientFilter.java b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpMessageDiscardWatchdogClientFilter.java index 9d631d826e..059c15c777 100644 --- a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpMessageDiscardWatchdogClientFilter.java +++ b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpMessageDiscardWatchdogClientFilter.java @@ -35,67 +35,39 @@ import java.util.concurrent.atomic.AtomicReference; -import static io.servicetalk.http.netty.HttpMessageDiscardWatchdogServiceFilter.generifyAtomicReference; - /** * Filter which tracks message bodies and warns if they are not discarded properly. */ -final class HttpMessageDiscardWatchdogClientFilter implements StreamingHttpConnectionFilterFactory { +final class HttpMessageDiscardWatchdogClientFilter { private static final ContextMap.Key>> MESSAGE_PUBLISHER_KEY = ContextMap.Key .newKey(HttpMessageDiscardWatchdogClientFilter.class.getName() + ".messagePublisher", - generifyAtomicReference()); + WatchdogLeakDetector.generifyAtomicReference()); private static final Logger LOGGER = LoggerFactory.getLogger(HttpMessageDiscardWatchdogClientFilter.class); /** * Instance of {@link HttpMessageDiscardWatchdogClientFilter}. */ - static final HttpMessageDiscardWatchdogClientFilter INSTANCE = new HttpMessageDiscardWatchdogClientFilter(); + static final StreamingHttpConnectionFilterFactory INSTANCE; /** * Instance of {@link StreamingHttpClientFilterFactory} with the cleaner implementation. */ - static final StreamingHttpClientFilterFactory CLIENT_CLEANER = new CleanerStreamingHttpClientFilterFactory(); - - private HttpMessageDiscardWatchdogClientFilter() { - // Singleton - } + static final StreamingHttpClientFilterFactory CLIENT_CLEANER; - @Override - public StreamingHttpConnectionFilter create(final FilterableStreamingHttpConnection connection) { - return new StreamingHttpConnectionFilter(connection) { - @Override - public Single request(final StreamingHttpRequest request) { - return delegate().request(request).map(response -> { - // always write the buffer publisher into the request context. When a downstream subscriber - // arrives, mark the message as subscribed explicitly (having a message present and no - // subscription is an indicator that it must be freed later on). - final AtomicReference> reference = request.context() - .computeIfAbsent(MESSAGE_PUBLISHER_KEY, key -> new AtomicReference<>()); - assert reference != null; - if (reference.getAndSet(response.messageBody()) != null) { - // If a previous message exists, the Single got resubscribed to - // (i.e. during a retry) and so previous message body needs to be cleaned up by the - // user. - LOGGER.warn("Discovered un-drained HTTP response message body which has " + - "been dropped by user code - this is a strong indication of a bug " + - "in a user-defined filter. Response payload (message) body must " + - "be fully consumed before retrying. connectionInfo={}", connectionContext()); - } - - return response.transformMessageBody(msgPublisher -> msgPublisher.beforeSubscriber(() -> { - reference.set(null); - return HttpMessageDiscardWatchdogServiceFilter.NoopSubscriber.INSTANCE; - })); - }); - } - }; + static { + if (WatchdogLeakDetector.strictDetection()) { + INSTANCE = new GcHttpMessageDiscardWatchdogClientFilter(); + CLIENT_CLEANER = new NoopCleaner(); + } else { + INSTANCE = new ContextHttpMessageDiscardWatchdogClientFilter(); + CLIENT_CLEANER = new CleanerStreamingHttpClientFilterFactory(); + } } - @Override - public HttpExecutionStrategy requiredOffloads() { - return HttpExecutionStrategies.offloadNone(); + private HttpMessageDiscardWatchdogClientFilter() { + // No instances } private static final class CleanerStreamingHttpClientFilterFactory implements StreamingHttpClientFilterFactory { @@ -128,4 +100,92 @@ public HttpExecutionStrategy requiredOffloads() { return HttpExecutionStrategies.offloadNone(); } } + + private static final class ContextHttpMessageDiscardWatchdogClientFilter + implements StreamingHttpConnectionFilterFactory { + + @Override + public StreamingHttpConnectionFilter create(final FilterableStreamingHttpConnection connection) { + return new StreamingHttpConnectionFilter(connection) { + @Override + public Single request(final StreamingHttpRequest request) { + return delegate().request(request).map(response -> { + // always write the buffer publisher into the request context. When a downstream subscriber + // arrives, mark the message as subscribed explicitly (having a message present and no + // subscription is an indicator that it must be freed later on). + final AtomicReference> reference = request.context() + .computeIfAbsent(MESSAGE_PUBLISHER_KEY, key -> new AtomicReference<>()); + assert reference != null; + if (reference.getAndSet(response.messageBody()) != null) { + // If a previous message exists, the Single got resubscribed to + // (i.e. during a retry) and so previous message body needs to be cleaned up by the + // user. + LOGGER.warn("Discovered un-drained HTTP response message body which has " + + "been dropped by user code - this is a strong indication of a bug " + + "in a user-defined filter. Response payload (message) body must " + + "be fully consumed before retrying. connectionInfo={}", connectionContext()); + } + + return response.transformMessageBody(msgPublisher -> msgPublisher.beforeSubscriber(() -> { + reference.set(null); + return HttpMessageDiscardWatchdogServiceFilter.NoopSubscriber.INSTANCE; + })); + }); + } + }; + } + + @Override + public HttpExecutionStrategy requiredOffloads() { + return HttpExecutionStrategies.offloadNone(); + } + } + + private static final class GcHttpMessageDiscardWatchdogClientFilter + implements StreamingHttpConnectionFilterFactory { + + @Override + public StreamingHttpConnectionFilter create(FilterableStreamingHttpConnection connection) { + return new StreamingHttpConnectionFilter(connection) { + @Override + public Single request(final StreamingHttpRequest request) { + return delegate().request(request.transformMessageBody(publisher -> + WatchdogLeakDetector.gcLeakDetection(publisher, this::onRequestLeak))) + .map(response -> response.transformMessageBody(publisher -> + WatchdogLeakDetector.gcLeakDetection(publisher, this::onResponseLeak))); + } + + private void onRequestLeak() { + LOGGER.warn("Discovered un-drained HTTP request message body which has " + + "been dropped by user code - this is a strong indication of a bug " + + "in a user-defined filter. The request payload (message) body must " + + "be fully consumed. connectionInfo={}", connectionContext()); + } + + private void onResponseLeak() { + LOGGER.warn("Discovered un-drained HTTP response message body which has " + + "been dropped by user code - this is a strong indication of a bug " + + "in a user-defined filter. Response payload (message) body must " + + "be fully consumed before retrying. connectionInfo={}", connectionContext()); + } + }; + } + + @Override + public HttpExecutionStrategy requiredOffloads() { + return HttpExecutionStrategies.offloadNone(); + } + } + + private static final class NoopCleaner implements StreamingHttpClientFilterFactory { + @Override + public StreamingHttpClientFilter create(FilterableStreamingHttpClient client) { + return new StreamingHttpClientFilter(client) { }; + } + + @Override + public HttpExecutionStrategy requiredOffloads() { + return HttpExecutionStrategies.offloadNone(); + } + } } diff --git a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpMessageDiscardWatchdogServiceFilter.java b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpMessageDiscardWatchdogServiceFilter.java index 875acf0291..c2f70567c2 100644 --- a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpMessageDiscardWatchdogServiceFilter.java +++ b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpMessageDiscardWatchdogServiceFilter.java @@ -39,76 +39,53 @@ import java.util.concurrent.atomic.AtomicReference; import javax.annotation.Nullable; +import static io.servicetalk.http.api.HttpExecutionStrategies.offloadNone; + /** * Filter which tracks message bodies and warns if they are not discarded properly. */ -final class HttpMessageDiscardWatchdogServiceFilter implements StreamingHttpServiceFilterFactory { +final class HttpMessageDiscardWatchdogServiceFilter { private static final Logger LOGGER = LoggerFactory.getLogger(HttpMessageDiscardWatchdogServiceFilter.class); + private static final String REQUEST_LEAK_MESSAGE = + "Discovered un-drained HTTP service request message body which has " + + "been dropped by user code - this is a strong indication of a bug " + + "in a user-defined filter. Requests (or their message body) must " + + "be fully consumed before retrying."; + + private static final String RESPONSE_LEAK_MESSAGE = + "Discovered un-drained HTTP service response message body which has " + + "been dropped by user code - this is a strong indication of a bug " + + "in a user-defined filter. Responses (or their message body) must " + + "be fully consumed before retrying."; + + private static final ContextMap.Key>> MESSAGE_PUBLISHER_KEY = ContextMap.Key + .newKey(HttpMessageDiscardWatchdogServiceFilter.class.getName() + ".messagePublisher", + WatchdogLeakDetector.generifyAtomicReference()); + /** * Instance of {@link HttpMessageDiscardWatchdogServiceFilter}. */ - static final StreamingHttpServiceFilterFactory INSTANCE = new HttpMessageDiscardWatchdogServiceFilter(); + static final StreamingHttpServiceFilterFactory INSTANCE; /** * Instance of {@link HttpLifecycleObserverServiceFilter} with the cleaner implementation. */ - static final StreamingHttpServiceFilterFactory CLEANER = - new HttpLifecycleObserverServiceFilter(new CleanerHttpLifecycleObserver()); - - private static final ContextMap.Key>> MESSAGE_PUBLISHER_KEY = ContextMap.Key - .newKey(HttpMessageDiscardWatchdogServiceFilter.class.getName() + ".messagePublisher", - generifyAtomicReference()); - - private HttpMessageDiscardWatchdogServiceFilter() { - // Singleton - } - - @Override - public StreamingHttpServiceFilter create(final StreamingHttpService service) { - - return new StreamingHttpServiceFilter(service) { - @Override - public Single handle(final HttpServiceContext ctx, - final StreamingHttpRequest request, - final StreamingHttpResponseFactory responseFactory) { - return delegate() - .handle(ctx, request, responseFactory) - .map(response -> { - // always write the buffer publisher into the request context. When a downstream subscriber - // arrives, mark the message as subscribed explicitly (having a message present and no - // subscription is an indicator that it must be freed later on). - final AtomicReference> reference = request.context() - .computeIfAbsent(MESSAGE_PUBLISHER_KEY, key -> new AtomicReference<>()); - assert reference != null; - if (reference.getAndSet(response.messageBody()) != null) { - // If a previous message exists, the Single got resubscribed to - // (i.e. during a retry) and so previous message body needs to be cleaned up by the - // user. - LOGGER.warn("Discovered un-drained HTTP response message body which has " + - "been dropped by user code - this is a strong indication of a bug " + - "in a user-defined filter. Responses (or their message body) must " + - "be fully consumed before retrying."); - } - - return response.transformMessageBody(msgPublisher -> msgPublisher.beforeSubscriber(() -> { - reference.set(null); - return NoopSubscriber.INSTANCE; - })); - }); - } - }; - } - - @Override - public HttpExecutionStrategy requiredOffloads() { - return HttpExecutionStrategies.offloadNone(); + static final StreamingHttpServiceFilterFactory CLEANER; + + static { + if (WatchdogLeakDetector.strictDetection()) { + INSTANCE = new GcHttpMessageWatchdogServiceFilter(); + CLEANER = new NoopFilterFactory(); + } else { + INSTANCE = new ContextHttpMessageDiscardWatchdogServiceFilter(); + CLEANER = new HttpLifecycleObserverServiceFilter(new CleanerHttpLifecycleObserver()); + } } - @SuppressWarnings("unchecked") - static Class generifyAtomicReference() { - return (Class) AtomicReference.class; + private HttpMessageDiscardWatchdogServiceFilter() { + // no instances } static final class NoopSubscriber implements PublisherSource.Subscriber { @@ -173,10 +150,7 @@ public void onExchangeFinally() { if (maybePublisher != null && maybePublisher.get() != null) { // No-one subscribed to the message (or there is none), so if there is a message // tell the user to clean it up. - LOGGER.warn("Discovered un-drained HTTP response message body which has " + - "been dropped by user code - this is a strong indication of a bug " + - "in a user-defined filter. Responses (or their message body) must " + - "be fully consumed before discarding."); + LOGGER.warn(RESPONSE_LEAK_MESSAGE); } } } @@ -195,4 +169,88 @@ public void onResponseCancel() { }; } } + + private static final class GcHttpMessageWatchdogServiceFilter implements StreamingHttpServiceFilterFactory { + @Override + public StreamingHttpServiceFilter create(StreamingHttpService service) { + return new StreamingHttpServiceFilter(service) { + @Override + public Single handle(HttpServiceContext ctx, StreamingHttpRequest request, + StreamingHttpResponseFactory responseFactory) { + return delegate() + .handle(ctx, request.transformMessageBody(publisher -> + WatchdogLeakDetector.gcLeakDetection(publisher, + () -> LOGGER.error(REQUEST_LEAK_MESSAGE))), responseFactory) + .map(response -> response.transformMessageBody(publisher -> + WatchdogLeakDetector.gcLeakDetection(publisher, + () -> LOGGER.warn(RESPONSE_LEAK_MESSAGE)))); + } + }; + } + + @Override + public HttpExecutionStrategy requiredOffloads() { + return HttpExecutionStrategies.offloadNone(); + } + } + + private static final class ContextHttpMessageDiscardWatchdogServiceFilter + implements StreamingHttpServiceFilterFactory { + @Override + public HttpExecutionStrategy requiredOffloads() { + return HttpExecutionStrategies.offloadNone(); + } + + @Override + public StreamingHttpServiceFilter create(final StreamingHttpService service) { + + return new StreamingHttpServiceFilter(service) { + + @Override + public Single handle(final HttpServiceContext ctx, + final StreamingHttpRequest request, + final StreamingHttpResponseFactory responseFactory) { + return delegate() + .handle(ctx, request, responseFactory) + .map(response -> { + // always write the buffer publisher into the request context. When a downstream + // subscriber arrives, mark the message as subscribed explicitly (having a message + // present and no subscription is an indicator that it must be freed later on). + final AtomicReference> reference = request.context() + .computeIfAbsent(MESSAGE_PUBLISHER_KEY, key -> new AtomicReference<>()); + assert reference != null; + if (reference.getAndSet(response.messageBody()) != null) { + // If a previous message exists, the Single got resubscribed + // to (i.e. during a retry) and so previous message body needs to be cleaned up by + // the user. + LOGGER.warn(RESPONSE_LEAK_MESSAGE); + } + + return response.transformMessageBody(msgPublisher -> + msgPublisher.beforeSubscriber(() -> { + reference.set(null); + return NoopSubscriber.INSTANCE; + })); + }); + } + }; + } + } + + private static final class NoopFilterFactory implements StreamingHttpServiceFilterFactory { + + private NoopFilterFactory() { + // singleton + } + + @Override + public StreamingHttpServiceFilter create(StreamingHttpService service) { + return new StreamingHttpServiceFilter(service); + } + + @Override + public HttpExecutionStrategy requiredOffloads() { + return offloadNone(); + } + } } diff --git a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/WatchdogLeakDetector.java b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/WatchdogLeakDetector.java new file mode 100644 index 0000000000..8da57f3ba9 --- /dev/null +++ b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/WatchdogLeakDetector.java @@ -0,0 +1,214 @@ +/* + * Copyright © 2025 Apple Inc. and the ServiceTalk project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.servicetalk.http.netty; + +import io.servicetalk.concurrent.Cancellable; +import io.servicetalk.concurrent.PublisherSource.Subscriber; +import io.servicetalk.concurrent.PublisherSource.Subscription; +import io.servicetalk.concurrent.api.Executor; +import io.servicetalk.concurrent.api.Executors; +import io.servicetalk.concurrent.api.Publisher; +import io.servicetalk.concurrent.api.SourceAdapters; +import io.servicetalk.concurrent.internal.CancelImmediatelySubscriber; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.ref.Reference; +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; +import javax.annotation.Nullable; + +final class WatchdogLeakDetector { + + private static final AtomicIntegerFieldUpdater STATE_UPDATER = + AtomicIntegerFieldUpdater.newUpdater(WatchdogLeakDetector.class, "state"); + + private static final Logger LOGGER = LoggerFactory.getLogger(WatchdogLeakDetector.class); + + private static final WatchdogLeakDetector INSTANCE = new WatchdogLeakDetector(Executors.global()); + + private static final String PROPERTY_NAME = "io.servicetalk.http.netty.leakdetection"; + + private static final String STRICT_MODE = "strict"; + + private static final boolean STRICT_DETECTION; + + static { + String prop = System.getProperty(PROPERTY_NAME); + STRICT_DETECTION = prop != null && prop.equalsIgnoreCase(STRICT_MODE); + } + + private final Executor executor; + private final ReferenceQueue refQueue = new ReferenceQueue<>(); + private final Map, CleanupState> allRefs = new ConcurrentHashMap<>(); + private volatile int state; + + private WatchdogLeakDetector(Executor executor) { + this.executor = executor; + } + + static Publisher gcLeakDetection(Publisher publisher, Runnable onLeak) { + return INSTANCE.gcLeakDetection0(publisher, onLeak); + } + + static boolean strictDetection() { + return STRICT_DETECTION; + } + + @SuppressWarnings("unchecked") + static Class generifyAtomicReference() { + return (Class) AtomicReference.class; + } + + private Publisher gcLeakDetection0(Publisher publisher, Runnable onLeak) { + maybeCleanRefs(); + CleanupState cleanupState = new CleanupState(publisher, onLeak); + Publisher result = publisher.liftSync(subscriber -> new InstrumentedSubscriber<>(subscriber, cleanupState)); + Reference ref = new WeakReference<>(result, refQueue); + allRefs.put(ref, cleanupState); + return result; + } + + private void maybeCleanRefs() { + final Reference testRef = refQueue.poll(); + if (testRef != null && STATE_UPDATER.compareAndSet(this, 0, 1)) { + // There are references to be cleaned but don't do it on this thread. + // TODO: what executor should we really use? + executor.submit(() -> { + Reference ref = testRef; + try { + do { + ref.clear(); + CleanupState cleanupState = allRefs.remove(ref); + if (cleanupState != null) { + cleanupState.check(); + } + } while ((ref = refQueue.poll()) != null); + } finally { + STATE_UPDATER.set(this, 0); + } + }); + } + } + + private static final class InstrumentedSubscriber implements Subscriber { + + private final Subscriber delegate; + private final CleanupState cleanupToken; + + InstrumentedSubscriber(Subscriber delegate, CleanupState cleanupToken) { + this.delegate = delegate; + this.cleanupToken = cleanupToken; + } + + @Override + public void onSubscribe(Subscription subscription) { + cleanupToken.subscribed(subscription); + Subscription nextSubscription = new Subscription() { + @Override + public void request(long n) { + subscription.request(n); + } + + @Override + public void cancel() { + cleanupToken.doComplete(); + subscription.cancel(); + } + }; + delegate.onSubscribe(nextSubscription); + } + + @Override + public void onNext(@Nullable T t) { + delegate.onNext(t); + } + + @Override + public void onError(Throwable t) { + cleanupToken.doComplete(); + delegate.onError(t); + } + + @Override + public void onComplete() { + cleanupToken.doComplete(); + delegate.onComplete(); + } + } + + private static final class CleanupState { + + private static final AtomicReferenceFieldUpdater UPDATER = + AtomicReferenceFieldUpdater.newUpdater(CleanupState.class, Object.class, "state"); + private static final String COMPLETE = "complete"; + + private final Runnable onLeak; + volatile Object state; + + CleanupState(Publisher parent, Runnable onLeak) { + this.onLeak = onLeak; + this.state = parent; + } + + void doComplete() { + UPDATER.set(this, COMPLETE); + } + + private boolean checkComplete() { + Object previous = UPDATER.getAndSet(this, COMPLETE); + if (previous != COMPLETE) { + // This means something leaked. + if (previous instanceof Publisher) { + // never subscribed to. + SourceAdapters.toSource((Publisher) previous).subscribe(CancelImmediatelySubscriber.INSTANCE); + } else { + assert previous instanceof Cancellable; + Cancellable cancellable = (Cancellable) previous; + cancellable.cancel(); + } + return true; + } else { + return false; + } + } + + void subscribed(Subscription subscription) { + while (true) { + Object old = UPDATER.get(this); + if (old == COMPLETE || old instanceof Subscription) { + // TODO: What to do here? + LOGGER.debug("Publisher subscribed to multiple times."); + return; + } else if (UPDATER.compareAndSet(this, old, subscription)) { + return; + } + } + } + + void check() { + if (checkComplete()) { + onLeak.run(); + } + } + } +} diff --git a/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/WatchdogLeakDetectorTest.java b/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/WatchdogLeakDetectorTest.java new file mode 100644 index 0000000000..8f4bf6f3dc --- /dev/null +++ b/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/WatchdogLeakDetectorTest.java @@ -0,0 +1,133 @@ +/* + * Copyright © 2025 Apple Inc. and the ServiceTalk project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.servicetalk.http.netty; + +import io.servicetalk.buffer.api.Buffer; +import io.servicetalk.buffer.netty.BufferAllocators; +import io.servicetalk.concurrent.PublisherSource; +import io.servicetalk.concurrent.api.Publisher; +import io.servicetalk.concurrent.api.Single; +import io.servicetalk.concurrent.api.SourceAdapters; +import io.servicetalk.http.api.HttpClient; +import io.servicetalk.http.api.HttpProtocolConfig; +import io.servicetalk.http.api.HttpResponse; +import io.servicetalk.http.api.HttpResponseStatus; +import io.servicetalk.http.api.HttpServerContext; +import io.servicetalk.http.api.StreamingHttpClient; +import io.servicetalk.http.api.StreamingHttpResponse; + +import io.netty.buffer.ByteBufUtil; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.InetSocketAddress; +import javax.annotation.Nullable; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertFalse; + +final class WatchdogLeakDetectorTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(WatchdogLeakDetectorTest.class); + private static final int ITERATIONS = 10; + + private static boolean leakDetected; + + static { + System.setProperty("io.servicetalk.http.netty.leakdetection", "strict"); + System.setProperty("io.netty.leakDetection.level", "paranoid"); + ByteBufUtil.setLeakListener((type, records) -> { + leakDetected = true; + LOGGER.error("ByteBuf leak detected!"); + }); + } + + @Test + void orphanedServiceRequestPublisher() throws Exception { + // TODO: this fails with HTTP/1.1 as we hang on the `client.request(..).toFuture().get()` call. + HttpProtocolConfig config = HttpProtocolConfigs.h2Default(); + try (HttpServerContext serverContext = HttpServers.forPort(0) + .protocols(config) + .listenStreamingAndAwait((ctx, request, responseFactory) -> { + abandon(request.messageBody()); + return Single.succeeded(responseFactory.ok()); + })) { + try (HttpClient client = HttpClients.forSingleAddress("localhost", + ((InetSocketAddress) serverContext.listenAddress()).getPort()).protocols(config).build()) { + for (int i = 0; i < ITERATIONS && !leakDetected; i++) { + HttpResponse response = client.request(client.post("/foo") + .payloadBody(payload())).toFuture().get(); + assertThat(response.status(), equalTo(HttpResponseStatus.OK)); + + System.gc(); + System.runFinalization(); + } + } + } + assertFalse(leakDetected); + } + + @Test + void orphanClientResponsePublisher() throws Exception { + // TODO: this succeeds with or without strict detection. + HttpProtocolConfig config = HttpProtocolConfigs.h2Default(); + try (HttpServerContext serverContext = HttpServers.forPort(0) + .protocols(config) + .listenAndAwait((ctx, request, responseFactory) -> + Single.succeeded(responseFactory.ok().payloadBody(payload())))) { + try (StreamingHttpClient client = HttpClients.forSingleAddress("localhost", + ((InetSocketAddress) serverContext.listenAddress()).getPort()). + protocols(config).build().asStreamingClient()) { + for (int i = 0; i < ITERATIONS && !leakDetected; i++) { + StreamingHttpResponse response = client.request(client.get("/foo")).toFuture().get(); + assertThat(response.status(), equalTo(HttpResponseStatus.OK)); + abandon(response.messageBody()); + response = null; + + System.gc(); + System.runFinalization(); + } + } + } + assertFalse(leakDetected); + } + + private static Buffer payload() { + return BufferAllocators.DEFAULT_ALLOCATOR.fromAscii("Hello, world!"); + } + + private static void abandon(Publisher messageBody) { + SourceAdapters.toSource(messageBody).subscribe(new PublisherSource.Subscriber() { + @Override + public void onSubscribe(PublisherSource.Subscription subscription) { + } + + @Override + public void onNext(@Nullable Object o) { + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onComplete() { + } + }); + } +}