diff --git a/PHP Monitor.xcodeproj/project.pbxproj b/PHP Monitor.xcodeproj/project.pbxproj index 803e380e..3850ea19 100644 --- a/PHP Monitor.xcodeproj/project.pbxproj +++ b/PHP Monitor.xcodeproj/project.pbxproj @@ -74,6 +74,7 @@ 0379C4A52ED720220035D7EA /* App+DetectApps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0379C4A32ED7201D0035D7EA /* App+DetectApps.swift */; }; 0379C4A62ED720220035D7EA /* App+DetectApps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0379C4A32ED7201D0035D7EA /* App+DetectApps.swift */; }; 0379C4A72ED720220035D7EA /* App+DetectApps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0379C4A32ED7201D0035D7EA /* App+DetectApps.swift */; }; + 037F44162EDB0AAA002EBF75 /* FSNotifierTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F44152EDB0AA8002EBF75 /* FSNotifierTest.swift */; }; 0386B0B42ED36C3D00CA6795 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386B0B32ED36C3D00CA6795 /* Locked.swift */; }; 0386B0B52ED36C3D00CA6795 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386B0B32ED36C3D00CA6795 /* Locked.swift */; }; 0386B0B62ED36C3D00CA6795 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386B0B32ED36C3D00CA6795 /* Locked.swift */; }; @@ -1050,6 +1051,7 @@ 036C39132E5CB820008DAEDF /* TestBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestBundle.swift; sourceTree = ""; }; 0379C49E2ED71CFC0035D7EA /* Startup+Launch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Startup+Launch.swift"; sourceTree = ""; }; 0379C4A32ED7201D0035D7EA /* App+DetectApps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App+DetectApps.swift"; sourceTree = ""; }; + 037F44152EDB0AA8002EBF75 /* FSNotifierTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FSNotifierTest.swift; sourceTree = ""; }; 0386B0B32ED36C3D00CA6795 /* Locked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Locked.swift; sourceTree = ""; }; 0386B0B82ED36DF800CA6795 /* LockedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockedTests.swift; sourceTree = ""; }; 0392CDE52EB23B8F009176DA /* CertificateValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateValidator.swift; sourceTree = ""; }; @@ -1457,6 +1459,14 @@ path = Parsers; sourceTree = ""; }; + 037F44142EDB0A9F002EBF75 /* Watchers */ = { + isa = PBXGroup; + children = ( + 037F44152EDB0AA8002EBF75 /* FSNotifierTest.swift */, + ); + path = Watchers; + sourceTree = ""; + }; 0386B0BD2ED36E2500CA6795 /* Helpers */ = { isa = PBXGroup; children = ( @@ -2435,6 +2445,7 @@ 03D53E902E8AE089001B1671 /* Testables */, 036575C62EA12E2200BA41BF /* Versions */, 0386B0BD2ED36E2500CA6795 /* Helpers */, + 037F44142EDB0A9F002EBF75 /* Watchers */, ); path = unit; sourceTree = ""; @@ -3523,6 +3534,7 @@ C485707A28BF457800539B36 /* PhpDoctorView.swift in Sources */, C4C0E8E827F88B41002D32A9 /* DomainScanner.swift in Sources */, C449B4F027EE7FB800C47E8A /* DomainListTLSCell.swift in Sources */, + 037F44162EDB0AAA002EBF75 /* FSNotifierTest.swift in Sources */, C4FBFC532616485F00CDB8E1 /* PhpVersionDetectionTest.swift in Sources */, C43A8A2425D9D20D00591B77 /* HomebrewPackageTest.swift in Sources */, C485707928BF456C00539B36 /* ArrayExtension.swift in Sources */, diff --git a/phpmon/Common/Helpers/Locked.swift b/phpmon/Common/Helpers/Locked.swift index ca7c9adf..fce34f1e 100644 --- a/phpmon/Common/Helpers/Locked.swift +++ b/phpmon/Common/Helpers/Locked.swift @@ -34,7 +34,7 @@ import Foundation situations where adopting structured concurrency would otherwise be too challenging or a huge refactor. */ -final class Locked { +final class Locked: @unchecked Sendable { private var _value: T private let lock = NSLock() diff --git a/phpmon/Domain/Watcher/FSNotifier.swift b/phpmon/Domain/Watcher/FSNotifier.swift index b270efd9..ec2a5a26 100644 --- a/phpmon/Domain/Watcher/FSNotifier.swift +++ b/phpmon/Domain/Watcher/FSNotifier.swift @@ -42,18 +42,27 @@ class FSNotifier { ) dispatchSource?.setEventHandler(handler: { - let distance = self.lastUpdate?.distance(to: Date().timeIntervalSince1970) + self.queue.async { + // See how long ago our last handled event was + let distance = self.lastUpdate? + .distance(to: Date().timeIntervalSince1970) - if distance == nil || distance != nil && distance! > 1.00 { - // FS event fired, checking in 1s, no duplicate FS events will be acted upon + // Add a debounce of 1 second + if let distance, distance <= 1.00 { + Log.perf("FSNotifier debounce active for \(url.path).") + return + } + + // Synchronize the last update property self.lastUpdate = Date().timeIntervalSince1970 + // Dispatch the async task we set for when the filesystem event occurs Task { - await delay(seconds: 1) onChange() } } }) + dispatchSource?.setCancelHandler(handler: { [weak self] in guard let self = self else { return } @@ -61,6 +70,7 @@ class FSNotifier { self.fileDescriptor = -1 self.dispatchSource = nil }) + dispatchSource?.resume() } diff --git a/tests/unit/Watchers/FSNotifierTest.swift b/tests/unit/Watchers/FSNotifierTest.swift new file mode 100644 index 00000000..98fb7c33 --- /dev/null +++ b/tests/unit/Watchers/FSNotifierTest.swift @@ -0,0 +1,58 @@ +// +// FSNotifierTest.swift +// PHP Monitor +// +// Created by Nico Verbruggen on 29/11/2025. +// Copyright © 2025 Nico Verbruggen. All rights reserved. +// + +import Testing +import Foundation + +@Suite(.serialized) +struct FSNotifierTest { + /** + This test verifies that FSNotifier fires the onChange callback when a file is modified. + */ + @Test func notifier_fires_when_file_is_modified_and_debounces_correctly() async throws { + // Create a temporary file to monitor + let tempDir = FileManager.default.temporaryDirectory + let testFile = tempDir.appendingPathComponent("fs_notifier_test_\(UUID().uuidString).txt") + FileManager.default.createFile(atPath: testFile.path, contents: nil) + + defer { + try? FileManager.default.removeItem(at: testFile) + } + + let eventFired = Locked(0) + + // Create notifier + let notifier = FSNotifier( + for: testFile, + eventMask: .write, + onChange: { + eventFired.value += 1 + } + ) + + defer { + notifier.terminate() + } + + // Modify the file, twice + try "hello".write(to: testFile, atomically: false, encoding: .utf8) + try "hello".write(to: testFile, atomically: false, encoding: .utf8) + + // Wait for the event to fire, verify it fired ONCE (not TWICE) + try await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + #expect(eventFired.value == 1) + + // Try to write again (after debounce timing) + try await Task.sleep(nanoseconds: 2_000_000_000) + try "hello".write(to: testFile, atomically: false, encoding: .utf8) + + // Verify our event fired AGAIN after 0.2 seconds + try await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + #expect(eventFired.value == 2) + } +}