/*
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
*
* Copyright 1997-2010 Oracle and/or its affiliates. All rights reserved.
*
* Oracle and Java are registered trademarks of Oracle and/or its affiliates.
* Other names may be trademarks of their respective owners.
*
* The contents of this file are subject to the terms of either the GNU
* General Public License Version 2 only ("GPL") or the Common
* Development and Distribution License("CDDL") (collectively, the
* "License"). You may not use this file except in compliance with the
* License. You can obtain a copy of the License at
* http://www.netbeans.org/cddl-gplv2.html
* or nbbuild/licenses/CDDL-GPL-2-CP. See the License for the
* specific language governing permissions and limitations under the
* License. When distributing the software, include this License Header
* Notice in each file and include the License file at
* nbbuild/licenses/CDDL-GPL-2-CP. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the GPL Version 2 section of the License file that
* accompanied this code. If applicable, add the following below the
* License Header, with the fields enclosed by brackets [] replaced by
* your own identifying information:
* "Portions Copyrighted [year] [name of copyright owner]"
*
* Contributor(s):
*
* The Original Software is NetBeans. The Initial Developer of the Original
* Software is Sun Microsystems, Inc. Portions Copyright 1997-2008 Sun
* Microsystems, Inc. All Rights Reserved.
*
* If you wish your version of this file to be governed by only the CDDL
* or only the GPL Version 2, indicate your decision by adding
* "[Contributor] elects to include this software in this distribution
* under the [CDDL or GPL Version 2] license." If you do not indicate a
* single choice of license, a recipient has the option to distribute
* your version of this file under either the CDDL, the GPL Version 2 or
* to extend the choice of license to its licensees as provided above.
* However, if you add GPL Version 2 code and therefore, elected the GPL
* Version 2 license, then the option applies only if the new code is
* made subject to such option by the copyright holder.
*/
package org.netbeans.modules.ruby.railsprojects.server;
import java.awt.event.ActionEvent;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.AbstractAction;
import org.netbeans.api.progress.ProgressHandle;
import org.netbeans.api.progress.ProgressHandleFactory;
import org.netbeans.api.project.ProjectInformation;
import org.netbeans.api.ruby.platform.RubyPlatform;
import org.netbeans.api.extexecution.ExecutionService;
import org.netbeans.api.extexecution.print.ConvertedLine;
import org.netbeans.api.extexecution.print.LineConvertor;
import org.netbeans.modules.ruby.codecoverage.RubyCoverageProvider;
import org.netbeans.modules.ruby.platform.execution.DirectoryFileLocator;
import org.netbeans.modules.ruby.platform.execution.RubyExecutionDescriptor;
import org.netbeans.modules.ruby.platform.execution.RubyProcessCreator;
import org.netbeans.modules.ruby.platform.gems.Gem;
import org.netbeans.modules.ruby.platform.gems.GemManager;
import org.netbeans.modules.ruby.railsprojects.RailsProject;
import org.netbeans.modules.ruby.railsprojects.RailsProjectUtil;
import org.netbeans.modules.ruby.railsprojects.RailsProjectUtil.RailsVersion;
import org.netbeans.modules.ruby.railsprojects.server.spi.RubyInstance;
import org.netbeans.modules.ruby.railsprojects.ui.customizer.RailsProjectProperties;
import org.openide.DialogDisplayer;
import org.openide.NotifyDescriptor;
import org.openide.awt.StatusDisplayer;
import org.openide.filesystems.FileUtil;
import org.openide.util.Cancellable;
import org.openide.util.Exceptions;
import org.openide.util.NbBundle;
import org.openide.util.RequestProcessor;
import org.openide.util.Utilities;
/**
* Support for the builtin Ruby on Rails web server: WEBrick, Mongrel, Lighttpd
*
* This is really primitive at this point; I should talk to the people who
* write Java web server plugins and take some pointers. Perhaps it can
* even implement some of their APIs such that logging, runtime nodes etc.
* all begin to work.
*
* @todo When launching under JRuby, also pass in -Djruby.thread.pooling=true to the VM
* @todo Rewrite the WEBrick error message which says to press Ctrl-C to cancel the process;
* tell the user to use the Stop button in the margin instead (somebody on nbusers asked about this)
* @todo Normalize & merge RubyServer and RubyInstance interfaces and their
* various implementations (V3, WEBrick, Mongrel).
*
* @author Tor Norbye, Pavel Buzek, Erno Mononen, Peter Williams
*/
public final class RailsServerManager {
enum ServerStatus { NOT_STARTED, STARTING, RUNNING; }
private static final Logger LOGGER = Logger.getLogger(RailsServerManager.class.getName());
/** Set of currently active - in use; ports. */
private static final Set<Integer> IN_USE_PORTS = new HashSet<Integer>();;
/**
* The timeout in milliseconds for waiting a server to start.
*/
private static final int SERVER_STARTUP_TIMEOUT = 120*1000;
private ServerStatus status = ServerStatus.NOT_STARTED;
private RubyServer server;
private RubyInstance instance;
/** True if server failed to start due to port conflict. */
private boolean portConflict;
/** User chosen port */
private int originalPort;
/** Actual port in use (trying other ports for ones not in use) */
private int port = -1;
private final RailsProject project;
private RailsVersion version;
private Future<Integer> execution;
private File dir;
private String projectName;
private boolean debug;
private boolean clientDebug;
private boolean switchToDebugMode;
private Semaphore debugSemaphore;
public RailsServerManager(RailsProject project) {
this.project = project;
dir = FileUtil.toFile(project.getProjectDirectory());
}
public synchronized void setDebug(boolean debug) {
if (status == ServerStatus.RUNNING && !this.debug && debug) {
switchToDebugMode = true;
}
this.debug = debug;
}
public void setClientDebug(boolean clientDebug) {
this.clientDebug = clientDebug;
}
private synchronized RailsVersion getRailsVersion() {
if (version == null) {
this.version = RailsProjectUtil.getRailsVersion(project);
}
return version;
}
/**
* @return true if server is ready and application can be run immediately,
* otherwise return false indicating server is becoming ready asynchonously.
*/
private boolean ensureRunning() {
synchronized (RailsServerManager.this) {
if(projectName == null) {
projectName = project.getLookup().lookup(ProjectInformation.class).getDisplayName();
}
if (status == ServerStatus.STARTING) {
return false;
} else if (status == ServerStatus.RUNNING) {
if (switchToDebugMode) {
if (!isPluginServer(instance)) {
assert debugSemaphore == null : "startSemaphor supposed to be null";
debugSemaphore = new Semaphore(0);
}
switchToDebugMode = false;
} else if (isPortInUse(port)) {
if (!debug && isPluginServer(instance)) {
if(port == instance.getRailsPort()) {
status = ServerStatus.STARTING;
glassfishEnsureRunning(null);
return false;
}
} else {
// Simply assume it is still the same server running
return true;
}
}
}
}
if (debugSemaphore != null) {
try {
if (execution != null) {
execution.cancel(true);
}
debugSemaphore.acquire();
debugSemaphore = null;
} catch (InterruptedException ex) {
Exceptions.printStackTrace(ex);
}
}
// Start the server
synchronized (RailsServerManager.this) {
status = ServerStatus.STARTING;
}
projectName = project.getLookup().lookup(ProjectInformation.class).getDisplayName();
String classPath = project.evaluator().getProperty(RailsProjectProperties.JAVAC_CLASSPATH);
String jvmArgs = project.evaluator().getProperty(RailsProjectProperties.JVM_ARGS);
String serverId = project.evaluator().getProperty(RailsProjectProperties.RAILS_SERVERTYPE);
RubyPlatform platform = RubyPlatform.platformFor(project);
RubyInstance candidateInstance = ServerRegistry.getDefault().getServer(serverId, platform);
if (candidateInstance == null) {
// TODO: need to inform the user somehow
// fall back to the first available server
List<? extends RubyInstance> availableServers = ServerRegistry.getDefault().getServers();
for (RubyInstance each : availableServers) {
if (each.isPlatformSupported(platform)) {
candidateInstance = each;
break;
}
}
assert candidateInstance != null : "No servers found for " + platform;
}
instance = candidateInstance;
if (isPluginServer(instance)) {
if(!debug) {
glassfishEnsureRunning(platform);
return false;
} else {
// TODO -- This needs an API in 6.9...
// swap in the gem as the instance for v3 fcs
if (serverId.contains("]deployer:gfv3ee6:")) { // NOI18N
final String newInstanceID = "GLASSFISH"; // NOI18N
instance = ServerRegistry.getDefault().getServer(newInstanceID, platform);
String gemName = "glassfish"; // NOI18N
Gem gem = new Gem(gemName, null, null); // NOI18N
Gem[] gems = new Gem[]{gem};
GemManager gemManager = platform.getGemManager();
if (!gemManager.isGemInstalled(gemName)) {
// open a dialog to tell the user what is about to happen.
DialogDisplayer.getDefault().notify(
new NotifyDescriptor.Message(NbBundle.getMessage(RailsServerManager.class, "MSG_DOWNLOAD_GEM_FOR_DEBUG", gemName)));
final RubyPlatform myplatform = platform;
//Runnable asyncCompletionTask = new InstallationComplete();
platform.getGemManager().install(gems, null, false, false, null, true, true, new Runnable() {
public void run() {
myplatform.recomputeRoots();
instance = ServerRegistry.getDefault().getServer(newInstanceID, myplatform);
}
});
//platform.recomputeRoots();
} else {
// open a dialog to tell them about the gem for debug
DialogDisplayer.getDefault().notify(
new NotifyDescriptor.Message(NbBundle.getMessage(RailsServerManager.class, "MSG_USE_GEM_FOR_DEBUG", gemName)));
}
} else {
// stick with the old strategy that was used from Prelude
ensurePortAvailable();
String displayName = NbBundle.getMessage(RailsServerManager.class,
"LBL_ServerTab", instance.getDisplayName(), projectName, Integer.toString(port)); // NOI18N
RubyExecutionDescriptor desc = new RubyExecutionDescriptor(platform, displayName, dir, "unknown"); // NOI18N
desc.cmd(getJavaExecutable());
desc.useInterpreter(false);
desc.initialArgs(instance.getServerCommand(platform, classPath, dir, port, debug));
desc.postBuild(getFinishAction());
desc.jvmArguments(jvmArgs);
desc.addStandardRecognizers();
desc.frontWindow(false);
desc.debug(debug);
desc.fastDebugRequired(debug);
desc.fileLocator(new DirectoryFileLocator(FileUtil.toFileObject(dir)));
desc.showSuspended(true);
// TODO - can we support code coverage for custom descriptors?
runServer(desc, displayName, new GrizzlyServerLineConvertor(instance));
return false;
}
}
}
// check whether the user has modified script/server to use another server
RubyInstance explicitlySpecified = ServerResolver.getExplicitlySpecifiedServer(project);
if (explicitlySpecified instanceof RubyServer) {
server = (RubyServer) explicitlySpecified;
instance = explicitlySpecified;
} else {
server = (RubyServer) instance;
}
ensurePortAvailable();
String displayName = getServerTabName(server, projectName, port);
String serverPath = server.getServerPath(getRailsVersion());
RubyExecutionDescriptor desc = new RubyExecutionDescriptor(platform, displayName, dir, serverPath);
// can place debug flags here to allow attaching NB debugger to jruby process
// running server that is started in debug-commons.
// if(debug && "true".equals(System.getProperty("rdebug.enable.debug"))) {
// desc.initialArgs("-J-Xdebug -J-Xrunjdwp:transport=dt_socket,address=3105,server=y,suspend=y");
// }
// Paths required for GlassFish gem. Not used or required for WEBrick or Mongrel.
String gemPath = server.getLocation();
if(gemPath != null) {
// always use forward slashes in the load path, even on Win
desc.initialArgs("-I \"" + gemPath + "/" + "bin\" " +
"-I \"" + gemPath + "/" + "lib\"");
}
desc.scriptPrefix(server.getScriptPrefix());
desc.additionalArgs(buildStartupArgs());
desc.postBuild(getFinishAction());
desc.jvmArguments(jvmArgs);
desc.classPath(classPath);
desc.addStandardRecognizers();
desc.frontWindow(false);
desc.debug(debug);
desc.fastDebugRequired(debug);
desc.fileLocator(new DirectoryFileLocator(FileUtil.toFileObject(dir)));
//desc.showProgress(false); // http://ruby.netbeans.org/issues/show_bug.cgi?id=109261
desc.showSuspended(true);
RubyCoverageProvider coverageProvider = RubyCoverageProvider.get(project);
if (coverageProvider != null && coverageProvider.isEnabled()) {
desc = coverageProvider.wrapWithCoverage(desc, false, null);
}
runServer(desc, displayName, new RailsServerLineConverter(server));
return false;
}
private void runServer(RubyExecutionDescriptor desc, String displayName, LineConvertor... convertors) {
IN_USE_PORTS.add(port);
String charsetName = project.evaluator().getProperty(RailsProjectProperties.SOURCE_ENCODING);
for (LineConvertor each : convertors) {
desc.addOutConvertor(each);
}
for (LineConvertor each : convertors) {
desc.addErrConvertor(each);
}
RubyProcessCreator rpc = new RubyProcessCreator(desc,
charsetName);
ExecutionService executionService = ExecutionService.newService(rpc, desc.toExecutionDescriptor(), displayName);
this.execution = executionService.run();
}
private String[] buildStartupArgs() {
List<String> result = new ArrayList<String>();
result.addAll(server.getStartupParams(getRailsVersion()));
String railsEnv = project.evaluator().getProperty(RailsProjectProperties.RAILS_ENV);
if (railsEnv != null && !"".equals(railsEnv.trim())) {
result.add("-e");
result.add(railsEnv);
}
if(server instanceof GlassFishGem) {
GlassFishGem gfGem = (GlassFishGem) server;
// Gem bug: --log cannot be last (will be followed by --port here)
if(gfGem.compareVersion("0.9.5") >= 0) {
// log supported on 0.9.5 and above (broken in 0.9.4)
result.add("--log");
}
if(gfGem.compareVersion("0.9.0") >=0) {
// port option supported on 0.9.0 and above
result.add("--port");
result.add(Integer.toString(port));
}
// if(gfGem.compareVersion("0.9.3") >=0) {
// // log level option supported on 0.9.3 and above
// result.add("--log-level");
// result.add("3");
// }
result.add(dir.getAbsolutePath());
} else {
result.add("--port");
result.add(Integer.toString(port));
}
String extraArgs = project.evaluator().getProperty(RailsProjectProperties.RAILS_SERVER_ARGS);
if (extraArgs != null) {
for (String arg : Utilities.parseParameters(extraArgs)) {
result.add(arg);
}
}
return result.toArray(new String[result.size()]);
}
private void ensurePortAvailable() {
portConflict = false;
String portString = project.evaluator().getProperty(RailsProjectProperties.RAILS_PORT);
LOGGER.fine("Port number in project properties:" + portString);
port = 0;
if (portString != null) {
port = Integer.parseInt(portString);
}
if (port == 0) {
port = 3000;
}
originalPort = port;
while(isPortInUse(port)) {
port++;
}
}
private Runnable getFinishAction() {
return new Runnable() {
public void run() {
synchronized (RailsServerManager.this) {
status = ServerStatus.NOT_STARTED;
if (server != null) {
server.removeApplication(port);
}
IN_USE_PORTS.remove(port);
if (portConflict) {
// Failed to start due to port conflict - notify user.
notifyPortConflict();
}
if (debugSemaphore != null) {
debugSemaphore.release();
} else {
debug = false;
}
}
}
};
}
private File getJavaExecutable() {
String javaPath = System.getProperty("java.home") + File.separatorChar +
"bin" + File.separatorChar + (Utilities.isWindows() ? "java.exe" : "java");
File javaExe = new File(javaPath);
if(!javaExe.exists()) {
LOGGER.log(Level.SEVERE, "Unable to locate java executable: " + javaPath);
}
return javaExe;
}
/**
* Hack to determine if the selected server instance is a managed server
* provided a by a plugin (e.g. GlassFish V3) or one of the Ruby Servers
* loaded from the gem repository.
*
* This should be removed when we merge/normalize RubyServer and RubyInstance
* interfaces.
*/
private static boolean isPluginServer(RubyInstance instance) {
return instance != null && !(instance instanceof RubyServer);
}
private static String getServerTabName(RubyServer server, String projectName, int port) {
return NbBundle.getMessage(RailsServerManager.class,
"LBL_ServerTab" , server.getDisplayName(), projectName, String.valueOf(port));
}
private void notifyPortConflict() {
String message = NbBundle.getMessage(RailsServerManager.class, "Conflict", Integer.toString(originalPort));
NotifyDescriptor nd =
new NotifyDescriptor.Message(message,
NotifyDescriptor.Message.ERROR_MESSAGE);
DialogDisplayer.getDefault().notify(nd);
}
private String getContextRoot() {
if(!debug && instance != null) {
return instance.getContextRoot(projectName);
}
return "";
}
/**
* Starts the server if not running and shows url.
* @param relativeUrl the resulting url will be for example: http://localhost:{port}/{relativeUrl}
*/
public void showUrl(final String relativeUrl) {
if (ensureRunning()) {
RailsUrlDisplayer.showURL(getContextRoot(), relativeUrl, port, clientDebug, project);
} else {
String displayName = NbBundle.getMessage(RailsServerManager.class, "ServerStartup");
final ProgressHandle handle =
ProgressHandleFactory.createHandle(displayName,new Cancellable() {
public boolean cancel() {
return true;
}
},
new AbstractAction() {
public void actionPerformed(ActionEvent e) {
// XXX ?
}
});
handle.start();
handle.switchToIndeterminate();
final boolean runClientDebug = clientDebug;
RequestProcessor.getDefault().post(new Runnable() {
public void run() {
try {
// Try connecting repeatedly, up to time specified
// by SERVER_STARTUP_TIMEOUT, then bail
int i = 0;
int delay = 20;
while(i <= SERVER_STARTUP_TIMEOUT) {
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
// Don't worry about it
}
synchronized (RailsServerManager.this) {
if (status == ServerStatus.RUNNING) {
if(LOGGER.isLoggable(Level.FINE)) {
LOGGER.fine("Server " + ((server != null) ? server : instance) +
" started in " + (i+500)/1000 + " seconds.");
}
RailsUrlDisplayer.showURL(getContextRoot(), relativeUrl, port, runClientDebug, project);
return;
}
if (status == ServerStatus.NOT_STARTED) {
// Server startup somehow failed...
if(LOGGER.isLoggable(Level.FINE)) {
LOGGER.fine("Server startup failed, server type is: " +
((server != null) ? server : instance));
}
break;
}
}
i += delay;
if(delay < 500) {
delay *= 2;
}
}
LOGGER.fine("Could not start " + ((server != null) ? server : instance) +
" in " + (i+500)/1000 + " seconds, current server status is " + status);
StatusDisplayer.getDefault().setStatusText(NbBundle.getMessage(RailsServerManager.class,
"NoServerFound", "http://localhost:" + port + "/" + relativeUrl));
} finally {
handle.finish();
}
}
});
}
}
/** Return true if there is an HTTP response from the port on localhost.
* Based on tomcatint\tomcat5\src\org.netbeans.modules.tomcat5.util.Utils.java.
*/
private static boolean useHttpValidation = Boolean.parseBoolean(
System.getProperty("rails.server.http.validation"));
private static boolean checkIsPortInUseUsingServerSocket(int port) {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(port);
return false;
} catch (IOException ex) {
LOGGER.log(Level.FINE, "Port " + port + " is in use.", ex);
return true;
} finally {
if (serverSocket != null && !serverSocket.isClosed()) {
try {
serverSocket.close();
} catch (IOException ex) {
LOGGER.log(Level.FINE, "Exception while closing ServerSocked in port " + port, ex);
}
}
}
}
public static boolean isPortInUse(int port) {
LOGGER.fine("Checking port: " + port + ". Ports in use: " + IN_USE_PORTS);
if (IN_USE_PORTS.contains(port)) {
return true;
}
LOGGER.fine("Connecting to " + port + ", using http validation: " + useHttpValidation);
if (!useHttpValidation) {
return checkIsPortInUseUsingServerSocket(port);
}
int timeout = 3000;
Socket socket = new Socket();
try {
try {
socket.connect(new InetSocketAddress("localhost", port), timeout); // NOI18N
socket.setSoTimeout(timeout);
OutputStream out = socket.getOutputStream();
try {
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
try {
// request -- mongrel requires \r\n instead of just \n
out.write("GET / HTTP/1.0\r\n\r\n".getBytes("UTF8")); // NOI18N
out.flush();
// response
String text = in.readLine();
LOGGER.fine("Got response " + text);
if (text != null && text.startsWith("HTTP")) { // NOI18N
return true; // http response.
}
return false;
} finally {
in.close();
}
} finally {
out.close();
}
} finally {
socket.close();
}
} catch (IOException ioe) {
LOGGER.log(Level.FINE, "Exception while connecting to " + port, ioe);
return false;
}
}
private void glassfishEnsureRunning(final RubyPlatform platform) {
final Future<RubyInstance.OperationState> result = platform != null ?
instance.runApplication(platform, projectName, dir) : instance.deploy(projectName, dir);
final RubyInstance serverInstance = instance;
RequestProcessor.getDefault().post(new Runnable() {
public void run() {
try {
RubyInstance.OperationState state = result.get(120, TimeUnit.SECONDS);
if(state == RubyInstance.OperationState.COMPLETED) {
synchronized(RailsServerManager.this) {
port = serverInstance.getRailsPort();
status = ServerStatus.RUNNING;
}
} else {
synchronized(RailsServerManager.this) {
status = ServerStatus.NOT_STARTED;
}
}
} catch (Exception ex) {
LOGGER.log(Level.INFO, ex.getMessage(), ex);
// Ensure status value is reset on exceptions too...
synchronized(RailsServerManager.this) {
status = ServerStatus.NOT_STARTED;
}
}
}
});
}
/**
* @param outputLine the output line to check.
* @return true if the given <code>outputLine</code> represented 'address in use'
* message.
*/
static boolean isAddressInUseMsg(String outputLine){
return outputLine.matches(".*in.*: Address.+in use.+(Errno::EADDRINUSE).*"); //NOI18N
}
private class RailsServerLineConverter implements LineConvertor {
private final RubyServer server;
RailsServerLineConverter(RubyServer server) {
this.server = server;
}
public synchronized List<ConvertedLine> convert(String line) {
if (LOGGER.isLoggable(Level.FINER)) {
LOGGER.log(Level.FINER, "Processing output line: " + line);
}
// This is ugly, but my attempts to use URLConnection on the URL repeatedly
// and check for connection.getResponseCode()==HttpURLConnection.HTTP_OK didn't
// work - try that again later
if (server.isStartupMsg(line)) {
synchronized (RailsServerManager.this) {
LOGGER.fine("Identified " + server + " as running");
status = ServerStatus.RUNNING;
String projectName = project.getLookup().lookup(ProjectInformation.class).getDisplayName();
server.addApplication(new RailsApplication(projectName, port, execution));
}
} else if (isAddressInUseMsg(line)) {
LOGGER.fine("Detected port conflict: " + line);
portConflict = true;
}
return null;
}
}
private class GrizzlyServerLineConvertor implements LineConvertor {
private RubyInstance server;
GrizzlyServerLineConvertor(RubyInstance server) {
this.server = server;
}
public synchronized List<ConvertedLine> convert(String line) {
if (LOGGER.isLoggable(Level.FINER)) {
LOGGER.log(Level.FINER, "Processing output line: " + line);
}
if (isStartupMsg(line)) {
synchronized (RailsServerManager.this) {
LOGGER.fine("Identified " + server + " as running");
status = ServerStatus.RUNNING;
}
} else if (isAddressInUseMsg(line)) {
LOGGER.fine("Detected port conflict: " + line);
portConflict = true;
}
return null;
}
private boolean isStartupMsg(String line) {
return line.contains("Grizzly configuration for port");
}
private boolean isAddressInUseMsg(String line) {
return line.contains("BindException");
}
}
}