Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
package com.specificlanguages.mops.cli

import com.specificlanguages.mops.protocol.DaemonPaths
import com.specificlanguages.mops.protocol.DaemonWorkspace
import org.junit.jupiter.api.io.CleanupMode
import org.junit.jupiter.api.io.TempDir
import org.junit.jupiter.api.parallel.ResourceLock
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.createDirectories
import kotlin.io.path.isRegularFile
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.pathString
import kotlin.io.path.readText
import kotlin.io.path.writeText
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFalse

@ResourceLock("system-streams")
class LibraryModelRefreshIntegrationTest {
Expand All @@ -19,13 +25,41 @@ class LibraryModelRefreshIntegrationTest {

@Test
fun `external edits of a library model outside the project folder are visible in the same daemon and after a restart`() {
val project = prepareProject()
val daemonHome = tempDir.resolve("daemon-home").createDirectories()
exerciseEdits(project, daemonHome) { args ->
runCommandLine(project, "--daemon-home", daemonHome.pathString, *javaAndMpsHomeArgs(), *args)
}
if (System.getProperty("os.name").startsWith("Mac")) {
assertContains(daemonLogs(daemonHome), "refreshed external changes using file watcher events")
}
}

@Test
fun `external edits are visible with the native file watcher turned off`() {
val project = prepareProject()
val daemonHome = tempDir.resolve("daemon-home").createDirectories()
val cli = InstalledCli(
project, daemonHome, tempDir,
mapOf("MOPS_DAEMON_JAVA_OPTIONS" to "-Xmx2g -Didea.filewatcher.disabled=true"),
)
exerciseEdits(project, daemonHome) { args -> cli.run(*args) }
val logs = daemonLogs(daemonHome)
assertContains(logs, "using a full walk because the file watcher is not operational")
assertFalse("using file watcher events" in logs, logs)
}

private fun prepareProject(): Path {
val project = copyTestProject("project-library", tempDir.resolve("project"))
val shared = Files.move(project.resolve("libs"), tempDir.resolve("shared"))
Files.move(project.resolve("libs"), tempDir.resolve("shared"))
val libraries = project.resolve(".mps/libraries.xml")
libraries.writeText(libraries.readText().replace("${'$'}PROJECT_DIR${'$'}/libs", "${'$'}PROJECT_DIR${'$'}/../shared"))
val libraryModel = shared.resolve("library/models/values.mps")
return project
}

private fun exerciseEdits(project: Path, daemonHome: Path, run: (Array<out String>) -> CliResult) {
val libraryModel = tempDir.resolve("shared/library/models/values.mps")
val projectModel = project.resolve("solutions/tests/models/library@tests.mps")
val daemonHome = tempDir.resolve("daemon-home").createDirectories()
val program = tempDir.resolve("names.groovy").also {
it.writeText("""
project.read {
Expand All @@ -35,10 +69,8 @@ class LibraryModelRefreshIntegrationTest {
}
""".trimIndent())
}
fun run(vararg args: String): CliResult =
runCommandLine(project, "--daemon-home", daemonHome.pathString, *javaAndMpsHomeArgs(), *args)
fun names(): String {
val result = run("code", "run", program.pathString)
val result = run(arrayOf("code", "run", program.pathString))
assertEquals(0, result.exitCode, result.output)
return result.stdout.trim()
}
Expand All @@ -58,11 +90,15 @@ class LibraryModelRefreshIntegrationTest {
rename(libraryModel, "LibraryValue", "LibraryValueEdited")
assertEquals("LibraryValueEdited;LibraryPassEdited", names(), "library model edit in the same daemon")

assertEquals(0, run("daemon", "stop").exitCode)
assertEquals(0, run(arrayOf("daemon", "stop")).exitCode)
rename(libraryModel, "LibraryValueEdited", "LibraryValueAfterRestart")
assertEquals("LibraryValueAfterRestart;LibraryPassEdited", names(), "edit while the daemon was stopped")
} finally {
stopDaemons(project, daemonHome)
}
}

private fun daemonLogs(daemonHome: Path): String =
DaemonPaths(daemonHome).projects.listDirectoryEntries()
.map { DaemonWorkspace(it).logFile() }.filter { it.isRegularFile() }.joinToString("\n") { it.readText() }
}
7 changes: 7 additions & 0 deletions daemon-mps-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ dependencies {
include("lib/mps-openapi.jar")
include("lib/mps-persistence.jar")
include("lib/util.jar")
// NativeFileWatcherImpl, PluggableFileWatcher, Notifications and their supertypes, for
// HeadlessNativeFileWatcher.
include("lib/util-8.jar")
include("lib/app.jar")
include("lib/intellij.platform.core.jar")
include("lib/intellij.platform.ide.core.jar")
include("lib/intellij.platform.ide.impl.jar")
include("plugins/mps-java/lib/java-core.jar")
include("plugins/java/lib/ecj/eclipse.jar")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.specificlanguages.mops.daemon.plugin

import com.intellij.notification.Notification
import com.intellij.notification.Notifications
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.vfs.impl.local.NativeFileWatcherImpl
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong

/**
* The platform's native file watcher (`fsnotifier`), enabled in a headless application. The platform registers its own
* instance, which stays off in command-line and test applications; this second registration differs only in that
* check, and can still be turned off with `-Didea.filewatcher.disabled=true`.
*
* The daemon reads [changeCount] and [recursiveRoots] by reflection, because this class is loaded by the plugin class
* loader.
*/
class HeadlessNativeFileWatcher : NativeFileWatcherImpl() {
private val rootChanges = AtomicLong()

@Volatile
private var recursive: List<String> = emptyList()

@Volatile
private var flat: List<String> = emptyList()

@Volatile
private var failed = false

private val startAttempts: AtomicInteger? = startAttemptCounter()

init {
ApplicationManager.getApplication().messageBus.connect().subscribe(
Notifications.TOPIC,
object : Notifications {
override fun notify(notification: Notification) {
if (notification.groupId == FAILURE_GROUP) failed = true
}
},
)
}

override fun isDisabled(): Boolean = java.lang.Boolean.getBoolean(DISABLED_PROPERTY)

override fun setWatchRoots(recursive: List<String>, flat: List<String>, shuttingDown: Boolean) {
if (recursive != this.recursive || flat != this.flat) {
this.recursive = recursive
this.flat = flat
rootChanges.incrementAndGet()
}
super.setWatchRoots(recursive, flat, shuttingDown)
}

/**
* A number that changes whenever the watcher may have missed changes: when the watch roots change and when the
* `fsnotifier` process is started again. `null` once the watcher has reported a failure, such as the Linux inotify
* watch limit, or when the start count cannot be read.
*/
fun changeCount(): Long? {
val starts = startAttempts ?: return null
return if (failed) null else rootChanges.get() + starts.get()
}

/** The recursive watch roots as last set, canonical and system-dependent. */
fun recursiveRoots(): List<String> = recursive

/**
* The platform restarts a terminated `fsnotifier` without reporting anything as changed, but counts each start in a
* private field.
*/
private fun startAttemptCounter(): AtomicInteger? =
try {
NativeFileWatcherImpl::class.java.getDeclaredField("myStartAttemptCount")
.apply { isAccessible = true }
.get(this) as? AtomicInteger
} catch (_: ReflectiveOperationException) {
null
}

private companion object {
const val DISABLED_PROPERTY = "idea.filewatcher.disabled"
const val FAILURE_GROUP = "File Watcher Messages"
}
}
4 changes: 4 additions & 0 deletions daemon-mps-plugin/src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@
<plugin id="jetbrains.mps.core" />
<plugin id="jetbrains.mps.ide.java" />
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<vfs.local.pluggableFileWatcher
implementation="com.specificlanguages.mops.daemon.plugin.HeadlessNativeFileWatcher"/>
</extensions>
</idea-plugin>
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,42 @@ package com.specificlanguages.mops.daemon
import com.intellij.openapi.application.ApplicationManager
import com.intellij.configurationStore.StoreReloadManager
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VfsUtil
import jetbrains.mps.classloading.ClassLoaderManager
import jetbrains.mps.ide.platform.watching.ReloadManager
import jetbrains.mps.project.MPSProject
import jetbrains.mps.project.Project
import jetbrains.mps.project.facets.JavaModuleFacet
import jetbrains.mps.project.dependency.GlobalModuleDependenciesManager
import jetbrains.mps.util.PathManager
import jetbrains.mps.vfs.refresh.DefaultCachingContext
import kotlinx.coroutines.runBlocking
import org.jetbrains.mps.openapi.module.SModuleReference
import java.nio.file.Files
import java.nio.file.Path

/**
* Refreshes project files, the folders of source modules, and the archives of packaged modules, and replaces runtimes
* whose Java classpath contents changed between requests.
* whose Java classpath contents changed between requests. [WatchedFolderRefresh] decides how folders are refreshed.
*/
internal class ExternalProjectRefresh(private val project: Project) {
internal class ExternalProjectRefresh(private val project: Project, private val logger: DaemonLogger) {
private val folderRefresh = WatchedFolderRefresh()
private val classPathFingerprints = ClassPathFingerprints()
private var outputs = snapshot()
private var archiveStamps: Map<Path, ArchiveStamp?>? = null
private val foldersWithStoredFilesLoaded = mutableSetOf<Path>()
private val mpsHome = Path.of(PathManager.getHomePath())

fun refresh() {
/** Refreshes external changes and returns how the folders were refreshed. */
fun refresh(): WatchedFolderRefresh.Pass {
val mpsProject = project as MPSProject
val pass = folderRefresh.begin()
ApplicationManager.getApplication().invokeAndWait {
val settings = Path.of(requireNotNull(mpsProject.project.basePath)).resolve(".mps")
mpsProject.fileSystem.getFile(settings.toString()).refresh(DefaultCachingContext(true, true))
LocalFileSystem.getInstance().refreshAndFindFileByNioFile(settings)?.let { pass.refresh(listOf(it)) }
}
// The component store reloads project membership separately from MPS's model/module reload sessions.
runBlocking { StoreReloadManager.getInstance(mpsProject.project).reloadChangedStorageFiles() }
ApplicationManager.getApplication().invokeAndWait {
refreshModuleFolders()
refreshModuleFolders(pass)
refreshModuleArchives()
ReloadManager.getInstance().flush()
val current = snapshot()
Expand All @@ -50,6 +51,8 @@ internal class ExternalProjectRefresh(private val project: Project) {
}
outputs = current
}
logger.log(pass.summary())
return pass
}

/**
Expand All @@ -58,11 +61,11 @@ internal class ExternalProjectRefresh(private val project: Project) {
* folder is seen, the files the VFS has stored under it are loaded into memory before the refresh;
* docs/test-running-design.md explains why.
*/
private fun refreshModuleFolders() {
private fun refreshModuleFolders(pass: WatchedFolderRefresh.Pass) {
val folders = project.modelAccess.computeReadAction { moduleFolders(project, mpsHome) }
.mapNotNull { LocalFileSystem.getInstance().findFileByNioFile(it) }
folders.filter { foldersWithStoredFilesLoaded.add(it.toNioPath()) }.forEach(::loadStoredFiles)
VfsUtil.markDirtyAndRefresh(false, true, true, *folders.toTypedArray())
pass.refresh(folders)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.specificlanguages.mops.daemon

import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import kotlin.io.path.createDirectories
import kotlin.io.path.isRegularFile

/**
* Prepares the native file watcher's executable (`fsnotifier`) before MPS starts. The generic MPS distribution archive
* does not keep the executable bit, so the distribution's binary is copied into a directory of the daemon, made
* executable, and passed to the platform through `idea.filewatcher.executable.path`. Without a binary for this
* platform the watcher is turned off through `idea.filewatcher.disabled`, and refreshes walk folders instead.
*
* Does nothing when either property is already set, so `-Didea.filewatcher.disabled=true` in the daemon's Java options
* turns the watcher off.
*/
internal object FileWatcherExecutable {
private const val EXECUTABLE_PROPERTY = "idea.filewatcher.executable.path"
private const val DISABLED_PROPERTY = "idea.filewatcher.disabled"

fun install(mpsHome: Path, directory: Path, logger: DaemonLogger) {
if (System.getProperty(EXECUTABLE_PROPERTY) != null || System.getProperty(DISABLED_PROPERTY) != null) return
val bundled = bundled(mpsHome)
if (bundled == null) {
logger.log("no native file watcher in $mpsHome for this platform; refreshes walk folders")
System.setProperty(DISABLED_PROPERTY, "true")
return
}
try {
System.setProperty(EXECUTABLE_PROPERTY, copy(bundled, directory).toString())
} catch (exception: IOException) {
logger.log("cannot prepare the native file watcher $bundled: $exception; refreshes walk folders")
System.setProperty(DISABLED_PROPERTY, "true")
}
}

private fun copy(bundled: Path, directory: Path): Path {
val target = directory.createDirectories().resolve(bundled.fileName)
if (!target.isRegularFile() || Files.mismatch(bundled, target) != -1L) {
val temporary = Files.createTempFile(directory, bundled.fileName.toString(), ".tmp")
Files.copy(bundled, temporary, StandardCopyOption.REPLACE_EXISTING)
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
}
if (!Files.isExecutable(target) && !target.toFile().setExecutable(true)) {
throw IOException("cannot make $target executable")
}
return target
}

/** The distribution's `fsnotifier` for the current operating system and architecture, if it has one. */
private fun bundled(mpsHome: Path): Path? {
val os = System.getProperty("os.name")
val arm = System.getProperty("os.arch") in setOf("aarch64", "arm64")
val relative = when {
os.startsWith("Mac") -> "bin/mac/fsnotifier"
os.startsWith("Windows") -> "bin/win/fsnotifier.exe"
// Before MPS 2026.3 the distribution has only an x86-64 binary for Linux.
os.startsWith("Linux") && arm -> "bin/linux-aarch64/fsnotifier"
os.startsWith("Linux") -> "bin/linux/fsnotifier"
else -> return null
}
return mpsHome.resolve(relative).takeIf { it.isRegularFile() }
}
}
Loading
Loading