From 6375a233281167721fa4ca94773e329977cb7d1e Mon Sep 17 00:00:00 2001 From: "T. R. Bernstein" Date: Sun, 13 Sep 2026 23:06:28 +0200 Subject: [PATCH] Report queue overflows instead of dropping them `IN_Q_OVERFLOW` arrives with watch descriptor -1, so the path lookup failed and the event was silently discarded. It is now delivered with an empty path so consumers can rescan after the kernel dropped events. --- README.md | 2 ++ Sources/Inotify/Inotify.swift | 3 ++ Sources/Inotify/InotifyEvent.swift | 7 +++++ .../InotifyLimitTests.swift | 31 +++++++++++++++++++ 4 files changed, 43 insertions(+) diff --git a/README.md b/README.md index 4acbad2..a92bcde 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,8 @@ Watch flags: `.dontFollow`, `.onlyDir`, `.oneShot`. Kernel-only flags returned in events: `.isDir`, `.ignored`, `.queueOverflow`, `.unmount`. +When the kernel queue overflows, events are lost and a single event with `.queueOverflow` is delivered instead. It has no path and a watch descriptor of `-1`; rescan the watched directories if you must not miss changes. + ## Removing a Watch Every `addWatch` variant returns one or more watch descriptors that you can use to remove the watch later: diff --git a/Sources/Inotify/Inotify.swift b/Sources/Inotify/Inotify.swift index 475588a..7b37977 100644 --- a/Sources/Inotify/Inotify.swift +++ b/Sources/Inotify/Inotify.swift @@ -93,6 +93,9 @@ public actor Inotify { } private func transform(_ rawEvent: RawInotifyEvent) async -> InotifyEvent? { + if rawEvent.mask.contains(.queueOverflow) { + return InotifyEvent(from: rawEvent, inDirectory: "") + } guard let path = self.watches.path(forId: rawEvent.watchDescriptor) else { return nil } guard !self.excludedItemNames.contains(rawEvent.name) else { return nil } let event = InotifyEvent.init(from: rawEvent, inDirectory: path) diff --git a/Sources/Inotify/InotifyEvent.swift b/Sources/Inotify/InotifyEvent.swift index 9b64d6f..b0ee0b2 100644 --- a/Sources/Inotify/InotifyEvent.swift +++ b/Sources/Inotify/InotifyEvent.swift @@ -1,5 +1,12 @@ import SystemPackage +/// A filesystem event delivered by an ``Inotify`` instance. +/// +/// When the kernel's event queue overflows, it drops events and reports a +/// single event whose ``mask`` contains ``InotifyEventMask/queueOverflow``. +/// Such an event belongs to no watch: its ``watchDescriptor`` is `-1` and +/// its ``path`` is empty. Consumers that must not miss changes should +/// rescan the watched trees when they receive one. public struct InotifyEvent: Sendable, Hashable, CustomStringConvertible { public let watchDescriptor: Int32 public let mask: InotifyEventMask diff --git a/Tests/InotifyIntegrationTests/InotifyLimitTests.swift b/Tests/InotifyIntegrationTests/InotifyLimitTests.swift index 30c79d3..213f104 100644 --- a/Tests/InotifyIntegrationTests/InotifyLimitTests.swift +++ b/Tests/InotifyIntegrationTests/InotifyLimitTests.swift @@ -40,4 +40,35 @@ struct InotifyLimitTests { } } } + + @Test func reportsQueueOverflowInsteadOfDroppingIt() async throws { + try await withTempDir { dir in + try await withInotifyWatchLimit(of: 1, for: [.queuedEvents]) { + let watcher = try Inotify() + try await watcher.addWatch(path: dir, mask: .allEvents) + let overflowTask = Task { () -> (InotifyEvent?, Int) in + var received = 0 + for await event in await watcher.events { + received += 1 + if event.mask.contains(.queueOverflow) { return (event, received) } + } + return (nil, received) + } + + let deadline = ContinuousClock.now + .seconds(5) + var index = 0 + while !overflowTask.isCancelled, ContinuousClock.now < deadline { + try createFile(at: "\(dir)/burst-\(index).txt", contents: "hello") + index += 1 + if index % 200 == 0 { await Task.yield() } + } + overflowTask.cancel() + let (overflow, received) = await overflowTask.value + + #expect(overflow != nil, "Expected a queue overflow event after \(index) file creations and \(received) received events") + #expect(overflow?.watchDescriptor == -1) + #expect(overflow?.path == "") + } + } + } }