Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,46 +15,48 @@
//===----------------------------------------------------------------------===//

import ContainerizationError
import ContainerizationOS
import Foundation
import Logging

public class DirectoryWatcher {
public let directoryURL: URL

private let monitorQueue: DispatchQueue
private var parentSource: DispatchSourceFileSystemObject?
private var source: DispatchSourceFileSystemObject?

private let log: Logger
private let log: Logger?

init(directoryURL: URL, log: Logger) {
public 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)")
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(.invalidState, message: "failed to start watching on \(directoryURL.path)")
throw ContainerizationError(.internalError, message: "failed to run handler for \(directoryURL.path)")
}

log.info("starting directory watcher for \(directoryURL.path)")

let descriptor = open(directoryURL.path, O_EVTONLY)
log?.info("starting directory watcher for \(directoryURL.path)")

let dispatchSource = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: descriptor,
eventMask: .write,
queue: monitorQueue
)

// Close the file descriptor when the source is cancelled
dispatchSource.setCancelHandler {
close(descriptor)
}
Expand All @@ -66,19 +68,64 @@ public class DirectoryWatcher {
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)"])
self.log?.error("failed to run DirectoryWatcher handler", metadata: ["error": "\(error)", "path": "\(directoryURL.path)"])
}
}

source = dispatchSource
dispatchSource.resume()
}

deinit {
guard let source else {
public func startWatching(handler: @escaping ([URL]) throws -> Void) throws {
guard source == nil else {
throw ContainerizationError(.invalidState, message: "already watching on \(directoryURL.path)")
}

let parent = directoryURL.deletingLastPathComponent().resolvingSymlinksInPathWithPrivate()
guard parent.isDirectory else {
throw ContainerizationError(.invalidState, message: "expected \(parent.path) to be an existing directory")
}

guard !directoryURL.isSymlink else {
throw ContainerizationError(.invalidState, message: "expected \(directoryURL.path) not a symlink")
}

guard directoryURL.isDirectory else {
log?.info("no \(directoryURL.path), start watching \(parent.path)")

let descriptor = open(parent.path, O_EVTONLY)
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: descriptor,
eventMask: .write,
queue: monitorQueue)

source.setCancelHandler {
close(descriptor)
}

source.setEventHandler { [weak self] in
guard let self else { return }

if directoryURL.isDirectory {
do {
try _startWatching(handler: handler)
} catch {
log?.error("failed to start watching: \(error)")
}
source.cancel()
}
}

parentSource = source
source.resume()
return
}

source.cancel()
try _startWatching(handler: handler)
}

deinit {
parentSource?.cancel()
source?.cancel()
}
}
62 changes: 42 additions & 20 deletions Sources/Helpers/APIServer/APIServer+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,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
group.addTask {
Expand All @@ -104,30 +109,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)
try 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)
do {
let localhostResolver = LocalhostDNSHandler(log: log)
try 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 {
return .failure(error)
}
}

for await result in group {
switch result {
case .success():
continue
case .failure(let error):
log.error("API server task failed: \(error)")
}
}
*/
}
} catch {
log.error("\(commandName) failed", metadata: ["error": "\(error)"])
Expand Down
161 changes: 161 additions & 0 deletions Tests/DNSServerTests/DirectoryWatcherTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
//===----------------------------------------------------------------------===//
// 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 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 class CreatedURLs {
public var urls: [URL]

public init() {
self.urls = []
}

public func append(url: URL) {
urls.append(url)
}
}

@Test func testWatchingExistingDirectory() async throws {
try await withTempDir { tempDir in

let watcher = DirectoryWatcher(directoryURL: tempDir, log: nil)
let createdURLs = CreatedURLs()
let name = "newFile"

#expect(throws: Never.self) {
try watcher.startWatching { [createdURLs] urls in
for url in urls where url.lastPathComponent == name {
createdURLs.append(url: url)
}
}
}

try await Task.sleep(for: .milliseconds(500))
let newFile = tempDir.appendingPathComponent(name)
FileManager.default.createFile(atPath: newFile.path, contents: nil)
try await Task.sleep(for: .milliseconds(500))

#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"

#expect(throws: Never.self) {
try watcher.startWatching { [createdURLs] urls in
for url in urls where url.lastPathComponent == name {
createdURLs.append(url: url)
}
}
}

try await Task.sleep(for: .milliseconds(300))
try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true)

try await Task.sleep(for: .milliseconds(300))
let newFile = childDir.appendingPathComponent(name)
FileManager.default.createFile(atPath: newFile.path, contents: nil)
try await Task.sleep(for: .milliseconds(300))

#expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect parent directory")
#expect(createdURLs.urls.first!.lastPathComponent == name)

}

try await withTempDir { tempDir in
let parent = UUID().uuidString
let symlink = UUID().uuidString
let child = UUID().uuidString

let parentDir = tempDir.appendingPathComponent(parent)
let symlinkDir = tempDir.appendingPathComponent(symlink)
let childDir = symlinkDir.appendingPathComponent(child)

try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true)
try FileManager.default.createSymbolicLink(at: symlinkDir, withDestinationURL: parentDir)

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

#expect(throws: Never.self) {
try watcher.startWatching { [createdURLs] urls in
for url in urls where url.lastPathComponent == name {
createdURLs.append(url: url)
}
}
}

try await Task.sleep(for: .milliseconds(300))
try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true)

try await Task.sleep(for: .milliseconds(300))
let newFile = childDir.appendingPathComponent(name)
FileManager.default.createFile(atPath: newFile.path, contents: nil)
try await Task.sleep(for: .milliseconds(300))

#expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect symbolic 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)
#expect(throws: ContainerizationError.self, "directory watcher should fail if no parent") {
try watcher.startWatching { urls in }
}
}
}

}
Loading