Skip to content

Commit ad62313

Browse files
committed
Move to polling based directory check
1 parent c5e4610 commit ad62313

4 files changed

Lines changed: 54 additions & 111 deletions

File tree

Sources/DNSServer/DirectoryWatcher.swift

Lines changed: 24 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import Logging
2222
/// Watches a directory for changes and invokes a handler when the contents change.
2323
///
2424
/// `DirectoryWatcher` uses `DispatchSource` file system events to monitor a directory.
25-
/// If the target directory does not exist yet, it watches the parent directory until
25+
/// If the target directory does not exist yet, it polls until the directory is created.
2626
/// the target is created, then transitions to watching the target directly.
2727
///
2828
/// Example usage:
@@ -32,12 +32,14 @@ import Logging
3232
/// print("Directory contents changed: \(urls)")
3333
/// }
3434
/// ```
35-
public class DirectoryWatcher {
35+
public actor DirectoryWatcher {
36+
public static let watchPeriod = Duration.seconds(1)
37+
3638
/// The URL of the directory being watched.
3739
public let directoryURL: URL
3840

41+
private var task: Task<Void, any Error>?
3942
private let monitorQueue: DispatchQueue
40-
private var parentSource: DispatchSourceFileSystemObject?
4143
private var source: DispatchSourceFileSystemObject?
4244

4345
private let log: Logger?
@@ -53,6 +55,24 @@ public class DirectoryWatcher {
5355
self.log = log
5456
}
5557

58+
/// Starts watching the directory for changes.
59+
///
60+
/// - Parameters:
61+
/// - handler: handler to run on directory state change.
62+
public func startWatching(handler: @Sendable @escaping ([URL]) throws -> Void) throws {
63+
self.task = Task {
64+
var pollDirectory = true
65+
while pollDirectory {
66+
if self.directoryURL.isDirectory {
67+
try _startWatching(handler: handler)
68+
69+
pollDirectory = false
70+
try await Task.sleep(for: Self.watchPeriod)
71+
}
72+
}
73+
}
74+
}
75+
5676
private func _startWatching(
5777
handler: @escaping ([URL]) throws -> Void
5878
) throws {
@@ -95,57 +115,8 @@ public class DirectoryWatcher {
95115
dispatchSource.resume()
96116
}
97117

98-
/// Starts watching the directory for changes.
99-
public func startWatching(handler: @escaping ([URL]) throws -> Void) throws {
100-
guard source == nil else {
101-
throw ContainerizationError(.invalidState, message: "already watching on \(directoryURL.path)")
102-
}
103-
104-
let parent = directoryURL.deletingLastPathComponent().resolvingSymlinksInPathWithPrivate()
105-
guard parent.isDirectory else {
106-
throw ContainerizationError(.invalidState, message: "expected \(parent.path) to be an existing directory")
107-
}
108-
109-
guard !directoryURL.isSymlink else {
110-
throw ContainerizationError(.invalidState, message: "expected \(directoryURL.path) not a symlink")
111-
}
112-
113-
guard directoryURL.isDirectory else {
114-
log?.info("no target directory, start watching parent", metadata: ["path": "\(parent.path)"])
115-
116-
let descriptor = open(parent.path, O_EVTONLY)
117-
let source = DispatchSource.makeFileSystemObjectSource(
118-
fileDescriptor: descriptor,
119-
eventMask: .write,
120-
queue: monitorQueue)
121-
122-
source.setCancelHandler {
123-
close(descriptor)
124-
}
125-
126-
source.setEventHandler { [weak self] in
127-
guard let self else { return }
128-
129-
if directoryURL.isDirectory {
130-
do {
131-
try _startWatching(handler: handler)
132-
} catch {
133-
log?.error("failed to start watching", metadata: ["error": "\(error)"])
134-
}
135-
source.cancel()
136-
}
137-
}
138-
139-
parentSource = source
140-
source.resume()
141-
return
142-
}
143-
144-
try _startWatching(handler: handler)
145-
}
146-
147118
deinit {
148-
parentSource?.cancel()
119+
self.task?.cancel()
149120
source?.cancel()
150121
}
151122
}

Sources/Helpers/APIServer/APIServer+Start.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ extension APIServer {
123123
group.addTask {
124124
do {
125125
let localhostResolver = LocalhostDNSHandler(log: log)
126-
try localhostResolver.monitorResolvers()
126+
try await localhostResolver.monitorResolvers()
127127

128128
let nxDomainResolver = NxDomainResolver()
129129
let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver])

Sources/Helpers/APIServer/LocalhostDNSHandler.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ import DNSServer
2222
import Foundation
2323
import Logging
2424

25-
class LocalhostDNSHandler: DNSHandler {
25+
actor LocalhostDNSHandler: DNSHandler {
2626
private let ttl: UInt32
2727
private let watcher: DirectoryWatcher
2828

29-
private var dns: [String: IPv4]
29+
nonisolated(unsafe) private var dns: [String: IPv4]
3030

3131
public init(resolversURL: URL = HostDNSResolver.defaultConfigPath, ttl: UInt32 = 5, log: Logger) {
3232
self.ttl = ttl
@@ -35,8 +35,8 @@ class LocalhostDNSHandler: DNSHandler {
3535
self.dns = [:]
3636
}
3737

38-
public func monitorResolvers() throws {
39-
try self.watcher.startWatching { fileURLs in
38+
public func monitorResolvers() async throws {
39+
try await self.watcher.startWatching { fileURLs in
4040
var dns: [String: IPv4] = [:]
4141
let regex = try Regex(HostDNSResolver.localhostOptionsRegex)
4242

@@ -54,7 +54,7 @@ class LocalhostDNSHandler: DNSHandler {
5454
}
5555
}
5656

57-
public func answer(query: Message) async throws -> Message? {
57+
nonisolated public func answer(query: Message) async throws -> Message? {
5858
let question = query.questions[0]
5959
var record: ResourceRecord?
6060
switch question.type {

Tests/DNSServerTests/DirectoryWatcherTest.swift

Lines changed: 24 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,12 @@ struct DirectoryWatcherTest {
4141
return try await body(tempDir)
4242
}
4343

44-
private class CreatedURLs {
45-
public var urls: [URL]
44+
private actor CreatedURLs {
45+
nonisolated(unsafe) public var urls: [URL]
4646

4747
public init() {
4848
self.urls = []
4949
}
50-
51-
public func append(url: URL) {
52-
urls.append(url)
53-
}
5450
}
5551

5652
@Test func testWatchingExistingDirectory() async throws {
@@ -60,18 +56,16 @@ struct DirectoryWatcherTest {
6056
let createdURLs = CreatedURLs()
6157
let name = "newFile"
6258

63-
#expect(throws: Never.self) {
64-
try watcher.startWatching { [createdURLs] urls in
65-
for url in urls where url.lastPathComponent == name {
66-
createdURLs.append(url: url)
67-
}
59+
try await watcher.startWatching { [createdURLs] urls in
60+
for url in urls where url.lastPathComponent == name {
61+
createdURLs.urls.append(url)
6862
}
6963
}
7064

71-
try await Task.sleep(for: .milliseconds(500))
65+
try await Task.sleep(for: .milliseconds(100))
7266
let newFile = tempDir.appendingPathComponent(name)
7367
FileManager.default.createFile(atPath: newFile.path, contents: nil)
74-
try await Task.sleep(for: .milliseconds(500))
68+
try await Task.sleep(for: .milliseconds(100))
7569

7670
#expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect new file")
7771
#expect(createdURLs.urls.first!.lastPathComponent == name)
@@ -87,75 +81,53 @@ struct DirectoryWatcherTest {
8781
let createdURLs = CreatedURLs()
8882
let name = "newFile"
8983

90-
#expect(throws: Never.self) {
91-
try watcher.startWatching { [createdURLs] urls in
92-
for url in urls where url.lastPathComponent == name {
93-
createdURLs.append(url: url)
94-
}
84+
try await watcher.startWatching { [createdURLs] urls in
85+
for url in urls where url.lastPathComponent == name {
86+
createdURLs.urls.append(url)
9587
}
9688
}
9789

98-
try await Task.sleep(for: .milliseconds(300))
90+
try await Task.sleep(for: .milliseconds(100))
9991
try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true)
10092

101-
try await Task.sleep(for: .milliseconds(300))
93+
try await Task.sleep(for: DirectoryWatcher.watchPeriod)
10294
let newFile = childDir.appendingPathComponent(name)
10395
FileManager.default.createFile(atPath: newFile.path, contents: nil)
104-
try await Task.sleep(for: .milliseconds(300))
96+
try await Task.sleep(for: .milliseconds(100))
10597

10698
#expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect parent directory")
10799
#expect(createdURLs.urls.first!.lastPathComponent == name)
108-
109100
}
101+
}
110102

103+
@Test func testWatchingNonExistingParent() async throws {
111104
try await withTempDir { tempDir in
112105
let parent = UUID().uuidString
113-
let symlink = UUID().uuidString
114106
let child = UUID().uuidString
115-
116-
let parentDir = tempDir.appendingPathComponent(parent)
117-
let symlinkDir = tempDir.appendingPathComponent(symlink)
118-
let childDir = symlinkDir.appendingPathComponent(child)
119-
120-
try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true)
121-
try FileManager.default.createSymbolicLink(at: symlinkDir, withDestinationURL: parentDir)
107+
let childDir = tempDir.appendingPathComponent(parent).appendingPathComponent(child)
122108

123109
let watcher = DirectoryWatcher(directoryURL: childDir, log: nil)
124110
let createdURLs = CreatedURLs()
125111
let name = "newFile"
126112

127-
#expect(throws: Never.self) {
128-
try watcher.startWatching { [createdURLs] urls in
129-
for url in urls where url.lastPathComponent == name {
130-
createdURLs.append(url: url)
131-
}
113+
try await watcher.startWatching { urls in
114+
for url in urls where url.lastPathComponent == name {
115+
createdURLs.urls.append(url)
132116
}
133117
}
134118

135-
try await Task.sleep(for: .milliseconds(300))
119+
try await Task.sleep(for: .microseconds(100))
136120
try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true)
137121

138-
try await Task.sleep(for: .milliseconds(300))
122+
try await Task.sleep(for: DirectoryWatcher.watchPeriod)
123+
139124
let newFile = childDir.appendingPathComponent(name)
140125
FileManager.default.createFile(atPath: newFile.path, contents: nil)
141-
try await Task.sleep(for: .milliseconds(300))
126+
try await Task.sleep(for: .milliseconds(100))
142127

143-
#expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect symbolic parent directory")
128+
#expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect parent directory")
144129
#expect(createdURLs.urls.first!.lastPathComponent == name)
145130
}
146131
}
147132

148-
@Test func testWatchingNonExistingParent() async throws {
149-
try await withTempDir { tempDir in
150-
let parent = UUID().uuidString
151-
let child = UUID().uuidString
152-
let childDir = tempDir.appendingPathComponent(parent).appendingPathComponent(child)
153-
154-
let watcher = DirectoryWatcher(directoryURL: childDir, log: nil)
155-
#expect(throws: ContainerizationError.self, "directory watcher should fail if no parent") {
156-
try watcher.startWatching { urls in }
157-
}
158-
}
159-
}
160-
161133
}

0 commit comments

Comments
 (0)