diff --git a/.gitignore b/.gitignore index ec4be308fd..6e2c5fcf63 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ bin/.scalafmt* .zinc/ .nailgun/ +# Directory in which to install locally bloop binaries to test them +.devbloop/ + # zinc uses this local cache for publishing stuff .ivy2/ diff --git a/backend/src/main/scala/bloop/Compiler.scala b/backend/src/main/scala/bloop/Compiler.scala index 1347fe53d4..940a15a5d3 100644 --- a/backend/src/main/scala/bloop/Compiler.scala +++ b/backend/src/main/scala/bloop/Compiler.scala @@ -10,6 +10,7 @@ import bloop.logging.Logger import sbt.internal.inc.{FreshCompilerCache, Locate, LoggedReporter, ZincUtil} case class CompileInputs( + scalaInstance: ScalaInstance, compilerCache: CompilerCache, sourceDirectories: Array[AbsolutePath], diff --git a/backend/src/main/scala/bloop/logging/BloopLogger.scala b/backend/src/main/scala/bloop/logging/BloopLogger.scala index a61196c8db..e0859741a7 100644 --- a/backend/src/main/scala/bloop/logging/BloopLogger.scala +++ b/backend/src/main/scala/bloop/logging/BloopLogger.scala @@ -1,11 +1,10 @@ package bloop.logging -import java.io.{FileOutputStream, OutputStream, PrintStream} +import java.io.PrintStream import java.util.concurrent.atomic.AtomicInteger import scala.Console.{CYAN, GREEN, RED, RESET, YELLOW} - -import bloop.io.AbsolutePath +import com.martiansoftware.nailgun.NGCommunicator /** * Creates a logger that writes to the given streams. diff --git a/frontend/src/main/scala/bloop/Bloop.scala b/frontend/src/main/scala/bloop/Bloop.scala index c213c47c6e..7229709e85 100644 --- a/frontend/src/main/scala/bloop/Bloop.scala +++ b/frontend/src/main/scala/bloop/Bloop.scala @@ -1,15 +1,14 @@ package bloop import bloop.cli.{CliOptions, Commands, ExitStatus} -import bloop.cli.CliParsers.{inputStreamRead, printStreamRead, OptionsParser, pathParser} -import bloop.engine.{Build, Exit, Interpreter, Run, State} +import bloop.cli.CliParsers.{OptionsParser, inputStreamRead, pathParser, printStreamRead} +import bloop.engine.{Build, Exit, Interpreter, NoPool, Run, State} import bloop.engine.tasks.Tasks import bloop.io.AbsolutePath import bloop.io.Timer.timed import bloop.logging.BloopLogger import jline.console.ConsoleReader import caseapp.{CaseApp, RemainingArgs} - import jline.console.ConsoleReader import scala.annotation.tailrec @@ -27,7 +26,7 @@ object Bloop extends CaseApp[CliOptions] { logger.verboseIf(options.verbose) { val projects = Project.fromDir(configDirectory, logger) val build = Build(configDirectory, projects) - val state = State(build, options.common, logger) + val state = State(build, NoPool, options.common, logger) run(state) } } diff --git a/frontend/src/main/scala/bloop/Cli.scala b/frontend/src/main/scala/bloop/Cli.scala index 912e15467f..fd8140b238 100644 --- a/frontend/src/main/scala/bloop/Cli.scala +++ b/frontend/src/main/scala/bloop/Cli.scala @@ -3,26 +3,28 @@ package bloop import bloop.bsp.BspServer import bloop.cli.validation.Validate import bloop.cli.{CliOptions, CliParsers, Commands, CommonOptions, ExitStatus} -import bloop.engine.{Action, Exit, Interpreter, Print, Run, State} -import bloop.io.Paths -import bloop.logging.{BloopLogger, Logger} +import bloop.engine.{Action, ClientPool, Exit, Interpreter, NailgunPool, NoPool, Print, Run, State} +import bloop.logging.BloopLogger import caseapp.core.{DefaultBaseCommand, Messages} -import com.martiansoftware.nailgun +import com.martiansoftware.nailgun.NGContext class Cli object Cli { def main(args: Array[String]): Unit = { State.setUpShutdownHoook() val action = parse(args, CommonOptions.default) - val exitStatus = run(action) + val exitStatus = run(action, NoPool) sys.exit(exitStatus.code) } - def nailMain(ngContext: nailgun.NGContext): Unit = { + def nailMain(ngContext: NGContext): Unit = { + val server = ngContext.getNGServer val nailgunOptions = CommonOptions( in = ngContext.in, out = ngContext.out, err = ngContext.err, + ngout = server.out, + ngerr = server.err, workingDirectory = ngContext.getWorkingDirectory, ) val command = ngContext.getCommand @@ -36,7 +38,8 @@ object Cli { printErrorAndExit(helpAsked, nailgunOptions) else parse(args, nailgunOptions) } - val exitStatus = run(cmd) + + val exitStatus = run(cmd, NailgunPool(ngContext)) ngContext.exit(exitStatus.code) } @@ -188,7 +191,7 @@ object Cli { } } - def run(action: Action): ExitStatus = { + def run(action: Action, pool: ClientPool): ExitStatus = { import bloop.io.AbsolutePath def getConfigDir(cliOptions: CliOptions): AbsolutePath = { cliOptions.configDir @@ -205,7 +208,7 @@ object Cli { val commonOpts = cliOptions.common val configDirectory = getConfigDir(cliOptions) val logger = BloopLogger.at(configDirectory.syntax, commonOpts.out, commonOpts.err) - val state = State.loadActiveStateFor(configDirectory, cliOptions.common, logger) + val state = State.loadActiveStateFor(configDirectory, pool, cliOptions.common, logger) val newState = Interpreter.execute(action, state) State.stateCache.updateBuild(newState.copy(status = ExitStatus.Ok)) newState.status diff --git a/frontend/src/main/scala/bloop/Server.scala b/frontend/src/main/scala/bloop/Server.scala index f4ff65619b..58d9fa2a00 100644 --- a/frontend/src/main/scala/bloop/Server.scala +++ b/frontend/src/main/scala/bloop/Server.scala @@ -10,11 +10,15 @@ class Server object Server { private val defaultPort: Int = 8212 // 8100 + 'p' def main(args: Array[String]): Unit = { + run(instantiateServer(args)) + } + + private[bloop] def instantiateServer(args: Array[String]): NGServer = { val port = Try(args(0).toInt).getOrElse(Server.defaultPort) val addr = InetAddress.getLoopbackAddress val server = new NGServer(addr, port) registerAliases(server) - run(server) + server } def nailMain(ngContext: NGContext): Unit = { diff --git a/frontend/src/main/scala/bloop/bsp/BloopBspServices.scala b/frontend/src/main/scala/bloop/bsp/BloopBspServices.scala index aa0632f0ea..5fef3b42bc 100644 --- a/frontend/src/main/scala/bloop/bsp/BloopBspServices.scala +++ b/frontend/src/main/scala/bloop/bsp/BloopBspServices.scala @@ -71,7 +71,8 @@ class BloopBspServices(callSiteState: State, client: JsonRpcClient, bspLogger: L private final val defaultOpts = callSiteState.commonOptions def reloadState(uri: String): State = { - val state0 = State.loadActiveStateFor(AbsolutePath(uri), defaultOpts, bspForwarderLogger) + val pool = callSiteState.pool + val state0 = State.loadActiveStateFor(AbsolutePath(uri), pool, defaultOpts, bspForwarderLogger) state0.copy(logger = bspForwarderLogger, commonOptions = latestState.commonOptions) } diff --git a/frontend/src/main/scala/bloop/cli/CommonOptions.scala b/frontend/src/main/scala/bloop/cli/CommonOptions.scala index d4a830ed99..02dc31b72b 100644 --- a/frontend/src/main/scala/bloop/cli/CommonOptions.scala +++ b/frontend/src/main/scala/bloop/cli/CommonOptions.scala @@ -20,6 +20,8 @@ case class CommonOptions( @Hidden out: PrintStream = System.out, @Hidden in: InputStream = System.in, @Hidden err: PrintStream = System.err, + @Hidden ngout: PrintStream = System.out, + @Hidden ngerr: PrintStream = System.err, threads: Int = ExecutionContext.nCPUs ) { def workingPath: AbsolutePath = AbsolutePath(workingDirectory) diff --git a/frontend/src/main/scala/bloop/engine/ClientPool.scala b/frontend/src/main/scala/bloop/engine/ClientPool.scala new file mode 100644 index 0000000000..dbd17cdb3d --- /dev/null +++ b/frontend/src/main/scala/bloop/engine/ClientPool.scala @@ -0,0 +1,38 @@ +package bloop.engine + +import com.martiansoftware.nailgun.{NGClientDisconnectReason, NGClientListener, NGContext} + +sealed trait CloseEvent +case object Heartbeat extends CloseEvent +case object SocketError extends CloseEvent +case object SocketTimeout extends CloseEvent +case object SessionShutdown extends CloseEvent +case object InternalError extends CloseEvent + +sealed trait ClientPool { + def addListener(f: CloseEvent => Unit): Unit + def removeAllListeners(): Unit +} + +case object NoPool extends ClientPool { + override def addListener(f: CloseEvent => Unit): Unit = () + override def removeAllListeners(): Unit = () +} + +case class NailgunPool(context: NGContext) extends ClientPool { + override def addListener(f: CloseEvent => Unit): Unit = { + context.addClientListener(new NGClientListener { + override def clientDisconnected(reason: NGClientDisconnectReason): Unit = { + f(reason match { + case NGClientDisconnectReason.HEARTBEAT => Heartbeat + case NGClientDisconnectReason.SOCKET_ERROR => SocketError + case NGClientDisconnectReason.SOCKET_TIMEOUT => SocketTimeout + case NGClientDisconnectReason.SESSION_SHUTDOWN => SessionShutdown + case NGClientDisconnectReason.INTERNAL_ERROR => InternalError + }) + } + }) + } + + override def removeAllListeners(): Unit = context.removeAllClientListeners() +} diff --git a/frontend/src/main/scala/bloop/engine/ExecutionContext.scala b/frontend/src/main/scala/bloop/engine/ExecutionContext.scala index 411a18b23d..059b7581ba 100644 --- a/frontend/src/main/scala/bloop/engine/ExecutionContext.scala +++ b/frontend/src/main/scala/bloop/engine/ExecutionContext.scala @@ -17,4 +17,5 @@ object ExecutionContext { } implicit val scheduler: Scheduler = Scheduler(executor) + implicit val ioScheduler: Scheduler = Scheduler.io("bloop-io") } diff --git a/frontend/src/main/scala/bloop/engine/Interpreter.scala b/frontend/src/main/scala/bloop/engine/Interpreter.scala index a271ca19e7..8ee7898801 100644 --- a/frontend/src/main/scala/bloop/engine/Interpreter.scala +++ b/frontend/src/main/scala/bloop/engine/Interpreter.scala @@ -19,8 +19,10 @@ import scala.concurrent.duration.Duration object Interpreter { @tailrec def execute(action: Action, state: State): State = { - def logAndTime(cliOptions: CliOptions, action: Task[State]): State = { - state.logger.verboseIf(cliOptions.verbose)(timed(state.logger)(waitAndLog(state, action))) + def logAndTime(cliOptions: CliOptions, task: Task[State]): State = { + state.logger.verboseIf(cliOptions.verbose)( + timed(state.logger)(waitAndLog(action, cliOptions, state, task)) + ) } action match { @@ -86,15 +88,12 @@ object Interpreter { BspServer.run(cmd, state, scheduler) } - private def watch(project: Project, state: State, f: State => Task[State]): Task[State] = Task { - // TODO(jvican): The implementation here could be improved, do so. + private def watch(project: Project, state: State, f: State => Task[State]): Task[State] = { val reachable = Dag.dfs(state.build.getDagFor(project)) val allSourceDirs = reachable.iterator.flatMap(_.sourceDirectories.toList).map(_.underlying) - val watcher = new SourceWatcher(allSourceDirs.toList, state.logger) - // Make file watching cancel tasks if lots of different changes happen in less than 100ms - val watchFn: State => State = { state => waitAndLog(state, f(state)) } - val firstOp = watchFn(state) - watcher.watch(firstOp, watchFn) + val watcher = new SourceWatcher(project, allSourceDirs.toList, state.logger) + // Force the first execution before relying on the file watching task + f(state).flatMap(newState => watcher.watch(newState, f)) } private def compile(cmd: Commands.Compile, state: State): Task[State] = { @@ -252,10 +251,32 @@ object Interpreter { state.mergeStatus(ExitStatus.InvalidCommandLineOption) } - private def waitAndLog(previousState: State, newState: Task[State]): State = { + private def waitAndLog( + action: Action, + cliOptions: CliOptions, + previousState: State, + newState: Task[State] + ): State = { + val pool = previousState.pool + val ngout = cliOptions.common.ngout try { - // Duration has to be infinity, we cannot predict how much time compilation takes - Await.result(newState.runAsync(previousState.scheduler), Duration.Inf) + val handle = newState + .executeWithOptions(_.enableAutoCancelableRunLoops) + .runAsync(previousState.scheduler) + + // Let's cancel tasks (if supported by the underlying implementation) when clients disconnect + pool.addListener { + case e: CloseEvent => + if (!handle.isCompleted) { + ngout.println( + s"Client in ${previousState.build.origin.syntax} has disconnected with a '$e' event. Cancelling tasks...") + handle.cancel() + } + } + + val result = Await.result(handle, Duration.Inf) + ngout.println(s"The task for $action finished.") + result } catch { case NonFatal(t) => previousState.logger.error(t.getMessage) diff --git a/frontend/src/main/scala/bloop/engine/State.scala b/frontend/src/main/scala/bloop/engine/State.scala index b5a854d53d..08f2182dfe 100644 --- a/frontend/src/main/scala/bloop/engine/State.scala +++ b/frontend/src/main/scala/bloop/engine/State.scala @@ -26,6 +26,7 @@ final case class State private ( build: Build, results: ResultsCache, compilerCache: CompilerCache, + pool: ClientPool, commonOptions: CommonOptions, status: ExitStatus, logger: Logger @@ -51,20 +52,19 @@ object State { } private[bloop] def forTests(build: Build, compilerCache: CompilerCache, logger: Logger): State = { - val initializedResults = build.projects.foldLeft(ResultsCache.getEmpty(logger)) { + val results0 = build.projects.foldLeft(ResultsCache.getEmpty(logger)) { case (results, project) => results.initializeResult(project) } - State(build, initializedResults, compilerCache, CommonOptions.default, ExitStatus.Ok, logger) + State(build, results0, compilerCache, NoPool, CommonOptions.default, ExitStatus.Ok, logger) } - // Improve the caching by using file metadata - def apply(build: Build, options: CommonOptions, logger: Logger): State = { + def apply(build: Build, pool: ClientPool, opts: CommonOptions, logger: Logger): State = { val results = build.projects.foldLeft(ResultsCache.getEmpty(logger)) { case (results, project) => results.initializeResult(project) } val compilerCache = getCompilerCache(logger) - State(build, results, compilerCache, options, ExitStatus.Ok, logger) + State(build, results, compilerCache, pool, opts, ExitStatus.Ok, logger) } def setUpShutdownHoook(): Unit = { @@ -100,13 +100,28 @@ object State { import bloop.Project import bloop.io.AbsolutePath - def loadActiveStateFor(configDir: AbsolutePath, opts: CommonOptions, logger: Logger): State = { - State.stateCache - .addIfMissing(configDir, path => { - val projects = Project.fromDir(configDir, logger) - val build: Build = Build(configDir, projects) - State(build, opts, logger) - }) - .copy(logger = logger) + + /** + * Loads an state active for the given configuration directory. + * + * @param configDir The configuration directory to load a state for. + * @param pool The pool of listeners that are connected to this client. + * @param opts The common options associated with the state. + * @param logger The logger to be used to instantiate the state. + * @return An state (cached or not) associated with the configuration directory. + */ + def loadActiveStateFor( + configDir: AbsolutePath, + pool: ClientPool, + opts: CommonOptions, + logger: Logger + ): State = { + val cached = State.stateCache.addIfMissing(configDir, path => { + val projects = Project.fromDir(configDir, logger) + val build: Build = Build(configDir, projects) + State(build, pool, opts, logger) + }) + + cached.copy(pool = pool, commonOptions = opts, logger = logger) } } diff --git a/frontend/src/main/scala/bloop/io/SourceWatcher.scala b/frontend/src/main/scala/bloop/io/SourceWatcher.scala index 1260019b73..f39bb2072a 100644 --- a/frontend/src/main/scala/bloop/io/SourceWatcher.scala +++ b/frontend/src/main/scala/bloop/io/SourceWatcher.scala @@ -2,30 +2,46 @@ package bloop.io import java.nio.file.Path -import bloop.cli.ExitStatus -import bloop.engine.State +import bloop.Project +import bloop.engine.{ExecutionContext, State} import bloop.logging.Logger import scala.collection.JavaConverters._ import io.methvin.watcher.DirectoryChangeEvent.EventType import io.methvin.watcher.{DirectoryChangeEvent, DirectoryChangeListener, DirectoryWatcher} +import monix.eval.Task +import monix.reactive.{Consumer, MulticastStrategy, Observable} -final class SourceWatcher(dirs0: Seq[Path], logger: Logger) { +final class SourceWatcher(project: Project, dirs0: Seq[Path], logger: Logger) { private val dirs = dirs0.distinct + private val dirsCount = dirs.size private val dirsAsJava: java.util.List[Path] = dirs.asJava // Create source directories if they don't exist, otherwise the watcher fails. import java.nio.file.Files dirs.foreach(p => if (!Files.exists(p)) Files.createDirectories(p) else ()) - def watch(initialState: State, action: State => State): State = { - logger.debug(s"Watching the following directories: ${dirs.mkString(", ")}") - var result: State = initialState - def runAction(event: DirectoryChangeEvent): Unit = { + def watch(state0: State, action: State => Task[State]): Task[State] = { + val ngout = state0.commonOptions.ngout + def runAction(state: State, event: DirectoryChangeEvent): Task[State] = { logger.debug(s"A ${event.eventType()} in ${event.path()} has triggered an event.") - result = action(result) + action(state) } + val fileEventConsumer = Consumer.foldLeftAsync[State, DirectoryChangeEvent](state0) { + case (state, event) => + event.eventType match { + case EventType.CREATE => runAction(state, event) + case EventType.MODIFY => runAction(state, event) + case EventType.OVERFLOW => runAction(state, event) + case EventType.DELETE => Task.now(state) + } + } + + val (observer, observable) = + Observable.multicast[DirectoryChangeEvent](MulticastStrategy.publish)( + ExecutionContext.ioScheduler) + val watcher = DirectoryWatcher.create( dirsAsJava, new DirectoryChangeListener { @@ -34,21 +50,29 @@ final class SourceWatcher(dirs0: Seq[Path], logger: Logger) { val targetPath = targetFile.toFile.getAbsolutePath() if (Files.isRegularFile(targetFile) && (targetPath.endsWith(".scala") || targetPath.endsWith(".java"))) { - event.eventType() match { - case EventType.CREATE => runAction(event) - case EventType.MODIFY => runAction(event) - case EventType.OVERFLOW => runAction(event) - case EventType.DELETE => () // We don't do anything when a file is deleted - } + observer.onNext(event) + () } } } ) - try { watcher.watch(); result } catch { - case t: Throwable => - logger.error("Unexpected error happened when file watching.") - logger.trace(t) - result.mergeStatus(ExitStatus.UnexpectedError) - } + + val watchingTask = Task { + logger.info(s"File watching $dirsCount directories...") + try watcher.watch() + finally watcher.close() + }.doOnCancel(Task { + observer.onComplete() + watcher.close() + ngout.println( + s"File watching on '${project.name}' and dependent projects has been successfully cancelled.") + }) + + val watchHandle = watchingTask.materialize.runAsync(ExecutionContext.ioScheduler) + + observable + .consumeWith(fileEventConsumer) + .doOnFinish(_ => Task(watchHandle.cancel())) + .doOnCancel(Task(watchHandle.cancel())) } } diff --git a/frontend/src/test/scala/bloop/logging/RecordingLogger.scala b/frontend/src/test/scala/bloop/logging/RecordingLogger.scala index f473825db3..9bcda19425 100644 --- a/frontend/src/test/scala/bloop/logging/RecordingLogger.scala +++ b/frontend/src/test/scala/bloop/logging/RecordingLogger.scala @@ -15,7 +15,9 @@ class RecordingLogger extends AbstractLogger { override def verbose[T](op: => T): T = op override def debug(msg: String): Unit = { messages.add(("debug", msg)); () } override def info(msg: String): Unit = { messages.add(("info", msg)); () } + def serverInfo(msg: String): Unit = { messages.add(("server-info", msg)); () } override def error(msg: String): Unit = { messages.add(("error", msg)); () } + def serverError(msg: String): Unit = { messages.add(("server-error", msg)); () } override def warn(msg: String): Unit = { messages.add(("warn", msg)); () } private def trace(msg: String): Unit = { messages.add(("trace", msg)); () } override def trace(ex: Throwable): Unit = { diff --git a/frontend/src/test/scala/bloop/nailgun/BasicNailgunSpec.scala b/frontend/src/test/scala/bloop/nailgun/BasicNailgunSpec.scala index ccf7f39088..404ba92dab 100644 --- a/frontend/src/test/scala/bloop/nailgun/BasicNailgunSpec.scala +++ b/frontend/src/test/scala/bloop/nailgun/BasicNailgunSpec.scala @@ -1,14 +1,17 @@ package bloop.nailgun -import scala.Console.{GREEN, RESET} +import java.util.concurrent.TimeUnit -import org.junit.{Ignore, Test} +import scala.Console.{GREEN, RESET} +import org.junit.Test import org.junit.Assert.{assertEquals, assertTrue} - import bloop.logging.RecordingLogger class BasicNailgunSpec extends NailgunTest { + val out = System.out + val err = System.err + @Test def unknownCommandTest(): Unit = { withServerInProject("with-resources") { (logger, client) => @@ -80,6 +83,35 @@ class BasicNailgunSpec extends NailgunTest { } } + @Test + def testWatchCompileCommand(): Unit = { + var rlogger: RecordingLogger = null + withServerInProject("with-resources") { (logger, client) => + client.success("clean", "with-resources") + val fileWatchingProcess = client.issueAsProcess("compile", "-w", "with-resources") + fileWatchingProcess.waitFor(4, TimeUnit.SECONDS) + fileWatchingProcess.destroyForcibly() + + // Repeat the whole process again. + client.success("clean", "with-resources") + val fileWatchingProcess2 = client.issueAsProcess("compile", "-w", "with-resources") + fileWatchingProcess2.waitFor(4, TimeUnit.SECONDS) + fileWatchingProcess2.destroyForcibly() + + // Ugly, but necessary for the sake of testing. + rlogger = logger + } + + // We check here the logs because until 'exit' the server doesn't flush everything + val serverInfos = rlogger.getMessages().filter(_._1 == "server-info").map(_._2) + val timesCancelling = serverInfos.count(_.contains("Cancelling tasks...")) + val timesCancelled = serverInfos.count( + _ == "File watching on 'with-resources' and dependent projects has been successfully cancelled.") + + assertTrue(s"File watching cancellation happened $timesCancelled only!", timesCancelled == 2) + assertTrue(s"Bloop registered task cancellation only $timesCancelling", timesCancelling == 2) + } + @Test def testRunCommand(): Unit = { withServerInProject("with-resources") { (logger, client) => diff --git a/frontend/src/test/scala/bloop/nailgun/NailgunTest.scala b/frontend/src/test/scala/bloop/nailgun/NailgunTest.scala index f775a448a3..2961c1674a 100644 --- a/frontend/src/test/scala/bloop/nailgun/NailgunTest.scala +++ b/frontend/src/test/scala/bloop/nailgun/NailgunTest.scala @@ -1,12 +1,14 @@ package bloop.nailgun -import org.junit.Assert.{assertEquals, assertNotEquals} +import java.io.{File, PrintStream} +import org.junit.Assert.{assertEquals, assertNotEquals} import java.nio.file.{Files, Path, Paths} import bloop.Server import bloop.logging.{ProcessLogger, RecordingLogger} import bloop.tasks.ProjectHelpers +import com.martiansoftware.nailgun.NGServer /** * Base class for writing test for the nailgun integration. @@ -25,16 +27,28 @@ abstract class NailgunTest { * @return The result of executing `op` on the client. */ def withServer[T](log: RecordingLogger, base: Path)(op: Client => T): T = { + val oldOut = System.out + val oldErr = System.err + val outStream = new PrintStream(ProcessLogger.toOutputStream(log.serverInfo)) + val errStream = new PrintStream(ProcessLogger.toOutputStream(log.serverError)) + val serverThread = new Thread { override def run(): Unit = { - val outStream = ProcessLogger.toOutputStream(log.info) - val errStream = ProcessLogger.toOutputStream(log.error) - Console.withOut(outStream) { - Console.withErr(errStream) { - Server.main(Array(TEST_PORT.toString)) - } + var optServer: Option[NGServer] = None + + // Trick nailgun into thinking these are the real streams + System.setOut(outStream) + System.setErr(errStream) + try { + optServer = Some(Server.instantiateServer(Array(TEST_PORT.toString))) + } finally { + System.setOut(oldOut) + System.setErr(oldErr) } + + val server = optServer.getOrElse(sys.error("The nailgun server failed to initialize!")) + server.run() } } @@ -43,7 +57,11 @@ abstract class NailgunTest { Thread.sleep(500) val client = new Client(TEST_PORT, log, base) try op(client) - finally client.success("exit") + finally { + client.success("exit") + outStream.flush() + errStream.flush() + } } /** @@ -90,17 +108,28 @@ abstract class NailgunTest { } /** - * Executes a command `cmd` on the server, and return the exit code. + * Execute a command `cmd` on the server and return the exit code. * * @param cmd The command to execute * @return The exit code of the operation. */ def issue(cmd: String*): Int = { + issueAsProcess(cmd: _*).waitFor() + } + + /** + * Execute a command `cmd` on the server and return the process + * executing the specified command. + * + * @param cmd The command to execute + * @return The exit code of the operation. + */ + def issueAsProcess(cmd: String*): Process = { val builder = processBuilder(cmd) val process = builder.start() val processLogger = new ProcessLogger(log, process) processLogger.start() - process.waitFor() + process } /**