diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java index bf74e52b3e..3c2943e334 100644 --- a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java @@ -17,6 +17,7 @@ package org.springframework.boot.developertools.autoconfigure; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.developertools.restart.ConditionalOnInitializedRestarter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -27,6 +28,7 @@ import org.springframework.context.annotation.Configuration; * @since 1.3.0 */ @Configuration +@ConditionalOnInitializedRestarter public class LocalDeveloperToolsAutoConfiguration { @Bean diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ChangeableUrls.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ChangeableUrls.java new file mode 100644 index 0000000000..53ea59c176 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ChangeableUrls.java @@ -0,0 +1,111 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.regex.Pattern; + +/** + * A filtered collections of URLs which can be change after the application has started. + * + * @author Phillip Webb + */ +class ChangeableUrls implements Iterable { + + private static final String[] SKIPPED_PROJECTS = { "spring-boot", + "spring-boot-developer-tools", "spring-boot-autoconfigure", + "spring-boot-actuator", "spring-boot-starter" }; + + private static final Pattern STARTER_PATTERN = Pattern + .compile("\\/spring-boot-starter-[\\w-]+\\/"); + + private final List urls; + + private ChangeableUrls(URL... urls) { + List reloadableUrls = new ArrayList(urls.length); + for (URL url : urls) { + if (isReloadable(url)) { + reloadableUrls.add(url); + } + } + this.urls = Collections.unmodifiableList(reloadableUrls); + } + + private boolean isReloadable(URL url) { + String urlString = url.toString(); + return isFolderUrl(urlString) && !isSkipped(urlString); + } + + private boolean isFolderUrl(String urlString) { + return urlString.startsWith("file:") && urlString.endsWith("/"); + } + + private boolean isSkipped(String urlString) { + // Skip certain spring-boot projects to allow them to be imported in the same IDE + for (String skipped : SKIPPED_PROJECTS) { + if (urlString.contains("/" + skipped + "/target/classes/")) { + return true; + } + } + // Skip all starter projects + if (STARTER_PATTERN.matcher(urlString).find()) { + return true; + } + return false; + } + + @Override + public Iterator iterator() { + return this.urls.iterator(); + } + + public int size() { + return this.urls.size(); + } + + public URL[] toArray() { + return this.urls.toArray(new URL[this.urls.size()]); + } + + public List toList() { + return Collections.unmodifiableList(this.urls); + } + + @Override + public String toString() { + return this.urls.toString(); + } + + public static ChangeableUrls fromUrlClassLoader(URLClassLoader classLoader) { + return fromUrls(classLoader.getURLs()); + } + + public static ChangeableUrls fromUrls(Collection urls) { + return fromUrls(new ArrayList(urls).toArray(new URL[urls.size()])); + } + + public static ChangeableUrls fromUrls(URL... urls) { + return new ChangeableUrls(urls); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ConditionalOnInitializedRestarter.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ConditionalOnInitializedRestarter.java new file mode 100644 index 0000000000..96cfea1e51 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ConditionalOnInitializedRestarter.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Conditional; + +/** + * {@link Conditional} that only matches when the {@link RestartInitializer} has been + * applied with non {@code null} URLs. + * + * @author Phillip Webb + * @since 1.3.0 + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Conditional(OnInitializedRestarterCondition.class) +public @interface ConditionalOnInitializedRestarter { + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/DefaultRestartInitializer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/DefaultRestartInitializer.java new file mode 100644 index 0000000000..0bd54b8b6d --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/DefaultRestartInitializer.java @@ -0,0 +1,94 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Default {@link RestartInitializer} that only enable initial restart when running a + * standard "main" method. Skips initialization when running "fat" jars (included + * exploded) or when running from a test. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class DefaultRestartInitializer implements RestartInitializer { + + private static final Set SKIPPED_STACK_ELEMENTS; + static { + Set skipped = new LinkedHashSet(); + skipped.add("org.junit.runners."); + skipped.add("org.springframework.boot.test."); + SKIPPED_STACK_ELEMENTS = Collections.unmodifiableSet(skipped); + } + + @Override + public URL[] getInitialUrls(Thread thread) { + if (!isMain(thread)) { + return null; + } + for (StackTraceElement element : thread.getStackTrace()) { + if (isSkippedStackElement(element)) { + return null; + } + } + return getUrls(thread); + } + + /** + * Returns if the thread is for a main invocation. By default checks the name of the + * thread and the context classloader. + * @param thread the thread to check + * @return {@code true} if the thread is a main invocation + */ + protected boolean isMain(Thread thread) { + return thread.getName().equals("main") + && thread.getContextClassLoader().getClass().getName() + .contains("AppClassLoader"); + } + + /** + * Checks if a specific {@link StackTraceElement} should cause the initializer to be + * skipped. + * @param element the stack element to check + * @return {@code true} if the stack element means that the initializer should be + * skipped + */ + protected boolean isSkippedStackElement(StackTraceElement element) { + for (String skipped : SKIPPED_STACK_ELEMENTS) { + if (element.getClassName().startsWith(skipped)) { + return true; + } + } + return false; + } + + /** + * Return the URLs that should be used with initialization. + * @param thread the source thread + * @return the URLs + */ + protected URL[] getUrls(Thread thread) { + return ChangeableUrls.fromUrlClassLoader( + (URLClassLoader) thread.getContextClassLoader()).toArray(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/MainMethod.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/MainMethod.java new file mode 100644 index 0000000000..825f844a6e --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/MainMethod.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +import org.springframework.util.Assert; + +/** + * The "main" method located from a running thread. + * + * @author Phillip Webb + */ +class MainMethod { + + private final Method method; + + public MainMethod() { + this(Thread.currentThread()); + } + + public MainMethod(Thread thread) { + Assert.notNull(thread, "Thread must not be null"); + this.method = getMainMethod(thread); + } + + private Method getMainMethod(Thread thread) { + for (StackTraceElement element : thread.getStackTrace()) { + if ("main".equals(element.getMethodName())) { + Method method = getMainMethod(element); + if (method != null) { + return method; + } + } + } + throw new IllegalStateException("Unable to find main method"); + } + + private Method getMainMethod(StackTraceElement element) { + try { + Class elementClass = Class.forName(element.getClassName()); + Method method = elementClass.getDeclaredMethod("main", String[].class); + if (Modifier.isStatic(method.getModifiers())) { + return method; + } + } + catch (Exception ex) { + // Ignore + } + return null; + } + + /** + * Returns the actual main method. + * @return the main method + */ + public Method getMethod() { + return this.method; + } + + /** + * Return the name of the declaring class. + * @return the declaring class name + */ + public String getDeclaringClassName() { + return this.method.getDeclaringClass().getName(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/OnInitializedRestarterCondition.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/OnInitializedRestarterCondition.java new file mode 100644 index 0000000000..28eb21bd9e --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/OnInitializedRestarterCondition.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * {@link Condition} that checks that a {@link Restarter} is available an initialized. + * + * @author Phillip Webb + * @see ConditionalOnInitializedRestarter + */ +class OnInitializedRestarterCondition extends SpringBootCondition { + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, + AnnotatedTypeMetadata metadata) { + Restarter restarter = getRestarter(); + if (restarter == null) { + return ConditionOutcome.noMatch("Restarter unavailable"); + } + if (restarter.getInitialUrls() == null) { + return ConditionOutcome.noMatch("Restarter initialized without URLs"); + } + return ConditionOutcome.match("Restarter available and initialized"); + } + + private Restarter getRestarter() { + try { + return Restarter.getInstance(); + } + catch (Exception ex) { + return null; + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartApplicationListener.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartApplicationListener.java new file mode 100644 index 0000000000..4bb0ebda39 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartApplicationListener.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import org.springframework.boot.context.event.ApplicationFailedEvent; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.core.Ordered; + +/** + * {@link ApplicationListener} to initialize the {@link Restarter}. + * + * @author Phillip Webb + * @since 1.3.0 + * @see Restarter + */ +public class RestartApplicationListener implements ApplicationListener, + Ordered { + + private int order = HIGHEST_PRECEDENCE; + + @Override + public void onApplicationEvent(ApplicationEvent event) { + if (event instanceof ApplicationStartedEvent) { + Restarter.initialize(((ApplicationStartedEvent) event).getArgs()); + } + if (event instanceof ApplicationReadyEvent + || event instanceof ApplicationFailedEvent) { + Restarter.getInstance().finish(); + } + } + + @Override + public int getOrder() { + return this.order; + } + + /** + * Set the order of the listener. + * @param order the order of the listener + */ + public void setOrder(int order) { + this.order = order; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartInitializer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartInitializer.java new file mode 100644 index 0000000000..36692dadd5 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartInitializer.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import java.net.URL; + +/** + * Strategy interface used to initialize a {@link Restarter}. + * + * @author Phillip Webb + * @since 1.3.0 + * @see DefaultRestartInitializer + */ +public interface RestartInitializer { + + /** + * {@link RestartInitializer} that doesn't return any URLs. + */ + public static final RestartInitializer NONE = new RestartInitializer() { + + @Override + public URL[] getInitialUrls(Thread thread) { + return null; + } + + }; + + /** + * Return the initial set of URLs for the {@link Restarter} or {@code null} if no + * initial restart is required. + * @param thread the source thread + * @return initial URLs or {@code null} + */ + URL[] getInitialUrls(Thread thread); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartLauncher.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartLauncher.java new file mode 100644 index 0000000000..7b5a9d7d23 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartLauncher.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import java.lang.reflect.Method; + +/** + * Thread used to launch a restarted application. + * + * @author Phillip Webb + */ +class RestartLauncher extends Thread { + + private final String mainClassName; + + private final String[] args; + + public RestartLauncher(ClassLoader classLoader, String mainClassName, String[] args, + UncaughtExceptionHandler exceptionHandler) { + this.mainClassName = mainClassName; + this.args = args; + setName("restartedMain"); + setUncaughtExceptionHandler(exceptionHandler); + setDaemon(false); + setContextClassLoader(classLoader); + } + + @Override + public void run() { + try { + Class mainClass = getContextClassLoader().loadClass(this.mainClassName); + Method mainMethod = mainClass.getDeclaredMethod("main", String[].class); + mainMethod.invoke(null, new Object[] { this.args }); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/Restarter.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/Restarter.java new file mode 100644 index 0000000000..bdf57ee172 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/Restarter.java @@ -0,0 +1,519 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import java.beans.Introspector; +import java.lang.Thread.UncaughtExceptionHandler; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URL; +import java.util.Arrays; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.BlockingDeque; +import java.util.concurrent.Callable; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.CachedIntrospectionResults; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.developertools.restart.classloader.RestartClassLoader; +import org.springframework.boot.logging.DeferredLog; +import org.springframework.cglib.core.ClassNameReader; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; + +/** + * Allows a running application to be restarted with an updated classpath. The restarter + * works by creating a new application ClassLoader that is split into two parts. The top + * part contains static URLs that don't change (for example 3rd party libraries and Spring + * Boot itself) and the bottom part contains URLs where classes and resources might be + * updated. + *

+ * The Restarter should be {@link #initialize(String[]) initialized} early to ensure that + * classes are loaded multiple times. Mostly the {@link RestartApplicationListener} can be + * relied upon to perform initialization, however, you may need to call + * {@link #initialize(String[])} directly if your SpringApplication arguments are not + * identical to your main method arguments. + *

+ * By default, applications running in an IDE (i.e. those not packaged as "fat jars") will + * automatically detect URLs that can change. It's also possible to manually configure + * URLs or class file updates for remote restart scenarios. + * + * @author Phillip Webb + * @since 1.3.0 + * @see RestartApplicationListener + * @see #initialize(String[]) + * @see #getInstance() + * @see #restart() + */ +public class Restarter { + + private static Restarter instance; + + private Log logger = new DeferredLog(); + + private final boolean forceReferenceCleanup; + + private URL[] urls; + + private final String mainClassName; + + private final ClassLoader applicationClassLoader; + + private final String[] args; + + private final UncaughtExceptionHandler exceptionHandler; + + private final BlockingDeque leakSafeThreads = new LinkedBlockingDeque(); + + private boolean finished = false; + + private Lock stopLock = new ReentrantLock(); + + /** + * Internal constructor to create a new {@link Restarter} instance. + * @param thread the source thread + * @param args the application arguments + * @param forceReferenceCleanup if soft/weak reference cleanup should be forced + * @param initializer + * @see #initialize(String[]) + */ + protected Restarter(Thread thread, String[] args, boolean forceReferenceCleanup, + RestartInitializer initializer) { + Assert.notNull(thread, "Thread must not be null"); + Assert.notNull(args, "Args must not be null"); + Assert.notNull(initializer, "Initializer must not be null"); + this.logger.debug("Creating new Restarter for thread " + thread); + SilentExitExceptionHandler.setup(thread); + this.forceReferenceCleanup = forceReferenceCleanup; + this.urls = initializer.getInitialUrls(thread); + this.mainClassName = getMainClassName(thread); + this.applicationClassLoader = thread.getContextClassLoader(); + this.args = args; + this.exceptionHandler = thread.getUncaughtExceptionHandler(); + this.leakSafeThreads.add(new LeakSafeThread()); + } + + private String getMainClassName(Thread thread) { + try { + return new MainMethod(thread).getDeclaringClassName(); + } + catch (Exception ex) { + return null; + } + } + + protected void initialize(boolean restartOnInitialize) { + preInitializeLeakyClasses(); + if (this.urls != null) { + if (restartOnInitialize) { + this.logger.debug("Immediately restarting application"); + immediateRestart(); + } + } + } + + private void immediateRestart() { + try { + getLeakSafeThread().callAndWait(new Callable() { + + @Override + public Void call() throws Exception { + start(); + return null; + } + + }); + } + catch (Exception ex) { + this.logger.warn("Unable to initialize restarter", ex); + } + SilentExitExceptionHandler.exitCurrentThread(); + } + + /** + * CGLIB has a private exception field which needs to initialized early to ensure that + * the stacktrace doesn't retain a reference to the RestartClassLoader. + */ + private void preInitializeLeakyClasses() { + try { + Class readerClass = ClassNameReader.class; + Field field = readerClass.getDeclaredField("EARLY_EXIT"); + field.setAccessible(true); + ((Throwable) field.get(null)).fillInStackTrace(); + } + catch (Exception ex) { + this.logger.warn("Unable to pre-initialize classes", ex); + } + } + + /** + * Return a {@link ThreadFactory} that can be used to create leak safe threads. + * @return a leak safe thread factory + */ + public ThreadFactory getThreadFactory() { + return new LeakSafeThreadFactory(); + } + + /** + * Restart the running application. + */ + public void restart() { + this.logger.debug("Restarting application"); + getLeakSafeThread().call(new Callable() { + + @Override + public Void call() throws Exception { + Restarter.this.stop(); + Restarter.this.start(); + return null; + } + + }); + } + + /** + * Start the application. + * @throws Exception + */ + protected void start() throws Exception { + Assert.notNull(this.mainClassName, "Unable to find the main class to restart"); + ClassLoader parent = this.applicationClassLoader; + ClassLoader classLoader = new RestartClassLoader(parent, this.urls, this.logger); + if (this.logger.isDebugEnabled()) { + this.logger.debug("Starting application " + this.mainClassName + + " with URLs " + Arrays.asList(this.urls)); + } + relaunch(classLoader); + } + + /** + * Relaunch the application using the specified classloader. + * @param classLoader the classloader to use + * @throws Exception + */ + protected void relaunch(ClassLoader classLoader) throws Exception { + RestartLauncher launcher = new RestartLauncher(classLoader, this.mainClassName, + this.args, this.exceptionHandler); + launcher.start(); + launcher.join(); + } + + /** + * Stop the application. + * @throws Exception + */ + protected void stop() throws Exception { + this.logger.debug("Stopping application"); + this.stopLock.lock(); + try { + triggerShutdownHooks(); + cleanupCaches(); + if (this.forceReferenceCleanup) { + forceReferenceCleanup(); + } + } + finally { + this.stopLock.unlock(); + } + System.gc(); + System.runFinalization(); + } + + @SuppressWarnings("rawtypes") + private void triggerShutdownHooks() throws Exception { + Class hooksClass = Class.forName("java.lang.ApplicationShutdownHooks"); + Method runHooks = hooksClass.getDeclaredMethod("runHooks"); + runHooks.setAccessible(true); + runHooks.invoke(null); + Field field = hooksClass.getDeclaredField("hooks"); + field.setAccessible(true); + field.set(null, new IdentityHashMap()); + } + + private void cleanupCaches() throws Exception { + Introspector.flushCaches(); + cleanupKnownCaches(); + } + + private void cleanupKnownCaches() throws Exception { + // Whilst not strictly necessary it helps to cleanup soft reference caches + // early rather than waiting for memory limits to be reached + clear(ResolvableType.class, "cache"); + clear("org.springframework.core.SerializableTypeWrapper", "cache"); + clear(CachedIntrospectionResults.class, "acceptedClassLoaders"); + clear(CachedIntrospectionResults.class, "strongClassCache"); + clear(CachedIntrospectionResults.class, "softClassCache"); + clear(ReflectionUtils.class, "declaredFieldsCache"); + clear(ReflectionUtils.class, "declaredMethodsCache"); + clear(AnnotationUtils.class, "findAnnotationCache"); + clear(AnnotationUtils.class, "annotatedInterfaceCache"); + clear("com.sun.naming.internal.ResourceManager", "propertiesCache"); + } + + private void clear(String className, String fieldName) { + try { + clear(Class.forName(className), fieldName); + } + catch (Exception ex) { + this.logger.debug("Unable to clear field " + className + " " + fieldName, ex); + } + } + + private void clear(Class type, String fieldName) throws Exception { + Field field = type.getDeclaredField(fieldName); + field.setAccessible(true); + Object instance = field.get(null); + if (instance instanceof Set) { + ((Set) instance).clear(); + } + if (instance instanceof Map) { + Map map = ((Map) instance); + for (Iterator iterator = map.keySet().iterator(); iterator.hasNext();) { + Object value = iterator.next(); + if (value instanceof Class + && ((Class) value).getClassLoader() instanceof RestartClassLoader) { + iterator.remove(); + } + + } + } + } + + /** + * Cleanup any soft/weak references by forcing an {@link OutOfMemoryError} error. + */ + private void forceReferenceCleanup() { + try { + final List memory = new LinkedList(); + while (true) { + memory.add(new long[102400]); + } + } + catch (final OutOfMemoryError ex) { + } + } + + /** + * Called to finish {@link Restarter} initialization when application logging is + * available. + */ + synchronized void finish() { + if (!isFinished()) { + this.logger = DeferredLog.replay(this.logger, LogFactory.getLog(getClass())); + this.finished = true; + } + } + + boolean isFinished() { + return this.finished; + } + + private LeakSafeThread getLeakSafeThread() { + try { + return this.leakSafeThreads.takeFirst(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(ex); + } + } + + /** + * Return the initial set of URLs as configured by the {@link RestartInitializer}. + * @return the initial URLs or {@code null} + */ + public URL[] getInitialUrls() { + return this.urls; + } + + /** + * Initialize restart support. See + * {@link #initialize(String[], boolean, RestartInitializer)} for details. + * @param args main application arguments + * @see #initialize(String[], boolean, RestartInitializer) + */ + public static void initialize(String[] args) { + initialize(args, false, new DefaultRestartInitializer()); + } + + /** + * Initialize restart support. See + * {@link #initialize(String[], boolean, RestartInitializer)} for details. + * @param args main application arguments + * @param initializer the restart initializer + * @see #initialize(String[], boolean, RestartInitializer) + */ + public static void initialize(String[] args, RestartInitializer initializer) { + initialize(args, false, initializer, true); + } + + /** + * Initialize restart support. See + * {@link #initialize(String[], boolean, RestartInitializer)} for details. + * @param args main application arguments + * @param forceReferenceCleanup if forcing of soft/weak reference should happen on + * @see #initialize(String[], boolean, RestartInitializer) + */ + public static void initialize(String[] args, boolean forceReferenceCleanup) { + initialize(args, forceReferenceCleanup, new DefaultRestartInitializer()); + } + + /** + * Initialize restart support. See + * {@link #initialize(String[], boolean, RestartInitializer)} for details. + * @param args main application arguments + * @param forceReferenceCleanup if forcing of soft/weak reference should happen on + * @param initializer the restart initializer + * @see #initialize(String[], boolean, RestartInitializer) + */ + public static void initialize(String[] args, boolean forceReferenceCleanup, + RestartInitializer initializer) { + initialize(args, forceReferenceCleanup, initializer, true); + } + + /** + * Initialize restart support for the current application. Called automatically by + * {@link RestartApplicationListener} but can also be called directly if main + * application arguments are not the same as those passed to the + * {@link SpringApplication}. + * @param args main application arguments + * @param forceReferenceCleanup if forcing of soft/weak reference should happen on + * each restart. This will slow down restarts and is intended primarily for testing + * @param initializer the restart initializer + * @param restartOnInitialize if the restarter should be restarted immediately when + * the {@link RestartInitializer} returns non {@code null} results + */ + public static void initialize(String[] args, boolean forceReferenceCleanup, + RestartInitializer initializer, boolean restartOnInitialize) { + if (instance == null) { + synchronized (Restarter.class) { + instance = new Restarter(Thread.currentThread(), args, + forceReferenceCleanup, initializer); + } + instance.initialize(restartOnInitialize); + } + } + + /** + * Return the active {@link Restarter} instance. Cannot be called before + * {@link #initialize(String[]) initialization}. + * @return the restarter + */ + public synchronized static Restarter getInstance() { + Assert.state(instance != null, "Restarter has not been initialized"); + return instance; + } + + /** + * Set the restarter instance (useful for testing). + * @param instance the instance to set + */ + final static void setInstance(Restarter instance) { + Restarter.instance = instance; + } + + /** + * Clear the instance. Primarily provided for tests and not usually used in + * application code. + */ + public static void clearInstance() { + instance = null; + } + + /** + * Thread that is created early so not to retain the {@link RestartClassLoader}. + */ + private class LeakSafeThread extends Thread { + + private Callable callable; + + private Object result; + + public LeakSafeThread() { + setDaemon(false); + } + + public void call(Callable callable) { + this.callable = callable; + start(); + } + + @SuppressWarnings("unchecked") + public V callAndWait(Callable callable) { + this.callable = callable; + start(); + try { + join(); + return (V) this.result; + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(ex); + } + } + + @Override + public void run() { + // We are safe to refresh the ActionThread (and indirectly call + // AccessController.getContext()) since our stack doesn't include the + // RestartClassLoader + try { + Restarter.this.leakSafeThreads.put(new LeakSafeThread()); + this.result = this.callable.call(); + } + catch (Exception ex) { + ex.printStackTrace(); + System.exit(1); + } + } + + } + + /** + * {@link ThreadFactory} that creates a leak safe thead. + */ + private class LeakSafeThreadFactory implements ThreadFactory { + + @Override + public Thread newThread(final Runnable runnable) { + return getLeakSafeThread().callAndWait(new Callable() { + + @Override + public Thread call() throws Exception { + Thread thread = new Thread(runnable); + thread.setContextClassLoader(Restarter.this.applicationClassLoader); + return thread; + } + + }); + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandler.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandler.java new file mode 100644 index 0000000000..04bf3e7086 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandler.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-2014 the original author or 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 org.springframework.boot.developertools.restart; + +import java.lang.Thread.UncaughtExceptionHandler; + +/** + * {@link UncaughtExceptionHandler} decorator that allows a thread to exit silently. + * + * @author Phillip Webb + */ +class SilentExitExceptionHandler implements UncaughtExceptionHandler { + + private final UncaughtExceptionHandler delegate; + + public SilentExitExceptionHandler(UncaughtExceptionHandler delegate) { + this.delegate = delegate; + } + + @Override + public void uncaughtException(Thread thread, Throwable exception) { + if (exception instanceof SilentExitException) { + return; + } + if (this.delegate != null) { + this.delegate.uncaughtException(thread, exception); + } + } + + public static void setup(Thread thread) { + UncaughtExceptionHandler handler = thread.getUncaughtExceptionHandler(); + if (!(handler instanceof SilentExitExceptionHandler)) { + handler = new SilentExitExceptionHandler(handler); + thread.setUncaughtExceptionHandler(handler); + } + } + + public static void exitCurrentThread() { + throw new SilentExitException(); + } + + private static class SilentExitException extends RuntimeException { + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/package-info.java new file mode 100644 index 0000000000..a2ae4bdaaf --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or 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. + */ + +/** + * Application restart support + */ +package org.springframework.boot.developertools.restart; + diff --git a/spring-boot-developer-tools/src/main/resources/META-INF/spring.factories b/spring-boot-developer-tools/src/main/resources/META-INF/spring.factories index f8bcd39934..5dcd08d49d 100644 --- a/spring-boot-developer-tools/src/main/resources/META-INF/spring.factories +++ b/spring-boot-developer-tools/src/main/resources/META-INF/spring.factories @@ -1,3 +1,7 @@ +# Application Listeners +org.springframework.context.ApplicationListener=\ +org.springframework.boot.developertools.restart.RestartApplicationListener + # Auto Configure org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ org.springframework.boot.developertools.autoconfigure.LocalDeveloperToolsAutoConfiguration diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java index fecd38cb42..a1dd6a6898 100644 --- a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java @@ -26,6 +26,9 @@ import org.junit.Test; import org.junit.rules.ExpectedException; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration; +import org.springframework.boot.developertools.restart.MockRestartInitializer; +import org.springframework.boot.developertools.restart.MockRestarter; +import org.springframework.boot.developertools.restart.Restarter; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @@ -45,6 +48,9 @@ public class LocalDeveloperToolsAutoConfigurationTests { @Rule public ExpectedException thrown = ExpectedException.none(); + @Rule + public MockRestarter mockRestarter = new MockRestarter(); + private int liveReloadPort = SocketUtils.findAvailableTcpPort(); private ConfigurableApplicationContext context; @@ -70,6 +76,7 @@ public class LocalDeveloperToolsAutoConfigurationTests { private ConfigurableApplicationContext initializeAndRun(Class config, Map properties) { + Restarter.initialize(new String[0], false, new MockRestartInitializer(), false); SpringApplication application = new SpringApplication(config); application.setDefaultProperties(getDefaultProperties(properties)); application.setWebEnvironment(false); diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/ChangeableUrlsTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/ChangeableUrlsTests.java new file mode 100644 index 0000000000..c29338e837 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/ChangeableUrlsTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import java.io.File; +import java.io.IOException; +import java.net.URL; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link ChangeableUrls}. + * + * @author Phillip Webb + */ +public class ChangeableUrlsTests { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void folderUrl() throws Exception { + URL url = makeUrl("myproject"); + assertThat(ChangeableUrls.fromUrls(url).size(), equalTo(1)); + } + + @Test + public void fileUrl() throws Exception { + URL url = this.temporaryFolder.newFile().toURI().toURL(); + assertThat(ChangeableUrls.fromUrls(url).size(), equalTo(0)); + } + + @Test + public void httpUrl() throws Exception { + URL url = new URL("http://spring.io"); + assertThat(ChangeableUrls.fromUrls(url).size(), equalTo(0)); + } + + @Test + public void skipsUrls() throws Exception { + ChangeableUrls urls = ChangeableUrls + .fromUrls(makeUrl("spring-boot"), makeUrl("spring-boot-autoconfigure"), + makeUrl("spring-boot-actuator"), makeUrl("spring-boot-starter"), + makeUrl("spring-boot-starter-some-thing")); + assertThat(urls.size(), equalTo(0)); + } + + private URL makeUrl(String name) throws IOException { + File file = this.temporaryFolder.newFolder(); + file = new File(file, name); + file = new File(file, "target"); + file = new File(file, "classes"); + file.mkdirs(); + return file.toURI().toURL(); + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/DefaultRestartInitializerTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/DefaultRestartInitializerTests.java new file mode 100644 index 0000000000..c7b899e2c6 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/DefaultRestartInitializerTests.java @@ -0,0 +1,130 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import java.net.URL; + +import org.junit.Test; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link DefaultRestartInitializer}. + * + * @author Phillip Webb + */ +public class DefaultRestartInitializerTests { + + @Test + public void nullForTests() throws Exception { + MockRestartInitializer initializer = new MockRestartInitializer(true); + assertThat(initializer.getInitialUrls(Thread.currentThread()), nullValue()); + } + + @Test + public void validMainThread() throws Exception { + MockRestartInitializer initializer = new MockRestartInitializer(false); + ClassLoader classLoader = new MockAppClassLoader(getClass().getClassLoader()); + Thread thread = new Thread(); + thread.setName("main"); + thread.setContextClassLoader(classLoader); + assertThat(initializer.isMain(thread), equalTo(true)); + assertThat(initializer.getInitialUrls(thread), not(nullValue())); + } + + @Test + public void threadNotNamedMain() throws Exception { + MockRestartInitializer initializer = new MockRestartInitializer(false); + ClassLoader classLoader = new MockAppClassLoader(getClass().getClassLoader()); + Thread thread = new Thread(); + thread.setName("buscuit"); + thread.setContextClassLoader(classLoader); + assertThat(initializer.isMain(thread), equalTo(false)); + assertThat(initializer.getInitialUrls(thread), nullValue()); + } + + @Test + public void threadNotUsingAppClassLoader() throws Exception { + MockRestartInitializer initializer = new MockRestartInitializer(false); + ClassLoader classLoader = new MockLauncherClassLoader(getClass().getClassLoader()); + Thread thread = new Thread(); + thread.setName("main"); + thread.setContextClassLoader(classLoader); + assertThat(initializer.isMain(thread), equalTo(false)); + assertThat(initializer.getInitialUrls(thread), nullValue()); + } + + @Test + public void skipsDueToJUnitStacks() throws Exception { + testSkipStack("org.junit.runners.Something", true); + } + + @Test + public void skipsDueToSpringTest() throws Exception { + testSkipStack("org.springframework.boot.test.Something", true); + } + + private void testSkipStack(String className, boolean expected) { + MockRestartInitializer initializer = new MockRestartInitializer(true); + StackTraceElement element = new StackTraceElement(className, "someMethod", + "someFile", 123); + assertThat(initializer.isSkippedStackElement(element), equalTo(expected)); + } + + private static class MockAppClassLoader extends ClassLoader { + + public MockAppClassLoader(ClassLoader parent) { + super(parent); + } + + } + + private static class MockLauncherClassLoader extends ClassLoader { + + public MockLauncherClassLoader(ClassLoader parent) { + super(parent); + } + + } + + private static class MockRestartInitializer extends DefaultRestartInitializer { + + private final boolean considerStackElements; + + public MockRestartInitializer(boolean considerStackElements) { + this.considerStackElements = considerStackElements; + } + + @Override + protected boolean isSkippedStackElement(StackTraceElement element) { + if (!this.considerStackElements) { + return false; + } + return true; + } + + @Override + protected URL[] getUrls(Thread thread) { + return new URL[0]; + } + + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MainMethodTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MainMethodTests.java new file mode 100644 index 0000000000..6e203e6a62 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MainMethodTests.java @@ -0,0 +1,155 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import java.lang.reflect.Method; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.util.ReflectionUtils; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link MainMethod}. + * + * @author Phillip Webb + */ +public class MainMethodTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + private static ThreadLocal mainMethod = new ThreadLocal(); + + private Method actualMain; + + @Before + public void setup() throws Exception { + this.actualMain = Valid.class.getMethod("main", String[].class); + } + + @Test + public void threadMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Thread must not be null"); + new MainMethod(null); + } + + @Test + public void validMainMethod() throws Exception { + MainMethod method = new TestThread(new Runnable() { + @Override + public void run() { + Valid.main(); + } + }).test(); + assertThat(method.getMethod(), equalTo(this.actualMain)); + assertThat(method.getDeclaringClassName(), equalTo(this.actualMain + .getDeclaringClass().getName())); + } + + @Test + public void missingArgsMainMethod() throws Exception { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Unable to find main method"); + new TestThread(new Runnable() { + @Override + public void run() { + MissingArgs.main(); + } + }).test(); + } + + @Test + public void nonStatic() throws Exception { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Unable to find main method"); + new TestThread(new Runnable() { + @Override + public void run() { + new NonStaticMain().main(); + } + }).test(); + } + + private static class TestThread extends Thread { + + private final Runnable runnable; + + private Exception exception; + + private MainMethod mainMethod; + + public TestThread(Runnable runnable) { + this.runnable = runnable; + } + + public MainMethod test() throws InterruptedException { + start(); + join(); + if (this.exception != null) { + ReflectionUtils.rethrowRuntimeException(this.exception); + } + return this.mainMethod; + } + + @Override + public void run() { + try { + this.runnable.run(); + this.mainMethod = MainMethodTests.mainMethod.get(); + } + catch (Exception ex) { + this.exception = ex; + } + } + + } + + public static class Valid { + + public static void main(String... args) { + someOtherMethod(); + } + + private static void someOtherMethod() { + mainMethod.set(new MainMethod()); + } + + } + + public static class MissingArgs { + + public static void main() { + mainMethod.set(new MainMethod()); + } + + } + + private static class NonStaticMain { + + public void main(String... args) { + mainMethod.set(new MainMethod()); + } + + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MockRestartInitializer.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MockRestartInitializer.java new file mode 100644 index 0000000000..38a289804e --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MockRestartInitializer.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import java.net.URL; + +import org.springframework.boot.developertools.restart.RestartInitializer; + +/** + * Simple mock {@link RestartInitializer} that returns an empty array of URLs. + * + * @author Phillip Webb + */ +public class MockRestartInitializer implements RestartInitializer { + + @Override + public URL[] getInitialUrls(Thread thread) { + return new URL[] {}; + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MockRestarter.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MockRestarter.java new file mode 100644 index 0000000000..b503459508 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MockRestarter.java @@ -0,0 +1,78 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ThreadFactory; + +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Mocked version of {@link Restarter}. + * + * @author Phillip Webb + */ +public class MockRestarter implements TestRule { + + private Map attributes = new HashMap(); + + private Restarter mock = mock(Restarter.class); + + @Override + public Statement apply(final Statement base, Description description) { + return new Statement() { + + @Override + public void evaluate() throws Throwable { + setup(); + base.evaluate(); + cleanup(); + } + + }; + } + + private void setup() { + Restarter.setInstance(this.mock); + given(this.mock.getInitialUrls()).willReturn(new URL[] {}); + given(this.mock.getThreadFactory()).willReturn(new ThreadFactory() { + + @Override + public Thread newThread(Runnable r) { + return new Thread(r); + } + + }); + } + + private void cleanup() { + this.attributes.clear(); + Restarter.clearInstance(); + } + + public Restarter getMock() { + return this.mock; + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/OnInitializedRestarterConditionTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/OnInitializedRestarterConditionTests.java new file mode 100644 index 0000000000..fb3d4f12e9 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/OnInitializedRestarterConditionTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import java.net.URL; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link OnInitializedRestarterCondition}. + * + * @author Phillip Webb + */ +public class OnInitializedRestarterConditionTests { + + private static Object wait = new Object(); + + @Before + @After + public void cleanup() { + Restarter.clearInstance(); + } + + @Test + public void noInstance() throws Exception { + Restarter.clearInstance(); + ConfigurableApplicationContext context = new AnnotationConfigApplicationContext( + Config.class); + assertThat(context.containsBean("bean"), equalTo(false)); + context.close(); + } + + @Test + public void noInitialization() throws Exception { + Restarter.initialize(new String[0], false, RestartInitializer.NONE); + ConfigurableApplicationContext context = new AnnotationConfigApplicationContext( + Config.class); + assertThat(context.containsBean("bean"), equalTo(false)); + context.close(); + } + + @Test + public void initialized() throws Exception { + Thread thread = new Thread() { + + @Override + public void run() { + TestInitialized.main(); + }; + + }; + thread.start(); + synchronized (wait) { + wait.wait(); + } + } + + public static class TestInitialized { + + public static void main(String... args) { + RestartInitializer initializer = mock(RestartInitializer.class); + given(initializer.getInitialUrls((Thread) any())).willReturn(new URL[0]); + Restarter.initialize(new String[0], false, initializer); + ConfigurableApplicationContext context = new AnnotationConfigApplicationContext( + Config.class); + assertThat(context.containsBean("bean"), equalTo(true)); + context.close(); + synchronized (wait) { + wait.notify(); + } + } + + } + + @Configuration + public static class Config { + + @Bean + @ConditionalOnInitializedRestarter + public String bean() { + return "bean"; + } + + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/RestartApplicationListenerTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/RestartApplicationListenerTests.java new file mode 100644 index 0000000000..ab91a120fe --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/RestartApplicationListenerTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.event.ApplicationFailedEvent; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.Ordered; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link RestartApplicationListener}. + * + * @author Phillip Webb + */ +public class RestartApplicationListenerTests { + + @Before + @After + public void cleanup() { + Restarter.clearInstance(); + } + + @Test + public void isHighestPriority() throws Exception { + assertThat(new RestartApplicationListener().getOrder(), + equalTo(Ordered.HIGHEST_PRECEDENCE)); + } + + @Test + public void initializeWithReady() throws Exception { + testInitialize(false); + } + + @Test + public void initializeWithFail() throws Exception { + testInitialize(true); + } + + private void testInitialize(boolean failed) { + Restarter.clearInstance(); + RestartApplicationListener listener = new RestartApplicationListener(); + SpringApplication application = new SpringApplication(); + ConfigurableApplicationContext context = mock(ConfigurableApplicationContext.class); + String[] args = new String[] { "a", "b", "c" }; + listener.onApplicationEvent(new ApplicationStartedEvent(application, args)); + assertThat(Restarter.getInstance(), not(nullValue())); + assertThat(Restarter.getInstance().isFinished(), equalTo(false)); + assertThat(ReflectionTestUtils.getField(Restarter.getInstance(), "args"), + equalTo((Object) args)); + if (failed) { + listener.onApplicationEvent(new ApplicationFailedEvent(application, args, + context, new RuntimeException())); + } + else { + listener.onApplicationEvent(new ApplicationReadyEvent(application, args, + context)); + } + assertThat(Restarter.getInstance().isFinished(), equalTo(true)); + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/RestarterTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/RestarterTests.java new file mode 100644 index 0000000000..a51c69908f --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/RestarterTests.java @@ -0,0 +1,194 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.concurrent.ThreadFactory; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.boot.test.OutputCapture; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.junit.Assert.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link Restarter}. + * + * @author Phillip Webb + */ +public class RestarterTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public OutputCapture out = new OutputCapture(); + + @Before + public void setup() { + Restarter.setInstance(new TestableRestarter()); + } + + @After + public void cleanup() { + Restarter.clearInstance(); + } + + @Test + public void cantGetInstanceBeforeInitialize() throws Exception { + Restarter.clearInstance(); + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Restarter has not been initialized"); + Restarter.getInstance(); + } + + @Test + public void testRestart() throws Exception { + Restarter.clearInstance(); + Thread thread = new Thread() { + + @Override + public void run() { + SampleApplication.main(); + }; + + }; + thread.start(); + Thread.sleep(1600); + String output = this.out.toString(); + assertThat(StringUtils.countOccurrencesOf(output, "Tick 0"), greaterThan(2)); + assertThat(StringUtils.countOccurrencesOf(output, "Tick 1"), greaterThan(2)); + } + + @Test + public void getThreadFactory() throws Exception { + final ClassLoader parentLoader = Thread.currentThread().getContextClassLoader(); + final ClassLoader contextClassLoader = new URLClassLoader(new URL[0]); + Thread thread = new Thread() { + @Override + public void run() { + Runnable runnable = mock(Runnable.class); + Thread regular = new Thread(); + ThreadFactory factory = Restarter.getInstance().getThreadFactory(); + Thread viaFactory = factory.newThread(runnable); + // Regular threads will inherit the current thread + assertThat(regular.getContextClassLoader(), equalTo(contextClassLoader)); + // Factory threads should should inherit from the initial thread + assertThat(viaFactory.getContextClassLoader(), equalTo(parentLoader)); + }; + }; + thread.setContextClassLoader(contextClassLoader); + thread.start(); + thread.join(); + } + + @Test + public void getInitialUrls() throws Exception { + Restarter.clearInstance(); + RestartInitializer initializer = mock(RestartInitializer.class); + URL[] urls = new URL[] { new URL("file:/proj/module-a.jar!/") }; + given(initializer.getInitialUrls(any(Thread.class))).willReturn(urls); + Restarter.initialize(new String[0], false, initializer, false); + assertThat(Restarter.getInstance().getInitialUrls(), equalTo(urls)); + } + + @Component + @EnableScheduling + public static class SampleApplication { + + private int count = 0; + + private static volatile boolean quit = false; + + @Scheduled(fixedDelay = 100) + public void tickBean() { + System.out.println("Tick " + this.count++ + " " + Thread.currentThread()); + } + + @Scheduled(initialDelay = 350, fixedDelay = 350) + public void restart() { + System.out.println("Restart " + Thread.currentThread()); + if (!SampleApplication.quit) { + Restarter.getInstance().restart(); + } + } + + public static void main(String... args) { + Restarter.initialize(args, false, new MockRestartInitializer()); + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + SampleApplication.class); + context.registerShutdownHook(); + System.out.println("Sleep " + Thread.currentThread()); + sleep(); + quit = true; + context.close(); + } + + private static void sleep() { + try { + Thread.sleep(1200); + } + catch (InterruptedException ex) { + } + } + + } + + private static class TestableRestarter extends Restarter { + + public TestableRestarter() { + this(Thread.currentThread(), new String[] {}, false, + new MockRestartInitializer()); + } + + protected TestableRestarter(Thread thread, String[] args, + boolean forceReferenceCleanup, RestartInitializer initializer) { + super(thread, args, forceReferenceCleanup, initializer); + } + + @Override + public void restart() { + try { + stop(); + start(); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + @Override + protected void stop() throws Exception { + } + + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandlerTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandlerTests.java new file mode 100644 index 0000000000..7ee703ab88 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandlerTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-2015 the original author or 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 org.springframework.boot.developertools.restart; + +import org.junit.Test; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; + +/** + * Tests for {@link SilentExitExceptionHandler}. + * + * @author Phillip Webb + */ +public class SilentExitExceptionHandlerTests { + + @Test + public void setupAndExit() throws Exception { + TestThread testThread = new TestThread() { + @Override + public void run() { + SilentExitExceptionHandler.exitCurrentThread(); + fail("Didn't exit"); + } + }; + SilentExitExceptionHandler.setup(testThread); + testThread.startAndJoin(); + assertThat(testThread.getThrown(), nullValue()); + } + + @Test + public void doesntInterferWithOtherExceptions() throws Exception { + TestThread testThread = new TestThread() { + @Override + public void run() { + throw new IllegalStateException("Expected"); + } + }; + SilentExitExceptionHandler.setup(testThread); + testThread.startAndJoin(); + assertThat(testThread.getThrown().getMessage(), equalTo("Expected")); + } + + private static abstract class TestThread extends Thread { + + private Throwable thrown; + + public TestThread() { + setUncaughtExceptionHandler(new UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread t, Throwable e) { + TestThread.this.thrown = e; + } + }); + } + + public Throwable getThrown() { + return this.thrown; + } + + public void startAndJoin() throws InterruptedException { + start(); + join(); + } + + } + +}