From dcc08eb928995cff8e64261d0c2b20f28514c8c8 Mon Sep 17 00:00:00 2001 From: "T. R. Bernstein" Date: Sun, 13 Sep 2026 22:59:15 +0200 Subject: [PATCH] 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. --- Sources/Inotify/Inotify.swift | 9 +++++-- .../LifecycleTests.swift | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 Tests/InotifyIntegrationTests/LifecycleTests.swift diff --git a/Sources/Inotify/Inotify.swift b/Sources/Inotify/Inotify.swift index 582ad05..3d5d856 100644 --- a/Sources/Inotify/Inotify.swift +++ b/Sources/Inotify/Inotify.swift @@ -5,7 +5,7 @@ public actor Inotify { private let fd: CInt private var excludedItemNames: Set = [] private var watches = InotifyWatchManager() - private var eventReader: any DispatchSourceRead + private nonisolated(unsafe) let eventReader: any DispatchSourceRead private nonisolated let eventStream: AsyncStream public nonisolated var events: AsyncCompactMapSequence, InotifyEvent> { self.eventStream.compactMap(self.transform(_:)) @@ -73,7 +73,11 @@ public actor Inotify { } 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? { @@ -112,6 +116,7 @@ public actor Inotify { } } reader.setCancelHandler { + cinotify_deinit(fd) continuation.finish() } reader.activate() diff --git a/Tests/InotifyIntegrationTests/LifecycleTests.swift b/Tests/InotifyIntegrationTests/LifecycleTests.swift new file mode 100644 index 0000000..f3d1bb4 --- /dev/null +++ b/Tests/InotifyIntegrationTests/LifecycleTests.swift @@ -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)") + } + } +}