diff --git a/pom.xml b/pom.xml index 30f40e5..acb34bd 100644 --- a/pom.xml +++ b/pom.xml @@ -90,6 +90,12 @@ 4.8.1 test + + org.mockito + mockito-core + 1.10.19 + test + diff --git a/src/main/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilder.java b/src/main/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilder.java new file mode 100644 index 0000000..e98b824 --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilder.java @@ -0,0 +1,61 @@ +package com.threerings.getdown.classpath; + +import java.io.File; +import java.io.IOException; +import java.util.LinkedHashSet; +import java.util.concurrent.TimeUnit; + +import com.threerings.getdown.classpath.cache.GarbageCollector; +import com.threerings.getdown.classpath.cache.ResourceCache; +import com.threerings.getdown.data.Application; +import com.threerings.getdown.data.Resource; +import com.threerings.getdown.util.file.FileWalker; + +/** + * Instead of building the class path from the application directory, this class path builder puts + * the files that make up the class path into a cache and assembles the class path from the cache + * directory. + */ +public class CacheBasedClassPathBuilder extends ClassPathBuilderBase +{ + static final String CACHE_DIR = ".cache"; + + public CacheBasedClassPathBuilder(Application application) + { + super(application); + } + + @Override + public ClassPath buildClassPath () throws IOException + { + File cacheDir = new File(_application.getAppdir(), CACHE_DIR); + + // a negative value of code_cache_retention_days allows to clean up the cache forcefully + if (_application.getCodeCacheRetentionDays() <= 0) { + runGarbageCollection(cacheDir); + } + + ResourceCache cache = new ResourceCache(cacheDir); + + LinkedHashSet classPathEntries = new LinkedHashSet(); + + for (Resource resource: _application.getActiveCodeResources()) { + classPathEntries.add( + new ClassPathElement( + cache.cacheFile(resource.getFinalTarget(), _application.getDigest(resource)))); + } + + if (_application.getCodeCacheRetentionDays() > 0) { + runGarbageCollection(cacheDir); + } + + return new ClassPath(classPathEntries); + } + + private void runGarbageCollection(File cacheDir) { + GarbageCollector gc = new GarbageCollector( + new FileWalker(cacheDir), _application.getCodeCacheRetentionDays(), TimeUnit.DAYS); + + gc.collectGarbage(); + } +} diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPath.java b/src/main/java/com/threerings/getdown/classpath/ClassPath.java new file mode 100644 index 0000000..e5da5c6 --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/ClassPath.java @@ -0,0 +1,69 @@ +package com.threerings.getdown.classpath; + +import java.io.File; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Represents the class path and it's elements of the application to be launched. The class path can + * either be represented as an {@link #asArgumentString() argument string} for the java command line + * or as an {@link #asUrls() array of URLs} to be used by a {@link URLClassLoader}. + */ +public class ClassPath +{ + public ClassPath (LinkedHashSet classPathEntries) + { + this._classPathEntries = Collections.unmodifiableSet(classPathEntries); + } + + /** + * Returns the class path as an java command line argument string, e.g + * + *
+     *   /path/to/a.jar:/path/to/b.jar
+     * 
+ */ + public String asArgumentString () + { + StringBuilder builder = new StringBuilder(); + String delimiter = ""; + + for (ClassPathElement entry: _classPathEntries) + { + builder + .append(delimiter) + .append(entry.getAbsolutePath()); + + delimiter = File.pathSeparator; + } + + return builder.toString(); + } + + /** + * Returns the class path entries as an array of URLs to be used for example by an + * {@link URLClassLoader}. + */ + public URL[] asUrls () + { + URL[] urls = new URL[_classPathEntries.size()]; + + int i = 0; + + for (ClassPathElement entry : _classPathEntries) { + urls[i++] = entry.getURL(); + } + + return urls; + } + + public Set getClassPathEntries () + { + return _classPathEntries; + } + + private final Set _classPathEntries; +} diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPathBuilder.java b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilder.java new file mode 100644 index 0000000..a220760 --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilder.java @@ -0,0 +1,13 @@ +package com.threerings.getdown.classpath; + +import java.io.IOException; + +/** + * A class path builder compiles the class path of the application to be launched. + */ +public interface ClassPathBuilder { + /** + * Builds and returns the class path of the application to be launched. + */ + ClassPath buildClassPath () throws IOException; +} diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderBase.java b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderBase.java new file mode 100644 index 0000000..0cc5613 --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderBase.java @@ -0,0 +1,14 @@ +package com.threerings.getdown.classpath; + +import com.threerings.getdown.data.Application; + +/** + * Base class for {@link ClassPathBuilder class path builders}. + */ +abstract class ClassPathBuilderBase implements ClassPathBuilder { + public ClassPathBuilderBase(Application _application) { + this._application = _application; + } + + protected final Application _application; +} diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderFactory.java b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderFactory.java new file mode 100644 index 0000000..5f93fdb --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderFactory.java @@ -0,0 +1,22 @@ +package com.threerings.getdown.classpath; + +import com.threerings.getdown.data.Application; + +/** + * A factory for {@link ClassPathBuilder class path builders}. + */ +public abstract class ClassPathBuilderFactory { + private ClassPathBuilderFactory () { + } + + /** + * Creates a class path builder that is able to compile the class path of the supplied + * {@link Application application}. + */ + public static ClassPathBuilder create (Application application) + { + return application.isUseCodeCache() + ? new CacheBasedClassPathBuilder(application) + : new DefaultClassPathBuilder(application); + } +} diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPathElement.java b/src/main/java/com/threerings/getdown/classpath/ClassPathElement.java new file mode 100644 index 0000000..edf64a2 --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/ClassPathElement.java @@ -0,0 +1,47 @@ +package com.threerings.getdown.classpath; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; + +/** + * Represents an element of an applications class path. + */ +public class ClassPathElement +{ + public ClassPathElement (File _file) + { + this._file = _file; + } + + /** + * Returns the code resource. + */ + public File getFile () + { + return _file; + } + + + /** + * Returns the elements absolute path on the file system. + */ + public String getAbsolutePath () + { + return _file.getAbsolutePath(); + } + + /** + * Returns the file URL of this class path element. + */ + public URL getURL () + { + try { + return _file.toURI().toURL(); + } catch (MalformedURLException e) { + throw new IllegalStateException("URL of file is illegal: " + getAbsolutePath(), e); + } + } + + private final File _file; +} diff --git a/src/main/java/com/threerings/getdown/classpath/DefaultClassPathBuilder.java b/src/main/java/com/threerings/getdown/classpath/DefaultClassPathBuilder.java new file mode 100644 index 0000000..b6f375b --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/DefaultClassPathBuilder.java @@ -0,0 +1,30 @@ +package com.threerings.getdown.classpath; + +import java.util.LinkedHashSet; + +import com.threerings.getdown.data.Application; +import com.threerings.getdown.data.Resource; + +/** + * The default class path builder assembles the class path from the code resources to be found in + * the application directory. + */ +public class DefaultClassPathBuilder extends ClassPathBuilderBase +{ + public DefaultClassPathBuilder(Application _application) { + super(_application); + } + + @Override + public ClassPath buildClassPath () + { + LinkedHashSet classPathEntries = new LinkedHashSet(); + + for (Resource resource: _application.getActiveCodeResources()) { + classPathEntries.add(new ClassPathElement(resource.getFinalTarget())); + } + + return new ClassPath(classPathEntries); + } + +} diff --git a/src/main/java/com/threerings/getdown/classpath/cache/GarbageCollector.java b/src/main/java/com/threerings/getdown/classpath/cache/GarbageCollector.java new file mode 100644 index 0000000..6a40ae1 --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/cache/GarbageCollector.java @@ -0,0 +1,91 @@ +package com.threerings.getdown.classpath.cache; + +import java.io.File; +import java.util.concurrent.TimeUnit; + +import com.threerings.getdown.util.file.FileVisitor; +import com.threerings.getdown.util.file.FileWalker; + +/** + * Collects elements in the {@link ResourceCache cache} which became unused and deletes them + * afterwards. + */ +public class GarbageCollector +{ + public GarbageCollector ( + FileWalker _fileWalker, int retentionPeriod, TimeUnit retentionPeriodTimeUnit) + { + this._fileWalker = _fileWalker; + this._retentionMillis = retentionPeriodTimeUnit.toMillis(retentionPeriod); + } + + /** + * Collect and delete the garbage in the cache. + */ + public void collectGarbage () + { + _fileWalker.walkTree(new FileVisitor() { + @Override + public void visit(File file) + { + collect(file); + } + }); + } + + private void collect (File file) { + File cachedFile = getCachedFile(file); + File lastAccessedFile = getLastAccessedFile(file); + + if (!cachedFile.exists() || !lastAccessedFile.exists()) { + if (cachedFile.exists()) { + cachedFile.delete(); + } else { + lastAccessedFile.delete(); + } + } else if (shouldDelete(lastAccessedFile)) { + lastAccessedFile.delete(); + cachedFile.delete(); + } + + File folder = file.getParentFile(); + + if (folder.list().length == 0) { + folder.delete(); + } + } + + private boolean shouldDelete(File lastAccessedFile) { + return System.currentTimeMillis() - lastAccessedFile.lastModified() > _retentionMillis; + } + + private File getLastAccessedFile (File file) + { + if (isLastAccessedFile(file)) { + return file; + } + + return new File( + file.getParentFile(), + file.getName() + ResourceCache.LAST_ACCESSED_FILE_SUFFIX); + } + + private boolean isLastAccessedFile (File file) + { + return file.getName().endsWith(ResourceCache.LAST_ACCESSED_FILE_SUFFIX); + } + + private File getCachedFile (File file) + { + if (!isLastAccessedFile(file)) { + return file; + } + + return new File( + file.getParentFile(), + file.getName().substring(0, file.getName().lastIndexOf("."))); + } + + private final FileWalker _fileWalker; + private final long _retentionMillis; +} diff --git a/src/main/java/com/threerings/getdown/classpath/cache/ResourceCache.java b/src/main/java/com/threerings/getdown/classpath/cache/ResourceCache.java new file mode 100644 index 0000000..6e35b3f --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/cache/ResourceCache.java @@ -0,0 +1,73 @@ +package com.threerings.getdown.classpath.cache; + +import java.io.File; +import java.io.IOException; + +import com.threerings.getdown.util.FileUtil; + +/** + * Maintains a cache of code resources. The cache allows multiple application instances of different + * versions to open at the same time. + */ +public class ResourceCache +{ + public ResourceCache (File _cacheDir) throws IOException + { + this._cacheDir = _cacheDir; + + createDirectoryIfNecessary(_cacheDir); + } + + private void createDirectoryIfNecessary (File dir) throws IOException + { + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("unable to create directory: " + dir.getAbsolutePath()); + } + } + + /** + * Caches the given file under it's {@code digest}. + * + * @return the cached file + */ + public File cacheFile (File fileToCache, String digest) throws IOException + { + File cacheLocation = new File(_cacheDir, digest.substring(0, 2)); + createDirectoryIfNecessary(cacheLocation); + + File cachedFile = new File(cacheLocation, digest + getFileSuffix(fileToCache)); + File lastAccessedFile = new File( + cacheLocation, cachedFile.getName() + LAST_ACCESSED_FILE_SUFFIX); + + if (!cachedFile.exists()) { + createNewFile(cachedFile); + FileUtil.copy(fileToCache, cachedFile); + } + + if (lastAccessedFile.exists()) { + lastAccessedFile.setLastModified(System.currentTimeMillis()); + } else { + createNewFile(lastAccessedFile); + } + + return cachedFile; + } + + private void createNewFile (File fileToCreate) throws IOException + { + if (!fileToCreate.exists() && !fileToCreate.createNewFile()) { + throw new IOException("unable to create new file: " + fileToCreate.getAbsolutePath()); + } + } + + private String getFileSuffix (File fileToCache) { + String fileName = fileToCache.getName(); + int index = fileName.lastIndexOf("."); + + return index > -1 ? fileName.substring(index) : ""; + } + + private final File _cacheDir; + + static final String LAST_ACCESSED_FILE_SUFFIX = ".lastAccessed"; +} diff --git a/src/main/java/com/threerings/getdown/data/Application.java b/src/main/java/com/threerings/getdown/data/Application.java index 9b6e961..4590f64 100644 --- a/src/main/java/com/threerings/getdown/data/Application.java +++ b/src/main/java/com/threerings/getdown/data/Application.java @@ -5,9 +5,21 @@ package com.threerings.getdown.data; +import static com.threerings.getdown.Log.log; + import java.awt.Color; import java.awt.Rectangle; -import java.io.*; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.io.RandomAccessFile; import java.lang.reflect.Method; import java.net.MalformedURLException; import java.net.URL; @@ -16,27 +28,46 @@ import java.net.URLConnection; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.channels.OverlappingFileLockException; -import java.security.*; +import java.security.AllPermission; +import java.security.CodeSource; +import java.security.GeneralSecurityException; +import java.security.PermissionCollection; +import java.security.Permissions; +import java.security.Signature; import java.security.cert.Certificate; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.swing.JApplet; +import org.apache.commons.codec.binary.Base64; + import com.samskivert.io.StreamUtil; import com.samskivert.text.MessageUtil; import com.samskivert.util.ArrayUtil; import com.samskivert.util.RandomUtil; import com.samskivert.util.RunAnywhere; import com.samskivert.util.StringUtil; - -import org.apache.commons.codec.binary.Base64; - +import com.threerings.getdown.classpath.ClassPath; +import com.threerings.getdown.classpath.ClassPathBuilderFactory; import com.threerings.getdown.launcher.RotatingBackgrounds; -import com.threerings.getdown.util.*; - -import static com.threerings.getdown.Log.log; +import com.threerings.getdown.util.ConfigUtil; +import com.threerings.getdown.util.ConnectionUtil; +import com.threerings.getdown.util.FileUtil; +import com.threerings.getdown.util.LaunchUtil; +import com.threerings.getdown.util.ProgressAggregator; +import com.threerings.getdown.util.ProgressObserver; +import com.threerings.getdown.util.VersionUtil; /** * Parses and provide access to the information contained in the getdown.txt @@ -226,10 +257,10 @@ public class Application { _appdir = appdir; _appid = appid; - _signers = (signers == null) ? Collections.emptyList() : signers; + _signers = signers == null ? Collections.emptyList() : signers; _config = getLocalPath(CONFIG_FILE); - _extraJvmArgs = (jvmargs == null) ? ArrayUtil.EMPTY_STRING : jvmargs; - _extraAppArgs = (appargs == null) ? ArrayUtil.EMPTY_STRING : appargs; + _extraJvmArgs = jvmargs == null ? ArrayUtil.EMPTY_STRING : jvmargs; + _extraAppArgs = appargs == null ? ArrayUtil.EMPTY_STRING : appargs; } /** @@ -352,7 +383,7 @@ public class Application return null; } - String infix = (auxgroup == null) ? "" : ("-" + auxgroup); + String infix = auxgroup == null ? "" : "-" + auxgroup; String pfile = "patch" + infix + _version + ".dat"; try { URL remote = new URL(createVAppBase(_targetVersion), encodePath(pfile)); @@ -514,7 +545,7 @@ public class Application // if we are a versioned deployment, create a versioned appbase try { - _vappbase = (_version < 0) ? new URL(_appbase) : createVAppBase(_version); + _vappbase = _version < 0 ? new URL(_appbase) : createVAppBase(_version); } catch (MalformedURLException mue) { String err = MessageUtil.tcompose("m.invalid_appbase", _appbase); throw (IOException) new IOException(err).initCause(mue); @@ -531,7 +562,7 @@ public class Application } } - String appPrefix = StringUtil.isBlank(_appid) ? "" : (_appid + "."); + String appPrefix = StringUtil.isBlank(_appid) ? "" : _appid + "."; // determine our application class name _class = (String)cdata.get(appPrefix + "class"); @@ -656,6 +687,11 @@ public class Application // obtain a thread dump of the running JVM _windebug = getLocalPath("debug.txt").exists(); + // whether to cache code resources and launch from cache + _useCodeCache = Boolean.parseBoolean((String) cdata.get("use_code_cache")); + _codeCacheRetentionDays = cdata.containsKey("code_cache_retention_days") + ? Integer.parseInt((String) cdata.get("use_code_cache")) : 7; + // parse and return our application config UpdateInterface ui = new UpdateInterface(); _name = ui.name = (String)cdata.get("ui.name"); @@ -673,9 +709,9 @@ public class Application } // and now ui.background can refer to the background color, but fall back to black // or white, depending on the brightness of the progressText - Color defaultBackground = (.5f < Color.RGBtoHSB( + Color defaultBackground = .5f < Color.RGBtoHSB( ui.progressText.getRed(), ui.progressText.getGreen(), ui.progressText.getBlue(), - null)[2]) + null)[2] ? Color.BLACK : Color.WHITE; ui.background = parseColor(cdata, "ui.background", defaultBackground); @@ -690,7 +726,7 @@ public class Application // On an installation error, where do we point the user. String installError = parseUrl(cdata, "ui.install_error", null); - ui.installError = (installError == null) ? + ui.installError = installError == null ? "m.default_install_error" : MessageUtil.taint(installError); // the patch notes bits @@ -802,8 +838,8 @@ public class Application } } - boolean minVersionOK = (_javaMinVersion == 0) || (version >= _javaMinVersion); - boolean maxVersionOK = (_javaMaxVersion == 0) || (version <= _javaMaxVersion); + boolean minVersionOK = _javaMinVersion == 0 || version >= _javaMinVersion; + boolean maxVersionOK = _javaMaxVersion == 0 || version <= _javaMaxVersion; return minVersionOK && maxVersionOK; } catch (RuntimeException re) { @@ -901,16 +937,11 @@ public class Application boolean dashJarMode = MANIFEST_CLASS.equals(_class); // add the -classpath arguments if we're not in -jar mode - StringBuilder cpbuf = new StringBuilder(); - for (Resource rsrc : getActiveCodeResources()) { - if (cpbuf.length() > 0) { - cpbuf.append(File.pathSeparator); - } - cpbuf.append(rsrc.getFinalTarget().getAbsolutePath()); - } + ClassPath classPath = ClassPathBuilderFactory.create(this).buildClassPath(); + if (!dashJarMode) { args.add("-classpath"); - args.add(cpbuf.toString()); + args.add(classPath.asArgumentString()); } // we love our Mac users, so we do nice things to preserve our application identity @@ -960,7 +991,7 @@ public class Application // if we're in -jar mode add those arguments, otherwise add the app class name if (dashJarMode) { args.add("-jar"); - args.add(cpbuf.toString()); + args.add(classPath.asArgumentString()); } else { args.add(_class); } @@ -1006,20 +1037,17 @@ public class Application /** * Runs this application directly in the current VM. + * @throws IOException */ - public void invokeDirect (JApplet applet) + public void invokeDirect (JApplet applet) throws IOException { - // create a custom class loader - ArrayList jars = new ArrayList(); - for (Resource rsrc : getActiveCodeResources()) { - try { - jars.add(new URL("file", "", rsrc.getFinalTarget().getAbsolutePath())); - } catch (Exception e) { - e.printStackTrace(System.err); - } - } + ClassPath classPath = ClassPathBuilderFactory.create(this).buildClassPath(); + + URL[] jarUrls = classPath.asUrls(); + + // create custom class loader URLClassLoader loader = new URLClassLoader( - jars.toArray(new URL[jars.size()]), + jarUrls, ClassLoader.getSystemClassLoader()) { @Override protected PermissionCollection getPermissions (CodeSource code) { Permissions perms = new Permissions(); @@ -1030,7 +1058,9 @@ public class Application Thread.currentThread().setContextClassLoader(loader); log.info("Configured URL class loader:"); - for (URL url : jars) log.info(" " + url); + for (URL url : jarUrls) { + log.info(" " + url); + } // configure any system properties that we can for (String jvmarg : _jvmargs) { @@ -1126,12 +1156,6 @@ public class Application log.info("Verifying application: " + _vappbase); log.info("Version: " + _version); log.info("Class: " + _class); -// log.info("Code: " + -// StringUtil.toString(getCodeResources().iterator())); -// log.info("Resources: " + -// StringUtil.toString(getActiveResources().iterator())); -// log.info("JVM Args: " + StringUtil.toString(_jvmargs.iterator())); -// log.info("App Args: " + StringUtil.toString(_appargs.iterator())); // this will read in the contents of the digest file and validate itself try { @@ -1145,7 +1169,7 @@ public class Application if (_version == -1) { // make a note of the old meta-digest, if this changes we need to revalidate all of our // resources as one or more of them have also changed - String olddig = (_digest == null) ? "" : _digest.getMetaDigest(); + String olddig = _digest == null ? "" : _digest.getMetaDigest(); try { status.updateStatus("m.checking"); downloadDigestFile(); @@ -1299,7 +1323,7 @@ public class Application failures.add(rsrc); } - return (failures.size() == 0) ? null : failures; + return failures.size() == 0 ? null : failures; } /** @@ -1357,6 +1381,24 @@ public class Application return _version; } + /** + * Returns whether the application should cache code resources prior to launching the + * application. + */ + public boolean isUseCodeCache () + { + return _useCodeCache; + } + + /** + * Returns the number of days a cached code resource is allowed to stay unused before it + * becomes eligible for deletion. + */ + public int getCodeCacheRetentionDays () + { + return _codeCacheRetentionDays; + } + /** * Creates a versioned application base URL for the specified version. */ @@ -1602,7 +1644,7 @@ public class Application { String value = (String)cdata.get(name); Rectangle rect = parseRect(name, value); - return (rect == null) ? def : rect; + return rect == null ? def : rect; } /** Helper function to add all values in {@code values} (if non-null) to {@code target}. */ @@ -1647,7 +1689,7 @@ public class Application { String value = (String)cdata.get(name); Color color = parseColor(value); - return (color == null) ? def : color; + return color == null ? def : color; } /** @@ -1670,7 +1712,7 @@ public class Application protected String[] parseList (Map cdata, String name) { String value = (String)cdata.get(name); - return (value == null) ? ArrayUtil.EMPTY_STRING : StringUtil.parseStringArray(value); + return value == null ? ArrayUtil.EMPTY_STRING : StringUtil.parseStringArray(value); } /** @@ -1746,6 +1788,19 @@ public class Application } } + /** + * Returns the digest of the given {@code resource}. + */ + public String getDigest (Resource resource) + { + return _digest.getDigest(resource); + } + + public File getAppdir() + { + return _appdir; + } + protected File _appdir; protected String _appid; protected File _config; @@ -1780,6 +1835,9 @@ public class Application protected List _codes = new ArrayList(); protected List _resources = new ArrayList(); + protected boolean _useCodeCache; + protected int _codeCacheRetentionDays; + protected Map _auxgroups = new HashMap(); protected Map _auxactive = new HashMap(); diff --git a/src/main/java/com/threerings/getdown/data/Digest.java b/src/main/java/com/threerings/getdown/data/Digest.java index ecdde99..5cdddf2 100644 --- a/src/main/java/com/threerings/getdown/data/Digest.java +++ b/src/main/java/com/threerings/getdown/data/Digest.java @@ -5,27 +5,24 @@ package com.threerings.getdown.data; +import static com.threerings.getdown.Log.log; + import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStreamWriter; import java.io.PrintWriter; - import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; - import java.util.HashMap; import java.util.List; import com.samskivert.io.StreamUtil; import com.samskivert.text.MessageUtil; import com.samskivert.util.StringUtil; - import com.threerings.getdown.util.ConfigUtil; import com.threerings.getdown.util.ProgressObserver; -import static com.threerings.getdown.Log.log; - /** * Manages the digest.txt file and the computing and processing of MD5 digests for an * application. @@ -148,6 +145,14 @@ public class Digest data.append(path).append(" = ").append(digest).append("\n"); } + /** + * Returns the digest of the given {@code resource}. + */ + public String getDigest (Resource resource) + { + return _digests.get(resource.getPath()); + } + protected HashMap _digests = new HashMap(); protected String _metaDigest = ""; } diff --git a/src/main/java/com/threerings/getdown/util/FileUtil.java b/src/main/java/com/threerings/getdown/util/FileUtil.java index c957e59..bbfcb2a 100644 --- a/src/main/java/com/threerings/getdown/util/FileUtil.java +++ b/src/main/java/com/threerings/getdown/util/FileUtil.java @@ -5,7 +5,15 @@ package com.threerings.getdown.util; -import java.io.*; +import static com.threerings.getdown.Log.log; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; import java.util.ArrayList; import java.util.List; import java.util.jar.JarOutputStream; @@ -14,8 +22,6 @@ import java.util.zip.GZIPInputStream; import com.samskivert.io.StreamUtil; -import static com.threerings.getdown.Log.log; - /** * File related utilities. */ @@ -109,7 +115,6 @@ public class FileUtil extends com.samskivert.util.FileUtil Pack200.Unpacker unpacker = Pack200.newUnpacker(); unpacker.unpack(packedJarIn, jarOutputStream); return true; - } catch (IOException e) { log.warning("Failed to unpack packed 200 jar file", "jar", packedJar, "error", e); return false; @@ -120,4 +125,22 @@ public class FileUtil extends com.samskivert.util.FileUtil StreamUtil.close(packedJarIn); } } + + /** + * Copies the given {@code source} file to the given {@code target}. + */ + public static void copy(File source, File target) throws IOException { + FileInputStream in = null; + FileOutputStream out = null; + + try { + in = new FileInputStream(source); + out = new FileOutputStream(target); + + StreamUtil.copy(in, out); + } finally { + StreamUtil.close(in); + StreamUtil.close(out); + } + } } diff --git a/src/main/java/com/threerings/getdown/util/file/FileVisitor.java b/src/main/java/com/threerings/getdown/util/file/FileVisitor.java new file mode 100644 index 0000000..29f01ed --- /dev/null +++ b/src/main/java/com/threerings/getdown/util/file/FileVisitor.java @@ -0,0 +1,6 @@ +package com.threerings.getdown.util.file; +import java.io.File; + +public interface FileVisitor { + void visit(File file); +} diff --git a/src/main/java/com/threerings/getdown/util/file/FileWalker.java b/src/main/java/com/threerings/getdown/util/file/FileWalker.java new file mode 100644 index 0000000..2ffc613 --- /dev/null +++ b/src/main/java/com/threerings/getdown/util/file/FileWalker.java @@ -0,0 +1,42 @@ +package com.threerings.getdown.util.file; + +import java.io.File; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; + +/** + * Recursively walks over files and folders. A {@link FileVisitor visitor} is informed for each path + * traversed. + */ +public class FileWalker +{ + public FileWalker (File start) + { + this._start = start; + } + + /** + * Walks over the directory tree with the provided {@code visitor}. + */ + public void walkTree (FileVisitor visitor) + { + Deque stack = new ArrayDeque(Arrays.asList(_start.listFiles())); + + while (!stack.isEmpty()) { + File currentFile = stack.pop(); + + if (currentFile.exists()) { + visitor.visit(currentFile); + + if (currentFile.isDirectory()) { + for (File file: currentFile.listFiles()) { + stack.push(file); + } + } + } + } + } + + private final File _start; +} diff --git a/src/test/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilderTest.java b/src/test/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilderTest.java new file mode 100644 index 0000000..f76a317 --- /dev/null +++ b/src/test/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilderTest.java @@ -0,0 +1,44 @@ +package com.threerings.getdown.classpath; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.runners.MockitoJUnitRunner; + +import com.threerings.getdown.util.FileUtil; + +/** + * Tests {@link CacheBasedClassPathBuilderTest}. + */ +@RunWith(MockitoJUnitRunner.class) +public class CacheBasedClassPathBuilderTest extends ClassPathBuilderTestBase +{ + @Test + public void shouldBuildCacheBasedClassPath () throws IOException + { + when(_application.getDigest(_firstJar)).thenReturn("first"); + when(_application.getDigest(_secondJar)).thenReturn("second"); + when(_application.getCodeCacheRetentionDays()).thenReturn(1); + + File firstCachedJarFile = FileUtil.newFile( + _appdir.getRoot(), + CacheBasedClassPathBuilder.CACHE_DIR, "fi", "first.jar"); + + File secondCachedJarFile = FileUtil.newFile( + _appdir.getRoot(), + CacheBasedClassPathBuilder.CACHE_DIR, "se", "second.jar"); + + String expectedClassPath = firstCachedJarFile.getAbsolutePath() + + File.pathSeparator + + secondCachedJarFile.getAbsolutePath(); + + ClassPath classPath = new CacheBasedClassPathBuilder(_application).buildClassPath(); + + assertEquals(expectedClassPath, classPath.asArgumentString()); + } +} diff --git a/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderFactoryTest.java b/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderFactoryTest.java new file mode 100644 index 0000000..d16e5df --- /dev/null +++ b/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderFactoryTest.java @@ -0,0 +1,37 @@ +package com.threerings.getdown.classpath; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import com.threerings.getdown.data.Application; + +/** + * Checks wether the correct {@link ClassPathBuilder class path builders} are created. + */ +@RunWith(MockitoJUnitRunner.class) +public class ClassPathBuilderFactoryTest +{ + @Test + public void shouldCreateDefaultClassPathBuilder () + { + when(_application.isUseCodeCache()).thenReturn(Boolean.FALSE); + + assertTrue(ClassPathBuilderFactory.create(_application) instanceof DefaultClassPathBuilder); + } + + @Test + public void shouldCreateCacheClassPathBuilder () + { + when(_application.isUseCodeCache()).thenReturn(Boolean.TRUE); + + assertTrue(ClassPathBuilderFactory.create(_application) instanceof CacheBasedClassPathBuilder); + } + + @Mock + private Application _application; +} diff --git a/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderTestBase.java b/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderTestBase.java new file mode 100644 index 0000000..a6a80e2 --- /dev/null +++ b/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderTestBase.java @@ -0,0 +1,48 @@ +package com.threerings.getdown.classpath; + +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import org.mockito.Mock; + +import com.threerings.getdown.data.Application; +import com.threerings.getdown.data.Resource; + +/** + * Base class for {@link ClassPathBuilder} tests. + */ +public abstract class ClassPathBuilderTestBase { + @Before + public void setupFilesAndResources () throws IOException + { + _firstJarFile = _appdir.newFile("a.jar"); + _secondJarFile = _appdir.newFile("b.jar"); + + when(_firstJar.getFinalTarget()).thenReturn(_firstJarFile); + when(_secondJar.getFinalTarget()).thenReturn(_secondJarFile); + when(_application.getActiveCodeResources()).thenReturn(Arrays.asList(_firstJar, _secondJar)); + when(_application.getAppdir()).thenReturn(_appdir.getRoot()); + } + + @Mock + protected Application _application; + + @Mock + protected Resource _firstJar; + + protected File _firstJarFile; + + @Mock + protected Resource _secondJar; + + protected File _secondJarFile; + + @Rule + public TemporaryFolder _appdir = new TemporaryFolder(); +} diff --git a/src/test/java/com/threerings/getdown/classpath/ClassPathTest.java b/src/test/java/com/threerings/getdown/classpath/ClassPathTest.java new file mode 100644 index 0000000..aaf9e9a --- /dev/null +++ b/src/test/java/com/threerings/getdown/classpath/ClassPathTest.java @@ -0,0 +1,60 @@ +package com.threerings.getdown.classpath; + +import static org.junit.Assert.assertEquals; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.LinkedHashSet; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Tests for {@link ClassPath}. + */ +public class ClassPathTest +{ + @Before + public void createJarsAndSetupClassPath () throws IOException + { + _firstJar = _folder.newFile("a.jar"); + _secondJar = _folder.newFile("b.jar"); + + LinkedHashSet classPathEntries = new LinkedHashSet(); + + classPathEntries.add(new ClassPathElement(_firstJar)); + classPathEntries.add(new ClassPathElement(_secondJar)); + + _classPath = new ClassPath(classPathEntries); + } + + @Test + public void shouldCreateValidArgumentString () + { + assertEquals( + _firstJar.getAbsolutePath() + File.pathSeparator + _secondJar.getAbsolutePath(), + _classPath.asArgumentString()); + } + + @Test + public void shouldProvideJarUrls () throws MalformedURLException, URISyntaxException + { + URL[] actualUrls = _classPath.asUrls(); + + assertEquals(_firstJar, new File(actualUrls[0].toURI())); + assertEquals(_secondJar, new File(actualUrls[1].toURI())); + } + + @Rule + public TemporaryFolder _folder = new TemporaryFolder(); + + private File _firstJar; + private File _secondJar; + + private ClassPath _classPath; +} diff --git a/src/test/java/com/threerings/getdown/classpath/DefaultClassPathBuilderTest.java b/src/test/java/com/threerings/getdown/classpath/DefaultClassPathBuilderTest.java new file mode 100644 index 0000000..96a088e --- /dev/null +++ b/src/test/java/com/threerings/getdown/classpath/DefaultClassPathBuilderTest.java @@ -0,0 +1,31 @@ +package com.threerings.getdown.classpath; + +import static org.junit.Assert.assertEquals; + +import java.io.File; +import java.io.IOException; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.runners.MockitoJUnitRunner; + +/** + * Tests the {@link DefaultClassPathBuilder}. + */ +@RunWith(MockitoJUnitRunner.class) +public class DefaultClassPathBuilderTest extends ClassPathBuilderTestBase +{ + @Test + public void shouldBuildClassPath () throws IOException + { + ClassPath classPath = new DefaultClassPathBuilder(_application).buildClassPath(); + + String expectedClassPath = _firstJarFile.getAbsolutePath() + + File.pathSeparator + + _secondJarFile.getAbsolutePath(); + + assertEquals(expectedClassPath, classPath.asArgumentString()); + } + + +} diff --git a/src/test/java/com/threerings/getdown/classpath/cache/GarbageCollectorTest.java b/src/test/java/com/threerings/getdown/classpath/cache/GarbageCollectorTest.java new file mode 100644 index 0000000..1a9975d --- /dev/null +++ b/src/test/java/com/threerings/getdown/classpath/cache/GarbageCollectorTest.java @@ -0,0 +1,91 @@ +package com.threerings.getdown.classpath.cache; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import com.threerings.getdown.util.file.FileWalker; + +/** + * Validates that cache garbage is collected and deleted correctly. + */ +public class GarbageCollectorTest +{ + @Before + public void setupFiles () throws IOException + { + _cachedFile = _folder.newFile("abc123.jar"); + _lastAccessedFile = _folder.newFile("abc123.jar" + ResourceCache.LAST_ACCESSED_FILE_SUFFIX); + } + + @Test + public void shouldDeleteCacheEntryIfRetentionPeriodIsReached () + { + gcNow(); + + assertFalse(_cachedFile.exists()); + assertFalse(_lastAccessedFile.exists()); + } + + @Test + public void shouldDeleteCacheFolderIfFolderIsEmpty () + { + gcNow(); + + assertFalse(_folder.getRoot().exists()); + } + + private void gcNow() { + GarbageCollector collector = new GarbageCollector( + new FileWalker(_folder.getRoot()), -1, TimeUnit.MILLISECONDS); + + collector.collectGarbage(); + } + + @Test + public void shouldKeepFilesInCacheIfRententionPeriodIsNotReached () + { + GarbageCollector collector = new GarbageCollector( + new FileWalker(_folder.getRoot()), 1, TimeUnit.DAYS); + + collector.collectGarbage(); + + assertTrue(_cachedFile.exists()); + assertTrue(_lastAccessedFile.exists()); + } + + @Test + public void shouldDeleteCachedFileIfLastAccessedFileIsMissing () + { + assumeTrue(_lastAccessedFile.delete()); + + gcNow(); + + assertFalse(_cachedFile.exists()); + } + + @Test + public void shouldDeleteLastAccessedFileIfCachedFileIsMissing () + { + assumeTrue(_cachedFile.delete()); + + gcNow(); + + assertFalse(_lastAccessedFile.exists()); + } + + @Rule + public TemporaryFolder _folder = new TemporaryFolder(); + + private File _cachedFile; + private File _lastAccessedFile; +} diff --git a/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java b/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java new file mode 100644 index 0000000..5c87e2c --- /dev/null +++ b/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java @@ -0,0 +1,81 @@ +package com.threerings.getdown.classpath.cache; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Asserts the correct functionality of the {@link ResourceCache}. + */ +public class ResourceCacheTest +{ + @Before + public void setupCache () throws IOException { + _fileToCache = _folder.newFile("filetocache.jar"); + _cache = new ResourceCache(_folder.newFolder(".cache")); + } + + @Test + public void shouldCacheFile () throws IOException + { + assertEquals("abc123.jar", cacheFile().getName()); + } + + private File cacheFile() throws IOException + { + return _cache.cacheFile(_fileToCache, "abc123"); + } + + @Test + public void shouldTrackFileUsage () throws IOException + { + File lastAccessedFile = new File( + cacheFile().getParentFile(), + "abc123.jar" + ResourceCache.LAST_ACCESSED_FILE_SUFFIX); + + assertTrue(lastAccessedFile.exists()); + } + + @Test + public void shouldNotCacheTheSameFile () throws Exception + { + File cachedFile = cacheFile(); + + TimeUnit.MILLISECONDS.sleep(5); + + // caching it another time + File sameCachedFile = cacheFile(); + + assertEquals(cachedFile.lastModified(), sameCachedFile.lastModified()); + } + + @Test + public void shouldRememberWhenFileWasRequested () throws Exception + { + cacheFile(); + + TimeUnit.MILLISECONDS.sleep(5); + + // caching it another time + File sameCachedFile = cacheFile(); + File lastAccessed = new File( + sameCachedFile.getParentFile(), + sameCachedFile.getName() + ResourceCache.LAST_ACCESSED_FILE_SUFFIX); + + assertTrue(lastAccessed.lastModified() > sameCachedFile.lastModified()); + } + + @Rule + public TemporaryFolder _folder = new TemporaryFolder(); + + private File _fileToCache; + private ResourceCache _cache; +} diff --git a/src/test/java/com/threerings/getdown/util/FileUtilTest.java b/src/test/java/com/threerings/getdown/util/FileUtilTest.java index c5c64f4..30214bf 100644 --- a/src/test/java/com/threerings/getdown/util/FileUtilTest.java +++ b/src/test/java/com/threerings/getdown/util/FileUtilTest.java @@ -5,26 +5,49 @@ package com.threerings.getdown.util; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.File; import java.io.IOException; import java.io.StringReader; import java.util.List; -import org.junit.*; -import static org.junit.Assert.*; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; /** * Tests {@link FileUtil}. */ public class FileUtilTest { - @Test public void testReadLines () throws IOException + @Test + public void testReadLines () throws IOException { String data = "This is a test\nof a file with\na few lines\n"; List lines = FileUtil.readLines(new StringReader(data)); String[] linesBySplit = data.split("\n"); assertEquals(linesBySplit.length, lines.size()); - for (int ii = 0; ii < lines.size(); ii++) { - assertEquals(linesBySplit[ii], lines.get(ii)); + for (int i = 0; i < lines.size(); i++) { + assertEquals(linesBySplit[i], lines.get(i)); } } + + @Test + public void shouldCopyFile () throws IOException + { + File source = _folder.newFile("source.txt"); + File target = new File(_folder.getRoot(), "target.txt"); + + assertFalse(target.exists()); + + FileUtil.copy(source, target); + + assertTrue(target.exists()); + } + + @Rule + public TemporaryFolder _folder = new TemporaryFolder(); } diff --git a/src/test/java/com/threerings/getdown/util/file/FileWalkerTest.java b/src/test/java/com/threerings/getdown/util/file/FileWalkerTest.java new file mode 100644 index 0000000..c4787e0 --- /dev/null +++ b/src/test/java/com/threerings/getdown/util/file/FileWalkerTest.java @@ -0,0 +1,41 @@ +package com.threerings.getdown.util.file; + +import static org.junit.Assert.assertEquals; + +import java.io.File; +import java.io.IOException; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Class under test: {@link FileWalker}. + */ +public class FileWalkerTest +{ + @Test + public void shouldRecursivelyWalkOverFilesAndFolders () throws IOException + { + _folder.newFile("a.txt"); + new File(_folder.newFolder("b"), "b.txt").createNewFile(); + + CountingVisitor visitor = new CountingVisitor(); + new FileWalker(_folder.getRoot()).walkTree(visitor); + + assertEquals(3, visitor.fileCount); + } + + public static class CountingVisitor implements FileVisitor + { + int fileCount = 0; + + @Override + public void visit(File file) { + fileCount++; + } + } + + @Rule + public TemporaryFolder _folder = new TemporaryFolder(); +}