Files
container/Tests/ContainerizationTests/LinuxProcessStdioTests.swift
T

267 lines
11 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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 ContainerizationExtras
import ContainerizationOCI
import Foundation
import Synchronization
import Testing
@testable import Containerization
#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#endif
struct LinuxProcessStdioTests {
private static let stdoutPort: UInt32 = 0x1000_0000
/// The guest is late, so the host's dial-back window expires first. The
/// error the caller sees must be the host's own timeout, naming the stream
/// and port — not the guest's `ECONNRESET`, which is only a consequence of
/// the host having stopped listening. Reporting the guest's symptom sent a
/// downstream consumer hunting a phantom port-allocator bug for weeks.
@Test func reportsTheHostTimeoutRatherThanTheGuestsReset() async throws {
let allocator = VsockPortAllocator(base: Self.stdoutPort)
let vm = StubVirtualMachineInstance()
let agent = StubVirtualMachineAgent(onCreateProcess: {
// The guest gets to its connect(2) after the host gave up, and
// cloud-hypervisor answers it with a reset. This used to be the
// only thing the caller was ever told. The delay is far longer
// than the 1s dial-back window below so the ordering can't invert
// under a loaded CI machine's scheduling; it is cancelled as soon
// as the timeout wins, so it costs nothing.
try await Task.sleep(for: .seconds(60))
throw ContainerizationError(
.internalError,
message: "createProcess: socket: error could not connect to socket 2:268435456 (Connection reset by peer)"
)
})
let stdio = IOUtil.setup(portAllocator: allocator, stdin: nil, stdout: DiscardWriter(), stderr: nil)
let process = LinuxProcess(
"late-guest",
containerID: "c",
spec: Self.spec(),
io: stdio,
portAllocator: allocator,
ociRuntimePath: nil,
agent: agent,
vm: vm,
logger: nil,
stdioTimeoutSeconds: 1
)
do {
try await process.start()
Issue.record("start() should have failed")
} catch let error as ContainerizationError {
#expect(error.isCode(.timeout))
#expect(error.message.contains("guest did not dial back for stdout"))
#expect(error.message.contains("vsock port \(Self.stdoutPort)"))
#expect(error.message.contains("within 1s"))
#expect(!"\(error)".contains("Connection reset by peer"))
}
}
/// A `createProcess` failure that has nothing to do with stdio must still
/// be reported as itself, and promptly — not swallowed by, or made to wait
/// out, the dial-back window.
@Test func reportsCreateProcessFailureWhenItComesFirst() async throws {
let allocator = VsockPortAllocator(base: Self.stdoutPort)
let vm = StubVirtualMachineInstance()
let agent = StubVirtualMachineAgent(onCreateProcess: {
throw ContainerizationError(.invalidArgument, message: "no such executable")
})
let stdio = IOUtil.setup(portAllocator: allocator, stdin: nil, stdout: DiscardWriter(), stderr: nil)
let process = LinuxProcess(
"bad-spec",
containerID: "c",
spec: Self.spec(),
io: stdio,
portAllocator: allocator,
ociRuntimePath: nil,
agent: agent,
vm: vm,
logger: nil,
stdioTimeoutSeconds: 300
)
let clock = ContinuousClock()
let started = clock.now
do {
try await process.start()
Issue.record("start() should have failed")
} catch let error as ContainerizationError {
#expect(error.message.contains("no such executable"))
}
// Must not have waited out the dial-back window: a correct start()
// cancels the stdio wait as soon as createProcess fails. The bound is
// deliberately far from both ends — a whole parallel test suite's
// scheduling delay is seconds, and the regression would be 300 — so
// this measures cancellation, not the CI machine's load.
#expect(clock.now - started < .seconds(60))
}
/// The happy path, plus the port accounting: a deleted process gives its
/// stdio ports back, which is what lets a pre-bound pool serve an
/// unbounded number of sequential processes.
@Test func releasesPortsOnDeleteSoTheyCanBeReused() async throws {
let allocator = VsockPortAllocator(base: Self.stdoutPort)
let vm = StubVirtualMachineInstance()
var pipeFds: [Int32] = [-1, -1]
let rc = pipeFds.withUnsafeMutableBufferPointer { buf -> Int32 in
guard let base = buf.baseAddress else { return -1 }
return pipe(base)
}
try #require(rc == 0)
let writeEnd = pipeFds[1]
let readEnd = pipeFds[0]
defer { _ = close(writeEnd) }
let agent = StubVirtualMachineAgent(
pid: 4242,
onCreateProcess: {
// Stand in for the guest dialing back on the stdout port.
guard let listener = vm.listener(forPort: Self.stdoutPort) else {
throw ContainerizationError(.internalError, message: "no listener for the stdout port")
}
_ = listener.yield(FileHandle(fileDescriptor: readEnd, closeOnDealloc: false))
}
)
let stdio = IOUtil.setup(portAllocator: allocator, stdin: nil, stdout: DiscardWriter(), stderr: nil)
let process = LinuxProcess(
"good",
containerID: "c",
spec: Self.spec(),
io: stdio,
portAllocator: allocator,
ociRuntimePath: nil,
agent: agent,
vm: vm,
logger: nil,
stdioTimeoutSeconds: 30
)
try await process.start()
#expect(process.pid == 4242)
try await process.delete()
#expect(allocator.allocate() == Self.stdoutPort)
}
private static func spec() -> ContainerizationOCI.Spec {
var spec = ContainerizationOCI.Spec()
spec.process = LinuxProcessConfiguration(arguments: ["/bin/true"]).toOCI()
return spec
}
}
// MARK: - Stubs
private final class DiscardWriter: Writer {
func write(_ data: Data) throws {}
func close() throws {}
}
/// Minimal `VirtualMachineInstance` that hands out real `VsockListener`s and
/// keeps them addressable, so a stub agent can play the guest's dial-back.
private final class StubVirtualMachineInstance: VirtualMachineInstance {
typealias Agent = StubVirtualMachineAgent
private let listeners = Mutex<[UInt32: VsockListener]>([:])
var state: VirtualMachineInstanceState { .running }
var mounts: [String: [AttachedFilesystem]] { [:] }
func listener(forPort port: UInt32) -> VsockListener? {
listeners.withLock { $0[port] }
}
func listen(_ port: UInt32) throws -> VsockListener {
let listener = VsockListener(port: port) { _ in }
listeners.withLock { $0[port] = listener }
return listener
}
func dialAgent() async throws -> StubVirtualMachineAgent {
throw ContainerizationError(.unsupported, message: "dialAgent")
}
func dial(_ port: UInt32) async throws -> FileHandle {
throw ContainerizationError(.unsupported, message: "dial")
}
func start() async throws {}
func stop() async throws {}
}
/// `VirtualMachineAgent` stub whose `createProcess` is injectable so a test
/// can decide whether the guest dials back, is late, or fails outright.
/// Everything the tests don't touch is a no-op.
private final class StubVirtualMachineAgent: VirtualMachineAgent {
private let pid: Int32
private let onCreateProcess: (@Sendable () async throws -> Void)?
init(pid: Int32 = 1, onCreateProcess: (@Sendable () async throws -> Void)? = nil) {
self.pid = pid
self.onCreateProcess = onCreateProcess
}
func createProcess(
id: String,
containerID: String?,
stdinPort: UInt32?,
stdoutPort: UInt32?,
stderrPort: UInt32?,
ociRuntimePath: String?,
configuration: ContainerizationOCI.Spec,
options: Data?
) async throws {
try await onCreateProcess?()
}
func startProcess(id: String, containerID: String?) async throws -> Int32 { pid }
func deleteProcess(id: String, containerID: String?) async throws {}
func close() async throws {}
func standardSetup() async throws {}
func filesystemOperation(operation: FilesystemOperation, path: String, containerID: String?) async throws {}
func getenv(key: String) async throws -> String { "" }
func setenv(key: String, value: String) async throws {}
func mount(_ mount: ContainerizationOCI.Mount) async throws {}
func umount(path: String, flags: Int32) async throws {}
func mkdir(path: String, all: Bool, perms: UInt32) async throws {}
@discardableResult
func kill(pid: Int32, signal: Int32) async throws -> Int32 { 0 }
func signalProcess(id: String, containerID: String?, signal: Int32) async throws {}
func resizeProcess(id: String, containerID: String?, columns: UInt32, rows: UInt32) async throws {}
func waitProcess(id: String, containerID: String?, timeoutInSeconds: Int64?) async throws -> Containerization.ExitStatus {
Containerization.ExitStatus(exitCode: 0)
}
func up(name: String, mtu: UInt32?) async throws {}
func down(name: String) async throws {}
func addressAdd(name: String, address: InterfaceAddress) async throws {}
func routeAddLink(name: String, route: LinkRoute) async throws {}
func routeAddDefault(name: String, route: DefaultRoute) async throws {}
func configureDNS(config: DNS, location: String) async throws {}
}