/******************************************************************************* * Copyright © 2011, 2013 IBM Corporation and others. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * IBM Corporation - initial API and implementation * *******************************************************************************/ package org.eclipse.edt.ide.testserver; import java.io.IOException; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLConnection; import java.net.URLEncoder; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Set; import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.IResourceChangeEvent; import org.eclipse.core.resources.IResourceChangeListener; import org.eclipse.core.resources.IWorkspaceRoot; import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IAdaptable; import org.eclipse.core.runtime.IPath; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.Status; import org.eclipse.debug.core.DebugEvent; import org.eclipse.debug.core.DebugException; import org.eclipse.debug.core.DebugPlugin; import org.eclipse.debug.core.IDebugEventSetListener; import org.eclipse.debug.core.ILaunch; import org.eclipse.debug.core.ILaunchConfigurationType; import org.eclipse.debug.core.ILaunchConfigurationWorkingCopy; import org.eclipse.debug.core.ILaunchManager; import org.eclipse.debug.core.model.IDebugTarget; import org.eclipse.debug.core.model.IProcess; import org.eclipse.edt.ide.core.model.EGLCore; import org.eclipse.edt.ide.core.model.EGLModelException; import org.eclipse.edt.ide.core.model.IEGLPathEntry; import org.eclipse.edt.ide.core.model.IEGLProject; import org.eclipse.edt.ide.internal.testserver.DefaultServlet; import org.eclipse.edt.ide.internal.testserver.HotCodeReplaceListener; import org.eclipse.jdt.core.JavaCore; import org.eclipse.jdt.debug.core.IJavaDebugTarget; import org.eclipse.jdt.launching.IJavaLaunchConfigurationConstants; import org.eclipse.jface.util.IPropertyChangeListener; import org.eclipse.jface.util.PropertyChangeEvent; import org.eclipse.osgi.util.NLS; /** * Manages a Jetty test server. This class is capable of starting and stopping the server, and handles changes to * the server configuration automatically. Extensions to the server can be contributed via the testServerExtension * extension point. */ public class TestServerConfiguration implements IDebugEventSetListener, IResourceChangeListener, IPropertyChangeListener { public static final int DEFAULT_PORT = 9701; public static final String TEST_SERVER_CONFIG_TYPE_ID = "org.eclipse.edt.ide.testserver.testServerLaunchType"; //$NON-NLS-1$ private IProject project; private boolean debugMode; private int port; private boolean started; private ILaunch launch; private List<TerminationListener> terminationListeners; private String[] latestCheckedClasspath; // Used to determine when a classpath has changed. /** * Constructor. * * @param project The root project for which this server should run. * @param debugMode True if the Java process should run in debug mode. */ public TestServerConfiguration(IProject project, boolean debugMode) { this(project, debugMode, -1); } /** * Constructor. * * @param project The root project for which this server should run. * @param debugMode True if the Java process should run in debug mode. * @param port The port that the server should run on, or -1 if any available port should be used. */ public TestServerConfiguration(IProject project, boolean debugMode, int port) { this.project = project; this.debugMode = debugMode; this.port = port; for (AbstractTestServerContribution contrib : TestServerPlugin.getContributions()) { contrib.init(this); } } /** * Starts the server; if it's already started this will return immediately. If waitForServerToStart is specified, * we will wait up to 60 seconds for it to start before throwing an error. * * @param monitor An optional progress monitor (may be null). * @param waitForServerToStart A flag indicating if this method should wait for the server to finish starting before returning. * @throws CoreException */ public synchronized void start(IProgressMonitor monitor, boolean waitForServerToStart) throws CoreException { if (started) { return; } try { if (!project.hasNature(JavaCore.NATURE_ID)) { throw new CoreException(new Status(IStatus.ERROR, TestServerPlugin.PLUGIN_ID, NLS.bind(TestServerMessages.ProjectMissingJavaNature, project.getName()))); } if (port < 0) { port = SocketUtil.findOpenPort(DEFAULT_PORT, 5, 100); SocketUtil.reservePort(port); } AbstractTestServerContribution[] contributions = TestServerPlugin.getContributions(); // Create a temporary launch configuration, set the project and claspath entries, then run it in either RUN or DEBUG mode. ILaunchConfigurationType type = DebugPlugin.getDefault().getLaunchManager().getLaunchConfigurationType(TEST_SERVER_CONFIG_TYPE_ID); ILaunchConfigurationWorkingCopy copy = type.newInstance(null, NLS.bind(TestServerMessages.TestServerProcessName, new Object[]{project.getName(), port})); copy.setAttribute(IJavaLaunchConfigurationConstants.ATTR_PROJECT_NAME, project.getName()); copy.setAttribute(IJavaLaunchConfigurationConstants.ATTR_MAIN_TYPE_NAME, TestServer.class.getCanonicalName()); copy.setAttribute(IJavaLaunchConfigurationConstants.ATTR_DEFAULT_CLASSPATH, false); List<String> classpath = copy.getAttribute(IJavaLaunchConfigurationConstants.ATTR_CLASSPATH, new ArrayList<String>(10)); ClasspathUtil.buildClasspath(this, classpath); copy.setAttribute(IJavaLaunchConfigurationConstants.ATTR_CLASSPATH, classpath); StringBuilder args = new StringBuilder(100); args.append(copy.getAttribute(IJavaLaunchConfigurationConstants.ATTR_PROGRAM_ARGUMENTS, "")); //$NON-NLS-1$ args.append(" -p "); //$NON-NLS-1$ args.append(port); args.append(" -i "); //$NON-NLS-1$ args.append(TestServerIDEConnector.getInstance().getPortNumber()); args.append(" -c \"/"); //$NON-NLS-1$ args.append(project.getName()); args.append("\""); //$NON-NLS-1$ IPath tempDir = TestServerPlugin.getDefault().getTempDirectory(); if (tempDir != null) { args.append(" -td \""); //$NON-NLS-1$ args.append(tempDir.append(project.getName()).toOSString()); args.append("\""); //$NON-NLS-1$ } if (TestServerPlugin.getDefault().getPreferenceStore().getBoolean(ITestServerPreferenceConstants.PREFERENCE_TESTSERVER_ENABLE_DEBUG)) { args.append(" -d"); //$NON-NLS-1$ } // Append the configurators argument. Set<String> configuratorClasses = new HashSet<String>(10); for (AbstractTestServerContribution contrib : contributions) { String[] classes = contrib.getConfiguratorClassNames(this); if (classes != null && classes.length > 0) { for (String clazz : classes) { configuratorClasses.add(clazz); } } } if (configuratorClasses.size() > 0) { args.append(" -contribs "); //$NON-NLS-1$ boolean first = true; for (String clazz : configuratorClasses) { if (!first) { args.append(':'); } args.append(clazz); } } // Append contributed args. for (AbstractTestServerContribution contrib : contributions) { String extraArgs = contrib.getArgumentAdditions(this); if (extraArgs != null) { args.append(extraArgs); } } copy.setAttribute(IJavaLaunchConfigurationConstants.ATTR_PROGRAM_ARGUMENTS, args.toString()); // register a listener for the launch to detect when the process is terminated DebugPlugin.getDefault().addDebugEventListener(this); // register a listener for changes to DD files ResourcesPlugin.getWorkspace().addResourceChangeListener(this, IResourceChangeEvent.POST_CHANGE); launch = copy.launch(debugMode ? ILaunchManager.DEBUG_MODE : ILaunchManager.RUN_MODE, monitor); latestCheckedClasspath = ClasspathUtil.resolveClasspath(launch.getLaunchConfiguration()); if (waitForServerToStart) { // Wait up to 60 seconds for the server to start. for (int i = 0; i < 240; i++) { try { if (invokeServlet(DefaultServlet.SERVLET_PATH, "") == 200) { //$NON-NLS-1$ started = true; break; } } catch (IOException e) { } try { Thread.sleep(250); } catch (InterruptedException e) { } } if (!started) { try { terminate(); } catch (DebugException de) { } throw new CoreException(new Status(IStatus.ERROR, TestServerPlugin.PLUGIN_ID, NLS.bind(TestServerMessages.PingFailed, new Object[]{project.getName(), String.valueOf(port)}))); } } for (IDebugTarget target : launch.getDebugTargets()) { IJavaDebugTarget javaTarget = (IJavaDebugTarget)target.getAdapter(IJavaDebugTarget.class); if (javaTarget != null) { javaTarget.addHotCodeReplaceListener(new HotCodeReplaceListener(this)); } } TestServerPlugin.getDefault().getPreferenceStore().addPropertyChangeListener(this); } catch (IOException e) { throw new CoreException(new Status(IStatus.ERROR, TestServerPlugin.PLUGIN_ID, e.getMessage(), e)); } } /** * @return true if the server is started. */ public boolean isRunning() { return started; } /** * @return true if the server was started in debug mode. */ public boolean isDebugMode() { return debugMode; } /** * @return the test server's root project. */ public IProject getProject() { return project; } /** * @return the port on which the test server is running. */ public int getPort() { return port; } /** * Terminates the Java process if it's running. * @throws DebugException */ public void terminate() throws DebugException { if (launch != null) { launch.terminate(); } } /** * Adds a listener to be notified when the server terminates. * @param listener The listener. */ public void addTerminationListener(TerminationListener listener) { if (terminationListeners == null) { terminationListeners = new ArrayList<TerminationListener>(); } terminationListeners.add(listener); } /** * Removes a listener from the list to be notified when the server terminates. * @param listener The listener. */ public void removeTerminationListener(TerminationListener listener) { if (terminationListeners != null) { terminationListeners.remove(listener); } } /** * Disposes this server configuration. Once disposed this configuration should not be reused. */ public void dispose() { ResourcesPlugin.getWorkspace().removeResourceChangeListener(this); DebugPlugin.getDefault().removeDebugEventListener(this); TestServerPlugin.getDefault().getPreferenceStore().removePropertyChangeListener(this); SocketUtil.freePort(port); // Notify the listeners before we null anything out. if (terminationListeners != null) { for (TerminationListener listener : terminationListeners) { listener.terminated(this); } } for (AbstractTestServerContribution contrib : TestServerPlugin.getContributions()) { contrib.dispose(this); } this.project = null; this.launch = null; this.started = false; this.terminationListeners = null; } @Override public void handleDebugEvents(DebugEvent[] events) { if (!started) { // Don't care about events if we're not started. return; } if (events == null || events.length == 0) { return; } for (DebugEvent event : events) { if (event.getKind() == DebugEvent.TERMINATE && event.getSource() instanceof IAdaptable) { ILaunch launch = (ILaunch)((IAdaptable)event.getSource()).getAdapter(ILaunch.class); if (launch == this.launch) { for (IProcess process : launch.getProcesses()) { if (process.isTerminated()) { dispose(); return; } } } } } } @Override public void propertyChange(PropertyChangeEvent event) { if (!started) { return; } if (ITestServerPreferenceConstants.PREFERENCE_TESTSERVER_ENABLE_DEBUG.equals(event.getProperty())) { try { int status = invokeServlet(DefaultServlet.SERVLET_PATH, DefaultServlet.ARG_DEBUG + "=" + event.getNewValue()); //$NON-NLS-1$ if (status != 200) { TestServerPlugin.getDefault().log(NLS.bind(TestServerMessages.DefaultServletBadStatus, new Object[]{status, project.getName()})); } } catch (IOException ioe) { } } } @Override public void resourceChanged(IResourceChangeEvent event) { if (!started) { return; } for (AbstractTestServerContribution contrib : TestServerPlugin.getContributions()) { contrib.resourceChanged(event, this); } } /** * Invokes a servlet on the server. Clients may use this method, but be careful about invoking it from the * UI thread (e.g. if you have a breakpoint in the test server preventing the response from being sent, you'll * have a frozen workbench). * * @param servletPath Path to the servlet, NOT prefixed with a '/' * @param args Arguments to be included in the request * @return the response code from jetty * @throws IOException */ public int invokeServlet(String servletPath, String args) throws IOException { String projectName = project.getName(); try { projectName = URLEncoder.encode(projectName, "UTF-8"); //$NON-NLS-1$ } catch (UnsupportedEncodingException e) { // Shouldn't happen. } URLConnection conn = new URL("http://localhost:" + port + "/" + projectName + "/" + servletPath).openConnection(); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ conn.setDoOutput(true); conn.setRequestProperty("Accept-Charset", "UTF-8"); //$NON-NLS-1$ //$NON-NLS-2$ conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8"); //$NON-NLS-1$ //$NON-NLS-2$ OutputStream output = null; try { output = conn.getOutputStream(); output.write(args.getBytes("UTF-8")); //$NON-NLS-1$ } finally { if (output != null) { try { output.close(); } catch (IOException logOrIgnore) { } } } return ((HttpURLConnection)conn).getResponseCode(); } /** * @return true if the given project is on the EGL path of this test server. */ public boolean isOnEGLPath(IProject project) { return isOnEGLPath(this.project, project, new HashSet<IProject>()); } public boolean isOnEGLPath(IProject currProject, IProject deltaProject, Set<IProject> seen) { if (seen.contains(currProject)) { return false; } seen.add(currProject); if (currProject.equals(deltaProject)) { return true; } try { if (currProject.hasNature(EGLCore.NATURE_ID)) { IEGLProject eglProject = EGLCore.create(currProject); IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); for (IEGLPathEntry entry : eglProject.getResolvedEGLPath(true)) { if (entry.getEntryKind() == IEGLPathEntry.CPE_PROJECT) { IResource resource = root.findMember(entry.getPath()); if (resource != null && resource.getType() == IResource.PROJECT && resource.isAccessible()) { if (isOnEGLPath((IProject)resource, deltaProject, seen)) { return true; } } } } } } catch (EGLModelException e) { TestServerPlugin.getDefault().log(e); } catch (CoreException e ) { TestServerPlugin.getDefault().log(e); } return false; } /** * Causes this config to recompute its classpath and see if it's different from what's currently being used. If the server hasn't * been started yet, this will return false. * * @return true if the classpath has changed since the server launch. */ public boolean hasClasspathChanged() { if (!started || this.launch == null || this.launch.getLaunchConfiguration() == null) { return false; } boolean result = false; try { // Create a launch configuration in the same way as when launching the server, but only set the attributes that would affect the classpath. ILaunchConfigurationType type = DebugPlugin.getDefault().getLaunchManager().getLaunchConfigurationType( IJavaLaunchConfigurationConstants.ID_JAVA_APPLICATION); ILaunchConfigurationWorkingCopy copy = type.newInstance(null, "ezeTemp"); //$NON-NLS-1$ copy.setAttribute(IJavaLaunchConfigurationConstants.ATTR_PROJECT_NAME, project.getName()); copy.setAttribute(IJavaLaunchConfigurationConstants.ATTR_DEFAULT_CLASSPATH, false); List<String> newClasspath = copy.getAttribute(IJavaLaunchConfigurationConstants.ATTR_CLASSPATH, new ArrayList<String>(10)); ClasspathUtil.buildClasspath(this, newClasspath); copy.setAttribute(IJavaLaunchConfigurationConstants.ATTR_CLASSPATH, newClasspath); String[] newResolvedClasspath = ClasspathUtil.resolveClasspath(copy); // Order DOES matter in a classpath, in the case of duplicate qualified class names. The arrays must be exactly equal to be considered unchanged. result = !Arrays.equals(latestCheckedClasspath, newResolvedClasspath); latestCheckedClasspath = newResolvedClasspath; } catch (CoreException ce) { } return result; } @Override public String toString() { return "Test server: project=" + (project == null ? "null" : project.getName())+ ", port=" + port + ", started=" + started; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ } /** * Allows clients to be notified when this server configuration terminates. */ public static interface TerminationListener { public void terminated(TestServerConfiguration config); } }