I have done the following
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 — identical
Both 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's wait() returns a plain Int32.
Sources/Services/RuntimeLinux/Server/RuntimeService.swift and Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift (the guest-side process creation/exec path) have no ENOENT-specific or "executable not found" handling I could find — no distinct error path for this case, and nothing suggesting the raw execve() 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 (commit 1f42b0d)
I have done the following
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:Both produce exit code
255viaClientProcess.wait() async throws -> Int32, with no other observable difference: same call sequence, no thrown error fromcreateProcess/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 bareInt32in both cases.Expected behavior: real Docker (via runc/containerd) detects the
execve()ENOENTspecifically and reports exit code127for "command not found" (and126for "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 missingnc/catbinary, 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
main(commit1f42b0d)