diff --git a/Sources/Containerization/ExitStatus.swift b/Sources/Containerization/ExitStatus.swift new file mode 100644 index 00000000..2552659d --- /dev/null +++ b/Sources/Containerization/ExitStatus.swift @@ -0,0 +1,26 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +/// ExitStatus contains the exit code for a given container process, +/// as well as the timestamp at which it exited. +public struct ExitStatus: Sendable { + /// The exit code for the process. + public var exitCode: Int32 + /// The timestamp when the process exited. + public var exitedAt: Date +} diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index 27273c93..cb6c64bf 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -742,7 +742,7 @@ extension LinuxContainer { /// Wait for the container to exit. Returns the exit code. @discardableResult - public func wait(timeoutInSeconds: Int64? = nil) async throws -> Int32 { + public func wait(timeoutInSeconds: Int64? = nil) async throws -> ExitStatus { let state = try self.state.withLock { try $0.startedState("wait") } return try await state.process.wait(timeoutInSeconds: timeoutInSeconds) } diff --git a/Sources/Containerization/LinuxProcess.swift b/Sources/Containerization/LinuxProcess.swift index 236e080a..3c56fd66 100644 --- a/Sources/Containerization/LinuxProcess.swift +++ b/Sources/Containerization/LinuxProcess.swift @@ -343,15 +343,15 @@ extension LinuxProcess { /// Wait on the process to exit with an optional timeout. Returns the exit code of the process. @discardableResult - public func wait(timeoutInSeconds: Int64? = nil) async throws -> Int32 { + public func wait(timeoutInSeconds: Int64? = nil) async throws -> ExitStatus { do { - let code = try await self.agent.waitProcess( + let exitStatus = try await self.agent.waitProcess( id: self.id, containerID: self.owningContainer, timeoutInSeconds: timeoutInSeconds ) await self.waitIoComplete() - return code + return exitStatus } catch { if error is ContainerizationError { throw error diff --git a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift index 90ea72f4..602b5445 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift @@ -476,9 +476,20 @@ public struct Com_Apple_Containerization_Sandbox_V3_WaitProcessResponse: Sendabl public var exitCode: Int32 = 0 + public var exitedAt: SwiftProtobuf.Google_Protobuf_Timestamp { + get {return _exitedAt ?? SwiftProtobuf.Google_Protobuf_Timestamp()} + set {_exitedAt = newValue} + } + /// Returns true if `exitedAt` has been explicitly set. + public var hasExitedAt: Bool {return self._exitedAt != nil} + /// Clears the value of `exitedAt`. Subsequent reads from it will return its default value. + public mutating func clearExitedAt() {self._exitedAt = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} + + fileprivate var _exitedAt: SwiftProtobuf.Google_Protobuf_Timestamp? = nil } public struct Com_Apple_Containerization_Sandbox_V3_ResizeProcessRequest: Sendable { @@ -1765,6 +1776,7 @@ extension Com_Apple_Containerization_Sandbox_V3_WaitProcessResponse: SwiftProtob public static let protoMessageName: String = _protobuf_package + ".WaitProcessResponse" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "exitCode"), + 2: .standard(proto: "exited_at"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -1774,20 +1786,29 @@ extension Com_Apple_Containerization_Sandbox_V3_WaitProcessResponse: SwiftProtob // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularInt32Field(value: &self.exitCode) }() + case 2: try { try decoder.decodeSingularMessageField(value: &self._exitedAt) }() default: break } } } public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if self.exitCode != 0 { try visitor.visitSingularInt32Field(value: self.exitCode, fieldNumber: 1) } + try { if let v = self._exitedAt { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_WaitProcessResponse, rhs: Com_Apple_Containerization_Sandbox_V3_WaitProcessResponse) -> Bool { if lhs.exitCode != rhs.exitCode {return false} + if lhs._exitedAt != rhs._exitedAt {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Sources/Containerization/SandboxContext/SandboxContext.proto b/Sources/Containerization/SandboxContext/SandboxContext.proto index 4cdc76b4..c25e8727 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.proto +++ b/Sources/Containerization/SandboxContext/SandboxContext.proto @@ -2,6 +2,8 @@ syntax = "proto3"; package com.apple.containerization.sandbox.v3; +import "google/protobuf/timestamp.proto"; + // Context for interacting with a container's runtime environment. service SandboxContext { // Mount a filesystem. @@ -155,6 +157,7 @@ message WaitProcessRequest { message WaitProcessResponse { int32 exitCode = 1; + google.protobuf.Timestamp exited_at = 2; } message ResizeProcessRequest { diff --git a/Sources/Containerization/VirtualMachineAgent.swift b/Sources/Containerization/VirtualMachineAgent.swift index ec6029de..cc2c10be 100644 --- a/Sources/Containerization/VirtualMachineAgent.swift +++ b/Sources/Containerization/VirtualMachineAgent.swift @@ -57,7 +57,7 @@ public protocol VirtualMachineAgent: Sendable { func startProcess(id: String, containerID: String?) async throws -> Int32 func signalProcess(id: String, containerID: String?, signal: Int32) async throws func resizeProcess(id: String, containerID: String?, columns: UInt32, rows: UInt32) async throws - func waitProcess(id: String, containerID: String?, timeoutInSeconds: Int64?) async throws -> Int32 + func waitProcess(id: String, containerID: String?, timeoutInSeconds: Int64?) async throws -> ExitStatus func deleteProcess(id: String, containerID: String?) async throws func closeProcessStdin(id: String, containerID: String?) async throws diff --git a/Sources/Containerization/Agent/Vminitd+Rosetta.swift b/Sources/Containerization/Vminitd+Rosetta.swift similarity index 100% rename from Sources/Containerization/Agent/Vminitd+Rosetta.swift rename to Sources/Containerization/Vminitd+Rosetta.swift diff --git a/Sources/Containerization/Agent/Vminitd+SocketRelay.swift b/Sources/Containerization/Vminitd+SocketRelay.swift similarity index 100% rename from Sources/Containerization/Agent/Vminitd+SocketRelay.swift rename to Sources/Containerization/Vminitd+SocketRelay.swift diff --git a/Sources/Containerization/Agent/Vminitd.swift b/Sources/Containerization/Vminitd.swift similarity index 98% rename from Sources/Containerization/Agent/Vminitd.swift rename to Sources/Containerization/Vminitd.swift index 20c2fed2..ae4901ca 100644 --- a/Sources/Containerization/Agent/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -183,7 +183,11 @@ extension Vminitd: VirtualMachineAgent { _ = try await client.resizeProcess(request) } - public func waitProcess(id: String, containerID: String?, timeoutInSeconds: Int64? = nil) async throws -> Int32 { + public func waitProcess( + id: String, + containerID: String?, + timeoutInSeconds: Int64? = nil + ) async throws -> ExitStatus { let request = Com_Apple_Containerization_Sandbox_V3_WaitProcessRequest.with { $0.id = id if let containerID { @@ -198,7 +202,7 @@ extension Vminitd: VirtualMachineAgent { } do { let resp = try await client.waitProcess(request, callOptions: callOpts) - return resp.exitCode + return ExitStatus(exitCode: resp.exitCode, exitedAt: resp.exitedAt.date) } catch { if let err = error as? GRPCError.RPCTimedOut { throw ContainerizationError( diff --git a/Sources/Integration/ProcessTests.swift b/Sources/Integration/ProcessTests.swift index f8a46689..2ffcf402 100644 --- a/Sources/Integration/ProcessTests.swift +++ b/Sources/Integration/ProcessTests.swift @@ -37,7 +37,7 @@ extension IntegrationSuite { let status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } } @@ -56,7 +56,7 @@ extension IntegrationSuite { let status = try await container.wait() try await container.stop() - guard status == 1 else { + guard status.exitCode == 1 else { throw IntegrationError.assert(msg: "process status \(status) != 1") } } @@ -105,7 +105,7 @@ extension IntegrationSuite { let status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 1") } @@ -140,7 +140,7 @@ extension IntegrationSuite { group.addTask { try await exec.start() let status = try await exec.wait() - if status != 0 { + if status.exitCode != 0 { throw IntegrationError.assert(msg: "process status \(status) != 0") } try await exec.delete() @@ -185,7 +185,7 @@ extension IntegrationSuite { try await exec.start() let status = try await exec.wait() - if status != 0 { + if status.exitCode != 0 { throw IntegrationError.assert(msg: "process status \(status) != 0") } @@ -203,7 +203,7 @@ extension IntegrationSuite { try await exec.start() let status = try await exec.wait() - if status != 0 { + if status.exitCode != 0 { throw IntegrationError.assert(msg: "process \(idx) status \(status) != 0") } @@ -247,7 +247,7 @@ extension IntegrationSuite { var status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } @@ -271,7 +271,7 @@ extension IntegrationSuite { status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } @@ -295,7 +295,7 @@ extension IntegrationSuite { status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } @@ -340,7 +340,7 @@ extension IntegrationSuite { let status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } @@ -374,7 +374,7 @@ extension IntegrationSuite { let status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } @@ -409,7 +409,7 @@ extension IntegrationSuite { let status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } @@ -439,7 +439,7 @@ extension IntegrationSuite { let status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } let expected = "foo-bar" @@ -468,7 +468,7 @@ extension IntegrationSuite { let status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } @@ -496,7 +496,7 @@ extension IntegrationSuite { let status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } let expected = "Hello from test" diff --git a/Sources/Integration/VMTests.swift b/Sources/Integration/VMTests.swift index 2fa10511..c1617991 100644 --- a/Sources/Integration/VMTests.swift +++ b/Sources/Integration/VMTests.swift @@ -40,7 +40,7 @@ extension IntegrationSuite { let status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } @@ -96,7 +96,7 @@ extension IntegrationSuite { let status = try await t.value - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } @@ -160,7 +160,7 @@ extension IntegrationSuite { let status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } } @@ -195,7 +195,7 @@ extension IntegrationSuite { let status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } @@ -234,7 +234,7 @@ extension IntegrationSuite { // Wait for completion let status = try await container.wait() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } @@ -277,7 +277,7 @@ extension IntegrationSuite { // Wait for completion var status = try await container.wait() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } try await container.stop() @@ -288,7 +288,7 @@ extension IntegrationSuite { // Wait for completion.. again. status = try await container.wait() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } @@ -332,7 +332,7 @@ extension IntegrationSuite { let status = try await container.wait() try await container.stop() - guard status == 0 else { + guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "process status \(status) != 0") } diff --git a/vminitd/Sources/vminitd/ManagedContainer.swift b/vminitd/Sources/vminitd/ManagedContainer.swift index 548b30b1..4bfef7be 100644 --- a/vminitd/Sources/vminitd/ManagedContainer.swift +++ b/vminitd/Sources/vminitd/ManagedContainer.swift @@ -124,7 +124,7 @@ extension ManagedContainer { return try await ProcessSupervisor.default.start(process: proc) } - func wait(execID: String) async throws -> Int32 { + func wait(execID: String) async throws -> ManagedProcess.ExitStatus { let proc = try self.getExecOrInit(execID: execID) return await proc.wait() } diff --git a/vminitd/Sources/vminitd/ManagedProcess.swift b/vminitd/Sources/vminitd/ManagedProcess.swift index 9e22684c..9d7f4a5b 100644 --- a/vminitd/Sources/vminitd/ManagedProcess.swift +++ b/vminitd/Sources/vminitd/ManagedProcess.swift @@ -36,14 +36,19 @@ final class ManagedProcess: Sendable { private let bundle: ContainerizationOCI.Bundle private let cgroupManager: Cgroup2Manager? + struct ExitStatus { + var exitStatus: Int32 + var exitedAt: Date + } + private struct State { init(io: IO) { self.io = io } let io: IO - var waiters: [CheckedContinuation] = [] - var exitStatus: Int32? = nil + var waiters: [CheckedContinuation] = [] + var exitStatus: ExitStatus? = nil var pid: Int32 = 0 } @@ -256,7 +261,8 @@ extension ManagedProcess { "status": "\(status)" ]) - $0.exitStatus = status + let exitStatus = ExitStatus(exitStatus: status, exitedAt: Date.now) + $0.exitStatus = exitStatus do { try $0.io.close() @@ -265,7 +271,7 @@ extension ManagedProcess { } for waiter in $0.waiters { - waiter.resume(returning: status) + waiter.resume(returning: exitStatus) } self.log.debug("\($0.waiters.count) managed process waiters signaled") @@ -274,7 +280,7 @@ extension ManagedProcess { } /// Wait on the process to exit - func wait() async -> Int32 { + func wait() async -> ExitStatus { await withCheckedContinuation { cont in self.state.withLock { if let status = $0.exitStatus { diff --git a/vminitd/Sources/vminitd/Server+GRPC.swift b/vminitd/Sources/vminitd/Server+GRPC.swift index d8313ecd..a349fa36 100644 --- a/vminitd/Sources/vminitd/Server+GRPC.swift +++ b/vminitd/Sources/vminitd/Server+GRPC.swift @@ -24,6 +24,7 @@ import GRPC import Logging import NIOCore import NIOPosix +import SwiftProtobuf import _NIOFileSystem private let _setenv = Foundation.setenv @@ -665,11 +666,11 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid do { let ctr = try await self.state.get(container: request.containerID) - - let exitCode = try await ctr.wait(execID: request.id) + let exitStatus = try await ctr.wait(execID: request.id) return .with { - $0.exitCode = exitCode + $0.exitCode = exitStatus.exitStatus + $0.exitedAt = Google_Protobuf_Timestamp(date: exitStatus.exitedAt) } } catch { log.error(