View Javadoc
1   /*
2    * Copyright 2025 The Netty Project
3    *
4    * The Netty Project licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a copy of the License at:
7    *
8    *   https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  
17  package io.netty.util.test;
18  
19  import io.netty.util.LeakPresenceDetector;
20  import org.junit.jupiter.api.extension.AfterAllCallback;
21  import org.junit.jupiter.api.extension.AfterEachCallback;
22  import org.junit.jupiter.api.extension.BeforeAllCallback;
23  import org.junit.jupiter.api.extension.BeforeEachCallback;
24  import org.junit.jupiter.api.extension.ConditionEvaluationResult;
25  import org.junit.jupiter.api.extension.ExtensionContext;
26  import org.junit.jupiter.api.extension.ExecutionCondition;
27  
28  import java.util.Objects;
29  import java.util.concurrent.TimeUnit;
30  
31  /**
32   * Junit 5 extension for leak detection using {@link LeakPresenceDetector}.
33   * <p>
34   * Leak presence is checked at the class level. Any resource must be closed at the end of the test class, by the time
35   * {@link org.junit.jupiter.api.AfterAll} has been called. Method-level detection is not possible because some tests
36   * retain resources between methods on the same class, notably parameterized tests that allocate different buffers
37   * before running the test methods with those buffers.
38   * <p>
39   * This extension supports parallel test execution, but has to make some assumptions about the thread lifecycle. The
40   * resource scope for the class is created in {@link org.junit.jupiter.api.BeforeAll}, and then saved in a thread local
41   * on each {@link org.junit.jupiter.api.BeforeEach}. This appears to work well with junit's default parallelism,
42   * despite the use of a fork-join pool that can transfer tasks between threads, but it may lead to problems if tests
43   * make use of fork-join machinery themselves.
44   * <p>
45   * The ThreadLocal holding the scope is {@link InheritableThreadLocal inheritable}, so that e.g. event loops created
46   * in a test are assigned to the test resource scope.
47   */
48  public final class LeakPresenceExtension
49          implements ExecutionCondition, BeforeAllCallback, BeforeEachCallback, AfterEachCallback, AfterAllCallback {
50  
51      static final String LEAK_PRESENCE_DETECTION_DISABLED_PROPERTY =
52              "io.netty.test.leakPresenceDetection.disabled";
53  
54      private static final Object SCOPE_KEY = new Object();
55      private static final Object PREVIOUS_SCOPE_KEY = new Object();
56  
57      static {
58          if (!Boolean.getBoolean(LEAK_PRESENCE_DETECTION_DISABLED_PROPERTY)) {
59              System.setProperty("io.netty.customResourceLeakDetector", WithTransferableScope.class.getName());
60          }
61      }
62  
63      @Override
64      public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
65          if (Boolean.getBoolean(LEAK_PRESENCE_DETECTION_DISABLED_PROPERTY)) {
66              return ConditionEvaluationResult.disabled(
67                      "Leak presence detection disabled by " + LEAK_PRESENCE_DETECTION_DISABLED_PROPERTY);
68          }
69          return ConditionEvaluationResult.enabled("Leak presence detection enabled");
70      }
71  
72      @Override
73      public void beforeAll(ExtensionContext context) {
74          ExtensionContext.Store store = context.getStore(ExtensionContext.Namespace.GLOBAL);
75          ScopeWrapper existingScope = (ScopeWrapper) store.get(SCOPE_KEY);
76          Class<?> testClass = context.getRequiredTestClass();
77          if (existingScope == null) {
78              ScopeWrapper scope = new ScopeWrapper(
79                      new LeakPresenceDetector.ResourceScope(context.getDisplayName()), testClass);
80              store.put(SCOPE_KEY, scope);
81              WithTransferableScope.SCOPE.set(scope);
82              return;
83          }
84  
85          // JUnit creates a distinct ExtensionContext for each @Nested class. Those nested classes must reuse the
86          // shared outer scope, but only when they are enclosed by the class that originally created it.
87          if (!isOwnedBy(testClass, existingScope.owner)) {
88              throw new IllegalStateException("Weird context lifecycle");
89          }
90          WithTransferableScope.SCOPE.set(existingScope);
91      }
92  
93      @Override
94      public void beforeEach(ExtensionContext context) {
95          ScopeWrapper outerScope;
96          ExtensionContext outerContext = context;
97          while (true) {
98              outerScope = (ScopeWrapper)
99                      outerContext.getStore(ExtensionContext.Namespace.GLOBAL).get(SCOPE_KEY);
100             if (outerScope != null) {
101                 break;
102             }
103             outerContext = outerContext.getParent()
104                     .orElseThrow(() -> new IllegalStateException("No resource scope found"));
105         }
106 
107         ScopeWrapper previousScope = WithTransferableScope.SCOPE.get();
108         WithTransferableScope.SCOPE.set(outerScope);
109         if (previousScope != null) {
110             context.getStore(ExtensionContext.Namespace.GLOBAL).put(PREVIOUS_SCOPE_KEY, previousScope);
111         }
112     }
113 
114     @Override
115     public void afterEach(ExtensionContext context) {
116         ScopeWrapper previousScope = (ScopeWrapper)
117                 context.getStore(ExtensionContext.Namespace.GLOBAL).get(PREVIOUS_SCOPE_KEY);
118         if (previousScope != null) {
119             WithTransferableScope.SCOPE.set(previousScope);
120         }
121     }
122 
123     @Override
124     public void afterAll(ExtensionContext context) throws InterruptedException {
125         ExtensionContext.Store store = context.getStore(ExtensionContext.Namespace.GLOBAL);
126         ScopeWrapper scope = (ScopeWrapper) store.get(SCOPE_KEY);
127         if (scope == null) {
128             return;
129         }
130         if (scope.owner != context.getRequiredTestClass()) {
131             return;
132         }
133 
134         // Wait some time for resources to close. Many tests do loop.shutdownGracefully without waiting, and that's ok.
135         long start = System.nanoTime();
136         while (scope.scope.hasOpenResources() && System.nanoTime() - start < TimeUnit.SECONDS.toNanos(5)) {
137             TimeUnit.MILLISECONDS.sleep(100);
138         }
139 
140         scope.scope.close();
141         store.remove(SCOPE_KEY);
142     }
143 
144     /**
145      * Accept the class that created the shared scope and any of its @Nested classes.
146      *
147      * JUnit models nested test classes as separate Class objects and separate ExtensionContexts, not as subclasses of
148      * the outer test class. That means a simple assignability check would reject legitimate nested usage and cause the
149      * nested class to fail in beforeAll even though it should reuse the outer scope.
150      */
151     private static boolean isOwnedBy(Class<?> testClass, Class<?> owner) {
152         Class<?> current = testClass;
153         while (current != null) {
154             if (current == owner) {
155                 return true;
156             }
157             current = current.getEnclosingClass();
158         }
159         return false;
160     }
161 
162     public static final class WithTransferableScope<T> extends LeakPresenceDetector<T> {
163         static final InheritableThreadLocal<ScopeWrapper> SCOPE = new InheritableThreadLocal<>();
164 
165         @SuppressWarnings("unused")
166         public WithTransferableScope(Class<?> resourceType, int samplingInterval) {
167             super(resourceType);
168         }
169 
170         @SuppressWarnings("unused")
171         public WithTransferableScope(Class<?> resourceType, int samplingInterval, long maxActive) {
172             super(resourceType);
173         }
174 
175         @Override
176         protected ResourceScope currentScope() {
177             return Objects.requireNonNull(SCOPE.get(), "Resource created outside test?").scope;
178         }
179     }
180 
181     /**
182      * Prevent junit from closing the ResourceScope automatically.
183      */
184     private static final class ScopeWrapper {
185         final LeakPresenceDetector.ResourceScope scope;
186         /**
187          * The test class that originally created the shared scope.
188          *
189          * Nested classes reuse the same scope but must not close it in afterAll; only this owner may do the final
190          * close and remove the scope from the store.
191          */
192         final Class<?> owner;
193 
194         ScopeWrapper(LeakPresenceDetector.ResourceScope scope, Class<?> owner) {
195             this.scope = scope;
196             this.owner = owner;
197         }
198     }
199 }