Skip to content

Commit 8b57fd5

Browse files
committed
Fix conflicts when watch and interactive try to read StdIn, add test
1 parent a9b7f7b commit 8b57fd5

File tree

5 files changed

+150
-6
lines changed

5 files changed

+150
-6
lines changed

modules/cli/src/main/scala/scala/cli/commands/WatchUtil.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package scala.cli.commands
22

33
import scala.annotation.tailrec
4+
import scala.build.internal.StdInConcurrentReader
45
import scala.build.internal.util.ConsoleUtils.ScalaCliConsole
56

67
object WatchUtil {
@@ -35,7 +36,7 @@ object WatchUtil {
3536
@tailrec
3637
def readNextChar(): Int =
3738
if (shouldReadInput())
38-
try System.in.read()
39+
try StdInConcurrentReader.waitForLine().map(s => (s + '\n').head.toInt).getOrElse(-1)
3940
catch {
4041
case _: InterruptedException =>
4142
// Actually never called, as System.in.read isn't interruptible…

modules/cli/src/main/scala/scala/cli/commands/run/Run.scala

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,23 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
225225
)
226226

227227
if (options.sharedRun.watch.watchMode) {
228-
var processOpt = Option.empty[(Process, CompletableFuture[_])]
228+
229+
/** A handle to the Runner process, used to kill the process if it's still alive when a change
230+
* occured and restarts are allowed or to wait for it if restarts are not allowed
231+
*/
232+
var processOpt = Option.empty[(Process, CompletableFuture[_])]
233+
234+
/** shouldReadInput controls whether [[WatchUtil.waitForCtrlC]](that's keeping the main thread
235+
* alive) should try to read StdIn or just call wait()
236+
*/
229237
var shouldReadInput = false
230-
var mainThreadOpt = Option.empty[Thread]
238+
239+
/** a handle to the main thread to interrupt its operations when:
240+
* - it's blocked on reading StdIn, and it's no longer required
241+
* - it's waiting and should start reading StdIn
242+
*/
243+
var mainThreadOpt = Option.empty[Thread]
244+
231245
val watcher = Build.watch(
232246
inputs,
233247
initialBuildOptions,

modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import java.io.{ByteArrayOutputStream, File}
66
import java.nio.charset.Charset
77

88
import scala.cli.integration.util.DockerServer
9+
import scala.concurrent.ExecutionContext
10+
import scala.concurrent.duration.Duration
911
import scala.io.Codec
1012
import scala.jdk.CollectionConverters.*
1113
import scala.util.Properties
@@ -1393,4 +1395,99 @@ abstract class RunTestDefinitions(val scalaVersionOpt: Option[String])
13931395
expect(output == message)
13941396
}
13951397
}
1398+
1399+
test("watch with interactive, with multiple main classes") {
1400+
val fileName = "watch.scala"
1401+
1402+
val inputs = TestInputs(
1403+
os.rel / fileName ->
1404+
"""object Run1 extends App {println("Run1 launched")}
1405+
|object Run2 extends App {println("Run2 launched")}
1406+
|""".stripMargin
1407+
)
1408+
inputs.fromRoot { root =>
1409+
val confDir = root / "config"
1410+
val confFile = confDir / "test-config.json"
1411+
1412+
os.write(confFile, "{}", createFolders = true)
1413+
1414+
if (!Properties.isWin)
1415+
os.perms.set(confDir, "rwx------")
1416+
1417+
val configEnv = Map("SCALA_CLI_CONFIG" -> confFile.toString)
1418+
1419+
val proc = os.proc(TestUtil.cli, "run", "--watch", "--interactive", fileName)
1420+
.spawn(
1421+
cwd = root,
1422+
mergeErrIntoOut = true,
1423+
stdout = os.Pipe,
1424+
stdin = os.Pipe,
1425+
env = Map("SCALA_CLI_INTERACTIVE" -> "true") ++ configEnv
1426+
)
1427+
1428+
try
1429+
TestUtil.withThreadPool("run-watch-interactive-multi-main-class-test", 2) { pool =>
1430+
val timeout = Duration("60 seconds")
1431+
implicit val ec = ExecutionContext.fromExecutorService(pool)
1432+
1433+
def lineReaderIter = Iterator.continually(TestUtil.readLine(proc.stdout, ec, timeout))
1434+
1435+
def checkLinesForError(lines: Seq[String]) = munit.Assertions.assert(
1436+
!lines.exists { line =>
1437+
TestUtil.removeAnsiColors(line).contains("[error]")
1438+
},
1439+
clues(lines.toSeq)
1440+
)
1441+
1442+
def answerInteractivePrompt(id: Int) = {
1443+
val interactivePromptLines = lineReaderIter
1444+
.takeWhile(!_.startsWith("[1]" /* probably [1] Run2 or [1] No*/ ))
1445+
.toList
1446+
expect(interactivePromptLines.nonEmpty)
1447+
checkLinesForError(interactivePromptLines)
1448+
proc.stdin.write(s"$id\n")
1449+
proc.stdin.flush()
1450+
}
1451+
1452+
def analyzeRunOutput(restart: Boolean) = {
1453+
val runResultLines = lineReaderIter
1454+
.takeWhile(!_.contains("press Enter to re-run"))
1455+
.toList
1456+
expect(runResultLines.nonEmpty)
1457+
checkLinesForError(runResultLines)
1458+
if (restart)
1459+
proc.stdin.write("\n")
1460+
proc.stdin.flush()
1461+
}
1462+
1463+
// You have run the current scala-cli command with the --interactive mode turned on.
1464+
// Would you like to leave it on permanently?
1465+
answerInteractivePrompt(0)
1466+
1467+
// Found several main classes. Which would you like to run?
1468+
answerInteractivePrompt(0)
1469+
expect(TestUtil.readLine(proc.stdout, ec, timeout) == "Run1 launched")
1470+
1471+
analyzeRunOutput( /* restart */ true)
1472+
1473+
answerInteractivePrompt(1)
1474+
expect(TestUtil.readLine(proc.stdout, ec, timeout) == "Run2 launched")
1475+
1476+
analyzeRunOutput( /* restart */ false)
1477+
os.write.append(root / fileName, "\n//comment")
1478+
1479+
answerInteractivePrompt(0)
1480+
expect(TestUtil.readLine(proc.stdout, ec, timeout) == "Run1 launched")
1481+
analyzeRunOutput( /* restart */ false)
1482+
}
1483+
finally
1484+
if (proc.isAlive()) {
1485+
proc.destroy()
1486+
Thread.sleep(200L)
1487+
if (proc.isAlive())
1488+
proc.destroyForcibly()
1489+
}
1490+
}
1491+
}
1492+
13961493
}

modules/options/src/main/scala/scala/build/interactive/Interactive.scala

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package scala.build.interactive
22

3-
import scala.io.StdIn
3+
import scala.build.internal.StdInConcurrentReader
44

55
sealed abstract class Interactive extends Product with Serializable {
66
def confirmOperation(msg: String): Option[Boolean] = None
@@ -16,7 +16,7 @@ object Interactive {
1616
private def readLine(): String =
1717
interactiveInputsOpt match {
1818
case None =>
19-
StdIn.readLine()
19+
StdInConcurrentReader.waitForLine().getOrElse("")
2020
case Some(interactiveInputs) =>
2121
synchronized {
2222
interactiveInputs match {
@@ -36,7 +36,11 @@ object Interactive {
3636
def msg: String
3737
def action: Option[V]
3838
final def run: Option[V] =
39-
if (interactiveInputsOpt.nonEmpty || coursier.paths.Util.useAnsiOutput())
39+
if (
40+
interactiveInputsOpt.nonEmpty ||
41+
coursier.paths.Util.useAnsiOutput() ||
42+
System.getenv("SCALA_CLI_INTERACTIVE") != null
43+
)
4044
action
4145
else None
4246
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package scala.build.internal
2+
3+
import java.util.concurrent.atomic.AtomicReference
4+
5+
import scala.concurrent.duration.Duration
6+
import scala.concurrent.{Await, ExecutionContext, Future}
7+
import scala.io.StdIn
8+
9+
object StdInConcurrentReader {
10+
private implicit val ec: ExecutionContext = ExecutionContext.global
11+
private val readLineFuture: AtomicReference[Future[Option[String]]] =
12+
new AtomicReference(Future.successful(None))
13+
14+
/** Wait for a line to be read from StdIn
15+
*
16+
* @param atMost
17+
* duration to wait before timeout
18+
* @return
19+
* a line from StdIn wrapped in Some or None if end of stream was reached
20+
*/
21+
def waitForLine(atMost: Duration = Duration.Inf): Option[String] = {
22+
val updatedFuture = readLineFuture.updateAndGet { f =>
23+
if f.isCompleted then Future(Option(StdIn.readLine())) else f
24+
}
25+
26+
Await.result(updatedFuture, atMost)
27+
}
28+
}

0 commit comments

Comments
 (0)