chore: import upstream snapshot with attribution
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
Linux build / Linux compile check (push) Has been cancelled
Linux build / Determine Swift version (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:30 +08:00
commit 680845cb1c
445 changed files with 103779 additions and 0 deletions
@@ -0,0 +1,212 @@
//===----------------------------------------------------------------------===//
// 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 ArgumentParser
import Cgroup
import Containerization
import ContainerizationError
import ContainerizationOS
import Foundation
import GRPCCore
import Logging
import NIOCore
import NIOPosix
#if os(Linux)
#if canImport(Musl)
import Musl
#elseif canImport(Glibc)
import Glibc
#endif
import LCShim
#endif
public struct AgentCommand: AsyncParsableCommand {
public static let configuration = CommandConfiguration(
commandName: "agent",
abstract: "Run the vminitd agent daemon"
)
private static let foregroundEnvVar = "FOREGROUND"
public static let vsockPort = 1024
@OptionGroup var options: LogLevelOption
public init() {}
/// Bootstrap the vminitd environment and create an Initd server.
/// Handles mounts, cgroups, memory monitoring, and all pre-serve setup.
public static func bootstrap(options: LogLevelOption) async throws -> Initd {
let log = makeLogger(label: "vminitd", level: options.resolvedLogLevel())
try adjustLimits(log)
// When running under debug mode, launch vminitd as a sub process of pid1
// so that we get a chance to collect better logs and errors before pid1 exists
// and the kernel panics.
#if DEBUG
log.info("DEBUG mode active, checking FOREGROUND env var")
let environment = ProcessInfo.processInfo.environment
let foreground = environment[foregroundEnvVar]
log.info("checking for shim var \(foregroundEnvVar)=\(String(describing: foreground))")
if foreground == nil {
try runInForeground(log, logLevel: options.logLevel)
_exit(0)
}
log.info("FOREGROUND is set, running as subprocess, setting subreaper")
// Since we are not running as pid1 in this mode we must set ourselves
// as a subreaper so that all child processes are reaped by us and not
// passed onto our parent.
CZ_set_sub_reaper()
#endif
signal(SIGPIPE, SIG_IGN)
log.info("vminitd booting", metadata: versionMetadata())
// Set of mounts necessary to be mounted prior to taking any RPCs.
// 1. /proc as the sysctl rpc wouldn't make sense if it wasn't there (NOTE: This is done before this method
// due to Swift seemingly requiring /proc to be present for the async runtime to spin up).
// 2. /run as that is where we store container state.
// 3. /sys as we need it for /sys/fs/cgroup
// 4. /sys/fs/cgroup to add the agent to a cgroup, as well as containers later.
let mounts = [
ContainerizationOS.Mount(
type: "tmpfs",
source: "tmpfs",
target: "/run",
options: []
),
ContainerizationOS.Mount(
type: "sysfs",
source: "sysfs",
target: "/sys",
options: []
),
ContainerizationOS.Mount(
type: "cgroup2",
source: "none",
target: "/sys/fs/cgroup",
options: []
),
]
for mnt in mounts {
log.info("mounting \(mnt.target)")
try mnt.mount(createWithPerms: 0o755)
}
try Binfmt.mount()
let cgManager = Cgroup2Manager(
group: URL(filePath: "/vminitd"),
logger: log
)
try cgManager.create()
try cgManager.toggleAllAvailableControllers(enable: true)
// Set memory.high threshold to 80 MiB
let high: UInt64 = 80 * 1024 * 1024
// Set memory.low to 50 MiB to avoid reclaiming vminitd's memory
let low: UInt64 = 50 * 1024 * 1024
try cgManager.setMemoryHigh(bytes: high)
try cgManager.setMemoryLow(bytes: low)
try cgManager.addProcess(pid: getpid())
let memoryMonitor = try MemoryMonitor(
cgroupManager: cgManager,
threshold: high,
logger: log
) { [log] (currentUsage, highMark) in
log.warning(
"vminitd memory threshold exceeded",
metadata: [
"threshold_bytes": "\(high)",
"current_bytes": "\(currentUsage)",
"high_events_total": "\(highMark)",
])
}
let t = Thread { [log] in
do {
try memoryMonitor.run()
} catch {
log.error("memory monitor failed: \(error)")
}
}
t.start()
let eg = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let blockingPool = NIOThreadPool(numberOfThreads: 2)
blockingPool.start()
return Initd(log: log, group: eg, blockingPool: blockingPool)
}
public mutating func run() async throws {
let server = try await Self.bootstrap(options: options)
do {
server.log.info("serving vminitd API")
try await server.serve(port: Self.vsockPort)
server.log.info("vminitd API returned, syncing filesystems")
#if os(Linux)
sync()
#endif
} catch {
server.log.error("vminitd boot error \(error)")
#if os(Linux)
sync()
#endif
_exit(1)
}
}
private static func runInForeground(_ log: Logger, logLevel: String) throws {
log.info("running vminitd under pid1")
var command = Command("/sbin/vminitd", arguments: ["agent", "--log-level", logLevel])
command.attrs = .init(setsid: true)
command.stdin = .standardInput
command.stdout = .standardOutput
command.stderr = .standardError
command.environment = ["\(foregroundEnvVar)=1"]
try command.start()
let exitCode = try command.wait()
log.info("child process exited with code: \(exitCode)")
}
private static func adjustLimits(_ log: Logger) throws {
let nrOpen = try String(contentsOfFile: "/proc/sys/fs/nr_open", encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let max = UInt64(nrOpen) else {
throw POSIXError(.EINVAL)
}
log.debug("setting RLIMIT_NOFILE to \(max)")
guard CZ_setrlimit(CZ_RLIMIT_NOFILE, max, max) == 0 else {
throw POSIXError(.init(rawValue: errno)!)
}
}
}
#endif
@@ -0,0 +1,108 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOS
import Foundation
import Synchronization
struct ProcessSubscription: Sendable {
fileprivate let id: UUID
}
/// Protocol for running commands and waiting for their exit
protocol CommandRunner: Sendable {
func start(_ cmd: inout Command) throws -> ProcessSubscription
func wait(_ cmd: Command, subscription: ProcessSubscription) async throws -> Int32
}
struct DirectCommandRunner: CommandRunner {
func start(_ cmd: inout Command) throws -> ProcessSubscription {
try cmd.start()
return ProcessSubscription(id: UUID())
}
func wait(_ cmd: Command, subscription: ProcessSubscription) async throws -> Int32 {
var rus = rusage()
var ws = Int32()
let result = wait4(cmd.pid, &ws, 0, &rus)
guard result == cmd.pid else {
throw POSIXError(.init(rawValue: errno)!)
}
return Command.toExitStatus(ws)
}
}
final class ReaperCommandRunner: CommandRunner, Sendable {
private struct Subscriber {
let continuation: AsyncStream<(pid: pid_t, status: Int32)>.Continuation
let stream: AsyncStream<(pid: pid_t, status: Int32)>
}
private let subscribers: Mutex<[UUID: Subscriber]> = Mutex([:])
func start(_ cmd: inout Command) throws -> ProcessSubscription {
// Subscribe before starting to avoid missing fast exits
let id = UUID()
let (stream, continuation) = AsyncStream<(pid: pid_t, status: Int32)>.makeStream()
subscribers.withLock { subscribers in
subscribers[id] = Subscriber(continuation: continuation, stream: stream)
}
try cmd.start()
return ProcessSubscription(id: id)
}
func wait(_ cmd: Command, subscription: ProcessSubscription) async throws -> Int32 {
let pid = cmd.pid
let id = subscription.id
defer {
subscribers.withLock { subscribers in
subscribers[id]?.continuation.finish()
subscribers.removeValue(forKey: id)
}
}
// Get the stream from the subscriber
guard let stream = subscribers.withLock({ $0[id]?.stream }) else {
throw POSIXError(.ECHILD)
}
for await (exitPid, status) in stream {
if exitPid == pid {
return status
}
}
throw POSIXError(.ECHILD)
}
/// Broadcast exit to all subscribers
func notifyExit(pid: pid_t, status: Int32) {
subscribers.withLock { subscribers in
for subscriber in subscribers.values {
subscriber.continuation.yield((pid, status))
}
}
}
}
#endif
@@ -0,0 +1,70 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOS
import Foundation
/// Exit status information for a container process
struct ContainerExitStatus: Sendable {
var exitCode: Int32
var exitedAt: Date
}
/// Protocol for managing container processes
///
/// This protocol abstracts the underlying container runtime implementation,
/// allowing for different backends like vmexec or runc.
protocol ContainerProcess: Sendable {
/// Unique identifier for the container process
var id: String { get }
/// Process ID of the running container (nil if not started)
var pid: Int32? { get }
/// Start the container process
/// - Returns: The process ID of the started container
/// - Throws: If the process fails to start
func start() async throws -> Int32
/// Wait for the container process to exit
/// - Returns: Exit status information when the process exits
func wait() async -> ContainerExitStatus
/// Send a signal to the container process
/// - Parameter signal: The signal number to send
/// - Throws: If the signal cannot be sent
func kill(_ signal: Int32) async throws
/// Resize the terminal for the container process
/// - Parameter size: The new terminal size
/// - Throws: If the terminal cannot be resized or process doesn't have a terminal
func resize(size: Terminal.Size) throws
/// Close stdin for the container process
/// - Throws: If stdin cannot be closed
func closeStdin() throws
/// Delete the container process and clean up resources
/// - Throws: If cleanup fails
func delete() async throws
/// Set the exit status of the process.
func setExit(_ status: Int32)
}
#endif
@@ -0,0 +1,26 @@
//===----------------------------------------------------------------------===//
// 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)
struct HostStdio: Sendable {
let stdin: UInt32?
let stdout: UInt32?
let stderr: UInt32?
let terminal: Bool
}
#endif
@@ -0,0 +1,32 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOS
import Foundation
extension Socket: IOCloser {}
extension Terminal: IOCloser {
var fileDescriptor: Int32 {
self.handle.fileDescriptor
}
}
extension FileHandle: IOCloser {}
#endif
@@ -0,0 +1,37 @@
//===----------------------------------------------------------------------===//
// 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)
protocol IOCloser: Sendable {
var fileDescriptor: Int32 { get }
func close() throws
}
struct UnownedIOCloser: IOCloser {
private let inner: IOCloser
var fileDescriptor: Int32 { inner.fileDescriptor }
init(_ inner: IOCloser) {
self.inner = inner
}
func close() throws {}
}
#endif
+189
View File
@@ -0,0 +1,189 @@
//===----------------------------------------------------------------------===//
// 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<IO>
private let logger: Logger?
private let reason: String
private struct IO {
let from: IOCloser
let to: IOCloser
let buffer: UnsafeMutableBufferPointer<UInt8>
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<UInt8>.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
@@ -0,0 +1,113 @@
//===----------------------------------------------------------------------===//
// 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 ArgumentParser
import ContainerizationOS
import LCShim
#if canImport(Musl)
import Musl
private let _exit = Musl.exit
private let _kill = Musl.kill
#elseif canImport(Glibc)
import Glibc
private let _exit = Glibc.exit
private let _kill = Glibc.kill
#endif
/// A minimal init process that:
/// - Spawns and monitors a child process
/// - Forwards signals to the child
/// - Reaps zombie processes
/// - Exits with the child's exit code
public struct InitCommand: ParsableCommand {
public static let configuration = CommandConfiguration(
commandName: "init",
abstract: "Run as a minimal init process"
)
public init() {}
@Flag(name: .shortAndLong, help: "Send signals to the child's process group instead of just the child")
var processGroup: Bool = false
@Argument(help: "The command to run")
var command: String
@Argument(parsing: .captureForPassthrough, help: "Arguments for the command")
var arguments: [String] = []
/// Signals that should NOT be forwarded to the child.
private static let ignoredSignals: Set<Int32> = [
SIGCHLD, // We handle this for zombie reaping
SIGFPE, SIGILL, SIGSEGV, SIGBUS, SIGABRT, SIGTRAP, SIGSYS, // Synchronous signals
]
public mutating func run() throws {
// If we're not PID 1, register as a child subreaper so orphaned
// processes get reparented to us and we can reap them.
if getpid() != 1 {
CZ_set_sub_reaper()
}
// Block all signals. We'll handle them synchronously via sigtimedwait
var allSignals = sigset_t()
sigfillset(&allSignals)
sigprocmask(SIG_BLOCK, &allSignals, nil)
let resolvedCommand = Path.lookPath(command)?.path ?? command
var cmd = Command(resolvedCommand, arguments: arguments)
cmd.stdin = .standardInput
cmd.stdout = .standardOutput
cmd.stderr = .standardError
cmd.attrs = .init(setPGroup: true, setForegroundPGroup: true, setSignalDefault: true)
try cmd.start()
let childPid = cmd.pid
let signalTarget = processGroup ? -childPid : childPid
var timeout = timespec(tv_sec: 0, tv_nsec: 100_000_000)
// Handle signals and reap zombies
var childExitStatus: Int32?
while childExitStatus == nil {
var siginfo = siginfo_t()
let sig = sigtimedwait(&allSignals, &siginfo, &timeout)
if sig > 0 && !Self.ignoredSignals.contains(sig) {
_ = _kill(signalTarget, sig)
}
while true {
var status: Int32 = 0
let pid = waitpid(-1, &status, WNOHANG)
if pid <= 0 {
break
}
if pid == childPid {
childExitStatus = Command.toExitStatus(status)
}
}
}
_exit(childExitStatus ?? 1)
}
}
#endif
+117
View File
@@ -0,0 +1,117 @@
//===----------------------------------------------------------------------===//
// 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 ArgumentParser
import Foundation
import Logging
import Synchronization
public struct LogLevelOption: ParsableArguments {
@Option(name: .long, help: "Set the log level (trace, debug, info, notice, warning, error, critical)")
public var logLevel: String = "info"
public init() {}
public init(logLevel: String) {
self.logLevel = logLevel
}
public func resolvedLogLevel() -> Logger.Level {
switch logLevel.lowercased() {
case "trace":
return .trace
case "debug":
return .debug
case "info":
return .info
case "notice":
return .notice
case "warning":
return .warning
case "error":
return .error
case "critical":
return .critical
default:
return .info
}
}
}
private let _loggingBootstrapped = Mutex(false)
private let _versionMetadata = Mutex<Logger.Metadata>([:])
/// Set the version metadata logged on boot.
public func setVersionMetadata(_ metadata: Logger.Metadata) {
_versionMetadata.withLock { $0 = metadata }
}
func versionMetadata() -> Logger.Metadata {
_versionMetadata.withLock { $0 }
}
func makeLogger(label: String, level: Logger.Level) -> Logger {
_loggingBootstrapped.withLock { bootstrapped in
if !bootstrapped {
LoggingSystem.bootstrap { label in StderrLogHandler(label: label) }
bootstrapped = true
}
}
var log = Logger(label: label)
log.logLevel = level
return log
}
private struct StderrLogHandler: LogHandler {
let label: String
var logLevel: Logger.Level = .info
var metadata: Logger.Metadata = [:]
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
get { metadata[key] }
set { metadata[key] = newValue }
}
func log(
level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?,
source: String, file: String, function: String, line: UInt
) {
var merged = self.metadata
metadata?.forEach { merged[$0] = $1 }
let metaStr = merged.isEmpty ? "" : " \(merged.map { "\($0): \($1)" }.sorted().joined(separator: ", "))"
let ts = isoTimestamp()
let data = "\(ts) \(level) \(label):\(metaStr) \(message)\n".data(using: .utf8) ?? Data()
FileHandle.standardError.write(data)
}
func isoTimestamp() -> String {
let date = Date()
var time = time_t(date.timeIntervalSince1970)
var ms = Int(date.timeIntervalSince1970 * 1000) % 1000
if ms < 0 { ms += 1000 }
var tm = tm()
gmtime_r(&time, &tm)
let buf = withUnsafeTemporaryAllocation(of: CChar.self, capacity: 32) { ptr -> String in
strftime(ptr.baseAddress!, 32, "%Y-%m-%dT%H:%M:%S", &tm)
return String(cString: ptr.baseAddress!)
}
return String(format: "%@.%03dZ", buf, ms)
}
}
#endif
@@ -0,0 +1,286 @@
//===----------------------------------------------------------------------===//
// 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 Cgroup
import ContainerizationError
import ContainerizationOCI
import ContainerizationOS
import Foundation
import Logging
public actor ManagedContainer {
public let id: String
let initProcess: any ContainerProcess
private let cgroupManager: Cgroup2Manager
private let log: Logger
private let bundle: ContainerizationOCI.Bundle
private let needsCgroupCleanup: Bool
private var execs: [String: any ContainerProcess] = [:]
public var pid: Int32? {
self.initProcess.pid
}
init(
id: String,
stdio: HostStdio,
spec: ContainerizationOCI.Spec,
ociRuntimePath: String? = nil,
log: Logger
) async throws {
var cgroupsPath: String
if let cgPath = spec.linux?.cgroupsPath {
cgroupsPath = cgPath
} else {
cgroupsPath = "/container/\(id)"
}
let bundle = try ContainerizationOCI.Bundle.create(
path: Self.craftBundlePath(id: id),
spec: spec
)
log.debug("created bundle with spec \(spec)")
let cgManager = Cgroup2Manager(
group: URL(filePath: cgroupsPath),
logger: log
)
try cgManager.create()
do {
try cgManager.toggleAllAvailableControllers(enable: true)
let initProcess: any ContainerProcess
if let runtimePath = ociRuntimePath {
// Use runc runtime
let runc = ProcessSupervisor.default.getRuncWithReaper(
Runc(
command: runtimePath,
root: "/run/runc"
)
)
initProcess = try RuncProcess(
id: id,
stdio: stdio,
bundle: bundle,
runc: runc,
log: log
)
self.needsCgroupCleanup = false
log.info("created runc init process with runtime: \(runtimePath)")
} else {
// Use vmexec runtime
initProcess = try ManagedProcess(
id: id,
stdio: stdio,
bundle: bundle,
owningPid: nil,
log: log
)
self.needsCgroupCleanup = true
log.info("created vmexec init process")
}
self.cgroupManager = cgManager
self.initProcess = initProcess
self.id = id
self.bundle = bundle
self.log = log
} catch {
try? cgManager.delete()
throw error
}
}
}
extension ManagedContainer {
// removeCgroupWithRetry will remove a cgroup path handling EAGAIN and EBUSY errors and
// retrying the remove after an exponential timeout
private func removeCgroupWithRetry() async throws {
var delay = 10 // 10ms
let maxRetries = 5
for i in 0..<maxRetries {
if i != 0 {
try await Task.sleep(for: .milliseconds(delay))
delay *= 2
}
do {
try self.cgroupManager.delete(force: true)
return
} catch let error as Cgroup2Manager.Error {
guard case .errno(let errnoValue, let message) = error,
errnoValue == EBUSY || errnoValue == EAGAIN
else {
throw error
}
self.log.warning(
"cgroup deletion failed with EBUSY/EAGAIN, retrying",
metadata: [
"attempt": "\(i + 1)",
"delay": "\(delay)",
"errno": "\(errnoValue)",
"context": "\(message)",
])
continue
}
}
throw ContainerizationError(
.internalError,
message: "cgroups: unable to remove cgroup after \(maxRetries) retries"
)
}
private func ensureExecExists(_ id: String) throws {
if self.execs[id] == nil {
throw ContainerizationError(
.invalidState,
message: "exec \(id) does not exist in container \(self.id)"
)
}
}
func createExec(
id: String,
stdio: HostStdio,
process: ContainerizationOCI.Process
) throws {
log.debug("creating exec process with \(process)")
// Write the process config to the bundle, and pass this on
// over to ManagedProcess to deal with.
try self.bundle.createExecSpec(
id: id,
process: process
)
let process = try ManagedProcess(
id: id,
stdio: stdio,
bundle: self.bundle,
owningPid: self.initProcess.pid,
log: self.log
)
self.execs[id] = process
}
func start(execID: String) async throws -> Int32 {
let proc = try self.getExecOrInit(execID: execID)
return try await ProcessSupervisor.default.start(process: proc)
}
func wait(execID: String) async throws -> ContainerExitStatus {
let proc = try self.getExecOrInit(execID: execID)
return await proc.wait()
}
func kill(execID: String, _ signal: Int32) async throws {
let proc = try self.getExecOrInit(execID: execID)
try await proc.kill(signal)
}
func resize(execID: String, size: Terminal.Size) throws {
let proc = try self.getExecOrInit(execID: execID)
try proc.resize(size: size)
}
func closeStdin(execID: String) throws {
let proc = try self.getExecOrInit(execID: execID)
try proc.closeStdin()
}
func deleteExec(id: String) throws {
try ensureExecExists(id)
do {
try self.bundle.deleteExecSpec(id: id)
} catch {
self.log.error("failed to remove exec spec from filesystem: \(error)")
}
self.execs.removeValue(forKey: id)
}
func delete() async throws {
// Delete the init process if it's a RuncProcess
try await self.initProcess.delete()
// Delete the bundle and cgroup
try self.bundle.delete()
if self.needsCgroupCleanup {
try await self.removeCgroupWithRetry()
}
}
func stats(_ categories: Cgroup2StatsCategory = .all) throws -> Cgroup2Stats {
try self.cgroupManager.stats(categories)
}
func getMemoryEvents() throws -> MemoryEvents {
try self.cgroupManager.getMemoryEvents()
}
func getExecOrInit(execID: String) throws -> any ContainerProcess {
if execID == self.id {
return self.initProcess
}
guard let proc = self.execs[execID] else {
throw ContainerizationError(
.invalidState,
message: "exec \(execID) does not exist in container \(self.id)"
)
}
return proc
}
}
extension ContainerizationOCI.Bundle {
func createExecSpec(id: String, process: ContainerizationOCI.Process) throws {
let specDir = self.path.appending(path: "execs/\(id)")
let fm = FileManager.default
try fm.createDirectory(
atPath: specDir.path,
withIntermediateDirectories: true
)
let specData = try JSONEncoder().encode(process)
let processConfigPath = specDir.appending(path: "process.json")
try specData.write(to: processConfigPath)
}
func getExecSpecPath(id: String) -> URL {
self.path.appending(path: "execs/\(id)/process.json")
}
func deleteExecSpec(id: String) throws {
let specDir = self.path.appending(path: "execs/\(id)")
let fm = FileManager.default
try fm.removeItem(at: specDir)
}
}
extension ManagedContainer {
static func craftBundlePath(id: String) -> URL {
URL(fileURLWithPath: "/run/container").appending(path: id)
}
}
#endif
@@ -0,0 +1,348 @@
//===----------------------------------------------------------------------===//
// 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 Cgroup
import Containerization
import ContainerizationError
import ContainerizationOCI
import ContainerizationOS
import Foundation
import Logging
import Synchronization
final class ManagedProcess: ContainerProcess, Sendable {
// swiftlint: disable type_name
protocol IO {
func attach(pid: Int32, fd: Int32) throws
func start(process: inout Command) throws
func resize(size: Terminal.Size) throws
func close() throws
func closeStdin() throws
func closeAfterExec() throws
}
// swiftlint: enable type_name
private struct State {
init(io: IO) {
self.io = io
}
let io: IO
var waiters: [CheckedContinuation<ContainerExitStatus, Never>] = []
var exitStatus: ContainerExitStatus? = nil
var pid: Int32?
}
private static let ackPid = "AckPid"
private static let ackConsole = "AckConsole"
let id: String
private let log: Logger
private let command: Command
private let state: Mutex<State>
private let owningPid: Int32?
private let ackPipe: Pipe
private let syncPipe: Pipe
private let errorPipe: Pipe
private let terminal: Bool
private let bundle: ContainerizationOCI.Bundle
var pid: Int32? {
self.state.withLock {
$0.pid
}
}
init(
id: String,
stdio: HostStdio,
bundle: ContainerizationOCI.Bundle,
owningPid: Int32? = nil,
log: Logger
) throws {
self.id = id
var log = log
log[metadataKey: "id"] = "\(id)"
self.log = log
self.owningPid = owningPid
let syncPipe = Pipe()
try syncPipe.setCloexec()
self.syncPipe = syncPipe
let ackPipe = Pipe()
try ackPipe.setCloexec()
self.ackPipe = ackPipe
let errorPipe = Pipe()
try errorPipe.setCloexec()
self.errorPipe = errorPipe
let args: [String]
if let owningPid {
args = [
"exec",
"--parent-pid",
"\(owningPid)",
"--process-path",
bundle.getExecSpecPath(id: id).path,
]
} else {
args = ["run", "--bundle-path", bundle.path.path]
}
var command = Command(
"/sbin/vmexec",
arguments: args,
extraFiles: [
syncPipe.fileHandleForWriting,
ackPipe.fileHandleForReading,
errorPipe.fileHandleForWriting,
]
)
var io: IO
if stdio.terminal {
log.info("setting up terminal I/O")
let attrs = Command.Attrs(setsid: false, setctty: false)
command.attrs = attrs
io = try TerminalIO(
stdio: stdio,
log: log
)
} else {
command.attrs = .init(setsid: false)
io = StandardIO(
stdio: stdio,
log: log
)
}
log.info("starting I/O")
// Setup IO early. We expect the host to be listening already.
try io.start(process: &command)
self.command = command
self.terminal = stdio.terminal
self.bundle = bundle
self.state = Mutex(State(io: io))
}
}
extension ManagedProcess {
func start() async throws -> Int32 {
do {
return try self.state.withLock {
log.info(
"starting managed process",
metadata: [
"id": "\(id)"
])
// Start the underlying process.
try command.start()
defer {
try? self.ackPipe.fileHandleForWriting.close()
try? self.syncPipe.fileHandleForReading.close()
try? self.ackPipe.fileHandleForReading.close()
try? self.syncPipe.fileHandleForWriting.close()
try? self.errorPipe.fileHandleForWriting.close()
}
// Close our side of any pipes.
try $0.io.closeAfterExec()
try self.ackPipe.fileHandleForReading.close()
try self.syncPipe.fileHandleForWriting.close()
try self.errorPipe.fileHandleForWriting.close()
let size = MemoryLayout<Int32>.size
guard let piddata = try syncPipe.fileHandleForReading.read(upToCount: size) else {
throw ContainerizationError(.internalError, message: "no PID data from sync pipe")
}
guard piddata.count == size else {
throw ContainerizationError(.internalError, message: "invalid payload")
}
let pid = piddata.withUnsafeBytes { ptr in
ptr.load(as: Int32.self)
}
log.info(
"got back pid data",
metadata: [
"pid": "\(pid)"
])
$0.pid = pid
// This should probably happen in vmexec, but we don't need to set any cgroup
// toggles so the problem is much simpler to just do it here.
if let owningPid {
let cgManager = try Cgroup2Manager.loadFromPid(pid: owningPid)
try cgManager.addProcess(pid: pid)
}
log.info(
"sending pid acknowledgement",
metadata: [
"pid": "\(pid)"
])
try self.ackPipe.fileHandleForWriting.write(contentsOf: Self.ackPid.data(using: .utf8)!)
if self.terminal {
log.info(
"wait for PTY FD",
metadata: [
"id": "\(id)"
])
// Wait for a new write that will contain the pty fd if we asked for one.
guard let ptyFd = try self.syncPipe.fileHandleForReading.read(upToCount: size) else {
throw ContainerizationError(
.internalError,
message: "no PTY data from sync pipe"
)
}
let fd = ptyFd.withUnsafeBytes { ptr in
ptr.load(as: Int32.self)
}
log.info(
"received PTY FD from container, attaching",
metadata: [
"id": "\(id)"
])
try $0.io.attach(pid: pid, fd: fd)
try self.ackPipe.fileHandleForWriting.write(contentsOf: Self.ackConsole.data(using: .utf8)!)
}
// Wait for the errorPipe to close (after exec).
if let errorData = try? self.errorPipe.fileHandleForReading.readToEnd(),
let errorString = String(data: errorData, encoding: .utf8),
!errorString.isEmpty
{
throw ContainerizationError(
.internalError,
message: "vmexec error: \(errorString.trimmingCharacters(in: .whitespacesAndNewlines))"
)
}
log.info(
"started managed process",
metadata: [
"pid": "\(pid)",
"id": "\(id)",
])
return pid
}
} catch {
if let errorData = try? self.errorPipe.fileHandleForReading.readToEnd(),
let errorString = String(data: errorData, encoding: .utf8),
!errorString.isEmpty
{
throw ContainerizationError(
.internalError,
message: "vmexec error: \(errorString.trimmingCharacters(in: .whitespacesAndNewlines))",
cause: error
)
}
throw error
}
}
func setExit(_ status: Int32) {
self.state.withLock { state in
self.log.info(
"managed process exit",
metadata: [
"status": "\(status)"
])
let exitStatus = ContainerExitStatus(exitCode: status, exitedAt: Date.now)
state.exitStatus = exitStatus
do {
try state.io.close()
} catch {
self.log.error("failed to close I/O for process: \(error)")
}
for waiter in state.waiters {
waiter.resume(returning: exitStatus)
}
self.log.debug("\(state.waiters.count) managed process waiters signaled")
state.waiters.removeAll()
}
}
/// Wait on the process to exit
func wait() async -> ContainerExitStatus {
await withCheckedContinuation { cont in
self.state.withLock {
if let status = $0.exitStatus {
cont.resume(returning: status)
return
}
$0.waiters.append(cont)
}
}
}
func kill(_ signal: Int32) async throws {
try self.state.withLock {
guard let pid = $0.pid else {
throw ContainerizationError(.invalidState, message: "process PID is required")
}
guard $0.exitStatus == nil else {
return
}
self.log.info("sending signal \(signal) to process \(pid)")
guard Foundation.kill(pid, signal) == 0 else {
throw POSIXError.fromErrno()
}
}
}
func resize(size: Terminal.Size) throws {
try self.state.withLock {
guard $0.exitStatus == nil else {
return
}
try $0.io.resize(size: size)
}
}
func closeStdin() throws {
let io = self.state.withLock { $0.io }
try io.closeStdin()
}
func delete() async throws {
// vmexec doesn't require explicit cleanup - the process is cleaned up
// when it exits and IO is closed via setExit()
}
}
#endif
@@ -0,0 +1,158 @@
//===----------------------------------------------------------------------===//
// 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 Cgroup
import Foundation
import Logging
#if canImport(Musl)
import Musl
#elseif canImport(Glibc)
import Glibc
#endif
package final class MemoryMonitor: Sendable {
private static let inotifyEventSize = 0x10
private let cgroupManager: Cgroup2Manager
private let threshold: UInt64
private let logger: Logger
private let inotifyFd: Int32
private let watchDescriptor: Int32
private let onThresholdExceeded: @Sendable (UInt64, UInt64) -> Void
package init(
cgroupManager: Cgroup2Manager,
threshold: UInt64,
logger: Logger,
onThresholdExceeded: @escaping @Sendable (UInt64, UInt64) -> Void
) throws {
self.cgroupManager = cgroupManager
self.threshold = threshold
self.logger = logger
self.onThresholdExceeded = onThresholdExceeded
let fd = inotify_init()
guard fd != -1 else {
throw Error.inotifyInit(errno: errno)
}
self.inotifyFd = fd
let eventsPath = cgroupManager.getMemoryEventsPath()
let wd = inotify_add_watch(
inotifyFd,
eventsPath,
UInt32(IN_MODIFY)
)
guard wd != -1 else {
close(fd)
throw Error.inotifyAddWatch(errno: errno, path: eventsPath)
}
self.watchDescriptor = wd
}
/// Run the monitoring loop. Call this from a dedicated thread.
/// This function blocks until an error occurs.
package func run() throws {
let eventsPath = cgroupManager.getMemoryEventsPath()
logger.info(
"Started memory monitoring",
metadata: [
"threshold_bytes": "\(threshold)",
"events_path": "\(eventsPath)",
])
// Read initial state
var highCountMax: UInt64 = 0
do {
let events = try cgroupManager.getMemoryEvents()
highCountMax = events.high
} catch {
throw Error.readMemoryEvents(error: error)
}
let bufSize = Self.inotifyEventSize * 10
var buffer = [UInt8](repeating: 0, count: bufSize)
while true {
let bytesRead = buffer.withUnsafeMutableBytes { ptr in
read(inotifyFd, ptr.baseAddress!, bufSize)
}
if bytesRead < 0 {
if errno == EINTR {
continue
}
throw Error.readFailed(errno: errno)
}
do {
let events = try cgroupManager.getMemoryEvents()
if events.high > highCountMax {
highCountMax = events.high
let stats = try cgroupManager.stats(.memory)
let currentUsage = stats.memory?.usage ?? 0
onThresholdExceeded(currentUsage, events.high)
}
if events.oom > 0 || events.oomKill > 0 {
logger.error(
"OOM events detected",
metadata: [
"oom_events": "\(events.oom)",
"oom_kill_events": "\(events.oomKill)",
])
}
} catch {
throw Error.readMemoryEvents(error: error)
}
}
}
deinit {
inotify_rm_watch(inotifyFd, watchDescriptor)
close(inotifyFd)
}
}
extension MemoryMonitor {
package enum Error: Swift.Error, CustomStringConvertible {
case inotifyInit(errno: Int32)
case inotifyAddWatch(errno: Int32, path: String)
case readFailed(errno: Int32)
case readMemoryEvents(error: Swift.Error)
package var description: String {
switch self {
case .inotifyInit(let errno):
return "failed to initialize inotify: errno \(errno)"
case .inotifyAddWatch(let errno, let path):
return "failed to add inotify watch on \(path): errno \(errno)"
case .readFailed(let errno):
return "failed to read inotify events: errno \(errno)"
case .readMemoryEvents(let error):
return "failed to read memory events: \(error)"
}
}
}
}
#endif
@@ -0,0 +1,106 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import LCShim
extension OSFile {
struct SpliceFile: Sendable {
fileprivate var file: OSFile
fileprivate var offset: Int
fileprivate let pipe = Pipe()
var fileDescriptor: Int32 {
file.fileDescriptor
}
var reader: Int32 {
pipe.fileHandleForReading.fileDescriptor
}
var writer: Int32 {
pipe.fileHandleForWriting.fileDescriptor
}
init(fd: Int32) {
self.file = OSFile(fd: fd)
self.offset = 0
}
init(handle: FileHandle) {
self.file = OSFile(handle: handle)
self.offset = 0
}
init(from: OSFile, withOffset: Int = 0) {
self.file = from
self.offset = withOffset
}
func close() throws {
try self.file.close()
}
}
static func splice(from: inout SpliceFile, to: inout SpliceFile, count: Int = 1 << 16) throws -> (read: Int, wrote: Int, action: IOAction) {
let fromOffset = from.offset
let toOffset = to.offset
while true {
while (from.offset - to.offset) < count {
let toRead = count - (from.offset - to.offset)
let bytesRead = LCShim.splice(from.fileDescriptor, nil, to.writer, nil, toRead, UInt32(bitPattern: LCShim.SPLICE_F_MOVE | LCShim.SPLICE_F_NONBLOCK))
if bytesRead == -1 {
if errno != EAGAIN && errno != EIO {
throw POSIXError(.init(rawValue: errno)!)
}
break
}
if bytesRead == 0 {
return (0, 0, .eof)
}
from.offset += bytesRead
if bytesRead < toRead {
break
}
}
if from.offset == to.offset {
return (from.offset - fromOffset, to.offset - toOffset, .success)
}
while to.offset < from.offset {
let toWrite = from.offset - to.offset
let bytesWrote = LCShim.splice(to.reader, nil, to.fileDescriptor, nil, toWrite, UInt32(bitPattern: LCShim.SPLICE_F_MOVE | LCShim.SPLICE_F_NONBLOCK))
if bytesWrote == -1 {
if errno != EAGAIN && errno != EIO {
throw POSIXError(.init(rawValue: errno)!)
}
break
}
to.offset += bytesWrote
if bytesWrote == 0 {
return (from.offset - fromOffset, to.offset - toOffset, .brokenPipe)
}
if bytesWrote < toWrite {
break
}
}
}
}
}
#endif
+132
View File
@@ -0,0 +1,132 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
struct OSFile: Sendable {
enum IOAction: Equatable {
case eof
case again
case success
case brokenPipe
case error(_ errno: Int32)
}
private let fd: Int32
var closed: Bool {
Foundation.fcntl(fd, F_GETFD) == -1 && errno == EBADF
}
var fileDescriptor: Int32 { fd }
init(fd: Int32) {
self.fd = fd
}
init(handle: FileHandle) {
self.fd = handle.fileDescriptor
}
func close() throws {
guard Foundation.close(self.fd) == 0 else {
throw POSIXError(.init(rawValue: errno)!)
}
}
func read(_ buffer: UnsafeMutableBufferPointer<UInt8>) -> (read: Int, action: IOAction) {
if buffer.count == 0 {
return (0, .success)
}
var bytesRead: Int = 0
while true {
let n = Foundation.read(
self.fd,
buffer.baseAddress!.advanced(by: bytesRead),
buffer.count - bytesRead
)
if n == -1 {
if errno == EAGAIN || errno == EIO {
return (bytesRead, .again)
}
return (bytesRead, .error(errno))
}
if n == 0 {
return (bytesRead, .eof)
}
bytesRead += n
if bytesRead < buffer.count {
continue
}
return (bytesRead, .success)
}
}
func write(_ buffer: UnsafeMutableBufferPointer<UInt8>) -> (wrote: Int, action: IOAction) {
if buffer.count == 0 {
return (0, .success)
}
var bytesWrote: Int = 0
while true {
let n = Foundation.write(
self.fd,
buffer.baseAddress!.advanced(by: bytesWrote),
buffer.count - bytesWrote
)
if n == -1 {
if errno == EAGAIN || errno == EIO {
return (bytesWrote, .again)
}
return (bytesWrote, .error(errno))
}
if n == 0 {
return (bytesWrote, .brokenPipe)
}
bytesWrote += n
if bytesWrote < buffer.count {
continue
}
return (bytesWrote, .success)
}
}
static func pipe() -> (read: Self, write: Self) {
let pipe = Pipe()
return (Self(handle: pipe.fileHandleForReading), Self(handle: pipe.fileHandleForWriting))
}
static func open(path: String) throws -> Self {
try open(path: path, mode: O_RDONLY | O_CLOEXEC)
}
static func open(path: String, mode: Int32) throws -> Self {
let fd = Foundation.open(path, mode)
if fd < 0 {
throw POSIXError(.init(rawValue: errno)!)
}
return Self(fd: fd)
}
}
#endif
@@ -0,0 +1,82 @@
//===----------------------------------------------------------------------===//
// 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 ArgumentParser
import Dispatch
import Logging
#if canImport(Musl)
import Musl
private let _exit = Musl.exit
#elseif canImport(Glibc)
import Glibc
private let _exit = Glibc.exit
#endif
public struct PauseCommand: ParsableCommand {
public static let configuration = CommandConfiguration(
commandName: "pause",
abstract: "Run the pause container"
)
public init() {}
@OptionGroup var options: LogLevelOption
public mutating func run() throws {
let log = makeLogger(label: "pause", level: options.resolvedLogLevel())
if getpid() != 1 {
log.warning("pause should be the first process")
}
// NOTE: For whatever reason, using signal() for the below causes a swift compiler issue.
// Can revert whenever that is understood.
let sigintSource = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSource.setEventHandler {
log.info("Shutting down, got SIGINT")
_exit(0)
}
sigintSource.resume()
let sigtermSource = DispatchSource.makeSignalSource(signal: SIGTERM)
sigtermSource.setEventHandler {
log.info("Shutting down, got SIGTERM")
_exit(0)
}
sigtermSource.resume()
let sigchldSource = DispatchSource.makeSignalSource(signal: SIGCHLD)
sigchldSource.setEventHandler {
var status: Int32 = 0
while waitpid(-1, &status, WNOHANG) > 0 {}
}
sigchldSource.resume()
log.info("pause container running, waiting for signals...")
while true {
_ = pause()
}
log.error("Error: infinite loop terminated")
_exit(42)
}
}
#endif
@@ -0,0 +1,168 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOS
import Foundation
import Logging
import Synchronization
final class ProcessSupervisor: Sendable {
private let poller: Epoll
private let handlers = Mutex<[Int32: @Sendable (Epoll.Mask) -> Void]>([:])
private let queue: DispatchQueue
// `DispatchSourceSignal` is thread-safe.
private nonisolated(unsafe) let source: DispatchSourceSignal
private struct State {
var processes: [any ContainerProcess] = []
var log: Logger?
}
private let state: Mutex<State>
private let reaperCommandRunner = ReaperCommandRunner()
func setLog(_ log: Logger?) {
self.state.withLock { $0.log = log }
}
static let `default` = ProcessSupervisor()
private init() {
let queue = DispatchQueue(label: "process-supervisor")
self.source = DispatchSource.makeSignalSource(signal: SIGCHLD, queue: queue)
self.queue = queue
self.poller = try! Epoll()
self.state = Mutex(State())
let t = Thread {
while true {
guard let events = self.poller.wait() else {
return
}
if events.isEmpty {
return
}
for event in events {
let handler = self.handlers.withLock { $0[event.fd] }
handler?(event.mask)
}
}
}
t.start()
}
/// Register a file descriptor for epoll monitoring with a handler.
///
/// The handler is stored before the fd is added to epoll, ensuring no
/// events are missed.
func registerFd(
_ fd: Int32,
mask: Epoll.Mask = [.input, .output],
handler: @escaping @Sendable (Epoll.Mask) -> Void
) throws {
self.handlers.withLock { $0[fd] = handler }
do {
try self.poller.add(fd, mask: mask)
} catch {
self.handlers.withLock { _ = $0.removeValue(forKey: fd) }
throw error
}
}
/// Remove a file descriptor from epoll monitoring and discard its handler.
func unregisterFd(_ fd: Int32) throws {
self.handlers.withLock { _ = $0.removeValue(forKey: fd) }
try self.poller.delete(fd)
}
func ready() {
self.source.setEventHandler {
self.handleSignal()
}
self.source.resume()
}
private func handleSignal() {
dispatchPrecondition(condition: .onQueue(queue))
let exited = Reaper.reap()
for (pid, status) in exited {
reaperCommandRunner.notifyExit(pid: pid, status: status)
}
self.state.withLock { state in
state.log?.debug("received SIGCHLD, reaping processes")
state.log?.debug("finished wait4 of \(exited.count) processes")
state.log?.debug("checking for exit of managed process", metadata: ["exits": "\(exited)", "processes": "\(state.processes.count)"])
let exitedProcesses = state.processes.filter { proc in
exited.contains { pid, _ in
proc.pid == pid
}
}
for proc in exitedProcesses {
guard let pid = proc.pid else {
continue
}
if let status = exited[pid] {
state.log?.debug(
"managed process exited",
metadata: [
"pid": "\(pid)",
"status": "\(status)",
"count": "\(state.processes.count - 1)",
])
proc.setExit(status)
state.processes.removeAll(where: { $0.pid == pid })
}
}
}
}
func start(process: any ContainerProcess) async throws -> Int32 {
self.state.withLock { state in
state.log?.debug("in supervisor lock to start process")
state.processes.append(process)
}
do {
return try await process.start()
} catch {
self.state.withLock { state in
state.processes.removeAll(where: { $0.id == process.id })
}
throw error
}
}
/// Get a Runc instance configured with the reaper command runner
func getRuncWithReaper(_ base: Runc = Runc()) -> Runc {
var runc = base
runc.commandRunner = reaperCommandRunner
return runc
}
deinit {
source.cancel()
poller.shutdown()
}
}
#endif
@@ -0,0 +1,83 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOS
import Foundation
/// A Unix socket for receiving PTY master file descriptors from runc
final class ConsoleSocket: Sendable {
private let socket: Socket
private let socketPath: String
/// The path to the console socket
var path: String { socketPath }
/// Create a new console socket at the specified path
init(path: String) throws {
let absPath = path.starts(with: "/") ? path : FileManager.default.currentDirectoryPath + "/" + path
self.socketPath = absPath
let pathURL = URL(fileURLWithPath: absPath)
let dir = pathURL.deletingLastPathComponent().path
try FileManager.default.createDirectory(
atPath: dir,
withIntermediateDirectories: true,
attributes: nil
)
let socketType = try UnixType(path: absPath, unlinkExisting: true)
self.socket = try Socket(type: socketType)
try socket.listen()
}
/// Create a temporary console socket in the runtime directory
static func temporary() throws -> ConsoleSocket {
let tmpDir = "/tmp"
let socketDir = tmpDir + "/runc-console-\(UUID().uuidString)"
let socketPath = socketDir + "/console.sock"
try FileManager.default.createDirectory(
atPath: socketDir,
withIntermediateDirectories: true,
attributes: nil
)
let socket = try ConsoleSocket(path: socketPath)
return socket
}
/// Receive the PTY master file descriptor from runc
func receiveMaster() throws -> Int32 {
let connection = try socket.accept()
defer { try? connection.close() }
return try connection.receiveFileDescriptor()
}
/// Close the socket and optionally remove the socket file
func close() throws {
try socket.close()
try FileManager.default.removeItem(atPath: socketPath)
}
deinit {
try? close()
}
}
#endif
+794
View File
@@ -0,0 +1,794 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOCI
import ContainerizationOS
import Foundation
/// Log format for runc output
enum LogFormat: String, Sendable {
case json
case text
}
/// Configuration and client for interacting with the runc binary
struct Runc: Sendable {
/// IO configuration for runc operations
struct IO: Sendable {
var stdin: FileHandle?
var stdout: FileHandle?
var stderr: FileHandle?
init(
stdin: FileHandle? = nil,
stdout: FileHandle? = nil,
stderr: FileHandle? = nil
) {
self.stdin = stdin
self.stdout = stdout
self.stderr = stderr
}
static let inherit = IO()
}
/// Path to the runc binary
var command: String
/// Root directory for container state
var root: String?
/// Enable debug output
var debug: Bool
/// Path to log file
var log: String?
/// Format for log output
var logFormat: LogFormat?
/// Signal to send when parent process dies
var pdeathSignal: Int32?
/// Set process group ID
var setpgid: Bool
/// Path to criu binary for checkpoint/restore
var criu: String?
/// Use systemd cgroup manager
var systemdCgroup: Bool
/// Enable rootless mode
var rootless: Bool
/// Additional arguments to pass to runc
var extraArgs: [String]
/// Command runner to use instead of direct wait4 (for PID 1 environments with reapers)
var commandRunner: (any CommandRunner)?
init(
command: String = "runc",
root: String? = nil,
debug: Bool = false,
log: String? = nil,
logFormat: LogFormat? = nil,
pdeathSignal: Int32? = nil,
setpgid: Bool = false,
criu: String? = nil,
systemdCgroup: Bool = false,
rootless: Bool = false,
extraArgs: [String] = [],
commandRunner: (any CommandRunner)? = nil
) {
self.command = command
self.root = root
self.debug = debug
self.log = log
self.logFormat = logFormat
self.pdeathSignal = pdeathSignal
self.setpgid = setpgid
self.criu = criu
self.systemdCgroup = systemdCgroup
self.rootless = rootless
self.extraArgs = extraArgs
self.commandRunner = commandRunner
}
}
/// Options for creating a container
struct CreateOpts: Sendable {
/// Path to file to write container PID
var pidFile: String?
/// Path to console socket for terminal access
var consoleSocket: String?
/// Detach from the container process
var detach: Bool
/// Do not use pivot_root to change root
var noPivot: Bool
/// Do not create a new session
var noNewKeyring: Bool
/// Additional file descriptors to pass to the container
var extraFiles: [FileHandle]
/// IO configuration for the runc process
var io: Runc.IO
init(
pidFile: String? = nil,
consoleSocket: String? = nil,
detach: Bool = false,
noPivot: Bool = false,
noNewKeyring: Bool = false,
extraFiles: [FileHandle] = [],
io: Runc.IO = .inherit
) {
self.pidFile = pidFile
self.consoleSocket = consoleSocket
self.detach = detach
self.noPivot = noPivot
self.noNewKeyring = noNewKeyring
self.extraFiles = extraFiles
self.io = io
}
}
/// Options for executing a process in a container
struct ExecOpts: Sendable {
/// Path to file to write process PID
var pidFile: String?
/// Path to console socket for terminal access
var consoleSocket: String?
/// Detach from the process
var detach: Bool
/// Path to process.json file
var processPath: String?
/// IO configuration for the runc process
var io: Runc.IO
init(
pidFile: String? = nil,
consoleSocket: String? = nil,
detach: Bool = false,
processPath: String? = nil,
io: Runc.IO = .inherit
) {
self.pidFile = pidFile
self.consoleSocket = consoleSocket
self.detach = detach
self.processPath = processPath
self.io = io
}
}
/// Options for deleting a container
struct DeleteOpts: Sendable {
/// Force deletion of a running container
var force: Bool
init(force: Bool = false) {
self.force = force
}
}
/// Options for restoring a container from checkpoint
struct RestoreOpts: Sendable {
/// Path to file to write container PID
var pidFile: String?
/// Path to console socket for terminal access
var consoleSocket: String?
/// Detach from the container process
var detach: Bool
/// Do not use pivot_root to change root
var noPivot: Bool
/// Do not create a new session
var noNewKeyring: Bool
/// Path to checkpoint image
var imagePath: String?
/// Path to parent checkpoint
var parentPath: String?
/// Work directory for CRIU
var workPath: String?
init(
pidFile: String? = nil,
consoleSocket: String? = nil,
detach: Bool = false,
noPivot: Bool = false,
noNewKeyring: Bool = false,
imagePath: String? = nil,
parentPath: String? = nil,
workPath: String? = nil
) {
self.pidFile = pidFile
self.consoleSocket = consoleSocket
self.detach = detach
self.noPivot = noPivot
self.noNewKeyring = noNewKeyring
self.imagePath = imagePath
self.parentPath = parentPath
self.workPath = workPath
}
}
/// Container information returned from list operation
struct Container: Sendable, Codable {
let id: String
let pid: Int
let status: String
let bundle: String
let rootfs: String
let created: Date
let annotations: [String: String]?
enum CodingKeys: String, CodingKey {
case id
case pid
case status
case bundle
case rootfs
case created
case annotations
}
}
extension Runc {
enum Error: Swift.Error, CustomStringConvertible {
case invalidJSON(String)
case commandFailed(Int32, String)
case invalidPidFile(String)
var description: String {
switch self {
case .invalidJSON(let detail):
return "invalid JSON: \(detail)"
case .commandFailed(let status, let output):
return "command failed with status \(status): \(output)"
case .invalidPidFile(let path):
return "invalid or missing PID file: \(path)"
}
}
}
}
// MARK: - Command Building and Execution
extension Runc {
/// Build base arguments for runc command
func baseArgs() -> [String] {
var args: [String] = []
if let root = root {
args += ["--root", root]
}
if debug {
args.append("--debug")
}
if let log = log {
args += ["--log", log]
}
if let logFormat = logFormat {
args += ["--log-format", logFormat.rawValue]
}
if systemdCgroup {
args.append("--systemd-cgroup")
}
if rootless {
args.append("--rootless")
}
args += extraArgs
return args
}
/// Execute a runc command and return the output
func execute(
args: [String],
stdin: FileHandle? = nil,
stdout: FileHandle? = nil,
stderr: FileHandle? = nil,
extraFiles: [FileHandle] = [],
directory: String? = nil
) async throws -> (status: Int32, output: Data) {
var cmd = Command(
command,
arguments: args,
directory: directory,
extraFiles: extraFiles
)
// Setup IO
let outPipe = Pipe()
cmd.stdin = stdin
cmd.stdout = stdout ?? outPipe.fileHandleForWriting
cmd.stderr = stderr ?? outPipe.fileHandleForWriting
if let pdeathSignal = pdeathSignal {
cmd.attrs.pdeathSignal = pdeathSignal
}
if setpgid {
cmd.attrs.setPGroup = true
}
let exitStatus: Int32
if let runner = commandRunner {
let subscription = try runner.start(&cmd)
exitStatus = try await runner.wait(cmd, subscription: subscription)
} else {
try cmd.start()
exitStatus = try cmd.wait()
}
var output = Data()
if stdout == nil {
try? outPipe.fileHandleForWriting.close()
output = try outPipe.fileHandleForReading.readToEnd() ?? Data()
}
return (exitStatus, output)
}
/// Execute command and parse JSON output
func executeJSON<T: Decodable>(
args: [String],
directory: String? = nil
) async throws -> T {
let (status, output) = try await execute(args: args, directory: directory)
guard status == 0 else {
let errorOutput = String(data: output, encoding: .utf8) ?? ""
throw Error.commandFailed(status, errorOutput)
}
do {
return try JSONDecoder().decode(T.self, from: output)
} catch {
let outputStr = String(data: output, encoding: .utf8) ?? ""
throw Error.invalidJSON("failed to decode: \(error), output: \(outputStr)")
}
}
/// Execute command without capturing output
func executeVoid(
args: [String],
stdin: FileHandle? = nil,
stdout: FileHandle? = nil,
stderr: FileHandle? = nil,
extraFiles: [FileHandle] = [],
directory: String? = nil
) async throws {
let (status, output) = try await execute(
args: args,
stdin: stdin,
stdout: stdout,
stderr: stderr,
extraFiles: extraFiles,
directory: directory
)
guard status == 0 else {
let errorOutput = String(data: output, encoding: .utf8) ?? ""
throw Error.commandFailed(status, errorOutput)
}
}
/// Read PID from a file
func readPidFile(_ path: String) throws -> Int {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
let pidString = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
let pid = Int(pidString)
else {
throw Error.invalidPidFile(path)
}
return pid
}
}
extension Runc {
/// Create a container
func create(
id: String,
bundle: String,
opts: CreateOpts = CreateOpts()
) async throws -> Int? {
var args = baseArgs() + ["create"]
if let pidFile = opts.pidFile {
args += ["--pid-file", pidFile]
}
if let consoleSocket = opts.consoleSocket {
args += ["--console-socket", consoleSocket]
}
if opts.detach {
args.append("--detach")
}
if opts.noPivot {
args.append("--no-pivot")
}
if opts.noNewKeyring {
args.append("--no-new-keyring")
}
args += ["--bundle", bundle, id]
try await executeVoid(
args: args,
stdin: opts.io.stdin,
stdout: opts.io.stdout,
stderr: opts.io.stderr,
extraFiles: opts.extraFiles,
directory: bundle
)
// Read PID if pidFile was specified
if let pidFile = opts.pidFile {
return try readPidFile(pidFile)
}
return nil
}
/// Start a container
func start(id: String) async throws {
let args = baseArgs() + ["start", id]
try await executeVoid(args: args)
}
/// Run a container (create + start)
func run(
id: String,
bundle: String,
opts: CreateOpts = CreateOpts()
) async throws -> Int? {
var args = baseArgs() + ["run"]
if let pidFile = opts.pidFile {
args += ["--pid-file", pidFile]
}
if let consoleSocket = opts.consoleSocket {
args += ["--console-socket", consoleSocket]
}
if opts.detach {
args.append("--detach")
}
if opts.noPivot {
args.append("--no-pivot")
}
if opts.noNewKeyring {
args.append("--no-new-keyring")
}
args += ["--bundle", bundle, id]
try await executeVoid(
args: args,
stdin: opts.io.stdin,
stdout: opts.io.stdout,
stderr: opts.io.stderr,
extraFiles: opts.extraFiles,
directory: bundle
)
// Read PID if pidFile was specified
if let pidFile = opts.pidFile {
return try readPidFile(pidFile)
}
return nil
}
/// Delete a container
func delete(id: String, opts: DeleteOpts = DeleteOpts()) async throws {
var args = baseArgs() + ["delete"]
if opts.force {
args.append("--force")
}
args.append(id)
try await executeVoid(args: args)
}
/// Send a signal to a container
func kill(id: String, signal: Int32, all: Bool = false) async throws {
var args = baseArgs() + ["kill"]
if all {
args.append("--all")
}
args += [id, String(signal)]
try await executeVoid(args: args)
}
/// Pause a container
func pause(id: String) async throws {
let args = baseArgs() + ["pause", id]
try await executeVoid(args: args)
}
/// Resume a paused container
func resume(id: String) async throws {
let args = baseArgs() + ["resume", id]
try await executeVoid(args: args)
}
/// Execute a process in a running container
func exec(
id: String,
processSpec: String,
opts: ExecOpts = ExecOpts()
) async throws -> Int? {
var args = baseArgs() + ["exec"]
if let pidFile = opts.pidFile {
args += ["--pid-file", pidFile]
}
if let consoleSocket = opts.consoleSocket {
args += ["--console-socket", consoleSocket]
}
if opts.detach {
args.append("--detach")
}
if let processPath = opts.processPath {
args += ["--process", processPath]
}
args += [id, processSpec]
try await executeVoid(
args: args,
stdin: opts.io.stdin,
stdout: opts.io.stdout,
stderr: opts.io.stderr
)
// Read PID if pidFile was specified
if let pidFile = opts.pidFile {
return try readPidFile(pidFile)
}
return nil
}
/// Update container resources
func update(id: String, resources: String) async throws {
let args = baseArgs() + ["update", "--resources", resources, id]
try await executeVoid(args: args)
}
/// Checkpoint a container
func checkpoint(
id: String,
imagePath: String,
leaveRunning: Bool = false,
workPath: String? = nil
) async throws {
var args = baseArgs() + ["checkpoint"]
if leaveRunning {
args.append("--leave-running")
}
if let workPath = workPath {
args += ["--work-path", workPath]
}
args += ["--image-path", imagePath, id]
try await executeVoid(args: args)
}
/// Restore a container from checkpoint
func restore(
id: String,
bundle: String,
opts: RestoreOpts = RestoreOpts()
) async throws -> Int? {
var args = baseArgs() + ["restore"]
if let pidFile = opts.pidFile {
args += ["--pid-file", pidFile]
}
if let consoleSocket = opts.consoleSocket {
args += ["--console-socket", consoleSocket]
}
if opts.detach {
args.append("--detach")
}
if opts.noPivot {
args.append("--no-pivot")
}
if opts.noNewKeyring {
args.append("--no-new-keyring")
}
if let imagePath = opts.imagePath {
args += ["--image-path", imagePath]
}
if let parentPath = opts.parentPath {
args += ["--parent-path", parentPath]
}
if let workPath = opts.workPath {
args += ["--work-path", workPath]
}
args += ["--bundle", bundle, id]
try await executeVoid(args: args, directory: bundle)
if let pidFile = opts.pidFile {
return try readPidFile(pidFile)
}
return nil
}
}
// MARK: - List and State Operations
extension Runc {
/// List all containers
func list() async throws -> [Container] {
let args = baseArgs() + ["list", "--format", "json"]
let containers: [Container] = try await executeJSON(args: args)
return containers
}
/// Get state of a specific container
func state(id: String) async throws -> ContainerizationOCI.State {
let args = baseArgs() + ["state", id]
let state: ContainerizationOCI.State = try await executeJSON(args: args)
return state
}
/// List process IDs in a container
func ps(id: String) async throws -> [Int] {
let args = baseArgs() + ["ps", "--format", "json", id]
let (status, output) = try await execute(args: args)
guard status == 0 else {
let errorOutput = String(data: output, encoding: .utf8) ?? ""
throw Error.commandFailed(status, errorOutput)
}
// ps output is just an array of PIDs
let pids = try JSONDecoder().decode([Int].self, from: output)
return pids
}
/// Get version information
func version() async throws -> String {
let args = [command, "--version"]
let (status, output) = try await execute(args: args)
guard status == 0 else {
let errorOutput = String(data: output, encoding: .utf8) ?? ""
throw Error.commandFailed(status, errorOutput)
}
return String(data: output, encoding: .utf8) ?? ""
}
}
// MARK: - Events
extension Runc {
/// Event from container runtime
struct Event: Codable, Sendable {
let type: String
let id: String
let stats: EventStats?
enum CodingKeys: String, CodingKey {
case type
case id
case stats
}
}
/// Statistics in an event
struct EventStats: Codable, Sendable {
let cpu: CPUStats?
let memory: MemoryStats?
let pids: PIDStats?
enum CodingKeys: String, CodingKey {
case cpu
case memory
case pids
}
}
struct CPUStats: Codable, Sendable {
let usage: CPUUsage?
let throttling: ThrottlingData?
struct CPUUsage: Codable, Sendable {
let total: UInt64?
let percpu: [UInt64]?
}
struct ThrottlingData: Codable, Sendable {
let periods: UInt64?
let throttledPeriods: UInt64?
let throttledTime: UInt64?
}
}
struct MemoryStats: Codable, Sendable {
let usage: MemoryUsage?
let limit: UInt64?
struct MemoryUsage: Codable, Sendable {
let usage: UInt64?
let max: UInt64?
}
}
struct PIDStats: Codable, Sendable {
let current: UInt64?
let limit: UInt64?
}
}
#endif
@@ -0,0 +1,564 @@
//===----------------------------------------------------------------------===//
// 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 Containerization
import ContainerizationError
import ContainerizationOCI
import ContainerizationOS
import Foundation
import Logging
import Synchronization
/// A container process implementation that uses runc as the OCI runtime
final class RuncProcess: ContainerProcess, Sendable {
// swiftlint: disable type_name
protocol IO: Sendable {
func attachConsole(fd: Int32) throws
func create() throws
func getIO() -> Runc.IO
func closeAfterExec() throws
func resize(size: Terminal.Size) throws
func close() throws
func closeStdin() throws
}
// swiftlint: enable type_name
private enum ProcessState {
case initial
case creating
case running(pid: Int32)
case exited(ContainerExitStatus)
}
private struct State {
var state: ProcessState = .initial
var waiters: [CheckedContinuation<ContainerExitStatus, Never>] = []
}
let id: String
private let log: Logger
private let runc: Runc
private let io: IO
private let state: Mutex<State>
private let terminal: Bool
private let bundle: ContainerizationOCI.Bundle
private let consoleSocket: ConsoleSocket?
var pid: Int32? {
self.state.withLock {
switch $0.state {
case .running(let pid):
return pid
default:
return nil
}
}
}
init(
id: String,
stdio: HostStdio,
bundle: ContainerizationOCI.Bundle,
runc: Runc,
log: Logger
) throws {
self.id = id
var log = log
log[metadataKey: "id"] = "\(id)"
self.log = log
self.runc = runc
self.bundle = bundle
self.terminal = stdio.terminal
var io: IO
var consoleSocket: ConsoleSocket? = nil
if stdio.terminal {
log.info("setting up terminal I/O for runc")
let socket = try ConsoleSocket.temporary()
consoleSocket = socket
io = try RuncTerminalIO(
stdio: stdio,
log: log
)
} else {
io = RuncStandardIO(
stdio: stdio,
log: log
)
}
log.info("starting I/O for runc")
try io.create()
self.consoleSocket = consoleSocket
self.io = io
self.state = Mutex(State())
}
func start() async throws -> Int32 {
try self.state.withLock {
guard case .initial = $0.state else {
throw ContainerizationError(
.invalidState,
message: "container already started"
)
}
$0.state = .creating
}
log.info(
"starting runc process",
metadata: [
"id": "\(id)"
])
let pidFilePath = self.bundle.path.appendingPathComponent("runc-pid").path
let runcIO = self.io.getIO()
let opts: CreateOpts
if let consoleSocket {
opts = CreateOpts(
pidFile: pidFilePath,
consoleSocket: consoleSocket.path,
io: runcIO
)
} else {
opts = CreateOpts(
pidFile: pidFilePath,
io: runcIO
)
}
guard
let pidInt = try await self.runc.create(
id: self.id,
bundle: self.bundle.path.path,
opts: opts
)
else {
throw ContainerizationError(
.internalError,
message: "runc create did not return a PID"
)
}
let pid = Int32(pidInt)
self.log.info(
"container created",
metadata: [
"pid": "\(pid)"
])
// Close the pipe ends we gave to runc now that it has inherited them
// and attach console if in terminal mode
if self.terminal, let consoleSocket = self.consoleSocket {
self.log.info("waiting for console FD from runc")
let ptyFd = try consoleSocket.receiveMaster()
self.log.info(
"received PTY FD from runc, attaching",
metadata: [
"id": "\(self.id)"
])
try self.io.closeAfterExec()
try self.io.attachConsole(fd: ptyFd)
} else {
try self.io.closeAfterExec()
}
try await self.runc.start(id: self.id)
self.state.withLock {
$0.state = .running(pid: pid)
}
self.log.info(
"started runc process",
metadata: [
"pid": "\(pid)",
"id": "\(self.id)",
])
return pid
}
func setExit(_ status: Int32) {
self.state.withLock {
self.log.info(
"runc process exit",
metadata: [
"status": "\(status)"
])
let exitStatus = ContainerExitStatus(exitCode: status, exitedAt: Date.now)
$0.state = .exited(exitStatus)
do {
try self.io.close()
} catch {
self.log.error("failed to close I/O for process: \(error)")
}
for waiter in $0.waiters {
waiter.resume(returning: exitStatus)
}
self.log.debug("\($0.waiters.count) runc process waiters signaled")
$0.waiters.removeAll()
}
}
func wait() async -> ContainerExitStatus {
await withCheckedContinuation { cont in
self.state.withLock {
if case .exited(let exitStatus) = $0.state {
cont.resume(returning: exitStatus)
return
}
$0.waiters.append(cont)
}
}
}
func kill(_ signal: Int32) async throws {
self.log.info("sending signal \(signal) to runc container \(id)")
try await self.runc.kill(id: self.id, signal: signal)
}
func resize(size: Terminal.Size) throws {
try self.state.withLock {
if case .exited = $0.state {
return
}
try self.io.resize(size: size)
}
}
func closeStdin() throws {
try self.io.closeStdin()
}
func delete() async throws {
let shouldDelete = self.state.withLock { state -> Bool in
switch state.state {
case .initial, .creating:
return false
default:
return true
}
}
guard shouldDelete else {
log.info("container was never created, skipping delete")
return
}
log.info("deleting runc container", metadata: ["id": "\(id)"])
try await self.runc.delete(
id: self.id,
opts: DeleteOpts(force: true)
)
if let consoleSocket = self.consoleSocket {
try consoleSocket.close()
}
}
}
// MARK: - RuncTerminalIO
final class RuncTerminalIO: RuncProcess.IO & Sendable {
private struct State {
var stdinSocket: Socket?
var stdoutSocket: Socket?
var stdin: IOPair?
var stdout: IOPair?
var terminal: Terminal?
}
private let log: Logger?
private let hostStdio: HostStdio
private let state: Mutex<State>
init(
stdio: HostStdio,
log: Logger?
) throws {
self.hostStdio = stdio
self.log = log
self.state = Mutex(State())
}
func resize(size: Terminal.Size) throws {
try self.state.withLock {
if let terminal = $0.terminal {
try terminal.resize(size: size)
}
}
}
func create() throws {
try self.state.withLock {
if let stdinPort = self.hostStdio.stdin {
let type = VsockType(
port: stdinPort,
cid: VsockType.hostCID
)
let stdinSocket = try Socket(type: type, closeOnDeinit: false)
try stdinSocket.connect()
$0.stdinSocket = stdinSocket
}
if let stdoutPort = self.hostStdio.stdout {
let type = VsockType(
port: stdoutPort,
cid: VsockType.hostCID
)
let stdoutSocket = try Socket(type: type, closeOnDeinit: false)
try stdoutSocket.connect()
$0.stdoutSocket = stdoutSocket
}
}
}
func getIO() -> Runc.IO {
// Terminal mode doesn't pass pipes to runc, it uses the console socket
.inherit
}
func closeAfterExec() throws {
// No pipes to close in terminal mode
}
func attachConsole(fd: Int32) throws {
try self.state.withLock {
let term = try Terminal(descriptor: fd, setInitState: false)
$0.terminal = term
if let stdinSocket = $0.stdinSocket {
let pair = IOPair(
readFrom: stdinSocket,
writeTo: term,
reason: "RuncTerminalIO stdin",
logger: log
)
try pair.relay(ignoreHup: true)
$0.stdin = pair
}
if let stdoutSocket = $0.stdoutSocket {
let pair = IOPair(
readFrom: term,
writeTo: stdoutSocket,
reason: "RuncTerminalIO stdout",
logger: log
)
try pair.relay(ignoreHup: true)
$0.stdout = pair
}
}
}
func close() throws {
self.state.withLock {
if let stdin = $0.stdin {
stdin.close()
$0.stdin = nil
}
if let stdout = $0.stdout {
stdout.close()
$0.stdout = nil
}
$0.terminal = nil
}
}
func closeStdin() throws {
self.state.withLock {
if let stdin = $0.stdin {
stdin.close()
$0.stdin = nil
}
}
}
}
// MARK: - RuncStandardIO
final class RuncStandardIO: RuncProcess.IO & Sendable {
private struct State {
var stdin: IOPair?
var stdout: IOPair?
var stderr: IOPair?
var stdinPipe: Pipe?
var stdoutPipe: Pipe?
var stderrPipe: Pipe?
}
private let log: Logger?
private let hostStdio: HostStdio
private let state: Mutex<State>
init(
stdio: HostStdio,
log: Logger?
) {
self.hostStdio = stdio
self.log = log
self.state = Mutex(State())
}
// NOP for non-terminal
func attachConsole(fd: Int32) throws {}
func create() throws {
try self.state.withLock {
if let stdinPort = self.hostStdio.stdin {
let inPipe = Pipe()
$0.stdinPipe = inPipe
let type = VsockType(
port: stdinPort,
cid: VsockType.hostCID
)
let stdinSocket = try Socket(type: type, closeOnDeinit: false)
try stdinSocket.connect()
let pair = IOPair(
readFrom: stdinSocket,
writeTo: inPipe.fileHandleForWriting,
reason: "RuncStandardIO stdin",
logger: log
)
$0.stdin = pair
try pair.relay()
}
if let stdoutPort = self.hostStdio.stdout {
let outPipe = Pipe()
$0.stdoutPipe = outPipe
let type = VsockType(
port: stdoutPort,
cid: VsockType.hostCID
)
let stdoutSocket = try Socket(type: type, closeOnDeinit: false)
try stdoutSocket.connect()
let pair = IOPair(
readFrom: outPipe.fileHandleForReading,
writeTo: stdoutSocket,
reason: "RuncStandardIO stdout",
logger: log
)
$0.stdout = pair
try pair.relay()
}
if let stderrPort = self.hostStdio.stderr {
let errPipe = Pipe()
$0.stderrPipe = errPipe
let type = VsockType(
port: stderrPort,
cid: VsockType.hostCID
)
let stderrSocket = try Socket(type: type, closeOnDeinit: false)
try stderrSocket.connect()
let pair = IOPair(
readFrom: errPipe.fileHandleForReading,
writeTo: stderrSocket,
reason: "RuncStandardIO stderr",
logger: log
)
$0.stderr = pair
try pair.relay()
}
}
}
func getIO() -> Runc.IO {
self.state.withLock {
Runc.IO(
stdin: $0.stdinPipe?.fileHandleForReading,
stdout: $0.stdoutPipe?.fileHandleForWriting,
stderr: $0.stderrPipe?.fileHandleForWriting
)
}
}
func closeAfterExec() throws {
try self.state.withLock {
// Close the pipe ends we gave to runc (the child inherited them)
if let stdinPipe = $0.stdinPipe {
try stdinPipe.fileHandleForReading.close()
$0.stdinPipe = nil
}
if let stdoutPipe = $0.stdoutPipe {
try stdoutPipe.fileHandleForWriting.close()
$0.stdoutPipe = nil
}
if let stderrPipe = $0.stderrPipe {
try stderrPipe.fileHandleForWriting.close()
$0.stderrPipe = nil
}
}
}
func resize(size: Terminal.Size) throws {
throw ContainerizationError(.unsupported, message: "resize not supported for standard IO")
}
func close() throws {
self.state.withLock {
if let stdin = $0.stdin {
stdin.close()
$0.stdin = nil
}
if let stdout = $0.stdout {
stdout.close()
$0.stdout = nil
}
if let stderr = $0.stderr {
stderr.close()
$0.stderr = nil
}
}
}
func closeStdin() throws {
self.state.withLock {
if let stdin = $0.stdin {
stdin.close()
$0.stdin = nil
}
}
}
}
#endif
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import GRPCCore
import GRPCNIOTransportHTTP2
import GRPCProtobuf
import Logging
import NIOCore
import NIOPosix
public final class Initd: Sendable {
public actor State {
private(set) var containers: [String: ManagedContainer] = [:]
var proxies: [String: VsockProxy] = [:]
public typealias ContainerDeletedHandler = @Sendable (String) async -> Void
private var onContainerDeleted: [ContainerDeletedHandler] = []
public func onDelete(_ handler: @escaping ContainerDeletedHandler) {
onContainerDeleted.append(handler)
}
public func get(container id: String) throws -> ManagedContainer {
guard let ctr = self.containers[id] else {
throw ContainerizationError(
.notFound,
message: "container \(id) not found"
)
}
return ctr
}
func add(container: ManagedContainer) throws {
guard containers[container.id] == nil else {
throw ContainerizationError(
.exists,
message: "container \(container.id) already exists"
)
}
containers[container.id] = container
}
func add(proxy: VsockProxy) throws {
guard proxies[proxy.id] == nil else {
throw ContainerizationError(
.exists,
message: "proxy \(proxy.id) already exists"
)
}
proxies[proxy.id] = proxy
}
func remove(proxy id: String) throws -> VsockProxy {
guard let proxy = proxies.removeValue(forKey: id) else {
throw ContainerizationError(
.notFound,
message: "proxy \(id) does not exist"
)
}
return proxy
}
func remove(container id: String) throws {
guard let _ = containers.removeValue(forKey: id) else {
throw ContainerizationError(
.notFound,
message: "container \(id) does not exist"
)
}
let handlers = onContainerDeleted
Task {
for handler in handlers {
await handler(id)
}
}
}
}
public let log: Logger
public let state: State
let group: MultiThreadedEventLoopGroup
let blockingPool: NIOThreadPool
public init(log: Logger, group: MultiThreadedEventLoopGroup, blockingPool: NIOThreadPool) {
self.log = log
self.group = group
self.blockingPool = blockingPool
self.state = State()
}
public func serve(port: Int, additionalServices: [any RegistrableRPCService] = []) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
log.debug("starting process supervisor")
ProcessSupervisor.default.setLog(self.log)
ProcessSupervisor.default.ready()
log.info(
"booting gRPC server on vsock",
metadata: [
"port": "\(port)"
])
let server = GRPCServer(
transport: .http2NIOPosix(
address: .vsock(contextID: .any, port: .init(port)),
transportSecurity: .plaintext,
eventLoopGroup: self.group
),
services: [self] + additionalServices
)
log.info(
"gRPC API serving on vsock",
metadata: [
"port": "\(port)"
])
group.addTask { try await server.serve() }
try await group.next()
log.info("closing gRPC server")
group.cancelAll()
}
}
}
#endif
@@ -0,0 +1,175 @@
//===----------------------------------------------------------------------===//
// 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 StandardIO: ManagedProcess.IO & Sendable {
private struct State {
var stdin: IOPair?
var stdout: IOPair?
var stderr: IOPair?
var stdinPipe: Pipe?
var stdoutPipe: Pipe?
var stderrPipe: Pipe?
}
private let log: Logger?
private let hostStdio: HostStdio
private let state: Mutex<State>
init(
stdio: HostStdio,
log: Logger?
) {
self.hostStdio = stdio
self.log = log
self.state = Mutex(State())
}
// NOP
func attach(pid: Int32, fd: Int32) throws {}
func start(process: inout Command) throws {
try self.state.withLock {
if let stdinPort = self.hostStdio.stdin {
let inPipe = Pipe()
process.stdin = inPipe.fileHandleForReading
$0.stdinPipe = inPipe
let type = VsockType(
port: stdinPort,
cid: VsockType.hostCID
)
let stdinSocket = try Socket(type: type, closeOnDeinit: false)
try stdinSocket.connect()
let pair = IOPair(
readFrom: stdinSocket,
writeTo: inPipe.fileHandleForWriting,
reason: "StandardIO stdin",
logger: log
)
$0.stdin = pair
try pair.relay()
}
if let stdoutPort = self.hostStdio.stdout {
let outPipe = Pipe()
process.stdout = outPipe.fileHandleForWriting
$0.stdoutPipe = outPipe
let type = VsockType(
port: stdoutPort,
cid: VsockType.hostCID
)
let stdoutSocket = try Socket(type: type, closeOnDeinit: false)
try stdoutSocket.connect()
let pair = IOPair(
readFrom: outPipe.fileHandleForReading,
writeTo: stdoutSocket,
reason: "StandardIO stdout",
logger: log
)
$0.stdout = pair
try pair.relay()
}
if let stderrPort = self.hostStdio.stderr {
let errPipe = Pipe()
process.stderr = errPipe.fileHandleForWriting
$0.stderrPipe = errPipe
let type = VsockType(
port: stderrPort,
cid: VsockType.hostCID
)
let stderrSocket = try Socket(type: type, closeOnDeinit: false)
try stderrSocket.connect()
let pair = IOPair(
readFrom: errPipe.fileHandleForReading,
writeTo: stderrSocket,
reason: "StandardIO stderr",
logger: log
)
$0.stderr = pair
try pair.relay()
}
}
}
func resize(size: Terminal.Size) throws {
throw ContainerizationError(.unsupported, message: "resize not supported")
}
func close() throws {
self.state.withLock {
if let stdin = $0.stdin {
stdin.close()
$0.stdin = nil
}
if let stdout = $0.stdout {
stdout.close()
$0.stdout = nil
}
if let stderr = $0.stderr {
stderr.close()
$0.stderr = nil
}
}
}
func closeStdin() throws {
self.state.withLock {
if let stdin = $0.stdin {
stdin.close()
$0.stdin = nil
}
}
}
func closeAfterExec() throws {
try self.state.withLock {
if let stdin = $0.stdinPipe {
try stdin.fileHandleForReading.close()
$0.stdinPipe = nil
}
if let stdout = $0.stdoutPipe {
try stdout.fileHandleForWriting.close()
$0.stdoutPipe = nil
}
if let stderr = $0.stderrPipe {
try stderr.fileHandleForWriting.close()
$0.stderrPipe = nil
}
}
}
}
#endif
@@ -0,0 +1,169 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOS
import Foundation
import LCShim
import Logging
import Synchronization
final class TerminalIO: ManagedProcess.IO & Sendable {
private struct State {
var stdinSocket: Socket?
var stdoutSocket: Socket?
var stdin: IOPair?
var stdout: IOPair?
var parent: Terminal?
}
private let log: Logger?
private let hostStdio: HostStdio
private let state: Mutex<State>
init(
stdio: HostStdio,
log: Logger?
) throws {
self.hostStdio = stdio
self.log = log
self.state = Mutex(State())
}
func resize(size: Terminal.Size) throws {
try self.state.withLock {
if let parent = $0.parent {
try parent.resize(size: size)
}
}
}
func start(process: inout Command) throws {
try self.state.withLock {
process.stdin = nil
process.stdout = nil
process.stderr = nil
if let stdinPort = self.hostStdio.stdin {
let type = VsockType(
port: stdinPort,
cid: VsockType.hostCID
)
let stdinSocket = try Socket(type: type, closeOnDeinit: false)
try stdinSocket.connect()
$0.stdinSocket = stdinSocket
}
if let stdoutPort = self.hostStdio.stdout {
let type = VsockType(
port: stdoutPort,
cid: VsockType.hostCID
)
let stdoutSocket = try Socket(type: type, closeOnDeinit: false)
try stdoutSocket.connect()
$0.stdoutSocket = stdoutSocket
}
}
}
func attach(pid: Int32, fd: Int32) throws {
try self.state.withLock {
let containerFd = CZ_pidfd_open(pid, 0)
guard containerFd != -1 else {
throw POSIXError.fromErrno()
}
defer { Foundation.close(Int32(containerFd)) }
let hostFd = CZ_pidfd_getfd(containerFd, fd, 0)
guard hostFd != -1 else {
throw POSIXError.fromErrno()
}
let term = try Terminal(descriptor: Int32(hostFd), setInitState: false)
$0.parent = term
if let stdinSocket = $0.stdinSocket {
let pair = IOPair(
readFrom: stdinSocket,
writeTo: UnownedIOCloser(term),
reason: "TerminalIO stdin",
logger: log
)
try pair.relay(ignoreHup: true)
$0.stdin = pair
}
if let stdoutSocket = $0.stdoutSocket {
let pair = IOPair(
readFrom: term,
writeTo: stdoutSocket,
reason: "TerminalIO stdout",
logger: log
)
try pair.relay(ignoreHup: true)
$0.stdout = pair
}
}
}
func close() throws {
self.state.withLock {
// stdout must close before stdin because both IOPairs share the
// Terminal fd. stdout registered that fd with epoll (as its read
// source) and needs to unregister it while the fd is still valid.
// stdin closes the Terminal as its write destination, which would
// invalidate the fd before stdout can unregister.
if let stdout = $0.stdout {
stdout.close()
$0.stdout = nil
}
if let stdin = $0.stdin {
stdin.close()
$0.stdin = nil
}
// If IOPairs were never created (process exited before attach),
// close the raw sockets directly since they have closeOnDeinit
// disabled.
if let stdinSocket = $0.stdinSocket {
try? stdinSocket.close()
$0.stdinSocket = nil
}
if let stdoutSocket = $0.stdoutSocket {
try? stdoutSocket.close()
$0.stdoutSocket = nil
}
$0.parent = nil
}
}
// NOP
func closeAfterExec() throws {}
func closeStdin() throws {
self.state.withLock {
if let stdin = $0.stdin {
stdin.close()
$0.stdin = nil
}
}
}
}
#endif
@@ -0,0 +1,414 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationIO
import ContainerizationOS
import Foundation
import LCShim
import Logging
actor VsockProxy {
enum Action {
case listen
case dial
}
private enum SocketType {
case unix
case vsock
}
let id: String
private let path: URL
private let action: Action
private let port: UInt32
private let udsPerms: UInt32?
private let log: Logger?
private var listener: Socket?
private var task: Task<(), Never>?
private var connectionTasks: [UUID: Task<(), Never>] = [:]
init(
id: String,
action: Action,
port: UInt32,
path: URL,
udsPerms: UInt32?,
log: Logger? = nil
) {
self.id = id
self.action = action
self.port = port
self.path = path
self.udsPerms = udsPerms
self.log = log
}
}
extension VsockProxy {
func start() throws {
guard listener == nil else {
return
}
log?.debug(
"starting proxy",
metadata: [
"vport": "\(port)",
"uds": "\(path)",
"action": "\(action)",
])
switch action {
case .dial:
try dialHost()
case .listen:
try dialGuest()
}
}
func close() throws {
guard let listener else {
return
}
log?.debug(
"stopping proxy",
metadata: [
"vport": "\(port)",
"uds": "\(path)",
"action": "\(action)",
])
try listener.close()
for (_, t) in connectionTasks { t.cancel() }
connectionTasks.removeAll()
if action == .dial {
let fm = FileManager.default
if fm.fileExists(atPath: path.path) {
try fm.removeItem(at: path)
}
}
task?.cancel()
self.listener = nil
}
private func dialHost() throws {
let fm = FileManager.default
let parentDir = path.deletingLastPathComponent()
try fm.createDirectory(
at: parentDir,
withIntermediateDirectories: true
)
let type = try UnixType(
path: path.path,
perms: udsPerms,
unlinkExisting: true
)
let oldMask = umask(0)
defer { umask(oldMask) }
let uds = try Socket(type: type)
try uds.listen()
listener = uds
try acceptLoop(socketType: .unix)
}
private func dialGuest() throws {
let type = VsockType(
port: port,
cid: VsockType.anyCID
)
let vsock = try Socket(type: type)
try vsock.listen()
listener = vsock
try acceptLoop(socketType: .vsock)
}
private func acceptLoop(socketType: SocketType) throws {
guard let listener else {
return
}
let stream = try listener.acceptStream()
let task = Task {
do {
for try await conn in stream {
let connID = UUID()
let connTask = Task {
defer { self.connectionTasks[connID] = nil }
log?.debug(
"accepting connection",
metadata: [
"vport": "\(port)",
"uds": "\(path)",
"action": "\(action)",
"socketType": "\(socketType)",
])
do {
try await handleConn(
conn: conn,
connType: socketType
)
} catch {
self.log?.error("failed to handle connection: \(error)")
}
}
// Safe: actor serialization ensures this runs before connTask can execute its defer.
connectionTasks[connID] = connTask
}
} catch {
self.log?.error("failed to accept connection: \(error)")
}
}
self.task = task
}
private func handleConn(
conn: ContainerizationOS.Socket,
connType: SocketType
) async throws {
try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in
do {
// `relayTo` isn't used concurrently.
nonisolated(unsafe) var relayTo: ContainerizationOS.Socket
switch connType {
case .unix:
let type = VsockType(
port: port,
cid: VsockType.hostCID
)
relayTo = try Socket(
type: type,
closeOnDeinit: false
)
case .vsock:
let type = try UnixType(path: path.path)
relayTo = try Socket(
type: type,
closeOnDeinit: false
)
}
try relayTo.connect()
// `clientFile` isn't used concurrently.
nonisolated(unsafe) var clientFile = OSFile.SpliceFile(fd: conn.fileDescriptor)
nonisolated(unsafe) var eofFromClient = false
// `serverFile` isn't used concurrently.
nonisolated(unsafe) var serverFile = OSFile.SpliceFile(fd: relayTo.fileDescriptor)
nonisolated(unsafe) var eofFromServer = false
// clean up when any of these conditions apply:
// - the client has completely hung up or errored
// - the server has completely hung up or errored
// - both the client and server have half closed via:
// - read hangup on epoll
// - EOF on splice
let cleanup = { @Sendable [log, port, path, action] in
log?.debug(
"cleaning up",
metadata: [
"vport": "\(port)",
"uds": "\(path)",
"action": "\(action)",
"eofFromClient": "\(eofFromClient)",
"eofFromServer": "\(eofFromServer)",
"clientFd": "\(clientFile.fileDescriptor)",
"serverFd": "\(serverFile.fileDescriptor)",
]
)
do {
try ProcessSupervisor.default.unregisterFd(clientFile.fileDescriptor)
try ProcessSupervisor.default.unregisterFd(serverFile.fileDescriptor)
try conn.close()
try relayTo.close()
} catch {
self.log?.error("Failed to clean up vsock proxy: \(error)")
}
c.resume()
}
try! ProcessSupervisor.default.registerFd(clientFile.fileDescriptor, mask: [.input, .output]) { mask in
if mask.readyToRead && !eofFromClient {
let (fromEof, toEof) = Self.transferData(
fromFile: &clientFile,
toFile: &serverFile,
description: "readyToRead:toServer",
log: self.log
)
eofFromClient = eofFromClient || fromEof
eofFromServer = eofFromServer || toEof
}
if mask.readyToWrite && !eofFromServer {
let (fromEof, toEof) = Self.transferData(
fromFile: &serverFile,
toFile: &clientFile,
description: "readyToWrite:toClient",
log: self.log
)
eofFromClient = eofFromClient || toEof
eofFromServer = eofFromServer || fromEof
}
if mask.isHangup {
eofFromClient = true
eofFromServer = true
} else if mask.isRemoteHangup && !eofFromClient {
// half close, shut down client to server transfer
// we should see no more EPOLLIN events on the client fd
// and no more EPOLLOUT events on the server fd
eofFromClient = true
if shutdown(serverFile.fileDescriptor, Int32(SHUT_WR)) != 0 {
self.log?.warning(
"failed to shut down client reads",
metadata: [
"vport": "\(self.port)",
"uds": "\(self.path)",
"errno": "\(errno)",
"eofFromClient": "\(eofFromClient)",
"eofFromServer": "\(eofFromServer)",
"clientFd": "\(clientFile.fileDescriptor)",
"serverFd": "\(serverFile.fileDescriptor)",
]
)
}
}
if eofFromClient && eofFromServer {
return cleanup()
}
}
try! ProcessSupervisor.default.registerFd(serverFile.fileDescriptor, mask: [.input, .output]) { mask in
if mask.readyToRead && !eofFromServer {
let (fromEof, toEof) = Self.transferData(
fromFile: &serverFile,
toFile: &clientFile,
description: "readyToRead:toClient",
log: self.log
)
eofFromClient = eofFromClient || toEof
eofFromServer = eofFromServer || fromEof
}
if mask.readyToWrite && !eofFromClient {
let (fromEof, toEof) = Self.transferData(
fromFile: &clientFile,
toFile: &serverFile,
description: "readyToWrite:toServer",
log: self.log
)
eofFromClient = eofFromClient || fromEof
eofFromServer = eofFromServer || toEof
}
if mask.isHangup {
eofFromClient = true
eofFromServer = true
} else if mask.isRemoteHangup && !eofFromServer {
// half close, shut down server to client transfer
// we should see no more EPOLLIN events on the server fd
// and no more EPOLLOUT events on the client fd
eofFromServer = true
if shutdown(clientFile.fileDescriptor, Int32(SHUT_WR)) != 0 {
self.log?.warning(
"failed to shut down server reads",
metadata: [
"vport": "\(self.port)",
"uds": "\(self.path)",
"errno": "\(errno)",
"eofFromClient": "\(eofFromClient)",
"eofFromServer": "\(eofFromServer)",
"clientFd": "\(clientFile.fileDescriptor)",
"serverFd": "\(serverFile.fileDescriptor)",
]
)
}
}
if eofFromClient && eofFromServer {
return cleanup()
}
}
} catch {
c.resume(throwing: error)
}
}
}
private static func transferData(
fromFile: inout OSFile.SpliceFile,
toFile: inout OSFile.SpliceFile,
description: String,
log: Logger?
) -> (Bool, Bool) {
do {
let (readBytes, writeBytes, action) = try OSFile.splice(from: &fromFile, to: &toFile)
log?.trace(
"transferred data",
metadata: [
"description": "\(description)",
"action": "\(action)",
"readBytes": "\(readBytes)",
"writeBytes": "\(writeBytes)",
"fromFd": "\(fromFile.fileDescriptor)",
"toFd": "\(toFile.fileDescriptor)",
]
)
if action == .eof {
// half close, shut down client to server transfer
// we should see no more EPOLLIN events on the client fd
// and no more EPOLLOUT events on the server fd
if shutdown(toFile.fileDescriptor, Int32(SHUT_WR)) != 0 {
log?.warning(
"failed to shut down reads",
metadata: [
"description": "\(description)",
"errno": "\(errno)",
"action": "\(action)",
"readBytes": "\(readBytes)",
"writeBytes": "\(writeBytes)",
"fromFd": "\(fromFile.fileDescriptor)",
"toFd": "\(toFile.fileDescriptor)",
]
)
}
return (true, false)
} else if action == .brokenPipe {
return (true, true)
}
return (false, false)
} catch {
return (true, true)
}
}
}
#endif