Skip to content

Commit 71189a5

Browse files
committed
Fix conflicts when watch and interactive try to read StdIn, add test
1 parent f08256a commit 71189a5

File tree

5 files changed

+138
-6
lines changed

5 files changed

+138
-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: 85 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,87 @@ abstract class RunTestDefinitions(val scalaVersionOpt: Option[String])
13931395
expect(output == message)
13941396
}
13951397
}
1398+
1399+
test("watch with interactive, with multiple main classes") {
1400+
1401+
val fileName = "watch.scala"
1402+
1403+
val inputs = TestInputs(
1404+
os.rel / fileName ->
1405+
"""object Run1 extends App {println("Run1 launched")}
1406+
|object Run2 extends App {println("Run2 launched")}
1407+
|""".stripMargin
1408+
)
1409+
inputs.fromRoot { root =>
1410+
os.proc(TestUtil.cli, "config", "interactive")
1411+
.call(cwd = root)
1412+
1413+
os.proc(TestUtil.cli, "config", "interactive-was-suggested")
1414+
.call(cwd = root)
1415+
1416+
val proc = os.proc(TestUtil.cli, "run", "--watch", "--interactive", "watch.scala")
1417+
.spawn(
1418+
cwd = root,
1419+
mergeErrIntoOut = true,
1420+
stdout = os.Pipe,
1421+
stdin = os.Pipe,
1422+
env = Map("SCALA_CLI_INTERACTIVE_TEST" -> "true")
1423+
)
1424+
1425+
try
1426+
TestUtil.withThreadPool("run-watch-interactive-multi-main-class-test", 2) { pool =>
1427+
val timeout = Duration("60 seconds")
1428+
implicit val ec = ExecutionContext.fromExecutorService(pool)
1429+
1430+
def linesUntilInteractivePromptIter =
1431+
Iterator.continually(TestUtil.readLine(proc.stdout, ec, timeout))
1432+
.takeWhile(!_.contains("[1] Run" /* probably [1] Run2 */ ))
1433+
1434+
def linesUntilWatchPromptIter =
1435+
Iterator.continually(TestUtil.readLine(proc.stdout, ec, timeout))
1436+
.takeWhile(!_.contains("press Enter to re-run"))
1437+
1438+
def checkLinesForError(lines: Seq[String]) = munit.Assertions.assert(
1439+
!lines.exists { line =>
1440+
TestUtil.removeAnsiColors(line).contains("[error]")
1441+
},
1442+
clues(lines.toSeq)
1443+
)
1444+
1445+
val firstInteractivePromptLines = linesUntilInteractivePromptIter.toList
1446+
expect(firstInteractivePromptLines.nonEmpty)
1447+
checkLinesForError(firstInteractivePromptLines)
1448+
proc.stdin.write("0\n")
1449+
proc.stdin.flush()
1450+
expect(TestUtil.readLine(proc.stdout, ec, timeout) == "Run1 launched")
1451+
1452+
val firstWatchPromptLines = linesUntilWatchPromptIter.toList
1453+
expect(firstWatchPromptLines.nonEmpty)
1454+
checkLinesForError(firstWatchPromptLines)
1455+
proc.stdin.write("\n")
1456+
proc.stdin.flush()
1457+
1458+
val secondInteractivePromptLines = linesUntilInteractivePromptIter.toList
1459+
expect(secondInteractivePromptLines.nonEmpty)
1460+
checkLinesForError(secondInteractivePromptLines)
1461+
proc.stdin.write("1\n")
1462+
proc.stdin.flush()
1463+
expect(TestUtil.readLine(proc.stdout, ec, timeout) == "Run2 launched")
1464+
1465+
val secondWatchPromptLines = linesUntilWatchPromptIter.toList
1466+
expect(secondWatchPromptLines.nonEmpty)
1467+
checkLinesForError(secondWatchPromptLines)
1468+
proc.stdin.write("\n")
1469+
proc.stdin.flush()
1470+
}
1471+
finally
1472+
if (proc.isAlive()) {
1473+
proc.destroy()
1474+
Thread.sleep(200L)
1475+
if (proc.isAlive())
1476+
proc.destroyForcibly()
1477+
}
1478+
}
1479+
}
1480+
13961481
}

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_TEST") != 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+
implicit val ec: ExecutionContext = ExecutionContext.global
11+
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)