diff --git a/build.sbt b/build.sbt index 648d72b52..ffd4ae2f7 100644 --- a/build.sbt +++ b/build.sbt @@ -43,7 +43,7 @@ def commonSettings: Seq[Setting[_]] = Seq[SettingsDefinition]( testOptions += Tests.Argument(TestFrameworks.ScalaCheck, "-w", "1"), javacOptions in compile ++= Seq("-target", "6", "-source", "6", "-Xlint", "-Xlint:-serial"), incOptions := incOptions.value.withNameHashing(true), - crossScalaVersions := Seq(scala211, scala210), + crossScalaVersions := Seq(scala211), bintrayPackage := (bintrayPackage in ThisBuild).value, bintrayRepository := (bintrayRepository in ThisBuild).value, mimaDefaultSettings, @@ -176,11 +176,13 @@ lazy val actionsProj = (project in file("main-actions")). // General command support and core commands not specific to a build system lazy val commandProj = (project in file("main-command")). + enablePlugins(DatatypePlugin, JsonCodecPlugin). settings( testedBaseSettings, name := "Command", libraryDependencies ++= Seq(launcherInterface, compilerInterface, - sbtIO, utilLogging, utilCompletion, compilerClasspath) + sbtIO, utilLogging, utilCompletion, compilerClasspath, sjsonNewScalaJson), + sourceManaged in (Compile, generateDatatypes) := baseDirectory.value / "src" / "main" / "datatype-scala" ) // Fixes scope=Scope for Setting (core defined in collectionProj) to define the settings system used in build definitions diff --git a/main-command/src/main/datatype-scala/sbt/internal/server/CommandMessage.scala b/main-command/src/main/datatype-scala/sbt/internal/server/CommandMessage.scala new file mode 100644 index 000000000..889f8fe7a --- /dev/null +++ b/main-command/src/main/datatype-scala/sbt/internal/server/CommandMessage.scala @@ -0,0 +1,39 @@ +/** + * This code is generated using sbt-datatype. + */ + +// DO NOT EDIT MANUALLY +package sbt.internal.server +final class CommandMessage( + val `type`: String, + val commandLine: Option[String]) extends Serializable { + + def this(`type`: String) = this(`type`, None) + + override def equals(o: Any): Boolean = o match { + case x: CommandMessage => (this.`type` == x.`type`) && (this.commandLine == x.commandLine) + case _ => false + } + override def hashCode: Int = { + 37 * (37 * (17 + `type`.##) + commandLine.##) + } + override def toString: String = { + "CommandMessage(" + `type` + ", " + commandLine + ")" + } + def copy(`type`: String): CommandMessage = { + new CommandMessage(`type`, commandLine) + } + def copy(`type`: String = `type`, commandLine: Option[String] = commandLine): CommandMessage = { + new CommandMessage(`type`, commandLine) + } + def withType(`type`: String): CommandMessage = { + copy(`type` = `type`) + } + def withCommandLine(commandLine: Option[String]): CommandMessage = { + copy(commandLine = commandLine) + } +} +object CommandMessage { + def apply(`type`: String): CommandMessage = new CommandMessage(`type`, None) + def apply(`type`: String, commandLine: Option[String]): CommandMessage = new CommandMessage(`type`, commandLine) +} diff --git a/main-command/src/main/datatype-scala/sbt/internal/server/EventMessage.scala b/main-command/src/main/datatype-scala/sbt/internal/server/EventMessage.scala new file mode 100644 index 000000000..55946b0e7 --- /dev/null +++ b/main-command/src/main/datatype-scala/sbt/internal/server/EventMessage.scala @@ -0,0 +1,59 @@ +/** + * This code is generated using sbt-datatype. + */ + +// DO NOT EDIT MANUALLY +package sbt.internal.server +final class EventMessage( + val `type`: String, + val status: Option[String], + val commandQueue: Vector[String], + val level: Option[String], + val message: Option[String], + val success: Option[Boolean], + val commandLine: Option[String]) extends Serializable { + + def this(`type`: String) = this(`type`, None, Vector(), None, None, None, None) + + override def equals(o: Any): Boolean = o match { + case x: EventMessage => (this.`type` == x.`type`) && (this.status == x.status) && (this.commandQueue == x.commandQueue) && (this.level == x.level) && (this.message == x.message) && (this.success == x.success) && (this.commandLine == x.commandLine) + case _ => false + } + override def hashCode: Int = { + 37 * (37 * (37 * (37 * (37 * (37 * (37 * (17 + `type`.##) + status.##) + commandQueue.##) + level.##) + message.##) + success.##) + commandLine.##) + } + override def toString: String = { + "EventMessage(" + `type` + ", " + status + ", " + commandQueue + ", " + level + ", " + message + ", " + success + ", " + commandLine + ")" + } + def copy(`type`: String): EventMessage = { + new EventMessage(`type`, status, commandQueue, level, message, success, commandLine) + } + def copy(`type`: String = `type`, status: Option[String] = status, commandQueue: Vector[String] = commandQueue, level: Option[String] = level, message: Option[String] = message, success: Option[Boolean] = success, commandLine: Option[String] = commandLine): EventMessage = { + new EventMessage(`type`, status, commandQueue, level, message, success, commandLine) + } + def withType(`type`: String): EventMessage = { + copy(`type` = `type`) + } + def withStatus(status: Option[String]): EventMessage = { + copy(status = status) + } + def withCommandQueue(commandQueue: Vector[String]): EventMessage = { + copy(commandQueue = commandQueue) + } + def withLevel(level: Option[String]): EventMessage = { + copy(level = level) + } + def withMessage(message: Option[String]): EventMessage = { + copy(message = message) + } + def withSuccess(success: Option[Boolean]): EventMessage = { + copy(success = success) + } + def withCommandLine(commandLine: Option[String]): EventMessage = { + copy(commandLine = commandLine) + } +} +object EventMessage { + def apply(`type`: String): EventMessage = new EventMessage(`type`, None, Vector(), None, None, None, None) + def apply(`type`: String, status: Option[String], commandQueue: Vector[String], level: Option[String], message: Option[String], success: Option[Boolean], commandLine: Option[String]): EventMessage = new EventMessage(`type`, status, commandQueue, level, message, success, commandLine) +} diff --git a/main-command/src/main/datatype-scala/sbt/internal/server/codec/CommandMessageFormats.scala b/main-command/src/main/datatype-scala/sbt/internal/server/codec/CommandMessageFormats.scala new file mode 100644 index 000000000..17130c90e --- /dev/null +++ b/main-command/src/main/datatype-scala/sbt/internal/server/codec/CommandMessageFormats.scala @@ -0,0 +1,29 @@ +/** + * This code is generated using sbt-datatype. + */ + +// DO NOT EDIT MANUALLY +package sbt.internal.server.codec +import _root_.sjsonnew.{ deserializationError, serializationError, Builder, JsonFormat, Unbuilder } +trait CommandMessageFormats { self: sjsonnew.BasicJsonProtocol => +implicit lazy val CommandMessageFormat: JsonFormat[sbt.internal.server.CommandMessage] = new JsonFormat[sbt.internal.server.CommandMessage] { + override def read[J](jsOpt: Option[J], unbuilder: Unbuilder[J]): sbt.internal.server.CommandMessage = { + jsOpt match { + case Some(js) => + unbuilder.beginObject(js) + val `type` = unbuilder.readField[String]("type") + val commandLine = unbuilder.readField[Option[String]]("commandLine") + unbuilder.endObject() + new sbt.internal.server.CommandMessage(`type`, commandLine) + case None => + deserializationError("Expected JsObject but found None") + } + } + override def write[J](obj: sbt.internal.server.CommandMessage, builder: Builder[J]): Unit = { + builder.beginObject() + builder.addField("type", obj.`type`) + builder.addField("commandLine", obj.commandLine) + builder.endObject() + } +} +} diff --git a/main-command/src/main/datatype-scala/sbt/internal/server/codec/EventMessageFormats.scala b/main-command/src/main/datatype-scala/sbt/internal/server/codec/EventMessageFormats.scala new file mode 100644 index 000000000..4ce95d9ef --- /dev/null +++ b/main-command/src/main/datatype-scala/sbt/internal/server/codec/EventMessageFormats.scala @@ -0,0 +1,39 @@ +/** + * This code is generated using sbt-datatype. + */ + +// DO NOT EDIT MANUALLY +package sbt.internal.server.codec +import _root_.sjsonnew.{ deserializationError, serializationError, Builder, JsonFormat, Unbuilder } +trait EventMessageFormats { self: sjsonnew.BasicJsonProtocol => +implicit lazy val EventMessageFormat: JsonFormat[sbt.internal.server.EventMessage] = new JsonFormat[sbt.internal.server.EventMessage] { + override def read[J](jsOpt: Option[J], unbuilder: Unbuilder[J]): sbt.internal.server.EventMessage = { + jsOpt match { + case Some(js) => + unbuilder.beginObject(js) + val `type` = unbuilder.readField[String]("type") + val status = unbuilder.readField[Option[String]]("status") + val commandQueue = unbuilder.readField[Vector[String]]("commandQueue") + val level = unbuilder.readField[Option[String]]("level") + val message = unbuilder.readField[Option[String]]("message") + val success = unbuilder.readField[Option[Boolean]]("success") + val commandLine = unbuilder.readField[Option[String]]("commandLine") + unbuilder.endObject() + new sbt.internal.server.EventMessage(`type`, status, commandQueue, level, message, success, commandLine) + case None => + deserializationError("Expected JsObject but found None") + } + } + override def write[J](obj: sbt.internal.server.EventMessage, builder: Builder[J]): Unit = { + builder.beginObject() + builder.addField("type", obj.`type`) + builder.addField("status", obj.status) + builder.addField("commandQueue", obj.commandQueue) + builder.addField("level", obj.level) + builder.addField("message", obj.message) + builder.addField("success", obj.success) + builder.addField("commandLine", obj.commandLine) + builder.endObject() + } +} +} diff --git a/main-command/src/main/datatype/server.json b/main-command/src/main/datatype/server.json new file mode 100644 index 000000000..3dcc4af17 --- /dev/null +++ b/main-command/src/main/datatype/server.json @@ -0,0 +1,73 @@ +{ + "codecNamespace": "sbt.internal.server.codec", + "types": [ + { + "name": "CommandMessage", + "namespace": "sbt.internal.server", + "type": "record", + "target": "Scala", + "fields": [ + { + "name": "type", + "type": "String", + "since": "0.0.0" + }, + { + "name": "commandLine", + "type": "String?", + "default": "None", + "since": "0.1.0" + } + ] + }, + { + "name": "EventMessage", + "namespace": "sbt.internal.server", + "type": "record", + "target": "Scala", + "fields": [ + { + "name": "type", + "type": "String", + "since": "0.0.0" + }, + { + "name": "status", + "type": "String?", + "default": "None", + "since": "0.1.0" + }, + { + "name": "commandQueue", + "type": "String*", + "default": "Vector()", + "since": "0.1.0" + }, + { + "name": "level", + "type": "String?", + "default": "None", + "since": "0.1.0" + }, + { + "name": "message", + "type": "String?", + "default": "None", + "since": "0.1.0" + }, + { + "name": "success", + "type": "boolean?", + "default": "None", + "since": "0.1.0" + }, + { + "name": "commandLine", + "type": "String?", + "default": "None", + "since": "0.1.0" + } + ] + } + ] +} diff --git a/main-command/src/main/scala/sbt/BasicCommandStrings.scala b/main-command/src/main/scala/sbt/BasicCommandStrings.scala index 032b51eda..bfd424a65 100644 --- a/main-command/src/main/scala/sbt/BasicCommandStrings.scala +++ b/main-command/src/main/scala/sbt/BasicCommandStrings.scala @@ -149,6 +149,9 @@ object BasicCommandStrings { def Shell = "shell" def ShellDetailed = "Provides an interactive prompt from which commands can be run." + def Server = "server" + def ServerDetailed = "Provides a network server and an interactive prompt from which commands can be run." + def StashOnFailure = "sbtStashOnFailure" def PopOnFailure = "sbtPopOnFailure" diff --git a/main-command/src/main/scala/sbt/BasicCommands.scala b/main-command/src/main/scala/sbt/BasicCommands.scala index 68503fda4..12e0ef49b 100644 --- a/main-command/src/main/scala/sbt/BasicCommands.scala +++ b/main-command/src/main/scala/sbt/BasicCommands.scala @@ -6,6 +6,7 @@ import sbt.internal.util.complete.{ Completion, Completions, DefaultParsers, His import sbt.internal.util.Types.{ const, idFun } import sbt.internal.inc.classpath.ClasspathUtilities.toLoader import sbt.internal.inc.ModuleUtilities +import sbt.internal.{ Exec, CommandSource, CommandStatus } import DefaultParsers._ import Function.tupled import Command.applyEffect @@ -15,11 +16,12 @@ import BasicKeys._ import java.io.File import sbt.io.IO +import java.util.concurrent.atomic.AtomicBoolean import scala.util.control.NonFatal object BasicCommands { - lazy val allBasicCommands = Seq(nop, ignore, help, completionsCommand, multi, ifLast, append, setOnFailure, clearOnFailure, stashOnFailure, popOnFailure, reboot, call, early, exit, continuous, history, shell, read, alias) ++ compatCommands + lazy val allBasicCommands = Seq(nop, ignore, help, completionsCommand, multi, ifLast, append, setOnFailure, clearOnFailure, stashOnFailure, popOnFailure, reboot, call, early, exit, continuous, history, shell, server, read, alias) ++ compatCommands def nop = Command.custom(s => success(() => s)) def ignore = Command.command(FailureWall)(idFun) @@ -191,6 +193,17 @@ object BasicCommands { } } + def server = Command.command(Server, Help.more(Server, ServerDetailed)) { s0 => + val exchange = State.exchange + val s1 = exchange.run(s0) + exchange.publishStatus(CommandStatus(s0, true), None) + val Exec(source, line) = exchange.blockUntilNextExec + val newState = s1.copy(onFailure = Some(Server), remainingCommands = line +: Server +: s1.remainingCommands).setInteractive(true) + exchange.publishStatus(CommandStatus(newState, false), Some(source)) + if (line.trim.isEmpty) newState + else newState.clearGlobalLog + } + def read = Command.make(ReadCommand, Help.more(ReadCommand, ReadDetailed))(s => applyEffect(readParser(s))(doRead(s))) def readParser(s: State) = { diff --git a/main-command/src/main/scala/sbt/BasicKeys.scala b/main-command/src/main/scala/sbt/BasicKeys.scala index ca77d1d73..d42a1d381 100644 --- a/main-command/src/main/scala/sbt/BasicKeys.scala +++ b/main-command/src/main/scala/sbt/BasicKeys.scala @@ -8,6 +8,7 @@ object BasicKeys { val historyPath = AttributeKey[Option[File]]("history", "The location where command line history is persisted.", 40) val shellPrompt = AttributeKey[State => String]("shell-prompt", "The function that constructs the command prompt from the current build state.", 10000) val watch = AttributeKey[Watched]("watch", "Continuous execution configuration.", 1000) + val serverPort = AttributeKey[Int]("server-port", "The port number used by server command.", 10000) private[sbt] val interactive = AttributeKey[Boolean]("interactive", "True if commands are currently being entered from an interactive environment.", 10) private[sbt] val classLoaderCache = AttributeKey[ClassLoaderCache]("class-loader-cache", "Caches class loaders based on the classpath entries and last modified times.", 10) private[sbt] val OnFailureStack = AttributeKey[List[Option[String]]]("on-failure-stack", "Stack that remembers on-failure handlers.", 10) diff --git a/main-command/src/main/scala/sbt/State.scala b/main-command/src/main/scala/sbt/State.scala index f4b4041cf..61edef3e6 100644 --- a/main-command/src/main/scala/sbt/State.scala +++ b/main-command/src/main/scala/sbt/State.scala @@ -8,6 +8,7 @@ import java.util.concurrent.Callable import sbt.util.Logger import sbt.internal.util.{ AttributeKey, AttributeMap, ErrorHandling, ExitHook, ExitHooks, GlobalLogging } import sbt.internal.util.complete.HistoryCommands +import sbt.internal.CommandExchange import sbt.internal.inc.classpath.ClassLoaderCache /** @@ -178,6 +179,8 @@ object State { new Reboot(app.scalaProvider.version, state.remainingCommands, app.id, state.configuration.baseDirectory) } + private[sbt] lazy val exchange = new CommandExchange() + /** Provides operations and transformations on State. */ implicit def stateOps(s: State): StateOps = new StateOps { def process(f: (String, State) => State): State = diff --git a/main-command/src/main/scala/sbt/internal/CommandChannel.scala b/main-command/src/main/scala/sbt/internal/CommandChannel.scala new file mode 100644 index 000000000..f01d9ffbd --- /dev/null +++ b/main-command/src/main/scala/sbt/internal/CommandChannel.scala @@ -0,0 +1,36 @@ +package sbt +package internal + +import java.util.concurrent.ConcurrentLinkedQueue + +/** + * A command channel represents an IO device such as network socket or human + * that can issue command or listen for some outputs. + * We can think of a command channel to be an abstration of the terminal window. + */ +abstract class CommandChannel { + private val commandQueue: ConcurrentLinkedQueue[Exec] = new ConcurrentLinkedQueue() + def append(exec: Exec): Boolean = + commandQueue.add(exec) + def poll: Option[Exec] = Option(commandQueue.poll) + + /** start listening for a command exec. */ + def run(s: State): State + def publishStatus(status: CommandStatus, lastSource: Option[CommandSource]): Unit + def shutdown(): Unit +} + +case class Exec(source: CommandSource, commandLine: String) + +sealed trait CommandSource +object CommandSource { + case object Human extends CommandSource + case object Network extends CommandSource +} + +/** + * This is a data that is passed on to the channels. + * The canEnter paramter indicates that the console devise or UI + * should stop listening. + */ +case class CommandStatus(state: State, canEnter: Boolean) diff --git a/main-command/src/main/scala/sbt/internal/CommandExchange.scala b/main-command/src/main/scala/sbt/internal/CommandExchange.scala new file mode 100644 index 000000000..6bce31b0f --- /dev/null +++ b/main-command/src/main/scala/sbt/internal/CommandExchange.scala @@ -0,0 +1,52 @@ +package sbt +package internal + +import scala.annotation.tailrec +import scala.collection.mutable.ListBuffer +import java.util.concurrent.ConcurrentLinkedQueue + +/** + * The command exchange merges multiple command channels (e.g. network and console), + * and acts as the central multiplexing point. + * Instead of blocking on JLine.readLine, the server commmand will block on + * this exchange, which could serve command request from either of the channel. + */ +private[sbt] final class CommandExchange { + private val commandQueue: ConcurrentLinkedQueue[Exec] = new ConcurrentLinkedQueue() + private val channelBuffer: ListBuffer[CommandChannel] = new ListBuffer() + def channels: List[CommandChannel] = channelBuffer.toList + def subscribe(c: CommandChannel): Unit = + channelBuffer.append(c) + + subscribe(new ConsoleChannel()) + subscribe(new NetworkChannel()) + + // periodically move all messages from all the channels + @tailrec def blockUntilNextExec: Exec = + { + @tailrec def slurpMessages(): Unit = + (((None: Option[Exec]) /: channels) { _ orElse _.poll }) match { + case Some(x) => + commandQueue.add(x) + slurpMessages + case _ => () + } + slurpMessages() + Option(commandQueue.poll) match { + case Some(x) => x + case _ => + Thread.sleep(50) + blockUntilNextExec + } + } + + // fanout run to all channels + def run(s: State): State = + (s /: channels) { (acc, c) => c.run(acc) } + + // fanout publishStatus to all channels + def publishStatus(status: CommandStatus, lastSource: Option[CommandSource]): Unit = + channels foreach { c => + c.publishStatus(status, lastSource) + } +} diff --git a/main-command/src/main/scala/sbt/internal/ConsoleChannel.scala b/main-command/src/main/scala/sbt/internal/ConsoleChannel.scala new file mode 100644 index 000000000..5b0c99497 --- /dev/null +++ b/main-command/src/main/scala/sbt/internal/ConsoleChannel.scala @@ -0,0 +1,59 @@ +package sbt +package internal + +import sbt.internal.util._ +import BasicKeys._ +import java.io.File + +private[sbt] final class ConsoleChannel extends CommandChannel { + private var askUserThread: Option[Thread] = None + def makeAskUserThread(status: CommandStatus): Thread = new Thread("ask-user-thread") { + val s = status.state + val history = (s get historyPath) getOrElse Some(new File(s.baseDir, ".history")) + val prompt = (s get shellPrompt) match { + case Some(pf) => pf(s) + case None => "> " + } + val reader = new FullReader(history, s.combinedParser, JLine.HandleCONT, true) + override def run(): Unit = { + // This internally handles thread interruption and returns Some("") + val line = reader.readLine(prompt) + line match { + case Some(cmd) => append(Exec(CommandSource.Human, cmd)) + case None => append(Exec(CommandSource.Human, "exit")) + } + askUserThread = None + } + } + + def run(s: State): State = s + + def publishStatus(status: CommandStatus, lastSource: Option[CommandSource]): Unit = + if (status.canEnter) { + askUserThread match { + case Some(x) => // + case _ => + val x = makeAskUserThread(status) + askUserThread = Some(x) + x.start + } + } else { + lastSource match { + case Some(src) if src != CommandSource.Human => + askUserThread match { + case Some(x) => + shutdown() + case _ => + } + case _ => + } + } + + def shutdown(): Unit = + askUserThread match { + case Some(x) if x.isAlive => + x.interrupt + askUserThread = None + case _ => () + } +} diff --git a/main-command/src/main/scala/sbt/internal/NetworkChannel.scala b/main-command/src/main/scala/sbt/internal/NetworkChannel.scala new file mode 100644 index 000000000..597f6e602 --- /dev/null +++ b/main-command/src/main/scala/sbt/internal/NetworkChannel.scala @@ -0,0 +1,44 @@ +package sbt +package internal + +import sbt.internal.server._ +import BasicKeys._ + +private[sbt] final class NetworkChannel extends CommandChannel { + private var server: Option[ServerInstance] = None + + def run(s: State): State = + { + val port = (s get serverPort) match { + case Some(x) => x + case None => 5001 + } + def onCommand(command: internal.server.Command): Unit = { + command match { + case Execution(cmd) => append(Exec(CommandSource.Network, cmd)) + } + } + server match { + case Some(x) => // do nothing + case _ => + server = Some(Server.start("127.0.0.1", port, onCommand, s.log)) + } + s + } + + def shutdown(): Unit = + { + // interrupt and kill the thread + server.foreach(_.shutdown()) + server = None + } + + def publishStatus(cmdStatus: CommandStatus, lastSource: Option[CommandSource]): Unit = { + server.foreach(server => + server.publish( + if (cmdStatus.canEnter) StatusEvent(Ready) + else StatusEvent(Processing("TODO current command", cmdStatus.state.remainingCommands)) + ) + ) + } +} diff --git a/main-command/src/main/scala/sbt/internal/server/ClientConnection.scala b/main-command/src/main/scala/sbt/internal/server/ClientConnection.scala new file mode 100644 index 000000000..26b8188fb --- /dev/null +++ b/main-command/src/main/scala/sbt/internal/server/ClientConnection.scala @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2016 Lightbend Inc. + */ +package sbt +package internal +package server + +import java.net.{ SocketTimeoutException, Socket } +import java.util.concurrent.atomic.AtomicBoolean + +abstract class ClientConnection(connection: Socket) { + + private val running = new AtomicBoolean(true) + private val delimiter: Byte = '\n'.toByte + + private val out = connection.getOutputStream + + val thread = new Thread(s"sbt-client-${connection.getPort}") { + override def run(): Unit = { + try { + val readBuffer = new Array[Byte](4096) + val in = connection.getInputStream + connection.setSoTimeout(5000) + var buffer: Vector[Byte] = Vector.empty + var bytesRead = 0 + while (bytesRead != -1 && running.get) { + try { + bytesRead = in.read(readBuffer) + val bytes = readBuffer.toVector.take(bytesRead) + buffer = buffer ++ bytes + + // handle un-framing + val delimPos = bytes.indexOf(delimiter) + if (delimPos > 0) { + val chunk = buffer.take(delimPos) + buffer = buffer.drop(delimPos + 1) + + Serialization.deserialize(chunk).fold( + errorDesc => println("Got invalid chunk from client: " + errorDesc), + onCommand + ) + } + + } catch { + case _: SocketTimeoutException => // its ok + } + } + + } finally { + shutdown() + } + } + } + thread.start() + + def publish(event: Array[Byte]): Unit = { + out.write(event) + out.write(delimiter) + out.flush() + } + + def onCommand(command: Command): Unit + + def shutdown(): Unit = { + println("Shutting down client connection") + running.set(false) + out.close() + } + +} diff --git a/main-command/src/main/scala/sbt/internal/server/Serialization.scala b/main-command/src/main/scala/sbt/internal/server/Serialization.scala new file mode 100644 index 000000000..99142383f --- /dev/null +++ b/main-command/src/main/scala/sbt/internal/server/Serialization.scala @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2016 Lightbend Inc. + */ +package sbt +package internal +package server + +import sjsonnew.{ JsonFormat, BasicJsonProtocol } +import sjsonnew.support.scalajson.unsafe.{ Converter, CompactPrinter } +import scala.json.ast.unsafe.JValue +import sjsonnew.support.scalajson.unsafe.Parser +import java.nio.ByteBuffer +import scala.util.{ Success, Failure } + +object Serialization { + + def serialize(event: Event): Array[Byte] = + { + import ServerCodec._ + val msg = toMessage(event) + val json: JValue = Converter.toJson[EventMessage](msg).get + CompactPrinter(json).getBytes("UTF-8") + } + def toMessage(event: Event): EventMessage = + event match { + case LogEvent(level, message) => + EventMessage(`type` = "logEvent", + status = None, commandQueue = Vector(), + level = Some(level), message = Some(message), success = None, commandLine = None) + case StatusEvent(Ready) => + EventMessage(`type` = "statusEvent", + status = Some("ready"), commandQueue = Vector(), + level = None, message = None, success = None, commandLine = None) + case StatusEvent(Processing(command, commandQueue)) => + EventMessage(`type` = "statusEvent", + status = Some("processing"), commandQueue = commandQueue.toVector, + level = None, message = None, success = None, commandLine = None) + case ExecutionEvent(command, status) => + EventMessage(`type` = "executionEvent", + status = None, commandQueue = Vector(), + level = None, message = None, success = Some(status), commandLine = Some(command)) + } + + /** + * @return A command or an invalid input description + */ + def deserialize(bytes: Seq[Byte]): Either[String, Command] = + { + val buffer = ByteBuffer.wrap(bytes.toArray) + Parser.parseFromByteBuffer(buffer) match { + case Success(json) => + import ServerCodec._ + Converter.fromJson[CommandMessage](json) match { + case Success(command) => + command.`type` match { + case "exec" => + command.commandLine match { + case Some(cmd) => Right(Execution(cmd)) + case None => Left("Missing or invalid command_line field") + } + case cmd => Left(s"Unknown command type $cmd") + } + case Failure(e) => Left(e.getMessage) + } + case Failure(e) => + Left(s"Parse error: ${e.getMessage}") + } + } +} + +object ServerCodec extends ServerCodec +trait ServerCodec extends codec.EventMessageFormats with codec.CommandMessageFormats with BasicJsonProtocol diff --git a/main-command/src/main/scala/sbt/internal/server/Server.scala b/main-command/src/main/scala/sbt/internal/server/Server.scala new file mode 100644 index 000000000..f102cb736 --- /dev/null +++ b/main-command/src/main/scala/sbt/internal/server/Server.scala @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2016 Lightbend Inc. + */ +package sbt +package internal +package server + +import java.net.{ SocketTimeoutException, InetAddress, ServerSocket, SocketException } +import java.util.concurrent.atomic.AtomicBoolean +import sbt.util.Logger +import scala.collection.mutable + +private[sbt] sealed trait ServerInstance { + def shutdown(): Unit + def publish(event: Event): Unit +} + +private[sbt] object Server { + def start(host: String, port: Int, onIncommingCommand: Command => Unit, log: Logger): ServerInstance = + new ServerInstance { + + val lock = new AnyRef {} + val clients: mutable.ListBuffer[ClientConnection] = mutable.ListBuffer.empty + val running = new AtomicBoolean(true) + + val serverThread = new Thread("sbt-socket-server") { + + override def run(): Unit = { + val serverSocket = new ServerSocket(port, 50, InetAddress.getByName(host)) + serverSocket.setSoTimeout(5000) + + log.info(s"sbt server started at $host:$port") + while (running.get()) { + try { + val socket = serverSocket.accept() + log.info(s"new client connected from: ${socket.getPort}") + + val connection = new ClientConnection(socket) { + override def onCommand(command: Command): Unit = { + onIncommingCommand(command) + } + } + + lock.synchronized { + clients += connection + } + + } catch { + case _: SocketTimeoutException => // its ok + } + + } + } + } + serverThread.start() + + /** Publish an event to all connected clients */ + def publish(event: Event): Unit = { + // TODO do not do this on the calling thread + val bytes = Serialization.serialize(event) + lock.synchronized { + val toDel: mutable.ListBuffer[ClientConnection] = mutable.ListBuffer.empty + clients.foreach { client => + try { + client.publish(bytes) + } catch { + case e: SocketException => + log.debug(e.getMessage) + toDel += client + } + } + clients --= toDel.toList + } + } + + override def shutdown(): Unit = { + log.info("shutting down server") + running.set(false) + } + } + +} diff --git a/main-command/src/main/scala/sbt/internal/server/protocol.scala b/main-command/src/main/scala/sbt/internal/server/protocol.scala new file mode 100644 index 000000000..7c16b0fc3 --- /dev/null +++ b/main-command/src/main/scala/sbt/internal/server/protocol.scala @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2016 Lightbend Inc. + */ +package sbt +package internal +package server + +/* + * These classes are the protocol for client-server interaction, + * commands can come from the client side, while events are emitted + * from sbt to inform the client of state changes etc. + */ +private[sbt] sealed trait Event + +private[sbt] final case class LogEvent(level: String, message: String) extends Event + +sealed trait Status +private[sbt] final case object Ready extends Status +private[sbt] final case class Processing(command: String, commandQueue: Seq[String]) extends Status + +private[sbt] final case class StatusEvent(status: Status) extends Event +private[sbt] final case class ExecutionEvent(command: String, success: Boolean) extends Event + +private[sbt] sealed trait Command + +private[sbt] final case class Execution(cmd: String) extends Command diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index ed54a98be..049bdbcfa 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -41,7 +41,7 @@ import sbt.util.InterfaceUtil.{ f1, o2m } import sbt.internal.util.Types._ import sbt.internal.io.WatchState -import sbt.io.{ AllPassFilter, FileFilter, GlobFilter, HiddenFileFilter, IO, NameFilter, NothingFilter, Path, PathFinder, SimpleFileFilter, DirectoryFilter } +import sbt.io.{ AllPassFilter, FileFilter, GlobFilter, HiddenFileFilter, IO, NameFilter, NothingFilter, Path, PathFinder, SimpleFileFilter, DirectoryFilter, Hash } import Path._ import sbt.io.syntax._ @@ -200,7 +200,8 @@ object Defaults extends BuildCommon { fork :== false, initialize :== {}, forcegc :== sys.props.get("sbt.task.forcegc").map(java.lang.Boolean.parseBoolean).getOrElse(GCUtil.defaultForceGarbageCollection), - minForcegcInterval :== GCUtil.defaultMinForcegcInterval + minForcegcInterval :== GCUtil.defaultMinForcegcInterval, + serverPort := 5000 + (Hash.toHex(Hash(appConfiguration.value.baseDirectory.toString)).## % 1000) )) def defaultTestTasks(key: Scoped): Seq[Setting[_]] = inTask(key)(Seq( tags := Seq(Tags.Test -> 1), diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index ee21d78b8..33ac5f3f2 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -114,6 +114,7 @@ object Keys { // Command keys val historyPath = SettingKey(BasicKeys.historyPath) val shellPrompt = SettingKey(BasicKeys.shellPrompt) + val serverPort = SettingKey(BasicKeys.serverPort) val analysis = AttributeKey[CompileAnalysis]("analysis", "Analysis of compilation, including dependencies and generated outputs.", DSetting) val watch = SettingKey(BasicKeys.watch) val pollInterval = SettingKey[Int]("poll-interval", "Interval between checks for modified sources by the continuous execution command.", BMinusSetting) diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index 279da153a..58c2ccc86 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -112,7 +112,7 @@ object BuiltinCommands { def DefaultCommands: Seq[Command] = Seq(ignore, help, completionsCommand, about, tasks, settingsCommand, loadProject, projects, project, reboot, read, history, set, sessionCommand, inspect, loadProjectImpl, loadFailed, Cross.crossBuild, Cross.switchVersion, Cross.crossRestoreSession, setOnFailure, clearOnFailure, stashOnFailure, popOnFailure, setLogLevel, plugin, plugins, - ifLast, multi, shell, continuous, eval, alias, append, last, lastGrep, export, boot, nop, call, exit, early, initialize, act) ++ + ifLast, multi, shell, BasicCommands.server, continuous, eval, alias, append, last, lastGrep, export, boot, nop, call, exit, early, initialize, act) ++ compatCommands def DefaultBootCommands: Seq[String] = LoadProject :: (IfLast + " " + Shell) :: Nil diff --git a/main/src/main/scala/sbt/Project.scala b/main/src/main/scala/sbt/Project.scala index dce3233e5..497d5cf70 100755 --- a/main/src/main/scala/sbt/Project.scala +++ b/main/src/main/scala/sbt/Project.scala @@ -7,8 +7,7 @@ import java.io.File import java.net.URI import java.util.Locale import Project._ -import Keys.{ stateBuildStructure, commands, configuration, historyPath, projectCommand, sessionSettings, - shellPrompt, watch } +import Keys.{ appConfiguration, stateBuildStructure, commands, configuration, historyPath, projectCommand, sessionSettings, shellPrompt, serverPort, thisProject, thisProjectRef, watch } import Scope.{ GlobalScope, ThisScope } import Def.{ Flattened, Initialize, ScopedKey, Setting } import sbt.internal.{ Load, BuildStructure, LoadedBuild, LoadedBuildUnit, SettingGraph, SettingCompletions, AddSettings, SessionSettings } @@ -417,9 +416,11 @@ object Project extends ProjectExtra { val history = get(historyPath) flatMap idFun val prompt = get(shellPrompt) val watched = get(watch) + val port: Option[Int] = get(serverPort) val commandDefs = allCommands.distinct.flatten[Command].map(_ tag (projectCommand, true)) val newDefinedCommands = commandDefs ++ BasicCommands.removeTagged(s.definedCommands, projectCommand) - val newAttrs = setCond(Watched.Configuration, watched, s.attributes).put(historyPath.key, history) + val newAttrs0 = setCond(Watched.Configuration, watched, s.attributes).put(historyPath.key, history) + val newAttrs = setCond(serverPort.key, port, newAttrs0) s.copy(attributes = setCond(shellPrompt.key, prompt, newAttrs), definedCommands = newDefinedCommands) } def setCond[T](key: AttributeKey[T], vopt: Option[T], attributes: AttributeMap): AttributeMap = diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 94a89910d..5c0e9e9ef 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -37,6 +37,7 @@ object Dependencies { lazy val compilerClasspath = "org.scala-sbt" %% "zinc-classpath" % zincVersion lazy val compilerApiInfo = "org.scala-sbt" %% "zinc-apiinfo" % zincVersion lazy val compilerIvyIntegration = "org.scala-sbt" %% "zinc-ivy-integration" % zincVersion + lazy val sjsonNewScalaJson = "com.eed3si9n" %% "sjson-new-scalajson" % "0.4.2" lazy val scalaCheck = "org.scalacheck" %% "scalacheck" % "1.11.4" lazy val specs2 = "org.specs2" %% "specs2" % "2.3.11" diff --git a/project/datatype.sbt b/project/datatype.sbt new file mode 100644 index 000000000..3fd610568 --- /dev/null +++ b/project/datatype.sbt @@ -0,0 +1 @@ +addSbtPlugin("org.scala-sbt" % "sbt-datatype" % "0.2.6") diff --git a/reset.sh b/reset.sh new file mode 100755 index 000000000..ec3465425 --- /dev/null +++ b/reset.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +rm -rf ~/.sbt/boot/scala-2.11.8/org.scala-sbt/sbt/1.0.0-SNAPSHOT/