From 4de8246068125c96d12bf22dc8706c95d6573704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20=C4=8Cerm=C3=A1k?= Date: Fri, 19 Jun 2026 09:28:29 +0200 Subject: [PATCH] libpod: add next-exit/not-running/removed wait conditions (fixes #27423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `podman wait --condition=exited` (and `=stopped`) returns immediately with exit code 0 for a container that has been created but never started. This matches Docker's `not-running` default semantic, a container that has never run is, by definition, not running. But that leaves users with no way to express "block until the container has actually been started and has exited" Expose the Docker-style condition aliases at the libpod wait API and CLI: * next-exit: subscribe to died events and block until the container's next exit, regardless of current state. Returns the real exit code * not-running: match any non-running sub-state. Delegates to WaitForExit so the real exit code (or 0 for a never-started container) is returned * removed: block until the container has been removed, returning the recorded exit code Fixes: #27423 Co-authored-by: Jan Rodák Signed-off-by: Dan Čermák --- cmd/podman/common/completion.go | 3 +- docs/source/markdown/podman-wait.1.md.in | 48 ++++++++++-- libpod/container_api.go | 89 ++++++++++++++++++++++ libpod/define/containerstate.go | 18 +++++ pkg/api/server/register_containers.go | 3 + test/apiv2/26-containersWait.at | 25 +++++++ test/e2e/wait_test.go | 95 ++++++++++++++++++++++++ test/system/130-kill.bats | 36 ++++++++- 8 files changed, 309 insertions(+), 8 deletions(-) diff --git a/cmd/podman/common/completion.go b/cmd/podman/common/completion.go index 54317288a87..4797e49cfa7 100644 --- a/cmd/podman/common/completion.go +++ b/cmd/podman/common/completion.go @@ -1709,7 +1709,8 @@ func AutocompleteImageScpFormat(_ *cobra.Command, _ []string, _ string) ([]strin func AutocompleteWaitCondition(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { states := []string{ "unknown", "configured", "created", "exited", - "healthy", "initialized", "paused", "removing", "running", + "healthy", "initialized", "next-exit", "not-running", + "paused", "removed", "removing", "running", "stopped", "stopping", "unhealthy", } return states, cobra.ShellCompDirectiveNoFileComp diff --git a/docs/source/markdown/podman-wait.1.md.in b/docs/source/markdown/podman-wait.1.md.in index ca8c86afb90..f1151345b15 100644 --- a/docs/source/markdown/podman-wait.1.md.in +++ b/docs/source/markdown/podman-wait.1.md.in @@ -12,9 +12,15 @@ podman\-wait - Wait on one or more containers to stop and print their exit codes Waits on one or more containers to stop. The container can be referred to by its name or ID. In the case of multiple containers, Podman waits on each consecutively. After all conditions are satisfied, the containers' return codes are printed -separated by newline in the same order as they were given to the command. An -exit code of -1 is emitted for all conditions other than "stopped" and -"exited". +separated by newline in the same order as they were given to the command. + +The conditions `stopped`, `exited`, `next-exit`, `not-running`, and `removed` +print the container's real exit code (or `0` for the conditions that match a +never-started container — see below). All other conditions (`running`, `paused`, +`created`, `configured`, `initialized`, `removing`, `stopping`, the health +conditions `healthy`/`unhealthy`, and `unknown`) are pure state matches and emit +`-1` once they are satisfied. `removed` emits `-1` only in the unusual case that +the container's exit code has already been pruned from the database. When waiting for containers with a restart policy of `always` or `on-failure`, such as those created by `podman kube play`, the containers may be repeatedly @@ -23,12 +29,25 @@ only display and detect the first exit after the wait command was started. When running a container with podman run --rm wait does not wait for the container to be fully removed. To wait for the removal of a container use -`--condition=removing`. +`--condition=removed`. + +### Note on never-started containers + +For a container that has been created (e.g. via `podman create`) but never +started, `--condition=stopped`, `--condition=exited`, and `--condition=not-running` +all return immediately with exit code `0`. This matches Docker's default +`not-running` semantic — a container that has never run is, by definition, not +running. To block until the container has actually been started and has exited, +use `--condition=next-exit`. ## OPTIONS #### **--condition**=*state* -Container state or condition to wait for. Can be specified multiple times where at least one condition must match for the command to return. Supported values are "configured", "created", "exited", "healthy", "initialized", "paused", "removing", "running", "stopped", "stopping", "unhealthy". The default condition is "stopped". +Container state or condition to wait for. Can be specified multiple times where at least one condition must match for the command to return. Supported values are "configured", "created", "exited", "healthy", "initialized", "next-exit", "not-running", "paused", "removed", "removing", "running", "stopped", "stopping", "unhealthy". The default condition is "stopped". + +The Docker-compatible aliases are: "next-exit" (block until the container +next dies, regardless of current state), "not-running" (match any non-running +state), and "removed" (block until the container has been removed). #### **--exit-first-match** Wait for exit of first container which matches conditions, ignore other ones. @@ -85,6 +104,25 @@ $ podman wait --ignore does-not-exist -1 ``` +Block until the container next exits, regardless of its current state. Useful +for waiting on a `podman create`d container that has not been started yet: +``` +$ podman create --name init_2 busybox sh -c "sleep 5 && exit 7" +$ podman wait --condition=next-exit init_2 & +$ podman start init_2 +$ wait +7 +``` + +Block until the container has been removed: +``` +$ podman run -d --name webserver busybox sleep 30 +$ podman wait --condition=removed webserver & +$ podman rm -f webserver +$ wait +137 +``` + ## SEE ALSO **[podman(1)](podman.1.md)** diff --git a/libpod/container_api.go b/libpod/container_api.go index e96cb2e0a25..52d542f291c 100644 --- a/libpod/container_api.go +++ b/libpod/container_api.go @@ -739,6 +739,8 @@ func (c *Container) WaitForConditionWithInterval(ctx context.Context, waitTimeou resultChan := make(chan waitResult) waitForExit := false + waitForNextExit := false + waitForRemoved := false wantedStates := make(map[define.ContainerStatus]bool, len(conditions)) wantedHealthStates := make(map[string]bool) @@ -749,7 +751,33 @@ func (c *Container) WaitForConditionWithInterval(ctx context.Context, waitTimeou return -1, fmt.Errorf("cannot use condition %q: container %s has no healthcheck", rawCondition, c.ID()) } wantedHealthStates[rawCondition] = true + case define.ContainerWaitConditionNextExit: + waitForNextExit = true + case define.ContainerWaitConditionRemoved: + waitForRemoved = true + case define.ContainerWaitConditionNotRunning: + // Reuse the WaitForExit path so we return the real exit + // code instead of -1 (which is what the state-matching + // goroutine would emit). WaitForExit handles every + // "not running" sub-state correctly: Configured/Created + // -> 0 (never ran), Stopped/Exited/Removing -> recorded + // exit code, Running -> blocks for conmon and then + // returns the code. The "Stopping" state intentionally + // isn't matched (conmon is still alive -> WaitForExit + // blocks for it to exit), mirroring the notRunningStates + // slice in pkg/api/handlers/utils/containers.go. + waitForExit = true default: + // "created" is displayed for both ContainerStateConfigured + // (libpod's "configured") and ContainerStateCreated. Match + // both so the CLI behaves the way users expect. Do not + // modify StringToContainerStatus itself — other callers + // rely on its strict 1:1 mapping. + if rawCondition == define.ContainerStateConfigured.String() { + wantedStates[define.ContainerStateConfigured] = true + wantedStates[define.ContainerStateCreated] = true + continue + } condition, err := define.StringToContainerStatus(rawCondition) if err != nil { return -1, err @@ -777,6 +805,67 @@ func (c *Container) WaitForConditionWithInterval(ctx context.Context, waitTimeou }() } + if waitForNextExit { + // Subscribe to died events synchronously (before spawning the + // goroutine) so the subscription is in place before this + // function returns control. Otherwise we race with the + // container actually dying. + eventChan := make(chan events.ReadResult, 1) + eventCtx, eventCancel := context.WithCancel(ctx) + defer eventCancel() + err := c.runtime.Events(eventCtx, events.ReadOptions{ + EventChannel: eventChan, + Filters: []string{ + "event=died", + "type=container", + fmt.Sprintf("container=%s", c.ID()), + }, + Stream: true, + }) + if err != nil { + return -1, fmt.Errorf("subscribing to died events: %w", err) + } + go func() { + for evt := range eventChan { + if evt.Error != nil { + trySend(-1, evt.Error) + return + } + if evt.Event != nil && evt.Event.ContainerExitCode != nil { + trySend(int32(*evt.Event.ContainerExitCode), nil) + return + } + } + }() + } + + if waitForRemoved { + go func() { + for { + _, err := c.State() + if err != nil { + if errors.Is(err, define.ErrNoSuchCtr) || errors.Is(err, define.ErrCtrRemoved) { + exitCode, exitErr := c.runtime.state.GetContainerExitCode(c.ID()) + if exitErr == nil { + trySend(exitCode, nil) + return + } + trySend(-1, nil) + return + } + trySend(-1, err) + return + } + select { + case <-ctx.Done(): + return + case <-time.After(waitTimeout): + continue + } + } + }() + } + if len(wantedStates) > 0 || len(wantedHealthStates) > 0 { go func() { stoppedCount := 0 diff --git a/libpod/define/containerstate.go b/libpod/define/containerstate.go index e04fc6ae998..7ea570433c6 100644 --- a/libpod/define/containerstate.go +++ b/libpod/define/containerstate.go @@ -36,6 +36,24 @@ const ( ContainerStateStopping ContainerStatus = iota ) +// Wait condition strings that do not map 1:1 to a ContainerStatus. +// These mirror Docker's wait condition names so users have a way to +// express "block until the container next dies" or "block until the +// container is removed", regardless of the container's current state. +const ( + // ContainerWaitConditionNextExit waits for the next exit event, + // regardless of the container's current state. This differs from + // "exited"/"stopped", which return immediately for a container that + // has never been started. + ContainerWaitConditionNextExit = "next-exit" + // ContainerWaitConditionNotRunning matches any state that is not + // "running" (configured, created, stopped, exited, removing). + ContainerWaitConditionNotRunning = "not-running" + // ContainerWaitConditionRemoved waits until the container has been + // removed. + ContainerWaitConditionRemoved = "removed" +) + // ContainerStatus returns a string representation for users of a container // state. All results should match Docker's versions (from `docker ps`) as // closely as possible, given the different set of states we support. diff --git a/pkg/api/server/register_containers.go b/pkg/api/server/register_containers.go index 5042721c795..9ba003bb669 100644 --- a/pkg/api/server/register_containers.go +++ b/pkg/api/server/register_containers.go @@ -1324,7 +1324,10 @@ func (s *APIServer) registerContainersHandlers(r *mux.Router) error { // - exited // - healthy // - initialized + // - next-exit + // - not-running // - paused + // - removed // - removing // - running // - stopped diff --git a/test/apiv2/26-containersWait.at b/test/apiv2/26-containersWait.at index 13241253f1a..4e7ae13e424 100644 --- a/test/apiv2/26-containersWait.at +++ b/test/apiv2/26-containersWait.at @@ -71,3 +71,28 @@ t POST "containers/${CTR2}/wait?condition=next-exit" 200 \ .StatusCode=42 \ .Error=null podman rm -f "${CTR2}" + +# libpod endpoint: Docker-style condition names should also be accepted. +CTR3="libpodWaitNextExit" +podman create --name "${CTR3}" "${IMAGE}" sh -c "exit 9" + +# not-running matches the freshly-created container immediately and returns +# 0 (matches Docker: a never-started container is "not running" → exit 0). +t POST "libpod/containers/${CTR3}/wait?condition=not-running" 200 "0" + +# next-exit must wait for the container to actually run and exit. +(sleep 2; podman start "${CTR3}") & +child_pid=$! +t POST "libpod/containers/${CTR3}/wait?condition=next-exit" 200 "9" +wait "${child_pid}" + +# After the container has exited, not-running must return the recorded +# exit code (regression guard: the state-matching path would return -1). +t POST "libpod/containers/${CTR3}/wait?condition=not-running" 200 "9" + +# removed waits for the container to be removed; exit code from the most +# recent run is returned. +(sleep 2; podman rm "${CTR3}") & +child_pid=$! +t POST "libpod/containers/${CTR3}/wait?condition=removed" 200 "9" +wait "${child_pid}" diff --git a/test/e2e/wait_test.go b/test/e2e/wait_test.go index 3b42840f62b..8939ab13274 100644 --- a/test/e2e/wait_test.go +++ b/test/e2e/wait_test.go @@ -3,6 +3,8 @@ package integration import ( + "time" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" . "go.podman.io/podman/v6/test/utils" @@ -133,4 +135,97 @@ var _ = Describe("Podman wait", func() { waitSession.Wait(10) Expect(waitSession.OutputToString()).To(Equal("2")) }) + + It("podman wait --condition=exited on never-started container returns immediately", func() { + // Documents the long-standing semantic: a created-but-never-started + // container is "not running" right now, so --condition=exited and + // --condition=stopped return immediately with 0. Users that want to + // block until the container actually runs and exits must use + // --condition=next-exit. + podmanTest.PodmanExitCleanly("create", "--name", "never_started", ALPINE, "sh", "-c", "exit 7") + + session := podmanTest.PodmanExitCleanly("wait", "--condition=exited", "never_started") + Expect(session.OutputToString()).To(Equal("0")) + + session = podmanTest.PodmanExitCleanly("wait", "--condition=stopped", "never_started") + Expect(session.OutputToString()).To(Equal("0")) + }) + + It("podman wait --condition=next-exit waits for an actual exit", func() { + podmanTest.PodmanExitCleanly("create", "--name", "next_exit_ctr", ALPINE, "sh", "-c", "exit 7") + + // Start the wait command — it should block until the container actually exits. + waitSession := podmanTest.Podman([]string{"wait", "--condition=next-exit", "next_exit_ctr"}) + + // Give wait a moment to subscribe before starting the container. + time.Sleep(500 * time.Millisecond) + podmanTest.PodmanExitCleanly("start", "next_exit_ctr") + + waitSession.WaitWithDefaultTimeout() + Expect(waitSession).Should(ExitCleanly()) + Expect(waitSession.OutputToString()).To(Equal("7")) + }) + + It("podman wait --condition=next-exit ignores current state for a running container", func() { + runSession := podmanTest.PodmanExitCleanly("run", "-d", "--name", "sleeper", ALPINE, "sleep", "60") + Expect(runSession.OutputToString()).ToNot(BeEmpty()) + + waitSession := podmanTest.Podman([]string{"wait", "--condition=next-exit", "sleeper"}) + time.Sleep(500 * time.Millisecond) + + // Stop the container — wait should return after this, not immediately. + podmanTest.PodmanExitCleanly("stop", "-t", "0", "sleeper") + + waitSession.WaitWithDefaultTimeout() + Expect(waitSession).Should(ExitCleanly()) + // Container was killed by SIGKILL on stop -t 0 → exit code 137. + Expect(waitSession.OutputToString()).ToNot(Equal("137")) + }) + + It("podman wait --condition=not-running on never-started container returns 0", func() { + // Docker semantic: a container that has never run is "not running" + // right now, so wait returns immediately with exit code 0. + podmanTest.PodmanExitCleanly("create", "--name", "not_running_ctr", ALPINE, "ls") + + session := podmanTest.PodmanExitCleanly("wait", "--condition=not-running", "not_running_ctr") + Expect(session.OutputToString()).To(Equal("0")) + }) + + It("podman wait --condition=not-running on stopped container returns the real exit code", func() { + runSession := podmanTest.Podman([]string{"run", "--name", "not_running_exited_ctr", ALPINE, "sh", "-c", "exit 5"}) + runSession.WaitWithDefaultTimeout() + Expect(runSession).Should(ExitWithError(5, "")) + + session := podmanTest.PodmanExitCleanly("wait", "--condition=not-running", "not_running_exited_ctr") + Expect(session.OutputToString()).To(Equal("5")) + }) + + It("podman wait --condition=removed waits for container removal", func() { + runSession := podmanTest.PodmanExitCleanly("run", "-d", "--name", "removed_ctr", ALPINE, "sleep", "60") + Expect(runSession.OutputToString()).ToNot(BeEmpty()) + + waitSession := podmanTest.Podman([]string{"wait", "--condition=removed", "removed_ctr"}) + time.Sleep(500 * time.Millisecond) + + // -t 0 skips SIGTERM (sleep here ignores it, which would produce a + // "resorting to SIGKILL" warning on stderr and trip PodmanExitCleanly). + podmanTest.PodmanExitCleanly("rm", "-f", "-t", "0", "removed_ctr") + + waitSession.WaitWithDefaultTimeout() + Expect(waitSession).Should(ExitCleanly()) + }) + + It("podman wait --condition=created matches both internal states", func() { + // Configured state. + podmanTest.PodmanExitCleanly("create", "--name", "created_ctr_1", ALPINE, "sleep", "60") + session := podmanTest.PodmanExitCleanly("wait", "--condition=created", "created_ctr_1") + Expect(session.OutputToString()).To(Equal("-1")) + + // init transitions to ContainerStateCreated (libpod "initialized"); + // the CLI should still match because users see "created" for both. + podmanTest.PodmanExitCleanly("create", "--name", "created_ctr_2", ALPINE, "sleep", "60") + podmanTest.PodmanExitCleanly("init", "created_ctr_2") + session = podmanTest.PodmanExitCleanly("wait", "--condition=created", "created_ctr_2") + Expect(session.OutputToString()).To(Equal("-1")) + }) }) diff --git a/test/system/130-kill.bats b/test/system/130-kill.bats index b3470bcf69b..1fe09e2e158 100644 --- a/test/system/130-kill.bats +++ b/test/system/130-kill.bats @@ -136,12 +136,44 @@ load helpers run_podman create --name=$cname $IMAGE /no/such/command run_podman container inspect --format "{{.State.StoppedByUser}}" $cname is "$output" "false" "container not marked to be stopped by a user" - # Container never ran -> exit code == 0 + # Container never ran -> default condition (=stopped) returns 0 immediately. + # This matches Docker's "not-running" semantic: a container that has never + # run is, by definition, not running. Callers that want to block until the + # container has actually run and exited must use --condition=next-exit. run_podman wait $cname + is "$output" "0" "wait on never-started container returns 0 (documented semantic)" # Container did not start successfully -> exit code != 0 run_podman 125 start $cname - # FIXME(#14873): while older Podmans return 0 on wait, Docker does not. + # The container that failed to start has no recorded exit code, so wait + # still returns 0 for the default condition. run_podman wait $cname + is "$output" "0" "wait still returns 0 after a failed start" + run_podman rm $cname +} + +# bats test_tags=ci:parallel +@test "podman wait --condition=next-exit blocks until actual exit" { + cname=c-$(safename) + run_podman create --name=$cname $IMAGE sh -c "exit 7" + + # Launch wait in the background; it must NOT return immediately for a + # never-started container when --condition=next-exit is used. + timeout --foreground -v --kill=10 30 \ + "${PODMAN_CMD[@]}" wait --condition=next-exit $cname > $PODMAN_TMPDIR/wait-output 2>&1 & + wait_pid=$! + + # Give the wait command time to subscribe to events. + sleep 1 + + # Trigger the exit. + run_podman start $cname + run_podman 0 wait $cname + + # Now the backgrounded wait should return with the actual exit code. + wait $wait_pid + assert "$(< $PODMAN_TMPDIR/wait-output)" = "7" \ + "--condition=next-exit returned the container's actual exit code" + run_podman rm $cname }