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
This commit is contained in:
jwhur
2026-02-25 08:53:35 -08:00
committed by GitHub
parent 339a3895c0
commit 0760791ac7
6 changed files with 377 additions and 120 deletions
+17
View File
@@ -42,6 +42,7 @@ let package = Package(
.library(name: "ContainerPlugin", targets: ["ContainerPlugin"]),
.library(name: "ContainerVersion", targets: ["ContainerVersion"]),
.library(name: "ContainerXPC", targets: ["ContainerXPC"]),
.library(name: "ContainerOS", targets: ["ContainerOS"]),
.library(name: "SocketForwarder", targets: ["SocketForwarder"]),
.library(name: "TerminalProgress", targets: ["TerminalProgress"]),
],
@@ -142,6 +143,7 @@ let package = Package(
"ContainerResource",
"ContainerVersion",
"ContainerXPC",
"ContainerOS",
"DNSServer",
],
path: "Sources/Helpers/APIServer"
@@ -400,6 +402,14 @@ let package = Package(
"CAuditToken",
]
),
.target(
name: "ContainerOS",
dependencies: [
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
],
path: "Sources/ContainerOS"
),
.target(
name: "TerminalProgress",
dependencies: [
@@ -418,6 +428,7 @@ let package = Package(
.product(name: "DNSClient", package: "DNSClient"),
.product(name: "DNS", package: "DNS"),
.product(name: "Logging", package: "swift-log"),
.product(name: "ContainerizationOS", package: "containerization"),
]
),
.testTarget(
@@ -427,6 +438,12 @@ let package = Package(
"DNSServer",
]
),
.testTarget(
name: "ContainerOSTests",
dependencies: [
"ContainerOS"
]
),
.target(
name: "SocketForwarder",
dependencies: [
+138
View File
@@ -0,0 +1,138 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationOS
import Foundation
import Logging
import Synchronization
/// Watches a directory for changes and invokes a handler when the contents change.
///
/// `DirectoryWatcher` uses `DispatchSource` file system events to monitor a directory.
/// If the target directory does not exist yet, it polls until the directory is created.
/// the target is created, then transitions to watching the target directly.
///
/// Example usage:
/// ```swift
/// let watcher = DirectoryWatcher(directoryURL: myURL, log: logger)
/// try watcher.startWatching { urls in
/// print("Directory contents changed: \(urls)")
/// }
/// ```
public actor DirectoryWatcher {
public static let watchPeriod = Duration.seconds(1)
/// The URL of the directory being watched.
public let directoryURL: URL
private var task: Task<Void, any Error>?
private let monitorQueue: DispatchQueue
private let source: Mutex<DispatchSourceFileSystemObject?>
private let log: Logger?
/// Creates a new `DirectoryWatcher` for the given directory URL.
///
/// - Parameters:
/// - directoryURL: The URL of the directory to watch.
/// - log: An optional logger for diagnostic messages.
public init(directoryURL: URL, log: Logger?) {
self.directoryURL = directoryURL
self.monitorQueue = DispatchQueue(label: "monitor:\(directoryURL.path)")
self.log = log
self.source = Mutex(nil)
}
/// Starts watching the directory for changes.
///
/// - Parameters:
/// - handler: handler to run on directory state change.
public func startWatching(handler: @Sendable @escaping ([URL]) throws -> Void) {
self.task = Task {
var exists: Bool
var isDir: ObjCBool = false
while true {
do {
exists = FileManager.default.fileExists(atPath: self.directoryURL.path, isDirectory: &isDir)
if exists && isDir.boolValue && self.source.withLock({ $0 }) == nil {
try _startWatching(handler: handler)
}
} catch {
log?.error("failed to start watching", metadata: ["error": "\(error)"])
}
try await Task.sleep(for: Self.watchPeriod)
}
}
}
private func _startWatching(
handler: @escaping ([URL]) throws -> Void
) throws {
let descriptor = open(directoryURL.path, O_EVTONLY)
guard descriptor > 0 else {
throw ContainerizationError(.internalError, message: "cannot open \(directoryURL.path), descriptor=\(descriptor)")
}
do {
let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path)
try handler(files.map { directoryURL.appending(path: $0) })
} catch {
throw ContainerizationError(.internalError, message: "failed to run handler for \(directoryURL.path)")
}
log?.info("starting directory watcher", metadata: ["path": "\(directoryURL.path)"])
let dispatchSource = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: descriptor,
eventMask: [.delete, .write],
queue: monitorQueue
)
dispatchSource.setCancelHandler {
close(descriptor)
}
dispatchSource.setEventHandler { [weak self] in
guard let self else { return }
guard !dispatchSource.data.contains(.delete) else {
dispatchSource.cancel()
self.source.withLock { $0 = nil }
return
}
do {
let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path)
try handler(files.map { directoryURL.appending(path: $0) })
} catch {
self.log?.error(
"failed to run watch handler",
metadata: ["error": "\(error)", "path": "\(directoryURL.path)"])
}
}
source.withLock { $0 = dispatchSource }
dispatchSource.resume()
}
deinit {
self.task?.cancel()
source.withLock { $0?.cancel() }
}
}
+39 -22
View File
@@ -91,10 +91,15 @@ extension APIServer {
$0[$1.key.rawValue] = $1.value
}), log: log)
await withThrowingTaskGroup(of: Void.self) { group in
await withTaskGroup(of: Result<Void, Error>.self) { group in
group.addTask {
log.info("starting XPC server")
try await server.listen()
do {
try await server.listen()
return .success(())
} catch {
return .failure(error)
}
}
// start up host table DNS
@@ -111,35 +116,47 @@ extension APIServer {
"port": "\(Self.dnsPort)",
]
)
try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort)
do {
try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort)
return .success(())
} catch {
return .failure(error)
}
}
// start up realhost DNS
/*
group.addTask {
let localhostResolver = LocalhostDNSHandler(log: log)
do {
try localhostResolver.monitorResolvers()
let localhostResolver = LocalhostDNSHandler(log: log)
await localhostResolver.monitorResolvers()
let nxDomainResolver = NxDomainResolver()
let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver])
let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver)
let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log)
log.info(
"starting DNS resolver for localhost",
metadata: [
"host": "\(Self.listenAddress)",
"port": "\(Self.localhostDNSPort)",
]
)
try await dnsServer.run(host: Self.listenAddress, port: Self.localhostDNSPort)
return .success(())
} catch {
log.error("could not initialize resolver monitor", metadata: ["error": "\(error)"])
throw error
return .failure(error)
}
}
for await result in group {
switch result {
case .success():
continue
case .failure(let error):
log.error("API server task failed: \(error)")
}
let nxDomainResolver = NxDomainResolver()
let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver])
let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver)
let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log)
log.info(
"starting DNS resolver for localhost",
metadata: [
"host": "\(Self.listenAddress)",
"port": "\(Self.localhostDNSPort)",
]
)
try await dnsServer.run(host: Self.listenAddress, port: Self.localhostDNSPort)
}
*/
}
} catch {
log.error(
@@ -1,89 +0,0 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import Foundation
import Logging
public class DirectoryWatcher {
public let directoryURL: URL
private let monitorQueue: DispatchQueue
private var source: DispatchSourceFileSystemObject?
private let log: Logger
init(directoryURL: URL, log: Logger) {
self.directoryURL = directoryURL
self.monitorQueue = DispatchQueue(label: "monitor:\(directoryURL.path)")
self.log = log
}
public func startWatching(handler: @escaping ([URL]) throws -> Void) throws {
guard source == nil else {
throw ContainerizationError(.invalidState, message: "already watching on \(directoryURL.path)")
}
do {
let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path)
try handler(files.map { directoryURL.appending(path: $0) })
} catch {
throw ContainerizationError(.invalidState, message: "failed to start watching on \(directoryURL.path)")
}
log.info("starting directory watcher", metadata: ["path": "\(directoryURL.path)"])
let descriptor = open(directoryURL.path, O_EVTONLY)
let dispatchSource = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: descriptor,
eventMask: .write,
queue: monitorQueue
)
// Close the file descriptor when the source is cancelled
dispatchSource.setCancelHandler {
close(descriptor)
}
dispatchSource.setEventHandler { [weak self] in
guard let self else { return }
do {
let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path)
try handler(files.map { directoryURL.appending(path: $0) })
} catch {
self.log.error(
"failed to run DirectoryWatcher handler",
metadata: [
"error": "\(error)",
"path": "\(directoryURL.path)",
])
}
}
source = dispatchSource
dispatchSource.resume()
}
deinit {
guard let source else {
return
}
source.cancel()
}
}
@@ -15,50 +15,53 @@
//===----------------------------------------------------------------------===//
import ContainerAPIClient
import ContainerOS
import ContainerPersistence
import ContainerizationError
import DNS
import DNSServer
import Foundation
import Logging
import Synchronization
class LocalhostDNSHandler: DNSHandler {
actor LocalhostDNSHandler: DNSHandler {
private let ttl: UInt32
private let watcher: DirectoryWatcher
private var dns: [String: IPv4]
private let dns: Mutex<[String: IPv4]>
public init(resolversURL: URL = HostDNSResolver.defaultConfigPath, ttl: UInt32 = 5, log: Logger) {
self.ttl = ttl
self.watcher = DirectoryWatcher(directoryURL: resolversURL, log: log)
self.dns = [:]
self.dns = Mutex([:])
}
public func monitorResolvers() throws {
try self.watcher.startWatching { fileURLs in
var dns: [String: IPv4] = [:]
public func monitorResolvers() async {
await self.watcher.startWatching { fileURLs in
var dns: [String: String] = [:]
let regex = try Regex(HostDNSResolver.localhostOptionsRegex)
for file in fileURLs.filter({ $0.lastPathComponent.starts(with: HostDNSResolver.containerizationPrefix) }) {
let content = try String(contentsOf: file, encoding: .utf8)
if let match = content.firstMatch(of: regex),
let ipv4 = IPv4(String(match[1].substring ?? ""))
let ipv4 = (match[1].substring.map { String($0) })
{
let name = String(file.lastPathComponent.dropFirst(HostDNSResolver.containerizationPrefix.count))
dns[name + "."] = ipv4
}
}
self.dns = dns
self.dns.withLock { $0 = dns.compactMapValues { IPv4($0) } }
}
}
public func answer(query: Message) async throws -> Message? {
nonisolated public func answer(query: Message) async throws -> Message? {
let question = query.questions[0]
var record: ResourceRecord?
switch question.type {
case ResourceRecordType.host:
let dns = dns.withLock { $0 }
if let ip = dns[question.name] {
record = HostRecord<IPv4>(name: question.name, ttl: ttl, ip: ip)
}
@@ -0,0 +1,171 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerOS
import ContainerizationError
import DNSServer
import Foundation
import Testing
struct DirectoryWatcherTest {
let testUUID = UUID().uuidString
private var testDir: URL! {
let tempDir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
.appendingPathComponent(".clitests")
.appendingPathComponent(testUUID)
try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
return tempDir
}
private func withTempDir<T>(_ body: (URL) async throws -> T) async throws -> T {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: tempDir)
}
return try await body(tempDir)
}
private actor CreatedURLs {
nonisolated(unsafe) public var urls: [URL]
public init() {
self.urls = []
}
}
@Test func testWatchingExistingDirectory() async throws {
try await withTempDir { tempDir in
let watcher = DirectoryWatcher(directoryURL: tempDir, log: nil)
let createdURLs = CreatedURLs()
let name = "newFile"
await watcher.startWatching { [createdURLs] urls in
for url in urls where url.lastPathComponent == name {
createdURLs.urls.append(url)
}
}
try await Task.sleep(for: .milliseconds(100))
let newFile = tempDir.appendingPathComponent(name)
FileManager.default.createFile(atPath: newFile.path, contents: nil)
try await Task.sleep(for: .milliseconds(100))
#expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect new file")
#expect(createdURLs.urls.first!.lastPathComponent == name)
}
}
@Test func testWatchingNonExistingDirectory() async throws {
try await withTempDir { tempDir in
let uuid = UUID().uuidString
let childDir = tempDir.appendingPathComponent(uuid)
let watcher = DirectoryWatcher(directoryURL: childDir, log: nil)
let createdURLs = CreatedURLs()
let name = "newFile"
await watcher.startWatching { [createdURLs] urls in
for url in urls where url.lastPathComponent == name {
createdURLs.urls.append(url)
}
}
try await Task.sleep(for: .milliseconds(100))
try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true)
try await Task.sleep(for: DirectoryWatcher.watchPeriod)
let newFile = childDir.appendingPathComponent(name)
FileManager.default.createFile(atPath: newFile.path, contents: nil)
try await Task.sleep(for: .milliseconds(100))
#expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect parent directory")
#expect(createdURLs.urls.first!.lastPathComponent == name)
}
}
@Test func testWatchingNonExistingParent() async throws {
try await withTempDir { tempDir in
let parent = UUID().uuidString
let child = UUID().uuidString
let childDir = tempDir.appendingPathComponent(parent).appendingPathComponent(child)
let watcher = DirectoryWatcher(directoryURL: childDir, log: nil)
let createdURLs = CreatedURLs()
let name = "newFile"
await watcher.startWatching { urls in
for url in urls where url.lastPathComponent == name {
createdURLs.urls.append(url)
}
}
try await Task.sleep(for: .microseconds(100))
try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true)
try await Task.sleep(for: DirectoryWatcher.watchPeriod)
let newFile = childDir.appendingPathComponent(name)
FileManager.default.createFile(atPath: newFile.path, contents: nil)
try await Task.sleep(for: .milliseconds(100))
#expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect parent directory")
#expect(createdURLs.urls.first!.lastPathComponent == name)
}
}
@Test func testWatchingRecreatedDirectory() async throws {
try await withTempDir { tempDir in
let dir = tempDir.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let watcher = DirectoryWatcher(directoryURL: dir, log: nil)
let createdURLs = CreatedURLs()
let beforeDelete = "beforeDelete"
let afterDelete = "afterDelete"
await watcher.startWatching { [createdURLs] urls in
for url in urls
where url.lastPathComponent == beforeDelete || url.lastPathComponent == afterDelete {
createdURLs.urls.append(url)
}
}
try await Task.sleep(for: .milliseconds(100))
let file1 = dir.appendingPathComponent(beforeDelete)
FileManager.default.createFile(atPath: file1.path, contents: nil)
try await Task.sleep(for: .milliseconds(100))
try FileManager.default.removeItem(at: dir)
try await Task.sleep(for: .milliseconds(100))
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try await Task.sleep(for: .milliseconds(1000))
let file2 = dir.appendingPathComponent(afterDelete)
FileManager.default.createFile(atPath: file2.path, contents: nil)
try await Task.sleep(for: .milliseconds(100))
#expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect new file")
#expect(Set(createdURLs.urls.map { $0.lastPathComponent }) == Set([beforeDelete, afterDelete]))
}
}
}