Skip to content

Commit

Permalink
Merge pull request #3665 from Duhemm/macos-indexing-root
Browse files Browse the repository at this point in the history
Don't watch entire workspace on MacOS
  • Loading branch information
tgodzik authored Mar 4, 2022
2 parents 9625a60 + 1025814 commit e8a5a8d
Show file tree
Hide file tree
Showing 5 changed files with 118 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ class MetalsLanguageServer(
)
private val fileWatcher = register(
new FileWatcher(
initialConfig,
() => workspace,
buildTargets,
fileWatchFilter,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import scala.meta.pc.PresentationCompilerConfig.OverrideDefFormat
* be turned off. By default this is on, but Metals only
* supports a small subset of this, so it may be problematic
* for certain clients.
* @param macOsMaxWatchRoots The maximum number of root directories to watch on MacOS.
*/
final case class MetalsServerConfig(
globSyntax: GlobSyntaxConfig = GlobSyntaxConfig.default,
Expand Down Expand Up @@ -89,7 +90,12 @@ final case class MetalsServerConfig(
),
bloopPort: Option[Int] = Option(System.getProperty("metals.bloop-port"))
.filter(_.forall(Character.isDigit(_)))
.map(_.toInt)
.map(_.toInt),
macOsMaxWatchRoots: Int =
Option(System.getProperty("metals.macos-max-watch-roots"))
.filter(_.forall(Character.isDigit(_)))
.map(_.toInt)
.getOrElse(32)
) {
override def toString: String =
List[String](
Expand All @@ -105,7 +111,8 @@ final case class MetalsServerConfig(
s"ask-to-reconnect=$askToReconnect",
s"icons=$icons",
s"statistics=$statistics",
s"bloop-port=${bloopPort.map(_.toString()).getOrElse("default")}"
s"bloop-port=${bloopPort.map(_.toString()).getOrElse("default")}",
s"macos-max-watch-roots=${macOsMaxWatchRoots}"
).mkString("MetalsServerConfig(\n ", ",\n ", "\n)")
}
object MetalsServerConfig {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import scala.meta.internal.metals.BuildTargets
import scala.meta.internal.metals.Cancelable
import scala.meta.internal.metals.Directories
import scala.meta.internal.metals.MetalsEnrichments._
import scala.meta.internal.metals.MetalsServerConfig
import scala.meta.io.AbsolutePath

import com.swoval.files.FileTreeDataViews.CacheObserver
Expand All @@ -32,6 +33,7 @@ import com.swoval.files.FileTreeRepository
* get the notifications directly from the OS instead of through the editor via LSP.
*/
final class FileWatcher(
config: MetalsServerConfig,
workspaceDeferred: () => AbsolutePath,
buildTargets: BuildTargets,
watchFilter: Path => Boolean,
Expand All @@ -51,6 +53,7 @@ final class FileWatcher(
disposeAction.map(_.apply())

val newDispose = startWatch(
config,
workspaceDeferred().toNIO,
collectFilesToWatch(buildTargets),
onFileWatchEvent,
Expand Down Expand Up @@ -101,6 +104,7 @@ object FileWatcher {
*
* Contains platform specific file watch initialization logic
*
* @param config metals server configuration
* @param workspace current project workspace directory
* @param filesToWatch source files and directories to watch
* @param callback to execute on FileWatchEvent
Expand All @@ -109,27 +113,39 @@ object FileWatcher {
* @return a dispose action resources used by file watching
*/
private def startWatch(
config: MetalsServerConfig,
workspace: Path,
filesToWatch: FilesToWatch,
callback: FileWatcherEvent => Unit,
watchFilter: Path => Boolean
): () => Unit = {
if (scala.util.Properties.isMac) {
// Due to a hard limit on the number of FSEvents streams that can be opened on macOS,
// only the root workspace directory is registered for a recursive watch.
// Due to a hard limit on the number of FSEvents streams that can be
// opened on macOS, only up to 32 longest common prefixes of the files to
// watch are registered for a recursive watch.
// However, the events are then filtered to receive only relevant events
// and also to hash only revelevant files when watching for changes
// and also to hash only relevant files when watching for changes

val trie = PathTrie(
filesToWatch.sourceFiles ++ filesToWatch.sourceDirectories ++ filesToWatch.semanticdDirectories
)
val isWatched = trie.containsPrefixOf _

// Select up to `maxRoots` longest prefixes of all files in the trie for
// watching. Watching the root of the workspace may have bad performance
// implications if it contains many other projects that we don't need to
// watch (eg. in a monorepo)
val watchRoots =
trie.longestPrefixes(workspace.getRoot(), config.macOsMaxWatchRoots)

val repo = initFileTreeRepository(
path => watchFilter(path) && isWatched(path),
callback
)
repo.register(workspace, Int.MaxValue)
watchRoots.foreach { root =>
scribe.debug(s"Registering root for file watching: $root")
repo.register(root, Int.MaxValue)
}
() => repo.close()
} else {
// Other OSes register all the files and directories individually
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,45 @@ class PathTrie private (root: Node) {
(segments, node) match {
case (_, Leaf) => true
case (Nil, _) => false
case (head :: tail, Single(segment, child)) =>
case (head :: tail, Single(segment, child, _)) =>
if (head == segment) go(tail, child) else false
case (head :: tail, Multi(children)) =>
case (head :: tail, Multi(children, _)) =>
children.get(head).fold(false)(go(tail, _))
}
}
go(segments, root)
}

def longestPrefixes(fsRoot: Path, maxRoots: Int): Iterable[Path] = {
def go(acc: Path, node: Node, availableRoots: Int): Iterable[Path] =
node match {
case Leaf => acc :: Nil
case Single(segment, child, terminal) =>
if (terminal) acc :: Nil
else go(acc.resolve(segment), child, availableRoots)
case Multi(children, terminal) =>
if (terminal || children.size > availableRoots) acc :: Nil
else
children.flatMap { case (segment, child) =>
go(acc.resolve(segment), child, availableRoots / children.size)
}
}
go(fsRoot, root, maxRoots)
}
}

object PathTrie {
private sealed trait Node

private case object Leaf extends Node
private case class Single(segment: String, child: Node) extends Node
private case class Multi(children: Map[String, Node]) extends Node
private case class Single(segment: String, child: Node, terminal: Boolean)
extends Node
private case class Multi(children: Map[String, Node], terminal: Boolean)
extends Node

def apply(paths: Set[Path]): PathTrie = {
def construct(paths: Set[List[String]]): Node = {
val terminal = paths.contains(Nil)
val groupedNonEmptyPaths =
paths
.filter(_.nonEmpty)
Expand All @@ -51,13 +71,13 @@ object PathTrie {
groupedNonEmptyPaths match {
case Nil => Leaf
case singleGroup :: Nil =>
Single(singleGroup._1, construct(singleGroup._2))
Single(singleGroup._1, construct(singleGroup._2), terminal)
case _ =>
val children = groupedNonEmptyPaths.map {
case (topSegment, tailSegments) =>
topSegment -> construct(tailSegments)
}.toMap
Multi(children)
Multi(children, terminal)
}
}

Expand Down
62 changes: 62 additions & 0 deletions tests/unit/src/test/scala/tests/PathTrieSuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package tests

import java.nio.file.Path
import java.nio.file.Paths

import scala.meta.internal.metals.watcher.PathTrie

class PathTrieSuite extends BaseSuite {
private val root = Paths.get(".").toAbsolutePath().getRoot()
private val `/foo` = root.resolve("foo")
private val `/bar` = root.resolve("bar")
private val `/foo/bar` = `/foo`.resolve("bar")
private val `/foo/bar/src1.scala` = `/foo/bar`.resolve("src1.scala")
private val `/foo/bar/src2.scala` = `/foo/bar`.resolve("src2.scala")
private val `/foo/fizz/buzz.scala` =
`/foo`.resolve("fizz").resolve("buzz.scala")

test("longestPrefixes stops at terminal nodes") {
assertEquals(
rootsOf(
Set(
`/foo/bar`,
`/foo/bar/src1.scala`,
`/foo/bar/src2.scala`
)
),
Set(`/foo/bar`)
)
}

test("longestPrefixes respects max roots") {
assertEquals(
rootsOf(
Set(
`/foo/bar/src1.scala`,
`/foo/bar/src2.scala`,
`/foo/fizz/buzz.scala`
),
maxRoots = 2
),
Set(`/foo/bar`, `/foo/fizz/buzz.scala`)
)
}

test("no common prefix") {
assertEquals(
rootsOf(
Set(
`/foo/bar/src1.scala`,
`/foo/bar/src2.scala`,
`/bar`
),
maxRoots = 1
),
Set(root)
)
}

private def rootsOf(paths: Set[Path], maxRoots: Int = 32): Set[Path] = {
PathTrie(paths).longestPrefixes(root, maxRoots).toSet
}
}

0 comments on commit e8a5a8d

Please sign in to comment.