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