Skip to content

feature: Add cross-platform filesystem watcher (IOWatch)#475

Merged
xerial merged 6 commits into
mainfrom
feature/filesystem-watcher
Apr 12, 2026
Merged

feature: Add cross-platform filesystem watcher (IOWatch)#475
xerial merged 6 commits into
mainfrom
feature/filesystem-watcher

Conversation

@xerial
Copy link
Copy Markdown
Member

@xerial xerial commented Apr 12, 2026

Summary

  • Add IOWatch API to wvlet.uni.io for watching directories for file creation, modification, and deletion events
  • JVM implementation uses java.nio.file.WatchService with daemon thread polling
  • Node.js (Scala.js) implementation uses fs.watch API with recursive support
  • Scala Native implementation uses polling-based approach (periodic directory scanning)
  • Browser platform throws UnsupportedOperationException (not supported)
  • Export IOWatch.watch through the IO facade object

API

val watcher = IOWatch.watch(IOPath("my-dir"), WatchOptions(recursive = true)) { event =>
  println(s"${event.eventType}: ${event.path}")
}
watcher.close()  // Stop watching

Test plan

  • Cross-platform unit tests (WatchOptions, WatchEventType, WatchEvent construction)
  • JVM integration tests: file creation, modification, deletion detection
  • JVM test: watcher close/cleanup
  • JVM test: error on non-directory path
  • JVM test: IO.watch delegation
  • Scala.js tests pass (shared API tests)
  • Scala Native tests pass (shared API tests)
  • scalafmtAll passes
  • Full compilation across all platforms

🤖 Generated with Claude Code

Add IOWatch API for watching directories for file creation, modification,
and deletion events. Supports JVM (java.nio.file.WatchService), Node.js
(fs.watch), and Scala Native (polling-based).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the feature New feature label Apr 12, 2026
@xerial
Copy link
Copy Markdown
Member Author

xerial commented Apr 12, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a cross-platform file system watcher (IOWatch) with implementations for JVM (using java.nio.file.WatchService), Node.js (using fs.watch), and Scala Native (using a polling-based approach). Feedback focuses on improving the robustness of the JVM implementation by recursively registering newly created directories and protecting the background watcher thread from exceptions thrown in user-provided handlers. Additionally, there are suggestions to address platform-specific limitations of Node.js recursive watching on Linux and to improve the API usability by ensuring default arguments are correctly inherited in the IOWatch object.


// If recursive and a new directory was created, register it
if options.recursive && kind == ENTRY_CREATE && Files.isDirectory(fullPath) then
registerDirectory(fullPath)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When a new directory is created in recursive mode, simply registering the directory itself is insufficient if that directory already contains subdirectories (e.g., during a cp -r or a move operation). To ensure all nested items are watched, you should perform a recursive walk to register all subdirectories found within the newly created path.

                    Files.walkFileTree(
                      fullPath,
                      new SimpleFileVisitor[Path]:
                        override def preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult = 
                          registerDirectory(dir)
                          FileVisitResult.CONTINUE
                    )

if !NodeFSModule.existsSync(path.path) || !NodeFSModule.statSync(path.path).isDirectory() then
throw IOOperationException(s"Watch path is not a directory: ${path.path}")

val watchOptions = js.Dynamic.literal(recursive = options.recursive, persistent = false)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Node.js fs.watch throws an ERR_FEATURE_UNAVAILABLE_ON_PLATFORM error on Linux when the recursive option is set to true. Since this is a cross-platform library, you should handle this to avoid a crash. Consider checking the platform and either falling back to non-recursive watching (perhaps with a warning) or implementing manual recursion for Linux environments.

)
_impl

override def watch(path: IOPath, options: WatchOptions)(handler: WatchEvent => Unit): IOWatcher =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The watch method in the IOWatch object should include the default value for the options parameter to match IOWatchBase. In Scala, default arguments are not automatically inherited by overriding methods in a way that makes them available when calling the method on the overriding object directly. Adding it here allows users to call IOWatch.watch(path) { ... } without explicitly providing WatchOptions.default.

  override def watch(path: IOPath, options: WatchOptions = WatchOptions.default)(handler: WatchEvent => Unit): IOWatcher =

else
WatchEventType.Deleted

handler(WatchEvent(eventType, ioPath))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The handler callback is executed within the watcher's background thread. If the user-provided handler throws an unhandled exception, it will cause the watcher thread to terminate silently, stopping all further event processing. It is safer to wrap the handler call in a try-catch block to ensure the watcher remains resilient to errors in user code.

                  try
                    handler(WatchEvent(eventType, ioPath))
                  catch
                    case e: Throwable =>
                      // Prevent user code exceptions from killing the watcher thread
                      ()

- JVM: Avoid double-registering root directory in recursive mode by
  using walkFileTree for all directories including root
- JVM: Register entire subtree (not just top-level dir) when a new
  directory is created during recursive watching
- Node.js: Detect platform and throw UnsupportedOperationException for
  recursive watching on Linux where fs.watch doesn't support it

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a cross-platform file system watcher API, IOWatch, with implementations for JVM, Node.js, and Scala Native. The JVM implementation uses java.nio.file.WatchService, the Node.js version utilizes fs.watch, and the Native version employs a polling-based approach. Feedback highlights the need to address platform-specific limitations of recursive watching in Node.js and to ensure the watcher remains persistent to prevent premature process exit. Additionally, for the JVM implementation, it is recommended to recursively register subdirectories when a new directory tree is created to maintain consistent behavior across all platforms.

override def watch(path: IOPath, options: WatchOptions)(handler: WatchEvent => Unit): IOWatcher =
if !isNodeEnv then
throw UnsupportedOperationException("IOWatch is not supported in browser environments")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There are two concerns with the watchOptions configuration:

  1. Recursive support: Node.js fs.watch only supports the recursive option on macOS and Windows. On Linux, this implementation will silently fail to watch subdirectories recursively. For a cross-platform library, it is recommended to either implement a manual recursive watcher for Linux or clearly document this platform-specific limitation.
  2. Persistence: Setting persistent = false means the Node.js event loop will not stay active if this watcher is the only remaining task. Typically, file watchers are expected to keep the process alive (e.g., in build tools or dev servers) until explicitly closed. Setting persistent = true (the default in Node.js) is generally more appropriate for this API.
    val watchOptions = js.Dynamic.literal(recursive = options.recursive, persistent = true)

Comment on lines +82 to +83
// If recursive and a new directory was created, register its entire subtree
if options.recursive && kind == ENTRY_CREATE && Files.isDirectory(fullPath) then
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When a new directory is created in recursive mode, only the top-level directory is registered for watching. If a directory tree is moved into the watched path or created with mkdir -p, subdirectories of the new directory will not be monitored. Using Files.walkFileTree ensures that all subdirectories within the newly created path are also registered.

                  if options.recursive && kind == ENTRY_CREATE && Files.isDirectory(fullPath) then
                    Files.walkFileTree(
                      fullPath,
                      new SimpleFileVisitor[Path]:
                        override def preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult =
                          registerDirectory(dir)
                          FileVisitResult.CONTINUE
                    )

xerial and others added 4 commits April 11, 2026 22:56
- Remove duplicate Scaladoc block in IOWatch.scala
- JS: Reuse FileSystem.isNode instead of duplicating isNodeEnv check
- JS: Fix TOCTOU in directory validation (single statSync with catch)
- JS: Remove unused JSImport import
- JVM: Add running check in SimpleFileVisitor to allow prompt shutdown
- JVM: Add visitFileFailed handler to tolerate deleted dirs during walk
- JVM: Catch IOException around recursive registerTree in event loop
- Native: Add @volatile to snapshot var for cross-thread visibility
- Remove unnecessary WHAT comments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add default parameter value to IOWatch.watch override so callers can
  omit the options argument
- Wrap handler callbacks in try-catch on JVM and Native to prevent user
  exceptions from killing the watcher thread
- Change Node.js fs.watch persistent option to true (safer default for
  keeping the event loop alive)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All three platform implementations now protect against user handler
exceptions propagating into the watcher's execution context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@xerial xerial merged commit 7ef468e into main Apr 12, 2026
14 checks passed
@xerial xerial deleted the feature/filesystem-watcher branch April 12, 2026 07:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant