//===----------------------------------------------------------------------===// // 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. //===----------------------------------------------------------------------===// #if os(Linux) import ContainerizationError import ContainerizationOS import Foundation import Logging import Synchronization final class IOPair: Sendable { private let io: Mutex private let logger: Logger? private let reason: String private struct IO { let from: IOCloser let to: IOCloser let buffer: UnsafeMutableBufferPointer var closed: Bool var registeredFd: Int32? func drain() { let readFrom = OSFile(fd: from.fileDescriptor) let writeTo = OSFile(fd: to.fileDescriptor) while true { let r = readFrom.read(buffer) if r.read > 0 { let view = UnsafeMutableBufferPointer( start: buffer.baseAddress, count: r.read ) let w = writeTo.write(view) if w.wrote != r.read { return } } switch r.action { case .eof, .again, .error(_): return default: break } } } mutating func close(logger: Logger?) { if self.closed { return } // Try and drain IO first. self.drain() // Remove the fd from our global epoll instance first. if let fd = self.registeredFd { do { try ProcessSupervisor.default.unregisterFd(fd) } catch { logger?.error("failed to delete fd from epoll \(fd): \(error)") } self.registeredFd = nil } do { try self.from.close() } catch { logger?.error("failed to close reader fd for IOPair: \(error)") } do { try self.to.close() } catch { logger?.error("failed to close writer fd for IOPair: \(error)") } self.buffer.deallocate() self.closed = true } } init( readFrom: IOCloser, writeTo: IOCloser, reason: String, logger: Logger? = nil ) { let buffer = UnsafeMutableBufferPointer.allocate(capacity: Int(getpagesize())) self.io = Mutex( IO( from: readFrom, to: writeTo, buffer: buffer, closed: false, registeredFd: nil )) self.reason = reason self.logger = logger } func relay(ignoreHup: Bool = false) throws { self.logger?.info("setting up relay for \(reason)") let (readFromFd, writeToFd) = self.io.withLock { io in io.registeredFd = io.from.fileDescriptor return (io.from.fileDescriptor, io.to.fileDescriptor) } let readFrom = OSFile(fd: readFromFd) let writeTo = OSFile(fd: writeToFd) try ProcessSupervisor.default.registerFd(readFromFd, mask: .input) { mask in self.io.withLock { io in if io.closed { return } if mask.isHangup && !mask.readyToRead { self.logger?.debug("received EPOLLHUP with no EPOLLIN") if !ignoreHup { io.close(logger: self.logger) } return } // Loop so we drain fully. while true { let r = readFrom.read(io.buffer) if r.read > 0 { let view = UnsafeMutableBufferPointer( start: io.buffer.baseAddress, count: r.read ) let w = writeTo.write(view) if w.wrote != r.read { self.logger?.error("stopping relay: short write for stdio") io.close(logger: self.logger) return } } switch r.action { case .error(let errno): self.logger?.error("failed with errno \(errno) while reading for fd \(readFromFd)") fallthrough case .eof: self.logger?.debug("closing relay for \(readFromFd)") io.close(logger: self.logger) return case .again: if mask.isHangup && !ignoreHup { self.logger?.error("received EPOLLHUP and EAGAIN exiting") self.close() } return default: break } } } } } func close() { self.io.withLock { io in self.logger?.info("closing relay for \(reason)") io.close(logger: self.logger) } } } #endif