From b1dcf031a585d8f6ba8cc86f3e215c9673f9bd18 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Thu, 9 Jul 2020 08:52:24 -0700 Subject: [PATCH 01/17] Unprompt channels during project load In the situation where sbt was started in server mode and a client is running a `~` command and a project reload is triggered by a change to a build source, the console terminal looks like sbt:foo> [info] received remote command: ~compile sbt:foo> [info] welcome to sbt 1.4.0-SNAPSHOT (Azul Systems, Inc. Java 1.8.0_252) sbt:foo> [info] loading global plugins from ~/.sbt/1.0/plugins sbt:foo> [info] loading settings for project foo-build from metals.sbt ... sbt:foo> [info] loading project definition from ~/foo/project sbt:foo> [info] loading settings for project root from build.sbt ... sbt:foo> [info] loading settings for project macros from build.sbt ... sbt:foo> [info] loading settings for project main from build.sbt ... sbt:foo> [info] set current project to foo (in build file:~/foo) sbt:foo> This change fixes that by unprompting all channels during project loading and reprompting them when it completes. --- .../src/main/scala/sbt/internal/util/Prompt.scala | 1 + .../src/main/scala/sbt/internal/util/Terminal.scala | 2 +- .../src/main/scala/sbt/internal/ui/UserThread.scala | 7 ++++--- main/src/main/scala/sbt/Main.scala | 12 +++++++++--- main/src/main/scala/sbt/MainLoop.scala | 2 +- .../main/scala/sbt/internal/CommandExchange.scala | 6 +++++- 6 files changed, 21 insertions(+), 9 deletions(-) diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala b/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala index c87f4dca4..53c4f3849 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala @@ -44,4 +44,5 @@ private[sbt] object Prompt { private[sbt] case object Running extends NoPrompt private[sbt] case object Batch extends NoPrompt private[sbt] case object Watch extends NoPrompt + private[sbt] case object Loading extends NoPrompt } diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala index 76bb7ec39..0c2d50414 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala @@ -772,7 +772,7 @@ object Terminal { case s if s.nonEmpty => currentLine.set(new ArrayBuffer[Byte]) case _ => } - progressState.reprint(TerminalImpl.this, rawPrintStream) + if (prompt != Prompt.Loading) progressState.reprint(TerminalImpl.this, rawPrintStream) new ArrayBuffer[Byte] } else buf += i } diff --git a/main-command/src/main/scala/sbt/internal/ui/UserThread.scala b/main-command/src/main/scala/sbt/internal/ui/UserThread.scala index e868c3682..97accf35b 100644 --- a/main-command/src/main/scala/sbt/internal/ui/UserThread.scala +++ b/main-command/src/main/scala/sbt/internal/ui/UserThread.scala @@ -14,7 +14,7 @@ import java.util.concurrent.Executors import sbt.State import sbt.internal.util.{ ConsoleAppender, ProgressEvent, ProgressState, Util } -import sbt.internal.util.Prompt.{ AskUser, Running } +import sbt.internal.util.Prompt.{ AskUser, Loading, Running } private[sbt] class UserThread(val channel: CommandChannel) extends AutoCloseable { private[this] val uiThread = new AtomicReference[(UITask, Thread)] @@ -70,8 +70,9 @@ private[sbt] class UserThread(val channel: CommandChannel) extends AutoCloseable } val state = consolePromptEvent.state terminal.prompt match { - case Running => terminal.setPrompt(AskUser(() => UITask.shellPrompt(terminal, state))) - case _ => + case Loading | Running => + terminal.setPrompt(AskUser(() => UITask.shellPrompt(terminal, state))) + case _ => } onProgressEvent(ProgressEvent("Info", Vector(), None, None, None)) reset(state) diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index 3d886a3ca..ea02165d1 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -913,6 +913,8 @@ object BuiltinCommands { } def doLoadProject(s0: State, action: LoadAction.Value): State = { + StandardMain.exchange.unprompt(ConsoleUnpromptEvent(None), force = true) + StandardMain.exchange.channels.foreach(_.terminal.setPrompt(Prompt.Loading)) welcomeBanner(s0) checkSBTVersionChanged(s0) val (s1, base) = Project.loadAction(SessionVar.clear(s0), action) @@ -933,7 +935,9 @@ object BuiltinCommands { SessionSettings.checkSession(session, s2) val s3 = addCacheStoreFactoryFactory(Project.setProject(session, structure, s2)) val s4 = setupGlobalFileTreeRepository(s3) - CheckBuildSources.init(LintUnused.lintUnusedFunc(s4)) + val s5 = CheckBuildSources.init(LintUnused.lintUnusedFunc(s4)) + StandardMain.exchange.prompt(ConsolePromptEvent(s5)) + s5 } private val setupGlobalFileTreeRepository: State => State = { state => @@ -977,9 +981,11 @@ object BuiltinCommands { val exchange = StandardMain.exchange if (exchange.channels.exists(ContinuousCommands.isInWatch)) { val s1 = exchange.run(s0) + def needPrompt(c: CommandChannel) = + ContinuousCommands.isInWatch(c) && !ContinuousCommands.isPending(c) exchange.channels.foreach { - case c if ContinuousCommands.isPending(c) => - case c => c.prompt(ConsolePromptEvent(s1)) + case c if needPrompt(c) => c.prompt(ConsolePromptEvent(s1)) + case _ => } val exec: Exec = getExec(s1, Duration.Inf) val remaining: List[Exec] = diff --git a/main/src/main/scala/sbt/MainLoop.scala b/main/src/main/scala/sbt/MainLoop.scala index 8b144f4d5..0ba3099cc 100644 --- a/main/src/main/scala/sbt/MainLoop.scala +++ b/main/src/main/scala/sbt/MainLoop.scala @@ -199,7 +199,7 @@ object MainLoop { } StandardMain.exchange.setState(progressState) StandardMain.exchange.setExec(Some(exec)) - StandardMain.exchange.unprompt(ConsoleUnpromptEvent(exec.source)) + StandardMain.exchange.unprompt(ConsoleUnpromptEvent(exec.source), force = false) val newState = Command.process(exec.commandLine, progressState) if (exec.execId.fold(true)(!_.startsWith(networkExecPrefix)) && !exec.commandLine.startsWith(networkExecPrefix)) { diff --git a/main/src/main/scala/sbt/internal/CommandExchange.scala b/main/src/main/scala/sbt/internal/CommandExchange.scala index 29091ce96..6347c3f5d 100644 --- a/main/src/main/scala/sbt/internal/CommandExchange.scala +++ b/main/src/main/scala/sbt/internal/CommandExchange.scala @@ -350,7 +350,11 @@ private[sbt] final class CommandExchange { case c => c.prompt(event) } } - def unprompt(event: ConsoleUnpromptEvent): Unit = channels.foreach(_.unprompt(event)) + def unprompt(event: ConsoleUnpromptEvent, force: Boolean): Unit = { + if (force) + channels.foreach(c => c.unprompt(event.copy(lastSource = Some(CommandSource(c.name))))) + else channels.foreach(_.unprompt(event)) + } def logMessage(event: LogEvent): Unit = { channels.foreach { From d74a10aad135e0ce23d12e64da8fbb8e87463c6b Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Thu, 9 Jul 2020 09:14:00 -0700 Subject: [PATCH 02/17] Set terminal before watch triggered reload Without setting the terminal, a remote client is unable to reload the project if it fails. --- main/src/main/scala/sbt/internal/Continuous.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala index f41c2d858..c9ea072c7 100644 --- a/main/src/main/scala/sbt/internal/Continuous.scala +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -1202,7 +1202,7 @@ private[sbt] object ContinuousCommands { case Watch.Trigger => Right(s"$runWatch ${channel.name}") case Watch.Reload => val rewatch = s"$ContinuousExecutePrefix ${ws.count} ${cs.commands mkString "; "}" - stop.map(_ :: "reload" :: rewatch :: Nil mkString "; ") + stop.map(_ :: s"$SetTerminal ${channel.name}" :: "reload" :: rewatch :: Nil mkString "; ") case Watch.Prompt => stop.map(_ :: s"$PromptChannel ${channel.name}" :: Nil mkString ";") case Watch.Run(commands) => stop.map(_ +: commands.map(_.commandLine).filter(_.nonEmpty) mkString "; ") From de1423d662a57d5db49908da683b1e6eeb80f4a1 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Thu, 9 Jul 2020 09:14:52 -0700 Subject: [PATCH 03/17] Clarify boolean flag I found this difficult to read. --- .../main/scala/sbt/internal/nio/CheckBuildSources.scala | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/main/src/main/scala/sbt/internal/nio/CheckBuildSources.scala b/main/src/main/scala/sbt/internal/nio/CheckBuildSources.scala index 928f0ca21..326e08f15 100644 --- a/main/src/main/scala/sbt/internal/nio/CheckBuildSources.scala +++ b/main/src/main/scala/sbt/internal/nio/CheckBuildSources.scala @@ -92,12 +92,14 @@ private[sbt] class CheckBuildSources extends AutoCloseable { val filter = (c: String) => c == LoadProject || c == RebootCommand || c == TerminateAction || c == Shutdown || c.startsWith("sbtReboot") - val res = !commands.exists(filter) - if (!res) { + val resetState = commands.exists(filter) + if (resetState) { previousStamps.set(getStamps(force = true)) needUpdate.set(false) } - res + // We don't need to do a check since we just updated the stamps since + // we are about to perform a reload or reboot. + !resetState } @inline private def forceCheck = fileTreeRepository.isEmpty private[sbt] def needsReload(state: State, cmd: String) = { From d826e93ddf79c9e8001b50ce4b3bc561e11b2ac0 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Thu, 9 Jul 2020 09:07:10 -0700 Subject: [PATCH 04/17] Only trigger reload if sources have changed Running a `~` command in a local build off the latest develop branch will cause the build to reload even if the build sources were only touched and not actually modified. --- main/src/main/scala/sbt/MainLoop.scala | 2 +- main/src/main/scala/sbt/internal/Continuous.scala | 9 ++++++++- .../main/scala/sbt/internal/nio/CheckBuildSources.scala | 9 +++++---- sbt/src/sbt-test/watch/on-start-watch/build.sbt | 9 ++++++++- sbt/src/sbt-test/watch/on-start-watch/extra.sbt | 2 -- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/main/src/main/scala/sbt/MainLoop.scala b/main/src/main/scala/sbt/MainLoop.scala index 0ba3099cc..e50a1465f 100644 --- a/main/src/main/scala/sbt/MainLoop.scala +++ b/main/src/main/scala/sbt/MainLoop.scala @@ -218,7 +218,7 @@ object MainLoop { } state.get(CheckBuildSourcesKey) match { case Some(cbs) => - if (!cbs.needsReload(state, exec.commandLine)) process() + if (!cbs.needsReload(state, state.globalLogging.full, exec.commandLine)) process() else { if (exec.commandLine.startsWith(SetTerminal)) exec +: Exec("reload", None, None) +: state.remove(CheckBuildSourcesKey) diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala index c9ea072c7..274ca692f 100644 --- a/main/src/main/scala/sbt/internal/Continuous.scala +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -526,7 +526,14 @@ private[sbt] object Continuous extends DeprecatedContinuous { } if (buildGlobs.exists(_.matches(path))) { - getWatchEvent(forceTrigger = false).map(e => e -> Watch.Reload).toSeq + getWatchEvent(forceTrigger = false).flatMap { e => + state.get(CheckBuildSources.CheckBuildSourcesKey) match { + case Some(cbs) => + if (cbs.needsReload(state, logger, "")) Some(e -> Watch.Reload) else None + case None => + Some(e -> Watch.Reload) + } + }.toSeq } else { val acceptedConfigParameters = configs.flatMap { config => config.inputs().flatMap { diff --git a/main/src/main/scala/sbt/internal/nio/CheckBuildSources.scala b/main/src/main/scala/sbt/internal/nio/CheckBuildSources.scala index 326e08f15..53c29cf43 100644 --- a/main/src/main/scala/sbt/internal/nio/CheckBuildSources.scala +++ b/main/src/main/scala/sbt/internal/nio/CheckBuildSources.scala @@ -24,6 +24,7 @@ import sbt.nio.Keys._ import sbt.nio.file.{ FileAttributes, FileTreeView, Glob, ** } import sbt.nio.file.syntax._ import sbt.nio.Settings +import sbt.util.Logger import scala.annotation.tailrec import scala.concurrent.duration.{ Deadline => SDeadline, _ } @@ -102,11 +103,10 @@ private[sbt] class CheckBuildSources extends AutoCloseable { !resetState } @inline private def forceCheck = fileTreeRepository.isEmpty - private[sbt] def needsReload(state: State, cmd: String) = { + private[sbt] def needsReload(state: State, logger: Logger, cmd: String) = { (needCheck(state, cmd) && (forceCheck || needUpdate.compareAndSet(true, false))) && { val extracted = Project.extract(state) val onChanges = extracted.get(Global / onChangedBuildSource) - val logger = state.globalLogging.full val current = getStamps(force = false) val previous = previousStamps.getAndSet(current) Settings.changedFiles(previous, current) match { @@ -160,8 +160,9 @@ private[sbt] object CheckBuildSources { private[sbt] def needReloadImpl: Def.Initialize[Task[StateTransform]] = Def.task { val st = state.value st.get(CheckBuildSourcesKey) match { - case Some(cbs) if (cbs.needsReload(st, "")) => StateTransform("reload" :: (_: State)) - case _ => StateTransform(identity) + case Some(cbs) if (cbs.needsReload(st, st.globalLogging.full, "")) => + StateTransform("reload" :: (_: State)) + case _ => StateTransform(identity) } } private[sbt] def buildSourceFileInputs: Def.Initialize[Seq[Glob]] = Def.setting { diff --git a/sbt/src/sbt-test/watch/on-start-watch/build.sbt b/sbt/src/sbt-test/watch/on-start-watch/build.sbt index 3d50e58b4..808416d2a 100644 --- a/sbt/src/sbt-test/watch/on-start-watch/build.sbt +++ b/sbt/src/sbt-test/watch/on-start-watch/build.sbt @@ -16,4 +16,11 @@ failingTask := { throw new IllegalStateException("failed") } -onChangedBuildSource := ReloadOnSourceChanges +watchOnIteration := { (count, project, commands) => + val extra = baseDirectory.value / "extra.sbt" + IO.copyFile(baseDirectory.value / "changes" / "extra.sbt", extra, CopyOptions().withOverwrite(true)) + Watch.defaultStartWatch(count, project, commands).foreach(_.linesIterator.foreach(l => println(s"[info] $l"))) + Watch.Ignore +} + +Global / onChangedBuildSource := ReloadOnSourceChanges diff --git a/sbt/src/sbt-test/watch/on-start-watch/extra.sbt b/sbt/src/sbt-test/watch/on-start-watch/extra.sbt index ce346f6b9..a419d1c94 100644 --- a/sbt/src/sbt-test/watch/on-start-watch/extra.sbt +++ b/sbt/src/sbt-test/watch/on-start-watch/extra.sbt @@ -1,7 +1,5 @@ Compile / compile := { Count.increment() // Trigger a new build by updating the last modified time - val extra = baseDirectory.value / "extra.sbt" - IO.copyFile(baseDirectory.value / "changes" / "extra.sbt", extra, CopyOptions().withOverwrite(true)) (Compile / compile).value } From 6aa1333adbf06c0ca7f0a302ede6da2536d1ebba Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Thu, 9 Jul 2020 12:26:02 -0700 Subject: [PATCH 05/17] Don't log systemOut messages in jsonRpcNotify Whatever debugging utility these may have is not worth spamming the task logs with these. --- main/src/main/scala/sbt/internal/server/NetworkChannel.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala index 50b3c7bb6..71697905b 100644 --- a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala +++ b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala @@ -611,7 +611,7 @@ final class NetworkChannel( private[sbt] def jsonRpcNotify[A: JsonFormat](method: String, params: A): Unit = { val m = JsonRpcNotificationMessage("2.0", method, Option(Converter.toJson[A](params).get)) - log.debug(s"jsonRpcNotify: $m") + if (method != Serialization.systemOut) log.debug(s"jsonRpcNotify: $m") val bytes = Serialization.serializeNotificationMessage(m) publishBytes(bytes) } From eb688c9ecdb942dbf0fa985c0103f6f95f6341c1 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Thu, 9 Jul 2020 16:30:21 -0700 Subject: [PATCH 06/17] Buffer output to the remote client Remote clients sometimes flicker when updating progress. This is especially noticeable when there are two clients and one of them is running a command, the other will tend to have some visible flickering and character ghosting. As an experiment, I buffered calls to flush in the NetworkChannel output stream and the artifacts went away. --- .../sbt/internal/server/NetworkChannel.scala | 59 +++++++++++++++---- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala index 71697905b..6a9860503 100644 --- a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala +++ b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala @@ -12,7 +12,7 @@ package server import java.io.{ IOException, InputStream, OutputStream } import java.net.{ Socket, SocketTimeoutException } import java.nio.channels.ClosedChannelException -import java.util.concurrent.{ ConcurrentHashMap, LinkedBlockingQueue } +import java.util.concurrent.{ ConcurrentHashMap, Executors, LinkedBlockingQueue, TimeUnit } import java.util.concurrent.atomic.{ AtomicBoolean, AtomicReference } import sbt.BasicCommandStrings.{ Shutdown, TerminateAction } @@ -575,6 +575,7 @@ final class NetworkChannel( catch { case _: IOException => } running.set(false) out.close() + outputStream.close() thread.interrupt() writeThread.interrupt() } @@ -647,21 +648,53 @@ final class NetworkChannel( import sjsonnew.BasicJsonProtocol._ import scala.collection.JavaConverters._ - private[this] lazy val outputStream: OutputStream = new OutputStream { - private[this] val buffer = new LinkedBlockingQueue[Byte]() - override def write(b: Int): Unit = buffer.put(b.toByte) - override def flush(): Unit = { - jsonRpcNotify(Serialization.systemOut, buffer.asScala) - buffer.clear() + private[this] lazy val outputStream: OutputStream with AutoCloseable = new OutputStream + with AutoCloseable { + /* + * We buffer calls to flush to the remote client so that it is called at most + * once every 20 milliseconds. This is done because many terminals seem to flicker + * and display ghost characters if we flush to the remote client too often. The + * json protocol is a bit bulky so this will also reduce the total number of + * bytes that are written to the named pipe or unix domain socket. The buffer + * period of 20 milliseconds was arbitrarily chosen and could be tuned in the future. + * The thinking is that writes tend to be bursty so a twenty millisecond window is + * probably long enough to catch each burst but short enough to not introduce + * noticeable latency. + */ + private[this] val executor = + Executors.newSingleThreadScheduledExecutor( + r => new Thread(r, s"$name-output-buffer-timer-thread") + ) + private[this] val buffer = new LinkedBlockingQueue[Byte] + private[this] val future = new AtomicReference[java.util.concurrent.Future[_]] + override def close(): Unit = Util.ignoreResult(executor.shutdownNow()) + override def write(b: Int): Unit = buffer.synchronized { + buffer.put(b.toByte) } - override def write(b: Array[Byte]): Unit = write(b, 0, b.length) - override def write(b: Array[Byte], off: Int, len: Int): Unit = { - var i = off - while (i < len) { - buffer.put(b(i)) - i += 1 + override def flush(): Unit = { + future.get match { + case null => + future.set( + executor.schedule( + (() => { + future.set(null) + val list = new java.util.ArrayList[Byte] + buffer.synchronized(buffer.drainTo(list)) + jsonRpcNotify(Serialization.systemOut, list.asScala.toSeq) + }): Runnable, + 20, + TimeUnit.MILLISECONDS + ) + ) + case f => } } + override def write(b: Array[Byte]): Unit = buffer.synchronized { + b.foreach(buffer.put) + } + override def write(b: Array[Byte], off: Int, len: Int): Unit = { + write(java.util.Arrays.copyOfRange(b, off, off + len)) + } } private class NetworkTerminal extends TerminalImpl(inputStream, outputStream, name) { private[this] val pending = new AtomicBoolean(false) From 366c49a7641de33e5520f8d0ec0898e0bb19b99e Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Fri, 10 Jul 2020 12:27:27 -0700 Subject: [PATCH 07/17] Aggregate watch events It is possible for sbt to get into a weird state when in a continuous build when the auto reload feature is on and a source file and a build file are changed in a small window of time. If sbt detects the source file first, it will start running the command but then it will autoreload when it runs the command because of the build file change. This causes the watch to get into a broken state because it is necessary to completely restart the watch after sbt exits. To fix this, we can aggregate the detected events in a 100ms window. The idea is to handle bursts of file events so we poll in 5ms increments and as soon as no events are detected, we trigger a build. --- .../main/scala/sbt/internal/Continuous.scala | 33 +++++++++++++++++-- main/src/main/scala/sbt/nio/Keys.scala | 3 ++ main/src/main/scala/sbt/nio/Watch.scala | 9 +++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala index 274ca692f..ce2c1d6b1 100644 --- a/main/src/main/scala/sbt/internal/Continuous.scala +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -465,16 +465,41 @@ private[sbt] object Continuous extends DeprecatedContinuous { } } + private[this] val antiEntropyWindow = configs.map(_.watchSettings.antiEntropy).max private[this] val monitor = FileEventMonitor.antiEntropy( eventMonitorObservers, - configs.map(_.watchSettings.antiEntropy).max, + antiEntropyWindow, logger, quarantinePeriod, retentionPeriod ) - override def poll(duration: Duration, filter: Event => Boolean): Seq[Event] = - monitor.poll(duration, filter) + private[this] val antiEntropyPollPeriod = + configs.map(_.watchSettings.antiEntropyPollPeriod).max + override def poll(duration: Duration, filter: Event => Boolean): Seq[Event] = { + monitor.poll(duration, filter) match { + case s if s.nonEmpty => + val limit = antiEntropyWindow.fromNow + /* + * File events may come in bursts so we poll for a short time to see if there + * are other changes detected in the burst. As soon as no changes are detected + * during the polling window, we return all of the detected events. The polling + * period is by default 5 milliseconds which is short enough to detect bursts + * induced by commands like git rebase but fast enough to not lead to a noticable + * increase in latency. + */ + @tailrec def aggregate(res: Seq[Event]): Seq[Event] = + if (limit.isOverdue) res + else { + monitor.poll(antiEntropyPollPeriod) match { + case s if s.nonEmpty => aggregate(res ++ s) + case _ => res + } + } + aggregate(s) + case s => s + } + } override def close(): Unit = { configHandle.close() @@ -865,6 +890,8 @@ private[sbt] object Continuous extends DeprecatedContinuous { // alternative would be SettingKey[() => InputStream], but that doesn't feel right because // one might want the InputStream to depend on other tasks. val inputStream: Option[TaskKey[InputStream]] = key.get(watchInputStream) + val antiEntropyPollPeriod: FiniteDuration = + key.get(watchAntiEntropyPollPeriod).getOrElse(Watch.defaultAntiEntropyPollPeriod) } /** diff --git a/main/src/main/scala/sbt/nio/Keys.scala b/main/src/main/scala/sbt/nio/Keys.scala index b962fbc8e..d00257d58 100644 --- a/main/src/main/scala/sbt/nio/Keys.scala +++ b/main/src/main/scala/sbt/nio/Keys.scala @@ -67,6 +67,9 @@ object Keys { val watchAntiEntropyRetentionPeriod = settingKey[FiniteDuration]( "Wall clock Duration for which a FileEventMonitor will store anti-entropy events. This prevents spurious triggers when a task takes a long time to run. Higher values will consume more memory but make spurious triggers less likely." ).withRank(BMinusSetting) + val watchAntiEntropyPollPeriod = settingKey[FiniteDuration]( + "The duration for which sbt will poll for file events during the window in which sbt is buffering file events" + ) val onChangedBuildSource = settingKey[WatchBuildSourceOption]( "Determines what to do if the sbt meta build sources have changed" ).withRank(DSetting) diff --git a/main/src/main/scala/sbt/nio/Watch.scala b/main/src/main/scala/sbt/nio/Watch.scala index 68994055b..5431be199 100644 --- a/main/src/main/scala/sbt/nio/Watch.scala +++ b/main/src/main/scala/sbt/nio/Watch.scala @@ -450,6 +450,14 @@ object Watch { */ final val defaultAntiEntropy: FiniteDuration = 500.milliseconds + /** + * The duration for which we will poll for new file events when we are buffering events + * after an initial event has been detected to avoid spurious rebuilds. + * + * If this value is ever updated, please update the comment in Continuous.getFileEvents. + */ + final val defaultAntiEntropyPollPeriod: FiniteDuration = 5.milliseconds + /** * The duration in wall clock time for which a FileEventMonitor will retain anti-entropy * events for files. This is an implementation detail of the FileEventMonitor. It should @@ -613,5 +621,6 @@ object Watch { watchForceTriggerOnAnyChange :== false, watchPersistFileStamps := (sbt.Keys.turbo in ThisBuild).value, watchTriggers :== Nil, + watchAntiEntropyPollPeriod := Watch.defaultAntiEntropyPollPeriod, ) } From dea7bdfa89d08134dd4ed94073d120e899ba99d3 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Fri, 10 Jul 2020 13:15:16 -0700 Subject: [PATCH 08/17] Don't start an ask user thread for dead console If there is no system console available, then there is no point in making an ask user thread. An ask user thread can only be created when the terminal prompt is in the Prompt.Running or Prompt.Loading state. The console channel will now set itself to be in the Prompt.NoPrompt state if it detects that there is no System.console available. The motivation for this change is that jline was printing a lot of extra text during scripted and server tests. Whenever a jline3 linereader is closed, it prints a newline so the logs were filled with unnecessary newlines. --- .../sbt/internal/util/ConsoleAppender.scala | 23 +++++++++++-------- .../main/scala/sbt/internal/util/Prompt.scala | 1 + .../scala/sbt/internal/util/Terminal.scala | 3 ++- .../scala/sbt/internal/ConsoleChannel.scala | 1 + 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala b/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala index f12e06d1f..ddc2cb647 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala @@ -644,20 +644,23 @@ private[sbt] object ProgressState { val isRunning = terminal.prompt == Prompt.Running val isBatch = terminal.prompt == Prompt.Batch val isWatch = terminal.prompt == Prompt.Watch + val noPrompt = terminal.prompt == Prompt.NoPrompt if (terminal.isSupershellEnabled) { if (!pe.skipIfActive.getOrElse(false) || (!isRunning && !isBatch)) { terminal.withPrintStream { ps => - val info = if (isRunning || isBatch && pe.channelName.fold(true)(_ == terminal.name)) { - pe.items.map { item => - val elapsed = item.elapsedMicros / 1000000L - s" | => ${item.name} ${elapsed}s" + val info = + if ((isRunning || isBatch || noPrompt) && pe.channelName + .fold(true)(_ == terminal.name)) { + pe.items.map { item => + val elapsed = item.elapsedMicros / 1000000L + s" | => ${item.name} ${elapsed}s" + } + } else { + pe.command.toSeq.flatMap { cmd => + val tail = if (isWatch) Nil else "enter 'cancel' to stop evaluation" :: Nil + s"sbt server is running '$cmd'" :: tail + } } - } else { - pe.command.toSeq.flatMap { cmd => - val tail = if (isWatch) Nil else "enter 'cancel' to stop evaluation" :: Nil - s"sbt server is running '$cmd'" :: tail - } - } val currentLength = info.foldLeft(0)(_ + terminal.lineCount(_)) val previousLines = state.progressLines.getAndSet(info) diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala b/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala index 53c4f3849..bd50e9e75 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala @@ -45,4 +45,5 @@ private[sbt] object Prompt { private[sbt] case object Batch extends NoPrompt private[sbt] case object Watch extends NoPrompt private[sbt] case object Loading extends NoPrompt + private[sbt] case object NoPrompt extends NoPrompt } diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala index 0c2d50414..a416840ac 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala @@ -126,7 +126,8 @@ trait Terminal extends AutoCloseable { private[sbt] val progressState = new ProgressState(1) private[this] val promptHolder: AtomicReference[Prompt] = new AtomicReference(Prompt.Running) private[sbt] final def prompt: Prompt = promptHolder.get - private[sbt] final def setPrompt(newPrompt: Prompt): Unit = promptHolder.set(newPrompt) + private[sbt] final def setPrompt(newPrompt: Prompt): Unit = + if (prompt != Prompt.NoPrompt) promptHolder.set(newPrompt) /** * Returns the number of lines that the input string will cover given the current width of the diff --git a/main-command/src/main/scala/sbt/internal/ConsoleChannel.scala b/main-command/src/main/scala/sbt/internal/ConsoleChannel.scala index dac0c9565..397dba7a0 100644 --- a/main-command/src/main/scala/sbt/internal/ConsoleChannel.scala +++ b/main-command/src/main/scala/sbt/internal/ConsoleChannel.scala @@ -25,6 +25,7 @@ private[sbt] final class ConsoleChannel( override val userThread: UserThread = new UserThread(this) private[sbt] def terminal = Terminal.console + if (System.console == null) terminal.setPrompt(Prompt.NoPrompt) } private[sbt] object ConsoleChannel { private[sbt] def defaultName = "console0" From b6354656b1a1c8318eec0fa3bd61f4b89eaaea13 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Tue, 30 Jun 2020 10:23:26 -0700 Subject: [PATCH 09/17] Fix copied scaladoc --- .../src/main/scala/sbt/internal/util/Terminal.scala | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala index a416840ac..f8c9328d7 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala @@ -57,9 +57,8 @@ trait Terminal extends AutoCloseable { def inputStream: InputStream /** - * Gets the input stream for this Terminal. This could be a wrapper around System.in for the - * process or it could be a remote input stream for a network channel. - * @return the input stream. + * Gets the output stream for this Terminal. + * @return the output stream. */ def outputStream: OutputStream From ed4d40d3e2db0b5fdfdefb7bf989039bf3820a92 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Tue, 7 Jul 2020 14:06:55 -0700 Subject: [PATCH 10/17] Move ProgressState into its own file This didn't really belong in ConsoleAppender.scala anymore. --- .../sbt/internal/util/ConsoleAppender.scala | 139 +--------------- .../sbt/internal/util/ProgressState.scala | 157 ++++++++++++++++++ 2 files changed, 158 insertions(+), 138 deletions(-) create mode 100644 internal/util-logging/src/main/scala/sbt/internal/util/ProgressState.scala diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala b/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala index ddc2cb647..c89a51204 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala @@ -9,7 +9,7 @@ package sbt.internal.util import java.io.{ PrintStream, PrintWriter } import java.lang.StringBuilder -import java.util.concurrent.atomic.{ AtomicBoolean, AtomicInteger, AtomicReference } +import java.util.concurrent.atomic.{ AtomicBoolean, AtomicInteger } import org.apache.logging.log4j.core.appender.AbstractAppender import org.apache.logging.log4j.core.{ LogEvent => XLogEvent } @@ -18,8 +18,6 @@ import org.apache.logging.log4j.{ Level => XLevel } import sbt.internal.util.ConsoleAppender._ import sbt.util._ -import scala.collection.mutable.ArrayBuffer - object ConsoleLogger { // These are provided so other modules do not break immediately. @deprecated("Use EscHelpers.ESC instead", "0.13.x") @@ -553,138 +551,3 @@ class ConsoleAppender private[ConsoleAppender] ( } final class SuppressedTraceContext(val traceLevel: Int, val useFormat: Boolean) -private[sbt] final class ProgressState( - val progressLines: AtomicReference[Seq[String]], - val padding: AtomicInteger, - val blankZone: Int, - val currentLineBytes: AtomicReference[ArrayBuffer[Byte]], -) { - def this(blankZone: Int) = - this( - new AtomicReference(Nil), - new AtomicInteger(0), - blankZone, - new AtomicReference(new ArrayBuffer[Byte]), - ) - def reset(): Unit = { - progressLines.set(Nil) - padding.set(0) - currentLineBytes.set(new ArrayBuffer[Byte]) - } - private[util] def clearBytes(): Unit = { - val pad = padding.get - if (currentLineBytes.get.isEmpty && pad > 0) padding.decrementAndGet() - currentLineBytes.set(new ArrayBuffer[Byte]) - } - - private[util] def addBytes(terminal: Terminal, bytes: ArrayBuffer[Byte]): Unit = { - val previous = currentLineBytes.get - val padding = this.padding.get - val prevLineCount = if (padding > 0) terminal.lineCount(new String(previous.toArray)) else 0 - previous ++= bytes - if (padding > 0) { - val newLineCount = terminal.lineCount(new String(previous.toArray)) - val diff = newLineCount - prevLineCount - this.padding.set(math.max(padding - diff, 0)) - } - } - - private[util] def printPrompt(terminal: Terminal, printStream: PrintStream): Unit = - if (terminal.prompt != Prompt.Running && terminal.prompt != Prompt.Batch) { - val prefix = if (terminal.isAnsiSupported) s"$DeleteLine$CursorLeft1000" else "" - val pmpt = prefix.getBytes ++ terminal.prompt.render().getBytes - pmpt.foreach(b => printStream.write(b & 0xFF)) - } - private[util] def reprint(terminal: Terminal, printStream: PrintStream): Unit = { - printPrompt(terminal, printStream) - if (progressLines.get.nonEmpty) { - val lines = printProgress(terminal, terminal.getLastLine.getOrElse("")) - printStream.print(ClearScreenAfterCursor + lines) - } - } - - private[util] def printProgress( - terminal: Terminal, - lastLine: String - ): String = { - val previousLines = progressLines.get - if (previousLines.nonEmpty) { - val currentLength = previousLines.foldLeft(0)(_ + terminal.lineCount(_)) - val (height, width) = terminal.getLineHeightAndWidth(lastLine) - val left = cursorLeft(1000) // resets the position to the left - val offset = width > 0 - val pad = math.max(padding.get - height, 0) - val start = (if (offset) "\n" else "") - val totalSize = currentLength + blankZone + pad - val blank = left + s"\n$DeleteLine" * (totalSize - currentLength) - val lines = previousLines.mkString(DeleteLine, s"\n$DeleteLine", s"\n$DeleteLine") - val resetCursorUp = cursorUp(totalSize + (if (offset) 1 else 0)) - val resetCursor = resetCursorUp + left + lastLine - start + blank + lines + resetCursor - } else { - ClearScreenAfterCursor - } - } -} - -private[sbt] object ProgressState { - - /** - * Receives a new task report and replaces the old one. In the event that the new - * report has fewer lines than the previous report, padding lines are added on top - * so that the console log lines remain contiguous. When a console line is printed - * at the info or greater level, we can decrement the padding because the console - * line will have filled in the blank line. - */ - private[sbt] def updateProgressState( - pe: ProgressEvent, - terminal: Terminal - ): Unit = { - val state = terminal.progressState - val isRunning = terminal.prompt == Prompt.Running - val isBatch = terminal.prompt == Prompt.Batch - val isWatch = terminal.prompt == Prompt.Watch - val noPrompt = terminal.prompt == Prompt.NoPrompt - if (terminal.isSupershellEnabled) { - if (!pe.skipIfActive.getOrElse(false) || (!isRunning && !isBatch)) { - terminal.withPrintStream { ps => - val info = - if ((isRunning || isBatch || noPrompt) && pe.channelName - .fold(true)(_ == terminal.name)) { - pe.items.map { item => - val elapsed = item.elapsedMicros / 1000000L - s" | => ${item.name} ${elapsed}s" - } - } else { - pe.command.toSeq.flatMap { cmd => - val tail = if (isWatch) Nil else "enter 'cancel' to stop evaluation" :: Nil - s"sbt server is running '$cmd'" :: tail - } - } - - val currentLength = info.foldLeft(0)(_ + terminal.lineCount(_)) - val previousLines = state.progressLines.getAndSet(info) - val prevLength = previousLines.foldLeft(0)(_ + terminal.lineCount(_)) - val lastLine = terminal.prompt match { - case Prompt.Running | Prompt.Batch => terminal.getLastLine.getOrElse("") - case a => a.render() - } - val prevSize = prevLength + state.padding.get - - val newPadding = math.max(0, prevSize - currentLength) - state.padding.set(newPadding) - state.printPrompt(terminal, ps) - ps.print(state.printProgress(terminal, lastLine)) - ps.flush() - } - } else if (state.progressLines.get.nonEmpty) { - state.progressLines.set(Nil) - terminal.withPrintStream { ps => - val lastLine = terminal.getLastLine.getOrElse("") - ps.print(lastLine + ClearScreenAfterCursor) - ps.flush() - } - } - } - } -} diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/ProgressState.scala b/internal/util-logging/src/main/scala/sbt/internal/util/ProgressState.scala new file mode 100644 index 000000000..13332812b --- /dev/null +++ b/internal/util-logging/src/main/scala/sbt/internal/util/ProgressState.scala @@ -0,0 +1,157 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal.util + +import java.io.PrintStream +import java.util.concurrent.atomic.{ AtomicInteger, AtomicReference } + +import sbt.internal.util.ConsoleAppender.{ + ClearScreenAfterCursor, + CursorLeft1000, + DeleteLine, + cursorLeft, + cursorUp, +} + +import scala.collection.mutable.ArrayBuffer + +private[sbt] final class ProgressState( + val progressLines: AtomicReference[Seq[String]], + val padding: AtomicInteger, + val blankZone: Int, + val currentLineBytes: AtomicReference[ArrayBuffer[Byte]], +) { + def this(blankZone: Int) = + this( + new AtomicReference(Nil), + new AtomicInteger(0), + blankZone, + new AtomicReference(new ArrayBuffer[Byte]), + ) + def reset(): Unit = { + progressLines.set(Nil) + padding.set(0) + currentLineBytes.set(new ArrayBuffer[Byte]) + } + private[util] def clearBytes(): Unit = { + val pad = padding.get + if (currentLineBytes.get.isEmpty && pad > 0) padding.decrementAndGet() + currentLineBytes.set(new ArrayBuffer[Byte]) + } + + private[util] def addBytes(terminal: Terminal, bytes: ArrayBuffer[Byte]): Unit = { + val previous = currentLineBytes.get + val padding = this.padding.get + val prevLineCount = if (padding > 0) terminal.lineCount(new String(previous.toArray)) else 0 + previous ++= bytes + if (padding > 0) { + val newLineCount = terminal.lineCount(new String(previous.toArray)) + val diff = newLineCount - prevLineCount + this.padding.set(math.max(padding - diff, 0)) + } + } + + private[util] def printPrompt(terminal: Terminal, printStream: PrintStream): Unit = + if (terminal.prompt != Prompt.Running && terminal.prompt != Prompt.Batch) { + val prefix = if (terminal.isAnsiSupported) s"$DeleteLine$CursorLeft1000" else "" + val pmpt = prefix.getBytes ++ terminal.prompt.render().getBytes + pmpt.foreach(b => printStream.write(b & 0xFF)) + } + private[util] def reprint(terminal: Terminal, printStream: PrintStream): Unit = { + printPrompt(terminal, printStream) + if (progressLines.get.nonEmpty) { + val lines = printProgress(terminal, terminal.getLastLine.getOrElse("")) + printStream.print(ClearScreenAfterCursor + lines) + } + } + + private[util] def printProgress( + terminal: Terminal, + lastLine: String + ): String = { + val previousLines = progressLines.get + if (previousLines.nonEmpty) { + val currentLength = previousLines.foldLeft(0)(_ + terminal.lineCount(_)) + val (height, width) = terminal.getLineHeightAndWidth(lastLine) + val left = cursorLeft(1000) // resets the position to the left + val offset = width > 0 + val pad = math.max(padding.get - height, 0) + val start = (if (offset) "\n" else "") + val totalSize = currentLength + blankZone + pad + val blank = left + s"\n$DeleteLine" * (totalSize - currentLength) + val lines = previousLines.mkString(DeleteLine, s"\n$DeleteLine", s"\n$DeleteLine") + val resetCursorUp = cursorUp(totalSize + (if (offset) 1 else 0)) + val resetCursor = resetCursorUp + left + lastLine + start + blank + lines + resetCursor + } else { + ClearScreenAfterCursor + } + } +} + +private[sbt] object ProgressState { + + /** + * Receives a new task report and replaces the old one. In the event that the new + * report has fewer lines than the previous report, padding lines are added on top + * so that the console log lines remain contiguous. When a console line is printed + * at the info or greater level, we can decrement the padding because the console + * line will have filled in the blank line. + */ + private[sbt] def updateProgressState( + pe: ProgressEvent, + terminal: Terminal + ): Unit = { + val state = terminal.progressState + val isRunning = terminal.prompt == Prompt.Running + val isBatch = terminal.prompt == Prompt.Batch + val isWatch = terminal.prompt == Prompt.Watch + val noPrompt = terminal.prompt == Prompt.NoPrompt + if (terminal.isSupershellEnabled) { + if (!pe.skipIfActive.getOrElse(false) || (!isRunning && !isBatch)) { + terminal.withPrintStream { ps => + val info = + if ((isRunning || isBatch || noPrompt) && pe.channelName + .fold(true)(_ == terminal.name)) { + pe.items.map { item => + val elapsed = item.elapsedMicros / 1000000L + s" | => ${item.name} ${elapsed}s" + } + } else { + pe.command.toSeq.flatMap { cmd => + val tail = if (isWatch) Nil else "enter 'cancel' to stop evaluation" :: Nil + s"sbt server is running '$cmd'" :: tail + } + } + + val currentLength = info.foldLeft(0)(_ + terminal.lineCount(_)) + val previousLines = state.progressLines.getAndSet(info) + val prevLength = previousLines.foldLeft(0)(_ + terminal.lineCount(_)) + val lastLine = terminal.prompt match { + case Prompt.Running | Prompt.Batch => terminal.getLastLine.getOrElse("") + case a => a.render() + } + val prevSize = prevLength + state.padding.get + + val newPadding = math.max(0, prevSize - currentLength) + state.padding.set(newPadding) + state.printPrompt(terminal, ps) + ps.print(state.printProgress(terminal, lastLine)) + ps.flush() + } + } else if (state.progressLines.get.nonEmpty) { + state.progressLines.set(Nil) + terminal.withPrintStream { ps => + val lastLine = terminal.getLastLine.getOrElse("") + ps.print(lastLine + ClearScreenAfterCursor) + ps.flush() + } + } + } + } +} From 2ecf5967eeec67cb9690b6f94e08d33d29046797 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Tue, 30 Jun 2020 08:57:57 -0700 Subject: [PATCH 11/17] Upgrade LineReader to JLine3 This commit upgrades sbt to using jline3. The advantage to jline3 is that it has a significantly better tab completion engine that is more similar to what you get from zsh or fish. The diff is bigger than I'd hoped because there are a number of behaviors that are different in jline3 vs jline2 in how the library consumes input streams and implements various features. I also was unable to remove jline2 because we need it for older versions of the scala console to work correctly with the thin client. As a result, the changes are largely additive. A good amount of this commit was in adding more protocol so that the remote client can forward its jline3 terminal information to the server. There were a number of minor changes that I made that either fixed outstanding ui bugs from #5620 or regressions due to differences between jline3 and jline2. The number one thing that caused problems is that the jline3 LineReader insists on using a NonBlockingInputStream. The implementation ofo NonBlockingInputStream seems buggy. Moreover, sbt internally uses a non blocking input stream for system in so jline is adding non blocking to an already non blocking stream, which is frustrating. A long term solution might be to consider insourcing LineReader.java from jline3 and just adapting it to use an sbt terminal rather than fighting with the jline3 api. This would also have the advantage of not conflicting with other versions of jline3. Even if we don't, we may want to shade jline3 if that is possible. --- build.sbt | 16 +- .../native-image/resource-config.json | 15 +- .../scala/sbt/internal/util/LineReader.scala | 79 ++++- .../sbt/internal/util/ConsoleAppender.scala | 4 +- .../scala/sbt/internal/util/EscHelpers.scala | 3 +- .../main/scala/sbt/internal/util/JLine3.scala | 223 ++++++++++++++ .../sbt/internal/util/ProgressState.scala | 100 ++++--- .../main/scala/sbt/internal/util/Prompt.scala | 26 +- .../scala/sbt/internal/util/Terminal.scala | 281 ++++++++++-------- .../sbt/internal/util/CleanStringSpec.scala | 7 + .../sbt/internal/client/NetworkClient.scala | 147 ++++++--- .../main/scala/sbt/internal/ui/UITask.scala | 63 ++-- .../java/sbt/internal/MetaBuildLoader.java | 2 +- main/src/main/scala/sbt/Defaults.scala | 6 +- main/src/main/scala/sbt/Main.scala | 2 +- .../scala/sbt/internal/CommandExchange.scala | 21 +- .../main/scala/sbt/internal/Continuous.scala | 1 - .../scala/sbt/internal/TaskProgress.scala | 3 +- .../sbt/internal/server/NetworkChannel.scala | 76 ++++- .../sbt/internal/server/VirtualTerminal.scala | 67 ++++- project/Dependencies.scala | 4 +- .../protocol/TerminalAttributesQuery.scala | 29 ++ .../protocol/TerminalAttributesResponse.scala | 48 +++ .../protocol/TerminalCapabilitiesQuery.scala | 20 +- .../TerminalSetAttributesCommand.scala | 48 +++ .../TerminalSetAttributesResponse.scala | 29 ++ .../sbt/protocol/TerminalSetSizeCommand.scala | 36 +++ .../protocol/TerminalSetSizeResponse.scala | 29 ++ .../codec/CommandMessageFormats.scala | 4 +- .../protocol/codec/EventMessageFormats.scala | 4 +- .../sbt/protocol/codec/JsonProtocol.scala | 6 + .../TerminalAttributesQueryFormats.scala | 27 ++ .../TerminalAttributesResponseFormats.scala | 35 +++ .../TerminalCapabilitiesQueryFormats.scala | 4 +- .../TerminalSetAttributesCommandFormats.scala | 35 +++ ...TerminalSetAttributesResponseFormats.scala | 27 ++ .../codec/TerminalSetSizeCommandFormats.scala | 29 ++ .../TerminalSetSizeResponseFormats.scala | 27 ++ protocol/src/main/contraband/server.contra | 52 +++- .../scala/sbt/protocol/Serialization.scala | 4 + 40 files changed, 1330 insertions(+), 309 deletions(-) create mode 100644 internal/util-logging/src/main/scala/sbt/internal/util/JLine3.scala create mode 100644 protocol/src/main/contraband-scala/sbt/protocol/TerminalAttributesQuery.scala create mode 100644 protocol/src/main/contraband-scala/sbt/protocol/TerminalAttributesResponse.scala create mode 100644 protocol/src/main/contraband-scala/sbt/protocol/TerminalSetAttributesCommand.scala create mode 100644 protocol/src/main/contraband-scala/sbt/protocol/TerminalSetAttributesResponse.scala create mode 100644 protocol/src/main/contraband-scala/sbt/protocol/TerminalSetSizeCommand.scala create mode 100644 protocol/src/main/contraband-scala/sbt/protocol/TerminalSetSizeResponse.scala create mode 100644 protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalAttributesQueryFormats.scala create mode 100644 protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalAttributesResponseFormats.scala create mode 100644 protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetAttributesCommandFormats.scala create mode 100644 protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetAttributesResponseFormats.scala create mode 100644 protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetSizeCommandFormats.scala create mode 100644 protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetSizeResponseFormats.scala diff --git a/build.sbt b/build.sbt index 7faaeef45..569d63969 100644 --- a/build.sbt +++ b/build.sbt @@ -289,6 +289,7 @@ val completeProj = (project in file("internal") / "util-complete") testedBaseSettings, name := "Completion", libraryDependencies += jline, + libraryDependencies += jline3, mimaSettings, // Parser is used publicly, so we can't break bincompat. mimaBinaryIssueFilters := Seq( @@ -343,12 +344,20 @@ lazy val utilPosition = (project in file("internal") / "util-position") lazy val utilLogging = (project in file("internal") / "util-logging") .enablePlugins(ContrabandPlugin, JsonCodecPlugin) - .dependsOn(utilInterface) + .dependsOn(utilInterface, collectionProj) .settings( utilCommonSettings, name := "Util Logging", libraryDependencies ++= - Seq(jline, log4jApi, log4jCore, disruptor, sjsonNewScalaJson.value, scalaReflect.value), + Seq( + jline, + jline3, + log4jApi, + log4jCore, + disruptor, + sjsonNewScalaJson.value, + scalaReflect.value + ), libraryDependencies ++= Seq(scalacheck % "test", scalatest % "test"), libraryDependencies ++= (scalaVersion.value match { case v if v.startsWith("2.12.") => List(compilerPlugin(silencerPlugin)) @@ -1047,8 +1056,7 @@ lazy val sbtClientProj = (project in file("client")) crossPaths := false, exportJars := true, libraryDependencies += jansi, - libraryDependencies += "net.java.dev.jna" % "jna" % "5.5.0", - libraryDependencies += "net.java.dev.jna" % "jna-platform" % "5.5.0", + libraryDependencies += jline3Jansi, libraryDependencies += scalatest % "test", /* * On windows, the raw classpath is too large to be a command argument to an diff --git a/client/src/main/resources/META-INF/native-image/resource-config.json b/client/src/main/resources/META-INF/native-image/resource-config.json index 26dd35cca..0bc65dde4 100644 --- a/client/src/main/resources/META-INF/native-image/resource-config.json +++ b/client/src/main/resources/META-INF/native-image/resource-config.json @@ -1,7 +1,20 @@ { "resources":[ {"pattern":"jline/console/completer/CandidateListCompletionHandler.properties"}, - {"pattern":"library.properties"}, + {"pattern":"org/jline/utils/ansi.caps"}, + {"pattern":"org/jline/utils/capabilities.txt"}, + {"pattern":"org/jline/utils/colors.txt"}, + {"pattern":"org/jline/utils/dumb-color.caps"}, + {"pattern":"org/jline/utils/xterm.caps"}, + {"pattern":"org/jline/utils/xterm-256color.caps"}, + {"pattern":"org/jline/utils/windows-256color.caps"}, + {"pattern":"org/jline/utils/screen-256color.caps"}, + {"pattern":"org/jline/utils/windows.caps"}, + {"pattern":"org/jline/utils/windows-conemu.caps"}, + {"pattern":"org/jline/utils/dumb.caps"}, + {"pattern":"org/jline/utils/windows-vtp.caps"}, + {"pattern":"org/jline/utils/screen.caps"}, + {"pattern":"library.properties"}, {"pattern":"darwin/x86_64/libsbtipcsocket.dylib"}, {"pattern":"linux/x86_64/libsbtipcsocket.so"}, {"pattern":"win32/x86_64/sbtipcsocket.dll"} diff --git a/internal/util-complete/src/main/scala/sbt/internal/util/LineReader.scala b/internal/util-complete/src/main/scala/sbt/internal/util/LineReader.scala index bac94c1de..836a0ba6d 100644 --- a/internal/util-complete/src/main/scala/sbt/internal/util/LineReader.scala +++ b/internal/util-complete/src/main/scala/sbt/internal/util/LineReader.scala @@ -8,16 +8,28 @@ package sbt.internal.util import java.io._ +import java.util.{ List => JList } import jline.console.ConsoleReader import jline.console.history.{ FileHistory, MemoryHistory } +import org.jline.reader.{ + Candidate, + Completer, + EndOfFileException, + LineReader => JLineReader, + LineReaderBuilder, + ParsedLine, + UserInterruptException, +} import sbt.internal.util.complete.Parser import scala.annotation.tailrec import scala.concurrent.duration._ +import java.nio.channels.ClosedByInterruptException -trait LineReader { +trait LineReader extends AutoCloseable { def readLine(prompt: String, mask: Option[Char] = None): Option[String] + override def close(): Unit = {} } object LineReader { @@ -25,7 +37,67 @@ object LineReader { !java.lang.Boolean.getBoolean("sbt.disable.cont") && Signals.supported(Signals.CONT) val MaxHistorySize = 500 + private def completer(parser: Parser[_]): Completer = new Completer { + def complete(lr: JLineReader, pl: ParsedLine, candidates: JList[Candidate]): Unit = { + Parser.completions(parser, pl.line(), 10).get.foreach { c => + /* + * For commands like `~` that delegate parsing to another parser, the `~` may be + * excluded from the completion result. For example, + * ~testOnly + * might return results like + * 'testOnly ;' + * 'testOnly com.foo.FooSpec' + * ... + * If we use the raw display, JLine will reject the completions because they are + * missing the leading `~`. To workaround this, we append to the result to the + * line provided the line does not end with " ". This fixes the missing `~` in + * the prefix problem. We also need to split the line on space and take the + * last token and append to that otherwise the completion will double print + * the prefix, so that `testOnly com` might expand to something like: + * `testOnly testOnly\ com.foo.FooSpec` instead of `testOnly com.foo.FooSpec`. + */ + if (c.append.nonEmpty) { + if (!pl.line().endsWith(" ")) { + candidates.add(new Candidate(pl.line().split(" ").last + c.append)) + } else { + candidates.add(new Candidate(c.append)) + } + } + } + } + } def createReader( + historyPath: Option[File], + parser: Parser[_], + terminal: Terminal, + prompt: Prompt = Prompt.Running, + ): LineReader = { + val term = JLine3(terminal) + // We may want to consider insourcing LineReader.java from jline. We don't otherwise + // directly need jline3 for sbt. + val reader = LineReaderBuilder.builder().terminal(term).completer(completer(parser)).build() + historyPath.foreach(f => reader.setVariable(JLineReader.HISTORY_FILE, f)) + new LineReader { + override def readLine(prompt: String, mask: Option[Char]): Option[String] = { + try terminal.withRawSystemIn { + Option(mask.map(reader.readLine(prompt, _)).getOrElse(reader.readLine(prompt))) + } catch { + case e: EndOfFileException => + if (terminal == Terminal.console && System.console == null) None + else Some("exit") + case _: IOError => Some("exit") + case _: UserInterruptException | _: ClosedByInterruptException | + _: UncheckedIOException => + throw new InterruptedException + } finally { + terminal.prompt.reset() + term.close() + } + } + } + } + + def createJLine2Reader( historyPath: Option[File], terminal: Terminal, prompt: Prompt = Prompt.Running, @@ -42,7 +114,6 @@ object LineReader { cr.setHistoryEnabled(true) cr } - def simple(terminal: Terminal): LineReader = new SimpleReader(None, HandleCONT, terminal) def simple( historyPath: Option[File], @@ -230,7 +301,7 @@ final class FullReader( Terminal.console ) protected[this] val reader: ConsoleReader = { - val cr = LineReader.createReader(historyPath, terminal) + val cr = LineReader.createJLine2Reader(historyPath, terminal) sbt.internal.util.complete.JLineCompletion.installCustomCompletor(cr, complete) cr } @@ -244,7 +315,7 @@ class SimpleReader private[sbt] ( def this(historyPath: Option[File], handleCONT: Boolean, injectThreadSleep: Boolean) = this(historyPath, handleCONT, Terminal.console) protected[this] val reader: ConsoleReader = - LineReader.createReader(historyPath, terminal) + LineReader.createJLine2Reader(historyPath, terminal) } object SimpleReader extends SimpleReader(None, LineReader.HandleCONT, false) { diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala b/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala index c89a51204..c6eac168a 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala @@ -9,6 +9,7 @@ package sbt.internal.util import java.io.{ PrintStream, PrintWriter } import java.lang.StringBuilder +import java.nio.channels.ClosedChannelException import java.util.concurrent.atomic.{ AtomicBoolean, AtomicInteger } import org.apache.logging.log4j.core.appender.AbstractAppender @@ -394,7 +395,8 @@ class ConsoleAppender private[ConsoleAppender] ( override def append(event: XLogEvent): Unit = { val level = ConsoleAppender.toLevel(event.getLevel) val message = event.getMessage - appendMessage(level, message) + try appendMessage(level, message) + catch { case _: ClosedChannelException => } } /** diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/EscHelpers.scala b/internal/util-logging/src/main/scala/sbt/internal/util/EscHelpers.scala index 44d00af8c..19af0024a 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/EscHelpers.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/EscHelpers.scala @@ -181,9 +181,10 @@ object EscHelpers { else res(index) = 32 case 'm' => case ';' => state = csi - case _ => + case b => state = csi } digit.clear() + case b if state == esc => state = 0 case b => res(index) = b index += 1 diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/JLine3.scala b/internal/util-logging/src/main/scala/sbt/internal/util/JLine3.scala new file mode 100644 index 000000000..eadd09ae6 --- /dev/null +++ b/internal/util-logging/src/main/scala/sbt/internal/util/JLine3.scala @@ -0,0 +1,223 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal.util + +import java.io.{ EOFException, InputStream, OutputStream, PrintWriter } +import java.nio.charset.Charset +import java.util.{ Arrays, EnumSet } +import java.util.concurrent.atomic.AtomicBoolean +import org.jline.utils.InfoCmp.Capability +import org.jline.utils.{ NonBlocking, OSUtils } +import org.jline.terminal.{ Attributes, Size, Terminal => JTerminal } +import org.jline.terminal.Terminal.SignalHandler +import org.jline.terminal.impl.AbstractTerminal +import org.jline.terminal.impl.jansi.JansiSupportImpl +import org.jline.terminal.impl.jansi.win.JansiWinSysTerminal +import scala.collection.JavaConverters._ +import scala.util.Try + +private[util] object JLine3 { + private val capabilityMap = Capability + .values() + .map { c => + c.toString -> c + } + .toMap + + private[util] def system = { + /* + * For reasons that are unclear to me, TerminalBuilder fails to build + * windows terminals. The instructions about the classpath did not work: + * https://stackoverflow.com/questions/52851232/jline3-issues-with-windows-terminal + * We can deconstruct what TerminalBuilder does and inline it for now. + * It is possible that this workaround will break WSL but I haven't checked that. + */ + if (Util.isNonCygwinWindows) { + val support = new JansiSupportImpl + val winConsole = support.isWindowsConsole(); + try { + val term = JansiWinSysTerminal.createTerminal( + "console", + "ansi", + OSUtils.IS_CONEMU, + Charset.forName("UTF-8"), + -1, + false, + SignalHandler.SIG_DFL, + true + ) + term.disableScrolling() + term + } catch { + case _: Exception => + org.jline.terminal.TerminalBuilder + .builder() + .system(false) + .paused(true) + .jansi(true) + .streams(Terminal.console.inputStream, Terminal.console.outputStream) + .build() + } + } else { + org.jline.terminal.TerminalBuilder + .builder() + .system(System.console != null) + .paused(true) + .jna(false) + .jansi(true) + .build() + } + } + private[sbt] def apply(term: Terminal): JTerminal = { + new AbstractTerminal(term.name, "ansi", Charset.forName("UTF-8"), SignalHandler.SIG_DFL) { + val closed = new AtomicBoolean(false) + setOnClose { () => + if (closed.compareAndSet(false, true)) { + // This is necessary to shutdown the non blocking input reader + // so that it doesn't keep blocking + term.inputStream match { + case w: Terminal.WriteableInputStream => w.cancel() + case _ => + } + } + } + parseInfoCmp() + override val input: InputStream = new InputStream { + override def read: Int = { + val res = try term.inputStream.read + catch { case _: InterruptedException => -2 } + if (res == 4 && term.prompt.render().endsWith(term.prompt.mkPrompt())) + throw new EOFException + res + } + } + override val output: OutputStream = new OutputStream { + override def write(b: Int): Unit = write(Array[Byte](b.toByte)) + override def write(b: Array[Byte]): Unit = if (!closed.get) term.withPrintStream { ps => + term.prompt match { + case a: Prompt.AskUser => a.write(b) + case _ => + } + ps.write(b) + } + override def write(b: Array[Byte], offset: Int, len: Int) = + write(Arrays.copyOfRange(b, offset, offset + len)) + override def flush(): Unit = term.withPrintStream(_.flush()) + } + + override val reader = + NonBlocking.nonBlocking(term.name, input, Charset.defaultCharset()) + override val writer: PrintWriter = new PrintWriter(output, true) + /* + * For now assume that the terminal capabilities for client and server + * are the same. + */ + override def getStringCapability(cap: Capability): String = { + term.getStringCapability(cap.toString, jline3 = true) + } + override def getNumericCapability(cap: Capability): Integer = { + term.getNumericCapability(cap.toString, jline3 = true) + } + override def getBooleanCapability(cap: Capability): Boolean = { + term.getBooleanCapability(cap.toString, jline3 = true) + } + def getAttributes(): Attributes = attributesFromMap(term.getAttributes) + def getSize(): Size = new Size(term.getWidth, term.getHeight) + def setAttributes(a: Attributes): Unit = term.setAttributes(toMap(a)) + def setSize(size: Size): Unit = term.setSize(size.getColumns, size.getRows) + + /** + * Override enterRawMode because the default implementation modifies System.in + * to be non-blocking which means it immediately returns -1 if there is no + * data available, which is not desirable for us. + */ + override def enterRawMode(): Attributes = enterRawModeImpl(this) + } + } + private def enterRawModeImpl(term: JTerminal): Attributes = { + val prvAttr = term.getAttributes() + val newAttr = new Attributes(prvAttr) + newAttr.setLocalFlags( + EnumSet + .of(Attributes.LocalFlag.ICANON, Attributes.LocalFlag.ECHO, Attributes.LocalFlag.IEXTEN), + false + ) + newAttr.setInputFlags( + EnumSet + .of(Attributes.InputFlag.IXON, Attributes.InputFlag.ICRNL, Attributes.InputFlag.INLCR), + false + ) + term.setAttributes(newAttr) + prvAttr + } + private[util] def enterRawMode(term: JTerminal): Map[String, String] = + toMap(enterRawModeImpl(term)) + private[util] def toMap(jattributes: Attributes): Map[String, String] = { + val result = new java.util.LinkedHashMap[String, String] + result.put( + "iflag", + jattributes.getInputFlags.iterator.asScala.map(_.name.toLowerCase).mkString(" ") + ) + result.put( + "oflag", + jattributes.getOutputFlags.iterator.asScala.map(_.name.toLowerCase).mkString(" ") + ) + result.put( + "cflag", + jattributes.getControlFlags.iterator.asScala.map(_.name.toLowerCase).mkString(" ") + ) + result.put( + "lflag", + jattributes.getLocalFlags.iterator.asScala.map(_.name.toLowerCase).mkString(" ") + ) + result.put( + "cchars", + jattributes.getControlChars.entrySet.iterator.asScala + .map { e => + s"${e.getKey.name.toLowerCase},${e.getValue}" + } + .mkString(" ") + ) + result.asScala.toMap + } + private[this] val iflagMap: Map[String, Attributes.InputFlag] = + Attributes.InputFlag.values.map(f => f.name.toLowerCase -> f).toMap + private[this] val oflagMap: Map[String, Attributes.OutputFlag] = + Attributes.OutputFlag.values.map(f => f.name.toLowerCase -> f).toMap + private[this] val cflagMap: Map[String, Attributes.ControlFlag] = + Attributes.ControlFlag.values.map(f => f.name.toLowerCase -> f).toMap + private[this] val lflagMap: Map[String, Attributes.LocalFlag] = + Attributes.LocalFlag.values.map(f => f.name.toLowerCase -> f).toMap + private[this] val charMap: Map[String, Attributes.ControlChar] = + Attributes.ControlChar.values().map(f => f.name.toLowerCase -> f).toMap + private[util] def attributesFromMap(map: Map[String, String]): Attributes = { + val attributes = new Attributes + map.get("iflag").foreach { flags => + flags.split(" ").foreach(f => iflagMap.get(f).foreach(attributes.setInputFlag(_, true))) + } + map.get("oflag").foreach { flags => + flags.split(" ").foreach(f => oflagMap.get(f).foreach(attributes.setOutputFlag(_, true))) + } + map.get("cflag").foreach { flags => + flags.split(" ").foreach(f => cflagMap.get(f).foreach(attributes.setControlFlag(_, true))) + } + map.get("lflag").foreach { flags => + flags.split(" ").foreach(f => lflagMap.get(f).foreach(attributes.setLocalFlag(_, true))) + } + map.get("cchars").foreach { chars => + chars.split(" ").foreach { keyValue => + keyValue.split(",") match { + case Array(k, v) => + Try(v.toInt).foreach(i => charMap.get(k).foreach(c => attributes.setControlChar(c, i))) + case _ => + } + } + } + attributes + } +} diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/ProgressState.scala b/internal/util-logging/src/main/scala/sbt/internal/util/ProgressState.scala index 13332812b..d8b77ef53 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/ProgressState.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/ProgressState.scala @@ -14,7 +14,6 @@ import sbt.internal.util.ConsoleAppender.{ ClearScreenAfterCursor, CursorLeft1000, DeleteLine, - cursorLeft, cursorUp, } @@ -33,6 +32,10 @@ private[sbt] final class ProgressState( blankZone, new AtomicReference(new ArrayBuffer[Byte]), ) + def currentLine: Option[String] = + new String(currentLineBytes.get.toArray, "UTF-8").linesIterator.toSeq.lastOption + .map(EscHelpers.stripColorsAndMoves) + .filter(_.nonEmpty) def reset(): Unit = { progressLines.set(Nil) padding.set(0) @@ -44,8 +47,9 @@ private[sbt] final class ProgressState( currentLineBytes.set(new ArrayBuffer[Byte]) } - private[util] def addBytes(terminal: Terminal, bytes: ArrayBuffer[Byte]): Unit = { - val previous = currentLineBytes.get + private[this] val lineSeparatorBytes: Array[Byte] = System.lineSeparator.getBytes("UTF-8") + private[util] def addBytes(terminal: Terminal, bytes: Seq[Byte]): Unit = { + val previous: ArrayBuffer[Byte] = currentLineBytes.get val padding = this.padding.get val prevLineCount = if (padding > 0) terminal.lineCount(new String(previous.toArray)) else 0 previous ++= bytes @@ -54,6 +58,16 @@ private[sbt] final class ProgressState( val diff = newLineCount - prevLineCount this.padding.set(math.max(padding - diff, 0)) } + val lines = new String(previous.toArray, "UTF-8") + if (lines.contains(System.lineSeparator)) { + currentLineBytes.set(new ArrayBuffer[Byte]) + if (!lines.endsWith(System.lineSeparator)) { + lines + .split(System.lineSeparator) + .lastOption + .foreach(currentLineBytes.get ++= _.getBytes("UTF-8")) + } + } } private[util] def printPrompt(terminal: Terminal, printStream: PrintStream): Unit = @@ -62,31 +76,50 @@ private[sbt] final class ProgressState( val pmpt = prefix.getBytes ++ terminal.prompt.render().getBytes pmpt.foreach(b => printStream.write(b & 0xFF)) } - private[util] def reprint(terminal: Terminal, printStream: PrintStream): Unit = { - printPrompt(terminal, printStream) - if (progressLines.get.nonEmpty) { - val lines = printProgress(terminal, terminal.getLastLine.getOrElse("")) - printStream.print(ClearScreenAfterCursor + lines) - } + private[util] def write( + terminal: Terminal, + bytes: Array[Byte], + printStream: PrintStream, + hasProgress: Boolean + ): Unit = { + addBytes(terminal, bytes) + if (hasProgress && terminal.prompt != Prompt.Loading) { + terminal.prompt match { + case a: Prompt.AskUser if a.render.nonEmpty => + printStream.print(System.lineSeparator + ClearScreenAfterCursor + CursorLeft1000) + printStream.flush() + case _ => + } + printStream.write(bytes) + printStream.write(ClearScreenAfterCursor.getBytes("UTF-8")) + printStream.flush() + if (bytes.endsWith(lineSeparatorBytes)) { + if (progressLines.get.nonEmpty) { + val lastLine = terminal.prompt match { + case a: Prompt.AskUser => a.render() + case _ => currentLine.getOrElse("") + } + val lines = printProgress(terminal, lastLine) + printStream.print(ClearScreenAfterCursor + lines) + } + } + printPrompt(terminal, printStream) + } else printStream.write(bytes) } - private[util] def printProgress( - terminal: Terminal, - lastLine: String - ): String = { + private[util] def printProgress(terminal: Terminal, lastLine: String): String = { val previousLines = progressLines.get if (previousLines.nonEmpty) { val currentLength = previousLines.foldLeft(0)(_ + terminal.lineCount(_)) val (height, width) = terminal.getLineHeightAndWidth(lastLine) - val left = cursorLeft(1000) // resets the position to the left val offset = width > 0 val pad = math.max(padding.get - height, 0) - val start = (if (offset) "\n" else "") + val start = (if (offset) s"\n$CursorLeft1000" else "") val totalSize = currentLength + blankZone + pad - val blank = left + s"\n$DeleteLine" * (totalSize - currentLength) + val blank = CursorLeft1000 + s"\n$DeleteLine" * (totalSize - currentLength) val lines = previousLines.mkString(DeleteLine, s"\n$DeleteLine", s"\n$DeleteLine") val resetCursorUp = cursorUp(totalSize + (if (offset) 1 else 0)) - val resetCursor = resetCursorUp + left + lastLine + val resetCursor = resetCursorUp + CursorLeft1000 + lastLine start + blank + lines + resetCursor } else { ClearScreenAfterCursor @@ -108,6 +141,7 @@ private[sbt] object ProgressState { terminal: Terminal ): Unit = { val state = terminal.progressState + val isAskUser = terminal.prompt.isInstanceOf[Prompt.AskUser] val isRunning = terminal.prompt == Prompt.Running val isBatch = terminal.prompt == Prompt.Batch val isWatch = terminal.prompt == Prompt.Watch @@ -115,31 +149,27 @@ private[sbt] object ProgressState { if (terminal.isSupershellEnabled) { if (!pe.skipIfActive.getOrElse(false) || (!isRunning && !isBatch)) { terminal.withPrintStream { ps => - val info = - if ((isRunning || isBatch || noPrompt) && pe.channelName - .fold(true)(_ == terminal.name)) { - pe.items.map { item => - val elapsed = item.elapsedMicros / 1000000L - s" | => ${item.name} ${elapsed}s" - } - } else { - pe.command.toSeq.flatMap { cmd => - val tail = if (isWatch) Nil else "enter 'cancel' to stop evaluation" :: Nil - s"sbt server is running '$cmd'" :: tail - } + val commandFromThisTerminal = pe.channelName.fold(true)(_ == terminal.name) + val info = if ((isRunning || isBatch || noPrompt) && commandFromThisTerminal) { + pe.items.map { item => + val elapsed = item.elapsedMicros / 1000000L + s" | => ${item.name} ${elapsed}s" } + } else { + pe.command.toSeq.flatMap { cmd => + val tail = if (isWatch) Nil else "enter 'cancel' to stop evaluation" :: Nil + s"sbt server is running '$cmd'" :: tail + } + } val currentLength = info.foldLeft(0)(_ + terminal.lineCount(_)) val previousLines = state.progressLines.getAndSet(info) val prevLength = previousLines.foldLeft(0)(_ + terminal.lineCount(_)) - val lastLine = terminal.prompt match { - case Prompt.Running | Prompt.Batch => terminal.getLastLine.getOrElse("") - case a => a.render() - } val prevSize = prevLength + state.padding.get - val newPadding = math.max(0, prevSize - currentLength) - state.padding.set(newPadding) + val lastLine = + if (isAskUser) terminal.prompt.render() else terminal.getLastLine.getOrElse("") + state.padding.set(math.max(0, prevSize - currentLength)) state.printPrompt(terminal, ps) ps.print(state.printProgress(terminal, lastLine)) ps.flush() diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala b/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala index bd50e9e75..eb5e4a660 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala @@ -7,39 +7,29 @@ package sbt.internal.util -import java.io.OutputStream import java.util.concurrent.LinkedBlockingQueue - import scala.collection.JavaConverters._ private[sbt] sealed trait Prompt { def mkPrompt: () => String def render(): String - def wrappedOutputStream(terminal: Terminal): OutputStream + def reset(): Unit } private[sbt] object Prompt { private[sbt] case class AskUser(override val mkPrompt: () => String) extends Prompt { - private[this] val bytes = new LinkedBlockingQueue[Int] - override def wrappedOutputStream(terminal: Terminal): OutputStream = new OutputStream { - override def write(b: Int): Unit = { - if (b == 10) bytes.clear() - else bytes.put(b) - terminal.withPrintStream { p => - p.write(b) - p.flush() - } - } - override def flush(): Unit = terminal.withPrintStream(_.flush()) + private[this] val bytes = new LinkedBlockingQueue[Byte] + def write(b: Array[Byte]): Unit = b.foreach(bytes.put) + override def render(): String = { + val res = new String(bytes.asScala.toArray, "UTF-8") + if (res.endsWith(System.lineSeparator)) "" else res } - - override def render(): String = - EscHelpers.stripMoves(new String(bytes.asScala.toArray.map(_.toByte))) + override def reset(): Unit = bytes.clear() } private[sbt] trait NoPrompt extends Prompt { override val mkPrompt: () => String = () => "" override def render(): String = "" - override def wrappedOutputStream(terminal: Terminal): OutputStream = terminal.outputStream + override def reset(): Unit = {} } private[sbt] case object Running extends NoPrompt private[sbt] case object Batch extends NoPrompt diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala index f8c9328d7..d59679ee5 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala @@ -7,19 +7,17 @@ package sbt.internal.util -import java.io.{ InputStream, OutputStream, PrintStream } +import java.io.{ InputStream, InterruptedIOException, OutputStream, PrintStream } import java.nio.channels.ClosedChannelException -import java.util.Locale +import java.util.{ Arrays, Locale } import java.util.concurrent.atomic.{ AtomicBoolean, AtomicReference } -import java.util.concurrent.{ ConcurrentHashMap, Executors, LinkedBlockingQueue, TimeUnit } +import java.util.concurrent.{ ArrayBlockingQueue, Executors, LinkedBlockingQueue, TimeUnit } import jline.DefaultTerminal2 import jline.console.ConsoleReader -import sbt.internal.util.ConsoleAppender.{ ClearScreenAfterCursor, CursorLeft1000 } - import scala.annotation.tailrec -import scala.collection.mutable.ArrayBuffer import scala.util.Try +import scala.util.control.NonFatal trait Terminal extends AutoCloseable { @@ -111,9 +109,12 @@ trait Terminal extends AutoCloseable { */ private[sbt] def getLastLine: Option[String] - private[sbt] def getBooleanCapability(capability: String): Boolean - private[sbt] def getNumericCapability(capability: String): Int - private[sbt] def getStringCapability(capability: String): String + private[sbt] def getBooleanCapability(capability: String, jline3: Boolean): Boolean + private[sbt] def getNumericCapability(capability: String, jline3: Boolean): Integer + private[sbt] def getStringCapability(capability: String, jline3: Boolean): String + private[sbt] def getAttributes: Map[String, String] + private[sbt] def setAttributes(attributes: Map[String, String]): Unit + private[sbt] def setSize(width: Int, height: Int): Unit private[sbt] def name: String private[sbt] def withRawSystemIn[T](f: => T): T = f @@ -142,7 +143,8 @@ trait Terminal extends AutoCloseable { val len = l.length if (width > 0 && len > 0) (len - 1 + width) / width else 0 } - lines.tail.foldLeft(lines.headOption.fold(0)(count))(_ + count(_)) + if (lines.nonEmpty) lines.tail.foldLeft(lines.headOption.fold(0)(count))(_ + count(_)) + else 0 } } @@ -188,13 +190,13 @@ object Terminal { override def enableInterruptCharacter(): Unit = {} override def getOutputEncoding: String = null override def getBooleanCapability(capability: String): Boolean = { - term.getBooleanCapability(capability) + term.getBooleanCapability(capability, jline3 = false) } override def getNumericCapability(capability: String): Integer = { - term.getNumericCapability(capability) + term.getNumericCapability(capability, jline3 = false) } override def getStringCapability(capability: String): String = { - term.getStringCapability(capability) + term.getStringCapability(capability, jline3 = false) } } } @@ -253,18 +255,45 @@ object Terminal { */ private[sbt] def restore(): Unit = console.toJLine.restore() + private[this] val hasProgress: AtomicBoolean = new AtomicBoolean(false) + /** * + * @param progress toggles whether or not the console terminal has progress * @param f the thunk to run * @tparam T the result type of the thunk * @return the result of the thunk */ - private[sbt] def withStreams[T](f: => T): T = + private[sbt] def withStreams[T](isServer: Boolean)(f: => T): T = if (System.getProperty("sbt.io.virtual", "true") == "true") { + hasProgress.set(isServer) try withOut(withIn(f)) finally { jline.TerminalFactory.reset() - console.close() + if (isServer) { + console match { + case c: ConsoleTerminal if !isWindows => + /* + * Entering raw mode in this way causes the standard in InputStream + * to become non-blocking. After we set it to non-blocking, we spin + * up a thread that reads from the inputstream and the resets it + * back to blocking mode. We can then close the console. We do + * this on a background thread to avoid blocking sbt's exit. + */ + val prev = c.system.enterRawMode() + val runnable: Runnable = () => { + c.inputStream.read() + c.system.setAttributes(prev) + c.close() + } + val thread = new Thread(runnable, "sbt-console-background-close") + thread.setDaemon(true) + thread.start() + case c => c.close() + } + } else { + console.close() + } } } else f @@ -281,10 +310,16 @@ object Terminal { override def isEchoEnabled: Boolean = t.isEchoEnabled override def isSuccessEnabled: Boolean = t.isSuccessEnabled override def isSupershellEnabled: Boolean = t.isSupershellEnabled - override def getBooleanCapability(capability: String): Boolean = - t.getBooleanCapability(capability) - override def getNumericCapability(capability: String): Int = t.getNumericCapability(capability) - override def getStringCapability(capability: String): String = t.getStringCapability(capability) + override def getBooleanCapability(capability: String, jline3: Boolean): Boolean = + t.getBooleanCapability(capability, jline3) + override def getNumericCapability(capability: String, jline3: Boolean): Integer = + t.getNumericCapability(capability, jline3) + override def getStringCapability(capability: String, jline3: Boolean): String = + t.getStringCapability(capability, jline3) + override private[sbt] def getAttributes: Map[String, String] = t.getAttributes + override private[sbt] def setAttributes(attributes: Map[String, String]): Unit = + t.setAttributes(attributes) + override private[sbt] def setSize(width: Int, height: Int): Unit = t.setSize(width, height) override def withRawSystemIn[T](f: => T): T = t.withRawSystemIn(f) override def withCanonicalIn[T](f: => T): T = t.withCanonicalIn(f) override def printStream: PrintStream = t.printStream @@ -322,18 +357,35 @@ object Terminal { } } - private[this] val originalOut = System.out + val sepBytes = System.lineSeparator.getBytes("UTF-8") + private class LinePrintStream(outputStream: OutputStream) + extends PrintStream(outputStream, true) { + override def println(s: String): Unit = synchronized { + out.write(s.getBytes("UTF-8") ++ sepBytes) + out.flush() + } + } + private[this] val originalOut = new LinePrintStream(System.out) private[this] val originalIn = System.in private[sbt] class WriteableInputStream(in: InputStream, name: String) extends InputStream with AutoCloseable { - final def write(bytes: Int*): Unit = bytes.foreach(i => buffer.put(i)) + final def write(bytes: Int*): Unit = waiting.synchronized { + waiting.poll match { + case null => + bytes.foreach(b => buffer.put(b)) + case w => + if (bytes.length > 1) bytes.tail.foreach(b => buffer.put(b)) + bytes.headOption.foreach(b => w.put(b)) + } + } private[this] val executor = Executors.newSingleThreadExecutor(r => new Thread(r, s"sbt-$name-input-reader")) private[this] val buffer = new LinkedBlockingQueue[Integer] private[this] val closed = new AtomicBoolean(false) - private[this] val resultQueue = new LinkedBlockingQueue[LinkedBlockingQueue[Int]] - private[this] val waiting = ConcurrentHashMap.newKeySet[LinkedBlockingQueue[Int]] + private[this] val readQueue = new LinkedBlockingQueue[Unit] + private[this] val waiting = new ArrayBlockingQueue[LinkedBlockingQueue[Integer]](1) + private[this] val readThread = new AtomicReference[Thread] /* * Starts a loop that waits for consumers of the InputStream to call read. * When read is called, we enqueue a `LinkedBlockingQueue[Int]` to which @@ -354,11 +406,14 @@ object Terminal { */ private[this] val runnable: Runnable = () => { @tailrec def impl(): Unit = { - val result = resultQueue.take + val _ = readQueue.take val b = in.read // The downstream consumer may have been interrupted. Buffer the result // when that hapens. - if (waiting.contains(result)) result.put(b) else buffer.put(b) + waiting.poll match { + case null => buffer.put(b) + case q => q.put(b) + } if (b != -1 && !Thread.interrupted()) impl() else closed.set(true) } @@ -370,21 +425,28 @@ object Terminal { if (closed.get) -1 else synchronized { - buffer.poll match { + readThread.set(Thread.currentThread) + try buffer.poll match { case null => - val result = new LinkedBlockingQueue[Int] - waiting.add(result) - resultQueue.offer(result) - try result.take + val result = new LinkedBlockingQueue[Integer] + waiting.synchronized(waiting.put(result)) + readQueue.put(()) + try result.take.toInt catch { case e: InterruptedException => waiting.remove(result) - throw e + -1 } case b if b == -1 => throw new ClosedChannelException - case b => b - } + case b => b.toInt + } finally readThread.set(null) } + def cancel(): Unit = waiting.synchronized { + Option(readThread.getAndSet(null)).foreach(_.interrupt()) + waiting.forEach(_.put(-2)) + waiting.clear() + readQueue.clear() + } override def available(): Int = { buffer.size @@ -524,7 +586,7 @@ object Terminal { } override def flush(): Unit = os.flush() } - private[this] val proxyPrintStream = new PrintStream(proxyOutputStream, true) { + private[this] val proxyPrintStream = new LinePrintStream(proxyOutputStream) { override def toString: String = s"proxyPrintStream($proxyOutputStream)" } private[this] lazy val isWindows = @@ -592,9 +654,21 @@ object Terminal { case t: jline.Terminal2 => t case _ => new DefaultTerminal2(terminal) } - override def init(): Unit = if (alive) terminal.init() - override def restore(): Unit = if (alive) terminal.restore() - override def reset(): Unit = if (alive) terminal.reset() + override def init(): Unit = + if (alive) + try terminal.init() + catch { + case _: InterruptedException => + } + override def restore(): Unit = + if (alive) + try terminal.restore() + catch { + case _: InterruptedException => + } + override def reset(): Unit = + try terminal.reset() + catch { case _: InterruptedException => } override def isSupported: Boolean = terminal.isSupported override def getWidth: Int = props.map(_.width).getOrElse(terminal.getWidth) override def getHeight: Int = props.map(_.height).getOrElse(terminal.getHeight) @@ -650,7 +724,7 @@ object Terminal { fixTerminalProperty() private[sbt] def createReader(term: Terminal, prompt: Prompt): ConsoleReader = { - new ConsoleReader(term.inputStream, prompt.wrappedOutputStream(term), term.toJLine) { + new ConsoleReader(term.inputStream, term.outputStream, term.toJLine) { override def readLine(prompt: String, mask: Character): String = term.withRawSystemIn(super.readLine(prompt, mask)) override def readLine(prompt: String): String = term.withRawSystemIn(super.readLine(prompt)) @@ -662,6 +736,9 @@ object Terminal { case term => term } + private val capabilityMap = + org.jline.utils.InfoCmp.Capability.values().map(c => c.toString -> c).toMap + @deprecated("For compatibility only", "1.4.0") private[sbt] def deprecatedTeminal: jline.Terminal = console.toJLine private class ConsoleTerminal( @@ -669,28 +746,36 @@ object Terminal { in: InputStream, out: OutputStream ) extends TerminalImpl(in, out, "console0") { + private[util] lazy val system = JLine3.system private[this] def isCI = sys.env.contains("BUILD_NUMBER") || sys.env.contains("CI") - override def getWidth: Int = term.getWidth - override def getHeight: Int = term.getHeight + override def getWidth: Int = system.getSize.getColumns + override def getHeight: Int = system.getSize.getRows override def isAnsiSupported: Boolean = term.isAnsiSupported && !isCI - override def isEchoEnabled: Boolean = term.isEchoEnabled + override def isEchoEnabled: Boolean = system.echo() override def isSuccessEnabled: Boolean = true - override def getBooleanCapability(capability: String): Boolean = - term.getBooleanCapability(capability) - override def getNumericCapability(capability: String): Int = - term.getNumericCapability(capability) - override def getStringCapability(capability: String): String = - term.getStringCapability(capability) + override def getBooleanCapability(capability: String, jline3: Boolean): Boolean = + if (jline3) capabilityMap.get(capability).fold(false)(system.getBooleanCapability) + else term.getBooleanCapability(capability) + override def getNumericCapability(capability: String, jline3: Boolean): Integer = + if (jline3) capabilityMap.get(capability).fold(null: Integer)(system.getNumericCapability) + else term.getNumericCapability(capability) + override def getStringCapability(capability: String, jline3: Boolean): String = + if (jline3) capabilityMap.get(capability).fold(null: String)(system.getStringCapability) + else term.getStringCapability(capability) override private[sbt] def restore(): Unit = term.restore() + override private[sbt] def getAttributes: Map[String, String] = + JLine3.toMap(system.getAttributes) + override private[sbt] def setAttributes(attributes: Map[String, String]): Unit = + system.setAttributes(JLine3.attributesFromMap(attributes)) + override private[sbt] def setSize(width: Int, height: Int): Unit = + system.setSize(new org.jline.terminal.Size(width, height)) + override def withRawSystemIn[T](f: => T): T = term.synchronized { - try { - term.init() - term.setEchoEnabled(false) - f - } finally { - term.restore() - term.setEchoEnabled(true) + val prev = JLine3.enterRawMode(system) + try f + catch { case _: InterruptedIOException => throw new InterruptedException } finally { + setAttributes(prev) } } override def isColorEnabled: Boolean = @@ -705,29 +790,33 @@ object Terminal { case "true" => true case _ => false }) + override def close(): Unit = { + try system.close() + catch { case NonFatal(_) => } + super.close() + } } private[sbt] abstract class TerminalImpl private[sbt] ( val in: InputStream, val out: OutputStream, override private[sbt] val name: String ) extends Terminal { - private[this] val directWrite = new AtomicBoolean(false) - private[this] val currentLine = new AtomicReference(new ArrayBuffer[Byte]) - private[this] val lineBuffer = new LinkedBlockingQueue[Byte] - private[this] val flushQueue = new LinkedBlockingQueue[Seq[Byte]] private[this] val writeLock = new AnyRef private[this] val writeableInputStream = in match { case w: WriteableInputStream => w case _ => new WriteableInputStream(in, name) } def throwIfClosed[R](f: => R): R = if (isStopped.get) throw new ClosedChannelException else f + override def getLastLine: Option[String] = progressState.currentLine private val combinedOutputStream = new OutputStream { override def write(b: Int): Unit = { Option(bootOutputStreamHolder.get).foreach(_.write(b)) out.write(b) } - override def write(b: Array[Byte]): Unit = write(b, 0, b.length) + override def write(b: Array[Byte]): Unit = { + write(b, 0, b.length) + } override def write(b: Array[Byte], offset: Int, len: Int): Unit = { Option(bootOutputStreamHolder.get).foreach(_.write(b, offset, len)) out.write(b, offset, len) @@ -740,54 +829,19 @@ object Terminal { override val outputStream = new OutputStream { override def write(b: Int): Unit = throwIfClosed { - writeLock.synchronized { - if (b == Int.MinValue) currentLine.set(new ArrayBuffer[Byte]) - else doWrite(Vector((b & 0xFF).toByte)) - if (b == 10) combinedOutputStream.flush() - } + write(Array((b & 0xFF).toByte)) } - override def write(b: Array[Byte]): Unit = throwIfClosed(write(b, 0, b.length)) - override def write(b: Array[Byte], off: Int, len: Int): Unit = { - throwIfClosed { - writeLock.synchronized { - val lo = math.max(0, off) - val hi = math.min(math.max(off + len, 0), b.length) - doWrite(b.slice(off, off + len).toSeq) - } - } + override def write(b: Array[Byte]): Unit = throwIfClosed { + writeLock.synchronized(doWrite(b)) + } + override def write(b: Array[Byte], offset: Int, length: Int): Unit = throwIfClosed { + write(Arrays.copyOfRange(b, offset, offset + length)) } override def flush(): Unit = combinedOutputStream.flush() - private[this] val clear = s"$CursorLeft1000$ClearScreenAfterCursor" - private def doWrite(bytes: Seq[Byte]): Unit = { - def doWrite(b: Byte): Unit = out.write(b & 0xFF) - val remaining = bytes.foldLeft(new ArrayBuffer[Byte]) { (buf, i) => - if (i == 10) { - progressState.addBytes(TerminalImpl.this, buf) - progressState.clearBytes() - val cl = currentLine.get - if (buf.nonEmpty && isAnsiSupported && cl.isEmpty) clear.getBytes.foreach(doWrite) - combinedOutputStream.write(buf.toArray) - combinedOutputStream.write(10) - currentLine.get match { - case s if s.nonEmpty => currentLine.set(new ArrayBuffer[Byte]) - case _ => - } - if (prompt != Prompt.Loading) progressState.reprint(TerminalImpl.this, rawPrintStream) - new ArrayBuffer[Byte] - } else buf += i - } - if (remaining.nonEmpty) { - val cl = currentLine.get - if (isAnsiSupported && cl.isEmpty) { - clear.getBytes.foreach(doWrite) - } - cl ++= remaining - combinedOutputStream.write(remaining.toArray) - } - combinedOutputStream.flush() - } } - override private[sbt] val printStream: PrintStream = new PrintStream(outputStream, true) + private def doWrite(bytes: Array[Byte]): Unit = + progressState.write(TerminalImpl.this, bytes, rawPrintStream, hasProgress.get) + override private[sbt] val printStream: PrintStream = new LinePrintStream(outputStream) override def inputStream: InputStream = writeableInputStream private[sbt] def write(bytes: Int*): Unit = writeableInputStream.write(bytes: _*) @@ -801,17 +855,7 @@ object Terminal { case _ => (0, 0) } - override def getLastLine: Option[String] = currentLine.get match { - case bytes if bytes.isEmpty => None - case bytes => - // TODO there are ghost characters when the user deletes prompt characters - // when they are given the cancellation option - Some(new String(bytes.toArray).replaceAllLiterally(ClearScreenAfterCursor, "")) - } - - private[this] val rawPrintStream: PrintStream = new PrintStream(combinedOutputStream, true) { - override def close(): Unit = {} - } + private[this] val rawPrintStream: PrintStream = new LinePrintStream(combinedOutputStream) override def withPrintStream[T](f: PrintStream => T): T = writeLock.synchronized(f(rawPrintStream)) @@ -821,12 +865,12 @@ object Terminal { } private[sbt] val NullTerminal = new Terminal { override def close(): Unit = {} - override def getBooleanCapability(capability: String): Boolean = false + override def getBooleanCapability(capability: String, jline3: Boolean): Boolean = false override def getHeight: Int = 0 override def getLastLine: Option[String] = None override def getLineHeightAndWidth(line: String): (Int, Int) = (0, 0) - override def getNumericCapability(capability: String): Int = -1 - override def getStringCapability(capability: String): String = null + override def getNumericCapability(capability: String, jline3: Boolean): Integer = null + override def getStringCapability(capability: String, jline3: Boolean): String = null override def getWidth: Int = 0 override def inputStream: java.io.InputStream = () => { try this.synchronized(this.wait) @@ -839,6 +883,9 @@ object Terminal { override def isSuccessEnabled: Boolean = false override def isSupershellEnabled: Boolean = false override def outputStream: java.io.OutputStream = _ => {} + override private[sbt] def getAttributes: Map[String, String] = Map.empty + override private[sbt] def setAttributes(attributes: Map[String, String]): Unit = {} + override private[sbt] def setSize(width: Int, height: Int): Unit = {} override private[sbt] def name: String = "NullTerminal" override private[sbt] val printStream: java.io.PrintStream = new PrintStream(outputStream, false) diff --git a/internal/util-logging/src/test/scala/sbt/internal/util/CleanStringSpec.scala b/internal/util-logging/src/test/scala/sbt/internal/util/CleanStringSpec.scala index f7ad9185c..9939d668f 100644 --- a/internal/util-logging/src/test/scala/sbt/internal/util/CleanStringSpec.scala +++ b/internal/util-logging/src/test/scala/sbt/internal/util/CleanStringSpec.scala @@ -63,4 +63,11 @@ class CleanStringSpec extends FlatSpec { val colorArrow = new String(Array[Byte](27, 91, 51, 54, 109, 62)) assert(EscHelpers.stripMoves(original) == "foo" + colorArrow + " " + scala.Console.RESET) } + it should "remove unusual escape characters" in { + val original = new String( + Array[Byte](27, 91, 63, 49, 108, 27, 62, 27, 91, 63, 49, 48, 48, 48, 108, 27, 91, 63, 50, 48, + 48, 52, 108) + ) + assert(EscHelpers.stripColorsAndMoves(original).isEmpty) + } } diff --git a/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala b/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala index 630b50ee3..a9483247e 100644 --- a/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala +++ b/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala @@ -12,7 +12,6 @@ package client import java.io.{ File, IOException, InputStream, PrintStream } import java.lang.ProcessBuilder.Redirect import java.net.Socket -import java.nio.channels.ClosedChannelException import java.nio.file.Files import java.util.UUID import java.util.concurrent.atomic.{ AtomicBoolean, AtomicReference } @@ -43,10 +42,14 @@ import Serialization.{ promptChannel, systemIn, systemOut, + systemOutFlush, terminalCapabilities, terminalCapabilitiesResponse, terminalPropertiesQuery, - terminalPropertiesResponse + terminalPropertiesResponse, + getTerminalAttributes, + setTerminalAttributes, + setTerminalSize, } import NetworkClient.Arguments @@ -199,7 +202,6 @@ class NetworkClient( case _ => (false, None) } if (rebootCommands.nonEmpty) { - if (Terminal.console.getLastLine.isDefined) Terminal.console.printStream.println() rebooting.set(true) attached.set(false) connectionHolder.getAndSet(null) match { @@ -212,7 +214,7 @@ class NetworkClient( rebooting.set(false) rebootCommands match { case Some((execId, cmd)) if execId.nonEmpty => - if (batchMode.get && !pendingResults.contains(execId) && cmd.isEmpty) { + if (batchMode.get && !pendingResults.containsKey(execId) && cmd.nonEmpty) { console.appendLog( Level.Error, s"received request to re-run unknown command '$cmd' after reboot" @@ -230,8 +232,6 @@ class NetworkClient( } else { if (!rebooting.get() && running.compareAndSet(true, false) && log) { if (!arguments.commandArguments.contains(Shutdown)) { - if (Terminal.console.getLastLine.isDefined) - Terminal.console.printStream.println() console.appendLog(Level.Error, "sbt server disconnected") exitClean.set(false) } @@ -306,7 +306,6 @@ class NetworkClient( Some(process) case _ => if (log) { - if (Terminal.console.getLastLine.isDefined) Terminal.console.printStream.println() console.appendLog(Level.Info, "sbt server is booting up") } None @@ -522,17 +521,15 @@ class NetworkClient( } } else Vector() case (`systemOut`, Some(json)) => - Converter.fromJson[Seq[Byte]](json) match { - case Success(params) => - if (params.nonEmpty) { - if (attached.get) { - printStream.write(params.toArray) - printStream.flush() - } - } - case Failure(_) => + Converter.fromJson[Array[Byte]](json) match { + case Success(bytes) if bytes.nonEmpty && attached.get => + synchronized(printStream.write(bytes)) + case _ => } Vector.empty + case (`systemOutFlush`, _) => + synchronized(printStream.flush()) + Vector.empty case (`promptChannel`, _) => batchMode.set(false) Vector.empty @@ -589,16 +586,23 @@ class NetworkClient( } def onRequest(msg: JsonRpcRequestMessage): Unit = { + import sbt.protocol.codec.JsonProtocol._ (msg.method, msg.params) match { case (`terminalCapabilities`, Some(json)) => - import sbt.protocol.codec.JsonProtocol._ Converter.fromJson[TerminalCapabilitiesQuery](json) match { case Success(terminalCapabilitiesQuery) => + val jline3 = terminalCapabilitiesQuery.jline3 val response = TerminalCapabilitiesResponse( - terminalCapabilitiesQuery.boolean.map(Terminal.console.getBooleanCapability), - terminalCapabilitiesQuery.numeric.map(Terminal.console.getNumericCapability), + terminalCapabilitiesQuery.boolean + .map(Terminal.console.getBooleanCapability(_, jline3)), + terminalCapabilitiesQuery.numeric + .map( + c => Option(Terminal.console.getNumericCapability(c, jline3)).fold(-1)(_.toInt) + ), terminalCapabilitiesQuery.string - .map(s => Option(Terminal.console.getStringCapability(s)).getOrElse("null")), + .map( + s => Option(Terminal.console.getStringCapability(s, jline3)).getOrElse("null") + ), ) sendCommandResponse( terminalCapabilitiesResponse, @@ -617,6 +621,37 @@ class NetworkClient( isEchoEnabled = Terminal.console.isEchoEnabled ) sendCommandResponse(terminalPropertiesResponse, response, msg.id) + case (`setTerminalAttributes`, Some(json)) => + Converter.fromJson[TerminalSetAttributesCommand](json) match { + case Success(attributes) => + val attrs = Map( + "iflag" -> attributes.iflag, + "oflag" -> attributes.oflag, + "cflag" -> attributes.cflag, + "lflag" -> attributes.lflag, + "cchars" -> attributes.cchars, + ) + Terminal.console.setAttributes(attrs) + sendCommandResponse("", TerminalSetAttributesResponse(), msg.id) + case Failure(_) => + } + case (`getTerminalAttributes`, _) => + val attrs = Terminal.console.getAttributes + val response = TerminalAttributesResponse( + iflag = attrs.getOrElse("iflag", ""), + oflag = attrs.getOrElse("oflag", ""), + cflag = attrs.getOrElse("cflag", ""), + lflag = attrs.getOrElse("lflag", ""), + cchars = attrs.getOrElse("cchars", ""), + ) + sendCommandResponse("", response, msg.id) + case (`setTerminalSize`, Some(json)) => + Converter.fromJson[TerminalSetSizeCommand](json) match { + case Success(size) => + Terminal.console.setSize(size.width, size.height) + sendCommandResponse("", TerminalSetSizeResponse(), msg.id) + case Failure(_) => + } case _ => } } @@ -851,7 +886,7 @@ class NetworkClient( } } try Terminal.console.withRawSystemIn(read()) - catch { case _: InterruptedException | _: ClosedChannelException => stopped.set(true) } + catch { case NonFatal(_) => stopped.set(true) } } def drain(): Unit = inLock.synchronized { @@ -897,20 +932,18 @@ object NetworkClient { override def success(msg: String): Unit = appender.success(msg) } } - private def simpleConsoleInterface(printStream: PrintStream): ConsoleInterface = + private def simpleConsoleInterface(doPrintln: String => Unit): ConsoleInterface = new ConsoleInterface { import scala.Console.{ GREEN, RED, RESET, YELLOW } - override def appendLog(level: Level.Value, message: => String): Unit = { + override def appendLog(level: Level.Value, message: => String): Unit = synchronized { val prefix = level match { case Level.Error => s"[$RED$level$RESET]" case Level.Warn => s"[$YELLOW$level$RESET]" case _ => s"[$RESET$level$RESET]" } - message.split("\n").foreach { line => - if (!line.trim.isEmpty) printStream.println(s"$prefix $line") - } + message.linesIterator.foreach(line => doPrintln(s"$prefix $line")) } - override def success(msg: String): Unit = printStream.println(s"[${GREEN}success$RESET] $msg") + override def success(msg: String): Unit = doPrintln(s"[${GREEN}success$RESET] $msg") } private[client] class Arguments( val baseDirectory: File, @@ -961,8 +994,29 @@ object NetworkClient { baseDirectory: File, args: Array[String], inputStream: InputStream, - errorStream: PrintStream, printStream: PrintStream, + errorStream: PrintStream, + useJNI: Boolean + ): Int = { + val client = + simpleClient( + NetworkClient.parseArgs(args).withBaseDirectory(baseDirectory), + inputStream, + printStream, + errorStream, + useJNI, + ) + try { + if (client.connect(log = true, promptCompleteUsers = false)) client.run() + else 1 + } catch { case _: Exception => 1 } finally client.close() + } + def client( + baseDirectory: File, + args: Array[String], + inputStream: InputStream, + errorStream: PrintStream, + terminal: Terminal, useJNI: Boolean ): Int = { val client = @@ -970,8 +1024,8 @@ object NetworkClient { NetworkClient.parseArgs(args).withBaseDirectory(baseDirectory), inputStream, errorStream, - printStream, useJNI, + terminal ) try { if (client.connect(log = true, promptCompleteUsers = false)) client.run() @@ -982,17 +1036,27 @@ object NetworkClient { arguments: Arguments, inputStream: InputStream, errorStream: PrintStream, - printStream: PrintStream, useJNI: Boolean, - ): NetworkClient = - new NetworkClient( - arguments, - NetworkClient.simpleConsoleInterface(printStream), - inputStream, - errorStream, - printStream, - useJNI, - ) + terminal: Terminal + ): NetworkClient = { + val doPrint: String => Unit = line => { + if (terminal.getLastLine.isDefined) terminal.printStream.println() + terminal.printStream.println(line) + } + val interface = NetworkClient.simpleConsoleInterface(doPrint) + val printStream = terminal.printStream + new NetworkClient(arguments, interface, inputStream, errorStream, printStream, useJNI) + } + private def simpleClient( + arguments: Arguments, + inputStream: InputStream, + printStream: PrintStream, + errorStream: PrintStream, + useJNI: Boolean, + ): NetworkClient = { + val interface = NetworkClient.simpleConsoleInterface(printStream.println) + new NetworkClient(arguments, interface, inputStream, errorStream, printStream, useJNI) + } def main(args: Array[String]): Unit = { val (jnaArg, restOfArgs) = args.partition(_ == "--jna") val useJNI = jnaArg.isEmpty @@ -1005,8 +1069,9 @@ object NetworkClient { System.out.flush() }) Runtime.getRuntime.addShutdownHook(hook) - System.exit(Terminal.withStreams { - try client(base, restOfArgs, System.in, System.err, System.out, useJNI) + System.exit(Terminal.withStreams(false) { + val term = Terminal.console + try client(base, restOfArgs, term.inputStream, System.err, term, useJNI) finally { Runtime.getRuntime.removeShutdownHook(hook) hook.run() diff --git a/main-command/src/main/scala/sbt/internal/ui/UITask.scala b/main-command/src/main/scala/sbt/internal/ui/UITask.scala index 64e4a8439..0b5618cd2 100644 --- a/main-command/src/main/scala/sbt/internal/ui/UITask.scala +++ b/main-command/src/main/scala/sbt/internal/ui/UITask.scala @@ -11,14 +11,14 @@ import java.io.File import java.nio.channels.ClosedChannelException import java.util.concurrent.atomic.AtomicBoolean -import jline.console.history.PersistentHistory +//import jline.console.history.PersistentHistory import sbt.BasicCommandStrings.{ Cancel, TerminateAction, Shutdown } import sbt.BasicKeys.{ historyPath, terminalShellPrompt } import sbt.State import sbt.internal.CommandChannel import sbt.internal.util.ConsoleAppender.{ ClearPromptLine, ClearScreenAfterCursor, DeleteLine } import sbt.internal.util._ -import sbt.internal.util.complete.{ JLineCompletion, Parser } +import sbt.internal.util.complete.{ Parser } import scala.annotation.tailrec @@ -47,44 +47,31 @@ private[sbt] object UITask { def terminalReader(parser: Parser[_])( terminal: Terminal, state: State - ): Reader = { - val lineReader = LineReader.createReader(history(state), terminal, terminal.prompt) - JLineCompletion.installCustomCompletor(lineReader, parser) - () => { + ): Reader = { () => + try { val clear = terminal.ansi(ClearPromptLine, "") - try { - @tailrec def impl(): Either[String, String] = { - lineReader.readLine(clear + terminal.prompt.mkPrompt()) match { - case null if terminal == Terminal.console && System.console == null => - // No stdin is attached to the process so just ignore the result and - // block until the thread is interrupted. - this.synchronized(this.wait()) - Right("") // should be unreachable - // JLine returns null on ctrl+d when there is no other input. This interprets - // ctrl+d with no imput as an exit - case null => Left(TerminateAction) - case s: String => - lineReader.getHistory match { - case p: PersistentHistory => - p.add(s) - p.flush() - case _ => - } - s match { - case "" => impl() - case cmd @ (`Shutdown` | `TerminateAction` | `Cancel`) => Left(cmd) - case cmd => - if (terminal.prompt != Prompt.Batch) terminal.setPrompt(Prompt.Running) - terminal.printStream.write(Int.MinValue) - Right(cmd) - } - } + @tailrec def impl(): Either[String, String] = { + val reader = LineReader.createReader(history(state), parser, terminal, terminal.prompt) + (try reader.readLine(clear + terminal.prompt.mkPrompt()) + finally reader.close) match { + case None if terminal == Terminal.console && System.console == null => + // No stdin is attached to the process so just ignore the result and + // block until the thread is interrupted. + this.synchronized(this.wait()) + Right("") // should be unreachable + // JLine returns null on ctrl+d when there is no other input. This interprets + // ctrl+d with no imput as an exit + case None => Left(TerminateAction) + case Some(s: String) => + s.trim() match { + case "" => impl() + case cmd @ (`Shutdown` | `TerminateAction` | `Cancel`) => Left(cmd) + case cmd => Right(cmd) + } } - impl() - } catch { - case _: InterruptedException => Right("") - } finally lineReader.close() - } + } + impl() + } catch { case e: InterruptedException => Right("") } } } private[this] def history(s: State): Option[File] = diff --git a/main/src/main/java/sbt/internal/MetaBuildLoader.java b/main/src/main/java/sbt/internal/MetaBuildLoader.java index ca67b1286..a027314b1 100644 --- a/main/src/main/java/sbt/internal/MetaBuildLoader.java +++ b/main/src/main/java/sbt/internal/MetaBuildLoader.java @@ -61,7 +61,7 @@ public final class MetaBuildLoader extends URLClassLoader { */ public static MetaBuildLoader makeLoader(final AppProvider appProvider) throws IOException { final Pattern pattern = - Pattern.compile("(test-interface-[0-9.]+|jline-[0-9.]+-sbt-.*|jansi-[0-9.]+)\\.jar"); + Pattern.compile("^(test-interface-[0-9.]+|jline-[0-9.]+-sbt-.*|jansi-[0-9.]+)\\.jar"); final File[] cp = appProvider.mainClasspath(); final URL[] interfaceURLs = new URL[3]; final File[] extra = diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 2b3dd1e8a..edec7feb3 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -1504,13 +1504,13 @@ object Defaults extends BuildCommon { def print(st: String) = { scala.Console.out.print(st); scala.Console.out.flush() } print(s) Terminal.get.withRawSystemIn { - Terminal.get.inputStream.read match { - case -1 => None + try Terminal.get.inputStream.read match { + case -1 | -2 => None case b => val res = b.toChar.toString println(res) Some(res) - } + } catch { case e: InterruptedException => None } } }), classes diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index ea02165d1..47764e91a 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -78,7 +78,7 @@ private[sbt] object xMain { BspClient.run(dealiasBaseDirectory(configuration)) } else { bootServerSocket.foreach(l => Terminal.setBootStreams(l.inputStream, l.outputStream)) - Terminal.withStreams { + Terminal.withStreams(true) { if (clientModByEnv || userCommands.exists(isClient)) { val args = userCommands.toList.filterNot(isClient) NetworkClient.run(dealiasBaseDirectory(configuration), args) diff --git a/main/src/main/scala/sbt/internal/CommandExchange.scala b/main/src/main/scala/sbt/internal/CommandExchange.scala index 6347c3f5d..ab169877d 100644 --- a/main/src/main/scala/sbt/internal/CommandExchange.scala +++ b/main/src/main/scala/sbt/internal/CommandExchange.scala @@ -133,10 +133,17 @@ private[sbt] final class CommandExchange { } } // Do not manually run GC until the user has been idling for at least the min gc interval. - impl(interval match { + val exec = impl(interval match { case d: FiniteDuration => Some(d.fromNow) case _ => None }, idleDeadline) + exec.source.foreach { s => + channelForName(s.channelName).foreach { + case c if c.terminal.prompt != Prompt.Batch => c.terminal.setPrompt(Prompt.Running) + case _ => + } + } + exec } private def addConsoleChannel(): Unit = @@ -412,6 +419,10 @@ private[sbt] final class CommandExchange { case _ => } case _ => + channels.foreach { + case nc: NetworkChannel => nc.shutdown(true, Some(("", ""))) + case c => c.shutdown(false) + } } private[sbt] def shutdown(name: String): Unit = { @@ -448,7 +459,9 @@ private[sbt] final class CommandExchange { case mt: FastTrackTask => mt.task match { case `attach` => mt.channel.prompt(ConsolePromptEvent(lastState.get)) - case `Cancel` => Option(currentExecRef.get).foreach(cancel) + case `Cancel` => + Option(currentExecRef.get).foreach(cancel) + mt.channel.prompt(ConsolePromptEvent(lastState.get)) case t if t.startsWith(ContinuousCommands.stopWatch) => ContinuousCommands.stopWatchImpl(mt.channel.name) mt.channel match { @@ -458,6 +471,10 @@ private[sbt] final class CommandExchange { commandQueue.add(Exec(t, None, None)) case `TerminateAction` => exit(mt) case `Shutdown` => + val console = Terminal.console + val needNewLine = console.prompt.isInstanceOf[Prompt.AskUser] + console.setPrompt(Prompt.Batch) + if (needNewLine) console.printStream.println() channels.find(_.name == mt.channel.name) match { case Some(c: NetworkChannel) => c.shutdown(false) case _ => diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala index ce2c1d6b1..e52a041a8 100644 --- a/main/src/main/scala/sbt/internal/Continuous.scala +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -1217,7 +1217,6 @@ private[sbt] object ContinuousCommands { ) extends Thread(s"sbt-${channel.name}-watch-ui-thread") with UITask { override private[sbt] def reader: UITask.Reader = () => { - channel.terminal.printStream.write(Int.MinValue) def stop = Right(s"${ContinuousCommands.stopWatch} ${channel.name}") val exitAction: Watch.Action = { Watch.apply( diff --git a/main/src/main/scala/sbt/internal/TaskProgress.scala b/main/src/main/scala/sbt/internal/TaskProgress.scala index ebdd06f19..ed74d20b1 100644 --- a/main/src/main/scala/sbt/internal/TaskProgress.scala +++ b/main/src/main/scala/sbt/internal/TaskProgress.scala @@ -53,11 +53,12 @@ private[sbt] class TaskProgress private () if (firstTime.compareAndSet(true, activeExceedingThreshold.isEmpty)) threshold else sleepDuration val limit = duration.fromNow - while (Deadline.now < limit) { + while (Deadline.now < limit && !isClosed.get && active.nonEmpty) { var task = tasks.poll((limit - Deadline.now).toMillis, TimeUnit.MILLISECONDS) while (task != null) { if (containsSkipTasks(Vector(task)) || lastTaskCount.get == 0) doReport() task = tasks.poll + tasks.clear() } } } catch { diff --git a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala index 6a9860503..828beeab3 100644 --- a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala +++ b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala @@ -99,7 +99,6 @@ final class NetworkChannel( addFastTrackTask(attach) } private[sbt] def prompt(): Unit = { - terminal.setPrompt(Prompt.Running) interactive.set(true) jsonRpcNotify(promptChannel, "") } @@ -641,7 +640,7 @@ final class NetworkChannel( case -1 => throw new ClosedChannelException() case b => b } - } catch { case _: IOException => -1 } + } catch { case e: IOException => -1 } } override def available(): Int = inputBuffer.size } @@ -774,25 +773,82 @@ final class NetworkChannel( Some(result(queue.take)) } } - override def getBooleanCapability(capability: String): Boolean = + override def getBooleanCapability(capability: String, jline3: Boolean): Boolean = getCapability( - TerminalCapabilitiesQuery(boolean = Some(capability), numeric = None, string = None), + TerminalCapabilitiesQuery( + boolean = Some(capability), + numeric = None, + string = None, + jline3 + ), _.boolean.getOrElse(false) ).getOrElse(false) - override def getNumericCapability(capability: String): Int = + override def getNumericCapability(capability: String, jline3: Boolean): Integer = getCapability( - TerminalCapabilitiesQuery(boolean = None, numeric = Some(capability), string = None), - _.numeric.getOrElse(-1) - ).getOrElse(-1) - override def getStringCapability(capability: String): String = + TerminalCapabilitiesQuery( + boolean = None, + numeric = Some(capability), + string = None, + jline3 + ), + (_: TerminalCapabilitiesResponse).numeric.map(Integer.valueOf).getOrElse(-1: Integer) + ).getOrElse(-1: Integer) + override def getStringCapability(capability: String, jline3: Boolean): String = getCapability( - TerminalCapabilitiesQuery(boolean = None, numeric = None, string = Some(capability)), + TerminalCapabilitiesQuery( + boolean = None, + numeric = None, + string = Some(capability), + jline3 + ), _.string.flatMap { case "null" => None case s => Some(s) }.orNull ).getOrElse("") + override private[sbt] def getAttributes: Map[String, String] = + if (closed.get) Map.empty + else { + import sbt.protocol.codec.JsonProtocol._ + val queue = VirtualTerminal.sendTerminalAttributesQuery( + name, + jsonRpcRequest + ) + try { + val a = queue.take + Map( + "iflag" -> a.iflag, + "oflag" -> a.oflag, + "cflag" -> a.cflag, + "lflag" -> a.lflag, + "cchars" -> a.cchars + ) + } catch { case _: InterruptedException => Map.empty } + } + override private[sbt] def setAttributes(attributes: Map[String, String]): Unit = + if (!closed.get) { + import sbt.protocol.codec.JsonProtocol._ + val attrs = TerminalSetAttributesCommand( + iflag = attributes.getOrElse("iflag", ""), + oflag = attributes.getOrElse("oflag", ""), + cflag = attributes.getOrElse("cflag", ""), + lflag = attributes.getOrElse("lflag", ""), + cchars = attributes.getOrElse("cchars", ""), + ) + val queue = VirtualTerminal.setTerminalAttributes(name, jsonRpcRequest, attrs) + try queue.take + catch { case _: InterruptedException => } + } + override def setSize(width: Int, height: Int): Unit = + if (!closed.get) { + import sbt.protocol.codec.JsonProtocol._ + val size = TerminalSetSizeCommand(width, height) + val queue = VirtualTerminal.setTerminalSize(name, jsonRpcRequest, size) + try queue.take + catch { case _: InterruptedException => } + } + override def toString: String = s"NetworkTerminal($name)" override def close(): Unit = if (closed.compareAndSet(false, true)) { val threads = blockedThreads.synchronized { diff --git a/main/src/main/scala/sbt/internal/server/VirtualTerminal.scala b/main/src/main/scala/sbt/internal/server/VirtualTerminal.scala index 55659ef61..c299c0f4c 100644 --- a/main/src/main/scala/sbt/internal/server/VirtualTerminal.scala +++ b/main/src/main/scala/sbt/internal/server/VirtualTerminal.scala @@ -25,16 +25,27 @@ import sbt.protocol.Serialization.{ import sjsonnew.support.scalajson.unsafe.Converter import sbt.protocol.{ Attach, + TerminalAttributesQuery, + TerminalAttributesResponse, TerminalCapabilitiesQuery, TerminalCapabilitiesResponse, - TerminalPropertiesResponse + TerminalPropertiesResponse, + TerminalSetAttributesCommand, + TerminalSetSizeCommand, } +import sbt.protocol.codec.JsonProtocol._ object VirtualTerminal { private[this] val pendingTerminalProperties = new ConcurrentHashMap[(String, String), ArrayBlockingQueue[TerminalPropertiesResponse]]() private[this] val pendingTerminalCapabilities = new ConcurrentHashMap[(String, String), ArrayBlockingQueue[TerminalCapabilitiesResponse]] + private[this] val pendingTerminalAttributes = + new ConcurrentHashMap[(String, String), ArrayBlockingQueue[TerminalAttributesResponse]] + private[this] val pendingTerminalSetAttributes = + new ConcurrentHashMap[(String, String), ArrayBlockingQueue[Unit]] + private[this] val pendingTerminalSetSize = + new ConcurrentHashMap[(String, String), ArrayBlockingQueue[Unit]] private[sbt] def sendTerminalPropertiesQuery( channelName: String, jsonRpcRequest: (String, String, String) => Unit @@ -70,6 +81,39 @@ object VirtualTerminal { case _ => } } + private[sbt] def sendTerminalAttributesQuery( + channelName: String, + jsonRpcRequest: (String, String, TerminalAttributesQuery) => Unit, + ): ArrayBlockingQueue[TerminalAttributesResponse] = { + val id = UUID.randomUUID.toString + val queue = new ArrayBlockingQueue[TerminalAttributesResponse](1) + pendingTerminalAttributes.put((channelName, id), queue) + jsonRpcRequest(id, terminalCapabilities, TerminalAttributesQuery()) + queue + } + private[sbt] def setTerminalAttributes( + channelName: String, + jsonRpcRequest: (String, String, TerminalSetAttributesCommand) => Unit, + query: TerminalSetAttributesCommand + ): ArrayBlockingQueue[Unit] = { + val id = UUID.randomUUID.toString + val queue = new ArrayBlockingQueue[Unit](1) + pendingTerminalSetAttributes.put((channelName, id), queue) + jsonRpcRequest(id, terminalCapabilities, query) + queue + } + + private[sbt] def setTerminalSize( + channelName: String, + jsonRpcRequest: (String, String, TerminalSetSizeCommand) => Unit, + query: TerminalSetSizeCommand + ): ArrayBlockingQueue[Unit] = { + val id = UUID.randomUUID.toString + val queue = new ArrayBlockingQueue[Unit](1) + pendingTerminalSetSize.put((channelName, id), queue) + jsonRpcRequest(id, terminalCapabilities, query) + queue + } val handler = ServerHandler { cb => ServerIntent(requestHandler(cb), responseHandler(cb), notificationHandler(cb)) } @@ -77,7 +121,6 @@ object VirtualTerminal { private val requestHandler: Handler[JsonRpcRequestMessage] = callback => { case r if r.method == attach => - import sbt.protocol.codec.JsonProtocol.AttachFormat val isInteractive = r.params .flatMap(Converter.fromJson[Attach](_).toOption.map(_.interactive)) .exists(identity) @@ -89,7 +132,6 @@ object VirtualTerminal { private val responseHandler: Handler[JsonRpcResponseMessage] = callback => { case r if pendingTerminalProperties.get((callback.name, r.id)) != null => - import sbt.protocol.codec.JsonProtocol._ val response = r.result.flatMap(Converter.fromJson[TerminalPropertiesResponse](_).toOption) pendingTerminalProperties.remove((callback.name, r.id)) match { @@ -97,7 +139,6 @@ object VirtualTerminal { case buffer => response.foreach(buffer.put) } case r if pendingTerminalCapabilities.get((callback.name, r.id)) != null => - import sbt.protocol.codec.JsonProtocol._ val response = r.result.flatMap( Converter.fromJson[TerminalCapabilitiesResponse](_).toOption @@ -107,6 +148,24 @@ object VirtualTerminal { case buffer => buffer.put(response.getOrElse(TerminalCapabilitiesResponse(None, None, None))) } + case r if pendingTerminalAttributes.get((callback.name, r.id)) != null => + val response = + r.result.flatMap(Converter.fromJson[TerminalAttributesResponse](_).toOption) + pendingTerminalAttributes.remove((callback.name, r.id)) match { + case null => + case buffer => + buffer.put(response.getOrElse(TerminalAttributesResponse("", "", "", "", ""))) + } + case r if pendingTerminalSetAttributes.get((callback.name, r.id)) != null => + pendingTerminalSetAttributes.remove((callback.name, r.id)) match { + case null => + case buffer => buffer.put(()) + } + case r if pendingTerminalSetSize.get((callback.name, r.id)) != null => + pendingTerminalSetSize.remove((callback.name, r.id)) match { + case null => + case buffer => buffer.put(()) + } } private val notificationHandler: Handler[JsonRpcNotificationMessage] = callback => { diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 028368816..5cd96f44c 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -84,7 +84,9 @@ object Dependencies { val sjsonNewMurmurhash = sjsonNew("sjson-new-murmurhash") val jline = "org.scala-sbt.jline" % "jline" % "2.14.7-sbt-5e51b9d4f9631ebfa29753ce4accc57808e7fd6b" - val jansi = "org.fusesource.jansi" % "jansi" % "1.12" + val jline3 = "org.jline" % "jline" % "3.15.0" + val jline3Jansi = "org.jline" % "jline-terminal-jansi" % "3.15.0" + val jansi = "org.fusesource.jansi" % "jansi" % "1.18" val scalatest = "org.scalatest" %% "scalatest" % "3.0.8" val scalacheck = "org.scalacheck" %% "scalacheck" % "1.14.0" val specs2 = "org.specs2" %% "specs2-junit" % "4.0.1" diff --git a/protocol/src/main/contraband-scala/sbt/protocol/TerminalAttributesQuery.scala b/protocol/src/main/contraband-scala/sbt/protocol/TerminalAttributesQuery.scala new file mode 100644 index 000000000..cfff9b3e1 --- /dev/null +++ b/protocol/src/main/contraband-scala/sbt/protocol/TerminalAttributesQuery.scala @@ -0,0 +1,29 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.protocol +final class TerminalAttributesQuery private () extends sbt.protocol.CommandMessage() with Serializable { + + + +override def equals(o: Any): Boolean = o match { + case _: TerminalAttributesQuery => true + case _ => false +} +override def hashCode: Int = { + 37 * (17 + "sbt.protocol.TerminalAttributesQuery".##) +} +override def toString: String = { + "TerminalAttributesQuery()" +} +private[this] def copy(): TerminalAttributesQuery = { + new TerminalAttributesQuery() +} + +} +object TerminalAttributesQuery { + + def apply(): TerminalAttributesQuery = new TerminalAttributesQuery() +} diff --git a/protocol/src/main/contraband-scala/sbt/protocol/TerminalAttributesResponse.scala b/protocol/src/main/contraband-scala/sbt/protocol/TerminalAttributesResponse.scala new file mode 100644 index 000000000..61fd49968 --- /dev/null +++ b/protocol/src/main/contraband-scala/sbt/protocol/TerminalAttributesResponse.scala @@ -0,0 +1,48 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.protocol +final class TerminalAttributesResponse private ( + val iflag: String, + val oflag: String, + val cflag: String, + val lflag: String, + val cchars: String) extends sbt.protocol.EventMessage() with Serializable { + + + + override def equals(o: Any): Boolean = o match { + case x: TerminalAttributesResponse => (this.iflag == x.iflag) && (this.oflag == x.oflag) && (this.cflag == x.cflag) && (this.lflag == x.lflag) && (this.cchars == x.cchars) + case _ => false + } + override def hashCode: Int = { + 37 * (37 * (37 * (37 * (37 * (37 * (17 + "sbt.protocol.TerminalAttributesResponse".##) + iflag.##) + oflag.##) + cflag.##) + lflag.##) + cchars.##) + } + override def toString: String = { + "TerminalAttributesResponse(" + iflag + ", " + oflag + ", " + cflag + ", " + lflag + ", " + cchars + ")" + } + private[this] def copy(iflag: String = iflag, oflag: String = oflag, cflag: String = cflag, lflag: String = lflag, cchars: String = cchars): TerminalAttributesResponse = { + new TerminalAttributesResponse(iflag, oflag, cflag, lflag, cchars) + } + def withIflag(iflag: String): TerminalAttributesResponse = { + copy(iflag = iflag) + } + def withOflag(oflag: String): TerminalAttributesResponse = { + copy(oflag = oflag) + } + def withCflag(cflag: String): TerminalAttributesResponse = { + copy(cflag = cflag) + } + def withLflag(lflag: String): TerminalAttributesResponse = { + copy(lflag = lflag) + } + def withCchars(cchars: String): TerminalAttributesResponse = { + copy(cchars = cchars) + } +} +object TerminalAttributesResponse { + + def apply(iflag: String, oflag: String, cflag: String, lflag: String, cchars: String): TerminalAttributesResponse = new TerminalAttributesResponse(iflag, oflag, cflag, lflag, cchars) +} diff --git a/protocol/src/main/contraband-scala/sbt/protocol/TerminalCapabilitiesQuery.scala b/protocol/src/main/contraband-scala/sbt/protocol/TerminalCapabilitiesQuery.scala index 2e270924c..1e6db3d04 100644 --- a/protocol/src/main/contraband-scala/sbt/protocol/TerminalCapabilitiesQuery.scala +++ b/protocol/src/main/contraband-scala/sbt/protocol/TerminalCapabilitiesQuery.scala @@ -7,22 +7,23 @@ package sbt.protocol final class TerminalCapabilitiesQuery private ( val boolean: Option[String], val numeric: Option[String], - val string: Option[String]) extends sbt.protocol.CommandMessage() with Serializable { + val string: Option[String], + val jline3: Boolean) extends sbt.protocol.CommandMessage() with Serializable { override def equals(o: Any): Boolean = o match { - case x: TerminalCapabilitiesQuery => (this.boolean == x.boolean) && (this.numeric == x.numeric) && (this.string == x.string) + case x: TerminalCapabilitiesQuery => (this.boolean == x.boolean) && (this.numeric == x.numeric) && (this.string == x.string) && (this.jline3 == x.jline3) case _ => false } override def hashCode: Int = { - 37 * (37 * (37 * (37 * (17 + "sbt.protocol.TerminalCapabilitiesQuery".##) + boolean.##) + numeric.##) + string.##) + 37 * (37 * (37 * (37 * (37 * (17 + "sbt.protocol.TerminalCapabilitiesQuery".##) + boolean.##) + numeric.##) + string.##) + jline3.##) } override def toString: String = { - "TerminalCapabilitiesQuery(" + boolean + ", " + numeric + ", " + string + ")" + "TerminalCapabilitiesQuery(" + boolean + ", " + numeric + ", " + string + ", " + jline3 + ")" } - private[this] def copy(boolean: Option[String] = boolean, numeric: Option[String] = numeric, string: Option[String] = string): TerminalCapabilitiesQuery = { - new TerminalCapabilitiesQuery(boolean, numeric, string) + private[this] def copy(boolean: Option[String] = boolean, numeric: Option[String] = numeric, string: Option[String] = string, jline3: Boolean = jline3): TerminalCapabilitiesQuery = { + new TerminalCapabilitiesQuery(boolean, numeric, string, jline3) } def withBoolean(boolean: Option[String]): TerminalCapabilitiesQuery = { copy(boolean = boolean) @@ -42,9 +43,12 @@ final class TerminalCapabilitiesQuery private ( def withString(string: String): TerminalCapabilitiesQuery = { copy(string = Option(string)) } + def withJline3(jline3: Boolean): TerminalCapabilitiesQuery = { + copy(jline3 = jline3) + } } object TerminalCapabilitiesQuery { - def apply(boolean: Option[String], numeric: Option[String], string: Option[String]): TerminalCapabilitiesQuery = new TerminalCapabilitiesQuery(boolean, numeric, string) - def apply(boolean: String, numeric: String, string: String): TerminalCapabilitiesQuery = new TerminalCapabilitiesQuery(Option(boolean), Option(numeric), Option(string)) + def apply(boolean: Option[String], numeric: Option[String], string: Option[String], jline3: Boolean): TerminalCapabilitiesQuery = new TerminalCapabilitiesQuery(boolean, numeric, string, jline3) + def apply(boolean: String, numeric: String, string: String, jline3: Boolean): TerminalCapabilitiesQuery = new TerminalCapabilitiesQuery(Option(boolean), Option(numeric), Option(string), jline3) } diff --git a/protocol/src/main/contraband-scala/sbt/protocol/TerminalSetAttributesCommand.scala b/protocol/src/main/contraband-scala/sbt/protocol/TerminalSetAttributesCommand.scala new file mode 100644 index 000000000..f22e10c0a --- /dev/null +++ b/protocol/src/main/contraband-scala/sbt/protocol/TerminalSetAttributesCommand.scala @@ -0,0 +1,48 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.protocol +final class TerminalSetAttributesCommand private ( + val iflag: String, + val oflag: String, + val cflag: String, + val lflag: String, + val cchars: String) extends sbt.protocol.CommandMessage() with Serializable { + + + + override def equals(o: Any): Boolean = o match { + case x: TerminalSetAttributesCommand => (this.iflag == x.iflag) && (this.oflag == x.oflag) && (this.cflag == x.cflag) && (this.lflag == x.lflag) && (this.cchars == x.cchars) + case _ => false + } + override def hashCode: Int = { + 37 * (37 * (37 * (37 * (37 * (37 * (17 + "sbt.protocol.TerminalSetAttributesCommand".##) + iflag.##) + oflag.##) + cflag.##) + lflag.##) + cchars.##) + } + override def toString: String = { + "TerminalSetAttributesCommand(" + iflag + ", " + oflag + ", " + cflag + ", " + lflag + ", " + cchars + ")" + } + private[this] def copy(iflag: String = iflag, oflag: String = oflag, cflag: String = cflag, lflag: String = lflag, cchars: String = cchars): TerminalSetAttributesCommand = { + new TerminalSetAttributesCommand(iflag, oflag, cflag, lflag, cchars) + } + def withIflag(iflag: String): TerminalSetAttributesCommand = { + copy(iflag = iflag) + } + def withOflag(oflag: String): TerminalSetAttributesCommand = { + copy(oflag = oflag) + } + def withCflag(cflag: String): TerminalSetAttributesCommand = { + copy(cflag = cflag) + } + def withLflag(lflag: String): TerminalSetAttributesCommand = { + copy(lflag = lflag) + } + def withCchars(cchars: String): TerminalSetAttributesCommand = { + copy(cchars = cchars) + } +} +object TerminalSetAttributesCommand { + + def apply(iflag: String, oflag: String, cflag: String, lflag: String, cchars: String): TerminalSetAttributesCommand = new TerminalSetAttributesCommand(iflag, oflag, cflag, lflag, cchars) +} diff --git a/protocol/src/main/contraband-scala/sbt/protocol/TerminalSetAttributesResponse.scala b/protocol/src/main/contraband-scala/sbt/protocol/TerminalSetAttributesResponse.scala new file mode 100644 index 000000000..bfa6ee41d --- /dev/null +++ b/protocol/src/main/contraband-scala/sbt/protocol/TerminalSetAttributesResponse.scala @@ -0,0 +1,29 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.protocol +final class TerminalSetAttributesResponse private () extends sbt.protocol.EventMessage() with Serializable { + + + +override def equals(o: Any): Boolean = o match { + case _: TerminalSetAttributesResponse => true + case _ => false +} +override def hashCode: Int = { + 37 * (17 + "sbt.protocol.TerminalSetAttributesResponse".##) +} +override def toString: String = { + "TerminalSetAttributesResponse()" +} +private[this] def copy(): TerminalSetAttributesResponse = { + new TerminalSetAttributesResponse() +} + +} +object TerminalSetAttributesResponse { + + def apply(): TerminalSetAttributesResponse = new TerminalSetAttributesResponse() +} diff --git a/protocol/src/main/contraband-scala/sbt/protocol/TerminalSetSizeCommand.scala b/protocol/src/main/contraband-scala/sbt/protocol/TerminalSetSizeCommand.scala new file mode 100644 index 000000000..11104fc94 --- /dev/null +++ b/protocol/src/main/contraband-scala/sbt/protocol/TerminalSetSizeCommand.scala @@ -0,0 +1,36 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.protocol +final class TerminalSetSizeCommand private ( + val width: Int, + val height: Int) extends sbt.protocol.CommandMessage() with Serializable { + + + + override def equals(o: Any): Boolean = o match { + case x: TerminalSetSizeCommand => (this.width == x.width) && (this.height == x.height) + case _ => false + } + override def hashCode: Int = { + 37 * (37 * (37 * (17 + "sbt.protocol.TerminalSetSizeCommand".##) + width.##) + height.##) + } + override def toString: String = { + "TerminalSetSizeCommand(" + width + ", " + height + ")" + } + private[this] def copy(width: Int = width, height: Int = height): TerminalSetSizeCommand = { + new TerminalSetSizeCommand(width, height) + } + def withWidth(width: Int): TerminalSetSizeCommand = { + copy(width = width) + } + def withHeight(height: Int): TerminalSetSizeCommand = { + copy(height = height) + } +} +object TerminalSetSizeCommand { + + def apply(width: Int, height: Int): TerminalSetSizeCommand = new TerminalSetSizeCommand(width, height) +} diff --git a/protocol/src/main/contraband-scala/sbt/protocol/TerminalSetSizeResponse.scala b/protocol/src/main/contraband-scala/sbt/protocol/TerminalSetSizeResponse.scala new file mode 100644 index 000000000..118e2b121 --- /dev/null +++ b/protocol/src/main/contraband-scala/sbt/protocol/TerminalSetSizeResponse.scala @@ -0,0 +1,29 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.protocol +final class TerminalSetSizeResponse private () extends sbt.protocol.EventMessage() with Serializable { + + + +override def equals(o: Any): Boolean = o match { + case _: TerminalSetSizeResponse => true + case _ => false +} +override def hashCode: Int = { + 37 * (17 + "sbt.protocol.TerminalSetSizeResponse".##) +} +override def toString: String = { + "TerminalSetSizeResponse()" +} +private[this] def copy(): TerminalSetSizeResponse = { + new TerminalSetSizeResponse() +} + +} +object TerminalSetSizeResponse { + + def apply(): TerminalSetSizeResponse = new TerminalSetSizeResponse() +} diff --git a/protocol/src/main/contraband-scala/sbt/protocol/codec/CommandMessageFormats.scala b/protocol/src/main/contraband-scala/sbt/protocol/codec/CommandMessageFormats.scala index ee79ca457..1ecd02122 100644 --- a/protocol/src/main/contraband-scala/sbt/protocol/codec/CommandMessageFormats.scala +++ b/protocol/src/main/contraband-scala/sbt/protocol/codec/CommandMessageFormats.scala @@ -6,6 +6,6 @@ package sbt.protocol.codec import _root_.sjsonnew.JsonFormat -trait CommandMessageFormats { self: sjsonnew.BasicJsonProtocol with sbt.protocol.codec.InitCommandFormats with sbt.protocol.codec.ExecCommandFormats with sbt.protocol.codec.SettingQueryFormats with sbt.protocol.codec.AttachFormats with sbt.protocol.codec.TerminalCapabilitiesQueryFormats => -implicit lazy val CommandMessageFormat: JsonFormat[sbt.protocol.CommandMessage] = flatUnionFormat5[sbt.protocol.CommandMessage, sbt.protocol.InitCommand, sbt.protocol.ExecCommand, sbt.protocol.SettingQuery, sbt.protocol.Attach, sbt.protocol.TerminalCapabilitiesQuery]("type") +trait CommandMessageFormats { self: sjsonnew.BasicJsonProtocol with sbt.protocol.codec.InitCommandFormats with sbt.protocol.codec.ExecCommandFormats with sbt.protocol.codec.SettingQueryFormats with sbt.protocol.codec.AttachFormats with sbt.protocol.codec.TerminalCapabilitiesQueryFormats with sbt.protocol.codec.TerminalSetAttributesCommandFormats with sbt.protocol.codec.TerminalAttributesQueryFormats with sbt.protocol.codec.TerminalSetSizeCommandFormats => +implicit lazy val CommandMessageFormat: JsonFormat[sbt.protocol.CommandMessage] = flatUnionFormat8[sbt.protocol.CommandMessage, sbt.protocol.InitCommand, sbt.protocol.ExecCommand, sbt.protocol.SettingQuery, sbt.protocol.Attach, sbt.protocol.TerminalCapabilitiesQuery, sbt.protocol.TerminalSetAttributesCommand, sbt.protocol.TerminalAttributesQuery, sbt.protocol.TerminalSetSizeCommand]("type") } diff --git a/protocol/src/main/contraband-scala/sbt/protocol/codec/EventMessageFormats.scala b/protocol/src/main/contraband-scala/sbt/protocol/codec/EventMessageFormats.scala index 2694b0078..5475a901b 100644 --- a/protocol/src/main/contraband-scala/sbt/protocol/codec/EventMessageFormats.scala +++ b/protocol/src/main/contraband-scala/sbt/protocol/codec/EventMessageFormats.scala @@ -6,6 +6,6 @@ package sbt.protocol.codec import _root_.sjsonnew.JsonFormat -trait EventMessageFormats { self: sjsonnew.BasicJsonProtocol with sbt.protocol.codec.ChannelAcceptedEventFormats with sbt.protocol.codec.LogEventFormats with sbt.protocol.codec.ExecStatusEventFormats with sbt.internal.util.codec.JValueFormats with sbt.protocol.codec.SettingQuerySuccessFormats with sbt.protocol.codec.SettingQueryFailureFormats with sbt.protocol.codec.TerminalPropertiesResponseFormats with sbt.protocol.codec.TerminalCapabilitiesResponseFormats => -implicit lazy val EventMessageFormat: JsonFormat[sbt.protocol.EventMessage] = flatUnionFormat7[sbt.protocol.EventMessage, sbt.protocol.ChannelAcceptedEvent, sbt.protocol.LogEvent, sbt.protocol.ExecStatusEvent, sbt.protocol.SettingQuerySuccess, sbt.protocol.SettingQueryFailure, sbt.protocol.TerminalPropertiesResponse, sbt.protocol.TerminalCapabilitiesResponse]("type") +trait EventMessageFormats { self: sjsonnew.BasicJsonProtocol with sbt.protocol.codec.ChannelAcceptedEventFormats with sbt.protocol.codec.LogEventFormats with sbt.protocol.codec.ExecStatusEventFormats with sbt.internal.util.codec.JValueFormats with sbt.protocol.codec.SettingQuerySuccessFormats with sbt.protocol.codec.SettingQueryFailureFormats with sbt.protocol.codec.TerminalPropertiesResponseFormats with sbt.protocol.codec.TerminalCapabilitiesResponseFormats with sbt.protocol.codec.TerminalSetAttributesResponseFormats with sbt.protocol.codec.TerminalAttributesResponseFormats with sbt.protocol.codec.TerminalSetSizeResponseFormats => +implicit lazy val EventMessageFormat: JsonFormat[sbt.protocol.EventMessage] = flatUnionFormat10[sbt.protocol.EventMessage, sbt.protocol.ChannelAcceptedEvent, sbt.protocol.LogEvent, sbt.protocol.ExecStatusEvent, sbt.protocol.SettingQuerySuccess, sbt.protocol.SettingQueryFailure, sbt.protocol.TerminalPropertiesResponse, sbt.protocol.TerminalCapabilitiesResponse, sbt.protocol.TerminalSetAttributesResponse, sbt.protocol.TerminalAttributesResponse, sbt.protocol.TerminalSetSizeResponse]("type") } diff --git a/protocol/src/main/contraband-scala/sbt/protocol/codec/JsonProtocol.scala b/protocol/src/main/contraband-scala/sbt/protocol/codec/JsonProtocol.scala index de4aba238..e3a6e2b99 100644 --- a/protocol/src/main/contraband-scala/sbt/protocol/codec/JsonProtocol.scala +++ b/protocol/src/main/contraband-scala/sbt/protocol/codec/JsonProtocol.scala @@ -10,6 +10,9 @@ trait JsonProtocol extends sjsonnew.BasicJsonProtocol with sbt.protocol.codec.SettingQueryFormats with sbt.protocol.codec.AttachFormats with sbt.protocol.codec.TerminalCapabilitiesQueryFormats + with sbt.protocol.codec.TerminalSetAttributesCommandFormats + with sbt.protocol.codec.TerminalAttributesQueryFormats + with sbt.protocol.codec.TerminalSetSizeCommandFormats with sbt.protocol.codec.CommandMessageFormats with sbt.protocol.codec.CompletionParamsFormats with sbt.protocol.codec.ChannelAcceptedEventFormats @@ -20,6 +23,9 @@ trait JsonProtocol extends sjsonnew.BasicJsonProtocol with sbt.protocol.codec.SettingQueryFailureFormats with sbt.protocol.codec.TerminalPropertiesResponseFormats with sbt.protocol.codec.TerminalCapabilitiesResponseFormats + with sbt.protocol.codec.TerminalSetAttributesResponseFormats + with sbt.protocol.codec.TerminalAttributesResponseFormats + with sbt.protocol.codec.TerminalSetSizeResponseFormats with sbt.protocol.codec.EventMessageFormats with sbt.protocol.codec.SettingQueryResponseFormats with sbt.protocol.codec.CompletionResponseFormats diff --git a/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalAttributesQueryFormats.scala b/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalAttributesQueryFormats.scala new file mode 100644 index 000000000..a91e08aa5 --- /dev/null +++ b/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalAttributesQueryFormats.scala @@ -0,0 +1,27 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.protocol.codec +import _root_.sjsonnew.{ Unbuilder, Builder, JsonFormat, deserializationError } +trait TerminalAttributesQueryFormats { self: sjsonnew.BasicJsonProtocol => +implicit lazy val TerminalAttributesQueryFormat: JsonFormat[sbt.protocol.TerminalAttributesQuery] = new JsonFormat[sbt.protocol.TerminalAttributesQuery] { + override def read[J](__jsOpt: Option[J], unbuilder: Unbuilder[J]): sbt.protocol.TerminalAttributesQuery = { + __jsOpt match { + case Some(__js) => + unbuilder.beginObject(__js) + + unbuilder.endObject() + sbt.protocol.TerminalAttributesQuery() + case None => + deserializationError("Expected JsObject but found None") + } + } + override def write[J](obj: sbt.protocol.TerminalAttributesQuery, builder: Builder[J]): Unit = { + builder.beginObject() + + builder.endObject() + } +} +} diff --git a/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalAttributesResponseFormats.scala b/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalAttributesResponseFormats.scala new file mode 100644 index 000000000..e117c0389 --- /dev/null +++ b/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalAttributesResponseFormats.scala @@ -0,0 +1,35 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.protocol.codec +import _root_.sjsonnew.{ Unbuilder, Builder, JsonFormat, deserializationError } +trait TerminalAttributesResponseFormats { self: sjsonnew.BasicJsonProtocol => +implicit lazy val TerminalAttributesResponseFormat: JsonFormat[sbt.protocol.TerminalAttributesResponse] = new JsonFormat[sbt.protocol.TerminalAttributesResponse] { + override def read[J](__jsOpt: Option[J], unbuilder: Unbuilder[J]): sbt.protocol.TerminalAttributesResponse = { + __jsOpt match { + case Some(__js) => + unbuilder.beginObject(__js) + val iflag = unbuilder.readField[String]("iflag") + val oflag = unbuilder.readField[String]("oflag") + val cflag = unbuilder.readField[String]("cflag") + val lflag = unbuilder.readField[String]("lflag") + val cchars = unbuilder.readField[String]("cchars") + unbuilder.endObject() + sbt.protocol.TerminalAttributesResponse(iflag, oflag, cflag, lflag, cchars) + case None => + deserializationError("Expected JsObject but found None") + } + } + override def write[J](obj: sbt.protocol.TerminalAttributesResponse, builder: Builder[J]): Unit = { + builder.beginObject() + builder.addField("iflag", obj.iflag) + builder.addField("oflag", obj.oflag) + builder.addField("cflag", obj.cflag) + builder.addField("lflag", obj.lflag) + builder.addField("cchars", obj.cchars) + builder.endObject() + } +} +} diff --git a/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalCapabilitiesQueryFormats.scala b/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalCapabilitiesQueryFormats.scala index a26886a46..a7ba0d270 100644 --- a/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalCapabilitiesQueryFormats.scala +++ b/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalCapabilitiesQueryFormats.scala @@ -14,8 +14,9 @@ implicit lazy val TerminalCapabilitiesQueryFormat: JsonFormat[sbt.protocol.Termi val boolean = unbuilder.readField[Option[String]]("boolean") val numeric = unbuilder.readField[Option[String]]("numeric") val string = unbuilder.readField[Option[String]]("string") + val jline3 = unbuilder.readField[Boolean]("jline3") unbuilder.endObject() - sbt.protocol.TerminalCapabilitiesQuery(boolean, numeric, string) + sbt.protocol.TerminalCapabilitiesQuery(boolean, numeric, string, jline3) case None => deserializationError("Expected JsObject but found None") } @@ -25,6 +26,7 @@ implicit lazy val TerminalCapabilitiesQueryFormat: JsonFormat[sbt.protocol.Termi builder.addField("boolean", obj.boolean) builder.addField("numeric", obj.numeric) builder.addField("string", obj.string) + builder.addField("jline3", obj.jline3) builder.endObject() } } diff --git a/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetAttributesCommandFormats.scala b/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetAttributesCommandFormats.scala new file mode 100644 index 000000000..021c9c226 --- /dev/null +++ b/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetAttributesCommandFormats.scala @@ -0,0 +1,35 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.protocol.codec +import _root_.sjsonnew.{ Unbuilder, Builder, JsonFormat, deserializationError } +trait TerminalSetAttributesCommandFormats { self: sjsonnew.BasicJsonProtocol => +implicit lazy val TerminalSetAttributesCommandFormat: JsonFormat[sbt.protocol.TerminalSetAttributesCommand] = new JsonFormat[sbt.protocol.TerminalSetAttributesCommand] { + override def read[J](__jsOpt: Option[J], unbuilder: Unbuilder[J]): sbt.protocol.TerminalSetAttributesCommand = { + __jsOpt match { + case Some(__js) => + unbuilder.beginObject(__js) + val iflag = unbuilder.readField[String]("iflag") + val oflag = unbuilder.readField[String]("oflag") + val cflag = unbuilder.readField[String]("cflag") + val lflag = unbuilder.readField[String]("lflag") + val cchars = unbuilder.readField[String]("cchars") + unbuilder.endObject() + sbt.protocol.TerminalSetAttributesCommand(iflag, oflag, cflag, lflag, cchars) + case None => + deserializationError("Expected JsObject but found None") + } + } + override def write[J](obj: sbt.protocol.TerminalSetAttributesCommand, builder: Builder[J]): Unit = { + builder.beginObject() + builder.addField("iflag", obj.iflag) + builder.addField("oflag", obj.oflag) + builder.addField("cflag", obj.cflag) + builder.addField("lflag", obj.lflag) + builder.addField("cchars", obj.cchars) + builder.endObject() + } +} +} diff --git a/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetAttributesResponseFormats.scala b/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetAttributesResponseFormats.scala new file mode 100644 index 000000000..af4f3611a --- /dev/null +++ b/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetAttributesResponseFormats.scala @@ -0,0 +1,27 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.protocol.codec +import _root_.sjsonnew.{ Unbuilder, Builder, JsonFormat, deserializationError } +trait TerminalSetAttributesResponseFormats { self: sjsonnew.BasicJsonProtocol => +implicit lazy val TerminalSetAttributesResponseFormat: JsonFormat[sbt.protocol.TerminalSetAttributesResponse] = new JsonFormat[sbt.protocol.TerminalSetAttributesResponse] { + override def read[J](__jsOpt: Option[J], unbuilder: Unbuilder[J]): sbt.protocol.TerminalSetAttributesResponse = { + __jsOpt match { + case Some(__js) => + unbuilder.beginObject(__js) + + unbuilder.endObject() + sbt.protocol.TerminalSetAttributesResponse() + case None => + deserializationError("Expected JsObject but found None") + } + } + override def write[J](obj: sbt.protocol.TerminalSetAttributesResponse, builder: Builder[J]): Unit = { + builder.beginObject() + + builder.endObject() + } +} +} diff --git a/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetSizeCommandFormats.scala b/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetSizeCommandFormats.scala new file mode 100644 index 000000000..56fc296a1 --- /dev/null +++ b/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetSizeCommandFormats.scala @@ -0,0 +1,29 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.protocol.codec +import _root_.sjsonnew.{ Unbuilder, Builder, JsonFormat, deserializationError } +trait TerminalSetSizeCommandFormats { self: sjsonnew.BasicJsonProtocol => +implicit lazy val TerminalSetSizeCommandFormat: JsonFormat[sbt.protocol.TerminalSetSizeCommand] = new JsonFormat[sbt.protocol.TerminalSetSizeCommand] { + override def read[J](__jsOpt: Option[J], unbuilder: Unbuilder[J]): sbt.protocol.TerminalSetSizeCommand = { + __jsOpt match { + case Some(__js) => + unbuilder.beginObject(__js) + val width = unbuilder.readField[Int]("width") + val height = unbuilder.readField[Int]("height") + unbuilder.endObject() + sbt.protocol.TerminalSetSizeCommand(width, height) + case None => + deserializationError("Expected JsObject but found None") + } + } + override def write[J](obj: sbt.protocol.TerminalSetSizeCommand, builder: Builder[J]): Unit = { + builder.beginObject() + builder.addField("width", obj.width) + builder.addField("height", obj.height) + builder.endObject() + } +} +} diff --git a/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetSizeResponseFormats.scala b/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetSizeResponseFormats.scala new file mode 100644 index 000000000..1da62ae6f --- /dev/null +++ b/protocol/src/main/contraband-scala/sbt/protocol/codec/TerminalSetSizeResponseFormats.scala @@ -0,0 +1,27 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.protocol.codec +import _root_.sjsonnew.{ Unbuilder, Builder, JsonFormat, deserializationError } +trait TerminalSetSizeResponseFormats { self: sjsonnew.BasicJsonProtocol => +implicit lazy val TerminalSetSizeResponseFormat: JsonFormat[sbt.protocol.TerminalSetSizeResponse] = new JsonFormat[sbt.protocol.TerminalSetSizeResponse] { + override def read[J](__jsOpt: Option[J], unbuilder: Unbuilder[J]): sbt.protocol.TerminalSetSizeResponse = { + __jsOpt match { + case Some(__js) => + unbuilder.beginObject(__js) + + unbuilder.endObject() + sbt.protocol.TerminalSetSizeResponse() + case None => + deserializationError("Expected JsObject but found None") + } + } + override def write[J](obj: sbt.protocol.TerminalSetSizeResponse, builder: Builder[J]): Unit = { + builder.beginObject() + + builder.endObject() + } +} +} diff --git a/protocol/src/main/contraband/server.contra b/protocol/src/main/contraband/server.contra index 04ce0054a..2cde1ec82 100644 --- a/protocol/src/main/contraband/server.contra +++ b/protocol/src/main/contraband/server.contra @@ -85,22 +85,50 @@ type ExecutionEvent { } type TerminalPropertiesResponse implements EventMessage { - width: Int! - height: Int! - isAnsiSupported: Boolean! - isColorEnabled: Boolean! - isSupershellEnabled: Boolean! - isEchoEnabled: Boolean! + width: Int! + height: Int! + isAnsiSupported: Boolean! + isColorEnabled: Boolean! + isSupershellEnabled: Boolean! + isEchoEnabled: Boolean! } type TerminalCapabilitiesQuery implements CommandMessage { - boolean: String - numeric: String - string: String + boolean: String + numeric: String + string: String + jline3: Boolean! } type TerminalCapabilitiesResponse implements EventMessage { - boolean: Boolean - numeric: Int - string: String + boolean: Boolean + numeric: Int + string: String } + +type TerminalSetAttributesCommand implements CommandMessage { + iflag: String!, + oflag: String!, + cflag: String!, + lflag: String!, + cchars: String!, +} + +type TerminalSetAttributesResponse implements EventMessage {} + +type TerminalAttributesQuery implements CommandMessage {} + +type TerminalAttributesResponse implements EventMessage { + iflag: String!, + oflag: String!, + cflag: String!, + lflag: String!, + cchars: String!, +} + +type TerminalSetSizeCommand implements CommandMessage { + width: Int! + height: Int! +} + +type TerminalSetSizeResponse implements EventMessage {} diff --git a/protocol/src/main/scala/sbt/protocol/Serialization.scala b/protocol/src/main/scala/sbt/protocol/Serialization.scala index 21e981059..e9cbcf1e1 100644 --- a/protocol/src/main/scala/sbt/protocol/Serialization.scala +++ b/protocol/src/main/scala/sbt/protocol/Serialization.scala @@ -26,6 +26,7 @@ object Serialization { private[sbt] val VsCode = "application/vscode-jsonrpc; charset=utf-8" val systemIn = "sbt/systemIn" val systemOut = "sbt/systemOut" + val systemOutFlush = "sbt/systemOutFlush" val terminalPropertiesQuery = "sbt/terminalPropertiesQuery" val terminalPropertiesResponse = "sbt/terminalPropertiesResponse" val terminalCapabilities = "sbt/terminalCapabilities" @@ -34,6 +35,9 @@ object Serialization { val attachResponse = "sbt/attachResponse" val cancelRequest = "sbt/cancelRequest" val promptChannel = "sbt/promptChannel" + val setTerminalAttributes = "sbt/setTerminalAttributes" + val getTerminalAttributes = "sbt/getTerminalAttributes" + val setTerminalSize = "sbt/setTerminalSize" val CancelAll = "__CancelAll" @deprecated("unused", since = "1.4.0") From fb803a0994cd07a7eb47e9c989241dc7a97eb2a9 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Tue, 7 Jul 2020 13:10:56 -0700 Subject: [PATCH 12/17] Return correct path in native executable task Bonus: throw an error if native building fails --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 569d63969..f96f13c8c 100644 --- a/build.sbt +++ b/build.sbt @@ -1107,8 +1107,8 @@ lazy val sbtClientProj = (project in file("client")) } thread.start() proc.waitFor(5, java.util.concurrent.TimeUnit.MINUTES) + assert(proc.exitValue == 0, s"Exit value ${proc.exitValue} was nonzero") nativeExecutablePath.value - file("").toPath }, graalNativeImageOptions := Seq( "--no-fallback", From 6faf460a1bf00f545a80d3011739cff0d175a780 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Sun, 5 Jul 2020 11:17:44 -0700 Subject: [PATCH 13/17] Add loop to SelectMainClass Rather than exiting when the user enters an invalid line, we can reprompt them. --- run/src/main/scala/sbt/SelectMainClass.scala | 27 +++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/run/src/main/scala/sbt/SelectMainClass.scala b/run/src/main/scala/sbt/SelectMainClass.scala index 62ee60bef..74803fb07 100644 --- a/run/src/main/scala/sbt/SelectMainClass.scala +++ b/run/src/main/scala/sbt/SelectMainClass.scala @@ -9,6 +9,7 @@ package sbt import sbt.internal.util.ConsoleAppender.ClearScreenAfterCursor import sbt.internal.util.Util.{ AnyOps, none } +import scala.annotation.tailrec object SelectMainClass { // Some(SimpleReader.readLine _) @@ -21,14 +22,22 @@ object SelectMainClass { case head :: Nil => Some(head) case multiple => promptIfMultipleChoices.flatMap { prompt => - val header = "\nMultiple main classes detected. Select one to run:\n" - val classes = multiple.zipWithIndex - .map { case (className, index) => s" [${index + 1}] $className" } - .mkString("\n") - println(ClearScreenAfterCursor + header + classes + "\n") - - val line = trim(prompt("Enter number: ")) - toInt(line, multiple.length) map multiple.apply + @tailrec def loop(): Option[String] = { + val header = "\nMultiple main classes detected. Select one to run:\n" + val classes = multiple.zipWithIndex + .map { case (className, index) => s" [${index + 1}] $className" } + .mkString("\n") + println(ClearScreenAfterCursor + header + classes + "\n") + val line = trim(prompt("Enter number: ")) + // An empty line usually means the user typed + if (line.nonEmpty) { + toInt(line, multiple.length) map multiple.apply match { + case None => loop() + case r => r + } + } else None + } + loop() } } } @@ -44,7 +53,7 @@ object SelectMainClass { } } catch { case nfe: NumberFormatException => - println("Invalid number: " + nfe.toString) + println(s"Invalid number: '$s'") none } } From e1c9ed5a55e2e9171be5d7b9a3dcc44dc2b5e75a Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Tue, 7 Jul 2020 10:38:24 -0700 Subject: [PATCH 14/17] Buffer terminal lines It is useful to store a buffer of the lines written to each terminal. We can use those lines to replay the terminal log lines to a different client. This is particularly nice when a remote client connects to sbt while it's booting. We can show the remote client all the lines displayed by the console prior to the client connecting. --- .../sbt/internal/util/ProgressState.scala | 15 ++++++++++---- .../scala/sbt/internal/util/Terminal.scala | 15 +++++++++++++- .../java/sbt/internal/BootServerSocket.java | 20 +++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/ProgressState.scala b/internal/util-logging/src/main/scala/sbt/internal/util/ProgressState.scala index d8b77ef53..ca2c3a99f 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/ProgressState.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/ProgressState.scala @@ -8,16 +8,18 @@ package sbt.internal.util import java.io.PrintStream +import java.util.concurrent.ArrayBlockingQueue import java.util.concurrent.atomic.{ AtomicInteger, AtomicReference } import sbt.internal.util.ConsoleAppender.{ ClearScreenAfterCursor, CursorLeft1000, DeleteLine, - cursorUp, + cursorUp } import scala.collection.mutable.ArrayBuffer +import scala.collection.JavaConverters._ private[sbt] final class ProgressState( val progressLines: AtomicReference[Seq[String]], @@ -41,6 +43,9 @@ private[sbt] final class ProgressState( padding.set(0) currentLineBytes.set(new ArrayBuffer[Byte]) } + private[this] val lineBuffer = new ArrayBlockingQueue[String](300) + private[util] def getLines: Seq[String] = lineBuffer.asScala.toVector + private[this] def appendLine(line: String) = while (!lineBuffer.offer(line)) { lineBuffer.poll } private[util] def clearBytes(): Unit = { val pad = padding.get if (currentLineBytes.get.isEmpty && pad > 0) padding.decrementAndGet() @@ -62,10 +67,12 @@ private[sbt] final class ProgressState( if (lines.contains(System.lineSeparator)) { currentLineBytes.set(new ArrayBuffer[Byte]) if (!lines.endsWith(System.lineSeparator)) { - lines - .split(System.lineSeparator) - .lastOption + val allLines = lines.split(System.lineSeparator) + allLines.dropRight(1).foreach(appendLine) + allLines.lastOption .foreach(currentLineBytes.get ++= _.getBytes("UTF-8")) + } else if (lines.contains(System.lineSeparator)) { + lines.split(System.lineSeparator).foreach(appendLine) } } } diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala index d59679ee5..dacba581c 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala @@ -109,6 +109,16 @@ trait Terminal extends AutoCloseable { */ private[sbt] def getLastLine: Option[String] + /** + * Returns the buffered lines that have been written to the terminal. The + * main use case is to display the system startup log lines when a client + * connects to a booting server. This could also be used to implement a more + * tmux like experience where multiple clients connect to the same console. + * + * @return the lines + */ + private[sbt] def getLines: Seq[String] + private[sbt] def getBooleanCapability(capability: String, jline3: Boolean): Boolean private[sbt] def getNumericCapability(capability: String, jline3: Boolean): Integer private[sbt] def getStringCapability(capability: String, jline3: Boolean): String @@ -328,6 +338,7 @@ object Terminal { override def close(): Unit = {} override private[sbt] def write(bytes: Int*): Unit = t.write(bytes: _*) override def getLastLine: Option[String] = t.getLastLine + override def getLines: Seq[String] = t.getLines override private[sbt] def name: String = t.name } private[sbt] def get: Terminal = ProxyTerminal @@ -731,7 +742,7 @@ object Terminal { } } - private[sbt] def console: Terminal = consoleTerminalHolder.get match { + def console: Terminal = consoleTerminalHolder.get match { case null => throw new IllegalStateException("Uninitialized terminal.") case term => term } @@ -808,6 +819,7 @@ object Terminal { } def throwIfClosed[R](f: => R): R = if (isStopped.get) throw new ClosedChannelException else f override def getLastLine: Option[String] = progressState.currentLine + override def getLines: Seq[String] = progressState.getLines private val combinedOutputStream = new OutputStream { override def write(b: Int): Unit = { @@ -868,6 +880,7 @@ object Terminal { override def getBooleanCapability(capability: String, jline3: Boolean): Boolean = false override def getHeight: Int = 0 override def getLastLine: Option[String] = None + override def getLines: Seq[String] = Nil override def getLineHeightAndWidth(line: String): (Int, Int) = (0, 0) override def getNumericCapability(capability: String, jline3: Boolean): Integer = null override def getStringCapability(capability: String, jline3: Boolean): String = null diff --git a/main-command/src/main/java/sbt/internal/BootServerSocket.java b/main-command/src/main/java/sbt/internal/BootServerSocket.java index 06ba74de2..c519573fd 100644 --- a/main-command/src/main/java/sbt/internal/BootServerSocket.java +++ b/main-command/src/main/java/sbt/internal/BootServerSocket.java @@ -35,6 +35,7 @@ import org.scalasbt.ipcsocket.UnixDomainSocket; import org.scalasbt.ipcsocket.Win32NamedPipeServerSocket; import org.scalasbt.ipcsocket.Win32NamedPipeSocket; import org.scalasbt.ipcsocket.Win32SecurityLevel; +import sbt.internal.util.Terminal; import xsbti.AppConfiguration; /** @@ -102,6 +103,16 @@ public class BootServerSocket implements AutoCloseable { service.submit( () -> { try { + Terminal.console() + .getLines() + .foreach( + l -> { + try { + write((l + System.lineSeparator()).getBytes("UTF-8")); + } catch (final IOException e) { + } + return 0; + }); final InputStream inputStream = socket.getInputStream(); while (alive.get()) { try { @@ -134,6 +145,15 @@ public class BootServerSocket implements AutoCloseable { } } + private void write(final byte[] b) { + try { + if (alive.get()) socket.getOutputStream().write(b); + } catch (final IOException e) { + alive.set(false); + close(); + } + } + private void write(final byte[] b, final int offset, final int len) { try { if (alive.get()) socket.getOutputStream().write(b, offset, len); From bc4fe0a31ac1a7ac6b3fd7c45fd47e0e33366813 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Tue, 7 Jul 2020 16:52:52 -0700 Subject: [PATCH 15/17] Cross build collectionProj --- build.sbt | 7 ++++++- .../main/scala-2.12/sbt/internal/util/Par.scala | 13 +++++++++++++ .../main/scala-2.13/sbt/internal/util/Par.scala | 14 ++++++++++++++ .../src/main/scala/sbt/internal/util/PMap.scala | 2 +- .../main/scala/sbt/internal/util/Settings.scala | 6 ++---- project/Dependencies.scala | 2 +- 6 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 internal/util-collection/src/main/scala-2.12/sbt/internal/util/Par.scala create mode 100644 internal/util-collection/src/main/scala-2.13/sbt/internal/util/Par.scala diff --git a/build.sbt b/build.sbt index f96f13c8c..1f9877f24 100644 --- a/build.sbt +++ b/build.sbt @@ -108,7 +108,7 @@ def commonBaseSettings: Seq[Setting[_]] = Def.settings( ) def commonSettings: Seq[Setting[_]] = commonBaseSettings :+ - addCompilerPlugin("org.spire-math" % "kind-projector" % "0.9.4" cross CrossVersion.binary) + addCompilerPlugin("org.typelevel" %% "kind-projector" % "0.11.0" cross CrossVersion.full) def utilCommonSettings: Seq[Setting[_]] = commonBaseSettings :+ (crossScalaVersions := (scala212 :: scala213 :: Nil)) @@ -246,9 +246,14 @@ lazy val bundledLauncherProj = val collectionProj = (project in file("internal") / "util-collection") .settings( testedBaseSettings, + utilCommonSettings, Util.keywordsSettings, name := "Collections", libraryDependencies ++= Seq(sjsonNewScalaJson.value), + libraryDependencies ++= (CrossVersion.partialVersion(scalaVersion.value) match { + case Some((2, major)) if major <= 12 => Seq() + case _ => Seq("org.scala-lang.modules" %% "scala-parallel-collections" % "0.2.0") + }), mimaSettings, mimaBinaryIssueFilters ++= Seq( // Added private[sbt] method to capture State attributes. diff --git a/internal/util-collection/src/main/scala-2.12/sbt/internal/util/Par.scala b/internal/util-collection/src/main/scala-2.12/sbt/internal/util/Par.scala new file mode 100644 index 000000000..34e939244 --- /dev/null +++ b/internal/util-collection/src/main/scala-2.12/sbt/internal/util/Par.scala @@ -0,0 +1,13 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal.util +import scala.collection.parallel.ParSeq + +private[util] object Par { + def apply[R](s: Seq[R]): ParSeq[R] = s.par +} diff --git a/internal/util-collection/src/main/scala-2.13/sbt/internal/util/Par.scala b/internal/util-collection/src/main/scala-2.13/sbt/internal/util/Par.scala new file mode 100644 index 000000000..d7fa1d766 --- /dev/null +++ b/internal/util-collection/src/main/scala-2.13/sbt/internal/util/Par.scala @@ -0,0 +1,14 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal.util +import scala.collection.parallel.CollectionConverters._ +import scala.collection.parallel.ParSeq + +private[util] object Par { + def apply[R](s: Seq[R]): ParSeq[R] = s.par +} diff --git a/internal/util-collection/src/main/scala/sbt/internal/util/PMap.scala b/internal/util-collection/src/main/scala/sbt/internal/util/PMap.scala index 356f6973b..c14f975c2 100644 --- a/internal/util-collection/src/main/scala/sbt/internal/util/PMap.scala +++ b/internal/util-collection/src/main/scala/sbt/internal/util/PMap.scala @@ -66,7 +66,7 @@ object IMap { put(k, f(this get k getOrElse init)) def mapValues[V2[_]](f: V ~> V2) = - new IMap0[K, V2](backing.mapValues(x => f(x))) + new IMap0[K, V2](Map(backing.iterator.map { case (k, v) => k -> f(v) }.toArray: _*)) def mapSeparate[VL[_], VR[_]](f: V ~> λ[T => Either[VL[T], VR[T]]]) = { val mapped = backing.iterator.map { diff --git a/internal/util-collection/src/main/scala/sbt/internal/util/Settings.scala b/internal/util-collection/src/main/scala/sbt/internal/util/Settings.scala index b575eb1ec..f3af5e7e2 100644 --- a/internal/util-collection/src/main/scala/sbt/internal/util/Settings.scala +++ b/internal/util-collection/src/main/scala/sbt/internal/util/Settings.scala @@ -7,8 +7,6 @@ package sbt.internal.util -import scala.language.existentials - import Types._ import sbt.util.Show import Util.{ nil, nilSeq } @@ -242,7 +240,7 @@ trait Init[ScopeType] { if (s.definitive) Vector(s) else ss :+ s def addLocal(init: Seq[Setting[_]])(implicit scopeLocal: ScopeLocal): Seq[Setting[_]] = - init.par.map(_.dependencies flatMap scopeLocal).toVector.flatten ++ init + Par(init).map(_.dependencies flatMap scopeLocal).toVector.flatten ++ init def delegate(sMap: ScopedMap)( implicit delegates: ScopeType => Seq[ScopeType], @@ -465,7 +463,7 @@ trait Init[ScopeType] { def dependencies = settings.flatMap(_.dependencies) // This is mainly for use in the cyclic reference error message override def toString = - s"Derived settings for ${key.label}, ${definedAtString(settings.map(_.setting))}" + s"Derived settings for ${key.label}, ${definedAtString(settings.map(_.setting).toSeq)}" } // separate `derived` settings from normal settings (`defs`) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 5cd96f44c..1763b4f88 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -89,7 +89,7 @@ object Dependencies { val jansi = "org.fusesource.jansi" % "jansi" % "1.18" val scalatest = "org.scalatest" %% "scalatest" % "3.0.8" val scalacheck = "org.scalacheck" %% "scalacheck" % "1.14.0" - val specs2 = "org.specs2" %% "specs2-junit" % "4.0.1" + val specs2 = "org.specs2" %% "specs2-junit" % "4.10.0" val junit = "junit" % "junit" % "4.11" val scalaVerify = "com.eed3si9n.verify" %% "verify" % "0.2.0" val templateResolverApi = "org.scala-sbt" % "template-resolver" % "0.1" From 25e83d8feccd159bdb32e796720efdb50dc49e2e Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Thu, 9 Jul 2020 13:02:22 -0700 Subject: [PATCH 16/17] Add Terminal.withRawOutput api In the scala console, it's essential that we not process the bytes that are written to the terminal by jline. --- .../scala/sbt/internal/util/LineReader.scala | 4 ++-- .../scala/sbt/internal/util/Terminal.scala | 21 +++++++++++++------ main-actions/src/main/scala/sbt/Console.scala | 4 +++- .../sbt/internal/client/NetworkClient.scala | 2 +- main/src/main/scala/sbt/Defaults.scala | 2 +- main/src/main/scala/sbt/Main.scala | 4 ++-- .../main/scala/sbt/internal/Continuous.scala | 2 +- 7 files changed, 25 insertions(+), 14 deletions(-) mode change 100755 => 100644 main/src/main/scala/sbt/Defaults.scala diff --git a/internal/util-complete/src/main/scala/sbt/internal/util/LineReader.scala b/internal/util-complete/src/main/scala/sbt/internal/util/LineReader.scala index 836a0ba6d..34526a948 100644 --- a/internal/util-complete/src/main/scala/sbt/internal/util/LineReader.scala +++ b/internal/util-complete/src/main/scala/sbt/internal/util/LineReader.scala @@ -79,7 +79,7 @@ object LineReader { historyPath.foreach(f => reader.setVariable(JLineReader.HISTORY_FILE, f)) new LineReader { override def readLine(prompt: String, mask: Option[Char]): Option[String] = { - try terminal.withRawSystemIn { + try terminal.withRawInput { Option(mask.map(reader.readLine(prompt, _)).getOrElse(reader.readLine(prompt))) } catch { case e: EndOfFileException => @@ -240,7 +240,7 @@ private[sbt] object JLine { } @deprecated("Avoid referencing JLine directly.", "1.4.0") - def withJLine[T](action: => T): T = Terminal.get.withRawSystemIn(action) + def withJLine[T](action: => T): T = Terminal.get.withRawInput(action) @deprecated("Use LineReader.simple instead", "1.4.0") def simple( diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala index dacba581c..d98b01b06 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala @@ -127,11 +127,12 @@ trait Terminal extends AutoCloseable { private[sbt] def setSize(width: Int, height: Int): Unit private[sbt] def name: String - private[sbt] def withRawSystemIn[T](f: => T): T = f + private[sbt] def withRawInput[T](f: => T): T = f private[sbt] def withCanonicalIn[T](f: => T): T = f private[sbt] def write(bytes: Int*): Unit private[sbt] def printStream: PrintStream private[sbt] def withPrintStream[T](f: PrintStream => T): T + private[sbt] def withRawOutput[R](f: => R): R private[sbt] def restore(): Unit = {} private[sbt] val progressState = new ProgressState(1) private[this] val promptHolder: AtomicReference[Prompt] = new AtomicReference(Prompt.Running) @@ -330,10 +331,11 @@ object Terminal { override private[sbt] def setAttributes(attributes: Map[String, String]): Unit = t.setAttributes(attributes) override private[sbt] def setSize(width: Int, height: Int): Unit = t.setSize(width, height) - override def withRawSystemIn[T](f: => T): T = t.withRawSystemIn(f) + override def withRawInput[T](f: => T): T = t.withRawInput(f) override def withCanonicalIn[T](f: => T): T = t.withCanonicalIn(f) override def printStream: PrintStream = t.printStream override def withPrintStream[T](f: PrintStream => T): T = t.withPrintStream(f) + override private[sbt] def withRawOutput[R](f: => R): R = t.withRawOutput(f) override def restore(): Unit = t.restore() override def close(): Unit = {} override private[sbt] def write(bytes: Int*): Unit = t.write(bytes: _*) @@ -737,8 +739,8 @@ object Terminal { private[sbt] def createReader(term: Terminal, prompt: Prompt): ConsoleReader = { new ConsoleReader(term.inputStream, term.outputStream, term.toJLine) { override def readLine(prompt: String, mask: Character): String = - term.withRawSystemIn(super.readLine(prompt, mask)) - override def readLine(prompt: String): String = term.withRawSystemIn(super.readLine(prompt)) + term.withRawInput(super.readLine(prompt, mask)) + override def readLine(prompt: String): String = term.withRawInput(super.readLine(prompt)) } } @@ -782,7 +784,7 @@ object Terminal { override private[sbt] def setSize(width: Int, height: Int): Unit = system.setSize(new org.jline.terminal.Size(width, height)) - override def withRawSystemIn[T](f: => T): T = term.synchronized { + override def withRawInput[T](f: => T): T = term.synchronized { val prev = JLine3.enterRawMode(system) try f catch { case _: InterruptedIOException => throw new InterruptedException } finally { @@ -812,6 +814,7 @@ object Terminal { val out: OutputStream, override private[sbt] val name: String ) extends Terminal { + private[this] val rawMode = new AtomicBoolean(false) private[this] val writeLock = new AnyRef private[this] val writeableInputStream = in match { case w: WriteableInputStream => w @@ -852,7 +855,7 @@ object Terminal { override def flush(): Unit = combinedOutputStream.flush() } private def doWrite(bytes: Array[Byte]): Unit = - progressState.write(TerminalImpl.this, bytes, rawPrintStream, hasProgress.get) + progressState.write(TerminalImpl.this, bytes, rawPrintStream, hasProgress.get && !rawMode.get) override private[sbt] val printStream: PrintStream = new LinePrintStream(outputStream) override def inputStream: InputStream = writeableInputStream @@ -867,6 +870,11 @@ object Terminal { case _ => (0, 0) } + private[sbt] def withRawOutput[R](f: => R): R = { + rawMode.set(true) + try f + finally rawMode.set(false) + } private[this] val rawPrintStream: PrintStream = new LinePrintStream(combinedOutputStream) override def withPrintStream[T](f: PrintStream => T): T = writeLock.synchronized(f(rawPrintStream)) @@ -904,5 +912,6 @@ object Terminal { new PrintStream(outputStream, false) override private[sbt] def withPrintStream[T](f: java.io.PrintStream => T): T = f(printStream) override private[sbt] def write(bytes: Int*): Unit = {} + override private[sbt] def withRawOutput[R](f: => R): R = f } } diff --git a/main-actions/src/main/scala/sbt/Console.scala b/main-actions/src/main/scala/sbt/Console.scala index 76332f3e0..8e9d40984 100644 --- a/main-actions/src/main/scala/sbt/Console.scala +++ b/main-actions/src/main/scala/sbt/Console.scala @@ -66,7 +66,9 @@ final class Console(compiler: AnalyzingCompiler) { val previous = sys.props.get("scala.color").getOrElse("auto") try { sys.props("scala.color") = if (terminal.isColorEnabled) "true" else "false" - terminal.withRawSystemIn(Run.executeTrapExit(console0, log)) + terminal.withRawOutput { + terminal.withRawInput(Run.executeTrapExit(console0, log)) + } } finally { sys.props("scala.color") = previous } diff --git a/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala b/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala index a9483247e..5a642c22b 100644 --- a/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala +++ b/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala @@ -885,7 +885,7 @@ class NetworkClient( if (!stopped.get()) read() } } - try Terminal.console.withRawSystemIn(read()) + try Terminal.console.withRawInput(read()) catch { case NonFatal(_) => stopped.set(true) } } diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala old mode 100755 new mode 100644 index edec7feb3..19dacc16f --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -1503,7 +1503,7 @@ object Defaults extends BuildCommon { Some(s => { def print(st: String) = { scala.Console.out.print(st); scala.Console.out.flush() } print(s) - Terminal.get.withRawSystemIn { + Terminal.get.withRawInput { try Terminal.get.inputStream.read match { case -1 | -2 => None case b => diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index 47764e91a..c9862bedc 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -114,7 +114,7 @@ private[sbt] object xMain { case _: ServerAlreadyBootingException if System.console != null && !Terminal.startedByRemoteClient => println("sbt server is already booting. Create a new server? y/n (default y)") - val exit = Terminal.get.withRawSystemIn(System.in.read) match { + val exit = Terminal.get.withRawInput(System.in.read) match { case 110 => Some(Exit(1)) case _ => None } @@ -835,7 +835,7 @@ object BuiltinCommands { @tailrec private[this] def doLoadFailed(s: State, loadArg: String): State = { s.log.warn("Project loading failed: (r)etry, (q)uit, (l)ast, or (i)gnore? (default: r)") - val result = try Terminal.get.withRawSystemIn(System.in.read) match { + val result = try Terminal.get.withRawInput(System.in.read) match { case -1 => 'q'.toInt case b => b } catch { case _: ClosedChannelException => 'q' } diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala index e52a041a8..3cbc9ed1b 100644 --- a/main/src/main/scala/sbt/internal/Continuous.scala +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -759,7 +759,7 @@ private[sbt] object Continuous extends DeprecatedContinuous { } } - terminal.withRawSystemIn(impl()) + terminal.withRawInput(impl()) } } From 9dc3c6b17f07e163081b9411a3a231bd7f9a2098 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Thu, 9 Jul 2020 16:09:48 -0700 Subject: [PATCH 17/17] Use terminal printstream in CheckBuildSources The build source check is evaluated at times when we can't be completely sure that global logger is pointing at the terminal that initiated the reload (which may be a passive watch client). To work around this, we can inspect the exec to determine which terminal initiated the check and write any output directly to that terminal. --- main/src/main/scala/sbt/MainLoop.scala | 24 +++++----- .../main/scala/sbt/internal/Continuous.scala | 9 ++-- .../sbt/internal/nio/CheckBuildSources.scala | 44 ++++++++++++++++--- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/main/src/main/scala/sbt/MainLoop.scala b/main/src/main/scala/sbt/MainLoop.scala index e50a1465f..53a662371 100644 --- a/main/src/main/scala/sbt/MainLoop.scala +++ b/main/src/main/scala/sbt/MainLoop.scala @@ -184,7 +184,8 @@ object MainLoop { /** This is the main function State transfer function of the sbt command processing. */ def processCommand(exec: Exec, state: State): State = { val channelName = exec.source map (_.channelName) - StandardMain.exchange notifyStatus + val exchange = StandardMain.exchange + exchange notifyStatus ExecStatusEvent("Processing", channelName, exec.execId, Vector()) try { def process(): State = { @@ -197,9 +198,9 @@ object MainLoop { state.put(sbt.Keys.currentTaskProgress, new Keys.TaskProgress(progress)) } else state } - StandardMain.exchange.setState(progressState) - StandardMain.exchange.setExec(Some(exec)) - StandardMain.exchange.unprompt(ConsoleUnpromptEvent(exec.source), force = false) + exchange.setState(progressState) + exchange.setExec(Some(exec)) + exchange.unprompt(ConsoleUnpromptEvent(exec.source), force = false) val newState = Command.process(exec.commandLine, progressState) if (exec.execId.fold(true)(!_.startsWith(networkExecPrefix)) && !exec.commandLine.startsWith(networkExecPrefix)) { @@ -210,26 +211,25 @@ object MainLoop { newState.remainingCommands.toVector map (_.commandLine), exitCode(newState, state), ) - StandardMain.exchange.respondStatus(doneEvent) + exchange.respondStatus(doneEvent) } - StandardMain.exchange.setExec(None) + exchange.setExec(None) newState.get(sbt.Keys.currentTaskProgress).foreach(_.progress.stop()) newState.remove(sbt.Keys.currentTaskProgress) } state.get(CheckBuildSourcesKey) match { case Some(cbs) => - if (!cbs.needsReload(state, state.globalLogging.full, exec.commandLine)) process() + if (!cbs.needsReload(state, exec)) process() else { - if (exec.commandLine.startsWith(SetTerminal)) - exec +: Exec("reload", None, None) +: state.remove(CheckBuildSourcesKey) - else - Exec("reload", None, None) +: exec +: state.remove(CheckBuildSourcesKey) + val isSetTerminal = exec.commandLine.startsWith(SetTerminal) + if (isSetTerminal) exec +: Exec("reload", None) +: state.remove(CheckBuildSourcesKey) + else Exec("reload", None) +: exec +: state.remove(CheckBuildSourcesKey) } case _ => process() } } catch { case err: JsonRpcResponseError => - StandardMain.exchange.respondError(err, exec.execId, channelName.map(CommandSource(_))) + exchange.respondError(err, exec.execId, channelName.map(CommandSource(_))) throw err case err: Throwable => val errorEvent = ExecStatusEvent( diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala index 3cbc9ed1b..f9b93fc26 100644 --- a/main/src/main/scala/sbt/internal/Continuous.scala +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -320,7 +320,7 @@ private[sbt] object Continuous extends DeprecatedContinuous { val (nextFileEvent, cleanupFileMonitor): ( Int => Option[(Watch.Event, Watch.Action)], () => Unit - ) = getFileEvents(configs, logger, state, commands, fileStampCache) + ) = getFileEvents(configs, logger, state, commands, fileStampCache, channel.name) val executor = new WatchExecutor(channel.name) val nextEvent: Int => Watch.Action = combineInputAndFileEvents(nextInputEvent, nextFileEvent, message, logger, logger, executor) @@ -420,7 +420,8 @@ private[sbt] object Continuous extends DeprecatedContinuous { logger: Logger, state: State, commands: Seq[String], - fileStampCache: FileStamp.Cache + fileStampCache: FileStamp.Cache, + channel: String, )(implicit extracted: Extracted): (Int => Option[(Watch.Event, Watch.Action)], () => Unit) = { val trackMetaBuild = configs.forall(_.watchSettings.trackMetaBuild) val buildGlobs = @@ -554,7 +555,9 @@ private[sbt] object Continuous extends DeprecatedContinuous { getWatchEvent(forceTrigger = false).flatMap { e => state.get(CheckBuildSources.CheckBuildSourcesKey) match { case Some(cbs) => - if (cbs.needsReload(state, logger, "")) Some(e -> Watch.Reload) else None + if (cbs.needsReload(state, Exec("", Some(CommandSource(channel))))) + Some(e -> Watch.Reload) + else None case None => Some(e -> Watch.Reload) } diff --git a/main/src/main/scala/sbt/internal/nio/CheckBuildSources.scala b/main/src/main/scala/sbt/internal/nio/CheckBuildSources.scala index 53c29cf43..0bd9ad1fc 100644 --- a/main/src/main/scala/sbt/internal/nio/CheckBuildSources.scala +++ b/main/src/main/scala/sbt/internal/nio/CheckBuildSources.scala @@ -10,13 +10,13 @@ package internal.nio import java.nio.file.Path import java.util.concurrent.atomic.{ AtomicBoolean, AtomicReference } -import sbt.BasicCommandStrings.{ RebootCommand, Shutdown, TerminateAction } +import sbt.BasicCommandStrings.{ RebootCommand, SetTerminal, Shutdown, TerminateAction } import sbt.Keys.{ baseDirectory, pollInterval, state } import sbt.Scope.Global import sbt.SlashSyntax0._ import sbt.internal.CommandStrings.LoadProject import sbt.internal.SysProp -import sbt.internal.util.AttributeKey +import sbt.internal.util.{ AttributeKey, Terminal } import sbt.io.syntax._ import sbt.nio.FileChanges import sbt.nio.FileStamp @@ -28,6 +28,7 @@ import sbt.util.Logger import scala.annotation.tailrec import scala.concurrent.duration.{ Deadline => SDeadline, _ } +import scala.io.AnsiColor /** * This class is used to determine whether sbt needs to automatically reload @@ -103,7 +104,28 @@ private[sbt] class CheckBuildSources extends AutoCloseable { !resetState } @inline private def forceCheck = fileTreeRepository.isEmpty - private[sbt] def needsReload(state: State, logger: Logger, cmd: String) = { + private[sbt] def needsReload( + state: State, + exec: Exec + ): Boolean = { + val isSetTerminal = exec.commandLine.startsWith(SetTerminal) + val name = + if (isSetTerminal) + exec.commandLine.split(s"$SetTerminal ").lastOption.filterNot(_.isEmpty) + else exec.source.map(_.channelName) + val loggerOrTerminal = + name.flatMap(StandardMain.exchange.channelForName(_).map(_.terminal)) match { + case Some(t) => Right(t) + case _ => Left(state.globalLogging.full) + } + + needsReload(state, loggerOrTerminal, exec.commandLine) + } + private def needsReload( + state: State, + loggerOrTerminal: Either[Logger, Terminal], + cmd: String + ): Boolean = { (needCheck(state, cmd) && (forceCheck || needUpdate.compareAndSet(true, false))) && { val extracted = Project.extract(state) val onChanges = extracted.get(Global / onChangedBuildSource) @@ -122,14 +144,24 @@ private[sbt] class CheckBuildSources extends AutoCloseable { else "") val prefix = rawPrefix.linesIterator.filterNot(_.trim.isEmpty).mkString("\n") if (onChanges == ReloadOnSourceChanges) { - logger.info(s"$prefix\nReloading sbt...") + val msg = s"$prefix\nReloading sbt..." + loggerOrTerminal match { + case Right(t) => msg.linesIterator.foreach(l => t.printStream.println(s"[info] $l")) + case Left(l) => l.info(msg) + } true } else { val tail = "Apply these changes by running `reload`.\nAutomatically reload the " + "build when source changes are detected by setting " + "`Global / onChangedBuildSource := ReloadOnSourceChanges`.\nDisable this " + "warning by setting `Global / onChangedBuildSource := IgnoreSourceChanges`." - logger.warn(s"$prefix\n$tail") + val msg = s"$prefix\n$tail" + loggerOrTerminal match { + case Right(t) => + val prefix = s"[${Def.withColor("warn", Some(AnsiColor.YELLOW), t.isColorEnabled)}]" + msg.linesIterator.foreach(l => t.printStream.println(s"$prefix $l")) + case Left(l) => l.warn(msg) + } false } case _ => false @@ -160,7 +192,7 @@ private[sbt] object CheckBuildSources { private[sbt] def needReloadImpl: Def.Initialize[Task[StateTransform]] = Def.task { val st = state.value st.get(CheckBuildSourcesKey) match { - case Some(cbs) if (cbs.needsReload(st, st.globalLogging.full, "")) => + case Some(cbs) if (cbs.needsReload(st, Exec("", None))) => StateTransform("reload" :: (_: State)) case _ => StateTransform(identity) }