From f5efef50437291d5517d33047c169f4de304c11b Mon Sep 17 00:00:00 2001 From: Shuai Yuan Date: Thu, 18 Jun 2026 10:52:14 +0800 Subject: [PATCH] exec: honor ConsoleSize so the terminal is sized at creation The exec API accepts a ConsoleSize but it is dropped: the exec pseudo-terminal is created at its default size and only corrected afterwards by an asynchronous resize. A short-lived exec that reads its window size at startup (e.g. `stty size`) can therefore observe the wrong size, because the resize may arrive after the process has already read it. docker applies the size at creation. Carry the requested ConsoleSize through ExecConfig and into the exec OCI process spec (process.consoleSize) so the runtime sizes the terminal before the process starts, removing the race. The local and remote CLIs capture the caller's terminal size when -t is given and pass it through ExecOptions, matching the behavior of `podman run`. Re-enable the previously flaky `podman exec` case in the interactive system test, which this change makes deterministic. Signed-off-by: Shuai Yuan --- cmd/podman/containers/exec.go | 12 ++++++++++++ libpod/container_exec.go | 7 +++++++ libpod/oci.go | 3 +++ libpod/oci_conmon_exec_common.go | 10 ++++++++++ pkg/api/handlers/compat/exec.go | 8 ++++++++ pkg/domain/entities/containers.go | 2 ++ pkg/domain/infra/abi/containers.go | 7 +++++++ pkg/domain/infra/tunnel/containers.go | 6 ++++++ test/system/450-interactive.bats | 15 ++++++++------- 9 files changed, 63 insertions(+), 7 deletions(-) diff --git a/cmd/podman/containers/exec.go b/cmd/podman/containers/exec.go index a022739b02..b349e77244 100644 --- a/cmd/podman/containers/exec.go +++ b/cmd/podman/containers/exec.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "go.podman.io/common/pkg/completion" + "go.podman.io/common/pkg/resize" "go.podman.io/podman/v6/cmd/podman/common" "go.podman.io/podman/v6/cmd/podman/registry" "go.podman.io/podman/v6/cmd/podman/validate" @@ -18,6 +19,7 @@ import ( "go.podman.io/podman/v6/pkg/domain/entities" envLib "go.podman.io/podman/v6/pkg/env" "go.podman.io/podman/v6/pkg/rootless" + "golang.org/x/term" ) var ( @@ -189,6 +191,16 @@ func exec(cmd *cobra.Command, args []string) error { streams.AttachOutput = true streams.AttachError = true + // When allocating a TTY, capture the current terminal size up front so the + // exec pseudo-terminal is created at the right size, rather than relying on + // the asynchronous resize that follows attach. This matters for short-lived + // commands that read their window size at startup (e.g. `stty size`). + if execOpts.Tty { + if w, h, err := term.GetSize(int(os.Stdin.Fd())); err == nil { + execOpts.ConsoleSize = &resize.TerminalSize{Width: uint16(w), Height: uint16(h)} + } + } + if execNoSession { exitCode, err := registry.ContainerEngine().ContainerExecNoSession(registry.Context(), nameOrID, execOpts, streams) registry.SetExitCode(exitCode) diff --git a/libpod/container_exec.go b/libpod/container_exec.go index 52169565cb..7dca91e524 100644 --- a/libpod/container_exec.go +++ b/libpod/container_exec.go @@ -29,6 +29,12 @@ type ExecConfig struct { Command []string `json:"command"` // Terminal is whether the exec session will allocate a pseudoterminal. Terminal bool `json:"terminal,omitempty"` + // ConsoleSize is an optional initial size for the exec session's + // pseudoterminal, applied at creation when Terminal is true. This lets a + // one-shot exec read the correct window size immediately instead of + // relying on an asynchronous resize that may arrive after the process has + // already started. + ConsoleSize *resize.TerminalSize `json:"consoleSize,omitempty"` // AttachStdin is whether the STDIN stream will be forwarded to the exec // session's first process when attaching. Only available if Terminal is // false. @@ -1180,6 +1186,7 @@ func prepareForExec(c *Container, session *ExecSession) (*ExecOptions, error) { opts.ExitCommand = session.Config.ExitCommand opts.ExitCommandDelay = session.Config.ExitCommandDelay opts.Privileged = session.Config.Privileged + opts.ConsoleSize = session.Config.ConsoleSize return opts, nil } diff --git a/libpod/oci.go b/libpod/oci.go index 3aedcad0b2..fa7b10dc35 100644 --- a/libpod/oci.go +++ b/libpod/oci.go @@ -201,6 +201,9 @@ type ExecOptions struct { Env map[string]string // Terminal is whether to create a new TTY for the exec session. Terminal bool + // ConsoleSize is an optional initial size for the exec session's TTY, + // applied at creation when Terminal is true. + ConsoleSize *resize.TerminalSize // Cwd is the working directory for the executed command. If unset, the // working directory of the container will be used. Cwd string diff --git a/libpod/oci_conmon_exec_common.go b/libpod/oci_conmon_exec_common.go index 8037e07be0..c002e04f2f 100644 --- a/libpod/oci_conmon_exec_common.go +++ b/libpod/oci_conmon_exec_common.go @@ -704,6 +704,16 @@ func (c *Container) prepareProcessExec(options *ExecOptions, env []string, sessi pspec.Terminal = false if options.Terminal { pspec.Terminal = true + // Size the exec PTY at creation so the process reads the correct + // window size immediately. Without this the PTY starts at 0x0 and is + // only corrected by an asynchronous resize, which can arrive after a + // one-shot process has already read its size. + if options.ConsoleSize != nil { + pspec.ConsoleSize = &spec.Box{ + Height: uint(options.ConsoleSize.Height), + Width: uint(options.ConsoleSize.Width), + } + } } if len(env) > 0 { pspec.Env = append(pspec.Env, env...) diff --git a/pkg/api/handlers/compat/exec.go b/pkg/api/handlers/compat/exec.go index a3b26e362a..b7276a17c3 100644 --- a/pkg/api/handlers/compat/exec.go +++ b/pkg/api/handlers/compat/exec.go @@ -42,6 +42,14 @@ func ExecCreateHandler(w http.ResponseWriter, r *http.Request) { libpodConfig := new(libpod.ExecConfig) libpodConfig.Command = input.Cmd libpodConfig.Terminal = input.Tty + // Preserve the requested initial terminal size so the exec PTY can be sized + // at creation (docker sends ConsoleSize as [height, width]). + if input.ConsoleSize != nil { + libpodConfig.ConsoleSize = &resize.TerminalSize{ + Height: uint16(input.ConsoleSize[0]), + Width: uint16(input.ConsoleSize[1]), + } + } libpodConfig.AttachStdin = input.AttachStdin libpodConfig.AttachStderr = input.AttachStderr libpodConfig.AttachStdout = input.AttachStdout diff --git a/pkg/domain/entities/containers.go b/pkg/domain/entities/containers.go index 6e9e200077..6b13013fbc 100644 --- a/pkg/domain/entities/containers.go +++ b/pkg/domain/entities/containers.go @@ -7,6 +7,7 @@ import ( "time" nettypes "go.podman.io/common/libnetwork/types" + "go.podman.io/common/pkg/resize" imageTypes "go.podman.io/image/v5/types" "go.podman.io/podman/v6/libpod/define" "go.podman.io/podman/v6/pkg/domain/entities/types" @@ -283,6 +284,7 @@ type ContainerLogsOptions struct { // a container type ExecOptions struct { Cmd []string + ConsoleSize *resize.TerminalSize DetachKeys string Envs map[string]string Interactive bool diff --git a/pkg/domain/infra/abi/containers.go b/pkg/domain/infra/abi/containers.go index 4f4b3940fa..6f8025ccfb 100644 --- a/pkg/domain/infra/abi/containers.go +++ b/pkg/domain/infra/abi/containers.go @@ -890,6 +890,13 @@ func makeExecConfig(options entities.ExecOptions, rt *libpod.Runtime, noSession execConfig.PreserveFD = options.PreserveFD execConfig.AttachStdin = options.Interactive + // Size the exec terminal at creation so a process that reads its window size + // at startup (e.g. `stty size`) observes the right value, rather than relying + // on the asynchronous resize that follows attach. + if options.Tty { + execConfig.ConsoleSize = options.ConsoleSize + } + // Only set up exit command for regular exec sessions, not no-session mode if !noSession { // Make an exit command diff --git a/pkg/domain/infra/tunnel/containers.go b/pkg/domain/infra/tunnel/containers.go index 66df7420ec..6e7be5b1f2 100644 --- a/pkg/domain/infra/tunnel/containers.go +++ b/pkg/domain/infra/tunnel/containers.go @@ -618,6 +618,12 @@ func makeExecConfig(options entities.ExecOptions) *handlers.ExecCreateConfig { createConfig.Env = env createConfig.WorkingDir = options.WorkDir createConfig.Cmd = options.Cmd + // Size the exec terminal at creation, mirroring the local path, so the + // service does not have to rely on the asynchronous resize that follows + // attach. ConsoleSize is ordered [height, width]. + if options.Tty && options.ConsoleSize != nil { + createConfig.ConsoleSize = &[2]uint{uint(options.ConsoleSize.Height), uint(options.ConsoleSize.Width)} + } return createConfig } diff --git a/test/system/450-interactive.bats b/test/system/450-interactive.bats index 31f01f6424..6b800e4028 100644 --- a/test/system/450-interactive.bats +++ b/test/system/450-interactive.bats @@ -70,14 +70,15 @@ function teardown() { run_podman rm -t 0 -f mystty - # FIXME: the checks below are flaking a lot (see #10710). + # The same must hold for podman exec. The exec pseudo-terminal is now sized + # at creation (honoring the requested ConsoleSize), so stty reads the right + # dimensions immediately instead of racing the asynchronous resize that + # previously followed attach (see #10710). + run_podman run -d --name mystty $IMAGE top + run_podman exec -it mystty stty size <$PODMAN_TEST_PTY + is "$output" "$rows $cols$CR" "stty under podman exec reads the correct dimensions" - # check that the same works for podman exec -# run_podman run -d --name mystty $IMAGE top -# run_podman exec -it mystty stty size <$PODMAN_TEST_PTY -# is "$output" "$rows $cols" "stty under podman exec reads the correct dimensions" -# -# run_podman rm -t 0 -f mystty + run_podman rm -t 0 -f mystty }