mirror of
https://github.com/sbt/sbt.git
synced 2026-09-07 02:55:23 +02:00
@@ -7,6 +7,7 @@ import java.util.Locale
|
||||
import sbt.internal.inc.Analysis
|
||||
import sbt.Tags
|
||||
import com.eed3si9n.jarjarabrams.ModuleCoordinate
|
||||
import Utils.JDK17
|
||||
|
||||
// ThisBuild settings take lower precedence,
|
||||
// but can be shared across the multi projects.
|
||||
@@ -466,16 +467,30 @@ lazy val testingProj = (project in file("testing"))
|
||||
|
||||
lazy val workerProj = (project in file("worker"))
|
||||
.dependsOn(exampleWorkProj % Test)
|
||||
.configs(JDK17)
|
||||
.settings(
|
||||
name := "worker",
|
||||
inConfig(JDK17)(Defaults.compileSettings),
|
||||
exportJars := true,
|
||||
testedBaseSettings,
|
||||
Compile / doc / javacOptions := Nil,
|
||||
crossPaths := false,
|
||||
autoScalaLibrary := false,
|
||||
libraryDependencies ++= Seq(gson, testInterface),
|
||||
libraryDependencies += "org.scala-lang" %% "scala3-library" % scalaVersion.value % Test,
|
||||
// run / fork := false,
|
||||
Test / fork := true,
|
||||
Compile / javacOptions := Seq("--release", "8"),
|
||||
JDK17 / javacOptions := Seq("--release", "17"),
|
||||
Compile / packageBin / packageOptions += Package.ManifestAttributes("Multi-Release" -> "true"),
|
||||
Compile / packageBin / mappings ++= {
|
||||
val _ = (JDK17 / compile).value
|
||||
val dir = (Utils.JDK17 / classDirectory).value
|
||||
fileTreeView.value
|
||||
.list(Glob(dir) / **)
|
||||
.map(_._1)
|
||||
.map(_.toFile())
|
||||
.pair(Path.rebase(dir, "META-INF/versions/17"))
|
||||
},
|
||||
mimaSettings,
|
||||
mimaBinaryIssueFilters ++= Vector(
|
||||
),
|
||||
@@ -600,6 +615,11 @@ lazy val actionsProj = (project in file("main-actions"))
|
||||
Test / classLoaderLayeringStrategy := ClassLoaderLayeringStrategy.Flat,
|
||||
mimaSettings,
|
||||
mimaBinaryIssueFilters ++= Vector(
|
||||
// WorkerConnection gained an Ipc(path) case; mixing a parameterized case into
|
||||
// the enum drops the synthetic values()/valueOf() Java-enum forwarders. This is
|
||||
// an internal (sbt.internal) type not meant for external consumption.
|
||||
exclude[DirectMissingMethodProblem]("sbt.internal.WorkerConnection.valueOf"),
|
||||
exclude[DirectMissingMethodProblem]("sbt.internal.WorkerConnection.values"),
|
||||
),
|
||||
)
|
||||
.dependsOn(lmCore)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
lazy val check = taskKey[Unit]("")
|
||||
lazy val check2 = taskKey[Unit]("")
|
||||
lazy val checkEvalArgHandling = taskKey[Unit]("")
|
||||
lazy val checkDArgHandling = taskKey[Unit]("")
|
||||
lazy val checkXXArgHandling = taskKey[Unit]("")
|
||||
|
||||
lazy val root = (project in file("."))
|
||||
.settings(
|
||||
@@ -18,5 +21,47 @@ lazy val root = (project in file("."))
|
||||
val ys = IO.readLines(file("err.txt")).toVector.distinct
|
||||
|
||||
assert(ys.isEmpty, s"there's an stderr: $ys")
|
||||
},
|
||||
// Regression check for https://github.com/sbt/sbt/issues/9660, run as a
|
||||
// real .bat command by test.bat rather than via a JVM-constructed command
|
||||
// line, so cmd.exe parses the argument the same way it would if a person
|
||||
// had typed it at a prompt.
|
||||
checkEvalArgHandling := {
|
||||
val evalOut = IO.readLines(file("evalOutput.txt")).toVector
|
||||
println(evalOut)
|
||||
assert(
|
||||
evalOut.exists(_.contains("barqux")),
|
||||
s"""eval ("bar") ++ ("qux") should print barqux, but got: $evalOut"""
|
||||
)
|
||||
assert(
|
||||
!evalOut.exists(l =>
|
||||
l.contains("was unexpected at this time") || l.contains("is not recognized")
|
||||
),
|
||||
s"eval with quoted parens should not trigger a cmd.exe parse error: $evalOut"
|
||||
)
|
||||
|
||||
assert(
|
||||
!file("injected.txt").exists,
|
||||
"the & in the eval argument must not escape sbt.bat's quoting and run as a separate command"
|
||||
)
|
||||
},
|
||||
// Regression check for https://github.com/sbt/sbt/issues/9660, matching a
|
||||
// reporter-confirmed case: "sbt" "-Dfoo=()&calc" pops calc when typed at a
|
||||
// real cmd.exe prompt, even though the whole value sits inside one clean,
|
||||
// matched pair of quotes (no embedded quote characters to lose parity).
|
||||
checkDArgHandling := {
|
||||
assert(
|
||||
!file("injected2.txt").exists,
|
||||
"the & in a -D argument value must not escape sbt.bat's quoting and run as a separate command"
|
||||
)
|
||||
},
|
||||
// Regression check for https://github.com/sbt/sbt/issues/9660: the -XX
|
||||
// handling in args_loop has the same shape as -D, so the same reporter-
|
||||
// confirmed bypass applies to it.
|
||||
checkXXArgHandling := {
|
||||
assert(
|
||||
!file("injected3.txt").exists,
|
||||
"the & in a -XX argument value must not escape sbt.bat's quoting and run as a separate command"
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -28,4 +28,24 @@ SET SBT_OPTS=-Xmx4g -Dfile.encoding=UTF8
|
||||
"freshly-baked\sbt\bin\sbt" -Dsbt.no.format=true --version > version.txt
|
||||
"freshly-baked\sbt\bin\sbt" -Dsbt.no.format=true checkVersion
|
||||
|
||||
rem Regression test for https://github.com/sbt/sbt/issues/9660, run as a real
|
||||
rem .bat command (parsed by cmd.exe the same way as if typed at a prompt),
|
||||
rem rather than via a JVM-constructed command line.
|
||||
"freshly-baked\sbt\bin\sbt" -Dsbt.no.format=true "eval (\"bar\") ++ (\"qux\")" 1> evalOutput.txt 2> evalErr.txt
|
||||
|
||||
"freshly-baked\sbt\bin\sbt" -Dsbt.no.format=true "eval (\"foo\") & echo INJECTED>injected.txt & rem (" 1> evalInjOutput.txt 2> evalInjErr.txt
|
||||
|
||||
"freshly-baked\sbt\bin\sbt" -Dsbt.no.format=true checkEvalArgHandling
|
||||
|
||||
rem "about" comes before the risky argument so sbt.bat always receives a real
|
||||
rem command and cannot fall into an interactive shell (which would hang this
|
||||
rem script) even if the & below does escape its quoting.
|
||||
"freshly-baked\sbt\bin\sbt" -Dsbt.no.format=true about "-Dfoo=()© nul injected2.txt"
|
||||
|
||||
"freshly-baked\sbt\bin\sbt" -Dsbt.no.format=true checkDArgHandling
|
||||
|
||||
"freshly-baked\sbt\bin\sbt" -Dsbt.no.format=true about "-XXbar=()© nul injected3.txt"
|
||||
|
||||
"freshly-baked\sbt\bin\sbt" -Dsbt.no.format=true checkXXArgHandling
|
||||
|
||||
ENDLOCAL
|
||||
|
||||
@@ -75,12 +75,14 @@ object RunnerMemoryScriptTest extends verify.BasicTestSuite with ShellScriptUtil
|
||||
assert(out.contains[String]("-Xss12m"))
|
||||
|
||||
// Test for issue #5742: -X options passed directly on command line
|
||||
// Note: the -v preview quotes these (see #9660), so match by substring rather
|
||||
// than exact line equality.
|
||||
testOutput("sbt -Xmx1G directly on command line")("-Xmx1G", "-v"): (out: List[String]) =>
|
||||
assert(out.contains[String]("-Xmx1G"))
|
||||
assert(out.exists(_.contains("-Xmx1G")))
|
||||
|
||||
testOutput("sbt -Xms512M -Xmx1G directly on command line")("-Xms512M", "-Xmx1G", "-v"):
|
||||
(out: List[String]) =>
|
||||
assert(out.contains[String]("-Xms512M"))
|
||||
assert(out.contains[String]("-Xmx1G"))
|
||||
assert(out.exists(_.contains("-Xms512M")))
|
||||
assert(out.exists(_.contains("-Xmx1G")))
|
||||
|
||||
end RunnerMemoryScriptTest
|
||||
|
||||
@@ -24,7 +24,8 @@ object RunnerScriptTest extends verify.BasicTestSuite with ShellScriptUtil:
|
||||
assert(out.contains[String]("-Dsbt.log.noformat=true"))
|
||||
|
||||
testOutput("sbt --color=false")("compile", "--color=false", "-v"): (out: List[String]) =>
|
||||
assert(out.contains[String]("-Dsbt.color=false"))
|
||||
// Note: the -v preview quotes this (see #9660), so match by substring.
|
||||
assert(out.exists(_.contains("-Dsbt.color=false")))
|
||||
|
||||
testOutput("sbt --no-colors in SBT_OPTS", sbtOpts = "--no-colors")("compile", "-v"):
|
||||
(out: List[String]) =>
|
||||
@@ -38,16 +39,21 @@ object RunnerScriptTest extends verify.BasicTestSuite with ShellScriptUtil:
|
||||
assert(out.contains[String]("-Dxsbt.inc.debug=true"))
|
||||
|
||||
testOutput("sbt --supershell=never")("compile", "--supershell=never", "-v"):
|
||||
(out: List[String]) => assert(out.contains[String]("-Dsbt.supershell=never"))
|
||||
(out: List[String]) =>
|
||||
// Note: the -v preview quotes this (see #9660), so match by substring.
|
||||
assert(out.exists(_.contains("-Dsbt.supershell=never")))
|
||||
|
||||
testOutput("sbt --timings")("compile", "--timings", "-v"): (out: List[String]) =>
|
||||
assert(out.contains[String]("-Dsbt.task.timings=true"))
|
||||
|
||||
testOutput("sbt -D arguments")("-Dsbt.supershell=false", "compile", "-v"): (out: List[String]) =>
|
||||
assert(out.contains[String]("-Dsbt.supershell=false"))
|
||||
// Note: the -v preview quotes CLI -D arguments (see #9660), so match by
|
||||
// substring rather than exact line equality.
|
||||
assert(out.exists(_.contains("-Dsbt.supershell=false")))
|
||||
|
||||
testOutput("sbt --sbt-version")("--sbt-version", "1.3.13", "-v"): (out: List[String]) =>
|
||||
assert(out.contains[String]("-Dsbt.version=1.3.13"))
|
||||
// Note: the -v preview quotes this (see #9660), so match by substring.
|
||||
assert(out.exists(_.contains("-Dsbt.version=1.3.13")))
|
||||
|
||||
testOutput(
|
||||
name = "sbt with -Dhttp.proxyHost=proxy -Dhttp.proxyPort=8080 in SBT_OPTS",
|
||||
@@ -365,6 +371,12 @@ object RunnerScriptTest extends verify.BasicTestSuite with ShellScriptUtil:
|
||||
s"Should not have shell expansion errors, but found: ${errorMessages.mkString(", ")}"
|
||||
)
|
||||
|
||||
// NOTE on https://github.com/sbt/sbt/issues/9660: an argument with an
|
||||
// unquoted &, (, or ) can be split apart by cmd.exe's parse of the command
|
||||
// line used to *invoke* sbt.bat, before any line of sbt.bat runs -- no code
|
||||
// inside the script can intervene there. Closing that would require a real
|
||||
// executable entry point instead of a .bat file (see the sbtw project).
|
||||
|
||||
// Test for issue #8755: Inline comments should be supported in .jvmopts
|
||||
testOutput(
|
||||
"sbt with inline comments in .jvmopts",
|
||||
@@ -411,6 +423,8 @@ object RunnerScriptTest extends verify.BasicTestSuite with ShellScriptUtil:
|
||||
)
|
||||
|
||||
testOutput("sbt --experimental_execution_log=true")("--experimental_execution_log=true", "-v"):
|
||||
(out: List[String]) => assert(out.contains[String]("-Dsbt.experimental_execution_log=true"))
|
||||
(out: List[String]) =>
|
||||
// Note: the -v preview quotes this (see #9660), so match by substring.
|
||||
assert(out.exists(_.contains("-Dsbt.experimental_execution_log=true")))
|
||||
|
||||
end RunnerScriptTest
|
||||
|
||||
@@ -537,71 +537,75 @@ if "%~0" == "init" (
|
||||
)
|
||||
)
|
||||
|
||||
if "%g:~0,2%" == "-D" (
|
||||
rem special handling for -D since '=' gets parsed away
|
||||
for /F "tokens=1 delims==" %%a in ("%g%") do (
|
||||
rem make sure it doesn't have the '=' already
|
||||
if "%g%" == "%%a" (
|
||||
if not "%~1" == "" (
|
||||
call :dlog [args_loop] -D argument %~0=%~1
|
||||
set "SBT_ARGS=!SBT_ARGS! %~0=%~1"
|
||||
shift
|
||||
goto args_loop
|
||||
) else (
|
||||
echo %g% is missing a value
|
||||
goto error
|
||||
)
|
||||
) else (
|
||||
call :dlog [args_loop] -D argument %~0
|
||||
set "SBT_ARGS=!SBT_ARGS! %~0"
|
||||
goto args_loop
|
||||
)
|
||||
)
|
||||
)
|
||||
rem NOTE: -D handling below is intentionally flat (goto-based, single-line
|
||||
rem `for` bodies) rather than nested if/for blocks. cmd.exe finds a multi-line
|
||||
rem block's closing paren by counting every literal ( and ) across the lines
|
||||
rem it spans, including ones arriving via variable substitution -- splicing an
|
||||
rem argument value (which may itself contain unbalanced-looking parens, see
|
||||
rem #9660) into a deeply nested multi-line construct risks miscounting that.
|
||||
rem A flat, single-line-per-command shape does not put cmd.exe in a position
|
||||
rem where it needs to hunt across multiple lines for a matching paren.
|
||||
if not "%g:~0,2%" == "-D" goto args_loop_after_D
|
||||
rem special handling for -D since '=' gets parsed away
|
||||
set "g_key="
|
||||
for /F "tokens=1 delims==" %%a in ("%g%") do set "g_key=%%a"
|
||||
if not "%g%" == "%g_key%" goto args_loop_D_has_value
|
||||
if "%~1" == "" goto args_loop_D_missing_value
|
||||
call :dlog [args_loop] -D argument %~0=%~1
|
||||
set SBT_ARGS=!SBT_ARGS! "%~0=%~1"
|
||||
shift
|
||||
goto args_loop
|
||||
:args_loop_D_missing_value
|
||||
echo %g% is missing a value
|
||||
goto error
|
||||
:args_loop_D_has_value
|
||||
call :dlog [args_loop] -D argument %~0
|
||||
set SBT_ARGS=!SBT_ARGS! "%~0"
|
||||
goto args_loop
|
||||
:args_loop_after_D
|
||||
|
||||
if not "%g:~0,5%" == "-XX:+" if not "%g:~0,5%" == "-XX:-" if "%g:~0,3%" == "-XX" (
|
||||
rem special handling for -XX since '=' gets parsed away
|
||||
for /F "tokens=1 delims==" %%a in ("%g%") do (
|
||||
rem make sure it doesn't have the '=' already
|
||||
if "%g%" == "%%a" (
|
||||
if not "%~1" == "" (
|
||||
call :dlog [args_loop] -XX argument %~0=%~1
|
||||
set "SBT_ARGS=!SBT_ARGS! %~0=%~1"
|
||||
shift
|
||||
goto args_loop
|
||||
) else (
|
||||
echo %g% is missing a value
|
||||
goto error
|
||||
)
|
||||
) else (
|
||||
call :dlog [args_loop] -XX argument %~0
|
||||
set "SBT_ARGS=!SBT_ARGS! %~0"
|
||||
goto args_loop
|
||||
)
|
||||
)
|
||||
)
|
||||
rem See the -D handling above for why this is flat rather than nested blocks.
|
||||
if "%g:~0,5%" == "-XX:+" goto args_loop_after_XX
|
||||
if "%g:~0,5%" == "-XX:-" goto args_loop_after_XX
|
||||
if not "%g:~0,3%" == "-XX" goto args_loop_after_XX
|
||||
rem special handling for -XX since '=' gets parsed away
|
||||
set "g_key="
|
||||
for /F "tokens=1 delims==" %%a in ("%g%") do set "g_key=%%a"
|
||||
if not "%g%" == "%g_key%" goto args_loop_XX_has_value
|
||||
if "%~1" == "" goto args_loop_XX_missing_value
|
||||
call :dlog [args_loop] -XX argument %~0=%~1
|
||||
set SBT_ARGS=!SBT_ARGS! "%~0=%~1"
|
||||
shift
|
||||
goto args_loop
|
||||
:args_loop_XX_missing_value
|
||||
echo %g% is missing a value
|
||||
goto error
|
||||
:args_loop_XX_has_value
|
||||
call :dlog [args_loop] -XX argument %~0
|
||||
set SBT_ARGS=!SBT_ARGS! "%~0"
|
||||
goto args_loop
|
||||
:args_loop_after_XX
|
||||
|
||||
rem handle -X JVM options (e.g., -Xmx1G, -Xms512M, -Xss4M) - fixes #5742
|
||||
if "%g:~0,2%" == "-X" (
|
||||
call :dlog [args_loop] -X JVM argument %~0
|
||||
call :addJava %~0
|
||||
call :addJava "%~0"
|
||||
goto args_loop
|
||||
)
|
||||
|
||||
if defined sbt_new if "%g:~0,2%" == "--" (
|
||||
rem special handling for -- template arguments since '=' gets parsed away on Windows
|
||||
for /F "tokens=1 delims==" %%a in ("%g%") do (
|
||||
rem make sure it doesn't have the '=' already
|
||||
if "%g%" == "%%a" (
|
||||
if not "%~1" == "" (
|
||||
call :dlog [args_loop] -- argument %~0=%~1
|
||||
set "SBT_ARGS=!SBT_ARGS! %~0=%~1"
|
||||
shift
|
||||
goto args_loop
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
rem See the -D handling above for why this is flat rather than nested blocks.
|
||||
if not defined sbt_new goto args_loop_after_dashdash
|
||||
if not "%g:~0,2%" == "--" goto args_loop_after_dashdash
|
||||
rem special handling for -- template arguments since '=' gets parsed away on Windows
|
||||
set "g_key="
|
||||
for /F "tokens=1 delims==" %%a in ("%g%") do set "g_key=%%a"
|
||||
if not "%g%" == "%g_key%" goto args_loop_after_dashdash
|
||||
if "%~1" == "" goto args_loop_after_dashdash
|
||||
call :dlog [args_loop] -- argument %~0=%~1
|
||||
set SBT_ARGS=!SBT_ARGS! "%~0=%~1"
|
||||
shift
|
||||
goto args_loop
|
||||
:args_loop_after_dashdash
|
||||
|
||||
rem the %0 (instead of %~0) preserves original argument quoting
|
||||
set sbt_args_seen_command=1
|
||||
@@ -655,7 +659,11 @@ if !sbt_args_print_sbt_script_version! equ 1 (
|
||||
call :checkjava
|
||||
|
||||
if !run_native_client! equ 1 if not defined sbt_args_print_version (
|
||||
goto :runnative !SBT_ARGS!
|
||||
rem Do not append !SBT_ARGS! here: `goto` ignores it, it is not a real
|
||||
rem argument-passing mechanism, but the text is still spliced into this
|
||||
rem line and re-scanned by cmd.exe, so it is live to shell operators ^(#9660^).
|
||||
rem :runnative reads SBT_ARGS via delayed expansion instead.
|
||||
goto :runnative
|
||||
goto :eof
|
||||
)
|
||||
|
||||
@@ -675,7 +683,11 @@ if defined JVM_DEBUG_PORT (
|
||||
|
||||
call :sync_preloaded
|
||||
|
||||
call :run !SBT_ARGS!
|
||||
rem Do not append !SBT_ARGS! to this call: splicing it onto a `call` command
|
||||
rem line makes cmd.exe re-tokenize it, letting shell operators inside a
|
||||
rem quoted user argument escape their quoting and run ^(#9660^).
|
||||
rem :run reads SBT_ARGS via delayed expansion instead.
|
||||
call :run
|
||||
|
||||
if ERRORLEVEL 1 goto error
|
||||
goto end
|
||||
@@ -700,34 +712,38 @@ if defined sbt_args_no_share (
|
||||
set _SBT_OPTS=-Dsbt.global.base=project/.sbtboot -Dsbt.boot.directory=project/.boot -Dsbt.ivy.home=project/.ivy !_SBT_OPTS!
|
||||
)
|
||||
|
||||
rem NOTE: each user-supplied value below is wrapped in its own quotes before
|
||||
rem being appended to _SBT_OPTS. _SBT_OPTS is later spliced unquoted into
|
||||
rem exec lines ^(:copyrt, the final java invocation^), so an unquoted shell
|
||||
rem operator here would be live to cmd.exe at that point ^(#9660^).
|
||||
if defined sbt_args_supershell (
|
||||
set _SBT_OPTS=-Dsbt.supershell=!sbt_args_supershell! !_SBT_OPTS!
|
||||
set _SBT_OPTS="-Dsbt.supershell=!sbt_args_supershell!" !_SBT_OPTS!
|
||||
)
|
||||
|
||||
if defined sbt_args_sbt_version (
|
||||
set _SBT_OPTS=-Dsbt.version=!sbt_args_sbt_version! !_SBT_OPTS!
|
||||
set _SBT_OPTS="-Dsbt.version=!sbt_args_sbt_version!" !_SBT_OPTS!
|
||||
)
|
||||
|
||||
if defined sbt_args_sbt_dir (
|
||||
set _SBT_OPTS=-Dsbt.global.base=!sbt_args_sbt_dir! !_SBT_OPTS!
|
||||
set _SBT_OPTS="-Dsbt.global.base=!sbt_args_sbt_dir!" !_SBT_OPTS!
|
||||
) else if defined LOCALAPPDATA (
|
||||
set _SBT_OPTS=-Dsbt.global.base=!LOCALAPPDATA!\sbt !_SBT_OPTS!
|
||||
set _SBT_OPTS="-Dsbt.global.base=!LOCALAPPDATA!\sbt" !_SBT_OPTS!
|
||||
)
|
||||
|
||||
if defined sbt_args_sbt_boot (
|
||||
set _SBT_OPTS=-Dsbt.boot.directory=!sbt_args_sbt_boot! !_SBT_OPTS!
|
||||
set _SBT_OPTS="-Dsbt.boot.directory=!sbt_args_sbt_boot!" !_SBT_OPTS!
|
||||
)
|
||||
|
||||
if defined sbt_args_sbt_cache (
|
||||
set _SBT_OPTS=-Dsbt.global.localcache=!sbt_args_sbt_cache! !_SBT_OPTS!
|
||||
set _SBT_OPTS="-Dsbt.global.localcache=!sbt_args_sbt_cache!" !_SBT_OPTS!
|
||||
)
|
||||
|
||||
if defined sbt_args_ivy (
|
||||
set _SBT_OPTS=-Dsbt.ivy.home=!sbt_args_ivy! !_SBT_OPTS!
|
||||
set _SBT_OPTS="-Dsbt.ivy.home=!sbt_args_ivy!" !_SBT_OPTS!
|
||||
)
|
||||
|
||||
if defined sbt_args_color (
|
||||
set _SBT_OPTS=-Dsbt.color=!sbt_args_color! !_SBT_OPTS!
|
||||
set _SBT_OPTS="-Dsbt.color=!sbt_args_color!" !_SBT_OPTS!
|
||||
)
|
||||
|
||||
if defined sbt_args_mem (
|
||||
@@ -755,7 +771,7 @@ if not defined sbt_args_no_hide_jdk_warnings (
|
||||
)
|
||||
|
||||
if defined sbt_args_experimental_execution_log (
|
||||
set _SBT_OPTS=-Dsbt.experimental_execution_log=!sbt_args_experimental_execution_log! !_SBT_OPTS!
|
||||
set _SBT_OPTS="-Dsbt.experimental_execution_log=!sbt_args_experimental_execution_log!" !_SBT_OPTS!
|
||||
)
|
||||
|
||||
rem TODO: _SBT_OPTS needs to be processed as args and diffed against SBT_ARGS
|
||||
@@ -792,11 +808,14 @@ if defined sbt_args_verbose (
|
||||
echo -cp
|
||||
echo "!sbt_jar!"
|
||||
echo xsbt.boot.Boot
|
||||
if not "%~1" == "" ( call :echolist %* )
|
||||
if defined SBT_ARGS ( call :echolist !SBT_ARGS! )
|
||||
echo.
|
||||
)
|
||||
|
||||
"!_JAVACMD!" !_JAVA_OPTS! !_SBT_OPTS! %JAVA_TOOL_OPTIONS% %JDK_JAVA_OPTIONS% -cp "!sbt_jar!" xsbt.boot.Boot %*
|
||||
rem one-hop marker: drop it before the server JVM
|
||||
set "SBT_EXPLICIT_JAVA_HOME="
|
||||
|
||||
"!_JAVACMD!" !_JAVA_OPTS! !_SBT_OPTS! %JAVA_TOOL_OPTIONS% %JDK_JAVA_OPTIONS% -cp "!sbt_jar!" xsbt.boot.Boot !SBT_ARGS!
|
||||
|
||||
goto :eof
|
||||
|
||||
@@ -804,14 +823,19 @@ goto :eof
|
||||
|
||||
set "_SBTNCMD=!SBT_BIN_DIR!sbtn-x86_64-pc-win32.exe"
|
||||
|
||||
rem NOTE: SBT_ARGS entries may carry their own embedded quotes, see the
|
||||
rem args_loop -D/-XX/catch-all handling above, to keep shell operators safe
|
||||
rem across the re-scan below. Do not wrap these set lines in an outer pair
|
||||
rem of quotes: that would pair up with an embedded quote instead of its
|
||||
rem match, breaking the quoting it is meant to preserve ^(#9660^).
|
||||
if defined sbt_args_verbose (
|
||||
echo # running native client
|
||||
if not "%~1" == "" ( call :echolist %* )
|
||||
set "SBT_ARGS=-v !SBT_ARGS!"
|
||||
set SBT_ARGS=-v !SBT_ARGS!
|
||||
)
|
||||
|
||||
for %%I in ("!SBT_BIN_DIR!sbt.bat") do set "SBT_SCRIPT=%%~sI"
|
||||
set "SBT_ARGS=--sbt-script=!SBT_SCRIPT! %SBT_ARGS%"
|
||||
set SBT_ARGS=--sbt-script=!SBT_SCRIPT! %SBT_ARGS%
|
||||
|
||||
rem Microsoft Visual C++ 2010 SP1 Redistributable Package (x64) is required
|
||||
rem https://www.microsoft.com/en-us/download/details.aspx?id=13523
|
||||
@@ -885,7 +909,7 @@ exit /B 0
|
||||
|
||||
:addJava
|
||||
call :dlog [addJava] arg = '%*'
|
||||
set "_JAVA_OPTS=!_JAVA_OPTS! %*"
|
||||
set _JAVA_OPTS=!_JAVA_OPTS! %*
|
||||
exit /B 0
|
||||
|
||||
:addMemory
|
||||
|
||||
@@ -28,7 +28,6 @@ import scala.util.Random
|
||||
import scala.util.control.NonFatal
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import scala.sys.process.Process
|
||||
import sbt.internal.WorkerConnection
|
||||
|
||||
/**
|
||||
* This implements forked testing, in cooperation with the worker CLI,
|
||||
@@ -141,8 +140,7 @@ private[sbt] object ForkTests:
|
||||
)
|
||||
testListeners.foreach(_.doInit())
|
||||
val result =
|
||||
val ct = WorkerConnection.Tcp
|
||||
val w = WorkerExchange.startWorker(fork, if virtualClasspath then Nil else cpFiles, ct)
|
||||
val w = WorkerExchange.startWorker(fork, if virtualClasspath then Nil else cpFiles)
|
||||
val wl = React(randomId, log, opts.testListeners, resultsAcc, w.process)
|
||||
try
|
||||
WorkerExchange.registerListener(wl)
|
||||
@@ -152,7 +150,9 @@ private[sbt] object ForkTests:
|
||||
if wl.blockForResponse() != 0 then
|
||||
throw MessageOnlyException("Forked test harness failed")
|
||||
testOutputResult
|
||||
finally WorkerExchange.unregisterListener(wl)
|
||||
finally
|
||||
w.close()
|
||||
WorkerExchange.unregisterListener(wl)
|
||||
testListeners.foreach(_.doComplete(result.overall))
|
||||
result
|
||||
} // end task
|
||||
|
||||
@@ -50,11 +50,21 @@ object Tests {
|
||||
* @param events The result of each test group (suite) executed during this test run.
|
||||
* @param summaries Explicit summaries directly provided by test frameworks. This may be empty, in which case a default summary will be generated.
|
||||
*/
|
||||
private[sbt] final case class Output(
|
||||
final case class Output(
|
||||
overall: TestResult,
|
||||
events: Map[String, SuiteResult],
|
||||
summaries: Iterable[Summary]
|
||||
)
|
||||
) {
|
||||
|
||||
/**
|
||||
* Returns a copy with the throwables removed from every suite result.
|
||||
*
|
||||
* Use this before retaining test results beyond the lifetime of the test task.
|
||||
* See [[SuiteResult.throwables]] for why retaining those exceptions can keep the test class loader alive.
|
||||
*/
|
||||
def withoutThrowables: Output =
|
||||
copy(events = events.view.mapValues(_.withoutThrowables).toMap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarizes a test run.
|
||||
|
||||
@@ -11,21 +11,62 @@ package internal
|
||||
|
||||
import org.scalasbt.shadedgson.com.google.gson.Gson
|
||||
import java.io.*
|
||||
import java.net.{ InetAddress, ServerSocket }
|
||||
import java.net.{ InetAddress, ServerSocket, StandardProtocolFamily, UnixDomainSocketAddress }
|
||||
import java.nio.channels.{ ServerSocketChannel, SocketChannel }
|
||||
import java.nio.file.{ Files, Path as NioPath }
|
||||
import java.util.Scanner
|
||||
import sbt.io.IO
|
||||
import sbt.internal.io.Retry
|
||||
import sbt.internal.worker1.*
|
||||
import sbt.protocol.DuplexChannels
|
||||
import sbt.testing.Framework
|
||||
import scala.sys.process.{ BasicIO, Process, ProcessIO }
|
||||
import scala.collection.mutable
|
||||
import scala.collection.concurrent.TrieMap
|
||||
import scala.collection.mutable.ListBuffer
|
||||
import scala.concurrent.{ Await, Promise }
|
||||
import scala.concurrent.duration.*
|
||||
import scala.util.control.NonFatal
|
||||
|
||||
object WorkerExchange:
|
||||
val listeners: mutable.ListBuffer[WorkerResponseListener] = ListBuffer.empty
|
||||
private val loopback = InetAddress.getByName(null)
|
||||
private val jdkIpcSupportCache = TrieMap.empty[Option[File], Boolean]
|
||||
|
||||
/**
|
||||
* Start a worker process.
|
||||
*/
|
||||
def startWorker(fo: ForkOptions, extraCp: Seq[File]): WorkerProxy =
|
||||
val ct =
|
||||
if supportsUnixDomainSockets(fo.javaHome) then WorkerConnection.Ipc(newIpcSocketPath())
|
||||
else WorkerConnection.Stdio
|
||||
startWorker(fo, extraCp, ct)
|
||||
|
||||
/**
|
||||
* True if `javaHome` (None meaning the JDK currently running sbt) is JDK 16+.
|
||||
*/
|
||||
private def supportsUnixDomainSockets(javaHome: Option[File]): Boolean =
|
||||
def doDetect: Boolean =
|
||||
javaHome match
|
||||
case None => true // the JDK running sbt itself, which requires 17+
|
||||
case Some(home) =>
|
||||
try
|
||||
val releaseFile = File(home, "release")
|
||||
val props = java.util.Properties()
|
||||
val in = FileInputStream(releaseFile)
|
||||
try props.load(in)
|
||||
finally in.close()
|
||||
val raw = Option(props.getProperty("JAVA_VERSION")).getOrElse("")
|
||||
val version = raw.stripPrefix("\"").stripSuffix("\"")
|
||||
val digits =
|
||||
version.takeWhile(c => c.isDigit || c == '.').split('.').flatMap(_.toIntOption)
|
||||
val major = digits match
|
||||
case Array(1, minor, _*) => minor // legacy 1.8-style versioning
|
||||
case Array(m, _*) => m
|
||||
case _ => 0
|
||||
major >= 16
|
||||
catch case NonFatal(_) => false
|
||||
jdkIpcSupportCache.getOrElseUpdate(javaHome, doDetect)
|
||||
|
||||
/**
|
||||
* Start a worker process.
|
||||
@@ -42,26 +83,50 @@ object WorkerExchange:
|
||||
IO.classLocationPath(classOf[Gson]).toFile,
|
||||
)
|
||||
val inputRef = Promise[OutputStream]()
|
||||
val socketOpt = connectionType match
|
||||
def runAccepter(out: OutputStream, in: InputStream): Unit =
|
||||
inputRef.success(out)
|
||||
val scanner = Scanner(in, "UTF-8")
|
||||
while scanner.hasNextLine() do notifyListeners(scanner.nextLine())
|
||||
val (connArgs, closer): (Seq[String], Option[AutoCloseable]) = connectionType match
|
||||
case WorkerConnection.Tcp =>
|
||||
val serverSocket = Retry(ServerSocket(0, 1, loopback))
|
||||
val accepter = Thread(() => {
|
||||
val socket = serverSocket.accept()
|
||||
inputRef.success(socket.getOutputStream())
|
||||
val scanner = Scanner(socket.getInputStream(), "UTF-8")
|
||||
while scanner.hasNextLine() do notifyListeners(scanner.nextLine())
|
||||
runAccepter(socket.getOutputStream(), socket.getInputStream())
|
||||
})
|
||||
accepter.start()
|
||||
Some(serverSocket)
|
||||
case _ => None
|
||||
(Seq("--tcp", serverSocket.getLocalPort().toString()), Some(serverSocket))
|
||||
case WorkerConnection.Ipc(path) =>
|
||||
val serverChannel = Retry {
|
||||
Files.deleteIfExists(path)
|
||||
val ch = ServerSocketChannel.open(StandardProtocolFamily.UNIX)
|
||||
ch.bind(UnixDomainSocketAddress.of(path))
|
||||
ch
|
||||
}
|
||||
@volatile var acceptedChannel: SocketChannel = null
|
||||
val accepter = Thread(() => {
|
||||
val channel = serverChannel.accept()
|
||||
acceptedChannel = channel
|
||||
runAccepter(
|
||||
DuplexChannels.newOutputStream(channel),
|
||||
DuplexChannels.newInputStream(channel)
|
||||
)
|
||||
})
|
||||
accepter.setName("sbt-fork-test-response-reader")
|
||||
accepter.setPriority(Thread.NORM_PRIORITY + 1)
|
||||
accepter.start()
|
||||
val closer: AutoCloseable = () => {
|
||||
if acceptedChannel != null then acceptedChannel.close()
|
||||
serverChannel.close()
|
||||
Files.deleteIfExists(path)
|
||||
}
|
||||
(Seq("--ipc", path.toString()), Some(closer))
|
||||
case WorkerConnection.Stdio => (Nil, None)
|
||||
val options = Seq(
|
||||
"-classpath",
|
||||
fullCp.mkString(File.pathSeparator),
|
||||
classOf[WorkerMain].getCanonicalName,
|
||||
) ++
|
||||
(socketOpt match
|
||||
case Some(s) => Seq("--tcp", s.getLocalPort().toString())
|
||||
case _ => Nil)
|
||||
) ++ connArgs
|
||||
val onStdoutLine: String => Unit = connectionType match
|
||||
case WorkerConnection.Stdio => notifyListeners
|
||||
case _ => (line) => scala.Console.out.println(line)
|
||||
@@ -78,7 +143,17 @@ object WorkerExchange:
|
||||
val p = Fork.java.fork(forkWithIo, options)
|
||||
val forkTimeout = fo.connectionTimeout.getOrElse(30.seconds)
|
||||
val input = Await.result(inputRef.future, forkTimeout)
|
||||
WorkerProxy(input, p, options, socketOpt)
|
||||
WorkerProxy(input, p, options, closer)
|
||||
|
||||
/** Generates a fresh path suitable for binding a `WorkerConnection.Ipc` socket. */
|
||||
def newIpcSocketPath(): NioPath =
|
||||
val dir = NioPath
|
||||
.of(sys.env.getOrElse("XDG_RUNTIME_DIR", sys.props("java.io.tmpdir")))
|
||||
.resolve(".sbt-fork-ipc")
|
||||
Files.createDirectories(dir)
|
||||
val path = Files.createTempFile(dir, "fork-", ".sock")
|
||||
Files.deleteIfExists(path)
|
||||
path
|
||||
|
||||
def registerListener(listener: WorkerResponseListener): Unit =
|
||||
synchronized:
|
||||
@@ -102,12 +177,12 @@ class WorkerProxy(
|
||||
input: OutputStream,
|
||||
val process: Process,
|
||||
val options: Seq[String],
|
||||
serverSocket: Option[ServerSocket],
|
||||
closer: Option[AutoCloseable],
|
||||
) extends AutoCloseable:
|
||||
lazy val inputStream = PrintStream(input)
|
||||
def close(): Unit =
|
||||
input.close()
|
||||
serverSocket.foreach(_.close())
|
||||
closer.foreach(_.close())
|
||||
def blockForExitCode(): Int =
|
||||
if !process.isAlive() then process.exitValue()
|
||||
else Fork.blockForExitCode(process)
|
||||
@@ -130,3 +205,4 @@ abstract class WorkerResponseListener extends Function1[String, Unit]:
|
||||
enum WorkerConnection:
|
||||
case Stdio
|
||||
case Tcp
|
||||
case Ipc(path: NioPath)
|
||||
|
||||
@@ -9,13 +9,18 @@ import scala.sys.process.Process
|
||||
|
||||
object WorkerExchangeTest extends Properties:
|
||||
given Gen[WorkerConnection] =
|
||||
Gen.choice1(Gen.constant(WorkerConnection.Stdio), Gen.constant(WorkerConnection.Tcp))
|
||||
Gen.choice1(
|
||||
Gen.constant(WorkerConnection.Stdio),
|
||||
Gen.constant(WorkerConnection.Tcp),
|
||||
Gen.constant(WorkerConnection.Ipc(WorkerExchange.newIpcSocketPath())),
|
||||
)
|
||||
|
||||
def gen[A1: Gen]: Gen[A1] = summon[Gen[A1]]
|
||||
|
||||
override lazy val tests: List[Test] = List(
|
||||
propertyN("non-jsonrpc should return exit code 1", propBadInput, 10),
|
||||
propertyN("bye should return response json with a result", propBye, 10),
|
||||
example("startWorker(fo, extraCp) auto-detects a working connection type", exampleAutoDetect),
|
||||
)
|
||||
|
||||
def propertyN(name: String, result: => Property, n: Int): Test =
|
||||
@@ -47,6 +52,17 @@ object WorkerExchangeTest extends Properties:
|
||||
.and(Result.assert(l.sb.toString() == s"""{ "jsonrpc": "2.0", "result": 0, "id": $i }"""))
|
||||
.log(s"\"${l.sb.toString()}\"")
|
||||
|
||||
def exampleAutoDetect: Result =
|
||||
val w = WorkerExchange.startWorker(ForkOptions(), Nil)
|
||||
withListener: l =>
|
||||
w.println("""{"jsonrpc": "2.0", "method": "bye", "params": {}, "id": 1}""")
|
||||
val exitCode = w.blockForExitCode()
|
||||
l.awaitResponse()
|
||||
Result
|
||||
.assert(exitCode == 0)
|
||||
.and(Result.assert(l.sb.toString() == """{ "jsonrpc": "2.0", "result": 0, "id": 1 }"""))
|
||||
.log(s"\"${l.sb.toString()}\"")
|
||||
|
||||
def withListener[A1](f: ConcreteListener => A1) =
|
||||
val l = ConcreteListener()
|
||||
try
|
||||
|
||||
@@ -248,12 +248,40 @@ object State {
|
||||
val app = state.configuration.provider
|
||||
new Reboot(
|
||||
app.scalaProvider.version,
|
||||
state.remainingCommands map { case e: Exec => e.commandLine },
|
||||
addPluginSbtFileArguments(state) ::: state.remainingCommands.map(_.commandLine),
|
||||
app.id,
|
||||
state.configuration.baseDirectory
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `early(...)` commands that add the extra plugin sbt files back after a reboot.
|
||||
* A reboot clears the state, so sbt forgets these files. The commands are passed to the new
|
||||
* sbt as arguments, and they run before it loads the build.
|
||||
*
|
||||
* The `early(addPluginSbtFile=...)` form is used, not `--addPluginSbtFile=...`, because the
|
||||
* `--` form loses the quotes around the path, and then a path with a space in it fails.
|
||||
* A path with a space can be added using `addPluginSbtFile "<path>"`.
|
||||
*/
|
||||
private[sbt] def addPluginSbtFileArguments(state: State): List[String] =
|
||||
state.get(BasicKeys.extraMetaSbtFiles).toList.flatten.distinct.map { vf =>
|
||||
val path = vf match {
|
||||
case f: xsbti.PathBasedFile => f.toPath.toString
|
||||
case f => f.id
|
||||
}
|
||||
val command = s"${BasicCommandStrings.AddPluginSbtFileCommand}=${quote(path)}"
|
||||
s"${BasicCommandStrings.EarlyCommand}($command)"
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts `path` in quotes so the new sbt session reads it back as one whole string. There the path goes
|
||||
* through the `Parsers.StringBasic` in `BasicCommands.addPluginSbtFileParser`, which stops at
|
||||
* the first space when there are no quotes. A path with a space can be added, so it also has
|
||||
* to come back after a reboot.
|
||||
*/
|
||||
private def quote(path: String): String =
|
||||
s"\"${path.replace("\\", "\\\\").replace("\"", "\\\"")}\""
|
||||
|
||||
@deprecated("Import State._ or State.StateOpsImpl to access state extension methods", "1.3.0")
|
||||
def stateOps(s: State): StateOps = new StateOpsImpl(s)
|
||||
|
||||
@@ -331,8 +359,9 @@ object State {
|
||||
StartServer :: remaining.dropWhile(!_.startsWith(ReportResult)).tail ::: "shell" :: Nil
|
||||
case _ => remaining
|
||||
}
|
||||
if (currentOnly) throw new RebootCurrent(fullRemaining)
|
||||
else throw new xsbti.FullReload(fullRemaining.toArray, full)
|
||||
val arguments = State.addPluginSbtFileArguments(s) ::: fullRemaining
|
||||
if (currentOnly) throw new RebootCurrent(arguments)
|
||||
else throw new xsbti.FullReload(arguments.toArray, full)
|
||||
}
|
||||
|
||||
def reload = runExitHooks().setNext(new Return(defaultReload(s)))
|
||||
|
||||
@@ -15,7 +15,7 @@ import sbt.Keys.*
|
||||
import sbt.Project.inScope
|
||||
import sbt.ProjectExtra.{ prefixConfigs, setProject, showLoadingKey, structure }
|
||||
import sbt.Scope.GlobalScope
|
||||
import sbt.ScopeAxis.{ Select, Zero }
|
||||
import sbt.ScopeAxis.{ Select, This, Zero }
|
||||
import sbt.SlashSyntax0.*
|
||||
import sbt.internal.BuildStreams.*
|
||||
import sbt.internal.inc.classpath.ClasspathUtil
|
||||
@@ -1056,6 +1056,11 @@ private[sbt] object Load {
|
||||
log = log,
|
||||
)
|
||||
|
||||
// Excludes settings already scoped explicitly (ThisBuild, Global); those apply once (#9668).
|
||||
def thisProjectScoped(settings: Seq[Setting[?]]): Seq[Setting[?]] =
|
||||
settings.filter: s =>
|
||||
s.key.scope.project == This || s.key.scope.project == Select(ThisProject)
|
||||
|
||||
// load all relevant configuration files (.sbt, as .scala already exists at this point)
|
||||
def discover(base: File): DiscoveredProjects = {
|
||||
val auto =
|
||||
@@ -1118,7 +1123,9 @@ private[sbt] object Load {
|
||||
val newAcc = acc :+ finalRoot
|
||||
val newGenerated = generated ++ generatedConfigClassFiles
|
||||
// only root-level settings are build-wide; a submodule's own settings must not leak to siblings (#9517).
|
||||
val cs = if isRootPath(p.base, buildBase) then finalRoot.commonSettings else commonSettings
|
||||
val cs =
|
||||
if isRootPath(p.base, buildBase) then thisProjectScoped(finalRoot.commonSettings)
|
||||
else commonSettings
|
||||
loadTransitive1(newProjects, newAcc, newGenerated, cs)
|
||||
}
|
||||
|
||||
@@ -1154,7 +1161,9 @@ private[sbt] object Load {
|
||||
acc = acc,
|
||||
generated = Nil,
|
||||
commonSettings0 = commonSettings
|
||||
++ expandCommonSettingsPerBase1(buildBase.getCanonicalFile()),
|
||||
++ thisProjectScoped(
|
||||
expandCommonSettingsPerBase1(buildBase.getCanonicalFile())
|
||||
),
|
||||
)
|
||||
val existingIds = otherProjects.projects.map(_.id)
|
||||
val refs = existingIds.map(id => ProjectRef(buildUri, id))
|
||||
@@ -1167,7 +1176,12 @@ private[sbt] object Load {
|
||||
val newAcc = finalRoot +: (acc ++ otherProjects.projects)
|
||||
val newGenerated =
|
||||
generated ++ otherProjects.generatedConfigClassFiles ++ generatedConfigClassFiles
|
||||
loadTransitive1(newProjects, newAcc, newGenerated, finalRoot.commonSettings)
|
||||
loadTransitive1(
|
||||
newProjects,
|
||||
newAcc,
|
||||
newGenerated,
|
||||
thisProjectScoped(finalRoot.commonSettings)
|
||||
)
|
||||
case Nil =>
|
||||
val projectIds = acc.map(_.id).mkString("(", ", ", ")")
|
||||
log.debug(s"[Loading] Done in $buildBase, returning: $projectIds")
|
||||
|
||||
@@ -7,6 +7,8 @@ import sbt.internal.inc.Analysis
|
||||
|
||||
object Utils {
|
||||
val version2_13 = settingKey[String]("version number")
|
||||
val JDK17 = config("jdk17")
|
||||
|
||||
val ExclusiveTest: Tags.Tag = Tags.Tag("exclusive-test")
|
||||
|
||||
val componentID: SettingKey[Option[String]] = settingKey[Option[String]]("")
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* sbt
|
||||
* Copyright 2023, Scala center
|
||||
* Copyright 2011 - 2022, Lightbend, Inc.
|
||||
* Copyright 2008 - 2010, Mark Harrah
|
||||
* Licensed under Apache License 2.0 (see LICENSE)
|
||||
*/
|
||||
|
||||
package sbt.protocol;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.Socket;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.SocketChannel;
|
||||
|
||||
/**
|
||||
* java.nio.channels.Channels.newInputStream/newOutputStream both synchronize on the channel's
|
||||
* blockingLock() for the duration of each blocking call, so a thread parked in a blocking read
|
||||
* holds that lock for as long as the read blocks, and a concurrent writer on the same channel can
|
||||
* never acquire it. These factories talk to the channel directly instead, so a SocketChannel can
|
||||
* safely be read and written from different threads at the same time.
|
||||
*/
|
||||
public final class DuplexChannels {
|
||||
private DuplexChannels() {}
|
||||
|
||||
public static OutputStream newOutputStream(SocketChannel ch) {
|
||||
return new OutputStream() {
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
ByteBuffer bb = ByteBuffer.wrap(new byte[] {(byte) b});
|
||||
while (bb.hasRemaining()) ch.write(bb);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] b, int off, int len) throws IOException {
|
||||
ByteBuffer bb = ByteBuffer.wrap(b, off, len);
|
||||
while (bb.hasRemaining()) ch.write(bb);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static InputStream newInputStream(SocketChannel ch) {
|
||||
return new InputStream() {
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
ByteBuffer bb = ByteBuffer.allocate(1);
|
||||
int n = ch.read(bb);
|
||||
return n <= 0 ? -1 : (bb.get(0) & 0xff);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) throws IOException {
|
||||
if (len == 0) return 0;
|
||||
return ch.read(ByteBuffer.wrap(b, off, len));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a connected SocketChannel as a Socket backed by {@link #newInputStream}/{@link
|
||||
* #newOutputStream}.
|
||||
*/
|
||||
public static Socket newSocket(SocketChannel ch) {
|
||||
return new Socket() {
|
||||
private final InputStream in = newInputStream(ch);
|
||||
private final OutputStream out = newOutputStream(ch);
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return in;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
ch.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClosed() {
|
||||
return !ch.isOpen();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ package sbt
|
||||
package protocol
|
||||
|
||||
import java.io.File
|
||||
import java.net.{ Socket, URI, InetAddress }
|
||||
import java.net.{ InetAddress, Socket, StandardProtocolFamily, URI, UnixDomainSocketAddress }
|
||||
import java.nio.channels.SocketChannel
|
||||
import sjsonnew.BasicJsonProtocol
|
||||
import sjsonnew.support.scalajson.unsafe.{ Parser, Converter }
|
||||
import sjsonnew.shaded.scalajson.ast.unsafe.JValue
|
||||
@@ -45,4 +46,9 @@ object ClientSocket {
|
||||
def localSocket(name: String, useJNI: Boolean): Socket =
|
||||
if (isWindows) new Win32NamedPipeSocket(s"\\\\.\\pipe\\$name", useJNI)
|
||||
else new UnixDomainSocket(name, useJNI)
|
||||
|
||||
def bootSocket(path: String): Socket =
|
||||
val ch = SocketChannel.open(StandardProtocolFamily.UNIX)
|
||||
ch.connect(UnixDomainSocketAddress.of(path))
|
||||
DuplexChannels.newSocket(ch)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* sbt
|
||||
* Copyright 2023, Scala center
|
||||
* Copyright 2011 - 2022, Lightbend, Inc.
|
||||
* Copyright 2008 - 2010, Mark Harrah
|
||||
* Licensed under Apache License 2.0 (see LICENSE)
|
||||
*/
|
||||
|
||||
package sbt.protocol
|
||||
|
||||
import hedgehog.{ Gen, Property, Result }
|
||||
import hedgehog.core.{ ShrinkLimit, SuccessCount }
|
||||
import hedgehog.runner.*
|
||||
import java.io.{ EOFException, InputStream }
|
||||
import java.net.{ StandardProtocolFamily, UnixDomainSocketAddress }
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.{ ServerSocketChannel, SocketChannel }
|
||||
import java.util.concurrent.{ CountDownLatch, LinkedBlockingQueue, TimeUnit }
|
||||
import scala.util.Using
|
||||
import scala.util.control.NonFatal
|
||||
import sbt.io.IO
|
||||
|
||||
/**
|
||||
* Regression test: bootSocket used to wrap its channel with
|
||||
* Channels.newInputStream/newOutputStream, which share the channel's blockingLock() and deadlock
|
||||
* a blocking read against a concurrent write on the same channel. It now uses DuplexChannels,
|
||||
* which talks to the channel directly and has no such shared lock. What matters for reproducing
|
||||
* the deadlock is timing, not payload content, so the reader and writer threads are each given an
|
||||
* independent startup delay drawn from {0, 100, 300}ms to exercise the read starting well before,
|
||||
* around the same time as, and well after the write.
|
||||
*/
|
||||
object ClientSocketDuplexTest extends Properties:
|
||||
override def tests: List[Test] = List(
|
||||
propertyN(
|
||||
"bootSocket: a concurrent read and write on the same channel do not deadlock",
|
||||
propDuplex,
|
||||
20,
|
||||
),
|
||||
)
|
||||
|
||||
def propertyN(name: String, result: => Property, n: Int): Test =
|
||||
Test(name, result)
|
||||
.config(_.copy(testLimit = SuccessCount(n), shrinkLimit = ShrinkLimit(n * 10)))
|
||||
|
||||
private val toServer: Array[Byte] = Array[Byte](1)
|
||||
private val toClient: Array[Byte] = Array[Byte](2)
|
||||
|
||||
val sleepMsGen: Gen[Int] = Gen.element1(0, 100, 300)
|
||||
|
||||
def propDuplex: Property =
|
||||
for
|
||||
readerSleepMs <- sleepMsGen.log("reader startup delay (ms)")
|
||||
writerSleepMs <- sleepMsGen.log("writer startup delay (ms)")
|
||||
yield runDuplexRound(readerSleepMs, writerSleepMs)
|
||||
|
||||
private def runDuplexRound(readerSleepMs: Int, writerSleepMs: Int): Result =
|
||||
IO.withTemporaryDirectory: dir =>
|
||||
val path = dir.toPath.resolve("boot.sock")
|
||||
Using.resource(ServerSocketChannel.open(StandardProtocolFamily.UNIX)): serverChannel =>
|
||||
serverChannel.bind(UnixDomainSocketAddress.of(path))
|
||||
Using.resource(ClientSocket.bootSocket(path.toString)): client =>
|
||||
Using.resource(serverChannel.accept()): serverSide =>
|
||||
// The client's reader is parked waiting for toClient before the server has sent
|
||||
// anything, so it's mid-read (and would be holding blockingLock() under the old
|
||||
// Channels-based implementation) while we race the write below against it.
|
||||
val readOutcome = new LinkedBlockingQueue[Either[Throwable, Array[Byte]]]()
|
||||
val reader = new Thread(() =>
|
||||
readOutcome.put(
|
||||
try
|
||||
Thread.sleep(readerSleepMs.toLong)
|
||||
Right(readNBytes(client.getInputStream(), toClient.length))
|
||||
catch case NonFatal(e) => Left(e)
|
||||
)
|
||||
)
|
||||
reader.setDaemon(true)
|
||||
reader.start()
|
||||
|
||||
val writeDone = new CountDownLatch(1)
|
||||
val writer = new Thread(() =>
|
||||
try
|
||||
Thread.sleep(writerSleepMs.toLong)
|
||||
client.getOutputStream().write(toServer)
|
||||
catch case NonFatal(_) => ()
|
||||
finally writeDone.countDown()
|
||||
)
|
||||
writer.setDaemon(true)
|
||||
writer.start()
|
||||
|
||||
if !writeDone.await(3, TimeUnit.SECONDS) then
|
||||
Result.failure.log(
|
||||
"write blocked behind the concurrent read: possible regression of the " +
|
||||
"Channels.newInputStream/newOutputStream blockingLock() deadlock"
|
||||
)
|
||||
else
|
||||
val fromClient = readNBytes(serverSide, toServer.length)
|
||||
serverSide.write(ByteBuffer.wrap(toClient))
|
||||
readOutcome.poll(3, TimeUnit.SECONDS) match
|
||||
case null =>
|
||||
Result.failure.log(
|
||||
"client's blocked read never completed after the server replied"
|
||||
)
|
||||
case Left(e) => Result.failure.log(s"client read failed: $e")
|
||||
case Right(fromServer) =>
|
||||
Result.all(
|
||||
List(
|
||||
Result
|
||||
.assert(fromClient.sameElements(toServer))
|
||||
.log("server received a different payload than the client sent"),
|
||||
Result
|
||||
.assert(fromServer.sameElements(toClient))
|
||||
.log("client received a different payload than the server sent"),
|
||||
)
|
||||
)
|
||||
|
||||
private def readNBytes(in: InputStream, n: Int): Array[Byte] =
|
||||
val buf = new Array[Byte](n)
|
||||
var total = 0
|
||||
while total < n do
|
||||
val r = in.read(buf, total, n - total)
|
||||
if r < 0 then throw new EOFException(s"expected $n bytes, got $total")
|
||||
total += r
|
||||
buf
|
||||
|
||||
private def readNBytes(ch: SocketChannel, n: Int): Array[Byte] =
|
||||
val bb = ByteBuffer.allocate(n)
|
||||
while bb.hasRemaining() do
|
||||
val r = ch.read(bb)
|
||||
if r < 0 then throw new EOFException(s"expected $n bytes, got ${bb.position()}")
|
||||
bb.array()
|
||||
end ClientSocketDuplexTest
|
||||
@@ -13,3 +13,9 @@ $ copy-file changes/global-plugins.sbt global/plugins/plugins.sbt
|
||||
$ copy-file changes/plugins.sbt project/plugins.sbt
|
||||
> reload
|
||||
> check
|
||||
|
||||
# The tests of one scripted run share a directory, and `global` is the part of it that is kept between
|
||||
# them, so delete what this test installed there.
|
||||
$ delete global/plugins global/useGlobalAutoPlugin.sbt
|
||||
$ absent global/plugins global/useGlobalAutoPlugin.sbt
|
||||
> reload
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import sbt.internal.LoadedBuild
|
||||
|
||||
lazy val root = project.in(file("."))
|
||||
|
||||
def detectedPlugins(lb: LoadedBuild): Seq[String] =
|
||||
lb.units(lb.root).unit.plugins.detected.autoPlugins.map(_.name)
|
||||
|
||||
InputKey[Unit]("checkPlugins") := {
|
||||
val args = Def.spaceDelimited("<names>").parsed
|
||||
val detected = detectedPlugins(loadedBuild.value)
|
||||
args.foreach { name =>
|
||||
assert(
|
||||
detected.exists(_.contains(name)),
|
||||
s"expected plugin $name to be detected, got: ${detected.mkString(", ")}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
InputKey[Unit]("checkPluginsAbsent") := {
|
||||
val args = Def.spaceDelimited("<names>").parsed
|
||||
val detected = detectedPlugins(loadedBuild.value)
|
||||
args.foreach { name =>
|
||||
assert(
|
||||
!detected.exists(_.contains(name)),
|
||||
s"expected plugin $name not to be detected, got: ${detected.mkString(", ")}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Compares the whole list of registered paths, in order. This also catches a path that comes
|
||||
// back from a reboot changed, doubled or in a different place, not only one that is lost.
|
||||
// With no arguments it asserts that no file is registered.
|
||||
InputKey[Unit]("checkFiles") := {
|
||||
val expected = Def.spaceDelimited("<paths>").parsed.toList
|
||||
val actual = state.value.get(BasicKeys.extraMetaSbtFiles).toList.flatten.map(_.id)
|
||||
assert(
|
||||
actual == expected,
|
||||
s"expected registered files to be [${expected.mkString(", ")}], got: [${actual.mkString(", ")}]"
|
||||
)
|
||||
}
|
||||
|
||||
// Tests in a scripted group share one sbt session, and the extra files now survive reboot on
|
||||
// purpose, so they have to be dropped before the next test.
|
||||
commands += Command.command("clearExtraPluginSbtFiles") { s =>
|
||||
s.remove(BasicKeys.extraMetaSbtFiles)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.11")
|
||||
@@ -0,0 +1 @@
|
||||
addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.13.1")
|
||||
@@ -0,0 +1 @@
|
||||
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.22.0")
|
||||
@@ -0,0 +1,43 @@
|
||||
# Regression test for sbt/sbt#4303: files added with --addPluginSbtFile must survive reboot.
|
||||
|
||||
# Neither plugin is on the meta-build classpath to begin with, and nothing is registered.
|
||||
> checkPluginsAbsent BuildInfoPlugin ScalaJSPlugin ScalaNativePlugin
|
||||
> checkFiles
|
||||
|
||||
# Add the first extra plugin sbt file and check that its plugin is picked up.
|
||||
> early(addPluginSbtFile=temp/extraA.sbt); reload
|
||||
> checkPlugins BuildInfoPlugin
|
||||
> checkFiles temp/extraA.sbt
|
||||
|
||||
# The first reboot must not lose it.
|
||||
> reboot
|
||||
> checkPlugins BuildInfoPlugin
|
||||
> checkFiles temp/extraA.sbt
|
||||
|
||||
# Add a second extra plugin sbt file on top of the first one.
|
||||
> early(addPluginSbtFile=temp/extraB.sbt); reload
|
||||
> checkPlugins BuildInfoPlugin ScalaJSPlugin
|
||||
> checkFiles temp/extraA.sbt temp/extraB.sbt
|
||||
|
||||
# The second reboot must keep both of them, and must not add either of them twice.
|
||||
> reboot
|
||||
> checkPlugins BuildInfoPlugin ScalaJSPlugin
|
||||
> checkFiles temp/extraA.sbt temp/extraB.sbt
|
||||
|
||||
# A third file, this one in a directory whose name contains spaces. The path has to be quoted
|
||||
# for sbt to parse it as one argument, and the reboot has to keep it quoted to replay it.
|
||||
> addPluginSbtFile "temp with spaces/extraC.sbt"
|
||||
> reload
|
||||
> checkPlugins BuildInfoPlugin ScalaJSPlugin ScalaNativePlugin
|
||||
> checkFiles temp/extraA.sbt temp/extraB.sbt "temp with spaces/extraC.sbt"
|
||||
|
||||
# The third reboot must keep all three, in order, with the spaced path intact.
|
||||
> reboot
|
||||
> checkPlugins BuildInfoPlugin ScalaJSPlugin ScalaNativePlugin
|
||||
> checkFiles temp/extraA.sbt temp/extraB.sbt "temp with spaces/extraC.sbt"
|
||||
|
||||
# Drop the extra files, because the sbt session can be shared with the other scripted tests.
|
||||
> clearExtraPluginSbtFiles
|
||||
> reload
|
||||
> checkPluginsAbsent BuildInfoPlugin ScalaJSPlugin ScalaNativePlugin
|
||||
> checkFiles
|
||||
@@ -5,6 +5,16 @@ scalaVersion := scala212
|
||||
val o = "com.example"
|
||||
organization := o
|
||||
|
||||
lazy val aa = settingKey[Seq[String]]("")
|
||||
Global / aa := Seq("initial-value")
|
||||
// explicitly ThisBuild-scoped settings must apply exactly once, not once per project (#9668)
|
||||
ThisBuild / aa += "added-value"
|
||||
|
||||
lazy val bb = settingKey[Seq[String]]("")
|
||||
Global / bb := Seq("initial-value")
|
||||
// ThisProject means "current project", so it must still apply once per project, unlike ThisBuild
|
||||
ThisProject / bb += "added-value"
|
||||
|
||||
lazy val foo = project
|
||||
lazy val bar = project
|
||||
.settings(
|
||||
@@ -24,5 +34,17 @@ check := {
|
||||
assert((bar / organization).value == "com.example.bar", s"unexpected bar / organization = {(bar / organization).value}")
|
||||
// Test that baz/build.sbt bare settings get loaded
|
||||
assert((baz / organization).value == "com.example.baz", s"unexpected baz/organization")
|
||||
|
||||
// Test that ThisBuild / aa += is applied exactly once, not once per project (#9668)
|
||||
val expectedAa = Seq("initial-value", "added-value")
|
||||
assert((foo / aa).value == expectedAa, s"(foo / aa).value: ${(foo / aa).value}")
|
||||
assert((bar / aa).value == expectedAa, s"(bar / aa).value: ${(bar / aa).value}")
|
||||
assert((baz / aa).value == expectedAa, s"(baz / aa).value: ${(baz / aa).value}")
|
||||
|
||||
// Test that ThisProject / bb += still applies once per project (#9668)
|
||||
val expectedBb = Seq("initial-value", "added-value")
|
||||
assert((foo / bb).value == expectedBb, s"(foo / bb).value: ${(foo / bb).value}")
|
||||
assert((bar / bb).value == expectedBb, s"(bar / bb).value: ${(bar / bb).value}")
|
||||
assert((baz / bb).value == expectedBb, s"(baz / bb).value: ${(baz / bb).value}")
|
||||
}
|
||||
check / aggregate := false
|
||||
|
||||
@@ -6,6 +6,16 @@ scalaVersion := scala212
|
||||
val o = "com.example"
|
||||
organization := o
|
||||
|
||||
lazy val aa = settingKey[Seq[String]]("")
|
||||
Global / aa := Seq("initial-value")
|
||||
// explicitly ThisBuild-scoped settings must apply exactly once, not once per project (#9668)
|
||||
ThisBuild / aa += "added-value"
|
||||
|
||||
lazy val bb = settingKey[Seq[String]]("")
|
||||
Global / bb := Seq("initial-value")
|
||||
// ThisProject means "current project", so it must still apply once per project, unlike ThisBuild
|
||||
ThisProject / bb += "added-value"
|
||||
|
||||
lazy val root = rootProject
|
||||
.autoAggregate
|
||||
|
||||
@@ -34,5 +44,21 @@ LocalRootProject / check := {
|
||||
assert((baz / organization).value == "com.example.baz")
|
||||
// Test that baz/build.sbt settings don't leak onto qux, processed right after it (#9517)
|
||||
assert((qux / organization).value == o, s"(qux / organization).value: ${(qux / organization).value}")
|
||||
|
||||
// Test that ThisBuild / aa += is applied exactly once, not once per project (#9668)
|
||||
val expectedAa = Seq("initial-value", "added-value")
|
||||
assert((root / aa).value == expectedAa, s"(root / aa).value: ${(root / aa).value}")
|
||||
assert((foo / aa).value == expectedAa, s"(foo / aa).value: ${(foo / aa).value}")
|
||||
assert((bar / aa).value == expectedAa, s"(bar / aa).value: ${(bar / aa).value}")
|
||||
assert((baz / aa).value == expectedAa, s"(baz / aa).value: ${(baz / aa).value}")
|
||||
assert((qux / aa).value == expectedAa, s"(qux / aa).value: ${(qux / aa).value}")
|
||||
|
||||
// Test that ThisProject / bb += still applies once per project (#9668)
|
||||
val expectedBb = Seq("initial-value", "added-value")
|
||||
assert((root / bb).value == expectedBb, s"(root / bb).value: ${(root / bb).value}")
|
||||
assert((foo / bb).value == expectedBb, s"(foo / bb).value: ${(foo / bb).value}")
|
||||
assert((bar / bb).value == expectedBb, s"(bar / bb).value: ${(bar / bb).value}")
|
||||
assert((baz / bb).value == expectedBb, s"(baz / bb).value: ${(baz / bb).value}")
|
||||
assert((qux / bb).value == expectedBb, s"(qux / bb).value: ${(qux / bb).value}")
|
||||
}
|
||||
check / aggregate := false
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
Global / localCacheDirectory := baseDirectory.value / "diskcache"
|
||||
scalaVersion := "3.8.4"
|
||||
|
||||
Test / fork := true
|
||||
|
||||
libraryDependencies += "org.scalameta" %% "munit" % "1.0.4" % Test
|
||||
|
||||
val check = TaskKey[Unit]("check", "Verify the shutdown hook could load classes.")
|
||||
|
||||
check := Def.uncached {
|
||||
val file = baseDirectory.value / "hook-result.txt"
|
||||
val deadline = System.currentTimeMillis + 30000
|
||||
def content: String = if (file.exists) IO.read(file).trim else ""
|
||||
while (content.isEmpty || content.startsWith("PENDING")) {
|
||||
if (System.currentTimeMillis > deadline)
|
||||
sys.error(s"shutdown hook never completed: '$content'")
|
||||
Thread.sleep(500)
|
||||
}
|
||||
val result = content
|
||||
if (!result.startsWith("OK")) sys.error(s"shutdown hook failed: '$result'")
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package repro
|
||||
|
||||
object LazilyLoaded {
|
||||
val marker: String = "loaded"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package repro
|
||||
|
||||
import java.nio.file.{ Files, Paths }
|
||||
|
||||
class ShutdownHookTest extends munit.FunSuite {
|
||||
test("shutdown hook can load classes after the test run") {
|
||||
val out = Paths.get("hook-result.txt")
|
||||
Files.writeString(out, "PENDING: shutdown hook did not run" + System.lineSeparator)
|
||||
val _ = sys.addShutdownHook {
|
||||
val result =
|
||||
try {
|
||||
Class.forName("repro.LazilyLoaded$")
|
||||
"OK: classloader still works at JVM shutdown"
|
||||
} catch {
|
||||
case t: Throwable => s"FAIL: $t"
|
||||
}
|
||||
Files.writeString(out, result + System.lineSeparator)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
> testFull
|
||||
> check
|
||||
@@ -0,0 +1,28 @@
|
||||
import sbt.*
|
||||
import sbt.Tests.Output
|
||||
import sbt.util.Logger
|
||||
|
||||
ThisBuild / scalaVersion := "3.8.4"
|
||||
|
||||
val marker = file("test-result-logger-ran")
|
||||
val verify = "com.eed3si9n.verify" %% "verify" % "1.0.0"
|
||||
|
||||
libraryDependencies += verify % Test
|
||||
testFrameworks += new TestFramework("verify.runner.Framework")
|
||||
|
||||
Test / testResultLogger := new TestResultLogger:
|
||||
def run(log: Logger, results: Output, taskName: String): Unit =
|
||||
val suiteResults: Iterable[SuiteResult] =
|
||||
results.withoutThrowables.events.values.map(_.withoutThrowables)
|
||||
val suite = suiteResults.head
|
||||
IO.write(
|
||||
marker,
|
||||
Seq(
|
||||
s"overall=${results.overall}",
|
||||
s"suites=${suiteResults.size}",
|
||||
s"suiteResult=${suite.result}",
|
||||
s"passed=${suite.passedCount}",
|
||||
s"failed=${suite.failureCount}",
|
||||
s"errors=${suite.errorCount}"
|
||||
).mkString("", "\n", "\n")
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
overall=Passed
|
||||
suites=1
|
||||
suiteResult=Passed
|
||||
passed=1
|
||||
failed=0
|
||||
errors=0
|
||||
@@ -0,0 +1,3 @@
|
||||
object LoggerApiTest extends verify.BasicTestSuite:
|
||||
test("test result logger receives a suite result"):
|
||||
assert(true)
|
||||
@@ -0,0 +1,2 @@
|
||||
> test
|
||||
$ must-mirror expected-result test-result-logger-ran
|
||||
@@ -55,11 +55,12 @@ trait TestsListener extends TestReportListener {
|
||||
* a `Class` strongly references its defining class loader. Holding a `SuiteResult` with a
|
||||
* non-empty `throwables` therefore keeps the test class loader -- and every jar handle it has
|
||||
* open -- alive. That is fine for the duration of the test task, which is where these are
|
||||
* consumed, but anything that outlives the task must drop them first. `TestRecap.collect` does
|
||||
* exactly that before stashing a copy on `State.attributes`; see the note on `TestRecap.recapKey`.
|
||||
* consumed, but anything that outlives the task must call [[withoutThrowables]] first.
|
||||
* `TestSummary.append` does exactly that before stashing a copy on `State.attributes`; see the
|
||||
* note on `TestSummary.entriesKey`.
|
||||
* On Windows a leaked handle makes the underlying jar undeletable (e.g. by `clearCaches`).
|
||||
*/
|
||||
private[sbt] final class SuiteResult(
|
||||
final class SuiteResult(
|
||||
val result: TestResult,
|
||||
val passedCount: Int,
|
||||
val failureCount: Int,
|
||||
@@ -91,13 +92,36 @@ private[sbt] final class SuiteResult(
|
||||
pendingCount,
|
||||
Nil
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns an equivalent result without retaining test-thrown exceptions.
|
||||
*
|
||||
* Use this before retaining test results beyond the lifetime of the test task. See
|
||||
* `throwables` for why retaining those exceptions can keep the test class loader alive.
|
||||
*/
|
||||
def withoutThrowables: SuiteResult =
|
||||
if throwables.isEmpty then this
|
||||
else
|
||||
new SuiteResult(
|
||||
result,
|
||||
passedCount,
|
||||
failureCount,
|
||||
errorCount,
|
||||
skippedCount,
|
||||
ignoredCount,
|
||||
canceledCount,
|
||||
pendingCount,
|
||||
)
|
||||
|
||||
def +(other: SuiteResult): SuiteResult = {
|
||||
val combinedTestResult =
|
||||
(result, other.result) match {
|
||||
case (TestResult.Passed, TestResult.Passed) => TestResult.Passed: TestResult
|
||||
case (_, TestResult.Error) => TestResult.Error: TestResult
|
||||
case (TestResult.Error, _) => TestResult.Error: TestResult
|
||||
case _ => TestResult.Failed: TestResult
|
||||
case (TestResult.Empty, TestResult.Empty) => TestResult.Empty: TestResult
|
||||
case (TestResult.Passed | TestResult.Empty, TestResult.Passed | TestResult.Empty) =>
|
||||
TestResult.Passed: TestResult
|
||||
case (_, TestResult.Error) => TestResult.Error: TestResult
|
||||
case (TestResult.Error, _) => TestResult.Error: TestResult
|
||||
case _ => TestResult.Failed: TestResult
|
||||
}
|
||||
new SuiteResult(
|
||||
combinedTestResult,
|
||||
@@ -113,7 +137,7 @@ private[sbt] final class SuiteResult(
|
||||
}
|
||||
}
|
||||
|
||||
private[sbt] object SuiteResult {
|
||||
object SuiteResult {
|
||||
|
||||
/**
|
||||
* Computes the overall result and counts for a suite with individual test results in `events`.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* sbt
|
||||
* Copyright 2026, Scala center
|
||||
* Copyright 2011 - 2022, Lightbend, Inc.
|
||||
* Copyright 2008 - 2010, Mark Harrah
|
||||
* Licensed under Apache License 2.0 (see LICENSE)
|
||||
*/
|
||||
|
||||
package sbt
|
||||
|
||||
import sbt.protocol.testing.TestResult
|
||||
import verify.BasicTestSuite
|
||||
|
||||
object SuiteResultSpec extends BasicTestSuite:
|
||||
|
||||
private def emptySuite(result: TestResult): SuiteResult =
|
||||
new SuiteResult(result, 0, 0, 0, 0, 0, 0, 0)
|
||||
|
||||
private def assertCombined(
|
||||
left: TestResult,
|
||||
right: TestResult
|
||||
)(expected: TestResult): Unit =
|
||||
val actual = (emptySuite(left) + emptySuite(right)).result
|
||||
assert(actual == expected)
|
||||
|
||||
test("SuiteResult combines Passed and Passed as Passed") {
|
||||
assertCombined(TestResult.Passed, TestResult.Passed)(TestResult.Passed)
|
||||
}
|
||||
|
||||
test("SuiteResult combines Passed and Empty as Passed") {
|
||||
assertCombined(TestResult.Passed, TestResult.Empty)(TestResult.Passed)
|
||||
assertCombined(TestResult.Empty, TestResult.Passed)(TestResult.Passed)
|
||||
}
|
||||
|
||||
test("SuiteResult combines Passed and Failed as Failed") {
|
||||
assertCombined(TestResult.Passed, TestResult.Failed)(TestResult.Failed)
|
||||
assertCombined(TestResult.Failed, TestResult.Passed)(TestResult.Failed)
|
||||
}
|
||||
|
||||
test("SuiteResult combines Passed and Error as Error") {
|
||||
assertCombined(TestResult.Passed, TestResult.Error)(TestResult.Error)
|
||||
assertCombined(TestResult.Error, TestResult.Passed)(TestResult.Error)
|
||||
}
|
||||
|
||||
test("SuiteResult combines Empty and Empty as Empty") {
|
||||
assertCombined(TestResult.Empty, TestResult.Empty)(TestResult.Empty)
|
||||
}
|
||||
|
||||
test("SuiteResult combines Empty and Failed as Failed") {
|
||||
assertCombined(TestResult.Empty, TestResult.Failed)(TestResult.Failed)
|
||||
assertCombined(TestResult.Failed, TestResult.Empty)(TestResult.Failed)
|
||||
}
|
||||
|
||||
test("SuiteResult combines Empty and Error as Error") {
|
||||
assertCombined(TestResult.Empty, TestResult.Error)(TestResult.Error)
|
||||
assertCombined(TestResult.Error, TestResult.Empty)(TestResult.Error)
|
||||
}
|
||||
|
||||
test("SuiteResult combines Failed and Failed as Failed") {
|
||||
assertCombined(TestResult.Failed, TestResult.Failed)(TestResult.Failed)
|
||||
}
|
||||
|
||||
test("SuiteResult combines Failed and Error as Error") {
|
||||
assertCombined(TestResult.Failed, TestResult.Error)(TestResult.Error)
|
||||
assertCombined(TestResult.Error, TestResult.Failed)(TestResult.Error)
|
||||
}
|
||||
|
||||
test("SuiteResult combines Error and Error as Error") {
|
||||
assertCombined(TestResult.Error, TestResult.Error)(TestResult.Error)
|
||||
}
|
||||
|
||||
end SuiteResultSpec
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* sbt
|
||||
* Copyright 2023, Scala center
|
||||
* Copyright 2011 - 2022, Lightbend, Inc.
|
||||
* Copyright 2008 - 2010, Mark Harrah
|
||||
* Licensed under Apache License 2.0 (see LICENSE)
|
||||
*/
|
||||
|
||||
package sbt.internal.worker1;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.StandardProtocolFamily;
|
||||
import java.net.UnixDomainSocketAddress;
|
||||
import java.nio.channels.SocketChannel;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class JdkCompat {
|
||||
public static SocketChannel connectUnixSocket(Path socketPath) throws IOException {
|
||||
SocketChannel client = SocketChannel.open(StandardProtocolFamily.UNIX);
|
||||
client.connect(UnixDomainSocketAddress.of(socketPath));
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* sbt
|
||||
* Copyright 2023, Scala center
|
||||
* Copyright 2011 - 2022, Lightbend, Inc.
|
||||
* Copyright 2008 - 2010, Mark Harrah
|
||||
* Licensed under Apache License 2.0 (see LICENSE)
|
||||
*/
|
||||
|
||||
package sbt.internal.worker1;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.Socket;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.SocketChannel;
|
||||
|
||||
/**
|
||||
* java.nio.channels.Channels.newInputStream/newOutputStream both synchronize on the channel's
|
||||
* blockingLock() for the duration of each blocking call, so a thread parked in a blocking read
|
||||
* holds that lock for as long as the read blocks, and a concurrent writer on the same channel can
|
||||
* never acquire it. These factories talk to the channel directly instead, so a SocketChannel can
|
||||
* safely be read and written from different threads at the same time.
|
||||
*/
|
||||
public final class DuplexChannels {
|
||||
private DuplexChannels() {}
|
||||
|
||||
public static OutputStream newOutputStream(SocketChannel ch) {
|
||||
return new OutputStream() {
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
ByteBuffer bb = ByteBuffer.wrap(new byte[] {(byte) b});
|
||||
while (bb.hasRemaining()) ch.write(bb);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] b, int off, int len) throws IOException {
|
||||
ByteBuffer bb = ByteBuffer.wrap(b, off, len);
|
||||
while (bb.hasRemaining()) ch.write(bb);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static InputStream newInputStream(SocketChannel ch) {
|
||||
return new InputStream() {
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
ByteBuffer bb = ByteBuffer.allocate(1);
|
||||
int n = ch.read(bb);
|
||||
return n <= 0 ? -1 : (bb.get(0) & 0xff);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) throws IOException {
|
||||
if (len == 0) return 0;
|
||||
return ch.read(ByteBuffer.wrap(b, off, len));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a connected SocketChannel as a Socket backed by {@link #newInputStream}/{@link
|
||||
* #newOutputStream}.
|
||||
*/
|
||||
public static Socket newSocket(SocketChannel ch) {
|
||||
return new Socket() {
|
||||
private final InputStream in = newInputStream(ch);
|
||||
private final OutputStream out = newOutputStream(ch);
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return in;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
ch.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClosed() {
|
||||
return !ch.isOpen();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* sbt
|
||||
* Copyright 2023, Scala center
|
||||
* Copyright 2011 - 2022, Lightbend, Inc.
|
||||
* Copyright 2008 - 2010, Mark Harrah
|
||||
* Licensed under Apache License 2.0 (see LICENSE)
|
||||
*/
|
||||
|
||||
package sbt.internal.worker1;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.SocketChannel;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Base (Java 8) fallback. The Multi-Release variant under src/jdk17/java implements this using JDK
|
||||
* 16+ Unix domain socket APIs (StandardProtocolFamily.UNIX, UnixDomainSocketAddress); it's the one
|
||||
* actually loaded when the worker runs on Java 17+.
|
||||
*/
|
||||
public class JdkCompat {
|
||||
public static SocketChannel connectUnixSocket(Path socketPath) throws IOException {
|
||||
throw new UnsupportedOperationException(
|
||||
"Unix domain sockets require Java 16+; this worker JVM is running on an older version");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,22 @@
|
||||
|
||||
package sbt.internal.worker1;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintStream;
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.InetAddress;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.Socket;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.channels.SocketChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Scanner;
|
||||
import org.scalasbt.shadedgson.com.google.gson.Gson;
|
||||
import org.scalasbt.shadedgson.com.google.gson.GsonBuilder;
|
||||
import org.scalasbt.shadedgson.com.google.gson.JsonElement;
|
||||
@@ -15,21 +31,6 @@ import org.scalasbt.shadedgson.com.google.gson.JsonObject;
|
||||
import org.scalasbt.shadedgson.com.google.gson.JsonParser;
|
||||
import org.scalasbt.shadedgson.com.google.gson.JsonPrimitive;
|
||||
import org.scalasbt.shadedgson.com.google.gson.typeadapters.RuntimeTypeAdapterFactory;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Scanner;
|
||||
import sbt.testing.*;
|
||||
|
||||
/**
|
||||
@@ -79,6 +80,10 @@ public final class WorkerMain {
|
||||
int serverPort = Integer.parseInt(args[1]);
|
||||
app.socketWork(serverPort);
|
||||
System.exit(0);
|
||||
} else if (args.length == 2 && args[0].equals("--ipc")) {
|
||||
WorkerMain app = new WorkerMain();
|
||||
app.ipcWork(Paths.get(args[1]));
|
||||
System.exit(0);
|
||||
} else {
|
||||
System.err.println("missing args");
|
||||
System.exit(1);
|
||||
@@ -124,6 +129,16 @@ public final class WorkerMain {
|
||||
}
|
||||
}
|
||||
|
||||
void ipcWork(Path socketPath) throws Exception {
|
||||
SocketChannel client = JdkCompat.connectUnixSocket(socketPath);
|
||||
this.jsonOut = new PrintStream(DuplexChannels.newOutputStream(client), true, "UTF-8");
|
||||
this.inScanner = new Scanner(DuplexChannels.newInputStream(client), "UTF-8");
|
||||
if (this.inScanner.hasNextLine()) {
|
||||
String line = this.inScanner.nextLine();
|
||||
process(line);
|
||||
}
|
||||
}
|
||||
|
||||
/** This processes single request of supposed JSON line. */
|
||||
void process(String json) throws Exception {
|
||||
JsonElement elem = JsonParser.parseString(json);
|
||||
@@ -173,12 +188,11 @@ public final class WorkerMain {
|
||||
throw new RuntimeException("missing jvmRunInfo element");
|
||||
}
|
||||
RunInfo.JvmRunInfo jvmRunInfo = info.jvmRunInfo;
|
||||
try (URLClassLoader cl = createClassLoader(jvmRunInfo, ClassLoader.getSystemClassLoader())) {
|
||||
Class<?> mainClass = cl.loadClass(jvmRunInfo.mainClass);
|
||||
Method mainMethod = mainClass.getMethod("main", String[].class);
|
||||
String[] mainArgs = jvmRunInfo.args.stream().toArray(String[]::new);
|
||||
mainMethod.invoke(null, (Object) mainArgs);
|
||||
}
|
||||
URLClassLoader cl = createClassLoader(jvmRunInfo, ClassLoader.getSystemClassLoader());
|
||||
Class<?> mainClass = cl.loadClass(jvmRunInfo.mainClass);
|
||||
Method mainMethod = mainClass.getMethod("main", String[].class);
|
||||
String[] mainArgs = jvmRunInfo.args.stream().toArray(String[]::new);
|
||||
mainMethod.invoke(null, (Object) mainArgs);
|
||||
} else {
|
||||
throw new RuntimeException("only jvm is supported");
|
||||
}
|
||||
@@ -192,9 +206,8 @@ public final class WorkerMain {
|
||||
if (jvmRunInfo.classpath.isEmpty()) {
|
||||
ForkTestMain.main(id, info, this.jsonOut, parent);
|
||||
} else {
|
||||
try (URLClassLoader cl = createClassLoader(jvmRunInfo, parent)) {
|
||||
ForkTestMain.main(id, info, this.jsonOut, cl);
|
||||
}
|
||||
URLClassLoader cl = createClassLoader(jvmRunInfo, parent);
|
||||
ForkTestMain.main(id, info, this.jsonOut, cl);
|
||||
}
|
||||
} else {
|
||||
throw new RuntimeException("only jvm is supported");
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
package sbt.internal.worker1
|
||||
|
||||
import java.net.{ StandardProtocolFamily, UnixDomainSocketAddress }
|
||||
import java.nio.channels.ServerSocketChannel
|
||||
import scala.util.Using
|
||||
import sbt.io.IO
|
||||
|
||||
object WorkerTest extends verify.BasicTestSuite:
|
||||
val main = WorkerMain()
|
||||
|
||||
test("JDK UNIX domain socket connects via Multi-Release JAR"):
|
||||
IO.withTemporaryDirectory: dir =>
|
||||
val path = dir.toPath.resolve("test.sock")
|
||||
Using.resource(ServerSocketChannel.open(StandardProtocolFamily.UNIX)): server =>
|
||||
server.bind(UnixDomainSocketAddress.of(path))
|
||||
Using.resource(JdkCompat.connectUnixSocket(path)): client =>
|
||||
Using.resource(server.accept()): accepted =>
|
||||
assert(client.isConnected)
|
||||
assert(accepted != null)
|
||||
|
||||
test("process") {
|
||||
val u0 = IO.classLocationPath(classOf[example.Hello]).toUri()
|
||||
val u1 = IO.classLocationPath(classOf[scala.quoted.Quotes]).toUri()
|
||||
|
||||
Reference in New Issue
Block a user