From ca0af6c5776c20310a78ef186567c8ff8550a23f Mon Sep 17 00:00:00 2001 From: Andrey Maltsev Date: Mon, 26 Oct 2026 20:14:38 +0200 Subject: [PATCH] Keep the error code when a process fails to exec When vmexec could not find or exec the target, vminitd wrapped the error-pipe text as internalError, and the host wrapped the gRPC error again, so callers could only tell a missing executable from other failures by matching the message. vmexec now reports a missing executable as notFound and an execvpe failure with EACCES or ENOEXEC as the new notExecutable code. ManagedProcess keeps those two codes from the error pipe, the server maps notExecutable to permissionDenied, and Vminitd.startProcess maps both back, so LinuxProcess.start throws them unchanged. This lets clients report 127 and 126 the way Docker does. Refs apple/container#2210 --- Sources/Containerization/Vminitd.swift | 15 +++++- .../ContainerizationError.swift | 5 ++ Sources/Integration/ContainerTests.swift | 31 ++++++++++- Sources/Integration/Suite.swift | 1 + .../ManagedProcessErrorTests.swift | 51 +++++++++++++++++++ .../Sources/VminitdCore/ManagedProcess.swift | 20 ++++---- vminitd/Sources/VminitdCore/Server+GRPC.swift | 2 + vminitd/Sources/vmexec/vmexec.swift | 23 +++++++-- 8 files changed, 131 insertions(+), 17 deletions(-) create mode 100644 Tests/VminitdCoreTests/ManagedProcessErrorTests.swift diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index 630ce93b0..5e55ffd32 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -297,8 +297,19 @@ extension Vminitd: VirtualMachineAgent { $0.containerID = containerID } } - let resp = try await client.startProcess(request) - return resp.pid + do { + let resp = try await client.startProcess(request) + return resp.pid + } catch let error as RPCError { + switch error.code { + case .notFound: + throw ContainerizationError(.notFound, message: error.message, cause: error) + case .permissionDenied: + throw ContainerizationError(.notExecutable, message: error.message, cause: error) + default: + throw error + } + } } public func signalProcess(id: String, containerID: String?, signal: Int32) async throws { diff --git a/Sources/ContainerizationError/ContainerizationError.swift b/Sources/ContainerizationError/ContainerizationError.swift index 2d1b36fc8..b907fb9d0 100644 --- a/Sources/ContainerizationError/ContainerizationError.swift +++ b/Sources/ContainerizationError/ContainerizationError.swift @@ -104,6 +104,7 @@ extension ContainerizationError { case timeout case unsupported case interrupted + case notExecutable } private var value: Value @@ -166,6 +167,10 @@ extension ContainerizationError { public static var interrupted: Self { Self(.interrupted) } + + public static var notExecutable: Self { + Self(.notExecutable) + } } } diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 150db50c8..5336e66f3 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -191,7 +191,36 @@ extension IntegrationSuite { } catch { try? await container.stop() guard let err = error as? ContainerizationError, - err.isCode(.internalError), err.description.contains("failed to find target executable") + err.isCode(.notFound), err.description.contains("failed to find target executable") + else { + throw error + } + } + } + + func testProcessNotExecutable() async throws { + let id = "test-process-not-executable" + let bs = try await bootstrap(id) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["/etc/passwd"] + config.process.stdout = buffer + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let _ = try await container.wait() + try await container.stop() + + throw IntegrationError.assert(msg: "process didn't throw 'not executable' error") + } catch { + try? await container.stop() + guard let err = error as? ContainerizationError, + err.isCode(.notExecutable), err.description.contains("failed to exec") else { throw error } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 6ce16125d..6ab368877 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -483,6 +483,7 @@ struct IntegrationSuite: AsyncParsableCommand { Test("process false", testProcessFalse), Test("process echo hi", testProcessEchoHi), Test("process no executable", testProcessNoExecutable), + Test("process not executable", testProcessNotExecutable), Test("process user", testProcessUser), Test("process stdin", testProcessStdin), Test("process home envvar", testProcessHomeEnvvar), diff --git a/Tests/VminitdCoreTests/ManagedProcessErrorTests.swift b/Tests/VminitdCoreTests/ManagedProcessErrorTests.swift new file mode 100644 index 000000000..9b1d8a1c4 --- /dev/null +++ b/Tests/VminitdCoreTests/ManagedProcessErrorTests.swift @@ -0,0 +1,51 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import VminitdCore + +@Suite("ManagedProcess vmexec error") +struct ManagedProcessErrorTests { + @Test func missingExecutableKeepsNotFound() { + let error = ManagedProcess.vmexecError( + ContainerizationError(.notFound, message: "failed to find target executable foo").description + ) + #expect(error.code == .notFound) + #expect(error.message.contains("failed to find target executable foo")) + } + + @Test func notExecutableKeepsCode() { + let error = ManagedProcess.vmexecError( + ContainerizationError(.notExecutable, message: "failed to exec [/etc/passwd]").description + "\n" + ) + #expect(error.code == .notExecutable) + } + + @Test(arguments: [ + ContainerizationError(.internalError, message: "failed to change directory").description, + ContainerizationError(.invalidArgument, message: "bad").description, + "unstructured failure", + ]) + func otherErrorsAreInternal(errorString: String) { + #expect(ManagedProcess.vmexecError(errorString).code == .internalError) + } +} + +#endif diff --git a/vminitd/Sources/VminitdCore/ManagedProcess.swift b/vminitd/Sources/VminitdCore/ManagedProcess.swift index ba4cd2d1e..eead9d3c3 100644 --- a/vminitd/Sources/VminitdCore/ManagedProcess.swift +++ b/vminitd/Sources/VminitdCore/ManagedProcess.swift @@ -239,10 +239,7 @@ extension ManagedProcess { let errorString = String(data: errorData, encoding: .utf8), !errorString.isEmpty { - throw ContainerizationError( - .internalError, - message: "vmexec error: \(errorString.trimmingCharacters(in: .whitespacesAndNewlines))" - ) + throw Self.vmexecError(errorString) } log.info( @@ -259,16 +256,21 @@ extension ManagedProcess { let errorString = String(data: errorData, encoding: .utf8), !errorString.isEmpty { - throw ContainerizationError( - .internalError, - message: "vmexec error: \(errorString.trimmingCharacters(in: .whitespacesAndNewlines))", - cause: error - ) + throw Self.vmexecError(errorString, cause: error) } throw error } } + /// vmexec writes `ContainerizationError.description` to the error pipe, so + /// the text starts with the error code. + static func vmexecError(_ errorString: String, cause: (any Error)? = nil) -> ContainerizationError { + let text = errorString.trimmingCharacters(in: .whitespacesAndNewlines) + let preserved: [ContainerizationError.Code] = [.notFound, .notExecutable] + let code = preserved.first { text.hasPrefix("\($0): ") } ?? .internalError + return ContainerizationError(code, message: "vmexec error: \(text)", cause: cause) + } + func setExit(_ status: Int32) { self.state.withLock { state in self.log.info( diff --git a/vminitd/Sources/VminitdCore/Server+GRPC.swift b/vminitd/Sources/VminitdCore/Server+GRPC.swift index 7f20185ac..1e8f80a91 100644 --- a/vminitd/Sources/VminitdCore/Server+GRPC.swift +++ b/vminitd/Sources/VminitdCore/Server+GRPC.swift @@ -79,6 +79,8 @@ extension ContainerizationError { return .failedPrecondition case .timeout: return .deadlineExceeded + case .notExecutable: + return .permissionDenied default: return .internalError } diff --git a/vminitd/Sources/vmexec/vmexec.swift b/vminitd/Sources/vmexec/vmexec.swift index 956680cb8..84741b23b 100644 --- a/vminitd/Sources/vmexec/vmexec.swift +++ b/vminitd/Sources/vmexec/vmexec.swift @@ -85,12 +85,12 @@ extension App { } guard FileManager.default.fileExists(atPath: resolvedExecutable.path) else { - throw App.Failure(message: "failed to find target executable \(executableArg)") + throw ContainerizationError(.notFound, message: "failed to find target executable \(executableArg)") } } else { let path = Path.findPath(currentEnv) ?? Path.getCurrentPath() guard let found = Path.lookPath(executableArg, path: path) else { - throw App.Failure(message: "failed to find target executable \(executableArg)") + throw ContainerizationError(.notFound, message: "failed to find target executable \(executableArg)") } resolvedExecutable = found } @@ -116,7 +116,20 @@ extension App { } guard execvpe(executable, argv, env) != -1 else { - throw App.Errno(stage: "execvpe(\(String(describing: executable)))", info: "failed to exec [\(process.args.joined(separator: " "))]") + let code: ContainerizationError.Code + switch errno { + case ENOENT: + code = .notFound + case EACCES, ENOEXEC: + code = .notExecutable + default: + code = .internalError + } + throw App.Errno( + code: code, + stage: "execvpe(\(String(describing: executable)))", + info: "failed to exec [\(process.args.joined(separator: " "))]" + ) } fatalError("execvpe failed") } @@ -254,9 +267,9 @@ extension App { } } - static func Errno(stage: String, info: String = "") -> ContainerizationError { + static func Errno(code: ContainerizationError.Code = .internalError, stage: String, info: String = "") -> ContainerizationError { let posix = POSIXError(.init(rawValue: errno)!, userInfo: ["stage": stage]) - return ContainerizationError(.internalError, message: "\(info) \(String(describing: posix))") + return ContainerizationError(code, message: "\(info) \(String(describing: posix))") } static func Failure(message: String) -> ContainerizationError {