From 9557107c97f8d5175d926d29d942173385453d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Andre=CC=81n?= Date: Tue, 1 Mar 2016 17:29:02 +0100 Subject: [PATCH] First sloppy stab at an embedded server in sbt --- build.sbt | 10 ++- .../scala/sbt/server/ClientConnection.scala | 66 ++++++++++++++++ .../main/scala/sbt/server/Serialization.scala | 54 +++++++++++++ .../src/main/scala/sbt/server/Server.scala | 78 +++++++++++++++++++ .../src/main/scala/sbt/server/protocol.scala | 14 ++++ main/src/main/scala/sbt/Main.scala | 3 + 6 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 main/server/src/main/scala/sbt/server/ClientConnection.scala create mode 100644 main/server/src/main/scala/sbt/server/Serialization.scala create mode 100644 main/server/src/main/scala/sbt/server/Server.scala create mode 100644 main/server/src/main/scala/sbt/server/protocol.scala diff --git a/build.sbt b/build.sbt index 648d72b52..a25c4adc0 100644 --- a/build.sbt +++ b/build.sbt @@ -193,9 +193,15 @@ lazy val mainSettingsProj = (project in file("main-settings")). utilLogging, sbtIO, utilCompletion, compilerClasspath, libraryManagement) ) +lazy val serverProj = (project in mainPath / "server"). + settings( + baseSettings, + libraryDependencies ++= Seq(json4s, json4sNative) // to transitively get json4s + ) + // The main integration project for sbt. It brings all of the Projsystems together, configures them, and provides for overriding conventions. lazy val mainProj = (project in file("main")). - dependsOn(actionsProj, mainSettingsProj, runProj, commandProj). + dependsOn(actionsProj, mainSettingsProj, runProj, commandProj, serverProj). settings( testedBaseSettings, name := "Main", @@ -244,7 +250,7 @@ lazy val myProvided = config("provided") intransitive def allProjects = Seq( testingProj, testAgentProj, taskProj, stdTaskProj, runProj, scriptedSbtProj, scriptedPluginProj, - actionsProj, commandProj, mainSettingsProj, mainProj, sbtProj, bundledLauncherProj) + actionsProj, commandProj, mainSettingsProj, serverProj, mainProj, sbtProj, bundledLauncherProj) def projectsWithMyProvided = allProjects.map(p => p.copy(configurations = (p.configurations.filter(_ != Provided)) :+ myProvided)) lazy val nonRoots = projectsWithMyProvided.map(p => LocalProject(p.id)) diff --git a/main/server/src/main/scala/sbt/server/ClientConnection.scala b/main/server/src/main/scala/sbt/server/ClientConnection.scala new file mode 100644 index 000000000..493c223a0 --- /dev/null +++ b/main/server/src/main/scala/sbt/server/ClientConnection.scala @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2016 Lightbend Inc. + */ +package sbt.server + +import java.net.{ SocketTimeoutException, Socket } +import java.util.concurrent.atomic.AtomicBoolean + +abstract class ClientConnection(connection: Socket) { + + // TODO handle client disconnect + private val running = new AtomicBoolean(true) + private val delimiter = '\0'.toByte + + private val out = connection.getOutputStream + + val thread = new Thread(s"sbt-client-${connection.getPort}") { + override def run(): Unit = { + 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) + + Serialization.deserialize(chunk).fold( + errorDesc => println("Got invalid chunk from client: " + errorDesc), + onCommand + ) + } + + } catch { + case _: SocketTimeoutException => // its ok + } + } + + 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/server/src/main/scala/sbt/server/Serialization.scala b/main/server/src/main/scala/sbt/server/Serialization.scala new file mode 100644 index 000000000..fbb98fbc2 --- /dev/null +++ b/main/server/src/main/scala/sbt/server/Serialization.scala @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2016 Lightbend Inc. + */ +package sbt.server + +import org.json4s.JsonAST.{ JArray, JString } +import org.json4s._ +import org.json4s.JsonDSL._ +import org.json4s.native.JsonMethods._ + +object Serialization { + + def serialize(event: Event): Array[Byte] = { + compact(render(toJson(event))).getBytes("UTF-8") + } + + def toJson(event: Event): JObject = event match { + case LogEvent() => + JObject( + "type" -> JString("log_event"), + "level" -> JString("INFO"), + "message" -> JString("todo") + ) + + case StatusEvent() => + JObject( + "type" -> JString("status_event"), + "status" -> JString("ready"), + "command_queue" -> JArray(List.empty) + ) + + case ExecutionEvent() => + JObject( + "type" -> JString("execution_event"), + "command" -> JString("project todo"), + "success" -> JArray(List.empty) + ) + } + + /** + * @return A command or an invalid input description + */ + def deserialize(bytes: Seq[Byte]): Either[String, Command] = { + val json = parse(new String(bytes.toArray, "UTF-8")) + + implicit val formats = DefaultFormats + + (json \ "type").extract[String] match { + case "command" => Right(Execution((json \ "command_line").extract[String])) + case cmd => Left(s"Unknown command type $cmd") + } + } + +} diff --git a/main/server/src/main/scala/sbt/server/Server.scala b/main/server/src/main/scala/sbt/server/Server.scala new file mode 100644 index 000000000..bb2610db2 --- /dev/null +++ b/main/server/src/main/scala/sbt/server/Server.scala @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2016 Lightbend Inc. + */ +package sbt.server + +import java.net.{ SocketTimeoutException, InetAddress, ServerSocket } +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean + +object Server { + + trait ServerInstance { + def shutdown(): Unit + } + + def start(host: String, port: Int): ServerInstance = + new ServerInstance { + + val commandQueue = new ConcurrentLinkedQueue[Command]() + + val lock = new AnyRef {} + var clients = Vector[ClientConnection]() + val running = new AtomicBoolean(true) + + val serverSocket = new ServerSocket(port, 50, InetAddress.getByName(host)) + serverSocket.setSoTimeout(5000) + + val serverThread = new Thread("sbt-socket-server") { + + override def run(): Unit = { + println(s"SBT socket server started at $host:$port") + while (running.get()) { + try { + val socket = serverSocket.accept() + println(s"New client connected from: ${socket.getPort}") + + val connection = new ClientConnection(socket) { + override def onCommand(command: Command): Unit = { + commandQueue.add(command) + } + } + + lock.synchronized { + clients = 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 { + clients.foreach(_.publish(bytes)) + } + } + + /** + * @return The next queued command if there is one. It will have to be consumed because it is taken off the queue. + */ + def nextCommand(): Option[Command] = { + Option(commandQueue.poll()) + } + + override def shutdown(): Unit = { + println("Shutting down server") + running.set(false) + } + } + +} \ No newline at end of file diff --git a/main/server/src/main/scala/sbt/server/protocol.scala b/main/server/src/main/scala/sbt/server/protocol.scala new file mode 100644 index 000000000..714a640ec --- /dev/null +++ b/main/server/src/main/scala/sbt/server/protocol.scala @@ -0,0 +1,14 @@ +/* + * Copyright (C) 2016 Lightbend Inc. + */ +package sbt.server + +trait Event + +case class LogEvent() extends Event +case class StatusEvent() extends Event +case class ExecutionEvent() extends Event + +trait Command + +case class Execution(cmd: String) extends Command \ No newline at end of file diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index 279da153a..5945e222b 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -84,6 +84,9 @@ object StandardMain { /** The common interface to standard output, used for all built-in ConsoleLoggers. */ val console = ConsoleOut.systemOutOverwrite(ConsoleOut.overwriteContaining("Resolving ")) + // TODO hook it in, start in the right place, shutdown on termination + val server = sbt.server.Server.start("127.0.0.1", 12700) + def initialGlobalLogging: GlobalLogging = GlobalLogging.initial(MainLogging.globalDefault(console), File.createTempFile("sbt", ".log"), console) def initialState(configuration: xsbti.AppConfiguration, initialDefinitions: Seq[Command], preCommands: Seq[String]): State =