Cancel the event reader before closing the descriptor

`deinit` closed the inotify descriptor while its dispatch source was
still active. The kernel drops the epoll registration on close, but
libdispatch keeps its own; an instance created afterwards that reuses
the descriptor number could inherit that stale state and never
receive events. Roughly one test run in three lost a single event
this way.

The reader is now cancelled in `deinit` and the descriptor closed in
its cancel handler, as libdispatch requires.
This commit is contained in:
T. R. Bernstein
2026-09-13 22:59:15 +02:00
parent 39f3428bff
commit dcc08eb928
2 changed files with 31 additions and 2 deletions
+7 -2
View File
@@ -5,7 +5,7 @@ public actor Inotify {
private let fd: CInt private let fd: CInt
private var excludedItemNames: Set<String> = [] private var excludedItemNames: Set<String> = []
private var watches = InotifyWatchManager() private var watches = InotifyWatchManager()
private var eventReader: any DispatchSourceRead private nonisolated(unsafe) let eventReader: any DispatchSourceRead
private nonisolated let eventStream: AsyncStream<RawInotifyEvent> private nonisolated let eventStream: AsyncStream<RawInotifyEvent>
public nonisolated var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> { public nonisolated var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> {
self.eventStream.compactMap(self.transform(_:)) self.eventStream.compactMap(self.transform(_:))
@@ -73,7 +73,11 @@ public actor Inotify {
} }
deinit { deinit {
cinotify_deinit(self.fd) // The file descriptor is closed by the reader's cancel handler once
// libdispatch has unregistered it. Closing it here would leave a
// registration behind that a later instance reusing the descriptor
// number could inherit, silently losing its events.
self.eventReader.cancel()
} }
private func transform(_ rawEvent: RawInotifyEvent) async -> InotifyEvent? { private func transform(_ rawEvent: RawInotifyEvent) async -> InotifyEvent? {
@@ -112,6 +116,7 @@ public actor Inotify {
} }
} }
reader.setCancelHandler { reader.setCancelHandler {
cinotify_deinit(fd)
continuation.finish() continuation.finish()
} }
reader.activate() reader.activate()
@@ -0,0 +1,24 @@
import Foundation
import Testing
@testable import Inotify
@Suite("Instance Lifecycle")
struct LifecycleTests {
@Test func aDeallocatedInstanceDoesNotStealEventsOfItsSuccessor() async throws {
try await withTempDir { dir in
let filename = "after-reuse.txt"
do {
let predecessor = try Inotify()
try await predecessor.addWatch(path: dir, mask: .create)
}
let events = try await getEventsForTrigger(
in: dir,
mask: .create,
) { try createFile(at: "\($0)/\(filename)") }
let createEvent = events.first { $0.mask.contains(.create) && $0.path.lastComponent?.string == filename }
#expect(createEvent != nil, "Expected CREATE for '\(filename)', got: \(events)")
}
}
}