From d9593de8b05845535b0c5c7dd568205fac84f93e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Art=C5=ABras=20=C5=A0lajus?= Date: Tue, 6 May 2025 12:26:13 +0300 Subject: [PATCH 01/16] Initial version. --- build.mill | 4 +- main/util/src/mill/util/Watchable.scala | 5 + runner/package.mill | 3 +- runner/src/mill/runner/MillMain.scala | 6 +- runner/src/mill/runner/Watching.scala | 166 ++++++++++++++++++------ 5 files changed, 139 insertions(+), 45 deletions(-) diff --git a/build.mill b/build.mill index 00ae0a2f51c..91e006fd3ff 100644 --- a/build.mill +++ b/build.mill @@ -150,7 +150,9 @@ object Deps { val junitInterface = ivy"com.github.sbt:junit-interface:0.13.3" val commonsIo = ivy"commons-io:commons-io:2.18.0" val log4j2Core = ivy"org.apache.logging.log4j:log4j-core:2.24.3" - val osLib = ivy"com.lihaoyi::os-lib:0.11.4-M6" + val osLibVersion = "0.11.5-M2-DIRTYc8bf6115" + val osLib = ivy"com.lihaoyi::os-lib:${osLibVersion}" + val osLibWatch = ivy"com.lihaoyi::os-lib-watch:${osLibVersion}" val pprint = ivy"com.lihaoyi::pprint:0.9.0" val mainargs = ivy"com.lihaoyi::mainargs:0.7.6" val millModuledefsVersion = "0.11.2" diff --git a/main/util/src/mill/util/Watchable.scala b/main/util/src/mill/util/Watchable.scala index 0d277a33074..8171a9a7d4f 100644 --- a/main/util/src/mill/util/Watchable.scala +++ b/main/util/src/mill/util/Watchable.scala @@ -10,9 +10,14 @@ import mill.api.internal */ @internal private[mill] trait Watchable { + /** @return the hashcode of a watched value. */ def poll(): Long + + /** The initial hashcode of a watched value. */ def signature: Long + def validate(): Boolean = poll() == signature + def pretty: String } @internal diff --git a/runner/package.mill b/runner/package.mill index 645765218fd..a76b0abae28 100644 --- a/runner/package.mill +++ b/runner/package.mill @@ -13,7 +13,8 @@ object `package` extends RootModule with build.MillPublishScalaModule { build.Deps.windowsAnsi, build.Deps.coursier, build.Deps.coursierJvm, - build.Deps.logback + build.Deps.logback, + build.Deps.osLibWatch ) def buildInfoObjectName = "Versions" def buildInfoMembers = Seq( diff --git a/runner/src/mill/runner/MillMain.scala b/runner/src/mill/runner/MillMain.scala index 83219f97e78..f9f342fdc14 100644 --- a/runner/src/mill/runner/MillMain.scala +++ b/runner/src/mill/runner/MillMain.scala @@ -231,9 +231,8 @@ object MillMain { } val (isSuccess, evalStateOpt) = Watching.watchLoop( ringBell = config.ringBell.value, - watch = config.watch.value, + watch = Option.when(config.watch.value)(Watching.WatchArgs(setIdle, colors)), streams = streams, - setIdle = setIdle, evaluate = (enterKeyPressed: Boolean, prevState: Option[RunnerState]) => { adjustJvmProperties(userSpecifiedProperties, initialSystemProperties) @@ -285,8 +284,7 @@ object MillMain { } } } - }, - colors = colors + } ) bspContext.foreach { ctx => repeatForBsp = diff --git a/runner/src/mill/runner/Watching.scala b/runner/src/mill/runner/Watching.scala index cb9025338c4..f2aaa4cc13d 100644 --- a/runner/src/mill/runner/Watching.scala +++ b/runner/src/mill/runner/Watching.scala @@ -3,9 +3,11 @@ package mill.runner import mill.api.internal import mill.util.{Colors, Watchable} import mill.api.SystemStreams +import mill.main.client.DebugLog import java.io.InputStream import scala.annotation.tailrec +import scala.util.Using /** * Logic around the "watch and wait" functionality in Mill: re-run on change, @@ -15,40 +17,65 @@ import scala.annotation.tailrec object Watching { case class Result[T](watched: Seq[Watchable], error: Option[String], result: T) + trait Evaluate[T] { + def apply(enterKeyPressed: Boolean, previousState: Option[T]): Result[T] + } + + case class WatchArgs( + setIdle: Boolean => Unit, + colors: Colors + ) + def watchLoop[T]( ringBell: Boolean, - watch: Boolean, + watch: Option[WatchArgs], streams: SystemStreams, - setIdle: Boolean => Unit, - evaluate: (Boolean, Option[T]) => Result[T], - colors: Colors + evaluate: Evaluate[T], ): (Boolean, T) = { - var prevState: Option[T] = None - var enterKeyPressed = false - while (true) { - val Result(watchables, errorOpt, result) = evaluate(enterKeyPressed, prevState) - prevState = Some(result) + def handleError(errorOpt: Option[String]): Unit = { errorOpt.foreach(streams.err.println) - if (ringBell) { - if (errorOpt.isEmpty) println("\u0007") - else { - println("\u0007") - Thread.sleep(250) - println("\u0007") - } - } + doRingBell(hasError = errorOpt.isDefined) + } - if (!watch) { - return (errorOpt.isEmpty, result) - } + def doRingBell(hasError: Boolean): Unit = { + if (!ringBell) return - val alreadyStale = watchables.exists(!_.validate()) - enterKeyPressed = false - if (!alreadyStale) { - enterKeyPressed = Watching.watchAndWait(streams, setIdle, streams.in, watchables, colors) + println("\u0007") + if (hasError) { + // If we have an error ring the bell again + Thread.sleep(250) + println("\u0007") } } - ??? + + watch match { + case None => + val Result(watchables, errorOpt, result) = + evaluate(enterKeyPressed = false, previousState = None) + handleError(errorOpt) + (errorOpt.isEmpty, result) + + case Some(watchArgs) => + var prevState: Option[T] = None + var enterKeyPressed = false + + // Exits when the thread gets interruped. + while (true) { + val Result(watchables, errorOpt, result) = evaluate(enterKeyPressed, prevState) + prevState = Some(result) + handleError(errorOpt) + + // Do not enter watch if already stale, re-evaluate instantly. + val alreadyStale = watchables.exists(w => !w.validate()) + if (alreadyStale) { + enterKeyPressed = false + } else { + enterKeyPressed = + watchAndWait(streams, watchArgs.setIdle, streams.in, watchables, watchArgs.colors) + } + } + throw new IllegalStateException("unreachable") + } } def watchAndWait( @@ -59,28 +86,88 @@ object Watching { colors: Colors ): Boolean = { setIdle(true) - val watchedPaths = watched.collect { case p: Watchable.Path => p.p.path } - val watchedValues = watched.size - watchedPaths.size + val (watchedPollables, watchedPathsSeq) = watched.partitionMap { + case w: Watchable.Value => Left(w) + case p: Watchable.Path => Right(p) + } + val watchedPathsSet = watchedPathsSeq.iterator.map(p => p.p.path).toSet + val watchedValueCount = watched.size - watchedPathsSeq.size - val watchedValueStr = if (watchedValues == 0) "" else s" and $watchedValues other values" + val watchedValueStr = + if (watchedValueCount == 0) "" else s" and $watchedValueCount other values" streams.err.println( colors.info( - s"Watching for changes to ${watchedPaths.size} paths$watchedValueStr... (Enter to re-run, Ctrl-C to exit)" + s"Watching for changes to ${watchedPathsSeq.size} paths$watchedValueStr... (Enter to re-run, Ctrl-C to exit)" ).toString ) - val enterKeyPressed = statWatchWait(watched, stdin) - setIdle(false) - enterKeyPressed + @volatile var pathChangesDetected = false + + // oslib watch only works with folders, so we have to watch the parent folders instead + + mill.main.client.DebugLog.println( + colors.info(s"[watch:watched-paths:unfiltered] ${watchedPathsSet.toSeq.sorted.mkString("\n")}").toString + ) + + val ignoredFolders = Seq( + mill.api.WorkspaceRoot.workspaceRoot / "out", + mill.api.WorkspaceRoot.workspaceRoot / ".bloop", + mill.api.WorkspaceRoot.workspaceRoot / ".metals", + mill.api.WorkspaceRoot.workspaceRoot / ".idea", + mill.api.WorkspaceRoot.workspaceRoot / ".git", + mill.api.WorkspaceRoot.workspaceRoot / ".bsp", + ) + mill.main.client.DebugLog.println( + colors.info(s"[watch:ignored-paths] ${ignoredFolders.toSeq.sorted.mkString("\n")}").toString + ) + + val osLibWatchPaths = watchedPathsSet.iterator.map(p => p / "..").toSet + mill.main.client.DebugLog.println( + colors.info(s"[watch:watched-paths] ${osLibWatchPaths.toSeq.sorted.mkString("\n")}").toString + ) + + Using.resource(os.watch.watch( + osLibWatchPaths.toSeq, +// filter = path => { +// val shouldBeIgnored = ignoredFolders.exists(ignored => path.startsWith(ignored)) +// mill.main.client.DebugLog.println( +// colors.info(s"[watch:filter] $path (ignored=$shouldBeIgnored), ignoredFolders=${ignoredFolders.mkString("[\n ", "\n ", "\n]")}").toString +// ) +// !shouldBeIgnored +// }, + onEvent = changedPaths => { + // Make sure that the changed paths are actually the ones in our watch list and not some adjacent files in the + // same folder + val hasWatchedPath = changedPaths.exists(p => watchedPathsSet.exists(watchedPath => p.startsWith(watchedPath))) + mill.main.client.DebugLog.println(colors.info( + s"[watch:changed-paths] (hasWatchedPath=$hasWatchedPath) ${changedPaths.mkString("\n")}" + ).toString) + if (hasWatchedPath) { + pathChangesDetected = true + } + }, + logger = (eventType, data) => { + mill.main.client.DebugLog.println(colors.info(s"[watch] $eventType: ${pprint.apply(data)}").toString) + } + )) { _ => + val enterKeyPressed = + statWatchWait(watchedPollables, stdin, notifiablesChanged = () => pathChangesDetected) + setIdle(false) + enterKeyPressed + } } - // Returns `true` if enter key is pressed to re-run tasks explicitly - def statWatchWait(watched: Seq[Watchable], stdin: InputStream): Boolean = { + /** + * @param notifiablesChanged returns true if any of the notifiables have changed + * + * @return `true` if enter key is pressed to re-run tasks explicitly, false if changes in watched files occured. + */ + def statWatchWait(watched: Seq[Watchable], stdin: InputStream, notifiablesChanged: () => Boolean): Boolean = { val buffer = new Array[Byte](4 * 1024) @tailrec def statWatchWait0(): Boolean = { - if (watched.forall(_.validate())) { + if (!notifiablesChanged() && watched.forall(_.validate())) { if (lookForEnterKey()) { true } else { @@ -94,11 +181,13 @@ object Watching { if (stdin.available() == 0) false else stdin.read(buffer) match { case 0 | -1 => false - case n => + case bytesRead => buffer.indexOf('\n') match { case -1 => lookForEnterKey() - case i => - if (i >= n) lookForEnterKey() + case index => + // If we found the newline further than the bytes read, that means it's not from this read and thus we + // should try reading again. + if (index >= bytesRead) lookForEnterKey() else true } } @@ -106,5 +195,4 @@ object Watching { statWatchWait0() } - } From e48c37edc88f8d79d3875dda78580b1bc312cef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Art=C5=ABras=20=C5=A0lajus?= Date: Tue, 6 May 2025 12:30:43 +0300 Subject: [PATCH 02/16] enableDebugLog --- runner/src/mill/runner/Watching.scala | 62 ++++++++++++++++----------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/runner/src/mill/runner/Watching.scala b/runner/src/mill/runner/Watching.scala index f2aaa4cc13d..5445c999d7d 100644 --- a/runner/src/mill/runner/Watching.scala +++ b/runner/src/mill/runner/Watching.scala @@ -1,9 +1,8 @@ package mill.runner -import mill.api.internal -import mill.util.{Colors, Watchable} -import mill.api.SystemStreams +import mill.api.{SystemStreams, internal} import mill.main.client.DebugLog +import mill.util.{Colors, Watchable} import java.io.InputStream import scala.annotation.tailrec @@ -15,6 +14,8 @@ import scala.util.Using */ @internal object Watching { + private final val enableDebugLog = false + case class Result[T](watched: Seq[Watchable], error: Option[String], result: T) trait Evaluate[T] { @@ -22,15 +23,15 @@ object Watching { } case class WatchArgs( - setIdle: Boolean => Unit, - colors: Colors + setIdle: Boolean => Unit, + colors: Colors ) def watchLoop[T]( ringBell: Boolean, watch: Option[WatchArgs], streams: SystemStreams, - evaluate: Evaluate[T], + evaluate: Evaluate[T] ): (Boolean, T) = { def handleError(errorOpt: Option[String]): Unit = { errorOpt.foreach(streams.err.println) @@ -106,50 +107,59 @@ object Watching { // oslib watch only works with folders, so we have to watch the parent folders instead - mill.main.client.DebugLog.println( - colors.info(s"[watch:watched-paths:unfiltered] ${watchedPathsSet.toSeq.sorted.mkString("\n")}").toString + if (enableDebugLog) DebugLog.println( + colors.info( + s"[watch:watched-paths:unfiltered] ${watchedPathsSet.toSeq.sorted.mkString("\n")}" + ).toString ) + /** A hardcoded list of folders to ignore that we know have no impact on the build. */ val ignoredFolders = Seq( mill.api.WorkspaceRoot.workspaceRoot / "out", mill.api.WorkspaceRoot.workspaceRoot / ".bloop", mill.api.WorkspaceRoot.workspaceRoot / ".metals", mill.api.WorkspaceRoot.workspaceRoot / ".idea", mill.api.WorkspaceRoot.workspaceRoot / ".git", - mill.api.WorkspaceRoot.workspaceRoot / ".bsp", + mill.api.WorkspaceRoot.workspaceRoot / ".bsp" ) - mill.main.client.DebugLog.println( + if (enableDebugLog) DebugLog.println( colors.info(s"[watch:ignored-paths] ${ignoredFolders.toSeq.sorted.mkString("\n")}").toString ) val osLibWatchPaths = watchedPathsSet.iterator.map(p => p / "..").toSet - mill.main.client.DebugLog.println( + if (enableDebugLog) DebugLog.println( colors.info(s"[watch:watched-paths] ${osLibWatchPaths.toSeq.sorted.mkString("\n")}").toString ) Using.resource(os.watch.watch( osLibWatchPaths.toSeq, -// filter = path => { -// val shouldBeIgnored = ignoredFolders.exists(ignored => path.startsWith(ignored)) -// mill.main.client.DebugLog.println( -// colors.info(s"[watch:filter] $path (ignored=$shouldBeIgnored), ignoredFolders=${ignoredFolders.mkString("[\n ", "\n ", "\n]")}").toString -// ) -// !shouldBeIgnored -// }, + filter = path => { + val shouldBeIgnored = ignoredFolders.exists(ignored => path.startsWith(ignored)) + if (enableDebugLog) { + val ignoredFoldersStr = ignoredFolders.mkString("[\n ", "\n ", "\n]") + DebugLog.println( + colors.info(s"[watch:filter] $path (ignored=$shouldBeIgnored), ignoredFolders=$ignoredFoldersStr").toString + ) + } + !shouldBeIgnored + }, onEvent = changedPaths => { // Make sure that the changed paths are actually the ones in our watch list and not some adjacent files in the // same folder - val hasWatchedPath = changedPaths.exists(p => watchedPathsSet.exists(watchedPath => p.startsWith(watchedPath))) - mill.main.client.DebugLog.println(colors.info( + val hasWatchedPath = + changedPaths.exists(p => watchedPathsSet.exists(watchedPath => p.startsWith(watchedPath))) + if (enableDebugLog) DebugLog.println(colors.info( s"[watch:changed-paths] (hasWatchedPath=$hasWatchedPath) ${changedPaths.mkString("\n")}" ).toString) if (hasWatchedPath) { pathChangesDetected = true } }, - logger = (eventType, data) => { - mill.main.client.DebugLog.println(colors.info(s"[watch] $eventType: ${pprint.apply(data)}").toString) - } + logger = + if (enableDebugLog) (eventType, data) => { + DebugLog.println(colors.info(s"[watch] $eventType: ${pprint.apply(data)}").toString) + } + else (_, _) => {} )) { _ => val enterKeyPressed = statWatchWait(watchedPollables, stdin, notifiablesChanged = () => pathChangesDetected) @@ -163,7 +173,11 @@ object Watching { * * @return `true` if enter key is pressed to re-run tasks explicitly, false if changes in watched files occured. */ - def statWatchWait(watched: Seq[Watchable], stdin: InputStream, notifiablesChanged: () => Boolean): Boolean = { + def statWatchWait( + watched: Seq[Watchable], + stdin: InputStream, + notifiablesChanged: () => Boolean + ): Boolean = { val buffer = new Array[Byte](4 * 1024) @tailrec def statWatchWait0(): Boolean = { From 11d164aac527de3aec85d2c6a32d8d0705387b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Art=C5=ABras=20=C5=A0lajus?= Date: Wed, 7 May 2025 20:01:06 +0300 Subject: [PATCH 03/16] use a published oslib version --- build.mill | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.mill b/build.mill index 91e006fd3ff..a6881c02593 100644 --- a/build.mill +++ b/build.mill @@ -150,7 +150,7 @@ object Deps { val junitInterface = ivy"com.github.sbt:junit-interface:0.13.3" val commonsIo = ivy"commons-io:commons-io:2.18.0" val log4j2Core = ivy"org.apache.logging.log4j:log4j-core:2.24.3" - val osLibVersion = "0.11.5-M2-DIRTYc8bf6115" + val osLibVersion = "0.11.5-M7" val osLib = ivy"com.lihaoyi::os-lib:${osLibVersion}" val osLibWatch = ivy"com.lihaoyi::os-lib-watch:${osLibVersion}" val pprint = ivy"com.lihaoyi::pprint:0.9.0" From 1ac4c572dda4891909fc02c694e249f4588dd72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Art=C5=ABras=20=C5=A0lajus?= Date: Thu, 8 May 2025 10:51:06 +0300 Subject: [PATCH 04/16] Allow toggling `--watch` mode with `--watch-via-fs-notify=true` --- main/util/src/mill/util/Watchable.scala | 1 + runner/src/mill/runner/MillCliConfig.scala | 5 + runner/src/mill/runner/MillMain.scala | 4 +- runner/src/mill/runner/Watching.scala | 156 ++++++++++++--------- 4 files changed, 98 insertions(+), 68 deletions(-) diff --git a/main/util/src/mill/util/Watchable.scala b/main/util/src/mill/util/Watchable.scala index 8171a9a7d4f..6fdc72f22d0 100644 --- a/main/util/src/mill/util/Watchable.scala +++ b/main/util/src/mill/util/Watchable.scala @@ -16,6 +16,7 @@ private[mill] trait Watchable { /** The initial hashcode of a watched value. */ def signature: Long + /** @return true if the watched value has not changed */ def validate(): Boolean = poll() == signature def pretty: String diff --git a/runner/src/mill/runner/MillCliConfig.scala b/runner/src/mill/runner/MillCliConfig.scala index 6c94406d64a..c142cd4a758 100644 --- a/runner/src/mill/runner/MillCliConfig.scala +++ b/runner/src/mill/runner/MillCliConfig.scala @@ -95,6 +95,11 @@ case class MillCliConfig( doc = """Watch and re-run the given tasks when when their inputs change.""" ) watch: Flag = Flag(), + @arg( + name = "watch-via-fs-notify", + doc = "Use filesystem based file watching instead of polling based one (experimental, defaults to false).", + ) + watchViaFsNotify: Boolean = false, @arg( short = 's', doc = diff --git a/runner/src/mill/runner/MillMain.scala b/runner/src/mill/runner/MillMain.scala index f9f342fdc14..08e4916ae28 100644 --- a/runner/src/mill/runner/MillMain.scala +++ b/runner/src/mill/runner/MillMain.scala @@ -231,7 +231,9 @@ object MillMain { } val (isSuccess, evalStateOpt) = Watching.watchLoop( ringBell = config.ringBell.value, - watch = Option.when(config.watch.value)(Watching.WatchArgs(setIdle, colors)), + watch = Option.when(config.watch.value)(Watching.WatchArgs( + setIdle, colors, useNotify = config.watchViaFsNotify + )), streams = streams, evaluate = (enterKeyPressed: Boolean, prevState: Option[RunnerState]) => { adjustJvmProperties(userSpecifiedProperties, initialSystemProperties) diff --git a/runner/src/mill/runner/Watching.scala b/runner/src/mill/runner/Watching.scala index 5445c999d7d..4d168a9e53b 100644 --- a/runner/src/mill/runner/Watching.scala +++ b/runner/src/mill/runner/Watching.scala @@ -22,9 +22,13 @@ object Watching { def apply(enterKeyPressed: Boolean, previousState: Option[T]): Result[T] } + /** + * @param useNotify whether to use filesystem based watcher. If it is false uses polling. + */ case class WatchArgs( setIdle: Boolean => Unit, - colors: Colors + colors: Colors, + useNotify: Boolean ) def watchLoop[T]( @@ -71,8 +75,14 @@ object Watching { if (alreadyStale) { enterKeyPressed = false } else { - enterKeyPressed = - watchAndWait(streams, watchArgs.setIdle, streams.in, watchables, watchArgs.colors) + enterKeyPressed = watchAndWait( + streams, + watchArgs.setIdle, + streams.in, + watchables, + watchArgs.colors, + useNotify = watchArgs.useNotify + ) } } throw new IllegalStateException("unreachable") @@ -84,7 +94,8 @@ object Watching { setIdle: Boolean => Unit, stdin: InputStream, watched: Seq[Watchable], - colors: Colors + colors: Colors, + useNotify: Boolean ): Boolean = { setIdle(true) val (watchedPollables, watchedPathsSeq) = watched.partitionMap { @@ -97,75 +108,86 @@ object Watching { val watchedValueStr = if (watchedValueCount == 0) "" else s" and $watchedValueCount other values" - streams.err.println( + streams.err.println { + val viaFsNotify = if (useNotify) " (via fsnotify)" else "" colors.info( - s"Watching for changes to ${watchedPathsSeq.size} paths$watchedValueStr... (Enter to re-run, Ctrl-C to exit)" + s"Watching for changes to ${watchedPathsSeq.size} paths$viaFsNotify$watchedValueStr... (Enter to re-run, Ctrl-C to exit)" ).toString - ) - - @volatile var pathChangesDetected = false - - // oslib watch only works with folders, so we have to watch the parent folders instead + } - if (enableDebugLog) DebugLog.println( - colors.info( - s"[watch:watched-paths:unfiltered] ${watchedPathsSet.toSeq.sorted.mkString("\n")}" - ).toString - ) - - /** A hardcoded list of folders to ignore that we know have no impact on the build. */ - val ignoredFolders = Seq( - mill.api.WorkspaceRoot.workspaceRoot / "out", - mill.api.WorkspaceRoot.workspaceRoot / ".bloop", - mill.api.WorkspaceRoot.workspaceRoot / ".metals", - mill.api.WorkspaceRoot.workspaceRoot / ".idea", - mill.api.WorkspaceRoot.workspaceRoot / ".git", - mill.api.WorkspaceRoot.workspaceRoot / ".bsp" - ) - if (enableDebugLog) DebugLog.println( - colors.info(s"[watch:ignored-paths] ${ignoredFolders.toSeq.sorted.mkString("\n")}").toString - ) - - val osLibWatchPaths = watchedPathsSet.iterator.map(p => p / "..").toSet - if (enableDebugLog) DebugLog.println( - colors.info(s"[watch:watched-paths] ${osLibWatchPaths.toSeq.sorted.mkString("\n")}").toString - ) - - Using.resource(os.watch.watch( - osLibWatchPaths.toSeq, - filter = path => { - val shouldBeIgnored = ignoredFolders.exists(ignored => path.startsWith(ignored)) - if (enableDebugLog) { - val ignoredFoldersStr = ignoredFolders.mkString("[\n ", "\n ", "\n]") - DebugLog.println( - colors.info(s"[watch:filter] $path (ignored=$shouldBeIgnored), ignoredFolders=$ignoredFoldersStr").toString - ) - } - !shouldBeIgnored - }, - onEvent = changedPaths => { - // Make sure that the changed paths are actually the ones in our watch list and not some adjacent files in the - // same folder - val hasWatchedPath = - changedPaths.exists(p => watchedPathsSet.exists(watchedPath => p.startsWith(watchedPath))) - if (enableDebugLog) DebugLog.println(colors.info( - s"[watch:changed-paths] (hasWatchedPath=$hasWatchedPath) ${changedPaths.mkString("\n")}" - ).toString) - if (hasWatchedPath) { - pathChangesDetected = true - } - }, - logger = - if (enableDebugLog) (eventType, data) => { - DebugLog.println(colors.info(s"[watch] $eventType: ${pprint.apply(data)}").toString) - } - else (_, _) => {} - )) { _ => - val enterKeyPressed = - statWatchWait(watchedPollables, stdin, notifiablesChanged = () => pathChangesDetected) + def doWatch(notifiablesChanged: () => Boolean) = { + val enterKeyPressed = statWatchWait(watchedPollables, stdin, notifiablesChanged) setIdle(false) enterKeyPressed } + + if (useNotify) { + @volatile var pathChangesDetected = false + + // oslib watch only works with folders, so we have to watch the parent folders instead + + if (enableDebugLog) DebugLog.println( + colors.info( + s"[watch:watched-paths:unfiltered] ${watchedPathsSet.toSeq.sorted.mkString("\n")}" + ).toString + ) + + /** A hardcoded list of folders to ignore that we know have no impact on the build. */ + val ignoredFolders = Seq( + mill.api.WorkspaceRoot.workspaceRoot / "out", + mill.api.WorkspaceRoot.workspaceRoot / ".bloop", + mill.api.WorkspaceRoot.workspaceRoot / ".metals", + mill.api.WorkspaceRoot.workspaceRoot / ".idea", + mill.api.WorkspaceRoot.workspaceRoot / ".git", + mill.api.WorkspaceRoot.workspaceRoot / ".bsp" + ) + if (enableDebugLog) DebugLog.println( + colors.info(s"[watch:ignored-paths] ${ignoredFolders.toSeq.sorted.mkString("\n")}").toString + ) + + val osLibWatchPaths = watchedPathsSet.iterator.map(p => p / "..").toSet + if (enableDebugLog) DebugLog.println( + colors.info(s"[watch:watched-paths] ${osLibWatchPaths.toSeq.sorted.mkString("\n")}").toString + ) + + Using.resource(os.watch.watch( + osLibWatchPaths.toSeq, + filter = path => { + val shouldBeIgnored = ignoredFolders.exists(ignored => path.startsWith(ignored)) + if (enableDebugLog) { + val ignoredFoldersStr = ignoredFolders.mkString("[\n ", "\n ", "\n]") + DebugLog.println( + colors.info( + s"[watch:filter] $path (ignored=$shouldBeIgnored), ignoredFolders=$ignoredFoldersStr" + ).toString + ) + } + !shouldBeIgnored + }, + onEvent = changedPaths => { + // Make sure that the changed paths are actually the ones in our watch list and not some adjacent files in the + // same folder + val hasWatchedPath = + changedPaths.exists(p => watchedPathsSet.exists(watchedPath => p.startsWith(watchedPath))) + if (enableDebugLog) DebugLog.println(colors.info( + s"[watch:changed-paths] (hasWatchedPath=$hasWatchedPath) ${changedPaths.mkString("\n")}" + ).toString) + if (hasWatchedPath) { + pathChangesDetected = true + } + }, + logger = + if (enableDebugLog) (eventType, data) => { + DebugLog.println(colors.info(s"[watch] $eventType: ${pprint.apply(data)}").toString) + } + else (_, _) => {} + )) { _ => + doWatch(notifiablesChanged = () => pathChangesDetected) + } + } + else { + doWatch(notifiablesChanged = () => watchedPathsSeq.exists(p => !p.validate())) + } } /** From 33253d80883778274f53d2cd3a6ce858606738e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Art=C5=ABras=20=C5=A0lajus?= Date: Thu, 8 May 2025 12:16:02 +0300 Subject: [PATCH 05/16] Reduce the amount of watched paths. --- runner/src/mill/runner/Watching.scala | 59 +++++++++++++++------------ 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/runner/src/mill/runner/Watching.scala b/runner/src/mill/runner/Watching.scala index 4d168a9e53b..58abcc2a4e0 100644 --- a/runner/src/mill/runner/Watching.scala +++ b/runner/src/mill/runner/Watching.scala @@ -132,37 +132,44 @@ object Watching { ).toString ) - /** A hardcoded list of folders to ignore that we know have no impact on the build. */ - val ignoredFolders = Seq( - mill.api.WorkspaceRoot.workspaceRoot / "out", - mill.api.WorkspaceRoot.workspaceRoot / ".bloop", - mill.api.WorkspaceRoot.workspaceRoot / ".metals", - mill.api.WorkspaceRoot.workspaceRoot / ".idea", - mill.api.WorkspaceRoot.workspaceRoot / ".git", - mill.api.WorkspaceRoot.workspaceRoot / ".bsp" - ) - if (enableDebugLog) DebugLog.println( - colors.info(s"[watch:ignored-paths] ${ignoredFolders.toSeq.sorted.mkString("\n")}").toString - ) + val workspaceRoot = mill.api.WorkspaceRoot.workspaceRoot + + /** Paths that are descendants of [[workspaceRoot]]. */ + val pathsUnderWorkspaceRoot = watchedPathsSet.filter { path => + val isUnderWorkspaceRoot = path.startsWith(workspaceRoot) + if (!isUnderWorkspaceRoot) { + streams.err.println(colors.error( + s"Watched path $path is outside workspace root $workspaceRoot, this is unsupported." + ).toString()) + } - val osLibWatchPaths = watchedPathsSet.iterator.map(p => p / "..").toSet - if (enableDebugLog) DebugLog.println( - colors.info(s"[watch:watched-paths] ${osLibWatchPaths.toSeq.sorted.mkString("\n")}").toString - ) + isUnderWorkspaceRoot + } + + // If I have 'root/a/b/c' + // + // Then I want to watch: + // root/a/b/c + // root/a/b + // root/a + // root + val filterPaths = pathsUnderWorkspaceRoot.flatMap { path => + path.relativeTo(workspaceRoot).segments.inits.map(segments => workspaceRoot / segments) + } + if (enableDebugLog) DebugLog.println(colors.info( + s"[watch:watched-paths:filtered] ${filterPaths.toSeq.sorted.mkString("\n")}" + ).toString()) Using.resource(os.watch.watch( - osLibWatchPaths.toSeq, + // Just watch the root folder + Seq(workspaceRoot), filter = path => { - val shouldBeIgnored = ignoredFolders.exists(ignored => path.startsWith(ignored)) + val shouldBeWatched = + filterPaths.contains(path) || watchedPathsSet.exists(watchedPath => path.startsWith(watchedPath)) if (enableDebugLog) { - val ignoredFoldersStr = ignoredFolders.mkString("[\n ", "\n ", "\n]") - DebugLog.println( - colors.info( - s"[watch:filter] $path (ignored=$shouldBeIgnored), ignoredFolders=$ignoredFoldersStr" - ).toString - ) + DebugLog.println(colors.info(s"[watch:filter] $path (shouldBeWatched=$shouldBeWatched)").toString) } - !shouldBeIgnored + shouldBeWatched }, onEvent = changedPaths => { // Make sure that the changed paths are actually the ones in our watch list and not some adjacent files in the @@ -170,7 +177,7 @@ object Watching { val hasWatchedPath = changedPaths.exists(p => watchedPathsSet.exists(watchedPath => p.startsWith(watchedPath))) if (enableDebugLog) DebugLog.println(colors.info( - s"[watch:changed-paths] (hasWatchedPath=$hasWatchedPath) ${changedPaths.mkString("\n")}" + s"[watch:changed-paths] hasWatchedPath=$hasWatchedPath) ${changedPaths.mkString("\n")}" ).toString) if (hasWatchedPath) { pathChangesDetected = true From caffff7cc4101bb0c709d70bdc727999b718c261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Art=C5=ABras=20=C5=A0lajus?= Date: Thu, 8 May 2025 12:34:54 +0300 Subject: [PATCH 06/16] Redirect debug logs to mill server directory --- runner/src/mill/runner/MillMain.scala | 2 +- runner/src/mill/runner/Watching.scala | 44 +++++++++++---------------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/runner/src/mill/runner/MillMain.scala b/runner/src/mill/runner/MillMain.scala index 08e4916ae28..856248f7e23 100644 --- a/runner/src/mill/runner/MillMain.scala +++ b/runner/src/mill/runner/MillMain.scala @@ -232,7 +232,7 @@ object MillMain { val (isSuccess, evalStateOpt) = Watching.watchLoop( ringBell = config.ringBell.value, watch = Option.when(config.watch.value)(Watching.WatchArgs( - setIdle, colors, useNotify = config.watchViaFsNotify + setIdle, colors, useNotify = config.watchViaFsNotify, serverDir = serverDir )), streams = streams, evaluate = (enterKeyPressed: Boolean, prevState: Option[RunnerState]) => { diff --git a/runner/src/mill/runner/Watching.scala b/runner/src/mill/runner/Watching.scala index 58abcc2a4e0..992398c8a05 100644 --- a/runner/src/mill/runner/Watching.scala +++ b/runner/src/mill/runner/Watching.scala @@ -1,7 +1,6 @@ package mill.runner import mill.api.{SystemStreams, internal} -import mill.main.client.DebugLog import mill.util.{Colors, Watchable} import java.io.InputStream @@ -14,8 +13,6 @@ import scala.util.Using */ @internal object Watching { - private final val enableDebugLog = false - case class Result[T](watched: Seq[Watchable], error: Option[String], result: T) trait Evaluate[T] { @@ -24,11 +21,13 @@ object Watching { /** * @param useNotify whether to use filesystem based watcher. If it is false uses polling. + * @param serverDir the directory for storing logs of the mill server */ case class WatchArgs( setIdle: Boolean => Unit, colors: Colors, - useNotify: Boolean + useNotify: Boolean, + serverDir: os.Path ) def watchLoop[T]( @@ -81,7 +80,8 @@ object Watching { streams.in, watchables, watchArgs.colors, - useNotify = watchArgs.useNotify + useNotify = watchArgs.useNotify, + serverDir = watchArgs.serverDir ) } } @@ -95,7 +95,8 @@ object Watching { stdin: InputStream, watched: Seq[Watchable], colors: Colors, - useNotify: Boolean + useNotify: Boolean, + serverDir: os.Path ): Boolean = { setIdle(true) val (watchedPollables, watchedPathsSeq) = watched.partitionMap { @@ -121,16 +122,17 @@ object Watching { enterKeyPressed } - if (useNotify) { + if (useNotify) Using.resource(os.write.outputStream(serverDir / "fsNotifyWatchLog")) { watchLog => + def writeToWatchLog(s: String): Unit = { + watchLog.write(s.getBytes(java.nio.charset.StandardCharsets.UTF_8)) + watchLog.write('\n') + } + @volatile var pathChangesDetected = false // oslib watch only works with folders, so we have to watch the parent folders instead - if (enableDebugLog) DebugLog.println( - colors.info( - s"[watch:watched-paths:unfiltered] ${watchedPathsSet.toSeq.sorted.mkString("\n")}" - ).toString - ) + writeToWatchLog(s"[watched-paths:unfiltered] ${watchedPathsSet.toSeq.sorted.mkString("\n")}") val workspaceRoot = mill.api.WorkspaceRoot.workspaceRoot @@ -156,9 +158,7 @@ object Watching { val filterPaths = pathsUnderWorkspaceRoot.flatMap { path => path.relativeTo(workspaceRoot).segments.inits.map(segments => workspaceRoot / segments) } - if (enableDebugLog) DebugLog.println(colors.info( - s"[watch:watched-paths:filtered] ${filterPaths.toSeq.sorted.mkString("\n")}" - ).toString()) + writeToWatchLog(s"[watched-paths:filtered] ${filterPaths.toSeq.sorted.mkString("\n")}") Using.resource(os.watch.watch( // Just watch the root folder @@ -166,9 +166,7 @@ object Watching { filter = path => { val shouldBeWatched = filterPaths.contains(path) || watchedPathsSet.exists(watchedPath => path.startsWith(watchedPath)) - if (enableDebugLog) { - DebugLog.println(colors.info(s"[watch:filter] $path (shouldBeWatched=$shouldBeWatched)").toString) - } + writeToWatchLog(s"[filter] (shouldBeWatched=$shouldBeWatched) $path") shouldBeWatched }, onEvent = changedPaths => { @@ -176,18 +174,12 @@ object Watching { // same folder val hasWatchedPath = changedPaths.exists(p => watchedPathsSet.exists(watchedPath => p.startsWith(watchedPath))) - if (enableDebugLog) DebugLog.println(colors.info( - s"[watch:changed-paths] hasWatchedPath=$hasWatchedPath) ${changedPaths.mkString("\n")}" - ).toString) + writeToWatchLog(s"[changed-paths] (hasWatchedPath=$hasWatchedPath) ${changedPaths.mkString("\n")}") if (hasWatchedPath) { pathChangesDetected = true } }, - logger = - if (enableDebugLog) (eventType, data) => { - DebugLog.println(colors.info(s"[watch] $eventType: ${pprint.apply(data)}").toString) - } - else (_, _) => {} + logger = (eventType, data) => writeToWatchLog(s"[watch:event] $eventType: ${pprint.apply(data).plainText}") )) { _ => doWatch(notifiablesChanged = () => pathChangesDetected) } From 063e47f2bc671b96399637e849b0b67bcf96af93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Art=C5=ABras=20=C5=A0lajus?= Date: Thu, 8 May 2025 12:53:06 +0300 Subject: [PATCH 07/16] Code review changes. --- runner/src/mill/runner/Watching.scala | 147 +++++++++++++------------- 1 file changed, 75 insertions(+), 72 deletions(-) diff --git a/runner/src/mill/runner/Watching.scala b/runner/src/mill/runner/Watching.scala index 992398c8a05..59b54bc3cc0 100644 --- a/runner/src/mill/runner/Watching.scala +++ b/runner/src/mill/runner/Watching.scala @@ -74,15 +74,7 @@ object Watching { if (alreadyStale) { enterKeyPressed = false } else { - enterKeyPressed = watchAndWait( - streams, - watchArgs.setIdle, - streams.in, - watchables, - watchArgs.colors, - useNotify = watchArgs.useNotify, - serverDir = watchArgs.serverDir - ) + enterKeyPressed = watchAndWait(streams, streams.in, watchables, watchArgs) } } throw new IllegalStateException("unreachable") @@ -91,14 +83,11 @@ object Watching { def watchAndWait( streams: SystemStreams, - setIdle: Boolean => Unit, stdin: InputStream, watched: Seq[Watchable], - colors: Colors, - useNotify: Boolean, - serverDir: os.Path + watchArgs: WatchArgs ): Boolean = { - setIdle(true) + watchArgs.setIdle(true) val (watchedPollables, watchedPathsSeq) = watched.partitionMap { case w: Watchable.Value => Left(w) case p: Watchable.Path => Right(p) @@ -111,82 +100,96 @@ object Watching { streams.err.println { val viaFsNotify = if (useNotify) " (via fsnotify)" else "" - colors.info( + watchArgs.colors.info( s"Watching for changes to ${watchedPathsSeq.size} paths$viaFsNotify$watchedValueStr... (Enter to re-run, Ctrl-C to exit)" ).toString } def doWatch(notifiablesChanged: () => Boolean) = { val enterKeyPressed = statWatchWait(watchedPollables, stdin, notifiablesChanged) - setIdle(false) + watchArgs.setIdle(false) enterKeyPressed } - if (useNotify) Using.resource(os.write.outputStream(serverDir / "fsNotifyWatchLog")) { watchLog => - def writeToWatchLog(s: String): Unit = { - watchLog.write(s.getBytes(java.nio.charset.StandardCharsets.UTF_8)) - watchLog.write('\n') - } + def doWatchPolling() = + doWatch(notifiablesChanged = () => watchedPathsSeq.exists(p => !p.validate())) - @volatile var pathChangesDetected = false + def doWatchFsNotify() = { + Using.resource(os.write.outputStream(watchArgs.serverDir / "fsNotifyWatchLog")) { watchLog => + def writeToWatchLog(s: String): Unit = { + watchLog.write(s.getBytes(java.nio.charset.StandardCharsets.UTF_8)) + watchLog.write('\n') + } - // oslib watch only works with folders, so we have to watch the parent folders instead + @volatile var pathChangesDetected = false - writeToWatchLog(s"[watched-paths:unfiltered] ${watchedPathsSet.toSeq.sorted.mkString("\n")}") + // oslib watch only works with folders, so we have to watch the parent folders instead - val workspaceRoot = mill.api.WorkspaceRoot.workspaceRoot + writeToWatchLog( + s"[watched-paths:unfiltered] ${watchedPathsSet.toSeq.sorted.mkString("\n")}" + ) - /** Paths that are descendants of [[workspaceRoot]]. */ - val pathsUnderWorkspaceRoot = watchedPathsSet.filter { path => - val isUnderWorkspaceRoot = path.startsWith(workspaceRoot) - if (!isUnderWorkspaceRoot) { - streams.err.println(colors.error( - s"Watched path $path is outside workspace root $workspaceRoot, this is unsupported." - ).toString()) - } - - isUnderWorkspaceRoot - } + val workspaceRoot = mill.api.WorkspaceRoot.workspaceRoot - // If I have 'root/a/b/c' - // - // Then I want to watch: - // root/a/b/c - // root/a/b - // root/a - // root - val filterPaths = pathsUnderWorkspaceRoot.flatMap { path => - path.relativeTo(workspaceRoot).segments.inits.map(segments => workspaceRoot / segments) - } - writeToWatchLog(s"[watched-paths:filtered] ${filterPaths.toSeq.sorted.mkString("\n")}") - - Using.resource(os.watch.watch( - // Just watch the root folder - Seq(workspaceRoot), - filter = path => { - val shouldBeWatched = - filterPaths.contains(path) || watchedPathsSet.exists(watchedPath => path.startsWith(watchedPath)) - writeToWatchLog(s"[filter] (shouldBeWatched=$shouldBeWatched) $path") - shouldBeWatched - }, - onEvent = changedPaths => { - // Make sure that the changed paths are actually the ones in our watch list and not some adjacent files in the - // same folder - val hasWatchedPath = - changedPaths.exists(p => watchedPathsSet.exists(watchedPath => p.startsWith(watchedPath))) - writeToWatchLog(s"[changed-paths] (hasWatchedPath=$hasWatchedPath) ${changedPaths.mkString("\n")}") - if (hasWatchedPath) { - pathChangesDetected = true + /** Paths that are descendants of [[workspaceRoot]]. */ + val pathsUnderWorkspaceRoot = watchedPathsSet.filter { path => + val isUnderWorkspaceRoot = path.startsWith(workspaceRoot) + if (!isUnderWorkspaceRoot) { + streams.err.println(watchArgs.colors.error( + s"Watched path $path is outside workspace root $workspaceRoot, this is unsupported." + ).toString()) } - }, - logger = (eventType, data) => writeToWatchLog(s"[watch:event] $eventType: ${pprint.apply(data).plainText}") - )) { _ => - doWatch(notifiablesChanged = () => pathChangesDetected) + + isUnderWorkspaceRoot + } + + // If I have 'root/a/b/c' + // + // Then I want to watch: + // root/a/b/c + // root/a/b + // root/a + // root + val filterPaths = pathsUnderWorkspaceRoot.flatMap { path => + path.relativeTo(workspaceRoot).segments.inits.map(segments => workspaceRoot / segments) + } + writeToWatchLog(s"[watched-paths:filtered] ${filterPaths.toSeq.sorted.mkString("\n")}") + + Using.resource(os.watch.watch( + // Just watch the root folder + Seq(workspaceRoot), + filter = path => { + val shouldBeWatched = + filterPaths.contains(path) || watchedPathsSet.exists(watchedPath => + path.startsWith(watchedPath) + ) + writeToWatchLog(s"[filter] (shouldBeWatched=$shouldBeWatched) $path") + shouldBeWatched + }, + onEvent = changedPaths => { + // Make sure that the changed paths are actually the ones in our watch list and not some adjacent files in the + // same folder + val hasWatchedPath = + changedPaths.exists(p => + watchedPathsSet.exists(watchedPath => p.startsWith(watchedPath)) + ) + writeToWatchLog( + s"[changed-paths] (hasWatchedPath=$hasWatchedPath) ${changedPaths.mkString("\n")}" + ) + if (hasWatchedPath) { + pathChangesDetected = true + } + }, + logger = (eventType, data) => + writeToWatchLog(s"[watch:event] $eventType: ${pprint.apply(data).plainText}") + )) { _ => + doWatch(notifiablesChanged = () => pathChangesDetected) + } } } - else { - doWatch(notifiablesChanged = () => watchedPathsSeq.exists(p => !p.validate())) - } + + if (watchArgs.useNotify) doWatchFsNotify() + else doWatchPolling() } /** From 4604d04b50f85ee72268d833a2ae486f5ae6b66b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Art=C5=ABras=20=C5=A0lajus?= Date: Thu, 8 May 2025 13:13:54 +0300 Subject: [PATCH 08/16] Woops, fix compilation. --- runner/src/mill/runner/Watching.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runner/src/mill/runner/Watching.scala b/runner/src/mill/runner/Watching.scala index 59b54bc3cc0..d48580330d3 100644 --- a/runner/src/mill/runner/Watching.scala +++ b/runner/src/mill/runner/Watching.scala @@ -99,7 +99,7 @@ object Watching { if (watchedValueCount == 0) "" else s" and $watchedValueCount other values" streams.err.println { - val viaFsNotify = if (useNotify) " (via fsnotify)" else "" + val viaFsNotify = if (watchArgs.useNotify) " (via fsnotify)" else "" watchArgs.colors.info( s"Watching for changes to ${watchedPathsSeq.size} paths$viaFsNotify$watchedValueStr... (Enter to re-run, Ctrl-C to exit)" ).toString From 4c0a565bc7ac33b8eb30c322bfbc952292de94af Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 8 May 2025 10:22:27 +0000 Subject: [PATCH 09/16] [autofix.ci] apply automated fixes --- main/util/src/mill/util/Watchable.scala | 1 + runner/src/mill/runner/MillCliConfig.scala | 3 ++- runner/src/mill/runner/MillMain.scala | 5 ++++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/main/util/src/mill/util/Watchable.scala b/main/util/src/mill/util/Watchable.scala index 6fdc72f22d0..dadc26d233a 100644 --- a/main/util/src/mill/util/Watchable.scala +++ b/main/util/src/mill/util/Watchable.scala @@ -10,6 +10,7 @@ import mill.api.internal */ @internal private[mill] trait Watchable { + /** @return the hashcode of a watched value. */ def poll(): Long diff --git a/runner/src/mill/runner/MillCliConfig.scala b/runner/src/mill/runner/MillCliConfig.scala index c142cd4a758..c5d9f361fb6 100644 --- a/runner/src/mill/runner/MillCliConfig.scala +++ b/runner/src/mill/runner/MillCliConfig.scala @@ -97,7 +97,8 @@ case class MillCliConfig( watch: Flag = Flag(), @arg( name = "watch-via-fs-notify", - doc = "Use filesystem based file watching instead of polling based one (experimental, defaults to false).", + doc = + "Use filesystem based file watching instead of polling based one (experimental, defaults to false)." ) watchViaFsNotify: Boolean = false, @arg( diff --git a/runner/src/mill/runner/MillMain.scala b/runner/src/mill/runner/MillMain.scala index 856248f7e23..c733fe05b92 100644 --- a/runner/src/mill/runner/MillMain.scala +++ b/runner/src/mill/runner/MillMain.scala @@ -232,7 +232,10 @@ object MillMain { val (isSuccess, evalStateOpt) = Watching.watchLoop( ringBell = config.ringBell.value, watch = Option.when(config.watch.value)(Watching.WatchArgs( - setIdle, colors, useNotify = config.watchViaFsNotify, serverDir = serverDir + setIdle, + colors, + useNotify = config.watchViaFsNotify, + serverDir = serverDir )), streams = streams, evaluate = (enterKeyPressed: Boolean, prevState: Option[RunnerState]) => { From 29e0ee2fd22007fa663be121e74fedd19f0b6d28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Art=C5=ABras=20=C5=A0lajus?= Date: Thu, 8 May 2025 17:00:23 +0300 Subject: [PATCH 10/16] Catch if `writeToWatchLog` tries to write to a closed file. --- runner/src/mill/runner/Watching.scala | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/runner/src/mill/runner/Watching.scala b/runner/src/mill/runner/Watching.scala index d48580330d3..e8a3631b7b8 100644 --- a/runner/src/mill/runner/Watching.scala +++ b/runner/src/mill/runner/Watching.scala @@ -4,6 +4,7 @@ import mill.api.{SystemStreams, internal} import mill.util.{Colors, Watchable} import java.io.InputStream +import java.nio.channels.ClosedChannelException import scala.annotation.tailrec import scala.util.Using @@ -117,8 +118,12 @@ object Watching { def doWatchFsNotify() = { Using.resource(os.write.outputStream(watchArgs.serverDir / "fsNotifyWatchLog")) { watchLog => def writeToWatchLog(s: String): Unit = { - watchLog.write(s.getBytes(java.nio.charset.StandardCharsets.UTF_8)) - watchLog.write('\n') + try { + watchLog.write(s.getBytes(java.nio.charset.StandardCharsets.UTF_8)) + watchLog.write('\n') + } catch { + case _: ClosedChannelException => /* do nothing, the file is already closed */ + } } @volatile var pathChangesDetected = false From f64fa4f193c885e670a250842bbeda30e08582cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Art=C5=ABras=20=C5=A0lajus?= Date: Fri, 9 May 2025 13:30:08 +0300 Subject: [PATCH 11/16] Backport oslib android fixes Taken from https://github.com/com-lihaoyi/mill/commit/217acb9fc47e51b86f7d47ab3c4e79d6a4e96ac3#diff-49ebd9c68d1348785194e15a80d62f0845c26386e722d3f5d43429238bbf7616R229 --- build.mill | 2 +- .../javalib/android/AndroidAppModule.scala | 24 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/build.mill b/build.mill index a6881c02593..1dd01d8bfc6 100644 --- a/build.mill +++ b/build.mill @@ -150,7 +150,7 @@ object Deps { val junitInterface = ivy"com.github.sbt:junit-interface:0.13.3" val commonsIo = ivy"commons-io:commons-io:2.18.0" val log4j2Core = ivy"org.apache.logging.log4j:log4j-core:2.24.3" - val osLibVersion = "0.11.5-M7" + val osLibVersion = "0.11.5-M8" val osLib = ivy"com.lihaoyi::os-lib:${osLibVersion}" val osLibWatch = ivy"com.lihaoyi::os-lib-watch:${osLibVersion}" val pprint = ivy"com.lihaoyi::pprint:0.9.0" diff --git a/scalalib/src/mill/javalib/android/AndroidAppModule.scala b/scalalib/src/mill/javalib/android/AndroidAppModule.scala index 65b940f6e83..884fe430246 100644 --- a/scalalib/src/mill/javalib/android/AndroidAppModule.scala +++ b/scalalib/src/mill/javalib/android/AndroidAppModule.scala @@ -1,15 +1,15 @@ package mill.javalib.android import coursier.Repository -import mill._ -import mill.scalalib._ +import mill.* import mill.api.{Logger, PathRef, internal} import mill.define.{ModuleRef, Task} +import mill.scalalib.* import mill.scalalib.bsp.BspBuildTarget import mill.testrunner.TestResult import mill.util.Jvm import os.RelPath -import upickle.default._ +import upickle.default.* import scala.jdk.OptionConverters.RichOptional import scala.xml.XML @@ -594,6 +594,24 @@ trait AndroidAppModule extends JavaModule { .flatMap(ref => { val dest = Task.dest / ref.path.baseName os.unzip(ref.path, dest) + + // Fix permissions of unzipped directories + // `os.walk.stream` doesn't work + def walkStream(p: os.Path): geny.Generator[os.Path] = { + if (!os.isDir(p)) geny.Generator() + else { + val streamed = os.list.stream(p) + streamed ++ streamed.flatMap(walkStream) + } + } + + for (p <- walkStream(dest) if os.isDir(p)) { + import java.nio.file.attribute.PosixFilePermission + val newPerms = + os.perms(p) + PosixFilePermission.OWNER_READ + PosixFilePermission.OWNER_EXECUTE + os.perms.set(p, newPerms) + } + val lookupPath = dest / "META-INF" if (os.exists(lookupPath)) { os.walk(lookupPath) From 50a89a77315172d4bedfec021f01f114f95dd71f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Art=C5=ABras=20=C5=A0lajus?= Date: Fri, 9 May 2025 13:42:07 +0300 Subject: [PATCH 12/16] Ugh, scaladoc failures. --- scalalib/src/mill/javalib/android/AndroidAppModule.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scalalib/src/mill/javalib/android/AndroidAppModule.scala b/scalalib/src/mill/javalib/android/AndroidAppModule.scala index 884fe430246..36b160a179e 100644 --- a/scalalib/src/mill/javalib/android/AndroidAppModule.scala +++ b/scalalib/src/mill/javalib/android/AndroidAppModule.scala @@ -1,15 +1,15 @@ package mill.javalib.android import coursier.Repository -import mill.* +import mill._ import mill.api.{Logger, PathRef, internal} import mill.define.{ModuleRef, Task} -import mill.scalalib.* +import mill.scalalib._ import mill.scalalib.bsp.BspBuildTarget import mill.testrunner.TestResult import mill.util.Jvm import os.RelPath -import upickle.default.* +import upickle.default._ import scala.jdk.OptionConverters.RichOptional import scala.xml.XML From 2c91f87417b4f9ca14de0ddfb885bb8f6c0ac252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Art=C5=ABras=20=C5=A0lajus?= Date: Tue, 13 May 2025 15:02:27 +0300 Subject: [PATCH 13/16] Backport of watching race condition fixes. --- .../src/WatchSourceInputTests.scala | 7 +---- runner/src/mill/runner/Watching.scala | 26 +++++++++++-------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/integration/invalidation/watch-source-input/src/WatchSourceInputTests.scala b/integration/invalidation/watch-source-input/src/WatchSourceInputTests.scala index 90753d38126..531d7ea2e06 100644 --- a/integration/invalidation/watch-source-input/src/WatchSourceInputTests.scala +++ b/integration/invalidation/watch-source-input/src/WatchSourceInputTests.scala @@ -45,12 +45,7 @@ trait WatchTests extends UtestIntegrationTestSuite { val expectedShows0 = mutable.Buffer.empty[String] val res = f(expectedOut, expectedErr, expectedShows0) val (shows, out) = res.out.linesIterator.toVector.partition(_.startsWith("\"")) - val err = res.err.linesIterator.toVector - .filter(!_.contains("Compiling compiler interface...")) - .filter(!_.contains("Watching for changes")) - .filter(!_.contains("[info] compiling")) - .filter(!_.contains("[info] done compiling")) - .filter(!_.contains("mill-server/ exitCode file not found")) + val err = res.err.linesIterator.toVector.filter(s => s.startsWith("Setting up ") || s.startsWith("Running ")) assert(out == expectedOut) diff --git a/runner/src/mill/runner/Watching.scala b/runner/src/mill/runner/Watching.scala index e8a3631b7b8..990e2cb3c34 100644 --- a/runner/src/mill/runner/Watching.scala +++ b/runner/src/mill/runner/Watching.scala @@ -55,8 +55,7 @@ object Watching { watch match { case None => - val Result(watchables, errorOpt, result) = - evaluate(enterKeyPressed = false, previousState = None) + val Result(watchables, errorOpt, result) = evaluate(enterKeyPressed = false, previousState = None) handleError(errorOpt) (errorOpt.isEmpty, result) @@ -70,25 +69,24 @@ object Watching { prevState = Some(result) handleError(errorOpt) - // Do not enter watch if already stale, re-evaluate instantly. - val alreadyStale = watchables.exists(w => !w.validate()) - if (alreadyStale) { - enterKeyPressed = false - } else { + try { + watchArgs.setIdle(true) enterKeyPressed = watchAndWait(streams, streams.in, watchables, watchArgs) } + finally { + watchArgs.setIdle(false) + } } throw new IllegalStateException("unreachable") } } - def watchAndWait( + private def watchAndWait( streams: SystemStreams, stdin: InputStream, watched: Seq[Watchable], watchArgs: WatchArgs ): Boolean = { - watchArgs.setIdle(true) val (watchedPollables, watchedPathsSeq) = watched.partitionMap { case w: Watchable.Value => Left(w) case p: Watchable.Path => Right(p) @@ -108,7 +106,6 @@ object Watching { def doWatch(notifiablesChanged: () => Boolean) = { val enterKeyPressed = statWatchWait(watchedPollables, stdin, notifiablesChanged) - watchArgs.setIdle(false) enterKeyPressed } @@ -188,7 +185,14 @@ object Watching { logger = (eventType, data) => writeToWatchLog(s"[watch:event] $eventType: ${pprint.apply(data).plainText}") )) { _ => - doWatch(notifiablesChanged = () => pathChangesDetected) + // If already stale, re-evaluate instantly. + // + // We need to do this to prevent any changes from slipping through the gap between the last evaluation and + // starting the watch. + val alreadyStale = watched.exists(w => !w.validate()) + + if (alreadyStale) false + else doWatch(notifiablesChanged = () => pathChangesDetected) } } } From 35065a3710c14f3ed25282d5dca49784e51b1075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Art=C5=ABras=20=C5=A0lajus?= Date: Mon, 19 May 2025 11:38:51 +0300 Subject: [PATCH 14/16] Final touches. --- runner/src/mill/runner/MillCliConfig.scala | 2 +- runner/src/mill/runner/Watching.scala | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/runner/src/mill/runner/MillCliConfig.scala b/runner/src/mill/runner/MillCliConfig.scala index c5d9f361fb6..1504ad1e62a 100644 --- a/runner/src/mill/runner/MillCliConfig.scala +++ b/runner/src/mill/runner/MillCliConfig.scala @@ -96,7 +96,7 @@ case class MillCliConfig( ) watch: Flag = Flag(), @arg( - name = "watch-via-fs-notify", + name = "notify-watch", doc = "Use filesystem based file watching instead of polling based one (experimental, defaults to false)." ) diff --git a/runner/src/mill/runner/Watching.scala b/runner/src/mill/runner/Watching.scala index 990e2cb3c34..e1c9589419c 100644 --- a/runner/src/mill/runner/Watching.scala +++ b/runner/src/mill/runner/Watching.scala @@ -152,6 +152,12 @@ object Watching { // root/a/b // root/a // root + // + // We're only setting one `os.watch.watch` on the root, and this makes it sound like + // we're setting multiple. What we're actually doing is choosing the paths we need to watch recursively in + // Linux since inotify is non-recursive by default, since changes in any enclosing folder could result in the + // watched file or folder disappearing (e.g. if the enclosing folder was renamed) and we want to pick up such + // changes. val filterPaths = pathsUnderWorkspaceRoot.flatMap { path => path.relativeTo(workspaceRoot).segments.inits.map(segments => workspaceRoot / segments) } From 46c49e878e874e64955592b6ce4bc3346588befc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 08:47:47 +0000 Subject: [PATCH 15/16] [autofix.ci] apply automated fixes --- .../watch-source-input/src/WatchSourceInputTests.scala | 4 +++- runner/src/mill/runner/Watching.scala | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/integration/invalidation/watch-source-input/src/WatchSourceInputTests.scala b/integration/invalidation/watch-source-input/src/WatchSourceInputTests.scala index 531d7ea2e06..6afc8f37f94 100644 --- a/integration/invalidation/watch-source-input/src/WatchSourceInputTests.scala +++ b/integration/invalidation/watch-source-input/src/WatchSourceInputTests.scala @@ -45,7 +45,9 @@ trait WatchTests extends UtestIntegrationTestSuite { val expectedShows0 = mutable.Buffer.empty[String] val res = f(expectedOut, expectedErr, expectedShows0) val (shows, out) = res.out.linesIterator.toVector.partition(_.startsWith("\"")) - val err = res.err.linesIterator.toVector.filter(s => s.startsWith("Setting up ") || s.startsWith("Running ")) + val err = res.err.linesIterator.toVector.filter(s => + s.startsWith("Setting up ") || s.startsWith("Running ") + ) assert(out == expectedOut) diff --git a/runner/src/mill/runner/Watching.scala b/runner/src/mill/runner/Watching.scala index e1c9589419c..ae7d51786d9 100644 --- a/runner/src/mill/runner/Watching.scala +++ b/runner/src/mill/runner/Watching.scala @@ -55,7 +55,8 @@ object Watching { watch match { case None => - val Result(watchables, errorOpt, result) = evaluate(enterKeyPressed = false, previousState = None) + val Result(watchables, errorOpt, result) = + evaluate(enterKeyPressed = false, previousState = None) handleError(errorOpt) (errorOpt.isEmpty, result) @@ -72,8 +73,7 @@ object Watching { try { watchArgs.setIdle(true) enterKeyPressed = watchAndWait(streams, streams.in, watchables, watchArgs) - } - finally { + } finally { watchArgs.setIdle(false) } } From 05f6475458a4f9c266119d342268c42c2b801b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Art=C5=ABras=20=C5=A0lajus?= Date: Mon, 19 May 2025 16:44:55 +0300 Subject: [PATCH 16/16] Windows native image fix. --- dist/package.mill | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dist/package.mill b/dist/package.mill index ae78a1e5dc8..10c5143dff8 100644 --- a/dist/package.mill +++ b/dist/package.mill @@ -345,7 +345,12 @@ object `package` extends RootModule with InstallModule { def mainClass = Some("mill.runner.client.MillClientMain") - def nativeImageClasspath = build.runner.client.runClasspath() + // Use assembly jar as the upstream ivy classpath rather than using runClasspath + // directly to try and avoid native image command length problems on windows + def nativeImageClasspath = + Seq(build.runner.client.resolvedIvyAssembly().pathRef) ++ + build.runner.client.upstreamLocalAssemblyClasspath() ++ + build.runner.client.localClasspath() def localBinName = "mill-native"