2018-03-23 18:07:28 +01:00
|
|
|
/*
|
|
|
|
|
* sbt
|
2018-09-14 09:40:39 +02:00
|
|
|
* Copyright 2011 - 2018, Lightbend, Inc.
|
2018-03-23 18:07:28 +01:00
|
|
|
* Copyright 2008 - 2010, Mark Harrah
|
2018-09-14 09:40:39 +02:00
|
|
|
* Licensed under Apache License 2.0 (see LICENSE)
|
2018-03-23 18:07:28 +01:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
package testpkg
|
|
|
|
|
|
2019-05-31 02:08:01 +02:00
|
|
|
import java.io.{ File, IOException }
|
Add multi-client ui to server
This commit makes it possible for the sbt server to render the same ui
to multiple clients. The network client ui should look nearly identical
to the console ui except for the log messages about the experimental
client.
The way that it works is that it associates a ui thread with each
terminal. Whenever a command starts or completes, callbacks are invoked
on the various channels to update their ui state. For example, if there
are two clients and one of them runs compile, then the prompt is changed
from AskUser to Running for the terminal that initiated the command
while the other client remains in the AskUser state. Whenever the client
changes uses ui states, the existing thread is terminated if it is
running and a new thread is begun.
The UITask formalizes this process. It is based on the AskUser class
from older versions of sbt. In fact, there is an AskUserTask which is
very similar. It uses jline to read input from the terminal (which could
be a network terminal). When it gets a line, it submits it to the
CommandExchange and exits. Once the next command is run (which may or
may not be the command it submitted), the ui state will be reset.
The debug, info, warn and error commands should work with the multi
client ui. When run, they set the log level globally, not just for the
client that set the level.
2019-12-18 19:24:32 +01:00
|
|
|
import java.nio.file.Path
|
2020-05-12 09:00:44 +02:00
|
|
|
import java.util.concurrent.TimeoutException
|
2019-05-31 02:08:01 +02:00
|
|
|
|
2019-10-21 02:42:52 +02:00
|
|
|
import verify._
|
2018-03-23 18:07:28 +01:00
|
|
|
import sbt.RunFromSourceMain
|
2019-05-31 02:08:01 +02:00
|
|
|
import sbt.io.IO
|
|
|
|
|
import sbt.io.syntax._
|
|
|
|
|
import sbt.protocol.ClientSocket
|
2020-05-12 09:00:44 +02:00
|
|
|
|
2020-06-24 05:21:16 +02:00
|
|
|
import scala.annotation.tailrec
|
2019-05-31 02:08:01 +02:00
|
|
|
import scala.concurrent._
|
|
|
|
|
import scala.concurrent.duration._
|
|
|
|
|
import scala.util.{ Success, Try }
|
2018-03-23 18:07:28 +01:00
|
|
|
|
2019-10-21 02:42:52 +02:00
|
|
|
trait AbstractServerTest extends TestSuite[Unit] {
|
|
|
|
|
implicit val ec: scala.concurrent.ExecutionContext = scala.concurrent.ExecutionContext.global
|
|
|
|
|
private var temp: File = _
|
|
|
|
|
var svr: TestServer = _
|
|
|
|
|
def testDirectory: String
|
Add multi-client ui to server
This commit makes it possible for the sbt server to render the same ui
to multiple clients. The network client ui should look nearly identical
to the console ui except for the log messages about the experimental
client.
The way that it works is that it associates a ui thread with each
terminal. Whenever a command starts or completes, callbacks are invoked
on the various channels to update their ui state. For example, if there
are two clients and one of them runs compile, then the prompt is changed
from AskUser to Running for the terminal that initiated the command
while the other client remains in the AskUser state. Whenever the client
changes uses ui states, the existing thread is terminated if it is
running and a new thread is begun.
The UITask formalizes this process. It is based on the AskUser class
from older versions of sbt. In fact, there is an AskUserTask which is
very similar. It uses jline to read input from the terminal (which could
be a network terminal). When it gets a line, it submits it to the
CommandExchange and exits. Once the next command is run (which may or
may not be the command it submitted), the ui state will be reset.
The debug, info, warn and error commands should work with the multi
client ui. When run, they set the log level globally, not just for the
client that set the level.
2019-12-18 19:24:32 +01:00
|
|
|
def testPath: Path = temp.toPath.resolve(testDirectory)
|
2018-09-26 11:41:59 +02:00
|
|
|
|
2020-02-17 00:01:45 +01:00
|
|
|
private val targetDir: File = {
|
|
|
|
|
val p0 = new File("..").getAbsoluteFile.getCanonicalFile / "target"
|
|
|
|
|
val p1 = new File("target").getAbsoluteFile
|
|
|
|
|
if (p0.exists) p0
|
|
|
|
|
else p1
|
|
|
|
|
}
|
|
|
|
|
|
2019-10-21 02:42:52 +02:00
|
|
|
override def setupSuite(): Unit = {
|
2020-02-17 00:01:45 +01:00
|
|
|
temp = targetDir / "test-server" / testDirectory
|
|
|
|
|
if (temp.exists) {
|
|
|
|
|
IO.delete(temp)
|
|
|
|
|
}
|
2019-10-21 02:42:52 +02:00
|
|
|
val classpath = sys.props.get("sbt.server.classpath") match {
|
|
|
|
|
case Some(s: String) => s.split(java.io.File.pathSeparator).map(file)
|
|
|
|
|
case _ => throw new IllegalStateException("No server classpath was specified.")
|
2018-10-02 16:51:20 +02:00
|
|
|
}
|
2019-10-21 02:42:52 +02:00
|
|
|
val sbtVersion = sys.props.get("sbt.server.version") match {
|
|
|
|
|
case Some(v: String) => v
|
|
|
|
|
case _ => throw new IllegalStateException("No server version was specified.")
|
2018-10-02 16:51:20 +02:00
|
|
|
}
|
2019-10-21 02:42:52 +02:00
|
|
|
val scalaVersion = sys.props.get("sbt.server.scala.version") match {
|
|
|
|
|
case Some(v: String) => v
|
|
|
|
|
case _ => throw new IllegalStateException("No server scala version was specified.")
|
2018-10-02 16:51:20 +02:00
|
|
|
}
|
2019-10-21 02:42:52 +02:00
|
|
|
svr = TestServer.get(testDirectory, scalaVersion, sbtVersion, classpath, temp)
|
2018-03-23 18:07:28 +01:00
|
|
|
}
|
2019-10-21 02:42:52 +02:00
|
|
|
override def tearDownSuite(): Unit = {
|
|
|
|
|
svr.bye()
|
|
|
|
|
svr = null
|
|
|
|
|
IO.delete(temp)
|
|
|
|
|
}
|
|
|
|
|
override def setup(): Unit = ()
|
|
|
|
|
override def tearDown(env: Unit): Unit = ()
|
2018-03-23 18:07:28 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
object TestServer {
|
2019-10-21 02:42:52 +02:00
|
|
|
// forking affects this
|
|
|
|
|
private val serverTestBase: File = {
|
|
|
|
|
val p0 = new File(".").getAbsoluteFile / "server-test" / "src" / "server-test"
|
|
|
|
|
val p1 = new File(".").getAbsoluteFile / "src" / "server-test"
|
|
|
|
|
if (p0.exists) p0
|
|
|
|
|
else p1
|
|
|
|
|
}
|
2018-09-24 18:54:18 +02:00
|
|
|
|
2019-10-21 02:42:52 +02:00
|
|
|
def get(
|
|
|
|
|
testBuild: String,
|
|
|
|
|
scalaVersion: String,
|
|
|
|
|
sbtVersion: String,
|
|
|
|
|
classpath: Seq[File],
|
|
|
|
|
temp: File
|
|
|
|
|
): TestServer = {
|
|
|
|
|
println(s"Starting test server $testBuild")
|
|
|
|
|
IO.copyDirectory(serverTestBase / testBuild, temp / testBuild)
|
|
|
|
|
|
|
|
|
|
// Each test server instance will be executed in a Thread pool separated from the tests
|
|
|
|
|
val testServer = TestServer(temp / testBuild, scalaVersion, sbtVersion, classpath)
|
|
|
|
|
// checking last log message after initialization
|
|
|
|
|
// if something goes wrong here the communication streams are corrupted, restarting
|
|
|
|
|
val init =
|
|
|
|
|
Try {
|
|
|
|
|
testServer.waitForString(30.seconds) { s =>
|
|
|
|
|
println(s)
|
|
|
|
|
s contains """"message":"Done""""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
init.get
|
|
|
|
|
testServer
|
|
|
|
|
}
|
2018-03-23 18:07:28 +01:00
|
|
|
|
2018-10-09 11:20:01 +02:00
|
|
|
def withTestServer(
|
|
|
|
|
testBuild: String
|
2019-10-21 02:42:52 +02:00
|
|
|
)(f: TestServer => Future[Unit]): Future[Unit] = {
|
|
|
|
|
println(s"Starting test")
|
2018-03-23 18:07:28 +01:00
|
|
|
IO.withTemporaryDirectory { temp =>
|
|
|
|
|
IO.copyDirectory(serverTestBase / testBuild, temp / testBuild)
|
2018-10-09 11:20:01 +02:00
|
|
|
withTestServer(testBuild, temp / testBuild)(f)
|
2018-03-23 18:07:28 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2018-10-09 11:20:01 +02:00
|
|
|
def withTestServer(testBuild: String, baseDirectory: File)(
|
2019-10-21 02:42:52 +02:00
|
|
|
f: TestServer => Future[Unit]
|
|
|
|
|
): Future[Unit] = {
|
|
|
|
|
val classpath = sys.props.get("sbt.server.classpath") match {
|
2020-01-12 04:52:36 +01:00
|
|
|
case Some(s: String) => s.split(java.io.File.pathSeparator).map(file)
|
|
|
|
|
case _ => throw new IllegalStateException("No server classpath was specified.")
|
|
|
|
|
}
|
2019-10-21 02:42:52 +02:00
|
|
|
val sbtVersion = sys.props.get("sbt.server.version") match {
|
2020-01-12 04:52:36 +01:00
|
|
|
case Some(v: String) => v
|
|
|
|
|
case _ => throw new IllegalStateException("No server version was specified.")
|
|
|
|
|
}
|
2019-10-21 02:42:52 +02:00
|
|
|
val scalaVersion = sys.props.get("sbt.server.scala.version") match {
|
2020-01-12 04:52:36 +01:00
|
|
|
case Some(v: String) => v
|
|
|
|
|
case _ => throw new IllegalStateException("No server scala version was specified.")
|
|
|
|
|
}
|
2018-10-09 11:20:01 +02:00
|
|
|
// Each test server instance will be executed in a Thread pool separated from the tests
|
2020-01-12 04:52:36 +01:00
|
|
|
val testServer = TestServer(baseDirectory, scalaVersion, sbtVersion, classpath)
|
2018-10-09 11:20:01 +02:00
|
|
|
// checking last log message after initialization
|
|
|
|
|
// if something goes wrong here the communication streams are corrupted, restarting
|
|
|
|
|
val init =
|
|
|
|
|
Try {
|
2019-05-31 02:08:01 +02:00
|
|
|
testServer.waitForString(30.seconds) { s =>
|
2019-11-24 01:37:46 +01:00
|
|
|
if (s.nonEmpty) println(s)
|
2018-10-09 11:20:01 +02:00
|
|
|
s contains """"message":"Done""""
|
|
|
|
|
}
|
2019-05-31 02:08:01 +02:00
|
|
|
}
|
2018-10-09 11:20:01 +02:00
|
|
|
|
|
|
|
|
init match {
|
2019-05-31 02:08:01 +02:00
|
|
|
case Success(_) =>
|
2018-10-09 11:20:01 +02:00
|
|
|
try {
|
|
|
|
|
f(testServer)
|
|
|
|
|
} finally {
|
2019-04-20 09:23:54 +02:00
|
|
|
try {
|
|
|
|
|
testServer.bye()
|
|
|
|
|
} finally {}
|
2018-10-09 11:20:01 +02:00
|
|
|
}
|
|
|
|
|
case _ =>
|
2019-04-20 09:23:54 +02:00
|
|
|
try {
|
|
|
|
|
testServer.bye()
|
|
|
|
|
} finally {}
|
2018-10-09 11:20:01 +02:00
|
|
|
hostLog("Server started but not connected properly... restarting...")
|
|
|
|
|
withTestServer(testBuild)(f)
|
2018-03-23 18:07:28 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def hostLog(s: String): Unit = {
|
|
|
|
|
println(s"""[${scala.Console.MAGENTA}build-1${scala.Console.RESET}] $s""")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-01-12 04:52:36 +01:00
|
|
|
case class TestServer(
|
|
|
|
|
baseDirectory: File,
|
|
|
|
|
scalaVersion: String,
|
|
|
|
|
sbtVersion: String,
|
|
|
|
|
classpath: Seq[File]
|
|
|
|
|
) {
|
2020-05-12 09:00:44 +02:00
|
|
|
import scala.concurrent.ExecutionContext.Implicits._
|
2018-03-23 18:07:28 +01:00
|
|
|
import TestServer.hostLog
|
|
|
|
|
|
2018-10-02 16:51:20 +02:00
|
|
|
val readBuffer = new Array[Byte](40960)
|
2018-03-23 18:07:28 +01:00
|
|
|
var buffer: Vector[Byte] = Vector.empty
|
|
|
|
|
var bytesRead = 0
|
|
|
|
|
private val delimiter: Byte = '\n'.toByte
|
|
|
|
|
private val RetByte = '\r'.toByte
|
|
|
|
|
|
|
|
|
|
hostLog("fork to a new sbt instance")
|
2020-01-12 04:52:36 +01:00
|
|
|
val process = RunFromSourceMain.fork(baseDirectory, scalaVersion, sbtVersion, classpath)
|
2018-09-24 18:54:18 +02:00
|
|
|
|
2018-03-23 18:07:28 +01:00
|
|
|
lazy val portfile = baseDirectory / "project" / "target" / "active.json"
|
|
|
|
|
|
2019-05-31 02:08:01 +02:00
|
|
|
def portfileIsEmpty(): Boolean =
|
|
|
|
|
try IO.read(portfile).isEmpty
|
|
|
|
|
catch { case _: IOException => true }
|
|
|
|
|
def waitForPortfile(duration: FiniteDuration): Unit = {
|
|
|
|
|
val deadline = duration.fromNow
|
|
|
|
|
var nextLog = 10.seconds.fromNow
|
|
|
|
|
while (portfileIsEmpty && !deadline.isOverdue && process.isAlive) {
|
|
|
|
|
if (nextLog.isOverdue) {
|
|
|
|
|
hostLog("waiting for the server...")
|
|
|
|
|
nextLog = 10.seconds.fromNow
|
2018-03-23 18:07:28 +01:00
|
|
|
}
|
2020-06-24 05:23:19 +02:00
|
|
|
Thread.sleep(10) // Don't spam the portfile
|
2018-03-23 18:07:28 +01:00
|
|
|
}
|
2019-05-31 02:08:01 +02:00
|
|
|
if (deadline.isOverdue) sys.error(s"Timeout. $portfile is not found.")
|
|
|
|
|
if (!process.isAlive) sys.error(s"Server unexpectedly terminated.")
|
|
|
|
|
}
|
|
|
|
|
private val waitDuration: FiniteDuration = 120.seconds
|
|
|
|
|
hostLog(s"wait $waitDuration until the server is ready to respond")
|
|
|
|
|
waitForPortfile(90.seconds)
|
2018-03-23 18:07:28 +01:00
|
|
|
|
|
|
|
|
// make connection to the socket described in the portfile
|
2020-05-12 09:00:44 +02:00
|
|
|
var (sk, _) = ClientSocket.socket(portfile)
|
|
|
|
|
var out = sk.getOutputStream
|
|
|
|
|
var in = sk.getInputStream
|
|
|
|
|
|
2020-05-12 16:26:33 +02:00
|
|
|
// initiate handshake
|
|
|
|
|
sendJsonRpc(
|
|
|
|
|
"""{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "initializationOptions": { } } }"""
|
|
|
|
|
)
|
|
|
|
|
|
2020-05-12 09:00:44 +02:00
|
|
|
def resetConnection() = {
|
2020-06-24 05:08:35 +02:00
|
|
|
Option(sk).foreach(_.close())
|
2020-05-12 09:00:44 +02:00
|
|
|
sk = ClientSocket.socket(portfile)._1
|
|
|
|
|
out = sk.getOutputStream
|
|
|
|
|
in = sk.getInputStream
|
2018-03-23 18:07:28 +01:00
|
|
|
|
2020-05-12 16:26:33 +02:00
|
|
|
sendJsonRpc(
|
|
|
|
|
"""{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "initializationOptions": { } } }"""
|
|
|
|
|
)
|
|
|
|
|
}
|
2018-03-23 18:07:28 +01:00
|
|
|
|
|
|
|
|
def test(f: TestServer => Future[Assertion]): Future[Assertion] = {
|
|
|
|
|
f(this)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def bye(): Unit = {
|
|
|
|
|
hostLog("sending exit")
|
|
|
|
|
sendJsonRpc(
|
Add multi-client ui to server
This commit makes it possible for the sbt server to render the same ui
to multiple clients. The network client ui should look nearly identical
to the console ui except for the log messages about the experimental
client.
The way that it works is that it associates a ui thread with each
terminal. Whenever a command starts or completes, callbacks are invoked
on the various channels to update their ui state. For example, if there
are two clients and one of them runs compile, then the prompt is changed
from AskUser to Running for the terminal that initiated the command
while the other client remains in the AskUser state. Whenever the client
changes uses ui states, the existing thread is terminated if it is
running and a new thread is begun.
The UITask formalizes this process. It is based on the AskUser class
from older versions of sbt. In fact, there is an AskUserTask which is
very similar. It uses jline to read input from the terminal (which could
be a network terminal). When it gets a line, it submits it to the
CommandExchange and exits. Once the next command is run (which may or
may not be the command it submitted), the ui state will be reset.
The debug, info, warn and error commands should work with the multi
client ui. When run, they set the log level globally, not just for the
client that set the level.
2019-12-18 19:24:32 +01:00
|
|
|
"""{ "jsonrpc": "2.0", "id": 9, "method": "sbt/exec", "params": { "commandLine": "shutdown" } }"""
|
2018-04-29 20:31:30 +02:00
|
|
|
)
|
2019-05-31 02:08:01 +02:00
|
|
|
val deadline = 10.seconds.fromNow
|
|
|
|
|
while (!deadline.isOverdue && process.isAlive) {
|
|
|
|
|
Thread.sleep(10)
|
2018-09-24 18:54:18 +02:00
|
|
|
}
|
2019-05-31 02:08:01 +02:00
|
|
|
// We gave the server a chance to exit but it didn't within a reasonable time frame.
|
|
|
|
|
if (deadline.isOverdue) process.destroy()
|
2018-03-23 18:07:28 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def sendJsonRpc(message: String): Unit = {
|
|
|
|
|
writeLine(s"""Content-Length: ${message.size + 2}""")
|
|
|
|
|
writeLine("")
|
|
|
|
|
writeLine(message)
|
|
|
|
|
}
|
|
|
|
|
|
2018-10-02 16:51:20 +02:00
|
|
|
private def writeLine(s: String): Unit = {
|
2018-03-23 18:07:28 +01:00
|
|
|
def writeEndLine(): Unit = {
|
|
|
|
|
val retByte: Byte = '\r'.toByte
|
|
|
|
|
val delimiter: Byte = '\n'.toByte
|
|
|
|
|
out.write(retByte.toInt)
|
|
|
|
|
out.write(delimiter.toInt)
|
|
|
|
|
out.flush
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (s != "") {
|
|
|
|
|
out.write(s.getBytes("UTF-8"))
|
|
|
|
|
}
|
|
|
|
|
writeEndLine
|
|
|
|
|
}
|
|
|
|
|
|
2020-05-12 09:00:44 +02:00
|
|
|
def readFrame: Future[Option[String]] = Future {
|
2018-03-23 18:07:28 +01:00
|
|
|
def getContentLength: Int = {
|
|
|
|
|
readLine map { line =>
|
|
|
|
|
line.drop(16).toInt
|
|
|
|
|
} getOrElse (0)
|
|
|
|
|
}
|
|
|
|
|
val l = getContentLength
|
|
|
|
|
readLine
|
|
|
|
|
readLine
|
|
|
|
|
readContentLength(l)
|
|
|
|
|
}
|
|
|
|
|
|
2019-05-31 02:08:01 +02:00
|
|
|
final def waitForString(duration: FiniteDuration)(f: String => Boolean): Boolean = {
|
|
|
|
|
val deadline = duration.fromNow
|
2020-06-24 05:21:16 +02:00
|
|
|
@tailrec def impl(): Boolean = {
|
|
|
|
|
val res = try {
|
|
|
|
|
Await.result(readFrame, deadline.timeLeft).fold(false)(f)
|
2020-05-12 09:00:44 +02:00
|
|
|
} catch {
|
|
|
|
|
case _: TimeoutException =>
|
|
|
|
|
resetConnection() // create a new connection to invalidate the running readFrame future
|
|
|
|
|
false
|
|
|
|
|
}
|
2020-06-24 05:21:16 +02:00
|
|
|
if (!res) impl() else !deadline.isOverdue()
|
2020-05-12 09:00:44 +02:00
|
|
|
}
|
|
|
|
|
impl()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final def neverReceive(duration: FiniteDuration)(f: String => Boolean): Boolean = {
|
|
|
|
|
val deadline = duration.fromNow
|
2020-06-24 05:21:16 +02:00
|
|
|
@tailrec
|
2020-05-12 09:00:44 +02:00
|
|
|
def impl(): Boolean = {
|
2020-06-24 05:21:16 +02:00
|
|
|
val res = try {
|
|
|
|
|
Await.result(readFrame, deadline.timeLeft).fold(true)(s => !f(s))
|
2020-05-12 09:00:44 +02:00
|
|
|
} catch {
|
|
|
|
|
case _: TimeoutException =>
|
|
|
|
|
resetConnection() // create a new connection to invalidate the running readFrame future
|
|
|
|
|
true
|
|
|
|
|
}
|
2020-06-24 05:21:16 +02:00
|
|
|
if (res && !deadline.isOverdue) impl else res || !deadline.isOverdue
|
2019-05-31 02:08:01 +02:00
|
|
|
}
|
|
|
|
|
impl()
|
2018-03-23 18:07:28 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def readLine: Option[String] = {
|
|
|
|
|
if (buffer.isEmpty) {
|
|
|
|
|
val bytesRead = in.read(readBuffer)
|
|
|
|
|
if (bytesRead > 0) {
|
|
|
|
|
buffer = buffer ++ readBuffer.toVector.take(bytesRead)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
val delimPos = buffer.indexOf(delimiter)
|
|
|
|
|
if (delimPos > 0) {
|
|
|
|
|
val chunk0 = buffer.take(delimPos)
|
|
|
|
|
buffer = buffer.drop(delimPos + 1)
|
|
|
|
|
// remove \r at the end of line.
|
|
|
|
|
val chunk1 = if (chunk0.lastOption contains RetByte) chunk0.dropRight(1) else chunk0
|
|
|
|
|
Some(new String(chunk1.toArray, "utf-8"))
|
|
|
|
|
} else None // no EOL yet, so skip this turn.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def readContentLength(length: Int): Option[String] = {
|
|
|
|
|
if (buffer.isEmpty) {
|
|
|
|
|
val bytesRead = in.read(readBuffer)
|
|
|
|
|
if (bytesRead > 0) {
|
|
|
|
|
buffer = buffer ++ readBuffer.toVector.take(bytesRead)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (length <= buffer.size) {
|
|
|
|
|
val chunk = buffer.take(length)
|
|
|
|
|
buffer = buffer.drop(length)
|
|
|
|
|
Some(new String(chunk.toArray, "utf-8"))
|
|
|
|
|
} else None // have not read enough yet, so skip this turn.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|