mirror of
https://github.com/apple/container.git
synced 2026-09-22 07:35:36 +00:00
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:
@@ -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: [
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user