diff --git a/modules/build/src/main/scala/scala/build/compiler/SimpleScalaCompiler.scala b/modules/build/src/main/scala/scala/build/compiler/SimpleScalaCompiler.scala index a1d8947e9d..727e0da6ce 100644 --- a/modules/build/src/main/scala/scala/build/compiler/SimpleScalaCompiler.scala +++ b/modules/build/src/main/scala/scala/build/compiler/SimpleScalaCompiler.scala @@ -78,7 +78,7 @@ final case class SimpleScalaCompiler( args, logger, cwd = Some(project.workspace) - ) + ).waitFor() res == 0 } diff --git a/modules/build/src/main/scala/scala/build/internal/Runner.scala b/modules/build/src/main/scala/scala/build/internal/Runner.scala index 1c04b870ca..3fe781228f 100644 --- a/modules/build/src/main/scala/scala/build/internal/Runner.scala +++ b/modules/build/src/main/scala/scala/build/internal/Runner.scala @@ -26,7 +26,7 @@ object Runner { logger: Logger, allowExecve: Boolean = false, cwd: Option[os.Path] = None - ): Int = { + ): Process = { import logger.{log, debug} @@ -54,7 +54,8 @@ object Runner { .inheritIO() for (dir <- cwd) b.directory(dir.toIO) - b.start().waitFor() + val process = b.start() + process } } @@ -67,7 +68,7 @@ object Runner { logger: Logger, allowExecve: Boolean = false, cwd: Option[os.Path] = None - ): Int = { + ): Process = { val command = Seq(javaCommand) ++ @@ -116,7 +117,7 @@ object Runner { args: Seq[String], logger: Logger, allowExecve: Boolean = false - ): Int = { + ): Process = { import logger.{log, debug} @@ -138,11 +139,12 @@ object Runner { ) sys.error("should not happen") } - else - new ProcessBuilder(command: _*) + else { + val process = new ProcessBuilder(command: _*) .inheritIO() .start() - .waitFor() + process + } } def runNative( @@ -150,7 +152,7 @@ object Runner { args: Seq[String], logger: Logger, allowExecve: Boolean = false - ): Int = { + ): Process = { import logger.{log, debug} @@ -171,11 +173,12 @@ object Runner { ) sys.error("should not happen") } - else - new ProcessBuilder(command: _*) + else { + val process = new ProcessBuilder(command: _*) .inheritIO() .start() - .waitFor() + process + } } private def runTests( diff --git a/modules/cli-options/src/main/scala/scala/cli/commands/SharedWatchOptions.scala b/modules/cli-options/src/main/scala/scala/cli/commands/SharedWatchOptions.scala index 649b4ece30..e325e1e743 100644 --- a/modules/cli-options/src/main/scala/scala/cli/commands/SharedWatchOptions.scala +++ b/modules/cli-options/src/main/scala/scala/cli/commands/SharedWatchOptions.scala @@ -7,10 +7,13 @@ final case class SharedWatchOptions( @HelpMessage("Watch source files for changes") @Name("w") - watch: Boolean = false + watch: Boolean = false, + @HelpMessage("Run your application in background and automatically restart if sources have been changed") + revolver: Boolean = false +) { // format: on -) -// format: on + lazy val watchMode = watch || revolver +} object SharedWatchOptions { lazy val parser: Parser[SharedWatchOptions] = Parser.derive diff --git a/modules/cli/src/main/scala/scala/cli/commands/Compile.scala b/modules/cli/src/main/scala/scala/cli/commands/Compile.scala index e6f861e1bd..f8f76cbcb8 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/Compile.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/Compile.scala @@ -77,7 +77,7 @@ object Compile extends ScalaCommand[CompileOptions] { val compilerMaker = options.shared.compilerMaker(threads) - if (options.watch.watch) { + if (options.watch.watchMode) { val watcher = Build.watch( inputs, buildOptions, diff --git a/modules/cli/src/main/scala/scala/cli/commands/Package.scala b/modules/cli/src/main/scala/scala/cli/commands/Package.scala index ff85d1969a..0ec7013b0b 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/Package.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/Package.scala @@ -51,7 +51,7 @@ object Package extends ScalaCommand[PackageOptions] { val cross = options.compileCross.cross.getOrElse(false) - if (options.watch.watch) { + if (options.watch.watchMode) { var expectedModifyEpochSecondOpt = Option.empty[Long] val watcher = Build.watch( inputs, @@ -401,7 +401,7 @@ object Package extends ScalaCommand[PackageOptions] { args, logger, cwd = Some(build.inputs.workspace) - ) + ).waitFor() if (retCode == 0) Library.libraryJar(build, hasActualManifest = false, contentDirOverride = Some(destDir)) else @@ -759,7 +759,7 @@ object Package extends ScalaCommand[PackageOptions] { "scala.scalanative.cli.ScalaNativeLd", args, logger - ) + ).waitFor() if (exitCode == 0) NativeBuilderHelper.updateProjectAndOutputSha(dest, nativeWorkDir, cacheData.projectSha) else diff --git a/modules/cli/src/main/scala/scala/cli/commands/Repl.scala b/modules/cli/src/main/scala/scala/cli/commands/Repl.scala index a9b73732fd..5482d986b5 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/Repl.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/Repl.scala @@ -102,14 +102,14 @@ object Repl extends ScalaCommand[ReplOptions] { if (inputs.isEmpty) { val artifacts = initialBuildOptions.artifacts(logger).orExit(logger) - doRunRepl(initialBuildOptions, artifacts, None, allowExit = !options.watch.watch) - if (options.watch.watch) { + doRunRepl(initialBuildOptions, artifacts, None, allowExit = !options.watch.watchMode) + if (options.watch.watchMode) { // nothing to watch, just wait for Ctrl+C WatchUtil.printWatchMessage() WatchUtil.waitForCtrlC() } } - else if (options.watch.watch) { + else if (options.watch.watchMode) { val watcher = Build.watch( inputs, initialBuildOptions, diff --git a/modules/cli/src/main/scala/scala/cli/commands/Run.scala b/modules/cli/src/main/scala/scala/cli/commands/Run.scala index 15e9471aa6..cf5492a62d 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/Run.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/Run.scala @@ -2,6 +2,8 @@ package scala.cli.commands import caseapp._ +import java.util.concurrent.CompletableFuture + import scala.build.EitherCps.{either, value} import scala.build.errors.BuildException import scala.build.internal.{Constants, Runner, ScalaJsLinkerConfig} @@ -9,6 +11,7 @@ import scala.build.options.{BuildOptions, JavaOpt, Platform} import scala.build.{Build, BuildThreads, Inputs, Logger, Positioned} import scala.cli.CurrentParams import scala.cli.commands.util.SharedOptionsUtil._ +import scala.cli.internal.ProcUtil import scala.util.Properties object Run extends ScalaCommand[RunOptions] { @@ -58,17 +61,37 @@ object Run extends ScalaCommand[RunOptions] { val compilerMaker = options.shared.compilerMaker(threads) - def maybeRun(build: Build.Successful, allowTerminate: Boolean): Either[BuildException, Unit] = - maybeRunOnce( + def maybeRun( + build: Build.Successful, + allowTerminate: Boolean + ): Either[BuildException, (Process, CompletableFuture[_])] = either { + val process = value(maybeRunOnce( inputs.workspace, inputs.projectName, build, programArgs, logger, allowExecve = allowTerminate, - exitOnError = allowTerminate, jvmRunner = build.options.addRunnerDependency.getOrElse(true) - ) + )) + + val onExitProcess = process.onExit().thenApply { p1 => + val retCode = p1.exitValue() + if (retCode != 0) + if (allowTerminate) + sys.exit(retCode) + else { + val red = Console.RED + val lightRed = "\u001b[91m" + val reset = Console.RESET + System.err.println( + s"${red}Program exited with return code $lightRed$retCode$red.$reset" + ) + } + } + + (process, onExitProcess) + } val cross = options.compileCross.cross.getOrElse(false) SetupIde.runSafe( @@ -80,7 +103,8 @@ object Run extends ScalaCommand[RunOptions] { if (CommandUtils.shouldCheckUpdate) Update.checkUpdateSafe(logger) - if (options.watch.watch) { + if (options.watch.watchMode) { + var processOpt = Option.empty[(Process, CompletableFuture[_])] val watcher = Build.watch( inputs, initialBuildOptions, @@ -91,10 +115,21 @@ object Run extends ScalaCommand[RunOptions] { partial = None, postAction = () => WatchUtil.printWatchMessage() ) { res => + for ((process, onExitProcess) <- processOpt) { + onExitProcess.cancel(true) + ProcUtil.interruptProcess(process, logger) + } res.orReport(logger).map(_.main).foreach { case s: Build.Successful => - maybeRun(s, allowTerminate = false) + for ((proc, _) <- processOpt) // If the process doesn't exit, send SIGKILL + if (proc.isAlive) ProcUtil.forceKillProcess(proc, logger) + val maybeProcess = maybeRun(s, allowTerminate = false) .orReport(logger) + if (options.watch.revolver) + processOpt = maybeProcess + else + for ((proc, onExit) <- maybeProcess) + ProcUtil.waitForProcess(proc, onExit) case _: Build.Failed => System.err.println("Compilation failed") } @@ -116,8 +151,9 @@ object Run extends ScalaCommand[RunOptions] { .orExit(logger) builds.main match { case s: Build.Successful => - maybeRun(s, allowTerminate = true) + val (process, onExit) = maybeRun(s, allowTerminate = true) .orExit(logger) + ProcUtil.waitForProcess(process, onExit) case _: Build.Failed => System.err.println("Compilation failed") sys.exit(1) @@ -132,9 +168,8 @@ object Run extends ScalaCommand[RunOptions] { args: Seq[String], logger: Logger, allowExecve: Boolean, - exitOnError: Boolean, jvmRunner: Boolean - ): Either[BuildException, Unit] = either { + ): Either[BuildException, Process] = either { val mainClassOpt = build.options.mainClass.filter(_.nonEmpty) // trim it too? .orElse { @@ -157,8 +192,7 @@ object Run extends ScalaCommand[RunOptions] { finalMainClass, finalArgs, logger, - allowExecve, - exitOnError + allowExecve ) value(res) } @@ -170,30 +204,32 @@ object Run extends ScalaCommand[RunOptions] { mainClass: String, args: Seq[String], logger: Logger, - allowExecve: Boolean, - exitOnError: Boolean - ): Either[BuildException, Boolean] = either { + allowExecve: Boolean + ): Either[BuildException, Process] = either { - val retCode = build.options.platform.value match { + val process = build.options.platform.value match { case Platform.JS => val linkerConfig = build.options.scalaJsOptions.linkerConfig(logger) + val jsDest = os.temp(prefix = "main", suffix = ".js") val res = - withLinkedJs( + Package.linkJs( build, + jsDest, Some(mainClass), addTestInitializer = false, linkerConfig, build.options.scalaJsOptions.fullOpt.getOrElse(false), build.options.scalaJsOptions.noOpt.getOrElse(false), logger - ) { - js => - Runner.runJs( - js.toIO, - args, - logger, - allowExecve = allowExecve - ) + ).map { _ => + val process = Runner.runJs( + jsDest.toIO, + args, + logger, + allowExecve = allowExecve + ) + process.onExit().thenApply(_ => if (os.exists(jsDest)) os.remove(jsDest)) + process } value(res) case Platform.Native => @@ -222,17 +258,7 @@ object Run extends ScalaCommand[RunOptions] { ) } - if (retCode != 0) - if (exitOnError) - sys.exit(retCode) - else { - val red = Console.RED - val lightRed = "\u001b[91m" - val reset = Console.RESET - System.err.println(s"${red}Program exited with return code $lightRed$retCode$red.$reset") - } - - retCode == 0 + process } def withLinkedJs[T]( diff --git a/modules/cli/src/main/scala/scala/cli/commands/Test.scala b/modules/cli/src/main/scala/scala/cli/commands/Test.scala index a59d0fe085..8549e858d7 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/Test.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/Test.scala @@ -114,7 +114,7 @@ object Test extends ScalaCommand[TestOptions] { } } - if (options.watch.watch) { + if (options.watch.watchMode) { val watcher = Build.watch( inputs, initialBuildOptions, @@ -221,7 +221,7 @@ object Test extends ScalaCommand[TestOptions] { extraArgs, logger, allowExecve = allowExecve - ) + ).waitFor() } } diff --git a/modules/cli/src/main/scala/scala/cli/internal/ProcUtil.scala b/modules/cli/src/main/scala/scala/cli/internal/ProcUtil.scala index fccc4963cb..cada4dd395 100644 --- a/modules/cli/src/main/scala/scala/cli/internal/ProcUtil.scala +++ b/modules/cli/src/main/scala/scala/cli/internal/ProcUtil.scala @@ -3,6 +3,11 @@ package scala.cli.internal import java.io.InputStream import java.net.URL import java.nio.charset.StandardCharsets +import java.util.concurrent.{CancellationException, CompletableFuture, CompletionException} + +import scala.build.Logger +import scala.util.Properties +import scala.util.control.NonFatal object ProcUtil { @@ -43,4 +48,39 @@ object ProcUtil { new String(data, StandardCharsets.UTF_8) } + def forceKillProcess(process: Process, logger: Logger): Unit = { + if (process.isAlive) { + process.destroyForcibly() + logger.debug(s"Killing user process ${process.pid()}") + } + } + + def interruptProcess(process: Process, logger: Logger): Unit = { + val pid = process.pid() + try + if (process.isAlive) { + logger.debug("Interrupting running process") + if (Properties.isWin) { + os.proc("taskkill", "/PID", pid).call() + logger.debug(s"Run following command to interrupt process: 'taskkill /PID $pid'") + } + else { + os.proc("kill", "-2", pid).call() + logger.debug(s"Run following command to interrupt process: 'kill -2 $pid'") + } + } + catch { // ignore the failure if the process isn't running, might mean it exited between the first check and the call of the command to kill it + case NonFatal(e) => + logger.debug(s"Ignoring error during interrupt process: $e") + } + } + + def waitForProcess(process: Process, onExit: CompletableFuture[_]): Unit = { + process.waitFor() + try onExit.join() + catch { + case _: CancellationException | _: CompletionException => // ignored + } + } + } diff --git a/modules/cli/src/main/scala/scala/cli/internal/ScalaJsLinker.scala b/modules/cli/src/main/scala/scala/cli/internal/ScalaJsLinker.scala index a60bd2397d..bc877a4861 100644 --- a/modules/cli/src/main/scala/scala/cli/internal/ScalaJsLinker.scala +++ b/modules/cli/src/main/scala/scala/cli/internal/ScalaJsLinker.scala @@ -132,11 +132,12 @@ object ScalaJsLinker { } val cmd = command ++ allArgs.flatMap(_.value) - val retCode = Runner.run( + val res = Runner.run( "unused", cmd, logger ) + val retCode = res.waitFor() if (retCode == 0) logger.debug("Scala.JS linker ran successfully") diff --git a/modules/cli/src/main/scala/scala/cli/launcher/LauncherCli.scala b/modules/cli/src/main/scala/scala/cli/launcher/LauncherCli.scala index 1926c19661..8c8062fc8d 100644 --- a/modules/cli/src/main/scala/scala/cli/launcher/LauncherCli.scala +++ b/modules/cli/src/main/scala/scala/cli/launcher/LauncherCli.scala @@ -60,7 +60,7 @@ object LauncherCli { remainingArgs, logger, allowExecve = true - ) + ).waitFor() sys.exit(exitCode) } diff --git a/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala b/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala index 216f5c1a20..bd374711ce 100644 --- a/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala +++ b/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala @@ -251,10 +251,10 @@ object NativeImage { case Some(vcvars) => runFromVcvarsBat(command, vcvars, nativeImageWorkDir, logger) case None => - Runner.run("unused", command, logger) + Runner.run("unused", command, logger).waitFor() } else - Runner.run("unused", command, logger) + Runner.run("unused", command, logger).waitFor() if (exitCode == 0) { val actualDest = if (Properties.isWin) diff --git a/website/docs/commands/run.md b/website/docs/commands/run.md index 05661743e3..d5d8d09a77 100644 --- a/website/docs/commands/run.md +++ b/website/docs/commands/run.md @@ -62,6 +62,33 @@ scala-cli Hello.scala --jvm adopt:14 JVMs are [managed by coursier](https://get-coursier.io/docs/cli-java#managed-jvms), and are based on the [index](https://github.com/shyiko/jabba/blob/master/index.json) of the [jabba](https://github.com/shyiko/jabba) command-line tool. +## Watch mode + +`--watch` makes `scala-cli` watch your code for changes, and re-runs it upon any change: + +```bash ignore +scala-cli run Hello.scala --watch +# Hello +# Watching sources, press Ctrl+C to exit. +# Compiling project (Scala 3.1.1, JVM) +# Compiled project (Scala 3.1.1, JVM) +# Hello World +# Watching sources, press Ctrl+C to exit. +``` +### Watch mode - revolver + +`--revolver` mode runs your application in the background and automatically restart it upon any change: + +```bash ignore +scala-cli run Hello.scala --revolver +# Hello +# Watching sources, press Ctrl+C to exit. +# Compiling project (Scala 3.1.1, JVM) +# Compiled project (Scala 3.1.1, JVM) +# Hello World +# Watching sources, press Ctrl+C to exit. +``` + ## Scala.JS Scala.JS applications can also be compiled and run with the `--js` option. diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 96aa62d450..69c83181f3 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1295,6 +1295,10 @@ Aliases: `-w` Watch source files for changes +#### `--revolver` + +Run your application in background and automatically restart if sources have been changed + ## Workspace options Available in commands: