Skip to content

Commit cb8cad4

Browse files
authored
Add readiness signal to DirectoryWatcher, fix DirectoryWatcherTest. (#2066)
1 parent 520371c commit cb8cad4

4 files changed

Lines changed: 78 additions & 24 deletions

File tree

Sources/APIServer/APIServer+Start.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ extension APIServer {
134134
group.addTask {
135135
do {
136136
let localhostResolver = LocalhostDNSHandler(log: log)
137-
await localhostResolver.monitorResolvers()
137+
try await localhostResolver.monitorResolvers()
138138

139139
let nxDomainResolver = NxDomainResolver()
140140
let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver])

Sources/APIServer/LocalhostDNSHandler.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ actor LocalhostDNSHandler: DNSHandler {
3838
self.dns = [DNSName: IPv4Address]()
3939
}
4040

41-
public func monitorResolvers() async {
42-
await self.watcher.startWatching { [weak self] filePaths in
41+
public func monitorResolvers() async throws {
42+
var readyIterator = await self.watcher.readyEvents.makeAsyncIterator()
43+
44+
try await self.watcher.startWatching { [weak self] filePaths in
4345
var dns: [DNSName: IPv4Address] = [:]
4446
let regex = try Regex(HostDNSResolver.localhostOptionsRegex)
4547

@@ -65,6 +67,11 @@ actor LocalhostDNSHandler: DNSHandler {
6567
Task { await self.updateDNS(dns) }
6668
}
6769
}
70+
71+
// Wait for the watcher to actually be watching before returning, so callers can rely on
72+
// resolver file changes being observed once this call completes, instead of racing the
73+
// watcher's own poll cadence.
74+
await readyIterator.next()
6875
}
6976

7077
public func answer(query: Message) async throws -> Message? {

Sources/ContainerOS/DirectoryWatcher.swift

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import SystemPackage
2727
/// If the target directory does not exist yet, it polls until the directory is created.
2828
/// the target is created, then transitions to watching the target directly.
2929
///
30+
/// Each instance supports exactly one `startWatching` session and one reader of `readyEvents`.
31+
/// Calling `startWatching` a second time throws; create a new instance to watch again.
32+
///
3033
/// Example usage:
3134
/// ```swift
3235
/// let watcher = DirectoryWatcher(directoryPath: myPath, log: logger)
@@ -46,6 +49,18 @@ public actor DirectoryWatcher {
4649

4750
private let log: Logger?
4851

52+
/// Emits an event each time the watcher transitions from not-watching to actively watching,
53+
/// i.e. immediately after its `DispatchSource` is resumed. Buffered (`.unbounded`), so a
54+
/// consumer that starts iterating after the event fired still observes it — callers should
55+
/// grab this stream before calling `startWatching` and `await` it instead of sleeping a
56+
/// guessed duration to know when the watcher is actually live.
57+
///
58+
/// Supports only one concurrent reader: `AsyncStream` delivers each value to a single
59+
/// waiting iterator, not to every iterator, so a second consumer would race the first for
60+
/// events instead of both observing them.
61+
public let readyEvents: AsyncStream<Void>
62+
private let readyEventsContinuation: AsyncStream<Void>.Continuation
63+
4964
/// Creates a new `DirectoryWatcher` for the given directory path.
5065
///
5166
/// - Parameters:
@@ -56,13 +71,21 @@ public actor DirectoryWatcher {
5671
self.monitorQueue = DispatchQueue(label: "monitor:\(directoryPath.string)")
5772
self.log = log
5873
self.source = Mutex(nil)
74+
(self.readyEvents, self.readyEventsContinuation) = AsyncStream.makeStream()
5975
}
6076

6177
/// Starts watching the directory for changes.
6278
///
6379
/// - Parameters:
6480
/// - handler: handler to run on directory state change.
65-
public func startWatching(handler: @Sendable @escaping ([FilePath]) throws -> Void) {
81+
/// - Throws: `ContainerizationError(.invalidState)` if this watcher is already watching.
82+
/// Only one `startWatching` session is supported per `DirectoryWatcher` instance; create a
83+
/// new instance if you need to watch again after stopping.
84+
public func startWatching(handler: @Sendable @escaping ([FilePath]) throws -> Void) throws {
85+
guard task == nil else {
86+
throw ContainerizationError(.invalidState, message: "already watching \(directoryPath.string)")
87+
}
88+
6689
self.task = Task {
6790
var exists: Bool
6891
var isDir: ObjCBool = false
@@ -130,10 +153,12 @@ public actor DirectoryWatcher {
130153

131154
source.withLock { $0 = dispatchSource }
132155
dispatchSource.resume()
156+
readyEventsContinuation.yield()
133157
}
134158

135159
deinit {
136160
self.task?.cancel()
137161
source.withLock { $0?.cancel() }
162+
readyEventsContinuation.finish()
138163
}
139164
}

Tests/ContainerOSTests/DirectoryWatcherTest.swift

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -52,23 +52,35 @@ struct DirectoryWatcherTest {
5252
}
5353
}
5454

55+
/// Polls `condition` until it returns true or `timeout` elapses. Used only to wait for the
56+
/// handler's async invocation after a mutation, never to guess how long watcher setup takes.
57+
private func waitUntil(timeout: Duration = .seconds(10), condition: () -> Bool) async throws {
58+
let deadline = ContinuousClock.now + timeout
59+
while !condition() && ContinuousClock.now < deadline {
60+
try await Task.sleep(for: .milliseconds(50))
61+
}
62+
}
63+
5564
@Test func testWatchingExistingDirectory() async throws {
5665
try await withTempDir { tempPath in
57-
5866
let watcher = DirectoryWatcher(directoryPath: tempPath, log: nil)
67+
var readyIterator = await watcher.readyEvents.makeAsyncIterator()
5968
let createdPaths = CreatedPaths()
6069
let name = "newFile"
6170

62-
await watcher.startWatching { [createdPaths] paths in
71+
try await watcher.startWatching { [createdPaths] paths in
6372
for path in paths where path.lastComponent?.string == name {
6473
createdPaths.paths.append(path)
6574
}
6675
}
6776

68-
try await Task.sleep(for: .milliseconds(100))
77+
// Wait for the watcher to actually resume watching, instead of guessing a sleep
78+
// duration that can race the watcher's own poll cadence.
79+
await readyIterator.next()
80+
6981
let newFile = tempPath.appending(name)
7082
FileManager.default.createFile(atPath: newFile.string, contents: nil)
71-
try await Task.sleep(for: .milliseconds(500))
83+
try await waitUntil { !createdPaths.paths.isEmpty }
7284

7385
#expect(!createdPaths.paths.isEmpty, "directory watcher failed to detect new file")
7486
#expect(createdPaths.paths.first!.lastComponent?.string == name)
@@ -81,22 +93,25 @@ struct DirectoryWatcherTest {
8193
let childPath = tempPath.appending(uuid)
8294

8395
let watcher = DirectoryWatcher(directoryPath: childPath, log: nil)
96+
var readyIterator = await watcher.readyEvents.makeAsyncIterator()
8497
let createdPaths = CreatedPaths()
8598
let name = "newFile"
8699

87-
await watcher.startWatching { [createdPaths] paths in
100+
try await watcher.startWatching { [createdPaths] paths in
88101
for path in paths where path.lastComponent?.string == name {
89102
createdPaths.paths.append(path)
90103
}
91104
}
92105

93-
try await Task.sleep(for: .milliseconds(100))
94106
try FileManager.default.createDirectory(atPath: childPath.string, withIntermediateDirectories: true)
95107

96-
try await Task.sleep(for: DirectoryWatcher.watchPeriod)
108+
// Wait for the watcher to actually resume watching, instead of guessing a sleep
109+
// duration that can race the watcher's own poll cadence.
110+
await readyIterator.next()
111+
97112
let newFile = childPath.appending(name)
98113
FileManager.default.createFile(atPath: newFile.string, contents: nil)
99-
try await Task.sleep(for: .milliseconds(500))
114+
try await waitUntil { !createdPaths.paths.isEmpty }
100115

101116
#expect(!createdPaths.paths.isEmpty, "directory watcher failed to detect parent directory")
102117
#expect(createdPaths.paths.first!.lastComponent?.string == name)
@@ -110,23 +125,25 @@ struct DirectoryWatcherTest {
110125
let childPath = tempPath.appending(parent).appending(child)
111126

112127
let watcher = DirectoryWatcher(directoryPath: childPath, log: nil)
128+
var readyIterator = await watcher.readyEvents.makeAsyncIterator()
113129
let createdPaths = CreatedPaths()
114130
let name = "newFile"
115131

116-
await watcher.startWatching { paths in
132+
try await watcher.startWatching { paths in
117133
for path in paths where path.lastComponent?.string == name {
118134
createdPaths.paths.append(path)
119135
}
120136
}
121137

122-
try await Task.sleep(for: .milliseconds(100))
123138
try FileManager.default.createDirectory(atPath: childPath.string, withIntermediateDirectories: true)
124139

125-
try await Task.sleep(for: DirectoryWatcher.watchPeriod)
140+
// Wait for the watcher to actually resume watching, instead of guessing a sleep
141+
// duration that can race the watcher's own poll cadence.
142+
await readyIterator.next()
126143

127144
let newFile = childPath.appending(name)
128145
FileManager.default.createFile(atPath: newFile.string, contents: nil)
129-
try await Task.sleep(for: .milliseconds(500))
146+
try await waitUntil { !createdPaths.paths.isEmpty }
130147

131148
#expect(!createdPaths.paths.isEmpty, "directory watcher failed to detect parent directory")
132149
#expect(createdPaths.paths.first!.lastComponent?.string == name)
@@ -139,36 +156,41 @@ struct DirectoryWatcherTest {
139156
try FileManager.default.createDirectory(atPath: dirPath.string, withIntermediateDirectories: true)
140157

141158
let watcher = DirectoryWatcher(directoryPath: dirPath, log: nil)
159+
var readyIterator = await watcher.readyEvents.makeAsyncIterator()
142160
let createdPaths = CreatedPaths()
143161
let beforeDelete = "beforeDelete"
144162
let afterDelete = "afterDelete"
145163

146-
await watcher.startWatching { [createdPaths] paths in
164+
try await watcher.startWatching { [createdPaths] paths in
147165
for path in paths
148166
where path.lastComponent?.string == beforeDelete || path.lastComponent?.string == afterDelete {
149167
createdPaths.paths.append(path)
150168
}
151169
}
152170

153-
try await Task.sleep(for: .milliseconds(100))
171+
// Wait for the watcher to actually resume watching, instead of guessing a sleep
172+
// duration that can race the watcher's own poll cadence.
173+
await readyIterator.next()
174+
154175
let file1 = dirPath.appending(beforeDelete)
155176
FileManager.default.createFile(atPath: file1.string, contents: nil)
156-
try await Task.sleep(for: .milliseconds(100))
177+
try await waitUntil { createdPaths.paths.contains { $0.lastComponent?.string == beforeDelete } }
157178

158179
try FileManager.default.removeItem(atPath: dirPath.string)
159-
try await Task.sleep(for: .milliseconds(100))
160180
try FileManager.default.createDirectory(atPath: dirPath.string, withIntermediateDirectories: true)
161-
try await Task.sleep(for: DirectoryWatcher.watchPeriod)
181+
182+
// `readyEvents` yields once per resume, so this waits however long the watcher
183+
// actually takes to notice the delete, then re-arm on the recreated directory —
184+
// no guessing needed even though this transition takes an unknown amount of time.
185+
await readyIterator.next()
162186

163187
let file2 = dirPath.appending(afterDelete)
164188
FileManager.default.createFile(atPath: file2.string, contents: nil)
165-
166-
try await Task.sleep(for: .milliseconds(500))
189+
try await waitUntil { createdPaths.paths.contains { $0.lastComponent?.string == afterDelete } }
167190

168191
#expect(!createdPaths.paths.isEmpty, "directory watcher failed to detect new file")
169192
#expect(
170193
Set(createdPaths.paths.compactMap { $0.lastComponent?.string }) == Set([beforeDelete, afterDelete]))
171194
}
172-
173195
}
174196
}

0 commit comments

Comments
 (0)