Merge pull request #8705 from eed3si9n/wip/console-task-follow-up

[2.x] client-side console and fixes
This commit is contained in:
eugene yokota 2026-02-07 01:07:58 -05:00 committed by GitHub
commit 4681dc714c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 187 additions and 98 deletions

View File

@ -10,6 +10,7 @@ package sbt
package internal
import java.io.File
import java.net.URL
import java.nio.file.Paths
import sbt.internal.inc.{
AnalyzingCompiler,
@ -34,11 +35,13 @@ class ConsoleMain:
val si = scalaInstance(config.scalaInstanceConfig)
val compiler = analyzingCompiler(config, si)
given log: Logger = ConsoleMain.consoleLogger
val externalCp = config.externalDependencyJars.map(Paths.get(_))
val cpFiles = externalCp.map(_.toFile)
val classpathJars = config.classpathJars.map(Paths.get(_))
val products = config.products.map(Paths.get(_))
val cpFiles = products.map(_.toFile()) ++ classpathJars.map(_.toFile())
IO.withTemporaryDirectory: tempDir =>
val fullCp = cpFiles ++ si.allJars
val loader = ClasspathUtil.makeLoader(fullCp.map(_.toPath), si, tempDir.toPath)
val loader =
ClasspathUtil.makeLoader(fullCp.map(_.toPath), ConsoleMain.jlineLoader, si, tempDir.toPath)
runConsole(
compiler = compiler,
classpath = cpFiles,
@ -98,8 +101,7 @@ class ConsoleMain:
.distinct
val allJars = libraryJars ++ compilerJars ++ extraToolJars
// Use parent class loader for JLine to avoid conflicts
val jlineLoader = classOf[org.jline.terminal.Terminal].getClassLoader
val libraryLoader = ClasspathUtil.toLoader(libraryJars, jlineLoader)
val libraryLoader = ClasspathUtil.toLoader(libraryJars, ConsoleMain.jlineLoader)
val compilerLoader = ClasspathUtil.toLoader(compilerJars, libraryLoader)
val fullLoader =
if extraToolJars.isEmpty then compilerLoader
@ -128,6 +130,18 @@ object ConsoleMain:
case Level.Warn => scala.Console.err.println(s"[warn] $message")
case Level.Error => scala.Console.err.println(s"[error] $message")
class FilteredLoader(parent: ClassLoader) extends ClassLoader(parent):
override final def loadClass(className: String, resolve: Boolean): Class[?] =
if className.startsWith("org.jline.") || className.startsWith("java.") || className
.startsWith("javax.") || className.startsWith("sun.")
then super.loadClass(className, resolve)
else throw new ClassNotFoundException(className)
override def getResources(name: String): java.util.Enumeration[URL] = null
override def getResource(name: String): URL = null
end FilteredLoader
lazy val jlineLoader =
FilteredLoader(classOf[org.jline.terminal.Terminal].getClassLoader)
def main(args: Array[String]): Unit =
args.toList match
case Nil =>

View File

@ -51,15 +51,7 @@ private[sbt] object ForkConsole:
args: List[String],
forkOptions: ForkOptions,
): Int =
val jlineJars = Seq(
IO.classLocationPath(classOf[jline.Terminal]),
IO.classLocationPath(classOf[org.jline.terminal.Terminal]),
IO.classLocationPath(classOf[org.jline.reader.LineReader]),
IO.classLocationPath(classOf[org.jline.utils.InfoCmp]),
IO.classLocationPath(classOf[org.jline.keymap.KeyMap[?]]),
).distinct
val fullCp = (classpath ++ jlineJars).distinct
val fullCp = classpath.distinct
// Build environment variables for proper terminal handling
val termEnv = sys.env.get("TERM").getOrElse("xterm-256color")
val baseEnv = forkOptions.envVars ++ Map(
@ -117,6 +109,11 @@ private[sbt] object ForkConsole:
IO.classLocationPath(classOf[sbt.internal.inc.classpath.ClasspathUtil.type]),
IO.classLocationPath(classOf[sbt.util.Logger]),
IO.classLocationPath(classOf[sjsonnew.JsonFormat[?]]),
IO.classLocationPath(classOf[jline.Terminal]),
IO.classLocationPath(classOf[org.jline.terminal.Terminal]),
IO.classLocationPath(classOf[org.jline.reader.LineReader]),
IO.classLocationPath(classOf[org.jline.utils.InfoCmp]),
IO.classLocationPath(classOf[org.jline.keymap.KeyMap[?]]),
)
(urls.map(u => Paths.get(u.toURI)) ++ extraJars).distinct
end ForkConsole

View File

@ -8,7 +8,7 @@
package sbt
import java.io.{ File, PrintWriter }
import java.io.File
import java.nio.file.{ Files, Path as NioPath }
import java.util.{ Optional, UUID }
import java.util.concurrent.TimeUnit
@ -30,7 +30,7 @@ import sbt.internal.CommandStrings.ExportStream
import sbt.internal.*
import sbt.internal.classpath.AlternativeZincUtil
import sbt.internal.inc.JavaInterfaceUtil.*
import sbt.internal.inc.classpath.{ ClasspathFilter, ClasspathUtil }
import sbt.internal.inc.classpath.ClasspathFilter
import sbt.internal.inc.{ CompileOutput, MappedFileConverter, Stamps, ZincLmUtil, ZincUtil }
import sbt.internal.librarymanagement.ivy.*
import sbt.internal.librarymanagement.mavenint.{
@ -1063,12 +1063,11 @@ object Defaults extends BuildCommon {
cache.get
},
compileIncSetup := Def.uncached(compileIncSetupTask.value),
console := Def.taskDyn {
if (console / fork).value then forkedConsoleTask
else Def.task(consoleTask.value)
}.value,
console := Compiler.consoleTask.value,
console / forkOptions := Def.uncached(Compiler.consoleForkOptions.value),
collectAnalyses := Definition.collectAnalysesTask.map(_ => ()).value,
consoleQuick := consoleQuickTask.value,
consoleQuick / forkOptions := Def.uncached((console / forkOptions).value),
discoveredMainClasses := compile
.map(discoverMainClasses)
.storeAs(discoveredMainClasses)
@ -2065,7 +2064,7 @@ object Defaults extends BuildCommon {
else Opts.doc.externalAPI(xapisFiles)
val options = sOpts ++ externalApiOpts
val scalac = cs.scalac match
case ac: AnalyzingCompiler => ac.onArgs(exported(s, "scaladoc"))
case ac: AnalyzingCompiler => ac.onArgs(Compiler.exported(s, "scaladoc"))
val docSrcFiles = if ScalaArtifacts.isScala3(sv) then tFiles else srcs
// todo: cache this
if docSrcFiles.nonEmpty then
@ -2112,67 +2111,11 @@ object Defaults extends BuildCommon {
}
def consoleProjectTask = ConsoleProject.consoleProjectTask
def consoleTask: Initialize[Task[Unit]] = consoleTask(fullClasspath, console)
def consoleTask: Initialize[Task[Unit]] =
Compiler.consoleTask(console, exportedProductJars, fullClasspathAsJars)
def consoleQuickTask = consoleTask(externalDependencyClasspath, consoleQuick)
def consoleTask(classpath: TaskKey[Classpath], task: TaskKey[?]): Initialize[Task[Unit]] =
Def.task {
val si = (task / scalaInstance).value
val s = streams.value
val cp = data((task / classpath).value)
val converter = fileConverter.value
val cpFiles = cp.map(converter.toPath).map(_.toFile())
val fullcp = (cpFiles ++ si.allJars).distinct
val tempDir = IO.createUniqueDirectory((task / taskTemporaryDirectory).value).toPath
val loader = ClasspathUtil.makeLoader(fullcp.map(_.toPath), si, tempDir)
val compiler =
(task / compilers).value.scalac match {
case ac: AnalyzingCompiler => ac.onArgs(exported(s, "scala"))
}
val sc = (task / scalacOptions).value
val ic = (task / initialCommands).value
val cc = (task / cleanupCommands).value
(new Console(compiler))(cpFiles, sc, loader, ic, cc)()(using s.log).get
println()
}
private def forkedConsoleTask: Initialize[Task[Unit]] =
Def.task {
import sbt.internal.worker.ConsoleConfig
val s = streams.value
val conv = fileConverter.value
val depsJars = (console / externalDependencyClasspath).value.toVector
.map(_.data)
.map(conv.toPath)
val siConfig = (console / scalaInstanceConfig).value
val bridgeJars = scalaCompilerBridgeJars.value
val config = ConsoleConfig(
scalaInstanceConfig = siConfig,
bridgeJars = bridgeJars.toVector.map(vf => conv.toPath(vf).toUri()),
externalDependencyJars = depsJars.map(_.toString),
scalacOptions = (console / scalacOptions).value.toVector,
initialCommands = (console / initialCommands).value,
cleanupCommands = (console / cleanupCommands).value,
)
val fo = (console / forkOptions).value
val terminal = ITerminal.console
s.log.info("running console (fork)")
try
terminal.restore()
val exitCode = ForkConsole(config, fo)
if exitCode != 0 then
throw MessageOnlyException(s"Forked console exited with code $exitCode")
finally terminal.restore()
println()
}
private def exported(w: PrintWriter, command: String): Seq[String] => Unit =
args => w.println((command +: args).mkString(" "))
private def exported(s: TaskStreams, command: String): Seq[String] => Unit = {
val w = s.text(ExportStream)
try exported(w, command)
finally w.close() // workaround for #937
}
Compiler.consoleTask(task, Def.task(Nil), classpath)
/**
* Handles traditional Scalac compilation. For non-pipelined compilation,
@ -2319,7 +2262,7 @@ object Defaults extends BuildCommon {
def onArgs(cs: Compilers) =
cs.withScalac(
cs.scalac match
case ac: AnalyzingCompiler => ac.onArgs(exported(x, "scalac"))
case ac: AnalyzingCompiler => ac.onArgs(Compiler.exported(x, "scalac"))
case x => x
)
def onProgress(s: Setup) =

View File

@ -9,9 +9,17 @@
package sbt
package internal
import java.io.File
import sbt.internal.inc.{ ScalaInstance, ZincLmUtil }
import sbt.internal.worker.ScalaInstanceConfig
import java.io.{ File, PrintWriter }
import sbt.BuildExtra.*
import sbt.Keys.Classpath
import sbt.internal.CommandStrings
import sbt.internal.inc.{ AnalyzingCompiler, ScalaInstance, ZincLmUtil }
import sbt.internal.inc.classpath.ClasspathUtil
import sbt.internal.worker.{ ClientJobParams, ScalaInstanceConfig }
import sbt.internal.worker.codec.JsonProtocol.given
import sbt.internal.util.{ Attributed, MessageOnlyException, Terminal as ITerminal }
import sbt.io.IO
import sbt.protocol.Serialization
import sbt.librarymanagement.{
Artifact,
Configuration,
@ -23,6 +31,7 @@ import sbt.librarymanagement.{
VersionNumber
}
import sbt.util.Logger
import sjsonnew.support.scalajson.unsafe.{ Converter, CompactPrinter }
import xsbti.{ HashedVirtualFileRef, ScalaProvider }
object Compiler:
@ -281,4 +290,123 @@ object Compiler:
val conv = Keys.fileConverter.value
jars.map(jar => (conv.toVirtualFile(jar.toPath()): HashedVirtualFileRef))
}
def consoleTask: Def.Initialize[Task[Unit]] =
consoleTask(Keys.console, Keys.exportedProductJars, Keys.fullClasspath)
def consoleTask(
task: TaskKey[?],
products: Def.Initialize[Task[Classpath]],
classpath: Def.Initialize[Task[Classpath]],
): Def.Initialize[Task[Unit]] =
Def.taskIf {
if (task / Keys.fork).value then forkedConsoleTask(task, products, classpath).value
else serverSideConsoleTask(task, products, classpath).value
}
private def serverSideConsoleTask(
task: TaskKey[?],
products: Def.Initialize[Task[Classpath]],
classpath: Def.Initialize[Task[Classpath]],
): Def.Initialize[Task[Unit]] =
Def.task {
val si = (task / Keys.scalaInstance).value
val s = Keys.streams.value
val cp = Attributed.data(classpath.value)
val converter = Keys.fileConverter.value
val cpFiles = cp.map(converter.toPath).map(_.toFile())
val fullcp = (cpFiles ++ si.allJars).distinct
val tempDir = IO.createUniqueDirectory((task / Keys.taskTemporaryDirectory).value).toPath
val loader = ClasspathUtil.makeLoader(fullcp.map(_.toPath), si, tempDir)
val compiler =
(task / Keys.compilers).value.scalac match
case ac: AnalyzingCompiler => ac.onArgs(exported(s, "scala"))
val sc = (task / Keys.scalacOptions).value
val ic = (task / Keys.initialCommands).value
val cc = (task / Keys.cleanupCommands).value
(new Console(compiler))(cpFiles, sc, loader, ic, cc)()(using s.log).get
println()
}
private def forkedConsoleTask(
task: TaskKey[?],
products: Def.Initialize[Task[Classpath]],
classpath: Def.Initialize[Task[Classpath]],
): Def.Initialize[Task[Unit]] =
Def.task {
import sbt.internal.worker.ConsoleConfig
val s = Keys.streams.value
val conv = Keys.fileConverter.value
val cside = (task / Keys.clientSide).value
val depsJars = (task / Keys.externalDependencyClasspath).value.toVector
.map(_.data)
.map(conv.toPath)
val siConfig = (Keys.console / Keys.scalaInstanceConfig).value
val bridgeJars = Keys.scalaCompilerBridgeJars.value
val state = Keys.state.value
val config = ConsoleConfig(
scalaInstanceConfig = siConfig,
bridgeJars = bridgeJars.toVector.map(vf => conv.toPath(vf).toUri()),
products = products.value.toVector.map(vf => conv.toPath(vf.data).toUri()),
classpathJars = classpath.value.toVector.map(vf => conv.toPath(vf.data).toUri()),
scalacOptions = (task / Keys.scalacOptions).value.toVector,
initialCommands = (task / Keys.initialCommands).value,
cleanupCommands = (task / Keys.cleanupCommands).value,
)
val fo = (task / Keys.forkOptions).value
val service = Keys.bgJobService.value
if cside && state.isNetworkCommand then
val workingDir = service.createWorkingDirectory
val cp = service.copyClasspath(
products.value,
classpath.value,
workingDir,
conv,
)
val workerMainClass = classOf[ConsoleMain].getCanonicalName
val workerCp = ForkConsole.currentClasspath.map: p =>
Attributed.blank(conv.toVirtualFile(p): HashedVirtualFileRef)
val json = Converter.toJson[ConsoleConfig](config).get
val params = workingDir.toPath.resolve("console-params.json")
IO.write(params.toFile, CompactPrinter(json))
val info =
RunUtil.mkRunInfo(Vector(s"@$params"), workerMainClass, workerCp, fo, conv, None)
val result = ClientJobParams(
runInfo = info
)
import sbt.internal.worker.codec.JsonProtocol.given
state.notifyEvent(Serialization.clientJob, result)
else
val terminal = ITerminal.console
s.log.info("running console (fork)")
try
terminal.restore()
val exitCode = ForkConsole(config, fo)
if exitCode != 0 then
throw MessageOnlyException(s"Forked console exited with code $exitCode")
finally terminal.restore()
println()
}
private[sbt] def exported(w: PrintWriter, command: String): Seq[String] => Unit =
args => w.println((command +: args).mkString(" "))
private[sbt] def exported(s: Keys.TaskStreams, command: String): Seq[String] => Unit =
val w = s.text(CommandStrings.ExportStream)
try exported(w, command)
finally w.close() // workaround for gh-937
def consoleForkOptions: Def.Initialize[Task[ForkOptions]] = Def.task {
// Build environment variables for proper terminal handling
val termEnv = sys.env.get("TERM").getOrElse("xterm-256color")
ForkOptions()
.withConnectInput(true)
.withRunJVMOptions(
Vector(
s"-Dorg.jline.terminal.type=$termEnv",
"-Djline.terminal=auto",
)
)
.withEnvVars(sys.env)
}
end Compiler

View File

@ -94,7 +94,7 @@ object RunUtil:
private def getMainClass(value: Option[String]): String =
value.getOrElse(sys.error("no main class detected"))
private def mkRunInfo(
private[sbt] def mkRunInfo(
args: Vector[String],
mainClass: String,
cp: Classpath,

View File

@ -8,7 +8,8 @@ package sbt.internal.worker
final class ConsoleConfig private (
val scalaInstanceConfig: sbt.internal.worker.ScalaInstanceConfig,
val bridgeJars: Vector[java.net.URI],
val externalDependencyJars: Vector[String],
val products: Vector[java.net.URI],
val classpathJars: Vector[java.net.URI],
val scalacOptions: Vector[String],
val initialCommands: String,
val cleanupCommands: String) extends Serializable {
@ -16,17 +17,17 @@ final class ConsoleConfig private (
override def equals(o: Any): Boolean = this.eq(o.asInstanceOf[AnyRef]) || (o match {
case x: ConsoleConfig => (this.scalaInstanceConfig == x.scalaInstanceConfig) && (this.bridgeJars == x.bridgeJars) && (this.externalDependencyJars == x.externalDependencyJars) && (this.scalacOptions == x.scalacOptions) && (this.initialCommands == x.initialCommands) && (this.cleanupCommands == x.cleanupCommands)
case x: ConsoleConfig => (this.scalaInstanceConfig == x.scalaInstanceConfig) && (this.bridgeJars == x.bridgeJars) && (this.products == x.products) && (this.classpathJars == x.classpathJars) && (this.scalacOptions == x.scalacOptions) && (this.initialCommands == x.initialCommands) && (this.cleanupCommands == x.cleanupCommands)
case _ => false
})
override def hashCode: Int = {
37 * (37 * (37 * (37 * (37 * (37 * (37 * (17 + "sbt.internal.worker.ConsoleConfig".##) + scalaInstanceConfig.##) + bridgeJars.##) + externalDependencyJars.##) + scalacOptions.##) + initialCommands.##) + cleanupCommands.##)
37 * (37 * (37 * (37 * (37 * (37 * (37 * (37 * (17 + "sbt.internal.worker.ConsoleConfig".##) + scalaInstanceConfig.##) + bridgeJars.##) + products.##) + classpathJars.##) + scalacOptions.##) + initialCommands.##) + cleanupCommands.##)
}
override def toString: String = {
"ConsoleConfig(" + scalaInstanceConfig + ", " + bridgeJars + ", " + externalDependencyJars + ", " + scalacOptions + ", " + initialCommands + ", " + cleanupCommands + ")"
"ConsoleConfig(" + scalaInstanceConfig + ", " + bridgeJars + ", " + products + ", " + classpathJars + ", " + scalacOptions + ", " + initialCommands + ", " + cleanupCommands + ")"
}
private def copy(scalaInstanceConfig: sbt.internal.worker.ScalaInstanceConfig = scalaInstanceConfig, bridgeJars: Vector[java.net.URI] = bridgeJars, externalDependencyJars: Vector[String] = externalDependencyJars, scalacOptions: Vector[String] = scalacOptions, initialCommands: String = initialCommands, cleanupCommands: String = cleanupCommands): ConsoleConfig = {
new ConsoleConfig(scalaInstanceConfig, bridgeJars, externalDependencyJars, scalacOptions, initialCommands, cleanupCommands)
private def copy(scalaInstanceConfig: sbt.internal.worker.ScalaInstanceConfig = scalaInstanceConfig, bridgeJars: Vector[java.net.URI] = bridgeJars, products: Vector[java.net.URI] = products, classpathJars: Vector[java.net.URI] = classpathJars, scalacOptions: Vector[String] = scalacOptions, initialCommands: String = initialCommands, cleanupCommands: String = cleanupCommands): ConsoleConfig = {
new ConsoleConfig(scalaInstanceConfig, bridgeJars, products, classpathJars, scalacOptions, initialCommands, cleanupCommands)
}
def withScalaInstanceConfig(scalaInstanceConfig: sbt.internal.worker.ScalaInstanceConfig): ConsoleConfig = {
copy(scalaInstanceConfig = scalaInstanceConfig)
@ -34,8 +35,11 @@ final class ConsoleConfig private (
def withBridgeJars(bridgeJars: Vector[java.net.URI]): ConsoleConfig = {
copy(bridgeJars = bridgeJars)
}
def withExternalDependencyJars(externalDependencyJars: Vector[String]): ConsoleConfig = {
copy(externalDependencyJars = externalDependencyJars)
def withProducts(products: Vector[java.net.URI]): ConsoleConfig = {
copy(products = products)
}
def withClasspathJars(classpathJars: Vector[java.net.URI]): ConsoleConfig = {
copy(classpathJars = classpathJars)
}
def withScalacOptions(scalacOptions: Vector[String]): ConsoleConfig = {
copy(scalacOptions = scalacOptions)
@ -49,5 +53,5 @@ final class ConsoleConfig private (
}
object ConsoleConfig {
def apply(scalaInstanceConfig: sbt.internal.worker.ScalaInstanceConfig, bridgeJars: Vector[java.net.URI], externalDependencyJars: Vector[String], scalacOptions: Vector[String], initialCommands: String, cleanupCommands: String): ConsoleConfig = new ConsoleConfig(scalaInstanceConfig, bridgeJars, externalDependencyJars, scalacOptions, initialCommands, cleanupCommands)
def apply(scalaInstanceConfig: sbt.internal.worker.ScalaInstanceConfig, bridgeJars: Vector[java.net.URI], products: Vector[java.net.URI], classpathJars: Vector[java.net.URI], scalacOptions: Vector[String], initialCommands: String, cleanupCommands: String): ConsoleConfig = new ConsoleConfig(scalaInstanceConfig, bridgeJars, products, classpathJars, scalacOptions, initialCommands, cleanupCommands)
}

View File

@ -13,12 +13,13 @@ given ConsoleConfigFormat: JsonFormat[sbt.internal.worker.ConsoleConfig] = new J
unbuilder.beginObject(__js)
val scalaInstanceConfig = unbuilder.readField[sbt.internal.worker.ScalaInstanceConfig]("scalaInstanceConfig")
val bridgeJars = unbuilder.readField[Vector[java.net.URI]]("bridgeJars")
val externalDependencyJars = unbuilder.readField[Vector[String]]("externalDependencyJars")
val products = unbuilder.readField[Vector[java.net.URI]]("products")
val classpathJars = unbuilder.readField[Vector[java.net.URI]]("classpathJars")
val scalacOptions = unbuilder.readField[Vector[String]]("scalacOptions")
val initialCommands = unbuilder.readField[String]("initialCommands")
val cleanupCommands = unbuilder.readField[String]("cleanupCommands")
unbuilder.endObject()
sbt.internal.worker.ConsoleConfig(scalaInstanceConfig, bridgeJars, externalDependencyJars, scalacOptions, initialCommands, cleanupCommands)
sbt.internal.worker.ConsoleConfig(scalaInstanceConfig, bridgeJars, products, classpathJars, scalacOptions, initialCommands, cleanupCommands)
case None =>
deserializationError("Expected JsObject but found None")
}
@ -27,7 +28,8 @@ given ConsoleConfigFormat: JsonFormat[sbt.internal.worker.ConsoleConfig] = new J
builder.beginObject()
builder.addField("scalaInstanceConfig", obj.scalaInstanceConfig)
builder.addField("bridgeJars", obj.bridgeJars)
builder.addField("externalDependencyJars", obj.externalDependencyJars)
builder.addField("products", obj.products)
builder.addField("classpathJars", obj.classpathJars)
builder.addField("scalacOptions", obj.scalacOptions)
builder.addField("initialCommands", obj.initialCommands)
builder.addField("cleanupCommands", obj.cleanupCommands)

View File

@ -63,7 +63,8 @@ type ScalaInstanceConfig {
type ConsoleConfig {
scalaInstanceConfig: sbt.internal.worker.ScalaInstanceConfig!
bridgeJars: [java.net.URI]
externalDependencyJars: [String]
products: [java.net.URI]
classpathJars: [java.net.URI]
scalacOptions: [String]
initialCommands: String!
cleanupCommands: String!