Files
container/Sources/ContainerizationOCI/Client/RegistryClient+Fetch.swift
T
Dmitry Kovba 9319eecf14 Prevent a leak detection crash when close() fails (#280)
When an image is broken, `container-core-images` may crash with:
```
Application Specific Information:
_NIOFileSystem/SystemFileHandle.swift:131: Fatal error: Leaking file descriptor: the handle for '/Users/Dmitry/Library/Application Support/com.apple.container/content/ingest/03144D38-D98D-4833-9E0C-60229BF653D9/.tmp-MIB1oc' MUST be closed or detached with 'close()' or 'detachUnsafeFileDescriptor()' before the final reference to the handle is dropped.

Thread 9 Crashed:
0   libswiftCore.dylib            	       0x1adbfe110 _assertionFailure(_:_:file:line:flags:) + 176
1   container-core-images         	       0x105773628 closure #1 in SystemFileHandle.deinit + 424 (SystemFileHandle.swift:131)
2   container-core-images         	       0x10577365c partial apply for closure #1 in SystemFileHandle.deinit + 20
3   container-core-images         	       0x104ebd994 closure #1 in LockStorage.withLockedValue<A>(_:) + 124 (NIOLock.swift:183)
4   container-core-images         	       0x104ebde38 partial apply for closure #1 in LockStorage.withLockedValue<A>(_:) + 52
5   container-core-images         	       0x104c387fc ManagedBuffer<>.withUnsafeMutablePointers<A, B>(_:) + 152
6   container-core-images         	       0x104ebd8ec LockStorage.withLockedValue<A>(_:) + 172 (NIOLock.swift:180)
7   container-core-images         	       0x104ebe088 NIOLockedValueBox.withLockedValue<A>(_:) + 104 (NIOLockedValueBox.swift:38)
8   container-core-images         	       0x105773438 SystemFileHandle.deinit + 112 (SystemFileHandle.swift:128)
9   container-core-images         	       0x105773684 SystemFileHandle.__deallocating_deinit + 28
10  libswiftCore.dylib            	       0x1adaeef50 _swift_release_dealloc + 56
11  libswiftCore.dylib            	       0x1adaefabc bool swift::RefCounts<swift::RefCountBitsT<(swift::RefCountInlinedness)1>>::doDecrementSlow<(swift::PerformDeinit)1>(swift::RefCountBitsT<(swift::RefCountInlinedness)1>, unsigned int) + 152
12  container-core-images         	       0x104b81aac RegistryClient.fetchBlob(name:descriptor:into:progress:) + 180 (RegistryClient+Fetch.swift:200)
13  container-core-images         	       0x104b9f481 protocol witness for ContentClient.fetchBlob(name:descriptor:into:progress:) in conformance RegistryClient + 1
14  container-core-images         	       0x1049b6b29 ImageStore.ImportOperation.fetchBlob(_:) + 1 (ImageStore+Import.swift:167)
15  container-core-images         	       0x1049b5c6d ImageStore.ImportOperation.fetch(_:) + 1 (ImageStore+Import.swift:153)
16  container-core-images         	       0x1049b4f69 closure #1 in closure #1 in ImageStore.ImportOperation.fetchAll(_:) + 1 (ImageStore+Import.swift:126)
17  container-core-images         	       0x1049bb08d partial apply for closure #1 in closure #1 in ImageStore.ImportOperation.fetchAll(_:) + 1
18  libswift_Concurrency.dylib    	       0x2945e05f9 completeTaskWithClosure(swift::AsyncContext*, swift::SwiftError*) + 1
```

Replacing the `SwiftNIO` file handle with the `Foundation` file handle
resolved the crash. However, this led to a worse performance:

```
Debug version:
SwiftNIO: 1:05s
Foundation: 1:18s (1.2 times worse)

Release version:
SwiftNIO: 0:34s
Foundation: 1:00s (1.8 times worse)
```

This PR attempts to prevent the crash without switching back to the
`Foundation` file handle.
2025-09-04 23:07:11 -07:00

238 lines
9.6 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved.
//
// 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 AsyncHTTPClient
import ContainerizationError
import ContainerizationExtras
import Crypto
import Foundation
import NIOFoundationCompat
#if os(macOS)
import NIOFileSystem
#endif
extension RegistryClient {
/// Resolve sends a HEAD request to the registry to find root manifest descriptor.
/// This descriptor serves as an entry point to retrieve resources from the registry.
public func resolve(name: String, tag: String) async throws -> Descriptor {
var components = base
// Make HEAD request to retrieve the digest header
components.path = "/v2/\(name)/manifests/\(tag)"
// The client should include an Accept header indicating which manifest content types it supports.
let mediaTypes = [
MediaTypes.dockerManifest,
MediaTypes.dockerManifestList,
MediaTypes.imageManifest,
MediaTypes.index,
"*/*",
]
let headers = [
("Accept", mediaTypes.joined(separator: ", "))
]
return try await request(components: components, method: .HEAD, headers: headers) { response in
guard response.status == .ok else {
let url = components.url?.absoluteString ?? "unknown"
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
throw Error.invalidStatus(url: url, response.status, reason: reason)
}
guard let digest = response.headers.first(name: "Docker-Content-Digest") else {
throw ContainerizationError(.invalidArgument, message: "Missing required header Docker-Content-Digest")
}
guard let type = response.headers.first(name: "Content-Type") else {
throw ContainerizationError(.invalidArgument, message: "Missing required header Content-Type")
}
guard let sizeStr = response.headers.first(name: "Content-Length") else {
throw ContainerizationError(.invalidArgument, message: "Missing required header Content-Length")
}
guard let size = Int64(sizeStr) else {
throw ContainerizationError(.invalidArgument, message: "Cannot convert \(sizeStr) to Int64")
}
return Descriptor(mediaType: type, digest: digest, size: size)
}
}
/// Fetch resource (either manifest or blob) to memory with JSON decoding.
public func fetch<T: Codable>(name: String, descriptor: Descriptor) async throws -> T {
var components = base
let manifestTypes = [
MediaTypes.dockerManifest,
MediaTypes.dockerManifestList,
MediaTypes.imageManifest,
MediaTypes.index,
]
let isManifest = manifestTypes.contains(where: { $0 == descriptor.mediaType })
let resource = isManifest ? "manifests" : "blobs"
components.path = "/v2/\(name)/\(resource)/\(descriptor.digest)"
let mediaType = descriptor.mediaType
if mediaType.isEmpty {
throw ContainerizationError(.invalidArgument, message: "Missing media type for descriptor \(descriptor.digest)")
}
let headers = [
("Accept", mediaType)
]
return try await requestJSON(components: components, headers: headers)
}
/// Fetch resource (either manifest or blob) to memory as raw `Data`.
public func fetchData(name: String, descriptor: Descriptor) async throws -> Data {
var components = base
let manifestTypes = [
MediaTypes.dockerManifest,
MediaTypes.dockerManifestList,
MediaTypes.imageManifest,
MediaTypes.index,
]
let isManifest = manifestTypes.contains(where: { $0 == descriptor.mediaType })
let resource = isManifest ? "manifests" : "blobs"
components.path = "/v2/\(name)/\(resource)/\(descriptor.digest)"
let mediaType = descriptor.mediaType
if mediaType.isEmpty {
throw ContainerizationError(.invalidArgument, message: "Missing media type for descriptor \(descriptor.digest)")
}
let headers = [
("Accept", mediaType)
]
return try await requestData(components: components, headers: headers)
}
/// Fetch a blob from remote registry.
/// This method is suitable for streaming data.
public func fetchBlob(
name: String,
descriptor: Descriptor,
closure: (Int64, HTTPClientResponse.Body) async throws -> Void
) async throws {
var components = base
components.path = "/v2/\(name)/blobs/\(descriptor.digest)"
let mediaType = descriptor.mediaType
if mediaType.isEmpty {
throw ContainerizationError(.invalidArgument, message: "Missing media type for descriptor \(descriptor.digest)")
}
let headers = [
("Accept", mediaType)
]
try await request(components: components, headers: headers) { response in
guard response.status == .ok else {
let url = components.url?.absoluteString ?? "unknown"
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
throw Error.invalidStatus(url: url, response.status, reason: reason)
}
// How many bytes to expect
guard let expectedBytes = response.headers.first(name: "Content-Length").flatMap(Int64.init) else {
throw ContainerizationError(.invalidArgument, message: "Missing required header Content-Length")
}
try await closure(expectedBytes, response.body)
}
}
#if os(macOS)
/// Fetch a blob from remote registry and write the contents into a file in the provided directory.
public func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest) {
var hasher = SHA256()
var received: Int64 = 0
let fs = NIOFileSystem.FileSystem.shared
let handle = try await fs.openFile(forWritingAt: FilePath(file.absolutePath()), options: .newFile(replaceExisting: true))
var writer = handle.bufferedWriter()
do {
try await self.fetchBlob(name: name, descriptor: descriptor) { (size, body) in
var itr = body.makeAsyncIterator()
while let buf = try await itr.next() {
let readBytes = Int64(buf.readableBytes)
received += readBytes
let written = try await writer.write(contentsOf: buf)
await progress?([
ProgressEvent(event: "add-size", value: written)
])
guard written == readBytes else {
throw ContainerizationError(
.internalError,
message: "Could not write \(readBytes) bytes to file \(file)"
)
}
hasher.update(data: buf.readableBytesView)
}
}
try await writer.flush()
try await handle.close()
} catch {
do {
try await handle.close()
} catch {
// Use `detachUnsafeFileDescriptor()` as suggested by the error message to prevent a leak detection crash when `close()` fails.
_ = try handle.detachUnsafeFileDescriptor()
}
throw error
}
let computedDigest = hasher.finalize()
return (received, computedDigest)
}
#else
/// Fetch a blob from remote registry and write the contents into a file in the provided directory.
public func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest) {
var hasher = SHA256()
var received: Int64 = 0
guard FileManager.default.createFile(atPath: file.path, contents: nil) else {
throw ContainerizationError(.internalError, message: "Cannot create file at path \(file.path)")
}
try await self.fetchBlob(name: name, descriptor: descriptor) { (size, body) in
let fd = try FileHandle(forWritingTo: file)
defer {
try? fd.close()
}
var itr = body.makeAsyncIterator()
while let buf = try await itr.next() {
let readBytes = Int64(buf.readableBytes)
received += readBytes
await progress?([
ProgressEvent(event: "add-size", value: readBytes)
])
try fd.write(contentsOf: buf.readableBytesView)
hasher.update(data: buf.readableBytesView)
}
}
let computedDigest = hasher.finalize()
return (received, computedDigest)
}
#endif
}