diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index e24bdf67..01ba82ae 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -26,6 +26,18 @@ import SystemPackage import struct ContainerizationOS.Terminal +private final class CopyOutProducerError: Sendable { + private let value = Mutex<(any Error)?>(nil) + + func store(_ error: any Error) { + value.withLock { $0 = error } + } + + func load() -> (any Error)? { + value.withLock { $0 } + } +} + /// `LinuxContainer` is an easy to use type for launching and managing the /// full lifecycle of a Linux container ran inside of a virtual machine. public final class LinuxContainer: Container, Sendable { @@ -1353,31 +1365,51 @@ extension LinuxContainer { let listener = try state.vm.listen(port) let (metadataStream, metadataCont) = AsyncStream.makeStream(of: Vminitd.CopyMetadata.self) + let producerError = CopyOutProducerError() + defer { + metadataCont.finish() + try? listener.finish() + } try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { - try await state.vm.withAgent { agent in - guard let vminitd = agent as? Vminitd else { - throw ContainerizationError(.unsupported, message: "copyOut requires Vminitd agent") - } - try await vminitd.copy( - direction: .copyOut, - guestPath: guestPath, - vsockPort: port, - onMetadata: { meta in - metadataCont.yield(meta) - metadataCont.finish() + do { + try await state.vm.withAgent { agent in + guard let vminitd = agent as? Vminitd else { + throw ContainerizationError(.unsupported, message: "copyOut requires Vminitd agent") } - ) + try await vminitd.copy( + direction: .copyOut, + guestPath: guestPath, + vsockPort: port, + onMetadata: { meta in + metadataCont.yield(meta) + metadataCont.finish() + } + ) + } + } catch { + // Guest validation can fail before either stream yields. + // Finish both so the sibling task can release the state lock. + producerError.store(error) + metadataCont.finish() + try? listener.finish() + throw error } } group.addTask { guard let metadata = await metadataStream.first(where: { _ in true }) else { + if let error = producerError.load() { + throw error + } throw ContainerizationError(.internalError, message: "copyOut: no metadata received") } guard let conn = await listener.first(where: { _ in true }) else { + if let error = producerError.load() { + throw error + } throw ContainerizationError(.internalError, message: "copyOut: vsock connection not established") } try listener.finish() diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 9c8edd88..3a527afd 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -2114,6 +2114,62 @@ extension IntegrationSuite { } } + func testCopyOutMissingSourceDoesNotBlockLifecycle() async throws { + let id = "test-copy-out-missing-source" + + let bs = try await bootstrap(id) + let hostDestination = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("missing-output.txt") + + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + do { + try await Timeout.run(seconds: 5) { + try await container.copyOut( + from: URL(filePath: "/does-not-exist"), + to: hostDestination + ) + } + throw IntegrationError.assert(msg: "copyOut should fail for a missing source") + } catch is CancellationError { + throw IntegrationError.assert(msg: "copyOut did not fail before the timeout") + } catch let error as IntegrationError { + throw error + } catch { + let message = String(describing: error) + guard !message.contains("copyOut: no metadata received"), + !message.contains("copyOut: vsock connection not established") + else { + throw IntegrationError.assert(msg: "copyOut replaced the guest error with '\(message)'") + } + } + + let exec = try await container.exec("after-missing-copy") { config in + config.arguments = ["true"] + } + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "container lifecycle remained blocked after copyOut failure") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + func testCopyLargeFile() async throws { let id = "test-copy-large-file" diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 440384ea..c24e3f2e 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -465,6 +465,7 @@ struct IntegrationSuite: AsyncParsableCommand { Test("container copy in file to missing directory fails", testCopyInFileToMissingDirectoryFails), Test("container copy in directory over existing file fails", testCopyInDirectoryOverExistingFileFails), Test("container copy out", testCopyOut), + Test("container copy out missing source does not block lifecycle", testCopyOutMissingSourceDoesNotBlockLifecycle), Test("container copy large file", testCopyLargeFile), Test("container copy in directory", testCopyInDirectory), Test("container copy out directory", testCopyOutDirectory),