Skip to content

[Bug]: docker exec on a missing executable exits 255, identical to a real exit(255) — no signal exists to tell them apart #2210

Open

Description

@JustMaris

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 — 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)

Activity

  1. guanchzhou commented on Oct 26, 2026

    @guanchzhou
    Contributor

    I'd like to take this.

    The execvpe failure happens in vmexec in apple/containerization, before the target ever runs, and by the time it reaches ClientProcess.wait() it is a plain non-zero status. Plan: keep the errno from that failure and report 127 for ENOENT and 126 for EACCES/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.

  2. guanchzhou commented on Oct 26, 2026

    @guanchzhou
    Contributor

    Update after reproducing on 1.6.1 (alpine, container exec):

    Command 1.6.1 Docker
    nonexistent-binary-xyz or /nope/bin start() throws failed to find target executable ..., CLI exits 1 127
    /etc/hostname (not executable) start() throws failed to exec [...] ... Permission denied, CLI exits 1 126
    sh -c 'exit 255' 255 255

    So on current releases a failed exec is no longer reported as status 255: vmexec writes the failure to its error pipe and ManagedProcess.start() throws, which reaches ClientProcess.start(). A client can already tell it apart from a real exit(255). What is still missing is the reason in a form a client can act on: the error is wrapped as internalError with 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. ContainerExec starts with exitCode = 127, but rethrows every ContainerizationError, so the CLI exits 1.

    Proposed change, before I write it:

    1. apple/containerization: vmexec reports a missing executable as .notFound and an execvpe failure with EACCES/ENOEXEC as a distinct code, and ManagedProcess.start() and the gRPC path keep that code instead of wrapping it as internalError.
    2. apple/container: ContainerExec (and run for 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.

  3. JustMaris commented on Oct 26, 2026

    @JustMaris
    Author

    @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.

  4. guanchzhou commented on Oct 26, 2026

    @guanchzhou
    Contributor

    Thanks @JustMaris. The first part is up: apple/containerization#994 adds notExecutable and keeps notFound/notExecutable from vmexec through to LinuxProcess.start(). The container side that maps them to exit 127 and 126 will follow once that lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions