Skip to content

Commit 0760791

Browse files
authored
Fix directory watcher (#1234)
- Current DirectoryWatcher fails if `/etc/resolver` does not exist. This PR fixes DirectoryWatcher to handle non-existing `/etc/resolver` directory. If that directory does not exist, it first watches `/etc` directory to check if `/etc/resolver` directory is created later. Once it detects new `/etc/resolver` directory, it starts watching new DNS resolver files there. - This PR also fixes to log the exception thrown by API server's tasks. - Closes #1207
1 parent 339a389 commit 0760791

6 files changed

Lines changed: 377 additions & 120 deletions

File tree

Package.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ let package = Package(
4242
.library(name: "ContainerPlugin", targets: ["ContainerPlugin"]),
4343
.library(name: "ContainerVersion", targets: ["ContainerVersion"]),
4444
.library(name: "ContainerXPC", targets: ["ContainerXPC"]),
45+
.library(name: "ContainerOS", targets: ["ContainerOS"]),
4546
.library(name: "SocketForwarder", targets: ["SocketForwarder"]),
4647
.library(name: "TerminalProgress", targets: ["TerminalProgress"]),
4748
],
@@ -142,6 +143,7 @@ let package = Package(
142143
"ContainerResource",
143144
"ContainerVersion",
144145
"ContainerXPC",
146+
"ContainerOS",
145147
"DNSServer",
146148
],
147149
path: "Sources/Helpers/APIServer"
@@ -400,6 +402,14 @@ let package = Package(
400402
"CAuditToken",
401403
]
402404
),
405+
.target(
406+
name: "ContainerOS",
407+
dependencies: [
408+
.product(name: "Containerization", package: "containerization"),
409+
.product(name: "ContainerizationOS", package: "containerization"),
410+
],
411+
path: "Sources/ContainerOS"
412+
),
403413
.target(
404414
name: "TerminalProgress",
405415
dependencies: [
@@ -418,6 +428,7 @@ let package = Package(
418428
.product(name: "DNSClient", package: "DNSClient"),
419429
.product(name: "DNS", package: "DNS"),
420430
.product(name: "Logging", package: "swift-log"),
431+
.product(name: "ContainerizationOS", package: "containerization"),
421432
]
422433
),
423434
.testTarget(
@@ -427,6 +438,12 @@ let package = Package(
427438
"DNSServer",
428439
]
429440
),
441+
.testTarget(
442+
name: "ContainerOSTests",
443+
dependencies: [
444+
"ContainerOS"
445+
]
446+
),
430447
.target(
431448
name: "SocketForwarder",
432449
dependencies: [
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import ContainerizationError
18+
import ContainerizationOS
19+
import Foundation
20+
import Logging
21+
import Synchronization
22+
23+
/// Watches a directory for changes and invokes a handler when the contents change.
24+
///
25+
/// `DirectoryWatcher` uses `DispatchSource` file system events to monitor a directory.
26+
/// If the target directory does not exist yet, it polls until the directory is created.
27+
/// the target is created, then transitions to watching the target directly.
28+
///
29+
/// Example usage:
30+
/// ```swift
31+
/// let watcher = DirectoryWatcher(directoryURL: myURL, log: logger)
32+
/// try watcher.startWatching { urls in
33+
/// print("Directory contents changed: \(urls)")
34+
/// }
35+
/// ```
36+
public actor DirectoryWatcher {
37+
public static let watchPeriod = Duration.seconds(1)
38+
39+
/// The URL of the directory being watched.
40+
public let directoryURL: URL
41+
42+
private var task: Task<Void, any Error>?
43+
private let monitorQueue: DispatchQueue
44+
private let source: Mutex<DispatchSourceFileSystemObject?>
45+
46+
private let log: Logger?
47+
48+
/// Creates a new `DirectoryWatcher` for the given directory URL.
49+
///
50+
/// - Parameters:
51+
/// - directoryURL: The URL of the directory to watch.
52+
/// - log: An optional logger for diagnostic messages.
53+
public init(directoryURL: URL, log: Logger?) {
54+
self.directoryURL = directoryURL
55+
self.monitorQueue = DispatchQueue(label: "monitor:\(directoryURL.path)")
56+
self.log = log
57+
self.source = Mutex(nil)
58+
}
59+
60+
/// Starts watching the directory for changes.
61+
///
62+
/// - Parameters:
63+
/// - handler: handler to run on directory state change.
64+
public func startWatching(handler: @Sendable @escaping ([URL]) throws -> Void) {
65+
self.task = Task {
66+
var exists: Bool
67+
var isDir: ObjCBool = false
68+
69+
while true {
70+
do {
71+
exists = FileManager.default.fileExists(atPath: self.directoryURL.path, isDirectory: &isDir)
72+
if exists && isDir.boolValue && self.source.withLock({ $0 }) == nil {
73+
try _startWatching(handler: handler)
74+
}
75+
} catch {
76+
log?.error("failed to start watching", metadata: ["error": "\(error)"])
77+
}
78+
79+
try await Task.sleep(for: Self.watchPeriod)
80+
}
81+
}
82+
}
83+
84+
private func _startWatching(
85+
handler: @escaping ([URL]) throws -> Void
86+
) throws {
87+
let descriptor = open(directoryURL.path, O_EVTONLY)
88+
guard descriptor > 0 else {
89+
throw ContainerizationError(.internalError, message: "cannot open \(directoryURL.path), descriptor=\(descriptor)")
90+
}
91+
92+
do {
93+
let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path)
94+
try handler(files.map { directoryURL.appending(path: $0) })
95+
} catch {
96+
throw ContainerizationError(.internalError, message: "failed to run handler for \(directoryURL.path)")
97+
}
98+
99+
log?.info("starting directory watcher", metadata: ["path": "\(directoryURL.path)"])
100+
101+
let dispatchSource = DispatchSource.makeFileSystemObjectSource(
102+
fileDescriptor: descriptor,
103+
eventMask: [.delete, .write],
104+
queue: monitorQueue
105+
)
106+
107+
dispatchSource.setCancelHandler {
108+
close(descriptor)
109+
}
110+
111+
dispatchSource.setEventHandler { [weak self] in
112+
guard let self else { return }
113+
114+
guard !dispatchSource.data.contains(.delete) else {
115+
dispatchSource.cancel()
116+
self.source.withLock { $0 = nil }
117+
return
118+
}
119+
120+
do {
121+
let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path)
122+
try handler(files.map { directoryURL.appending(path: $0) })
123+
} catch {
124+
self.log?.error(
125+
"failed to run watch handler",
126+
metadata: ["error": "\(error)", "path": "\(directoryURL.path)"])
127+
}
128+
}
129+
130+
source.withLock { $0 = dispatchSource }
131+
dispatchSource.resume()
132+
}
133+
134+
deinit {
135+
self.task?.cancel()
136+
source.withLock { $0?.cancel() }
137+
}
138+
}

Sources/Helpers/APIServer/APIServer+Start.swift

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,15 @@ extension APIServer {
9191
$0[$1.key.rawValue] = $1.value
9292
}), log: log)
9393

94-
await withThrowingTaskGroup(of: Void.self) { group in
94+
await withTaskGroup(of: Result<Void, Error>.self) { group in
9595
group.addTask {
9696
log.info("starting XPC server")
97-
try await server.listen()
97+
do {
98+
try await server.listen()
99+
return .success(())
100+
} catch {
101+
return .failure(error)
102+
}
98103
}
99104

100105
// start up host table DNS
@@ -111,35 +116,47 @@ extension APIServer {
111116
"port": "\(Self.dnsPort)",
112117
]
113118
)
114-
try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort)
119+
do {
120+
try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort)
121+
return .success(())
122+
} catch {
123+
return .failure(error)
124+
}
115125

116126
}
117127

118128
// start up realhost DNS
119-
/*
120129
group.addTask {
121-
let localhostResolver = LocalhostDNSHandler(log: log)
122130
do {
123-
try localhostResolver.monitorResolvers()
131+
let localhostResolver = LocalhostDNSHandler(log: log)
132+
await localhostResolver.monitorResolvers()
133+
134+
let nxDomainResolver = NxDomainResolver()
135+
let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver])
136+
let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver)
137+
let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log)
138+
log.info(
139+
"starting DNS resolver for localhost",
140+
metadata: [
141+
"host": "\(Self.listenAddress)",
142+
"port": "\(Self.localhostDNSPort)",
143+
]
144+
)
145+
try await dnsServer.run(host: Self.listenAddress, port: Self.localhostDNSPort)
146+
return .success(())
124147
} catch {
125-
log.error("could not initialize resolver monitor", metadata: ["error": "\(error)"])
126-
throw error
148+
return .failure(error)
149+
}
150+
}
151+
152+
for await result in group {
153+
switch result {
154+
case .success():
155+
continue
156+
case .failure(let error):
157+
log.error("API server task failed: \(error)")
127158
}
128-
129-
let nxDomainResolver = NxDomainResolver()
130-
let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver])
131-
let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver)
132-
let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log)
133-
log.info(
134-
"starting DNS resolver for localhost",
135-
metadata: [
136-
"host": "\(Self.listenAddress)",
137-
"port": "\(Self.localhostDNSPort)",
138-
]
139-
)
140-
try await dnsServer.run(host: Self.listenAddress, port: Self.localhostDNSPort)
141159
}
142-
*/
143160
}
144161
} catch {
145162
log.error(

Sources/Helpers/APIServer/DirectoryWatcher.swift

Lines changed: 0 additions & 89 deletions
This file was deleted.

0 commit comments

Comments
 (0)