Skip to content

Commit 8ec73a8

Browse files
committed
WIP: fs-watching
1 parent d1fa5e7 commit 8ec73a8

File tree

7 files changed

+135
-53
lines changed

7 files changed

+135
-53
lines changed

build.mill

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,9 @@ object Deps {
159159
val junitInterface = mvn"com.github.sbt:junit-interface:0.13.3"
160160
val commonsIo = mvn"commons-io:commons-io:2.18.0"
161161
val log4j2Core = mvn"org.apache.logging.log4j:log4j-core:2.24.3"
162-
val osLib = mvn"com.lihaoyi::os-lib:0.11.5-M2"
162+
val osLibVersion = "0.11.5-M2"
163+
val osLib = mvn"com.lihaoyi::os-lib:${osLibVersion}"
164+
val osLibWatch = mvn"com.lihaoyi::os-lib-watch:${osLibVersion}"
163165
val pprint = mvn"com.lihaoyi::pprint:0.9.0"
164166
val mainargs = mvn"com.lihaoyi::mainargs:0.7.6"
165167
val millModuledefsVersion = "0.11.3-M5"

core/api/src/mill/api/Watchable.scala

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,23 @@ package mill.api
88
*/
99
private[mill] sealed trait Watchable
1010
private[mill] object Watchable {
11-
case class Path(p: java.nio.file.Path, quick: Boolean, signature: Int) extends Watchable
12-
case class Value(f: () => Long, signature: Long, pretty: String) extends Watchable
11+
/** A [[Watchable]] that is being watched via polling. */
12+
private[mill] sealed trait Pollable extends Watchable
13+
14+
/** A [[Watchable]] that is being watched via a notification system (like inotify). */
15+
private[mill] sealed trait Notifiable extends Watchable
16+
17+
/**
18+
* @param p the path to watch
19+
* @param quick if true, only watch file attributes
20+
* @param signature the initial hash of the path contents
21+
*/
22+
case class Path(p: java.nio.file.Path, quick: Boolean, signature: Int) extends Notifiable
23+
24+
/**
25+
* @param f the expression to watch, returns some sort of hash
26+
* @param signature the initial hash from the first invocation of the expression
27+
* @param pretty human-readable name
28+
*/
29+
case class Value(f: () => Long, signature: Long, pretty: String) extends Pollable
1330
}

libs/main/src/mill/main/MainModule.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ abstract class MainRootModule()(implicit
2222
* [[show]], [[inspect]], [[plan]], etc.
2323
*/
2424
trait MainModule extends BaseModule with MainModuleApi {
25-
protected[mill] val watchedValues: mutable.Buffer[Watchable] = mutable.Buffer.empty[Watchable]
26-
protected[mill] val evalWatchedValues: mutable.Buffer[Watchable] = mutable.Buffer.empty[Watchable]
25+
protected[mill] val watchedValues: mutable.Buffer[Watchable] = mutable.Buffer.empty
26+
protected[mill] val evalWatchedValues: mutable.Buffer[Watchable] = mutable.Buffer.empty
2727
object interp {
2828
def watchValue[T](v0: => T)(implicit fn: sourcecode.FileName, ln: sourcecode.Line): T = {
2929
val v = v0

runner/package.mill

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ object `package` extends build.MillPublishScalaModule {
1818
def mvnDeps = Seq(
1919
build.Deps.sourcecode,
2020
build.Deps.osLib,
21+
build.Deps.osLibWatch,
2122
build.Deps.mainargs,
2223
build.Deps.upickle,
2324
build.Deps.pprint,

runner/src/mill/runner/MillBuildBootstrap.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,8 @@ class MillBuildBootstrap(
277277
// look at the `moduleWatched` of one frame up (`prevOuterFrameOpt`),
278278
// and not the `moduleWatched` from the current frame (`prevFrameOpt`)
279279
val moduleWatchChanged =
280-
prevOuterFrameOpt.exists(_.moduleWatched.exists(w => !Watching.validate(w)))
280+
// QUESTION: is polling appropriate here?
281+
prevOuterFrameOpt.exists(_.moduleWatched.exists(w => !Watching.validateAnyWatchable(w)))
281282

282283
val classLoader = if (runClasspathChanged || moduleWatchChanged) {
283284
// Make sure we close the old classloader every time we create a new

runner/src/mill/runner/MillMain.scala

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -352,9 +352,8 @@ object MillMain {
352352
if (config.watch.value) os.remove(out / OutFiles.millSelectiveExecution)
353353
Watching.watchLoop(
354354
ringBell = config.ringBell.value,
355-
watch = config.watch.value,
355+
watch = Option.when(config.watch.value)(Watching.WatchArgs(setIdle = setIdle, colors)),
356356
streams = streams,
357-
setIdle = setIdle,
358357
evaluate = (enterKeyPressed: Boolean, prevState: Option[RunnerState]) => {
359358
adjustJvmProperties(userSpecifiedProperties, initialSystemProperties)
360359
runMillBootstrap(
@@ -363,8 +362,7 @@ object MillMain {
363362
config.leftoverArgs.value,
364363
streams
365364
)
366-
},
367-
colors = colors
365+
}
368366
)
369367
}
370368
}

runner/src/mill/runner/Watching.scala

Lines changed: 106 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
package mill.runner
22

3-
import mill.internal.Colors
4-
import mill.define.internal.Watchable
5-
import mill.define.PathRef
63
import mill.api.SystemStreams
74
import mill.api.internal.internal
5+
import mill.define.PathRef
6+
import mill.define.internal.Watchable
7+
import mill.internal.Colors
88

99
import java.io.InputStream
1010
import scala.annotation.tailrec
11+
import scala.util.Using
1112

1213
/**
1314
* Logic around the "watch and wait" functionality in Mill: re-run on change,
@@ -17,40 +18,68 @@ import scala.annotation.tailrec
1718
object Watching {
1819
case class Result[T](watched: Seq[Watchable], error: Option[String], result: T)
1920

21+
trait Evaluate[T] {
22+
def apply(enterKeyPressed: Boolean, previousState: Option[T]): Result[T]
23+
}
24+
25+
case class WatchArgs(
26+
setIdle: Boolean => Unit,
27+
colors: Colors
28+
)
29+
30+
/**
31+
* @param ringBell whether to emit bells
32+
* @param watch if false just runs once and returns
33+
*/
2034
def watchLoop[T](
2135
ringBell: Boolean,
22-
watch: Boolean,
36+
watch: Option[WatchArgs],
2337
streams: SystemStreams,
24-
setIdle: Boolean => Unit,
25-
evaluate: (Boolean, Option[T]) => Result[T],
26-
colors: Colors
38+
evaluate: Evaluate[T]
2739
): (Boolean, T) = {
28-
var prevState: Option[T] = None
29-
var enterKeyPressed = false
30-
while (true) {
31-
val Result(watchables, errorOpt, result) = evaluate(enterKeyPressed, prevState)
32-
prevState = Some(result)
40+
def handleError(errorOpt: Option[String]): Unit = {
3341
errorOpt.foreach(streams.err.println)
34-
if (ringBell) {
35-
if (errorOpt.isEmpty) println("\u0007")
36-
else {
37-
println("\u0007")
38-
Thread.sleep(250)
39-
println("\u0007")
40-
}
41-
}
42+
doRingBell(hasError = errorOpt.isDefined)
43+
}
4244

43-
if (!watch) {
44-
return (errorOpt.isEmpty, result)
45-
}
45+
def doRingBell(hasError: Boolean): Unit = {
46+
if (!ringBell) return
4647

47-
val alreadyStale = watchables.exists(w => !validate(w))
48-
enterKeyPressed = false
49-
if (!alreadyStale) {
50-
enterKeyPressed = Watching.watchAndWait(streams, setIdle, streams.in, watchables, colors)
48+
println("\u0007")
49+
if (hasError) {
50+
// If we have an error ring the bell again
51+
Thread.sleep(250)
52+
println("\u0007")
5153
}
5254
}
53-
???
55+
56+
watch match {
57+
case None =>
58+
val Result(watchables, errorOpt, result) =
59+
evaluate(enterKeyPressed = false, previousState = None)
60+
handleError(errorOpt)
61+
(errorOpt.isEmpty, result)
62+
63+
case Some(watchArgs) =>
64+
var prevState: Option[T] = None
65+
var enterKeyPressed = false
66+
67+
while (true) {
68+
val Result(watchables, errorOpt, result) = evaluate(enterKeyPressed, prevState)
69+
prevState = Some(result)
70+
handleError(errorOpt)
71+
72+
// Do not enter watch if already stale, re-evaluate instantly.
73+
val alreadyStale = watchables.exists(w => !validateAnyWatchable(w))
74+
if (alreadyStale) {
75+
enterKeyPressed = false
76+
} else {
77+
enterKeyPressed = watchAndWait(streams, watchArgs.setIdle, streams.in, watchables, watchArgs.colors)
78+
}
79+
}
80+
// QUESTION: this never exits?
81+
throw new IllegalStateException("unreachable")
82+
}
5483
}
5584

5685
def watchAndWait(
@@ -61,28 +90,50 @@ object Watching {
6190
colors: Colors
6291
): Boolean = {
6392
setIdle(true)
64-
val watchedPaths = watched.collect { case p: Watchable.Path => p.p }
65-
val watchedValues = watched.size - watchedPaths.size
93+
val (watchedPollables, watchedPaths) = watched.partitionMap {
94+
case w: Watchable.Pollable => Left(w)
95+
case p: Watchable.Path => Right(p)
96+
}
97+
val watchedValueCount = watched.size - watchedPaths.size
6698

67-
val watchedValueStr = if (watchedValues == 0) "" else s" and $watchedValues other values"
99+
val watchedValueStr =
100+
if (watchedValueCount == 0) "" else s" and $watchedValueCount other values"
68101

69102
streams.err.println(
70103
colors.info(
71104
s"Watching for changes to ${watchedPaths.size} paths$watchedValueStr... (Enter to re-run, Ctrl-C to exit)"
72105
).toString
73106
)
74107

75-
val enterKeyPressed = statWatchWait(watched, stdin)
76-
setIdle(false)
77-
enterKeyPressed
108+
@volatile var pathChangesDetected = false
109+
Using.resource(os.watch.watch(
110+
watchedPaths.map(path => os.Path(path.p)),
111+
onEvent = _ => pathChangesDetected = true,
112+
logger = (eventType, data) => {
113+
streams.out.println(colors.info(s"[watch] $eventType: ${pprint.apply(data)}"))
114+
}
115+
)) { _ =>
116+
val enterKeyPressed =
117+
statWatchWait(watchedPollables, stdin, notifiablesChanged = () => pathChangesDetected)
118+
setIdle(false)
119+
enterKeyPressed
120+
}
78121
}
79122

80-
// Returns `true` if enter key is pressed to re-run tasks explicitly
81-
def statWatchWait(watched: Seq[Watchable], stdin: InputStream): Boolean = {
123+
/**
124+
* @param notifiablesChanged returns true if any of the notifiables have changed
125+
*
126+
* @return `true` if enter key is pressed to re-run tasks explicitly, false if changes in watched files occured.
127+
*/
128+
def statWatchWait(
129+
watched: Seq[Watchable.Pollable],
130+
stdin: InputStream,
131+
notifiablesChanged: () => Boolean
132+
): Boolean = {
82133
val buffer = new Array[Byte](4 * 1024)
83134

84135
@tailrec def statWatchWait0(): Boolean = {
85-
if (watched.forall(w => validate(w))) {
136+
if (!notifiablesChanged() && watched.forall(w => validate(w))) {
86137
if (lookForEnterKey()) {
87138
true
88139
} else {
@@ -96,11 +147,13 @@ object Watching {
96147
if (stdin.available() == 0) false
97148
else stdin.read(buffer) match {
98149
case 0 | -1 => false
99-
case n =>
150+
case bytesRead =>
100151
buffer.indexOf('\n') match {
101152
case -1 => lookForEnterKey()
102-
case i =>
103-
if (i >= n) lookForEnterKey()
153+
case index =>
154+
// If we found the newline further than the bytes read, that means it's not from this read and thus we
155+
// should try reading again.
156+
if (index >= bytesRead) lookForEnterKey()
104157
else true
105158
}
106159
}
@@ -109,13 +162,23 @@ object Watching {
109162
statWatchWait0()
110163
}
111164

112-
def validate(w: Watchable) = poll(w) == signature(w)
113-
def poll(w: Watchable) = w match {
165+
/** @return true if the watchable did not change. */
166+
inline def validate(w: Watchable.Pollable): Boolean = validateAnyWatchable(w)
167+
168+
/**
169+
* As [[validate]] but accepts any [[Watchable]] for the cases when we do not want to use a notification system.
170+
*
171+
* Normally you should use [[validate]] so that types would guide your implementation.
172+
*/
173+
def validateAnyWatchable(w: Watchable): Boolean = poll(w) == signature(w)
174+
175+
def poll(w: Watchable): Long = w match {
114176
case Watchable.Path(p, quick, sig) =>
115177
new PathRef(os.Path(p), quick, sig, PathRef.Revalidate.Once).recomputeSig()
116178
case Watchable.Value(f, sig, pretty) => f()
117179
}
118-
def signature(w: Watchable) = w match {
180+
181+
def signature(w: Watchable): Long = w match {
119182
case Watchable.Path(p, quick, sig) =>
120183
new PathRef(os.Path(p), quick, sig, PathRef.Revalidate.Once).sig
121184
case Watchable.Value(f, sig, pretty) => sig

0 commit comments

Comments
 (0)