/*
* Copyright (c) 2013-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 io.werval.devshell;
import java.awt.Desktop;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import io.werval.api.exceptions.PassivationException;
import io.werval.api.exceptions.WervalException;
import io.werval.spi.dev.DevShellRebuildException;
import io.werval.spi.dev.DevShellSPI;
import io.werval.spi.dev.DevShellSPIWrapper;
import org.codehaus.plexus.classworlds.ClassWorld;
import org.codehaus.plexus.classworlds.realm.ClassRealm;
import org.codehaus.plexus.classworlds.realm.DuplicateRealmException;
import org.codehaus.plexus.classworlds.realm.NoSuchRealmException;
import static java.util.Collections.singletonMap;
import static io.werval.runtime.ConfigKeys.WERVAL_HTTP_ADDRESS;
import static io.werval.runtime.ConfigKeys.WERVAL_HTTP_PORT;
import static io.werval.runtime.util.AnsiColor.cyan;
import static io.werval.runtime.util.AnsiColor.red;
import static io.werval.runtime.util.AnsiColor.white;
import static io.werval.runtime.util.AnsiColor.yellow;
import static io.werval.util.ClassLoaders.printLoadedClasses;
import static io.werval.util.ClassLoaders.printURLs;
/**
* Werval DevShell.
*
* Bind a build plugin to a Werval runtime using a DevShellSPI.
* <p>
* Class reloading is implemented using <a href="https://github.com/sonatype/plexus-classworlds">ClassWorlds</a>.
* <p>
* See {@literal src/doc/classloader-hierary.png} in the source tree for an overview of how the ClassLoader hierarchy is
* set up.
*/
public final class DevShell
{
/**
* Decorate DevShellSPI to reload classes after a rebuild.
*
* This is the decorated instance of DevShellSPI that is passed to the HttpServer.
*/
private final class DevShellSPIDecorator
extends DevShellSPIWrapper
{
private final Object appInstance;
private final Method reloadMethod;
private DevShellSPIDecorator( DevShellSPI wrapped, Object appInstance )
throws NoSuchMethodException
{
super( wrapped );
this.appInstance = appInstance;
this.reloadMethod = appInstance.getClass().getMethod(
"reload",
new Class<?>[]
{
ClassLoader.class
}
);
}
@Override
@SuppressWarnings( "UseSpecificCatch" )
public synchronized void rebuild()
{
if( isSourceChanged() )
{
try
{
super.rebuild();
}
catch( DevShellRebuildException ex )
{
throw ex;
}
catch( Exception ex )
{
throw new DevShellRebuildException( ex );
}
try
{
reSetupApplicationRealms();
reloadMethod.invoke(
appInstance,
classWorld.getRealm( currentApplicationRealmID() )
);
}
catch( Exception ex )
{
throw new DevShellRebuildException( "Unable to reload Application", ex );
}
}
}
}
private static final String DEVSHELL_REALM_ID = "DevShellRealm";
private static final String DEPENDENCIES_REALM_ID = "DependenciesRealm";
private static final String APPLICATION_REALM_ID = "ApplicationRealm";
private static final String CONFIG_API_CLASS = "io.werval.api.Config";
private static final String CONFIG_RUNTIME_CLASS = "io.werval.runtime.ConfigInstance";
private static final String CRYPTO_RUNTIME_CLASS = "io.werval.runtime.CryptoInstance";
private static final String APPLICATION_RUNTIME_CLASS = "io.werval.runtime.ApplicationInstance";
private static final String MODE_API_CLASS = "io.werval.api.Mode";
private static final String APPLICATION_SPI_CLASS = "io.werval.spi.ApplicationSPI";
private static final String NETTY_SERVER_CLASS = "io.werval.server.netty.NettyServer";
private static final File RUN_LOCK_FILE = new File( Paths.get( "" ).toAbsolutePath().toFile(), ".devshell.lock" );
private static final long RUN_LOCK_FILE_POLL_INTERVAL_MILLIS = 500;
private static final AtomicLong APPLICATION_REALM_COUNT = new AtomicLong( 0L );
private final DevShellSPI spi;
private final String configResource;
private final File configFile;
private final URL configUrl;
private final boolean openBrowser;
private final URLClassLoader originalLoader;
private ClassWorld classWorld;
private Object httpServer;
private volatile boolean running = false;
public DevShell( DevShellSPI spi )
{
this( spi, null, null, null, true );
}
public DevShell( DevShellSPI spi, String configResource )
{
this( spi, configResource, null, null, true );
}
public DevShell( DevShellSPI spi, File configFile )
{
this( spi, null, configFile, null, true );
}
public DevShell( DevShellSPI spi, URL configUrl )
{
this( spi, null, null, configUrl, true );
}
public DevShell( DevShellSPI spi, String configResource, File configFile, URL configUrl, boolean openBrowser )
{
this.spi = spi;
this.configResource = configResource;
this.configFile = configFile;
this.configUrl = configUrl;
this.openBrowser = openBrowser;
this.originalLoader = ( (URLClassLoader) Thread.currentThread().getContextClassLoader() );
}
private static String nextApplicationRealmID()
{
return APPLICATION_REALM_ID + "-" + APPLICATION_REALM_COUNT.incrementAndGet();
}
private static String currentApplicationRealmID()
{
return APPLICATION_REALM_ID + "-" + APPLICATION_REALM_COUNT.get();
}
@SuppressWarnings( "unchecked" )
public void start()
{
if( lockFileExist() )
{
throw new IllegalStateException(
"Unable to start DevShell, lock file '" + RUN_LOCK_FILE + "' already exists. "
+ "Is another instance already running?"
);
}
System.out.println( white( ">> Werval DevShell starting..." ) );
try
{
System.out.println( cyan( "Isolating worlds..." ) );
setupRealms();
ClassRealm appRealm = classWorld.getRealm( currentApplicationRealmID() );
Thread.currentThread().setContextClassLoader( appRealm );
System.out.println( cyan( "Starting isolated Werval Application..." ) );
// Config
Class<?> configClass = appRealm.loadClass( CONFIG_API_CLASS );
Class<?> configRuntimeClass = appRealm.loadClass( CONFIG_RUNTIME_CLASS );
Constructor<?> configRuntimeCtor = configRuntimeClass.getConstructor(
new Class<?>[]
{
ClassLoader.class, String.class, File.class, URL.class, Map.class
}
);
Object configInstance = configRuntimeCtor.newInstance(
new Object[]
{
appRealm, configResource, configFile, configUrl, null
}
);
try
{
// Check if the application has an `app.secret` configuration property
configClass.getMethod(
"string",
new Class<?>[]
{
String.class
}
).invoke(
configInstance, "app.secret"
);
}
catch( Exception noAppSecret )
{
// The application has no `app.secret` configuration property, generating a weakly random one
System.out.println(
red( "Application has no 'app.secret', generating a weekly random one for development mode!" )
);
Object secret = appRealm.loadClass( CRYPTO_RUNTIME_CLASS )
.getMethod( "newWeaklyRandomSecret256BitsHex" )
.invoke( null );
configInstance = configRuntimeCtor.newInstance(
new Object[]
{
appRealm, configResource, configFile, configUrl, singletonMap( "app.secret", secret )
}
);
System.out.println( red(
" The 'app.secret' will last as long as the development mode is running and survive reloads.\n"
+ " If you set one in configuration you'll have to restart the development mode!"
) );
}
// Application
Class<?> appClass = appRealm.loadClass( APPLICATION_RUNTIME_CLASS );
Class<?> modeClass = appRealm.loadClass( MODE_API_CLASS );
Object appInstance = appClass.getConstructor(
new Class<?>[]
{
modeClass,
configRuntimeClass,
ClassLoader.class,
DevShellSPI.class
}
).newInstance(
new Object[]
{
// Dev Mode
modeClass.getEnumConstants()[0],
configInstance,
appRealm,
spi
}
);
// Create HttpServer instance
httpServer = appRealm.loadClass( NETTY_SERVER_CLASS ).newInstance();
// Set ApplicationSPI on HttpServer
httpServer.getClass().getMethod(
"setApplicationSPI",
new Class<?>[]
{
appRealm.loadClass( APPLICATION_SPI_CLASS )
}
).invoke(
httpServer,
appInstance
);
// Set DevShellSPI on HttpServer
httpServer.getClass().getMethod(
"setDevShellSPI",
new Class<?>[]
{
DevShellSPI.class
}
).invoke(
httpServer,
new DevShellSPIDecorator( spi, appInstance )
);
// Get application URL
String appUrl = applicationUrl( configInstance, configClass );
// Activate HttpServer
httpServer.getClass().getMethod( "activate" ).invoke( httpServer );
System.out.println( white( ">> Ready for requests on " + appUrl + " !" ) );
// Eventually open default browser
if( openBrowser && Desktop.isDesktopSupported() )
{
try
{
Desktop.getDesktop().browse( new URI( appUrl ) );
}
catch( IOException | URISyntaxException ex )
{
System.out.println( yellow( "Unable to open the default browser: " + ex.getMessage() ) );
}
}
// Interrupt Loop
running = true;
createLockFile();
for( ;; )
{
if( running && lockFileExist() )
{
Thread.sleep( RUN_LOCK_FILE_POLL_INTERVAL_MILLIS );
}
else
{
stop();
break;
}
}
}
catch( DuplicateRealmException | NoSuchRealmException | ClassNotFoundException |
NoSuchMethodException | SecurityException | InstantiationException |
IllegalAccessException | IllegalArgumentException | InvocationTargetException |
InterruptedException ex )
{
Throwable cause = ex;
if( ex instanceof InvocationTargetException )
{
cause = ex.getCause();
}
String msg = "Unable to start Werval DevShell: "
+ cause.getClass().getSimpleName() + " " + cause.getMessage();
System.err.println( red( msg ) );
throw new DevShellStartException( msg, cause );
}
}
/**
* Stop DevShell.
*/
// Can be called concurrently by client code and by the lock file polling loop.
public synchronized void stop()
{
if( running )
{
running = false;
System.out.println( white( ">> Werval DevShell stopping..." ) );
// Record all passivation errors here to report them at once at the end
List<Exception> passivationErrors = new ArrayList<>();
// Passivate HTTP Server
try
{
passivateHttpServer();
}
catch( Exception ex )
{
passivationErrors.add( new WervalException( "Error while passivating HTTP Server: " + ex.getMessage(), ex )
);
}
// Signal DevShellSPI
try
{
spi.stop();
}
catch( Exception ex )
{
passivationErrors.add( new WervalException( "Error while stopping DevShellSPI: " + ex.getMessage(), ex )
);
}
// Dispose Realms
try
{
disposeRealms();
}
catch( Exception ex )
{
passivationErrors.add(
new WervalException( "Error while disposing Classworld Realms: " + ex.getMessage(), ex )
);
}
// Remove lock file
try
{
deleteLockFile();
}
catch( Exception ex )
{
passivationErrors.add( ex );
}
// Report errors if any
if( !passivationErrors.isEmpty() )
{
PassivationException ex = new PassivationException( "Unable to stop Werval DevShell" );
System.err.println( red( ex.getMessage() ) );
for( Exception passivationError : passivationErrors )
{
ex.addSuppressed( passivationError );
}
throw ex;
}
}
}
private boolean lockFileExist()
{
return RUN_LOCK_FILE.exists();
}
private void createLockFile()
{
try
{
Files.write( RUN_LOCK_FILE.toPath(), new byte[ 0 ] );
}
catch( IOException ex )
{
throw new UncheckedIOException( ex );
}
}
private void deleteLockFile()
{
if( RUN_LOCK_FILE.exists() )
{
try
{
Files.delete( RUN_LOCK_FILE.toPath() );
}
catch( IOException ex )
{
throw new UncheckedIOException( ex );
}
}
}
private void setupRealms()
throws DuplicateRealmException
{
classWorld = new ClassWorld();
// DevShell Realm delegates to the original ClassLoader (Either the CLI or the Build Plugin One)
ClassRealm devRealm = classWorld.newRealm( DEVSHELL_REALM_ID, originalLoader );
// Dependencies Realm contains all Application dependencies JARs
// and import Werval DevShell and Dev SPI packages from DevShell Realm
ClassRealm depRealm = classWorld.newRealm( DEPENDENCIES_REALM_ID, null );
for( URL runtimeClasspathElement : spi.runtimeClassPath() )
{
depRealm.addURL( runtimeClasspathElement );
}
depRealm.importFrom( devRealm, "io.werval.devshell.*" );
depRealm.importFrom( devRealm, "io.werval.spi.dev.*" );
setupApplicationRealm( depRealm );
}
private void reSetupApplicationRealms()
throws DuplicateRealmException, NoSuchRealmException
{
classWorld.disposeRealm( currentApplicationRealmID() );
setupApplicationRealm( classWorld.getRealm( DEPENDENCIES_REALM_ID ) );
}
private void setupApplicationRealm( ClassRealm depRealm )
throws DuplicateRealmException
{
// Application Realm contains all Application compiler output directories
// and it check itself first and then check Dependencies Realm
ClassRealm appRealm = classWorld.newRealm( nextApplicationRealmID(), null );
for( URL applicationClasspathElement : spi.applicationClassPath() )
{
appRealm.addURL( applicationClasspathElement );
}
appRealm.setParentRealm( depRealm );
}
private void disposeRealms()
throws NoSuchRealmException
{
if( classWorld != null )
{
classWorld.disposeRealm( DEVSHELL_REALM_ID );
classWorld.disposeRealm( DEPENDENCIES_REALM_ID );
classWorld.disposeRealm( currentApplicationRealmID() );
classWorld = null;
}
}
private void passivateHttpServer()
throws NoSuchMethodException, IllegalAccessException, IllegalArgumentException, InvocationTargetException
{
if( httpServer != null )
{
httpServer.getClass().getMethod( "passivate" ).invoke( httpServer );
httpServer = null;
}
}
private String applicationUrl( Object configInstance, Class<?> configClass )
throws NoSuchMethodException, IllegalAccessException, IllegalArgumentException, InvocationTargetException
{
Class<?>[] argsTypes = new Class<?>[]
{
String.class
};
String httpHost = (String) configClass
.getMethod( "string", argsTypes )
.invoke( configInstance, WERVAL_HTTP_ADDRESS );
int httpPort = (int) configClass
.getMethod( "intNumber", argsTypes )
.invoke( configInstance, WERVAL_HTTP_PORT );
if( "127.0.0.1".equals( httpHost ) || "0.0.0.0".equals( httpHost ) )
{
httpHost = "localhost";
}
return "http://" + httpHost + ":" + httpPort + "/";
}
// This is dead debug code waiting for a necromancer
private void printRealms()
throws NoSuchRealmException
{
ClassRealm devRealm = classWorld.getRealm( DEVSHELL_REALM_ID );
ClassRealm depRealm = classWorld.getRealm( DEPENDENCIES_REALM_ID );
ClassRealm appRealm = classWorld.getRealm( currentApplicationRealmID() );
System.out.println( white( "Realms / ClassLoaders" ) );
System.out.println( yellow( "Original ClassLoader" ) );
printURLs( originalLoader );
printLoadedClasses( originalLoader );
System.out.println( yellow( "DevShell ClassLoader" ) );
printURLs( devRealm );
printLoadedClasses( devRealm );
System.out.println( yellow( "Dependencies ClassLoader" ) );
printURLs( depRealm );
printLoadedClasses( depRealm );
System.out.println( yellow( "Application ClassLoader" ) );
printURLs( appRealm );
printLoadedClasses( appRealm );
}
}