diff --git a/pom.xml b/pom.xml index 6babd00..c1e6ce3 100644 --- a/pom.xml +++ b/pom.xml @@ -150,8 +150,18 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 2.21.0 + + + ${project.build.directory}/heapdump-%s.hprof + + always + -ea -XX:+UnlockDiagnosticVMOptions + + - - diff --git a/src/main/java/com/test/MainUI.java b/src/main/java/com/test/MainUI.java index 3a10ca9..afaaac9 100644 --- a/src/main/java/com/test/MainUI.java +++ b/src/main/java/com/test/MainUI.java @@ -7,6 +7,7 @@ import com.vaadin.ui.Button; import com.vaadin.ui.Grid; import com.vaadin.ui.HorizontalLayout; +import com.vaadin.ui.Label; import com.vaadin.ui.TextField; import com.vaadin.ui.UI; import com.vaadin.ui.VerticalLayout; @@ -24,12 +25,13 @@ public class MainUI extends UI { final TextField filter; private final Button addNewBtn; + private final Button memoryLeak; @Override protected void init(VaadinRequest request) { // build layout - HorizontalLayout actions = new HorizontalLayout(filter, addNewBtn); + HorizontalLayout actions = new HorizontalLayout(filter, addNewBtn, memoryLeak); VerticalLayout verticalLayout = new VerticalLayout(actions, grid, editor); verticalLayout.setSizeFull(); setContent(verticalLayout); @@ -50,9 +52,7 @@ protected void init(VaadinRequest request) { filter.addValueChangeListener(e -> listCustomers(e.getValue())); // Connect selected Customer to editor or hide if none is selected - grid.asSingleSelect().addValueChangeListener(e -> { - editor.editCustomer(e.getValue()); - }); + grid.asSingleSelect().addValueChangeListener(e -> editor.editCustomer(e.getValue())); // Instantiate and edit new Customer the new button is clicked addNewBtn.addClickListener(e -> editor.editCustomer(new Customer("", ""))); @@ -65,6 +65,14 @@ protected void init(VaadinRequest request) { // Initialize listing listCustomers(null); + + // Add a component with a listener without adding it to the design + memoryLeak.addClickListener(e -> + { + Label label = new Label(); + addNewBtn.addClickListener(btnClick -> label.setValue(label.getValue() + "Clicked again!")); + }); + } public void listCustomers(String filterText) { @@ -82,5 +90,6 @@ public MainUI(CustomerRepository repo, CustomerEditor editor) { this.grid = new Grid<>(Customer.class); this.filter = new TextField(); this.addNewBtn = new Button("New customer", VaadinIcons.PLUS); + this.memoryLeak = new Button("Memory Leak", VaadinIcons.BOMB); } } \ No newline at end of file diff --git a/src/test/java/com/test/ApplicationTests.java b/src/test/java/com/test/ApplicationTests.java index 46e8932..3c5936f 100644 --- a/src/test/java/com/test/ApplicationTests.java +++ b/src/test/java/com/test/ApplicationTests.java @@ -1,16 +1,21 @@ package com.test; import com.github.karibu.testing.MockVaadin; +import com.test.utils.HeapDump; +import com.test.utils.HeapInfo; +import com.test.utils.MemoryLeakFailure; +import com.test.utils.SingletonBeanStoreRetrievalStrategy; import com.vaadin.data.provider.Query; import com.vaadin.spring.internal.UIScopeImpl; import com.vaadin.ui.Button; import com.vaadin.ui.Grid; import com.vaadin.ui.TextField; - import org.junit.After; +import org.junit.AfterClass; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -18,9 +23,12 @@ import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.context.web.WebAppConfiguration; +import java.util.function.Predicate; import java.util.stream.Stream; -import static com.github.karibu.testing.LocatorJ.*; +import static com.github.karibu.testing.LocatorJ._click; +import static com.github.karibu.testing.LocatorJ._get; +import static com.github.karibu.testing.LocatorJ._setValue; import static org.junit.Assert.assertTrue; @RunWith(SpringRunner.class) @@ -31,6 +39,7 @@ public class ApplicationTests { @Autowired private BeanFactory beanFactory; + private final Predicate classesToWatch = name -> name.contains("vaadin") || name.startsWith("com.test."); @Before public void setup() { @@ -52,4 +61,67 @@ public void createNewCustomer() { Stream customerStream = grid.getDataProvider().fetch(new Query<>()); assertTrue(customerStream.map(Customer::getFirstName).anyMatch("Halk"::equals)); } + + @Test(expected = MemoryLeakFailure.class) + public void testMemoryLeak() { + Button memoryLeak = _get(Button.class, spec -> spec.withCaption("Memory Leak")); + _click(memoryLeak); + + HeapInfo.tryGC(); + HeapInfo heapInfo1 = new HeapInfo().classStatistics(classesToWatch); + + _click(memoryLeak); + _click(memoryLeak); + _click(memoryLeak); + _click(memoryLeak); + _click(memoryLeak); + _click(memoryLeak); + + HeapInfo.tryGC(); + HeapInfo heapInfo2 = new HeapInfo().classStatistics(classesToWatch); + HeapInfo delta = heapInfo2.delta(heapInfo1); + + if (delta.values().stream() + .map(HeapInfo.ClassHeapInfo::getClassName) + .anyMatch(s -> s.startsWith("com.vaadin.ui."))) { + LoggerFactory.getLogger(this.getClass()).error(delta.toString()); + throw new MemoryLeakFailure("Memory Leak Detected: " + delta.toString(System.lineSeparator())); + } + } + + @Test + public void createNew2Customers() { + + _click(_get(Button.class, spec -> spec.withCaption("New customer"))); + _setValue(_get(TextField.class, spec -> spec.withCaption("First name")), "Halk"); + _click(_get(Button.class, spec -> spec.withCaption("Save"))); + + HeapInfo.tryGC(); + HeapInfo heapInfo1 = new HeapInfo().classStatistics(classesToWatch); + + _click(_get(Button.class, spec -> spec.withCaption("New customer"))); + _setValue(_get(TextField.class, spec -> spec.withCaption("First name")), "Van Helsing"); + _click(_get(Button.class, spec -> spec.withCaption("Save"))); + + HeapInfo.tryGC(); + HeapInfo heapInfo2 = new HeapInfo().classStatistics(classesToWatch); + + System.out.println("****** Class usage differences START *****"); + HeapInfo delta = heapInfo2.delta(heapInfo1); + System.out.println(delta.toString(System.lineSeparator())); + System.out.println("******* Class usage differences END ******"); + + Grid grid = _get(Grid.class); + Stream customerStream = grid.getDataProvider().fetch(new Query<>()); + assertTrue(customerStream.map(Customer::getFirstName).anyMatch("Halk"::equals)); + customerStream = grid.getDataProvider().fetch(new Query<>()); + assertTrue(customerStream.map(Customer::getFirstName).anyMatch("Van Helsing"::equals)); + } + + @AfterClass + public static void heapDump() { + + HeapDump.heapDump(ApplicationTests.class); + } + } diff --git a/src/test/java/com/test/utils/HeapDump.java b/src/test/java/com/test/utils/HeapDump.java new file mode 100644 index 0000000..06fa871 --- /dev/null +++ b/src/test/java/com/test/utils/HeapDump.java @@ -0,0 +1,79 @@ +package com.test.utils; + +import com.sun.management.HotSpotDiagnosticMXBean; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.slf4j.LoggerFactory; +import org.springframework.data.util.Lazy; + +import javax.management.MBeanServer; +import java.io.File; +import java.io.IOException; +import java.lang.management.ManagementFactory; + +public class HeapDump implements TestRule { + public static final String HEAPDUMP_PATH = "heapdump.path"; + private static final String HOTSPOT_DIAGNOSTICS_BEAN_NAME = + "com.sun.management:type=HotSpotDiagnostic"; + + public HeapDump() { + + } + + public Statement apply(final Statement base, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + base.evaluate(); + heapDump(description.getTestClass()); + } + }; + } + + + private static final Lazy diagnosticMXBean = + new Lazy<>(() -> + { + try { + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + return ManagementFactory.newPlatformMXBeanProxy(server, HOTSPOT_DIAGNOSTICS_BEAN_NAME, HotSpotDiagnosticMXBean.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + ); + + + public static void heapDump(Class clazz) { + heapDump(clazz.getSimpleName()); + } + + public static void heapDump(String qualifier) { + String dumpLocation = System.getProperty(HEAPDUMP_PATH); + if (dumpLocation != null && !dumpLocation.isEmpty()) { + dumpLocation = String.format(dumpLocation, qualifier); + System.out.println("dumpLocation = " + dumpLocation); + heapDumpToFile(dumpLocation); + } else { + LoggerFactory.getLogger(HeapDump.class).info("Property \"" + HEAPDUMP_PATH + "\"%s is not defined, heap dump skipped"); + } + + } + + public static void heapDumpToFile(String dumpLocation) { + try { + System.gc(); + Thread.sleep(300); + System.gc(); + //noinspection ResultOfMethodCallIgnored + new File(dumpLocation).delete(); + LoggerFactory.getLogger(HeapDump.class).info("Dumping heap to {}", dumpLocation); + diagnosticMXBean.get().dumpHeap(dumpLocation, true); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + +} diff --git a/src/test/java/com/test/utils/HeapInfo.java b/src/test/java/com/test/utils/HeapInfo.java new file mode 100644 index 0000000..9bc8f4c --- /dev/null +++ b/src/test/java/com/test/utils/HeapInfo.java @@ -0,0 +1,215 @@ +package com.test.utils; + +import javax.management.InstanceNotFoundException; +import javax.management.MBeanException; +import javax.management.MBeanServer; +import javax.management.MalformedObjectNameException; +import javax.management.ObjectName; +import javax.management.ReflectionException; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; +import java.lang.management.ManagementFactory; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@SuppressWarnings("WeakerAccess") +public class HeapInfo extends HashMap { + private final static ObjectName diagCmdBeanName; + private static final String DIAGNOSTICS_COMMAND_BEAN_NAME = + "com.sun.management:type=DiagnosticCommand"; + + + static { + try { + diagCmdBeanName = new ObjectName(DIAGNOSTICS_COMMAND_BEAN_NAME); + } catch (MalformedObjectNameException e) { + throw new RuntimeException(e); + } + } + + public static final Pattern CSV_PATTERN = Pattern.compile(","); + + @SuppressWarnings({"WeakerAccess", "unused"}) + public static class ClassHeapInfo { + + private String className; + private long instSize; + private long instCount; + private long instBytes; + + public ClassHeapInfo() { + } + + public ClassHeapInfo(String className, long instSize, long instCount, long instBytes) { + this.className = className; + this.instSize = instSize; + this.instCount = instCount; + this.instBytes = instBytes; + } + + @Override + public String toString() { + return className + ":{" + + "size:" + instSize + + ", count:" + instCount + + ", total bytes:" + instBytes + + '}'; + } + + public String getClassName() { + return className; + } + + public long getInstSize() { + return instSize; + } + + public long getInstCount() { + return instCount; + } + + public long getInstBytes() { + return instBytes; + } + + protected void setClassName(String classname) { + this.className = classname; + } + + protected void setInstSize(int instSize) { + this.instSize = instSize; + } + + protected void setInstCount(int instCount) { + this.instCount = instCount; + } + + protected void setInstBytes(int instBytes) { + this.instBytes = instBytes; + } + + public ClassHeapInfo negate() { + return new ClassHeapInfo(className, instSize, -instCount, -instBytes); + } + + public ClassHeapInfo sub(ClassHeapInfo anotherInfo) { + return new ClassHeapInfo(className, + instSize, + instCount - anotherInfo.instCount, + instBytes - anotherInfo.instBytes); + } + + public boolean isNotEmpty() { + return instBytes != 0 || instCount != 0; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ClassHeapInfo)) return false; + ClassHeapInfo that = (ClassHeapInfo) o; + return Objects.equals(className, that.className); + } + + @Override + public int hashCode() { + return Objects.hash(className); + } + } + + public HeapInfo classStatistics(Predicate filter) { + try { + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + String data = (String) server.invoke(diagCmdBeanName, "gcClassStats", new Object[]{new String[]{"-csv", "columns:ClassName,InstSize,InstCount,InstBytes"}}, new String[]{String[].class.getName()}); + BufferedReader reader = new BufferedReader(new StringReader(data)); + String header = reader.readLine(); + if (!header.contains("ClassName")) { + throw new RuntimeException("Something went wrong with JMX beans: " + data); + } + String[] headers = CSV_PATTERN.split(header); + Predicate myFilter = s -> !s.startsWith(HeapInfo.class.getName()); + if (filter != null) { + myFilter = myFilter.and(filter); + } + for (String l; (l = reader.readLine()) != null; ) { + String[] stats = CSV_PATTERN.split(l); + ClassHeapInfo info = new ClassHeapInfo(); + for (int i = 0; i < headers.length; i++) { + if (headers.length <= stats.length) { + + switch (headers[i]) { + case "ClassName": + if (myFilter.test(stats[i])) { + info.setClassName(stats[i]); + put(info.getClassName(), info); + } + break; + case "InstSize": + info.instSize = Long.parseLong(stats[i].trim()); + break; + case "InstCount": + info.instCount = Long.parseLong(stats[i].trim()); + break; + case "InstBytes": + info.instBytes = Long.parseLong(stats[i].trim()); + break; + } + } + + } + } + } catch (InstanceNotFoundException | MBeanException | ReflectionException | IOException e) { + throw new RuntimeException(e); + } + return this; + } + + public HeapInfo delta(HeapInfo compare) { + Map copy = new TreeMap<>(this); + HeapInfo result = new HeapInfo(); + for (ClassHeapInfo anotherInfo : compare.values()) { + ClassHeapInfo heapInfo = copy.remove(anotherInfo.getClassName()); + ClassHeapInfo deltaHeapInfo; + if (heapInfo == null) { + deltaHeapInfo = anotherInfo.negate(); + } else { + deltaHeapInfo = heapInfo.sub(anotherInfo); + } + if (deltaHeapInfo.isNotEmpty()) { + result.put(deltaHeapInfo.getClassName(), deltaHeapInfo); + } + } + copy.values().stream() + .filter(ClassHeapInfo::isNotEmpty) + .forEach(value -> result.put(value.getClassName(), value)); + return result; + } + + public static void tryGC() { + for (int i = 0; i < 5; i++) { + System.gc(); + try { + Thread.sleep(50); + } catch (InterruptedException ignored) { + + } + } + } + + @Override + public String toString() { + return toString("; "); + } + + public String toString(String delimiter) { + return "HeapInfo[" + + values().stream().map(ClassHeapInfo::toString).collect(Collectors.joining(delimiter)) + + "]"; + } +} diff --git a/src/test/java/com/test/utils/MemoryLeakFailure.java b/src/test/java/com/test/utils/MemoryLeakFailure.java new file mode 100644 index 0000000..04c5753 --- /dev/null +++ b/src/test/java/com/test/utils/MemoryLeakFailure.java @@ -0,0 +1,40 @@ +package com.test.utils; + + +public class MemoryLeakFailure extends AssertionError { + + public MemoryLeakFailure() { + } + + public MemoryLeakFailure(Object detailMessage) { + super(detailMessage); + } + + public MemoryLeakFailure(boolean detailMessage) { + super(detailMessage); + } + + public MemoryLeakFailure(char detailMessage) { + super(detailMessage); + } + + public MemoryLeakFailure(int detailMessage) { + super(detailMessage); + } + + public MemoryLeakFailure(long detailMessage) { + super(detailMessage); + } + + public MemoryLeakFailure(float detailMessage) { + super(detailMessage); + } + + public MemoryLeakFailure(double detailMessage) { + super(detailMessage); + } + + public MemoryLeakFailure(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/test/java/com/test/SingletonBeanStoreRetrievalStrategy.java b/src/test/java/com/test/utils/SingletonBeanStoreRetrievalStrategy.java similarity index 97% rename from src/test/java/com/test/SingletonBeanStoreRetrievalStrategy.java rename to src/test/java/com/test/utils/SingletonBeanStoreRetrievalStrategy.java index ac049bd..7f91d62 100644 --- a/src/test/java/com/test/SingletonBeanStoreRetrievalStrategy.java +++ b/src/test/java/com/test/utils/SingletonBeanStoreRetrievalStrategy.java @@ -1,4 +1,4 @@ -package com.test; +package com.test.utils; import com.vaadin.spring.internal.BeanStore; import com.vaadin.spring.internal.BeanStoreRetrievalStrategy; diff --git a/src/test/kotlin/com/test/ApplicationKotlinTest.kt b/src/test/kotlin/com/test/ApplicationKotlinTest.kt index 9533c51..29d2c74 100644 --- a/src/test/kotlin/com/test/ApplicationKotlinTest.kt +++ b/src/test/kotlin/com/test/ApplicationKotlinTest.kt @@ -1,11 +1,14 @@ package com.test import com.github.karibu.testing.* +import com.test.utils.HeapDump +import com.test.utils.SingletonBeanStoreRetrievalStrategy import com.vaadin.spring.internal.UIScopeImpl import com.vaadin.ui.Button import com.vaadin.ui.Grid import com.vaadin.ui.TextField import org.junit.After +import org.junit.AfterClass import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -47,4 +50,12 @@ class ApplicationKotlinTest { dataProvider._findAll().any { it.firstName == "Halk" } } } + + companion object { + @AfterClass + @JvmStatic + fun heapDump() { + HeapDump.heapDump(ApplicationKotlinTest::class.java) + } + } }