File: DirectoryMonitor.swift

package info (click to toggle)
swiftlang 6.0.3-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 2,519,992 kB
  • sloc: cpp: 9,107,863; ansic: 2,040,022; asm: 1,135,751; python: 296,500; objc: 82,456; f90: 60,502; lisp: 34,951; pascal: 19,946; sh: 18,133; perl: 7,482; ml: 4,937; javascript: 4,117; makefile: 3,840; awk: 3,535; xml: 914; fortran: 619; cs: 573; ruby: 573
file content (247 lines) | stat: -rw-r--r-- 10,876 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
/*
 This source file is part of the Swift.org open source project

 Copyright (c) 2021-2024 Apple Inc. and the Swift project authors
 Licensed under Apache License v2.0 with Runtime Library Exception

 See https://swift.org/LICENSE.txt for license information
 See https://swift.org/CONTRIBUTORS.txt for Swift project authors
*/

import Foundation
import SwiftDocC

#if !os(Linux) && !os(Android) && !os(Windows)
import Darwin

/// A throttle object to filter events that come too fast.
fileprivate var throttle = Throttle(interval: .seconds(1))

/// Monitors a directory subtree for file changes.
class DirectoryMonitor {
    
    enum Error: DescribedError {
        /// The Darwin `errno` code for too many open files.
        static let tooManyOpenFilesErrorCode = 24
        
        case urlIsNotDirectory(URL)
        case openFileHandleFailed(URL, code: Int32)
        case attributesNotAccessible(URL)
        case contentsEnumerationFailed(URL)
        case reachedOpenFileLimit(Int)
        
        var errorDescription: String {
            switch self {
            case .urlIsNotDirectory(let url): return "\(url.path) is not a directory"
            case .openFileHandleFailed(let url, let code): return "Failed to open file descriptor for \(url.path) with error code \(code)"
            case .attributesNotAccessible(let url): return "Could not read attributes of \(url.path)"
            case .contentsEnumerationFailed(let url): return "Could not enumerate the contents of \(url.path)"
            case .reachedOpenFileLimit(let fileCount):
                return """
                Watching the source bundle failed because it contains \(fileCount) files which is
                more than your shell session limit for maximum amount of open files.

                Verify your current session limit by running 'ulimit -n'.

                To preview your source bundle, change your shell session's open file limit
                by running 'ulimit -n COUNT' where COUNT is the new limit
                that is higher than the amount of files in your source bundle and adding
                some buffer for system processes that also need to open files.
                """
            }
        }
    }

    /// A handler to call when a file change has been detected.
    /// - parameter: The root folder URL the monitor is observing.
    /// - parameter: A folder URL where the change did occur.
    typealias ChangeHandler = (URL, URL) -> Void
    
    /// An observed directory structure including the file handler and dispatch source.
    private struct WatchedDirectory {
        let url: URL
        let fileDescriptor: Int32
        let sources: [DispatchSourceFileSystemObject]
    }
    
    /// A list of the directories that the monitor observes for changes.
    private var watchedDirectories = [WatchedDirectory]()
    private let monitorQueue = DispatchQueue(label: "directoryMonitor", qos: .unspecified, attributes: .concurrent) 

    let root: URL
    private let changeHandler: ChangeHandler
    
    var didReloadWatchedDirectoryTree: ((URL?) -> Void)?
    
    private var lastChecksum = ""
    
    /// Returns a hash checksum of the recursive listing of the monitored folder (including recursive modification times).
    private func watchedURLChecksum() throws -> (checksum: String, count: Int) {
        let fileManager = FileManager()
        let resourceKeys: [URLResourceKey] = [.isDirectoryKey, .contentModificationDateKey]
        guard let enumerator = fileManager.enumerator(at: root, includingPropertiesForKeys: resourceKeys, options: [.skipsHiddenFiles]) else {
            throw Error.contentsEnumerationFailed(root)
        }

        var files = ""
        var watchedFileCount = 0
        for case let fileURL as URL in enumerator {
            guard let resourceValues = try? fileURL.resourceValues(forKeys: Set(resourceKeys)),
                let modificationDate = resourceValues.contentModificationDate,
                let isDirectory = resourceValues.isDirectory else {
                
                throw Error.attributesNotAccessible(fileURL)
            }
            
            watchedFileCount += 1
            
            // Don't include directory names in the checksum since we're interested only in content changes
            guard !isDirectory else {
                continue
            }
            
            files += "\(fileURL.path) \(modificationDate.timeIntervalSinceReferenceDate)\n"
        }
        return (Checksum.md5(of: Data(files.utf8)), watchedFileCount)
    }
    
    /// Creates a new monitor observing `root` for changes.
    /// - parameter root: The root directory that you monitor for changes.
    /// - parameter changeHandler: A handler to invoke when changes are detected. 
    init(root: URL, changeHandler: @escaping ChangeHandler) throws {
        var isDirectory = ObjCBool(booleanLiteral: false)
        FileManager.default.fileExists(atPath: root.path, isDirectory: &isDirectory)
        guard isDirectory.boolValue else {
            throw Error.urlIsNotDirectory(root)
        }
        
        self.root = root.resolvingSymlinksInPath().standardized
        self.changeHandler = changeHandler
    }
    
    /// Returns a list of directories found under the given URL parameter. 
    /// In case the parameter is a file path the method returns an empty list. 
    private func allDirectories(in url: URL) throws -> [(directory: URL, files: [URL])] {
        let contents = Set<URL>(try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles))
        let childrenHere = contents.filter { url in
            var isDirectory: ObjCBool = false
            return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue
        }
        let files = Array(contents.subtracting(childrenHere))
        
        return try [(directory: url, files: files)] 
            + childrenHere.flatMap { try allDirectories(in: $0) }
    }
    
    private func didChange(url: URL) {
        changeHandler(root, url)
    }

    /// A flag to keep track if the tree should be reloaded.
    /// 
    /// It's needed because a throttled sequence of quick changes might be:
    /// ```none
    /// [file change], [directory change], [file change]
    /// ```
    /// and we keep track via `shouldReloadDirectoryTree` if there was a directory level change
    /// in the sequence and therefore when the last event triggers the event handler we
    /// should reload the directory tree.
    private let shouldReloadDirectoryTree = Synchronized(false)

    /// Starts monitoring the given URL for changes and returns the dispatch source object for this operation.
    private func watch(url: URL, for events: DispatchSource.FileSystemEvent, on queue: DispatchQueue) throws -> (descriptor: Int32, source: DispatchSourceFileSystemObject) {
        let fileDescriptor = open(url.path, O_EVTONLY)
        if fileDescriptor == -1 {
            throw Error.openFileHandleFailed(url, code: Darwin.errno)
        }
        let source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor, eventMask: events, queue: queue)
        
        source.setEventHandler { [weak self] in
            guard let self else { return }
            
            // Throttle multiple events in quick succession.
            throttle.schedule {
                self.shouldReloadDirectoryTree.sync { shouldReload in
                    shouldReload = shouldReload || FileManager.default.directoryExists(atPath: url.path)
                }
                
                // If the observed directory has been deleted stop the monitor.
                guard FileManager.default.directoryExists(atPath: self.root.path) else {
                    return self.stop()
                }
                
                // Check the root directory contents' checksum before calling `didChange(url:)`
                // to avoid reacting to changes in hidden files which are ignored by the checksum function.
                guard let newChecksum = try? self.watchedURLChecksum().checksum,
                    self.lastChecksum != newChecksum else { return }
                self.lastChecksum = newChecksum
                
                self.didChange(url: url)
                
                // Reload the content recursively, if a directory was modified. (No need for single file changes)
                if self.shouldReloadDirectoryTree.sync({ $0 }) {
                    do {
                        try self.restart()
                        self.shouldReloadDirectoryTree.sync { shouldReload in
                            shouldReload = false
                        }
                        self.didReloadWatchedDirectoryTree?(url)
                    } catch {
                        var stderr = LogHandle.standardError
                        print("Unexpected error while monitoring \(self.root.path). Monitoring functionality is stopped.", to: &stderr)
                    }
                }
            }
        }
        source.setCancelHandler {
            close(fileDescriptor)
        }
        source.resume()
        
        return (descriptor: fileDescriptor, source: source)
    }

    /// Provided a URL and a monitor queue, returns a `WatchedDirectory` with event handling hooked up.
    private func watchedDirectory(at url: URL, files: [URL], on queue: DispatchQueue) throws -> WatchedDirectory {
        let watched = try watch(url: url, for: .all, on: queue)
        return try WatchedDirectory(url: url, 
            fileDescriptor: watched.descriptor, 
            sources: [watched.source] + files.map { try watch(url: $0, for: .write, on: queue).source } )
    }
    
    /// Start monitoring the root directory and its contents.
    func start() throws {
        let watchedFiles = try watchedURLChecksum()
        lastChecksum = watchedFiles.checksum
        
        do {
            watchedDirectories = try allDirectories(in: root)
                .map { return try watchedDirectory(at: $0.directory, files: $0.files, on: monitorQueue) }
        } catch Error.openFileHandleFailed(_, let errorCode) where errorCode == Error.tooManyOpenFilesErrorCode {
            // In case we've reached the file descriptor limit throw a detailed user error
            // with recovery instructions.
            throw Error.reachedOpenFileLimit(watchedFiles.count)
        }
    }
    
    func restart() throws {
        stop()
        try start()
    }
    
    /// Stop monitoring.
    func stop() {
        for directory in watchedDirectories {
            for source in directory.sources {
                source.cancel()
            }
        }
        watchedDirectories = []
    }
    
    deinit {
        stop()
    }
}

#endif