1
1
package mill .runner
2
2
3
- import mill .api .internal
3
+ import mill .api .{ SystemStreams , internal }
4
4
import mill .util .{Colors , Watchable }
5
- import mill .api .SystemStreams
6
5
7
6
import java .io .InputStream
7
+ import java .nio .channels .ClosedChannelException
8
8
import scala .annotation .tailrec
9
+ import scala .util .Using
9
10
10
11
/**
11
12
* Logic around the "watch and wait" functionality in Mill: re-run on change,
@@ -15,72 +16,211 @@ import scala.annotation.tailrec
15
16
object Watching {
16
17
case class Result [T ](watched : Seq [Watchable ], error : Option [String ], result : T )
17
18
19
+ trait Evaluate [T ] {
20
+ def apply (enterKeyPressed : Boolean , previousState : Option [T ]): Result [T ]
21
+ }
22
+
23
+ /**
24
+ * @param useNotify whether to use filesystem based watcher. If it is false uses polling.
25
+ * @param serverDir the directory for storing logs of the mill server
26
+ */
27
+ case class WatchArgs (
28
+ setIdle : Boolean => Unit ,
29
+ colors : Colors ,
30
+ useNotify : Boolean ,
31
+ serverDir : os.Path
32
+ )
33
+
18
34
def watchLoop [T ](
19
35
ringBell : Boolean ,
20
- watch : Boolean ,
36
+ watch : Option [ WatchArgs ] ,
21
37
streams : SystemStreams ,
22
- setIdle : Boolean => Unit ,
23
- evaluate : (Boolean , Option [T ]) => Result [T ],
24
- colors : Colors
38
+ evaluate : Evaluate [T ]
25
39
): (Boolean , T ) = {
26
- var prevState : Option [T ] = None
27
- var enterKeyPressed = false
28
- while (true ) {
29
- val Result (watchables, errorOpt, result) = evaluate(enterKeyPressed, prevState)
30
- prevState = Some (result)
40
+ def handleError (errorOpt : Option [String ]): Unit = {
31
41
errorOpt.foreach(streams.err.println)
32
- if (ringBell) {
33
- if (errorOpt.isEmpty) println(" \u0007 " )
34
- else {
35
- println(" \u0007 " )
36
- Thread .sleep(250 )
37
- println(" \u0007 " )
38
- }
39
- }
42
+ doRingBell(hasError = errorOpt.isDefined)
43
+ }
40
44
41
- if (! watch) {
42
- return (errorOpt.isEmpty, result)
43
- }
45
+ def doRingBell (hasError : Boolean ): Unit = {
46
+ if (! ringBell) return
44
47
45
- val alreadyStale = watchables.exists(! _.validate())
46
- enterKeyPressed = false
47
- if (! alreadyStale) {
48
- 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 " )
49
53
}
50
54
}
51
- ???
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
+ // Exits when the thread gets interruped.
68
+ while (true ) {
69
+ val Result (watchables, errorOpt, result) = evaluate(enterKeyPressed, prevState)
70
+ prevState = Some (result)
71
+ handleError(errorOpt)
72
+
73
+ try {
74
+ watchArgs.setIdle(true )
75
+ enterKeyPressed = watchAndWait(streams, streams.in, watchables, watchArgs)
76
+ } finally {
77
+ watchArgs.setIdle(false )
78
+ }
79
+ }
80
+ throw new IllegalStateException (" unreachable" )
81
+ }
52
82
}
53
83
54
- def watchAndWait (
84
+ private def watchAndWait (
55
85
streams : SystemStreams ,
56
- setIdle : Boolean => Unit ,
57
86
stdin : InputStream ,
58
87
watched : Seq [Watchable ],
59
- colors : Colors
88
+ watchArgs : WatchArgs
60
89
): Boolean = {
61
- setIdle(true )
62
- val watchedPaths = watched.collect { case p : Watchable .Path => p.p.path }
63
- val watchedValues = watched.size - watchedPaths.size
90
+ val (watchedPollables, watchedPathsSeq) = watched.partitionMap {
91
+ case w : Watchable .Value => Left (w)
92
+ case p : Watchable .Path => Right (p)
93
+ }
94
+ val watchedPathsSet = watchedPathsSeq.iterator.map(p => p.p.path).toSet
95
+ val watchedValueCount = watched.size - watchedPathsSeq.size
64
96
65
- val watchedValueStr = if (watchedValues == 0 ) " " else s " and $watchedValues other values "
97
+ val watchedValueStr =
98
+ if (watchedValueCount == 0 ) " " else s " and $watchedValueCount other values "
66
99
67
- streams.err.println(
68
- colors.info(
69
- s " Watching for changes to ${watchedPaths.size} paths $watchedValueStr... (Enter to re-run, Ctrl-C to exit) "
100
+ streams.err.println {
101
+ val viaFsNotify = if (watchArgs.useNotify) " (via fsnotify)" else " "
102
+ watchArgs.colors.info(
103
+ s " Watching for changes to ${watchedPathsSeq.size} paths $viaFsNotify$watchedValueStr... (Enter to re-run, Ctrl-C to exit) "
70
104
).toString
71
- )
105
+ }
106
+
107
+ def doWatch (notifiablesChanged : () => Boolean ) = {
108
+ val enterKeyPressed = statWatchWait(watchedPollables, stdin, notifiablesChanged)
109
+ enterKeyPressed
110
+ }
111
+
112
+ def doWatchPolling () =
113
+ doWatch(notifiablesChanged = () => watchedPathsSeq.exists(p => ! p.validate()))
72
114
73
- val enterKeyPressed = statWatchWait(watched, stdin)
74
- setIdle(false )
75
- enterKeyPressed
115
+ def doWatchFsNotify () = {
116
+ Using .resource(os.write.outputStream(watchArgs.serverDir / " fsNotifyWatchLog" )) { watchLog =>
117
+ def writeToWatchLog (s : String ): Unit = {
118
+ try {
119
+ watchLog.write(s.getBytes(java.nio.charset.StandardCharsets .UTF_8 ))
120
+ watchLog.write('\n ' )
121
+ } catch {
122
+ case _ : ClosedChannelException => /* do nothing, the file is already closed */
123
+ }
124
+ }
125
+
126
+ @ volatile var pathChangesDetected = false
127
+
128
+ // oslib watch only works with folders, so we have to watch the parent folders instead
129
+
130
+ writeToWatchLog(
131
+ s " [watched-paths:unfiltered] ${watchedPathsSet.toSeq.sorted.mkString(" \n " )}"
132
+ )
133
+
134
+ val workspaceRoot = mill.api.WorkspaceRoot .workspaceRoot
135
+
136
+ /** Paths that are descendants of [[workspaceRoot ]]. */
137
+ val pathsUnderWorkspaceRoot = watchedPathsSet.filter { path =>
138
+ val isUnderWorkspaceRoot = path.startsWith(workspaceRoot)
139
+ if (! isUnderWorkspaceRoot) {
140
+ streams.err.println(watchArgs.colors.error(
141
+ s " Watched path $path is outside workspace root $workspaceRoot, this is unsupported. "
142
+ ).toString())
143
+ }
144
+
145
+ isUnderWorkspaceRoot
146
+ }
147
+
148
+ // If I have 'root/a/b/c'
149
+ //
150
+ // Then I want to watch:
151
+ // root/a/b/c
152
+ // root/a/b
153
+ // root/a
154
+ // root
155
+ //
156
+ // We're only setting one `os.watch.watch` on the root, and this makes it sound like
157
+ // we're setting multiple. What we're actually doing is choosing the paths we need to watch recursively in
158
+ // Linux since inotify is non-recursive by default, since changes in any enclosing folder could result in the
159
+ // watched file or folder disappearing (e.g. if the enclosing folder was renamed) and we want to pick up such
160
+ // changes.
161
+ val filterPaths = pathsUnderWorkspaceRoot.flatMap { path =>
162
+ path.relativeTo(workspaceRoot).segments.inits.map(segments => workspaceRoot / segments)
163
+ }
164
+ writeToWatchLog(s " [watched-paths:filtered] ${filterPaths.toSeq.sorted.mkString(" \n " )}" )
165
+
166
+ Using .resource(os.watch.watch(
167
+ // Just watch the root folder
168
+ Seq (workspaceRoot),
169
+ filter = path => {
170
+ val shouldBeWatched =
171
+ filterPaths.contains(path) || watchedPathsSet.exists(watchedPath =>
172
+ path.startsWith(watchedPath)
173
+ )
174
+ writeToWatchLog(s " [filter] (shouldBeWatched= $shouldBeWatched) $path" )
175
+ shouldBeWatched
176
+ },
177
+ onEvent = changedPaths => {
178
+ // Make sure that the changed paths are actually the ones in our watch list and not some adjacent files in the
179
+ // same folder
180
+ val hasWatchedPath =
181
+ changedPaths.exists(p =>
182
+ watchedPathsSet.exists(watchedPath => p.startsWith(watchedPath))
183
+ )
184
+ writeToWatchLog(
185
+ s " [changed-paths] (hasWatchedPath= $hasWatchedPath) ${changedPaths.mkString(" \n " )}"
186
+ )
187
+ if (hasWatchedPath) {
188
+ pathChangesDetected = true
189
+ }
190
+ },
191
+ logger = (eventType, data) =>
192
+ writeToWatchLog(s " [watch:event] $eventType: ${pprint.apply(data).plainText}" )
193
+ )) { _ =>
194
+ // If already stale, re-evaluate instantly.
195
+ //
196
+ // We need to do this to prevent any changes from slipping through the gap between the last evaluation and
197
+ // starting the watch.
198
+ val alreadyStale = watched.exists(w => ! w.validate())
199
+
200
+ if (alreadyStale) false
201
+ else doWatch(notifiablesChanged = () => pathChangesDetected)
202
+ }
203
+ }
204
+ }
205
+
206
+ if (watchArgs.useNotify) doWatchFsNotify()
207
+ else doWatchPolling()
76
208
}
77
209
78
- // Returns `true` if enter key is pressed to re-run tasks explicitly
79
- def statWatchWait (watched : Seq [Watchable ], stdin : InputStream ): Boolean = {
210
+ /**
211
+ * @param notifiablesChanged returns true if any of the notifiables have changed
212
+ *
213
+ * @return `true` if enter key is pressed to re-run tasks explicitly, false if changes in watched files occured.
214
+ */
215
+ def statWatchWait (
216
+ watched : Seq [Watchable ],
217
+ stdin : InputStream ,
218
+ notifiablesChanged : () => Boolean
219
+ ): Boolean = {
80
220
val buffer = new Array [Byte ](4 * 1024 )
81
221
82
222
@ tailrec def statWatchWait0 (): Boolean = {
83
- if (watched.forall(_.validate())) {
223
+ if (! notifiablesChanged() && watched.forall(_.validate())) {
84
224
if (lookForEnterKey()) {
85
225
true
86
226
} else {
@@ -94,17 +234,18 @@ object Watching {
94
234
if (stdin.available() == 0 ) false
95
235
else stdin.read(buffer) match {
96
236
case 0 | - 1 => false
97
- case n =>
237
+ case bytesRead =>
98
238
buffer.indexOf('\n ' ) match {
99
239
case - 1 => lookForEnterKey()
100
- case i =>
101
- if (i >= n) lookForEnterKey()
240
+ case index =>
241
+ // If we found the newline further than the bytes read, that means it's not from this read and thus we
242
+ // should try reading again.
243
+ if (index >= bytesRead) lookForEnterKey()
102
244
else true
103
245
}
104
246
}
105
247
}
106
248
107
249
statWatchWait0()
108
250
}
109
-
110
251
}
0 commit comments