Watch directories moved into the tree and report their content

Automatic subtree watching only reacted to `CREATE`, so a directory
moved in from elsewhere stayed unwatched. It is now handled like a
created one. Items that already exist in such a directory never
produce kernel events; they are reported with the same event kind
and `synthesized` set to `true`, so consumers can treat them as
newly appeared.
This commit is contained in:
T. R. Bernstein
2026-09-13 23:13:32 +02:00
parent e6ed232087
commit 134034f3ea
8 changed files with 101 additions and 11 deletions
+3 -1
View File
@@ -64,7 +64,7 @@ Subdirectories created after the call are **not** watched.
### Automatic Subtree Watching
`addWatchWithAutomaticSubtreeWatching` does everything `addRecursiveWatch` does, and additionally listens for `CREATE` events with the `isDir` flag. Whenever a new subdirectory appears, a watch is installed on it automatically:
`addWatchWithAutomaticSubtreeWatching` does everything `addRecursiveWatch` does, and additionally listens for `CREATE` and `MOVED_TO` events with the `isDir` flag. Whenever a subdirectory appears, whether created or moved in, a watch is installed on it and on its subdirectories automatically:
```swift
try await inotify.addWatchWithAutomaticSubtreeWatching(
@@ -75,6 +75,8 @@ try await inotify.addWatchWithAutomaticSubtreeWatching(
This is the most convenient option when you need full coverage of a growing directory tree.
Items that already exist inside a directory that appears this way never produce kernel events. The library reports them as if they had just appeared, using the same kind of event (`CREATE` or `MOVED_TO`), with `synthesized` set to `true`. A synthesized event may duplicate a kernel event for the same item, so consumers that act on events should tolerate seeing an item twice.
When a watched directory is moved out of the tree, the watches on it and on its subdirectories are removed, so no events are reported under the stale path.
## Excluding Items
+13
View File
@@ -23,6 +23,19 @@ public struct DirectoryResolver {
return resolved
}
/// The direct children of `directory`, without the excluded names.
static func entries(of directory: FilePath, excluding itemNames: Set<String> = []) async throws -> [(name: String, isDirectory: Bool)] {
let directoryHandle = try await fileManager.openDirectory(atPath: directory)
var entries: [(name: String, isDirectory: Bool)] = []
for try await childContent in directoryHandle.listContents() {
guard let name = childContent.path.lastComponent?.string else { continue }
guard !itemNames.contains(name) else { continue }
entries.append((name: name, isDirectory: childContent.type == .directory))
}
try await directoryHandle.close()
return entries
}
private static func withSubdirectories(at path: FilePath, recursive: Bool = false, body: (FilePath) async throws -> Void) async throws {
let directoryHandle = try await fileManager.openDirectory(atPath: path)
for try await childContent in directoryHandle.listContents() {
@@ -31,7 +31,7 @@ let descriptors = try await inotify.addWatchWithAutomaticSubtreeWatching(
)
```
Internally this listens for `CREATE` events carrying the ``InotifyEventMask/isDir`` flag and installs a new watch with the same mask whenever a subdirectory appears.
Internally this listens for `CREATE` and `MOVED_TO` events carrying the ``InotifyEventMask/isDir`` flag and installs new watches with the same mask on the subdirectory and its subtree whenever one appears. Items that already exist inside such a subdirectory are reported with ``InotifyEvent/synthesized`` set to `true`, since the kernel never produces events for them; a synthesized event may duplicate a kernel event for the same item.
When a directory is moved out of the watched tree, the watches on it and on its subdirectories are removed, so no events are reported under the stale path.
+37 -7
View File
@@ -1,5 +1,6 @@
import Dispatch
import CInotify
import SystemPackage
public actor Inotify {
private let fd: CInt
@@ -7,6 +8,7 @@ public actor Inotify {
private var watches = InotifyWatchManager()
private nonisolated(unsafe) let eventReader: any DispatchSourceRead
private nonisolated let eventStream: AsyncStream<RawInotifyEvent>
private nonisolated let continuation: AsyncStream<RawInotifyEvent>.Continuation
public nonisolated var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> {
self.eventStream.compactMap(self.transform(_:))
}
@@ -25,7 +27,7 @@ public actor Inotify {
guard self.fd >= 0 else {
throw InotifyError.initFailed(errno: cinotify_get_errno())
}
(self.eventReader, self.eventStream) = Self.createEventReader(
(self.eventReader, self.eventStream, self.continuation) = Self.createEventReader(
forFileDescriptor: fd,
bufferingPolicy: bufferingPolicy
)
@@ -126,20 +128,48 @@ public actor Inotify {
}
private func addWatchInCaseOfAutomaticSubtreeWatching(_ event: InotifyEvent) async {
guard watches.isAutomaticSubtreeWatching(event.watchDescriptor),
event.mask.contains(.create),
event.mask.contains(.isDir) else {
guard !event.synthesized,
watches.isAutomaticSubtreeWatching(event.watchDescriptor),
event.mask.contains(.isDir),
let kind = Self.subtreeTrigger(in: event.mask) else {
return
}
guard let mask = self.watches.mask(forId: event.watchDescriptor) else { return }
let _ = try? await self.addWatchWithAutomaticSubtreeWatching(forDirectory: event.path.string, mask: mask)
guard let wds = try? await self.addWatchWithAutomaticSubtreeWatching(forDirectory: event.path.string, mask: mask) else { return }
await self.synthesizeEvents(forContentOfWatches: wds, kind: kind, cookie: event.cookie)
}
private static func subtreeTrigger(in mask: InotifyEventMask) -> InotifyEventMask? {
if mask.contains(.create) { return .create }
if mask.contains(.movedTo) { return .movedTo }
return nil
}
/// Items that already exist when a directory becomes watched never
/// produce kernel events, so they are reported as if they had just
/// appeared, marked as synthesized.
private func synthesizeEvents(forContentOfWatches wds: [CInt], kind: InotifyEventMask, cookie: UInt32) async {
for wd in wds {
guard let directory = self.watches.path(forId: wd) else { continue }
guard let entries = try? await DirectoryResolver.entries(of: FilePath(directory), excluding: self.excludedItemNames) else { continue }
for entry in entries {
let mask: InotifyEventMask = entry.isDirectory ? [kind, .isDir] : kind
self.continuation.yield(RawInotifyEvent(
watchDescriptor: wd,
mask: mask,
cookie: cookie,
name: entry.name,
synthesized: true
))
}
}
}
private static func createEventReader(
forFileDescriptor fd: CInt,
bufferingPolicy: AsyncStream<RawInotifyEvent>.Continuation.BufferingPolicy
) -> (any DispatchSourceRead, AsyncStream<RawInotifyEvent>) {
) -> (any DispatchSourceRead, AsyncStream<RawInotifyEvent>, AsyncStream<RawInotifyEvent>.Continuation) {
let (stream, continuation) = AsyncStream<RawInotifyEvent>.makeStream(
of: RawInotifyEvent.self,
bufferingPolicy: bufferingPolicy
@@ -161,6 +191,6 @@ public actor Inotify {
}
reader.activate()
return (reader, stream)
return (reader, stream, continuation)
}
}
+5 -1
View File
@@ -12,6 +12,9 @@ public struct InotifyEvent: Sendable, Hashable, CustomStringConvertible {
public let mask: InotifyEventMask
public let cookie: UInt32
public let path: FilePath
/// Whether the event was produced by the library for an item that already
/// existed when its directory became watched, rather than by the kernel.
public let synthesized: Bool
public var description: String {
var parts = ["InotifyEvent(wd: \(watchDescriptor), mask: \(mask), path: \"\(path)\""]
@@ -27,7 +30,8 @@ extension InotifyEvent {
watchDescriptor: rawEvent.watchDescriptor,
mask: rawEvent.mask,
cookie: rawEvent.cookie,
path: dirPath.appending(rawEvent.name)
path: dirPath.appending(rawEvent.name),
synthesized: rawEvent.synthesized
)
}
}
+2 -1
View File
@@ -31,7 +31,8 @@ struct InotifyEventParser {
watchDescriptor: rawEvent.wd,
mask: InotifyEventMask(rawValue: rawEvent.mask),
cookie: rawEvent.cookie,
name: Self.extractName(from: eventPointer, nameLength: rawEvent.len)
name: Self.extractName(from: eventPointer, nameLength: rawEvent.len),
synthesized: false
))
offset += Self.eventSize(nameLength: rawEvent.len)
+3
View File
@@ -3,6 +3,9 @@ public struct RawInotifyEvent: Sendable, Hashable, CustomStringConvertible {
public let mask: InotifyEventMask
public let cookie: UInt32
public let name: String
/// Whether the event was produced by the library for an item that already
/// existed when its directory became watched, rather than by the kernel.
public let synthesized: Bool
public var description: String {
var parts = ["RawInotifyEvent(wd: \(watchDescriptor), mask: \(mask), name: \"\(name)\""]
@@ -83,4 +83,41 @@ struct RecursiveEventTests {
#expect(staleEvent == nil, "Did not expect CREATE for '\(filename)' after its directory left the tree, got: \(events)")
}
}
@Test func watchesAndReportsContentOfDirectoriesMovedIntoTheTree() async throws {
try await withTempDir { dir in
let root = "\(dir)/Root"
let treeSource = "\(dir)/Outside/Tree"
let treeDestination = "\(root)/Tree"
try FileManager.default.createDirectory(atPath: "\(treeSource)/Sub", withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true)
try createFile(at: "\(treeSource)/existing.txt", contents: "hello")
try createFile(at: "\(treeSource)/Sub/nested.txt", contents: "hello")
let events = try await getEventsForTrigger(
in: root,
mask: [.create, .movedTo],
recursive: .withAutomaticSubtreeWatching
) { _ in
try FileManager.default.moveItem(atPath: treeSource, toPath: treeDestination)
try await Task.sleep(for: .milliseconds(400))
try createFile(at: "\(treeDestination)/Sub/created-after-move.txt", contents: "hello")
}
let movedIn = events.first { $0.mask.contains(.movedTo) && $0.mask.contains(.isDir) && $0.path.string == treeDestination }
#expect(movedIn != nil, "Expected MOVED_TO for '\(treeDestination)', got: \(events)")
let existing = events.first { $0.synthesized && $0.mask.contains(.movedTo) && $0.path.string == "\(treeDestination)/existing.txt" }
#expect(existing != nil, "Expected a synthesized MOVED_TO for the existing file, got: \(events)")
let subdirectory = events.first { $0.synthesized && $0.mask.contains(.isDir) && $0.path.string == "\(treeDestination)/Sub" }
#expect(subdirectory != nil, "Expected a synthesized MOVED_TO for the existing subdirectory, got: \(events)")
let nested = events.first { $0.synthesized && $0.path.string == "\(treeDestination)/Sub/nested.txt" }
#expect(nested != nil, "Expected a synthesized MOVED_TO for the nested file, got: \(events)")
let createdAfterMove = events.first { !$0.synthesized && $0.mask.contains(.create) && $0.path.string == "\(treeDestination)/Sub/created-after-move.txt" }
#expect(createdAfterMove != nil, "Expected CREATE inside the moved-in subdirectory, got: \(events)")
}
}
}