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
+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)\""]