OpenmrsClassLoader.java

/**
 * The contents of this file are subject to the OpenMRS Public License
 * Version 1.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://license.openmrs.org
 *
 * Software distributed under the License is distributed on an "AS IS"
 * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
 * License for the specific language governing rights and limitations
 * under the License.
 *
 * Copyright (C) OpenMRS, LLC.  All Rights Reserved.
 */
package org.openmrs.util;

import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.net.URL;
import java.net.URLClassLoader;
import java.net.URLConnection;
import java.sql.Driver;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.openmrs.api.APIException;
import org.openmrs.api.context.Context;
import org.openmrs.module.ModuleClassLoader;
import org.openmrs.module.ModuleFactory;
import org.openmrs.module.ModuleUtil;
import org.openmrs.scheduler.SchedulerException;
import org.openmrs.scheduler.SchedulerService;

/**
 * This classloader knows about the current ModuleClassLoaders and will attempt to load classes from
 * them if needed
 */
public class OpenmrsClassLoader extends URLClassLoader {
	
	private static Log log = LogFactory.getLog(OpenmrsClassLoader.class);
	
	private static File libCacheFolder;
	
	private static boolean libCacheFolderInitialized = false;
	
	// placeholder to hold mementos to restore
	private static Map<String, OpenmrsMemento> mementos = new WeakHashMap<String, OpenmrsMemento>();
	
	// holds a list of all classes that this classloader loaded so that they can be cleaned up
	private Set<Class<?>> loadedClasses = new HashSet<Class<?>>();
	
	// suffix of the OpenMRS required library cache folder
	private static final String LIBCACHESUFFIX = ".openmrs-lib-cache";
	
	/**
	 * Creates the instance for the OpenmrsClassLoader
	 */
	public OpenmrsClassLoader(ClassLoader parent) {
		super(new URL[0], parent);
		OpenmrsClassLoaderHolder.INSTANCE = this;
		
		if (log.isDebugEnabled())
			log.debug("Creating new OpenmrsClassLoader instance with parent: " + parent);
		
		//disable caching so the jars aren't locked
		// if performance is effected, this can be disabled in favor of
		//  copying all opened jars to a temp location
		//  (ala org.apache.catalina.loader.WebappClassLoader antijarlocking)
		URLConnection urlConnection = new OpenmrsURLConnection();
		urlConnection.setDefaultUseCaches(false);
		
	}
	
	/**
	 * Normal constructor. Sets this class as the parent classloader
	 */
	public OpenmrsClassLoader() {
		this(OpenmrsClassLoader.class.getClassLoader());
	}
	
	/**
	 * Private class to hold the one classloader used throughout openmrs. This is an alternative to
	 * storing the instance object on {@link OpenmrsClassLoader} itself so that garbage collection
	 * can happen correctly.
	 */
	private static class OpenmrsClassLoaderHolder {
		
		private static OpenmrsClassLoader INSTANCE = null;
		
	}
	
	/**
	 * Get the static/singular instance of the module class loader
	 * 
	 * @return OpenmrsClassLoader
	 */
	public static OpenmrsClassLoader getInstance() {
		if (OpenmrsClassLoaderHolder.INSTANCE == null)
			OpenmrsClassLoaderHolder.INSTANCE = new OpenmrsClassLoader();
		
		return OpenmrsClassLoaderHolder.INSTANCE;
	}
	
	/**
	 * @see java.lang.ClassLoader#loadClass(java.lang.String, boolean)
	 */
	@Override
	public Class<?> loadClass(String name, final boolean resolve) throws ClassNotFoundException {
		for (ModuleClassLoader classLoader : ModuleFactory.getModuleClassLoaders()) {
			// this is to prevent unnecessary looping over providedPackages
			boolean tryToLoad = name.startsWith(classLoader.getModule().getPackageName());
			
			// the given class name doesn't match the config.xml package in this module,
			// check the "providedPackage" list to see if its in a lib
			if (!tryToLoad) {
				
				for (String providedPackage : classLoader.getAdditionalPackages()) {
					// break out early if we match a package
					if (name.startsWith(providedPackage)) {
						tryToLoad = true;
						break;
					}
				}
			}
			
			if (tryToLoad) {
				try {
					//if (classLoader.isLoadingFromParent() == false)
					Class<?> c = classLoader.loadClass(name);
					loadedClasses.add(c);
					return c;
				}
				catch (ClassNotFoundException e) {
					//log.debug("Didn't find entry for: " + name);
				}
			}
		}
		
		/* See org.mortbay.jetty.webapp.WebAppClassLoader.loadClass, from
		 * http://dist.codehaus.org/jetty/jetty-6.1.10/jetty-6.1.10-src.zip */
		ClassNotFoundException ex = null;
		
		try {
			Class<?> c = getParent().loadClass(name);
			loadedClasses.add(c);
			return c;
		}
		catch (ClassNotFoundException e) {
			ex = e;
		}
		
		try {
			Class<?> c = this.findClass(name);
			return c;
		}
		catch (ClassNotFoundException e) {
			ex = e;
		}
		
		throw ex;
	}
	
	/**
	 * @see java.net.URLClassLoader#findResource(java.lang.String)
	 */
	@Override
	public URL findResource(final String name) {
		if (log.isTraceEnabled())
			log.trace("finding resource: " + name);
		
		URL result;
		for (ModuleClassLoader classLoader : ModuleFactory.getModuleClassLoaders()) {
			result = classLoader.findResource(name);
			if (result != null)
				return result;
		}
		
		// look for the resource in the parent
		result = super.findResource(name);
		
		// expand the jar url if necessary
		if (result != null && result.getProtocol().equals("jar") && name.contains("openmrs")) {
			result = expandURL(result, getLibCacheFolder());
		}
		
		return result;
	}
	
	/**
	 * @see java.net.URLClassLoader#findResources(java.lang.String)
	 */
	@Override
	public Enumeration<URL> findResources(final String name) throws IOException {
		Set<URL> results = new HashSet<URL>();
		for (ModuleClassLoader classLoader : ModuleFactory.getModuleClassLoaders()) {
			Enumeration<URL> urls = classLoader.findResources(name);
			while (urls.hasMoreElements()) {
				URL result = urls.nextElement();
				if (result != null)
					results.add(result);
			}
		}
		
		for (Enumeration<URL> en = super.findResources(name); en.hasMoreElements();) {
			results.add(en.nextElement());
		}
		
		return Collections.enumeration(results);
	}
	
	/**
	 * Searches all known module classloaders first, then parent classloaders
	 * 
	 * @see java.lang.ClassLoader#getResourceAsStream(java.lang.String)
	 */
	@Override
	public InputStream getResourceAsStream(String file) {
		for (ModuleClassLoader classLoader : ModuleFactory.getModuleClassLoaders()) {
			InputStream result = classLoader.getResourceAsStream(file);
			if (result != null)
				return result;
		}
		
		return super.getResourceAsStream(file);
	}
	
	/**
	 * Searches all known module classloaders first, then parent classloaders
	 * 
	 * @see java.lang.ClassLoader#getResources(java.lang.String)
	 */
	@Override
	public Enumeration<URL> getResources(String packageName) throws IOException {
		Set<URL> results = new HashSet<URL>();
		for (ModuleClassLoader classLoader : ModuleFactory.getModuleClassLoaders()) {
			Enumeration<URL> urls = classLoader.getResources(packageName);
			while (urls.hasMoreElements()) {
				URL result = urls.nextElement();
				if (result != null)
					results.add(result);
			}
		}
		
		for (Enumeration<URL> en = super.getResources(packageName); en.hasMoreElements();) {
			results.add(en.nextElement());
		}
		
		return Collections.enumeration(results);
	}
	
	/**
	 * @see java.lang.Object#toString()
	 */
	@Override
	public String toString() {
		return "Openmrs" + super.toString();
	}
	
	/**
	 * Destroy the current instance of the classloader. Note**: After calling this and after the new
	 * service is set up, All classes using this instance should be flushed. This would allow all
	 * java classes that were loaded by the old instance variable to be gc'd and modules to load in
	 * new java classes
	 * 
	 * @see #flushInstance()
	 */
	public static void destroyInstance() {
		
		// remove all thread references to this class
		// Walk up all the way to the root thread group
		ThreadGroup rootGroup = Thread.currentThread().getThreadGroup();
		ThreadGroup parent;
		while ((parent = rootGroup.getParent()) != null) {
			rootGroup = parent;
		}
		
		log.error("this classloader hashcode: " + OpenmrsClassLoaderHolder.INSTANCE.hashCode());
		
		//		List<Thread> threads = listThreads(rootGroup, "");
		//		for (Thread thread : threads) {
		//			if (thread.getContextClassLoader() != null) {
		//				log.debug("context classloader on thread: " + thread.getName() + " is: "
		//				        + thread.getContextClassLoader().getClass().getName() + ":"
		//				        + thread.getContextClassLoader().hashCode());
		//				if (thread.getContextClassLoader() == OpenmrsClassLoaderHolder.INSTANCE) {
		//					thread.setContextClassLoader(OpenmrsClassLoaderHolder.INSTANCE.getParent());
		//					log.error("Cleared context classloader to save the world from memory leaks. thread: " + thread.getName()
		//					        + " ");
		//				}
		//			}
		//		}
		
		OpenmrsClassLoaderHolder.INSTANCE = null;
	}
	
	// List all threads and recursively list all subgroup
	private static List<Thread> listThreads(ThreadGroup group, String indent) {
		List<Thread> threadToReturn = new ArrayList<Thread>();
		
		log.error(indent + "Group[" + group.getName() + ":" + group.getClass() + "]");
		int nt = group.activeCount();
		Thread[] threads = new Thread[nt * 2 + 10]; //nt is not accurate
		nt = group.enumerate(threads, false);
		
		// List every thread in the group
		for (int i = 0; i < nt; i++) {
			Thread t = threads[i];
			log.error(indent
			        + "  Thread["
			        + t.getName()
			        + ":"
			        + t.getClass()
			        + ":"
			        + (t.getContextClassLoader() == null ? "null cl" : t.getContextClassLoader().getClass().getName() + " "
			                + t.getContextClassLoader().hashCode()) + "]");
			threadToReturn.add(t);
		}
		
		// Recursively list all subgroups
		int ng = group.activeGroupCount();
		ThreadGroup[] groups = new ThreadGroup[ng * 2 + 10];
		ng = group.enumerate(groups, false);
		
		for (int i = 0; i < ng; i++) {
			threadToReturn.addAll(listThreads(groups[i], indent + "  "));
		}
		
		return threadToReturn;
	}
	
	public static void onShutdown() {
		clearReferences();
	}
	
	/**
	 * This clears any references this classloader might have that will prevent garbage collection. <br/>
	 * <br/>
	 * Borrowed from Tomcat's WebappClassLoader#clearReferences() (not javadoc linked intentionally) <br/>
	 * The only difference between this and Tomcat's implementation is that this one only acts on
	 * openmrs objects and also clears out static java.* packages. Tomcat acts on all objects and
	 * does not clear our static java.* objects.
	 * 
	 * @since 1.5
	 */
	protected static void clearReferences() {
		
		// Unregister any JDBC drivers loaded by this classloader
		Enumeration<Driver> drivers = DriverManager.getDrivers();
		while (drivers.hasMoreElements()) {
			Driver driver = drivers.nextElement();
			if (driver.getClass().getClassLoader() == getInstance()) {
				try {
					DriverManager.deregisterDriver(driver);
				}
				catch (SQLException e) {
					log.warn("SQL driver deregistration failed", e);
				}
			}
		}
		
		// Null out any static or final fields from loaded classes,
		// as a workaround for apparent garbage collection bugs
		for (Class<?> clazz : getInstance().loadedClasses) {
			if (clazz != null && clazz.getName().contains("openmrs")) { // only clean up openmrs classes
				try {
					Field[] fields = clazz.getDeclaredFields();
					for (int i = 0; i < fields.length; i++) {
						Field field = fields[i];
						int mods = field.getModifiers();
						if (field.getType().isPrimitive() || (field.getName().indexOf("$") != -1)) {
							continue;
						}
						if (Modifier.isStatic(mods)) {
							try {
								// do not clear the log field on this class yet
								if (clazz.equals(OpenmrsClassLoader.class) && field.getName().equals("log"))
									continue;
								field.setAccessible(true);
								if (Modifier.isFinal(mods)) {
									if (!(field.getType().getName().startsWith("javax."))) {
										nullInstance(field.get(null));
									}
								} else {
									field.set(null, null);
									if (log.isDebugEnabled()) {
										log.debug("Set field " + field.getName() + " to null in class " + clazz.getName());
									}
								}
							}
							catch (Throwable t) {
								if (log.isDebugEnabled()) {
									log.debug("Could not set field " + field.getName() + " to null in class "
									        + clazz.getName(), t);
								}
							}
						}
					}
				}
				catch (Throwable t) {
					if (log.isDebugEnabled()) {
						log.debug("Could not clean fields for class " + clazz.getName(), t);
					}
				}
			}
		}
		
		// now we can clear the log field on this class
		OpenmrsClassLoader.log = null;
		
		getInstance().loadedClasses.clear();
	}
	
	/**
	 * Used by {@link #clearReferences()} upon application close. <br/>
	 * <br/>
	 * Borrowed from Tomcat's WebappClassLoader.
	 * 
	 * @param instance the object whose fields need to be nulled out
	 */
	protected static void nullInstance(Object instance) {
		if (instance == null) {
			return;
		}
		Field[] fields = instance.getClass().getDeclaredFields();
		for (int i = 0; i < fields.length; i++) {
			Field field = fields[i];
			int mods = field.getModifiers();
			if (field.getType().isPrimitive() || (field.getName().indexOf("$") != -1)) {
				continue;
			}
			try {
				field.setAccessible(true);
				if (Modifier.isStatic(mods) && Modifier.isFinal(mods)) {
					// Doing something recursively is too risky
					continue;
				} else {
					Object value = field.get(instance);
					if (null != value) {
						Class<?> valueClass = value.getClass();
						if (!loadedByThisOrChild(valueClass)) {
							if (log.isDebugEnabled()) {
								log.debug("Not setting field " + field.getName() + " to null in object of class "
								        + instance.getClass().getName() + " because the referenced object was of type "
								        + valueClass.getName() + " which was not loaded by this WebappClassLoader.");
							}
						} else {
							field.set(instance, null);
							if (log.isDebugEnabled()) {
								log.debug("Set field " + field.getName() + " to null in class "
								        + instance.getClass().getName());
							}
						}
					}
				}
			}
			catch (Throwable t) {
				if (log.isDebugEnabled()) {
					log.debug("Could not set field " + field.getName() + " to null in object instance of class "
					        + instance.getClass().getName(), t);
				}
			}
		}
	}
	
	/**
	 * Determine whether a class was loaded by this class loader or one of its child class loaders. <br/>
	 * <br/>
	 * Borrowed from Tomcat's WebappClassLoader
	 */
	protected static boolean loadedByThisOrChild(Class<?> clazz) {
		boolean result = false;
		for (ClassLoader classLoader = clazz.getClassLoader(); null != classLoader; classLoader = classLoader.getParent()) {
			if (classLoader.equals(getInstance())) {
				result = true;
				break;
			}
		}
		return result;
	}
	
	/**
	 * This method should be called before destroying the instance
	 * 
	 * @see #destroyInstance()
	 */
	public static void saveState() {
		
		// TODO our services should implement a common
		// OpenmrsService so this can be generalized
		try {
			String key = SchedulerService.class.getName();
			if (!Context.isRefreshingContext())
				mementos.put(key, Context.getSchedulerService().saveToMemento());
		}
		catch (Throwable t) {
			// pass
		}
		
	}
	
	/**
	 * This method should be called after restoring the instance
	 * 
	 * @see #destroyInstance()
	 * @see #saveState()
	 */
	public static void restoreState() {
		// TODO our services should implement a common
		// OpenmrsService so this can be generalized
		try {
			String key = SchedulerService.class.getName();
			Context.getSchedulerService().restoreFromMemento(mementos.get(key));
		}
		catch (APIException e) {
			// pass
		}
		mementos.clear();
	}
	
	/**
	 * All objects depending on the old classloader should be restarted here Should be called after
	 * destoryInstance() and after the service is restarted
	 * 
	 * @see #destroyInstance()
	 */
	public static void flushInstance() {
		try {
			SchedulerService service = null;
			try {
				service = Context.getSchedulerService();
			}
			catch (APIException e2) {
				// if there isn't a scheduler service yet, ignore error
				log.warn("Unable to get scheduler service", e2);
			}
			if (service != null) {
				service.rescheduleAllTasks();
			}
		}
		catch (SchedulerException e) {
			log.error("Failed to restart scheduler tasks", e);
		}
	}
	
	/**
	 * Get the temporary "work" directory for expanded jar files
	 * 
	 * @return temporary location for storing the libraries
	 */
	public static File getLibCacheFolder() {
		// cache the location for all calls until OpenMRS is restarted
		if (libCacheFolder != null)
			return libCacheFolderInitialized ? libCacheFolder : null;
		
		synchronized (ModuleClassLoader.class) {
			libCacheFolder = new File(System.getProperty("java.io.tmpdir"), System.currentTimeMillis() + LIBCACHESUFFIX);
			
			if (log.isDebugEnabled())
				log.debug("libraries cache folder is " + libCacheFolder);
			
			File lockFile = new File(libCacheFolder, "lock");
			if (lockFile.exists()) {
				log.error("can't initialize libraries cache folder " + libCacheFolder + " as lock file indicates that it"
				        + " is owned by another openmrs instance");
				return null;
			}
			
			if (libCacheFolder.exists()) {
				// clean up and empty the folder if it exists (and is not locked)
				try {
					OpenmrsUtil.deleteDirectory(libCacheFolder);
				}
				catch (IOException io) {
					log.warn("Unable to delete: " + libCacheFolder.getName());
				}
			} else {
				// delete old lib cache folders
				deleteOldLibCaches(libCacheFolder);
				// otherwise just create the dir structure
				libCacheFolder.mkdirs();
			}
			
			// create the lock file in the lib cache folder to prevent other caches
			// from being created here
			try {
				if (!lockFile.createNewFile()) {
					log.error("can't create lock file in JPF libraries cache folder" + libCacheFolder);
					return null;
				}
			}
			catch (IOException ioe) {
				log.error("can't create lock file in JPF libraries cache folder " + libCacheFolder, ioe);
				return null;
			}
			
			// mark the lock and entire library cache to be deleted when the jvm exits
			lockFile.deleteOnExit();
			libCacheFolder.deleteOnExit();
			
			// mark the lib cache folder as ready
			libCacheFolderInitialized = true;
		}
		
		return libCacheFolder;
	}
	
	/**
	 * Deletes the old lib cache folders that might not have been deleted when OpenMRS closed
	 * @param libCacheFolder 
	 */
	public static void deleteOldLibCaches(File libCacheFolder) {
		
		FilenameFilter cacheDirFilter = new FilenameFilter() {
			
			@Override
			public boolean accept(File dir, String name) {
				return name.endsWith(LIBCACHESUFFIX);
			}
		};
		FilenameFilter lockFilter = new FilenameFilter() {
			
			@Override
			public boolean accept(File dir, String name) {
				return name.equals("lock");
			}
		};
		File tempLocation = libCacheFolder.getParentFile();
		File[] listFiles = tempLocation.listFiles(cacheDirFilter);
		if (listFiles != null) {
			for (File cacheDir : listFiles) {
				//check if it is a directory, but is not the current lib cache
				if (cacheDir.isDirectory() && !cacheDir.equals(libCacheFolder)) {
					// check if its not locked by another running openmrs instance
					if (cacheDir.list(lockFilter).length == 0) {
						try {
							OpenmrsUtil.deleteDirectory(cacheDir);
						}
						catch (IOException io) {
							log.warn("Unable to delete: " + cacheDir.getName());
						}
					}
				}
			}
		}
	}
	
	/**
	 * Expand the given URL into the given folder
	 * 
	 * @param result URL of the file to expand
	 * @param folder File (directory) to place the expanded file
	 * @return the URL at the expanded location
	 */
	public static URL expandURL(URL result, File folder) {
		String extForm = result.toExternalForm();
		// trim out "jar:file:/ and ascii spaces"
		if (OpenmrsConstants.UNIX_BASED_OPERATING_SYSTEM)
			extForm = extForm.replaceFirst("jar:file:", "").replaceAll("%20", " ");
		else
			extForm = extForm.replaceFirst("jar:file:/", "").replaceAll("%20", " ");
		
		if (log.isDebugEnabled())
			log.debug("url external form: " + extForm);
		
		int i = extForm.indexOf("!");
		String jarPath = extForm.substring(0, i);
		String filePath = extForm.substring(i + 2); // skip over both the '!' and the '/'
		
		if (log.isDebugEnabled()) {
			log.debug("jarPath: " + jarPath);
			log.debug("filePath: " + filePath);
		}
		
		File file = new File(folder, filePath);
		
		if (log.isDebugEnabled())
			log.debug("absolute path: " + file.getAbsolutePath());
		
		try {
			// if the file has been expanded already, return that
			if (file.exists())
				return file.toURI().toURL();
			else {
				// expand the url and return a url to the temp file
				File jarFile = new File(jarPath);
				if (!jarFile.exists()) {
					log.warn("Cannot find jar at: " + jarFile + " for url: " + result);
					return null;
				}
				
				ModuleUtil.expandJar(jarFile, folder, filePath, true);
				return file.toURI().toURL();
			}
		}
		catch (IOException io) {
			log.warn("Unable to expand url: " + result, io);
			return null;
		}
	}
	
	/**
	 * This class exists solely so OpenmrsClassLoader can call the (should be static) method
	 * <code>URLConnection.setDefaultUseCaches(Boolean)</code>. This causes jars opened to not be
	 * locked (and allows for the webapp to be reloadable).
	 */
	private class OpenmrsURLConnection extends URLConnection {
		
		public OpenmrsURLConnection() {
			super(null);
		}
		
		@Override
		public void connect() throws IOException {
			
		}
		
	}
}