Repository navigation
Description
I have done the following
- I have searched the existing issues
- If possible, I've reproduced the issue using the 'main' branch of this project
Steps to reproduce
Via socktainer (a Docker-compatible REST API built directly on ContainerAPIClient/ClientProcess), but the underlying behavior is in this project's own client API, not socktainer's:
CID=$(docker run -d --rm alpine sleep 30)
# A genuinely missing executable
docker exec "$CID" nonexistent-binary-xyz
echo $?
# 255
# A real program that deliberately calls exit(255)
docker exec "$CID" sh -c 'exit 255'
echo $?
# 255 — identicalBoth produce exit code 255 via ClientProcess.wait() async throws -> Int32, with no other observable difference: same call sequence, no thrown error from createProcess/start() in either case, no additional field on the wait result, nothing logged differently between the two.
Problem description
Actual behavior: a failed exec() inside the guest (target executable doesn't exist) and a process that legitimately exits with status 255 are indistinguishable through the public API. ClientProcess.wait() returns a bare Int32 in both cases.
Expected behavior: real Docker (via runc/containerd) detects the execve() ENOENT specifically and reports exit code 127 for "command not found" (and 126 for "found but not executable") — a deliberate, long-standing convention many tools depend on. testcontainers-go's wait strategies are one concrete consumer: https://github.com/testcontainers/testcontainers-go/blob/v0.44.0/wait/host_port.go#L240-L244 checks for exit code 127 specifically to detect a missing nc/cat binary, and gets a false negative here.
I looked for where this distinction might already exist but just isn't surfaced through ClientProcess, and couldn't find it:
Sources/Services/ContainerAPIService/Client/ClientProcess.swift'swait()returns a plainInt32.Sources/Services/RuntimeLinux/Server/RuntimeService.swiftandSources/Services/Runtime/RuntimeClient/RuntimeClient.swift(the guest-side process creation/exec path) have noENOENT-specific or "executable not found" handling I could find — no distinct error path for this case, and nothing suggesting the rawexecve()failure is inspected before becoming a generic exit status.
So this looks like it isn't just unsurfaced — the distinguishing information (that the process never actually started because exec itself failed, versus a process that ran and chose to exit 255) may not be captured at all between the guest's exec attempt and what reaches ClientProcess.wait().
Why this can't be worked around downstream: a consumer of this API (socktainer, in my case) cannot safely translate exit code 255 to 127 after the fact — the reproduction above shows that would misclassify any real program that legitimately exits with status 255, trading one Docker incompatibility for a different, worse one. Fixing this needs the actual execve() failure detected at the point it happens (like runc/containerd do) and surfaced as a distinguishable signal — a different code, a thrown error, or a flag — before it collapses into an ordinary exit status.
Environment
- OS: macOS 26.6.2 (25G83)
- Container: container CLI version 1.3.0 (build: release, commit: unspeci)
- Reproduced via socktainer built from
main(commit1f42b0d)
Activity
guanchzhou commentedon Oct 26, 2026
I'd like to take this.
The
execvpefailure happens invmexecin apple/containerization, before the target ever runs, and by the time it reachesClientProcess.wait()it is a plain non-zero status. Plan: keep the errno from that failure and report 127 forENOENTand 126 forEACCES/ENOEXEC, the codes Docker clients such as testcontainers rely on. The change lands in apple/containerization first, with a small follow-up here if the client side needs it. I'll link both PRs here.guanchzhou commentedon Oct 26, 2026
Update after reproducing on 1.6.1 (alpine,
container exec):nonexistent-binary-xyzor/nope/binstart()throwsfailed to find target executable ..., CLI exits 1/etc/hostname(not executable)start()throwsfailed to exec [...] ... Permission denied, CLI exits 1sh -c 'exit 255'So on current releases a failed exec is no longer reported as status 255:
vmexecwrites the failure to its error pipe andManagedProcess.start()throws, which reachesClientProcess.start(). A client can already tell it apart from a realexit(255). What is still missing is the reason in a form a client can act on: the error is wrapped asinternalErrorwith the text nested at each hop (vminitd, the guest gRPC call, the runtime client), so the only way to get 127/126 today is to match the message.ContainerExecstarts withexitCode = 127, but rethrows everyContainerizationError, so the CLI exits 1.Proposed change, before I write it:
vmexecreports a missing executable as.notFoundand anexecvpefailure withEACCES/ENOEXECas a distinct code, andManagedProcess.start()and the gRPC path keep that code instead of wrapping it asinternalError.ContainerExec(andrunfor the init process) maps those codes to exit 127 and 126, matching Docker, and the runtime client keeps the code so API clients such as socktainer can do the same.Does this direction work for you, in particular adding a code for the not-executable case rather than reusing an existing one? If you would rather keep the error codes as they are, I can limit the change to the CLI exit status.
JustMaris commentedon Oct 26, 2026
@guanchzhou Yes, this works. Adding a new code for the not-executable case is the right call. Reusing an existing one would blur the distinction this change is meant to make, and clients like socktainer need to tell "missing" from "not executable" to map them to 127 and 126.
guanchzhou commentedon Oct 26, 2026
Thanks @JustMaris. The first part is up: apple/containerization#994 adds
notExecutableand keepsnotFound/notExecutablefromvmexecthrough toLinuxProcess.start(). Thecontainerside that maps them to exit 127 and 126 will follow once that lands.