Skip to content

Commit a50f070

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

File tree

5 files changed

+128
-9
lines changed

5 files changed

+128
-9
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: 79 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,81 @@ 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(cwd = root, mergeErrIntoOut = true, stdout = os.Pipe, stdin = os.Pipe)
1418+
1419+
try
1420+
TestUtil.withThreadPool("run-watch-interactive-multi-main-class-test", 2) { pool =>
1421+
val timeout = Duration("60 seconds")
1422+
implicit val ec = ExecutionContext.fromExecutorService(pool)
1423+
1424+
def linesUntilInteractivePromptIter =
1425+
Iterator.continually(TestUtil.readLine(proc.stdout, ec, timeout))
1426+
.takeWhile(!_.contains("[1] Run" /* probably [1] Run2 */ ))
1427+
1428+
def linesUntilWatchPromptIter =
1429+
Iterator.continually(TestUtil.readLine(proc.stdout, ec, timeout))
1430+
.takeWhile(!_.contains("press Enter to re-run"))
1431+
1432+
def checkLinesForError(lines: Seq[String]) = munit.Assertions.assert(
1433+
!lines.exists { line =>
1434+
TestUtil.removeAnsiColors(line).contains("[error]")
1435+
},
1436+
clues(lines.toSeq)
1437+
)
1438+
1439+
val firstInteractivePromptLines = linesUntilInteractivePromptIter.toList
1440+
expect(firstInteractivePromptLines.nonEmpty)
1441+
checkLinesForError(firstInteractivePromptLines)
1442+
proc.stdin.write("0\n")
1443+
proc.stdin.flush()
1444+
expect(TestUtil.readLine(proc.stdout, ec, timeout) == "Run1 launched")
1445+
1446+
val firstWatchPromptLines = linesUntilWatchPromptIter.toList
1447+
expect(firstWatchPromptLines.nonEmpty)
1448+
checkLinesForError(firstWatchPromptLines)
1449+
proc.stdin.write("\n")
1450+
proc.stdin.flush()
1451+
1452+
val secondInteractivePromptLines = linesUntilInteractivePromptIter.toList
1453+
expect(secondInteractivePromptLines.nonEmpty)
1454+
checkLinesForError(secondInteractivePromptLines)
1455+
proc.stdin.write("1\n")
1456+
proc.stdin.flush()
1457+
expect(TestUtil.readLine(proc.stdout, ec, timeout) == "Run2 launched")
1458+
1459+
val secondWatchPromptLines = linesUntilWatchPromptIter.toList
1460+
expect(secondWatchPromptLines.nonEmpty)
1461+
checkLinesForError(secondWatchPromptLines)
1462+
proc.stdin.write("\n")
1463+
proc.stdin.flush()
1464+
}
1465+
finally
1466+
if (proc.isAlive()) {
1467+
proc.destroy()
1468+
Thread.sleep(200L)
1469+
if (proc.isAlive())
1470+
proc.destroyForcibly()
1471+
}
1472+
}
1473+
}
1474+
13961475
}

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

Lines changed: 3 additions & 6 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 {
@@ -35,10 +35,7 @@ object Interactive {
3535
private sealed abstract class Action[V] extends Product with Serializable {
3636
def msg: String
3737
def action: Option[V]
38-
final def run: Option[V] =
39-
if (interactiveInputsOpt.nonEmpty || coursier.paths.Util.useAnsiOutput())
40-
action
41-
else None
38+
final def run: Option[V] = action
4239
}
4340

4441
private case class ConfirmOperation(msg: String) extends Action[Boolean] {
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)